Help us shape the future of Go! Take the 2026 Go Developer Survey

comment

package
v1.0.0 Latest Latest
Warning

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

Go to latest
Published: Sep 26, 2026 License: AGPL-3.0 Imports: 26 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, events app.Events, logger *slog.Logger) *Handler

NewHandler 构造 Handler;notify 与 events 可为 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 From added in v0.2.0

func From(a *app.App) *Module

From 取回评论模块;未装配时返回 nil。

func New

func New() *Module

New 构造模块。

func (*Module) Delete added in v0.2.0

func (m *Module) Delete(ctx context.Context, id int64) error

Delete 删一条评论,给插件这类内部调用方用:权限由调用方判定。

func (*Module) Migrations

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

Migrations 实现 app.Migrator。

func (*Module) Moderate added in v0.2.0

func (m *Module) Moderate(ctx context.Context, id int64, status Status) error

Moderate 改一条评论的状态,给插件这类内部调用方用:权限由调用方判定。 从别的状态改成通过时派发 comment.approved,与后台审核一致。

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) AuthorEmail added in v0.2.1

func (s *Store) AuthorEmail(ctx context.Context, userID int64) (string, error)

AuthorEmail 取内容作者的邮箱,新评论通知没填收件地址时发给他;账号停用或不存在时返回空串。

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