评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页
- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀 - 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑 - 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换 - 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
This commit is contained in:
+398
-18
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user