comment

package
v0.1.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 25, 2026 License: AGPL-3.0 Imports: 25 Imported by: 0

Documentation

Overview

Package comment 提供评论与回复树。

评论是本项目里唯一由**匿名访客**写入的用户内容,安全模型因此与文章正文相反: 正文信任已认证用户、原样保留 HTML;评论一律先全文转义再做有限的富化(见 render.go)。

依赖 content 模块的 posts 表与 mail 模块的发信服务:modules.go 中两者都必须先于本模块注册。

Index

Constants

View Source
const GroupComment = "comment"

GroupComment 是评论设置分组。

View Source
const Name = "comment"

Name 是模块名,也是迁移版本表的后缀(goose_db_version_comment)。

Variables

View Source
var (
	// ErrNotFound 表示评论不存在。
	ErrNotFound = errors.New("评论不存在")
	// ErrPostNotFound 表示评论的目标内容不存在。
	ErrPostNotFound = errors.New("评论的内容不存在")
	// ErrInvalid 表示字段违反了数据库约束。
	ErrInvalid = errors.New("字段不合法")
)

错误哨兵。处理器据此映射为 400 / 404。

Functions

func Count

func Count(nodes []*PublicView) int

Count 统计一棵回复树里的节点数,供分页元数据使用。

func Render

func Render(text string) string

Render 把访客提交的纯文本转成可以直出的 HTML。

这里的信任模型与文章正文**相反**:文章由已认证用户撰写,原样保留 HTML; 评论来自匿名访客,必须假定其中有攻击载荷。 因此顺序是「先全文转义,再做有限的富化」——先转义保证任何输入都不可能变成标签, 后续加进去的标签全部由本函数自己生成,形态与属性都在掌控之中。

Types

type Comment

type Comment struct {
	bun.BaseModel `bun:"table:comments,alias:cm"`

	ID          int64     `bun:"id,pk,autoincrement" json:"id"`
	PostID      int64     `bun:"post_id,notnull"     json:"postId"`
	ParentID    *int64    `bun:"parent_id"           json:"parentId" doc:"父评论 ID,顶层评论为 null"`
	UserID      *int64    `bun:"user_id"             json:"userId" doc:"已登录用户的 ID;访客评论为 null"`
	AuthorName  string    `bun:"author_name,notnull" json:"authorName"`
	AuthorEmail string    `bun:"author_email"        json:"authorEmail" doc:"仅 Console 可见"`
	AuthorURL   string    `bun:"author_url"          json:"authorUrl"`
	Content     string    `bun:"content,notnull"     json:"content" doc:"访客提交的原文,纯文本"`
	ContentHTML string    `bun:"content_html"        json:"contentHtml" doc:"转义并线性化后的展示版本"`
	Status      Status    `bun:"status,notnull"      json:"status" enum:"pending,approved,spam"`
	IP          string    `bun:"ip"                  json:"ip" doc:"仅 Console 可见"`
	UserAgent   string    `bun:"user_agent"          json:"userAgent" doc:"仅 Console 可见"`
	CreatedAt   time.Time `bun:"created_at,nullzero" json:"createdAt"`
	UpdatedAt   time.Time `bun:"updated_at,nullzero" json:"updatedAt"`

	// 以下为关联数据,由存储层按需填充,不是数据库列。
	Post *PostRef `bun:"-" json:"post,omitempty"`
}

Comment 是一条评论。

字段分两类:带 json 标签的可以出现在 Console 响应里; 邮箱、IP 与 UA 是个人信息,只在 Console 平面出现,前台视图另有一套(见 PublicView)。

type ConfigFunc

type ConfigFunc func(ctx context.Context) Settings

ConfigFunc 返回评论设置;由模块装配时注入设置服务,测试可传 nil 用缺省值。

type DefaultChecker

type DefaultChecker struct{}

DefaultChecker 是内置判定器。

func (DefaultChecker) Check

func (DefaultChecker) Check(cfg *Settings, in *SpamInput) Verdict

Check 实现 SpamChecker。判据从确定性最高的往下排,命中即返回。

type Filter

type Filter struct {
	Status Status
	PostID int64
	// AuthorID 非零时只返回该作者名下内容的评论,用于不带 _any 权限的调用者。
	AuthorID int64
	Q        string
}

Filter 是后台列表的筛选条件。

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler 提供评论的 Console 与 Public 接口。

func NewHandler

func NewHandler(store *Store, cfg ConfigFunc, spam SpamChecker, notify Notifier) *Handler

NewHandler 构造 Handler;notify 可为 nil,spam 为 nil 时用内置判定器。

func (*Handler) Register

func (h *Handler) Register(console, public huma.API)

Register 挂载接口。

type Module

type Module struct {
	// contains filtered or unexported fields
}

Module 是评论模块。

func New

func New() *Module

New 构造模块。

func (*Module) Migrations

func (m *Module) Migrations() fs.FS

Migrations 实现 app.Migrator。

func (*Module) Name

func (m *Module) Name() string

Name 实现 app.Module。

func (*Module) Navigation

func (m *Module) Navigation() app.Navigation

Navigation 实现 app.NavigationProvider。

func (*Module) Permissions

func (m *Module) Permissions() []app.Permission

Permissions 实现 app.PermissionProvider。

func (*Module) Register

func (m *Module) Register(a *app.App) error

Register 实现 app.Module:只做装配,不访问数据库。

func (*Module) Routes

func (m *Module) Routes(r app.Router)

Routes 实现 app.RouteProvider。

func (*Module) Settings

func (m *Module) Settings() []app.SettingGroup

Settings 实现 app.SettingsProvider。

type Notifier

type Notifier interface {
	CommentCreated(ctx context.Context, event *NotifyEvent)
}

Notifier 在评论创建后决定要不要发通知。

实现必须立即返回:它跑在请求路径上,而发信要走后台队列。

type NotifyEvent

type NotifyEvent struct {
	Comment *Comment
	Post    *PostRef
	// Settings 是判定时的评论设置,避免通知侧再读一次。
	Settings Settings
	// AutoApproved 为真表示该评论已直接展示,通知里不必再提「待审核」。
	AutoApproved bool
	// Parent 是被回复的评论,仅在这是一条回复时有值。
	Parent *Comment
}

NotifyEvent 描述一次需要通知的评论事件。

type PostRef

type PostRef struct {
	ID       int64  `json:"id"`
	Type     string `json:"type"`
	Title    string `json:"title"`
	Slug     string `json:"slug"`
	AuthorID int64  `json:"authorId"`
	// Status 与 Visibility 供前台接口复刻正文的可见性规则。
	Status     string `json:"-"`
	Visibility string `json:"-"`
}

PostRef 是评论所属内容的摘要,供后台列表直接显示与跳转。

func (*PostRef) PubliclyVisible

func (r *PostRef) PubliclyVisible() bool

PubliclyVisible 报告内容是否对访客可见,判据与主题前台的 publicFilter 完全一致: 已发布且公开。

这里刻意不接受「作者可见自己的私密内容」:Public 平面是访客平面, 草稿、私密与回收站中的内容一律不对外提供服务,其存在性也不该被探知。 作者要看自己未公开内容的评论,走 Console。

type PublicView

type PublicView struct {
	ID          int64         `json:"id"`
	ParentID    *int64        `json:"parentId"`
	AuthorName  string        `json:"authorName"`
	AuthorURL   string        `json:"authorUrl"`
	ContentHTML string        `json:"contentHtml" doc:"已转义的展示 HTML,主题直出即可"`
	IsAuthor    bool          `json:"isAuthor" doc:"是否为该内容的作者本人所发"`
	CreatedAt   time.Time     `json:"createdAt"`
	Children    []*PublicView `json:"children" doc:"回复,按时间正序"`
}

PublicView 是前台可见的评论,**不含**邮箱、IP 与 UA。

单独建一个类型而不是给字段加 omitempty:前台响应少一个字段是功能问题, 多一个字段是数据泄漏,两者的严重程度不对等,故用类型把边界钉死。

func BuildTree

func BuildTree(items []Comment, postAuthorID int64) []*PublicView

BuildTree 把平铺的评论按 parent_id 组装成回复树。

只保留能追溯到顶层的节点:父评论被删或被标垃圾时,其回复不该跑到顶层去展示。

type Settings

type Settings struct {
	Enabled         bool   `json:"enabled"`
	AllowAnonymous  bool   `json:"allowAnonymous"`
	RequireEmail    bool   `json:"requireEmail"`
	RequireApproval bool   `json:"requireApproval"`
	MaxLength       int    `json:"maxLength"`
	IntervalSeconds int    `json:"intervalSeconds"`
	MaxLinks        int    `json:"maxLinks"`
	Blocklist       string `json:"blocklist"`
	NotifyNew       bool   `json:"notifyNew"`
	NotifyReply     bool   `json:"notifyReply"`
	NotifyTo        string `json:"notifyTo"`
}

Settings 是 comment 分组的有效值。

type SpamChecker

type SpamChecker interface {
	Check(cfg *Settings, in *SpamInput) Verdict
}

SpamChecker 判定一条评论是否为垃圾。

做成接口是为了给插件留位置:v1 只做频率限制、链接数与关键词, Akismet 一类的服务留给扩展实现,核心不引入任何外部依赖。

func NewSpamChecker

func NewSpamChecker() SpamChecker

NewSpamChecker 返回内置判定器。

type SpamInput

type SpamInput struct {
	AuthorName string
	AuthorURL  string
	Content    string
	// Honeypot 是表单里的隐藏字段:正常访客看不见也不会填,填了的必是机器人。
	Honeypot string
	// LastFromIP 是同一 IP 的上一条评论时间,零值表示没有。
	LastFromIP time.Time
	Now        time.Time
}

SpamInput 是判定所需的信息。

type Status

type Status string

Status 是评论的审核状态。

const (
	// StatusPending 待审核:仅后台可见。
	StatusPending Status = "pending"
	// StatusApproved 已通过:前台可见。
	StatusApproved Status = "approved"
	// StatusSpam 垃圾:留档以便训练与申诉,前台不可见。
	StatusSpam Status = "spam"
)

评论状态。删除即删行,故没有回收站态。

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store 提供评论的持久化操作。

直接用 bun 读 posts 表而不经 content 模块:两者共用同一张表, 而模块之间只允许经 App 交互,不值得为一次标题查询引入跨模块依赖。

func NewStore

func NewStore(db bun.IDB) *Store

NewStore 构造 Store。

func (*Store) CountApproved

func (s *Store) CountApproved(ctx context.Context, postID int64) (int, error)

CountApproved 统计某篇内容已通过的评论数。

func (*Store) CountPending

func (s *Store) CountPending(ctx context.Context, authorID int64) (int, error)

CountPending 统计待审评论数,供 Console 侧显示待办角标。

func (*Store) Create

func (s *Store) Create(ctx context.Context, c *Comment) error

Create 写入评论;成功后回填 ID 与时间戳。

func (*Store) Delete

func (s *Store) Delete(ctx context.Context, id int64) error

Delete 删除评论;其回复由外键级联删除。

func (*Store) Get

func (s *Store) Get(ctx context.Context, id int64) (*Comment, error)

Get 按 ID 查评论。

func (*Store) LastByIP

func (s *Store) LastByIP(ctx context.Context, ip string) (time.Time, error)

LastByIP 返回某 IP 最近一条评论的时间,供频率限制使用。

只算「已存在」的评论:把待审的也算上,同一 IP 反复提交就会被自己挡住, 而正常访客并不知道自己的评论在待审。

func (*Store) ListApproved

func (s *Store) ListApproved(ctx context.Context, postID int64) ([]Comment, error)

ListApproved 返回某篇内容下已通过的**全部**评论,按时间正序。

不分页:回复树一旦被切开就没法正确组装,而单篇内容的评论量本就有上限。

func (*Store) Page

func (s *Store) Page(ctx context.Context, filter Filter, params api.PageParams) ([]Comment, int, error)

Page 分页返回评论,可按状态、内容与作者筛选。

func (*Store) PostRef

func (s *Store) PostRef(ctx context.Context, id int64) (*PostRef, error)

PostRef 按 ID 查内容摘要,同时给出作者 ID 供所有权判定。

func (*Store) SerializeByIP added in v0.1.9

func (s *Store) SerializeByIP(ctx context.Context, ip string, fn func(ctx context.Context, tx *Store) error) error

SerializeByIP 在一个事务里执行 fn,同一来源 IP 的调用排队进行。

频率判定是「先查这个 IP 的上一条,再写这一条」:同一 IP 几乎同时提交的两条评论 会各自查到「没有上一条」而双双放行。按 IP 取事务级 advisory lock, 后一条要等前一条提交后才去查,多实例下同样成立。

func (*Store) UpdateStatus

func (s *Store) UpdateStatus(ctx context.Context, id int64, status Status) error

UpdateStatus 修改审核状态。

type Verdict

type Verdict struct {
	// Spam 为真时直接标为垃圾,不进审核队列。
	Spam bool
	// Reason 说明判据,写进日志便于调规则。
	Reason string
}

Verdict 是一次反垃圾判定的结论。

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL