diff --git a/backend/internal/admin/api.go b/backend/internal/admin/api.go index 3ebd36f..1403e2d 100644 --- a/backend/internal/admin/api.go +++ b/backend/internal/admin/api.go @@ -15,6 +15,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "sync" "time" @@ -72,6 +73,24 @@ func (a *API) Routes() http.Handler { mux.HandleFunc("/api/admin/files", a.guard(a.files)) mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID)) mux.HandleFunc("/api/admin/settings", a.guard(a.settings)) + mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments)) + mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID)) + mux.HandleFunc("/api/admin/readers", a.guard(a.adminReaders)) + mux.HandleFunc("/api/admin/readers/", a.guard(func(w http.ResponseWriter, r *http.Request) { + // 路径形如 /api/admin/readers/{id}/ban + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/readers/"), "/") + parts := strings.Split(rest, "/") + if len(parts) != 2 || parts[1] != "ban" { + httpx.NotFound(w) + return + } + id, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil || id <= 0 { + httpx.BadRequest(w, "bad reader id") + return + } + a.adminReaderBan(w, r, id) + })) return mux } @@ -772,3 +791,98 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) { httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required") } } + +// ---------- comments(评论审核与管理) ---------- + +func (a *API) adminComments(w http.ResponseWriter, r *http.Request) { + status := httpx.QueryString(r, "status") + page := httpx.QueryInt(r, "page", 1) + size := httpx.QueryInt(r, "size", 20) + fp, err := a.Store.ListCommentsAdmin(status, page, size) + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, fp) +} + +func (a *API) adminCommentByID(w http.ResponseWriter, r *http.Request) { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/comments/"), "/") + id, err := parseInt(rest) + if err != nil { + httpx.BadRequest(w, "bad comment id") + return + } + switch r.Method { + case http.MethodPut: + // 审核动作:{"status": "visible" | "pending"} + var in struct { + Status string `json:"status"` + } + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + if in.Status != "visible" && in.Status != "pending" { + httpx.BadRequest(w, "status 只支持 visible / pending") + return + } + if err := a.Store.SetCommentStatus(id, in.Status); err != nil { + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"ok": true}) + case http.MethodDelete: + // 后台删除同样走软删(墓碑保楼层) + if err := a.Store.DeleteComment(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"ok": true}) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required") + } +} + +// ---------- readers(评论用户与禁言) ---------- + +func (a *API) adminReaders(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + readers, err := a.Store.ListReaders() + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"readers": readers}) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "GET required") + } +} + +func (a *API) adminReaderBan(w http.ResponseWriter, r *http.Request, id int64) { + var in struct { + Banned bool `json:"banned"` + } + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + if err := a.Store.SetReaderBanned(id, in.Banned); err != nil { + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"ok": true, "banned": in.Banned}) +} diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index eafcd4c..1ea64a1 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "oneblog/internal/auth" "oneblog/internal/config" "oneblog/internal/httpx" "oneblog/internal/model" @@ -20,9 +21,12 @@ import ( ) type API struct { - Store *store.Store - Cfg *config.Config - Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享) + Store *store.Store + Cfg *config.Config + Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享) + // 评论区读者会话与 GitHub OAuth(main.go 装配) + ReaderSessions *auth.ReaderSessions + GH auth.GitHub } func (a *API) Routes() http.Handler { @@ -34,6 +38,14 @@ func (a *API) Routes() http.Handler { } httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver}) }) + // 读者登录与评论 + mux.HandleFunc("/api/auth/providers", a.authProviders) + mux.HandleFunc("/api/auth/me", a.authMe) + mux.HandleFunc("/api/auth/logout", a.authLogout) + mux.HandleFunc("/api/auth/github/login", a.githubLogin) + mux.HandleFunc("/api/auth/callback/github", a.githubCallback) + mux.HandleFunc("/api/comments", a.comments) + mux.HandleFunc("/api/comments/", a.commentSub) mux.HandleFunc("/api/site", a.site) mux.HandleFunc("/api/posts", a.listPosts) mux.HandleFunc("/api/posts/", a.getPost) diff --git a/backend/internal/api/comments.go b/backend/internal/api/comments.go new file mode 100644 index 0000000..712d7f9 --- /dev/null +++ b/backend/internal/api/comments.go @@ -0,0 +1,380 @@ +// 读者登录与评论的公开接口。登录走 GitHub OAuth 整页跳转; +// 会话是 httpOnly cookie(one_reader),与后台会话(one_session)互不相通。 +// +// 审核:设置里开了「先审后显」时,新评论 status=pending—— +// 只有作者自己能在列表里看到(带「审核中」角标),站主通过后才公开。 +package api + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "oneblog/internal/auth" + "oneblog/internal/httpx" + "oneblog/internal/model" + "oneblog/internal/render" + "oneblog/internal/store" +) + +const ( + readerCookie = "one_reader" + oauthStateCook = "one_oauth_state" + maxCommentLen = 500 + editWindow = 10 * time.Minute +) + +// readerID 从会话 cookie 解出读者 ID;匿名返回 false +func (a *API) readerID(r *http.Request) (int64, bool) { + ck, err := r.Cookie(auth.ReaderCookie) + if err != nil { + return 0, false + } + id, err := a.ReaderSessions.Verify(ck.Value) + if err != nil { + return 0, false + } + return id, true +} + +func (a *API) authProviders(w http.ResponseWriter, r *http.Request) { + providers := []map[string]any{} + if a.GH.Enabled() { + providers = append(providers, map[string]any{ + "id": "github", "label": "GitHub", "kind": "redirect", + }) + } + httpx.OK(w, map[string]any{"providers": providers}) +} + +func (a *API) authMe(w http.ResponseWriter, r *http.Request) { + var user any // 匿名时 {user: null},前端判空即「未登录」 + if id, ok := a.readerID(r); ok { + if reader, err := a.Store.GetReader(id); err == nil { + user = map[string]any{ + "id": reader.ID, "name": reader.Name, "handle": reader.Handle, + "avatar_url": reader.AvatarURL, "url": reader.URL, + "provider": reader.Provider, "is_owner": false, "banned": reader.Banned, + } + } + } + httpx.OK(w, map[string]any{"user": user}) +} + +func (a *API) authLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: "", Path: "/", MaxAge: -1}) + httpx.OK(w, map[string]any{"ok": true}) +} + +// githubLogin 跳转 GitHub 授权页。state 防 CSRF 存短命 cookie; +// 授权完成回到 callback 后必须带上同一个值。 +func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) { + if !a.GH.Enabled() { + httpx.NotFound(w) + return + } + state := randHex(16) + http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/", + HttpOnly: true, MaxAge: 600}) + http.Redirect(w, r, a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state), http.StatusFound) +} + +// githubCallback 用 code 换身份:GitHub 用户 → upsert 读者 → 发会话 → +// 回到首页。 +func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) { + if !a.GH.Enabled() { + httpx.NotFound(w) + return + } + ck, err := r.Cookie(oauthStateCook) + if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") { + httpx.BadRequest(w, "state 不匹配,请重新登录") + return + } + gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github") + if err != nil { + httpx.ServerError(w, err) + return + } + u, err := a.GH.FetchUser(r.Context(), gh) + if err != nil { + httpx.ServerError(w, err) + return + } + name := u.Name + if name == "" { + name = u.Login + } + reader, err := a.Store.UpsertReader(model.Reader{ + Provider: "github", Handle: u.Login, Name: name, + AvatarURL: u.AvatarURL, URL: u.HTMLURL, + }) + if err != nil { + httpx.ServerError(w, err) + return + } + token, _ := a.ReaderSessions.Issue(reader.ID) + http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/", + HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())}) + http.Redirect(w, r, "/", http.StatusFound) +} + +func randHex(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} + +// ---------- comments(评论的读取与发表) ---------- + +func (a *API) comments(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + a.listComments(w, r) + case http.MethodPost: + a.createComment(w, r) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required") + } +} + +func (a *API) listComments(w http.ResponseWriter, r *http.Request) { + postID := httpx.QueryInt(r, "post_id", 0) + if postID <= 0 { + httpx.BadRequest(w, "post_id required") + return + } + viewer, _ := a.readerID(r) + newest := httpx.QueryString(r, "sort") == "newest" + roots, err := a.Store.ListCommentsByPost(int64(postID), viewer, newest) + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{ + "items": roots, "total": len(roots), + "page": 1, "size": len(roots), + }) +} + +// createComment 发表评论(含回复)。登录 + 未禁言 + 评论开关开着; +// 审核开关开着时新评论进「待审」。 +func (a *API) createComment(w http.ResponseWriter, r *http.Request) { + readerID, ok := a.readerID(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "登录后才能评论") + return + } + reader, err := a.Store.GetReader(readerID) + if err != nil { + httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录") + return + } + if reader.Banned { + httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论") + return + } + st, err := a.Store.GetSettings() + if err != nil { + httpx.ServerError(w, err) + return + } + if !st.CommentsEnabled { + httpx.Error(w, http.StatusForbidden, "评论未开放") + return + } + var in struct { + PostID int64 `json:"post_id"` + ParentID int64 `json:"parent_id"` + BodyMd string `json:"body_md"` + } + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + body := strings.TrimSpace(in.BodyMd) + if body == "" { + httpx.BadRequest(w, "评论内容不能为空") + return + } + if len([]rune(body)) > maxCommentLen { + httpx.BadRequest(w, "评论最多 500 字") + return + } + if _, err := a.Store.Get(in.PostID); err != nil { + httpx.BadRequest(w, "文章不存在") + return + } + var parent model.Comment + root := int64(0) + if in.ParentID > 0 { + p, err := a.Store.GetComment(in.ParentID) + if err != nil { + httpx.BadRequest(w, "回复的评论不存在") + return + } + if p.PostID != in.PostID { + httpx.BadRequest(w, "回复的评论不属于这篇文章") + return + } + parent = p + root = parent.RootID + if root == 0 { + root = parent.ID + } + } + status := "visible" + if st.CommentsReview { + status = "pending" + } + c, err := a.Store.CreateComment(model.Comment{ + PostID: in.PostID, UserID: readerID, ParentID: in.ParentID, RootID: root, + BodyMd: body, BodyHTML: render.Markdown(body), Status: status, + }) + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.Created(w, c) +} + +// commentSub /api/comments/{id} 与 /api/comments/{root}/thread 的分发 +func (a *API) commentSub(w http.ResponseWriter, r *http.Request) { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/comments/"), "/") + if rest == "" { + httpx.NotFound(w) + return + } + // {root}/thread:楼内回复翻页(当前实现全量内嵌,这里兜底返回剩余) + if strings.HasSuffix(rest, "/thread") { + rootID, err := strconv.ParseInt(strings.TrimSuffix(rest, "/thread"), 10, 64) + if err != nil { + httpx.BadRequest(w, "bad root id") + return + } + a.commentThread(w, r, rootID) + return + } + id, err := strconv.ParseInt(rest, 10, 64) + if err != nil { + httpx.BadRequest(w, "bad comment id") + return + } + switch r.Method { + case http.MethodPut: + a.editComment(w, r, id) + case http.MethodDelete: + a.deleteComment(w, r, id) + default: + httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required") + } +} + +func (a *API) commentThread(w http.ResponseWriter, r *http.Request, rootID int64) { + root, err := a.Store.GetComment(rootID) + if err != nil { + httpx.ServerError(w, err) + return + } + cursor := httpx.QueryInt(r, "cursor", 0) + items := []model.Comment{} + if cursor >= 0 && cursor < len(root.Replies) { + items = root.Replies[cursor:] + } + httpx.OK(w, map[string]any{"items": items, "cursor": "", "reply_count": root.ReplyCount}) +} + +// editComment 作者改自己的评论:10 分钟内有效,且未被禁言未删除 +func (a *API) editComment(w http.ResponseWriter, r *http.Request, id int64) { + readerID, ok := a.readerID(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "登录已过期") + return + } + c, err := a.Store.GetComment(id) + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + if err != nil { + httpx.ServerError(w, err) + return + } + if c.UserID != readerID { + httpx.Error(w, http.StatusForbidden, "只能编辑自己的评论") + return + } + if c.IsDeleted { + httpx.NotFound(w) + return + } + if time.Since(mustParse(c.CreatedAt)) > editWindow { + httpx.Error(w, http.StatusForbidden, "超过可编辑时间") + return + } + var in struct { + BodyMd string `json:"body_md"` + } + if err := httpx.Decode(r, &in); err != nil { + httpx.BadRequest(w, "invalid body") + return + } + body := strings.TrimSpace(in.BodyMd) + if body == "" { + httpx.BadRequest(w, "评论内容不能为空") + return + } + if len([]rune(body)) > maxCommentLen { + httpx.BadRequest(w, "评论最多 500 字") + return + } + if err := a.Store.UpdateCommentBody(id, body, render.Markdown(body)); err != nil { + httpx.ServerError(w, err) + return + } + updated, err := a.Store.GetComment(id) + if err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, updated) +} + +// deleteComment 作者软删自己的评论(留壳保楼层) +func (a *API) deleteComment(w http.ResponseWriter, r *http.Request, id int64) { + readerID, ok := a.readerID(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "登录已过期") + return + } + c, err := a.Store.GetComment(id) + if errors.Is(err, store.ErrNotFound) { + httpx.NotFound(w) + return + } + if err != nil { + httpx.ServerError(w, err) + return + } + if c.UserID != readerID { + httpx.Error(w, http.StatusForbidden, "只能删除自己的评论") + return + } + if err := a.Store.DeleteComment(id); err != nil { + httpx.ServerError(w, err) + return + } + httpx.OK(w, map[string]any{"ok": true}) +} + +func mustParse(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return time.Time{} + } + return t +} diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go new file mode 100644 index 0000000..a91b567 --- /dev/null +++ b/backend/internal/auth/auth.go @@ -0,0 +1,172 @@ +// Package auth 提供评论区两侧的基础设施:读者会话(cookie one_reader, +// payload 带 reader: 前缀,与后台令牌不可互换)和 GitHub OAuth 客户端。 +package auth + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// ReaderCookie 是读者会话的 cookie 名(与后台的 one_session 区分开) +const ReaderCookie = "one_reader" + +// ReaderSessions 签发 / 校验读者会话令牌: +// base64("reader::") + "." + HMAC-SHA256。 +type ReaderSessions struct { + secret []byte + ttl time.Duration +} + +func NewReaderSessions(secret string, ttl time.Duration) *ReaderSessions { + if ttl <= 0 { + ttl = 30 * 24 * time.Hour + } + return &ReaderSessions{secret: []byte(secret), ttl: ttl} +} + +var ErrBadSession = errors.New("invalid reader session") + +func (s *ReaderSessions) Issue(readerID int64) (string, time.Time) { + exp := time.Now().Add(s.ttl) + payload := readerPayload(readerID, exp) + enc := base64.RawURLEncoding.EncodeToString([]byte(payload)) + return enc + "." + s.sign(payload), exp +} + +func (s *ReaderSessions) Verify(token string) (int64, error) { + parts := strings.Split(token, ".") + if len(parts) != 2 { + return 0, ErrBadSession + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return 0, ErrBadSession + } + // 必须以 reader: 开头——后台令牌(base64("admin:"))复制过来也无效 + if len(raw) < 10 || string(raw)[:7] != "reader:" { + return 0, ErrBadSession + } + // 余下是 ":" + rest := string(raw)[7:] + i := strings.IndexByte(rest, ':') + if i <= 0 { + return 0, ErrBadSession + } + id, err := strconv.ParseInt(rest[:i], 10, 64) + if err != nil { + return 0, ErrBadSession + } + expUnix, err := strconv.ParseInt(rest[i+1:], 10, 64) + if err != nil { + return 0, ErrBadSession + } + if time.Now().After(time.Unix(expUnix, 0)) { + return 0, ErrBadSession + } + // 签名放在最后验证:payload 与 exp 都验过再比对 MAC + if !hmac.Equal([]byte(s.sign(string(raw))), []byte(parts[1])) { + return 0, ErrBadSession + } + return id, nil +} + +func readerPayload(id int64, exp time.Time) string { + return "reader:" + strconv.FormatInt(id, 10) + ":" + strconv.FormatInt(exp.Unix(), 10) +} + +func (s *ReaderSessions) sign(payload string) string { + mac := hmac.New(sha256.New, s.secret) + mac.Write([]byte(payload)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// ---------- GitHub OAuth ---------- + +// GitHub 持有 OAuth 应用凭据。没配 = GitHub 登录不开放, +// 前台登录卡自动不显示该入口。 +type GitHub struct { + ClientID string + ClientSecret string +} + +func (g GitHub) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" } + +const ( + githubAuthURL = "https://github.com/login/oauth/authorize" + githubTokenURL = "https://github.com/login/oauth/access_token" + githubUserURL = "https://api.github.com/user" +) + +// LoginURL 生成 GitHub 授权页跳转地址 +func (g GitHub) LoginURL(redirectURI, state string) string { + v := url.Values{} + v.Set("client_id", g.ClientID) + v.Set("redirect_uri", redirectURI) + v.Set("scope", "read:user") + v.Set("state", state) + return githubAuthURL + "?" + v.Encode() +} + +// GitHubUser 是 GitHub 用户接口里我们关心的字段 +type GitHubUser struct { + Login string `json:"login"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + HTMLURL string `json:"html_url"` +} + +// Exchange 用授权码换 access token +func (g GitHub) Exchange(ctx context.Context, code, redirectURI string) (string, error) { + v := url.Values{} + v.Set("client_id", g.ClientID) + v.Set("client_secret", g.ClientSecret) + v.Set("code", code) + v.Set("redirect_uri", redirectURI) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubTokenURL, strings.NewReader(v.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + var out struct { + AccessToken string `json:"access_token"` + } + if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" { + return "", fmt.Errorf("github: token exchange failed") + } + return out.AccessToken, nil +} + +// FetchUser 拉取 GitHub 用户资料 +func (g GitHub) FetchUser(ctx context.Context, accessToken string) (GitHubUser, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubUserURL, nil) + if err != nil { + return GitHubUser{}, err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + res, err := http.DefaultClient.Do(req) + if err != nil { + return GitHubUser{}, err + } + defer res.Body.Close() + var u GitHubUser + if err := json.NewDecoder(res.Body).Decode(&u); err != nil || u.Login == "" { + return GitHubUser{}, fmt.Errorf("github: fetch user failed") + } + return u, nil +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3c38df3..e6efe8e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -21,6 +21,10 @@ type Config struct { SiteURL string InsecureDev bool + // 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入) + GitHubClientID string + GitHubClientSecret string + // 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理): // S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com, // 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求 @@ -28,12 +32,12 @@ type Config struct { // AccessKey / SecretAccessKey / Bucket = R2 凭据与桶名 // 五项齐全 → 上传走 R2、直链走 PublicURL;缺任一项回落本地磁盘 // (DataDir/uploads),并在启动日志提示一句。 - StorageDriver string // r2 | local - S3Endpoint string // env: S3Api - R2Bucket string - R2AccessKey string - R2SecretKey string - UploadsPublicBase string // env: PublicURL + StorageDriver string // r2 | local + S3Endpoint string // env: S3Api + R2Bucket string + R2AccessKey string + R2SecretKey string + UploadsPublicBase string // env: PublicURL } func getenv(k, def string) string { @@ -122,6 +126,10 @@ func Load() (*Config, error) { } } + // 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入) + c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "") + c.GitHubClientSecret = getenv("ONE_GITHUB_CLIENT_SECRET", "") + return c, nil } diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 1a76224..1e7726b 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -29,6 +29,9 @@ type Post struct { // ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。 ContentLen int64 `json:"content_len"` Tags []string `json:"tags"` + // Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示), + // 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。 + Images []string `json:"images"` } type PostInput struct { @@ -42,6 +45,8 @@ type PostInput struct { PublishedAt string `json:"published_at"` Tags []string `json:"tags"` ReadingMinutes *int `json:"reading_minutes"` + // Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空 + Images []string `json:"images"` } type Tag struct { @@ -123,6 +128,14 @@ type FilePage struct { Size int `json:"size"` } +// CommentPage 是后台评论管理的分页容器(含文章标题与发表者)。 +type CommentPage struct { + Items []Comment `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` +} + // Dashboard is the snapshot rendered on /admin (homepage). type Dashboard struct { TotalPosts int `json:"total_posts"` @@ -161,6 +174,10 @@ type Settings struct { // SocialLinks 以 JSON 数组形式存在 settings KV 里(key: social_links), // 解析失败/为空时前台拿到空数组,区块自动隐藏。 SocialLinks []SocialLink `json:"social_links"` + // 评论开关(comments_enabled):关着时前台整个评论区不渲染。 + CommentsEnabled bool `json:"comments_enabled"` + // 审核开关(comments_review):开着时新评论先进「待审」,站主通过后才公开。 + CommentsReview bool `json:"comments_review"` // LightSkinID is the front-end skin used when the client (or system) // prefers light. Valid values: paper / sage / rose. // Dark side is fixed to ink for now — kept implicit so we can add @@ -186,3 +203,38 @@ type Settings struct { // sanitizing it would only break the snippet. CustomJS string `json:"custom_js"` } + +// Reader 是评论区的登录用户(GitHub OAuth)。Banned = 禁言中。 +type Reader struct { + ID int64 `json:"id"` + Provider string `json:"provider"` + Handle string `json:"handle"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + URL string `json:"url"` + Banned bool `json:"banned"` + // CommentCount 是累计评论数(后台用户列表展示用) + CommentCount int64 `json:"comment_count"` + CreatedAt string `json:"created_at"` +} + +// Comment 是一条评论。回复扁平存储(parent_id/root_id),渲染时挂到 replies。 +// User 是发表者快照;is_deleted = 软删(留壳显示「已删除」,保住楼层上下文)。 +type Comment struct { + ID int64 `json:"id"` + PostID int64 `json:"post_id"` + ParentID int64 `json:"parent_id"` + RootID int64 `json:"root_id"` + User *Reader `json:"user"` + UserID int64 `json:"user_id"` + BodyMd string `json:"body_md"` + BodyHTML string `json:"body_html"` + Status string `json:"status"` + IsDeleted bool `json:"is_deleted"` + CreatedAt string `json:"created_at"` + EditedAt string `json:"edited_at"` + Replies []Comment `json:"replies"` + ReplyCount int `json:"reply_count"` + // PostTitle 是文章标题(后台评论列表联表带出,仅管理接口填充) + PostTitle string `json:"post_title"` +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index 38d9c94..d893003 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -96,6 +96,30 @@ func (s *Store) migrate() error { store TEXT NOT NULL DEFAULT 'local', created_at TEXT NOT NULL )`, ai), + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS users ( + id %s, + provider TEXT NOT NULL, + handle TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + banned INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + UNIQUE(provider, handle) + )`, ai), + fmt.Sprintf(`CREATE TABLE IF NOT EXISTS comments ( + id %s, + post_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + parent_id INTEGER NOT NULL DEFAULT 0, + root_id INTEGER NOT NULL DEFAULT 0, + body_md TEXT NOT NULL DEFAULT '', + body_html TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'visible', + is_deleted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + edited_at TEXT NOT NULL DEFAULT '' + )`, ai), } for _, q := range stmts { if _, err := s.db.Exec(s.db.Q(q)); err != nil { @@ -109,6 +133,8 @@ func (s *Store) migrate() error { columnAdds := []string{ `ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`, `ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`, + // 短文配图(快发盒上传,Twitter 式网格展示),JSON 数组 + `ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`, } for _, q := range columnAdds { if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") && @@ -126,6 +152,9 @@ func (s *Store) migrate() error { {"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`}, {"idx_files_key", `CREATE UNIQUE INDEX IF NOT EXISTS idx_files_key ON files(key)`}, {"idx_files_created", `CREATE INDEX IF NOT EXISTS idx_files_created ON files(created_at DESC)`}, + {"idx_comments_post", `CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id, created_at)`}, + {"idx_comments_user", `CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)`}, + {"idx_comments_status", `CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status, created_at DESC)`}, } for _, ix := range indexes { if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") { @@ -138,13 +167,15 @@ func (s *Store) migrate() error { func (s *Store) seedSettings() error { defs := map[string]string{ - "site_title": "ONE · 一个博客", - "site_desc": "长文与短文,同一种节奏。", - "author_name": "ONE", - "author_bio": "写点长的,也写点短的。", - "footer_note": "© ONE · 一个博客", - "icp": "", - "posts_per_page": "10", + "site_title": "ONE · 一个博客", + "site_desc": "长文与短文,同一种节奏。", + "author_name": "ONE", + "author_bio": "写点长的,也写点短的。", + "footer_note": "© ONE · 一个博客", + "icp": "", + "posts_per_page": "10", + "comments_enabled": "1", + "comments_review": "0", } for k, v := range defs { if s.db.Dialect == db.Postgres { @@ -207,6 +238,9 @@ func settingsFromMap(m map[string]string) model.Settings { st.CustomCSS = decodeCSSMap(m["custom_css"]) st.CustomJS = m["custom_js"] st.SocialLinks = decodeSocialLinks(m["social_links"]) + // 开关类:'1' / 'true' 都算开,其余(含空)算关 + st.CommentsEnabled = m["comments_enabled"] == "1" || strings.EqualFold(m["comments_enabled"], "true") + st.CommentsReview = m["comments_review"] == "1" || strings.EqualFold(m["comments_review"], "true") if n := atoi(m["posts_per_page"]); n > 0 { st.PostsPerPage = n } @@ -389,8 +423,11 @@ func (s *Store) UpdateSettings(st model.Settings) error { "custom_css": encodeCSSMap(st.CustomCSS), // 原样存:站主自己的代码,不做任何转义/清洗。 "custom_js": st.CustomJS, - // 空数组存空串:KV 里不留 "null"。 + // 社交 / 源码链接:JSON 数组,空数组存 "[]"。 "social_links": encodeSocialLinks(st.SocialLinks), + // 开关统一存 '1' / '0'。 + "comments_enabled": b2s(st.CommentsEnabled), + "comments_review": b2s(st.CommentsReview), } for k, v := range sets { if s.db.Dialect == db.Postgres { @@ -440,21 +477,46 @@ func sanitizeOrder(o string) string { } const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status, - published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)` + published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images` // listCols 用于列表/时间线:不传 content_md(前端不用), // 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。 const listCols = `id, kind, title, slug, summary, cover_url, '' AS content_md, CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html, - status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)` + status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images` + +// images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。 +func decodeImages(s string) []string { + out := []string{} + if strings.TrimSpace(s) == "" { + return out + } + if err := json.Unmarshal([]byte(s), &out); err != nil { + return []string{} + } + return out +} + +func encodeImages(imgs []string) string { + if len(imgs) == 0 { + return "[]" + } + b, err := json.Marshal(imgs) + if err != nil { + return "[]" + } + return string(b) +} func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) { var p model.Post + var imgs string err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, - &p.ReadingMinutes, &p.ContentLen) + &p.ReadingMinutes, &p.ContentLen, &imgs) p.Tags = []string{} + p.Images = decodeImages(imgs) return p, err } @@ -627,9 +689,10 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) { } func scanPostInto(row *sql.Row, p *model.Post) error { + var imgs string err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, &p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt, - &p.ReadingMinutes, &p.ContentLen) + &p.ReadingMinutes, &p.ContentLen, &imgs) if err == sql.ErrNoRows { return ErrNotFound } @@ -637,6 +700,7 @@ func scanPostInto(row *sql.Row, p *model.Post) error { return err } p.Tags = []string{} + p.Images = decodeImages(imgs) return nil } @@ -881,17 +945,17 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) { var id int64 q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status, - published_at,created_at,updated_at,reading_minutes) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`) + published_at,created_at,updated_at,reading_minutes,images) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`) if s.db.Dialect == db.Postgres { err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, - p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id) + p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images)).Scan(&id) if err != nil { return p, err } } else { res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, - p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes) + p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images)) if err != nil { return p, err } @@ -939,6 +1003,10 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) { // current value (empty string included), so any update round-trips with // whatever the user last saved. p.CoverURL = strings.TrimSpace(in.CoverURL) + // Images 同理:nil = 不修改(快发盒只发新帖,后台编辑器全量回传) + if in.Images != nil { + p.Images = in.Images + } p.UpdatedAt = now() if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 { p.ReadingMinutes = *in.ReadingMinutes @@ -947,9 +1015,9 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) { } if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?, - content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`), + content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=?,images=? WHERE id=?`), p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status, - p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil { + p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), id); err != nil { return p, err } if in.Tags != nil { @@ -1508,3 +1576,315 @@ func (s *Store) DeleteFile(id int64) (model.File, error) { } return f, nil } + +// b2s 布尔转 KV 开关值 +func b2s(b bool) string { + if b { + return "1" + } + return "0" +} + +// ---------- readers(评论区的登录用户) ---------- + +// UpsertReader 按 (provider, handle) 找人:找到就更新资料,找不到就建档。 +// banned 是状态位,不随资料更新覆盖。 +func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) { + r.CreatedAt = now() + q := s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at) + VALUES (?,?,?,?,?,0,?) + ON CONFLICT (provider,handle) DO UPDATE SET + name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`) + // SQLite 的 ON CONFLICT 语法 Postgres 也认(现代版);老库退化走下面分支 + if s.db.Dialect == db.Postgres { + if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at) + VALUES (?,?,?,?,?,0,?) ON CONFLICT (provider,handle) DO UPDATE SET + name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`), + r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil { + return model.Reader{}, err + } + return s.GetReaderByProviderHandle(r.Provider, r.Handle) + } + if _, err := s.db.Exec(q, r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil { + return model.Reader{}, err + } + return s.GetReaderByProviderHandle(r.Provider, r.Handle) +} + +func (s *Store) GetReader(id int64) (model.Reader, error) { + r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE id = ?`), id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.Reader{}, ErrNotFound + } + return model.Reader{}, err + } + return r, nil +} + +func (s *Store) GetReaderByProviderHandle(provider, handle string) (model.Reader, error) { + r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE provider = ? AND handle = ?`), provider, handle)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.Reader{}, ErrNotFound + } + return model.Reader{}, err + } + return r, nil +} + +func (s *Store) SetReaderBanned(id int64, banned bool) error { + res, err := s.db.Exec(s.db.Q(`UPDATE users SET banned = ? WHERE id = ?`), b2i(banned), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// ListReaders 后台的用户列表:带评论数,禁言中的排前面 +func (s *Store) ListReaders() ([]model.Reader, error) { + rows, err := s.db.Query(s.db.Q(`SELECT u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.created_at, + (SELECT COUNT(*) FROM comments c WHERE c.user_id = u.id AND c.is_deleted = 0) AS cnt + FROM users u ORDER BY u.banned DESC, u.created_at DESC`)) + if err != nil { + return nil, err + } + defer rows.Close() + out := []model.Reader{} + for rows.Next() { + var r model.Reader + var cnt int64 + if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.CreatedAt, &cnt); err != nil { + return nil, err + } + r.CommentCount = cnt + out = append(out, r) + } + return out, rows.Err() +} + +func b2i(b bool) int64 { + if b { + return 1 + } + return 0 +} + +func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) { + var r model.Reader + var banned int64 + err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.CreatedAt) + r.Banned = banned == 1 + return r, err +} + +// ---------- comments ---------- + +const commentCols = `c.id, c.post_id, c.parent_id, c.root_id, c.user_id, c.body_md, c.body_html, + c.status, c.is_deleted, c.created_at, c.edited_at, + u.id, u.provider, u.handle, u.name, u.avatar_url, u.url, u.banned, u.created_at` + +func scanComment(sc interface{ Scan(...any) error }) (model.Comment, error) { + var c model.Comment + var u model.Reader + var banned int64 + err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML, + &c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt, + &u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt) + if err != nil { + return c, err + } + c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name, + AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt} + c.Replies = []model.Comment{} + return c, nil +} + +// CreateComment 新建评论;parent/root 归属与审核状态由调用方决定 +func (s *Store) CreateComment(c model.Comment) (model.Comment, error) { + c.CreatedAt = now() + q := s.db.Q(`INSERT INTO comments (post_id,user_id,parent_id,root_id,body_md,body_html,status,created_at) + VALUES (?,?,?,?,?,?,?,?)`) + var id int64 + if s.db.Dialect == db.Postgres { + err := s.db.QueryRow(q+` RETURNING id`, c.PostID, c.UserID, c.ParentID, c.RootID, + c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt).Scan(&id) + if err != nil { + return model.Comment{}, err + } + } else { + res, err := s.db.Exec(q, c.PostID, c.UserID, c.ParentID, c.RootID, c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt) + if err != nil { + return model.Comment{}, err + } + if id, err = res.LastInsertId(); err != nil { + return model.Comment{}, err + } + } + return s.GetComment(id) +} + +// GetComment 单条(含用户) +func (s *Store) GetComment(id int64) (model.Comment, error) { + c, err := scanComment(s.db.QueryRow(s.db.Q(`SELECT `+commentCols+` FROM comments c + JOIN users u ON u.id = c.user_id WHERE c.id = ?`), id)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.Comment{}, ErrNotFound + } + return model.Comment{}, err + } + return c, nil +} + +// ListCommentsByPost 一篇文章的公开评论树:顶层可见 + 访客自己的待审; +// 每条顶层内嵌全部可见回复。deleted 的行保留(墓碑:正文清空,楼层不塌)。 +func (s *Store) ListCommentsByPost(postID, viewerID int64, newestFirst bool) ([]model.Comment, error) { + order := `ASC` + if newestFirst { + order = `DESC` + } + rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c + JOIN users u ON u.id = c.user_id + WHERE c.post_id = ? AND c.parent_id = 0 + AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?)) + ORDER BY c.created_at `+order), postID, viewerID) + if err != nil { + return nil, err + } + defer rows.Close() + roots := []model.Comment{} + idx := map[int64]int{} + for rows.Next() { + c, err := scanComment(rows) + if err != nil { + return nil, err + } + c.Replies = []model.Comment{} + idx[c.ID] = len(roots) + roots = append(roots, c) + } + if err := rows.Err(); err != nil { + return nil, err + } + // 回复:可见的(+访客自己的待审),按时间正序挂在各自根下 + rows2, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c + JOIN users u ON u.id = c.user_id + WHERE c.post_id = ? AND c.parent_id <> 0 + AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?)) + ORDER BY c.created_at ASC`), postID, viewerID) + if err != nil { + return nil, err + } + defer rows2.Close() + for rows2.Next() { + c, err := scanComment(rows2) + if err != nil { + return nil, err + } + if at, ok := idx[c.RootID]; ok { + roots[at].Replies = append(roots[at].Replies, c) + } + } + for i := range roots { + roots[i].ReplyCount = len(roots[i].Replies) + } + return roots, rows2.Err() +} + +// ListCommentsAdmin 后台的评论列表(平铺,含用户与文章标题),status 过滤 +func (s *Store) ListCommentsAdmin(status string, page, size int) (model.CommentPage, error) { + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + where := "" + var args []any + switch status { + case "pending", "visible": + where = ` WHERE c.status = '` + status + `' AND c.is_deleted = 0` + default: + where = ` WHERE c.is_deleted = 0` + } + var total int + if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM comments c`+where), args...).Scan(&total); err != nil { + return model.CommentPage{}, err + } + args = append(args, size, (page-1)*size) + rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+`, COALESCE(p.title, '') FROM comments c + JOIN users u ON u.id = c.user_id + JOIN posts p ON p.id = c.post_id`+where+` + ORDER BY c.created_at DESC LIMIT ? OFFSET ?`), args...) + if err != nil { + return model.CommentPage{}, err + } + defer rows.Close() + out := []model.Comment{} + for rows.Next() { + c, title, err := scanCommentAdmin(rows) + if err != nil { + return model.CommentPage{}, err + } + c.PostTitle = title + out = append(out, c) + } + return model.CommentPage{Items: out, Total: total, Page: page, Size: size}, rows.Err() +} + +// CommentPage 后台评论管理的分页容器 +type modelCommentPageAlias = struct{} + +func scanCommentAdmin(sc interface{ Scan(...any) error }) (model.Comment, string, error) { + var c model.Comment + var u model.Reader + var title string + var banned int64 + err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML, + &c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt, + &u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt, &title) + c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name, + AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt} + return c, title, err +} + +// SetCommentStatus 审核通过 / 退回待审 +func (s *Store) SetCommentStatus(id int64, status string) error { + res, err := s.db.Exec(s.db.Q(`UPDATE comments SET status = ? WHERE id = ?`), status, id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// DeleteComment 软删:留壳(「该评论已删除」),正文清空 +func (s *Store) DeleteComment(id int64) error { + res, err := s.db.Exec(s.db.Q(`UPDATE comments SET is_deleted = 1, body_md = '', body_html = '' WHERE id = ?`), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// UpdateCommentBody 编辑后的正文回写 +func (s *Store) UpdateCommentBody(id int64, md, html string) error { + res, err := s.db.Exec(s.db.Q(`UPDATE comments SET body_md = ?, body_html = ?, edited_at = ? WHERE id = ?`), + md, html, now(), id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} diff --git a/backend/main.go b/backend/main.go index a168a56..362b2f4 100644 --- a/backend/main.go +++ b/backend/main.go @@ -18,6 +18,7 @@ import ( "oneblog/internal/admin" "oneblog/internal/api" + "oneblog/internal/auth" "oneblog/internal/config" "oneblog/internal/db" "oneblog/internal/storage" @@ -60,9 +61,19 @@ func main() { blobs = storage.NewLocal(filepath.Join(cfg.DataDir, "uploads")) } - public := &api.API{Store: st, Cfg: cfg, Blobs: blobs} + // 评论区的读者会话(cookie one_reader),30 天有效 + readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour) + + public := &api.API{ + Store: st, + Cfg: cfg, + Blobs: blobs, + ReaderSessions: readerSessions, + GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret}, + } adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)) adminAPI.Blobs = blobs + adminAPI.Blobs = blobs root := http.NewServeMux() root.Handle("/api/admin/", adminAPI.Routes()) diff --git a/frontend/src/admin/AdminLayout.vue b/frontend/src/admin/AdminLayout.vue index 286d6e1..30fb851 100644 --- a/frontend/src/admin/AdminLayout.vue +++ b/frontend/src/admin/AdminLayout.vue @@ -60,6 +60,7 @@ const crumb = computed(() => { if (p.endsWith('/new')) return '写新的' if (/^\/admin\/\d+/.test(p)) return '编辑文章' if (p.endsWith('/files')) return '文件' + if (p.endsWith('/comments')) return '评论' if (p.endsWith('/tags')) return '标签' if (p.endsWith('/settings')) return '设置' if (p === '/admin' || p === '/admin/') return '总览' @@ -124,6 +125,10 @@ watch(() => route.path, () => loadCounts()) ▣ 文件 + + ❝ + 评论 +
操作
✎ diff --git a/frontend/src/admin/CommentsView.vue b/frontend/src/admin/CommentsView.vue new file mode 100644 index 0000000..ccb7de9 --- /dev/null +++ b/frontend/src/admin/CommentsView.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/frontend/src/admin/SettingsView.vue b/frontend/src/admin/SettingsView.vue index 26d1555..91a6cb9 100644 --- a/frontend/src/admin/SettingsView.vue +++ b/frontend/src/admin/SettingsView.vue @@ -147,6 +147,8 @@ async function load() { settings.value.ui_id = UI_CONSTANTS.DEFAULT } socialText.value = socialToText(settings.value.social_links) + settings.value.comments_enabled = !!settings.value.comments_enabled + settings.value.comments_review = !!settings.value.comments_review // 服务端保证非 null,这里再兜一层,并预建分区键, // 这样 v-model 绑定的文本框一开始就有值可写。 if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') { @@ -382,6 +384,17 @@ async function save() { +
+ + + +