Gitea源码深度剖析-第7章 Issue 体系:数据模型、生命周期与渲染链路
前六章分别搭起了总览、启动配置、路由、数据层、权限与 Git 底层的骨架,本章进入 Gitea 最核心的协作功能——Issue 体系。这里的"Issue 体系"是一个有意为之的宽口径:Gitea 从建模第一天起就把 Issue 和 Pull Request 视为同一张表上的两种行(Issue.IsPull 布尔列区分),因此本章讲透 Issue 的数据模型、编号分配、生命周期、评论渲染、标签、里程碑、模板与筛选,实际上也就讲透了 PR 列表页、PR 时间线的共同地基;PR 特有的合并、评审流程则留给后续章节。
本章的全部结论来自 gitea-28.1.0 仓库源码,涉及的顶层目录如下:
| 目录 | 职责 |
|---|---|
models/issues/ | 数据模型层:结构体、xorm tag、SQL 查询、事务 |
services/issue/ | 服务层:业务编排、权限复核、notify 事件派发 |
modules/references/ | 跨仓引用解析引擎(@mention、#123、owner/repo#N、close 关键词) |
modules/issue/template/ | Issue 模板的结构化解析与 Markdown 渲染 |
modules/markup/ | Markdown/代码/评论的通用渲染管线 |
routers/web/repo/issue*.go | Web 侧 handler(19 个文件) |
routers/api/v1/repo/issue*.go | API 侧 handler(13 个文件) |
先给出全章路线图,各小节的入口函数与文件坐标在正文中逐一标注:
- 7.1 数据模型全景:逐表结构体、xorm tag 与 ER 图
- 7.2 Issue 结构体逐字段与
issue_index表的并发安全取号 - 7.3 Comment 多态设计:CommentType 0..38 常量表与能力方法
- 7.4 创建链路:从表单到
services/issue.NewIssue的完整时序 - 7.5 关闭/重开/删除:乐观并发、依赖检查与删除策略
- 7.6 跨仓引用与关键词解析引擎
- 7.7 评论提交、Markdown 渲染与编辑删除
- 7.8 Reaction、附件、订阅与 @mention 收件人计算
- 7.9 标签与里程碑:关联表、互斥标签与物化计数
- 7.10 Issue 模板:目录发现、YAML 表单解析与渲染
- 7.11 列表筛选与批量操作的 SQL 生成
- 7.12 web 与 API 两侧 handler 入口总表
7.1 数据模型全景与 ER 图
models/issues/ 包内与 Issue 体系直接相关的表共 11 张,先用一张表把它们的职责和跨章索引位置列清楚:
| 表(结构体) | 文件 | 职责 | 关键索引/约束 |
|---|---|---|---|
issue(Issue) | models/issues/issue.go | Issue/PR 主体,一表两用 | UNIQUE(repo_index) 复合唯一 |
comment(Comment) | models/issues/comment.go | 多态评论/事件表 | INDEX(issue_id)、INDEX(type) |
label(Label) | models/issues/label.go | 仓库级/组织级标签 | INDEX(repo_id)、INDEX(org_id) |
issue_label(IssueLabel) | models/issues/issue_label.go | Issue↔Label 中间表 | UNIQUE(s)(issue_id,label_id) |
milestone(Milestone) | models/issues/milestone.go | 里程碑及进度物化列 | INDEX(repo_id,is_closed) |
reaction(Reaction) | models/issues/reaction.go | 表情回应 | UNIQUE(s) 五列联合 |
issue_watch(IssueWatch) | models/issues/issue_watch.go | Issue 级订阅 | UNIQUE(watch)(user_id,issue_id) |
issue_user(IssueUser) | models/issues/issue_user.go | 已读/被 mention 标记 | unique(uid_to_issue) |
issue_dependencies(IssueDependency) | models/issues/issue_dependency.go | 阻塞关系 | 两端各建索引 |
stopwatch(Stopwatch) | models/issues/issue_timetrack.go | 计时器 | issue/user 关联 |
tracked_time(TrackedTime) | models/issues/issue_timetrack.go | 手工登记工时 | issue/user 关联 |
attachment(附件)表单独放在 models/repo/attachment.go,因为同一种附件机制也被 Release、评论、Wiki 复用,7.8 节再展开。
7.1.1 主要实体的 xorm tag 细读
以 Issue 为例(完整逐字段分析见 7.2),几个有信息量的 tag 写法值得先说明,后文其他表沿用同样的约定:
type Issue struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"` // Repository ID.
Index int64 `xorm:"INDEX UNIQUE(repo_index)"` // Issue number in the repository.
Title string `xorm:"name"` // Issue title
Content string `xorm:"LONGTEXT"`
...
}
pk autoincr:主键自增,各表通用;INDEX:单列索引;UNIQUE(repo_index):xorm 的命名复合唯一约束语法,同一括号名出现的字段共同组成唯一索引——(repo_id, index)的复合唯一正是"仓内编号不重复"的数据库级保证;LONGTEXT:正文列显式放大到 LONGTEXT(MySQL 类型),因为 Issue 正文、评论正文都可能极大;name:把 Go 字段Title映射到数据库列title(xorm 默认会转小写下划线,这里显式声明是为了兼容旧列名)。
不落库的关联字段统一用 xorm:"-",例如 Issue.Repo、Issue.Poster、Issue.Comments、Issue.Labels 等,它们只在 LoadAttributes 之后于内存中存在,避免被 xorm 误当作列。时间列统一用 CreatedUnix 这类 Unix 秒字段配 INDEX,由 xorm 的 created/updated 自动填充机制维护。
7.1.2 ER 图
erDiagram
repository ||--o{ issue : "repo_id, 一表两用"
user ||--o{ issue : "poster_id"
milestone ||--o{ issue : "milestone_id 可空"
issue ||--o{ comment : "issue_id 多态时间线"
user ||--o{ comment : "poster_id"
issue ||--o{ issue_label : ""
label ||--o{ issue_label : ""
issue ||--o{ reaction : "comment_id=0 时挂 issue"
comment ||--o{ reaction : "comment_id>0 时挂 comment"
user ||--o{ issue_watch : ""
issue ||--o{ issue_watch : ""
user ||--o{ issue_user : "uid"
issue ||--o{ issue_user : "is_read / is_mentioned"
issue ||--o{ issue_dependency : "dependent_id 阻塞于"
issue ||--o{ attachment : "issue_id 经 comment 或直接关联"
issue ||--o{ issue_index_group : "issue_index 表按 repo.ID 取号"
需要注意图中两处容易误读的地方:
Issue与repository是严格的多对一,但编号不随 issue 行自增,而是由独立的issue_index资源序号表按仓库分组发放(7.2 节);- Reaction 通过
comment_id是否为 0 区分挂在 issue 上还是挂在某条评论上——这是一种"用哨兵值扩展外键语义"的写法,FindReactionsOptions里-1表示"显式只要 issue 级"、0表示"不过滤",三种取值三种语义,见 7.8 节。
7.2 Issue 结构体逐字段与并发安全的编号分配
7.2.1 Issue 结构体:落库字段
models/issues/issue.go 中的 Issue 是全章核心,摘录其落库字段(xorm:"-" 的关联字段见 7.2.2):
// Issue represents an issue or a pull request
type Issue struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX UNIQUE(repo_index)"` // Repository ID.
Repo *repo_model.Repository `xorm:"-"` // Repository relation.
Index int64 `xorm:"INDEX UNIQUE(repo_index)"` // Issue number in the repository.
PosterID int64 `xorm:"INDEX"` // User ID of the poster.
Poster *user_model.User `xorm:"-"` // User relation.
OriginalAuthorID int64 `xorm:"INDEX"` // User ID of the author in the external system
OriginalAuthor string `xorm:"varchar(255)"` // Author of the issue in the external system
Name string `xorm:"varchar(255)"`
Content string `xorm:"LONGTEXT"`
ContentVersion int64 `xorm:"NOT NULL DEFAULT 0"` // version of the content, used for edit-wins race protection
IsClosed bool `xorm:"INDEX"`
IsPull bool `xorm:"INDEX"` // Issue is a pull request
IsLocked bool `xorm:"NOT NULL DEFAULT false"` // Issue is locked
IsDraft bool `xorm:"NOT NULL DEFAULT false"` // Issue is a draft pull request
Branch string `xorm:"varchar(255)"` // user requested branch pull from
MilestoneID int64 `xorm:"INDEX"`
Milestone *Milestone `xorm:"-"` // Milestone relation
DeadlineUnix int64 `xorm:"INDEX"`
CreatedUnix int64 `xorm:"INDEX created"`
UpdatedUnix int64 `xorm:"INDEX updated"`
ClosedUnix int64 `xorm:"INDEX"`
NumComments int `xorm:"NOT NULL DEFAULT 0"`
NumReactions int `xorm:"NOT NULL DEFAULT 0"`
Ref string `xorm:"varchar(255)"` // issue ref, for external tracker
...
}
逐字段的几个要点:
Index而不是Number:仓内编号字段的真实名字是Index(任务描述中提到的number只是 API/URL 层的叫法,api.Issue结构体里对应字段也叫Number,但数据库列是index)。UNIQUE(repo_index)保证同一仓库内编号唯一,跨仓库允许重号——/owner/repo/issues/3的3就是它。IsPull同表共存:models/issues/issue.go文件头注释直接写明 "Issue represents an issue or a pull request"。这带来一个全书反复出现的推论:几乎所有查询都要么Where("is_pull = ?", false)过滤,要么由调用方(issues vs pulls 页面)显式指定。ContentVersion:编辑乐观并发的版本号,ChangeIssueContent用WHERE content_version = ?条件更新,affected == 0即判定他人已抢先编辑(7.5 节)。NumComments是物化列:不实时COUNT,由评论增删时通过UpdateIssueNumCommentsBuilder重算,且只统计ConversationCountedCommentType()(即Comment、Review两种),系统事件(改标签、改里程碑、关闭等)不计入正文评论数。Ref:外部 issue tracker(如 Jira 风格的ABC-1234)引用时回填的外部编号。
7.2.2 Issue 结构体:xorm:"-" 关联字段与 LoadAttributes
落库字段之外,Issue 挂了十几个仅存内存的关系字段:Repo、Poster、Milestone、Assignee/Assignees、Labels、Comments、Attachments、Reactions、PullRequest、Projects、DependencyInfo、PinOrder、Stopwatch、TotalTrackedTime 等。把它们加载进来的统一入口是 LoadAttributes(ctx):
func (issue *Issue) LoadAttributes(ctx context.Context) error {
if issue.LoadRepo(ctx) != nil { ... }
if issue.LoadPoster(ctx) != nil { ... }
if issue.Repo.Owner != nil { ... }
if issue.MilestoneID > 0 && issue.Milestone == nil {
if err := issue.LoadMilestone(ctx); err != nil { ... }
}
if err := issue.LoadLabels(ctx); err != nil { ... }
// projects, assignees, pull request, attachments, comments, ...
}
设计动机:列表页(7.11 节)出于性能只做轻量加载,进入详情页或调用 HTMLURL()、State() 这类需要关联数据的方法前,由 handler 显式调用 LoadAttributes。这层"显式加载"是 Gitea 模型层的一贯纪律——第 4 章讲数据层时已经见过同样的模式(LoadRepo/LoadUser 系列),避免 ORM 隐式 join 带来的 N+1 与不可控查询。
issue.Comments 的懒加载有一个性能关键点 loadCommentsByType:
func (issue *Issue) loadCommentsByType(ctx context.Context, typ ...CommentType) error {
if issue.Comments != nil {
return nil // Assumed has been loaded full comments.
}
...
}
只要 issue.Comments != nil 就认为全量评论已加载——详情页一次性 FindComments 拉取全部评论缓存在内存,后续按类型取(CommentsByType)都是内存过滤,不再打库。
7.2.3 issue_index 表:并发安全的编号分配
编号从哪来?直觉答案是"对 issue 表做自增",但那样取号与插行必须同一个自增序列,跨库行为不一致且无法预分配。Gitea 的做法是独立的资源序号表 db.ResourceIndex(models/db/index.go):
// ResourceIndex is the struct for a monotonically increasing counter
// and it is implemented database-specifically to avoid race conditions.
type ResourceIndex struct {
GroupID int64 `xorm:"pk"`
MaxIndex int64 `xorm:"NOT NULL"`
}
GroupID 是分组键——对 Issue 而言就是 repo.ID;MaxIndex 是当前已发放的最大编号。取号入口是 db.GetNextResourceIndex(ctx, "issue_index", repo.ID),内部按数据库类型分发到四种实现:
PostgreSQL:单语句原子操作
func postgresGetNextResourceIndex(ctx context.Context, session db.Engine, groupType string, groupID int64) (int64, error) {
var maxIndex int64
// for postgres, we can use the RETURNING clause to get the new value
// UPDATE "resource_index" SET max_index = max_index + 1 WHERE group_id=$1 AND group_type=$2 RETURNING max_index
...
}
MySQL:ON DUPLICATE KEY UPDATE 后再 SELECT
MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE 不支持返回受影响的新值(不带 RETURNING),所以先执行 upsert 把计数 +1,再在同一事务里 SELECT 出来。
MSSQL:MERGE ... HOLDLOCK
SQL Server 需要 WITH (HOLDLOCK) 序列化,否则两个并发 MERGE 可能都命中"不存在"分支导致插入冲突。
通用兜底(SQLite 及其他)
// SQLite and other databases that do not support the above syntax
// first try to UPDATE, if no rows affected, INSERT 0 then UPDATE again, finally SELECT.
三种操作都要求在资源创建所在的同一事务内执行——models/db/index.go 中的注释原文是 "it must run in the same transaction where the resource is created"。因为取号成功但事务回滚时,这个编号必须一起回滚,否则会留下空洞(空洞本身无害,但同一事务保证了"取号即插行"的原子性)。models/issues/issue_update.go 的 NewIssue 正是这样组织的:
func NewIssue(ctx context.Context, issue *Issue, labels []*Label, uuids []string) (err error) {
if err = issue.LoadAttributes(ctx); err != nil {
return err
}
// Create issue_index including pull requests
return db.WithTx(ctx, func(ctx context.Context) error {
issue.Index, err = db.GetNextResourceIndex(ctx, "issue_index", issue.RepoID)
if err != nil {
return err
}
return NewIssueWithIndex(ctx, issue, labels, uuids)
})
}
NewIssueWithIndex 要求 Index > 0(if issue.Index <= 0 { return ErrIssueIndexInvalid } 语义),供外部已知编号的场景(例如迁移导入、测试)绕过取号直接插入。
编号的第二重维护是 repo 行上的统计列:num_issues、num_pulls、num_closed_issues、num_closed_pulls。IncrRepoIssueNumbers/DecrRepoIssueNumbers 按 issue.IsPull 与"开/关/总"三个维度选择更新哪一列:
func IncrRepoIssueNumbers(ctx context.Context, repoID int64, isPull, isClosed bool) error {
col := "num_issues"
if isPull {
col = "num_pulls"
}
if isClosed {
col = "num_closed_" + col
}
_, err := db.GetEngine(ctx).Table("repository").
Where("id = ?", repoID).
Incr(col, 1).Update()
return err
}
repo.num_*、milestone.num_*、label.num_issues 都属于这类"物化计数",牺牲一点写放大换取列表页免聚合查询。与之配套的还有对账函数 RecalculateIssueIndexForRepo(models/issues/issue_index.go):当 issue 行被删除导致 MAX(index) 与 issue_index 不一致时,把序号表同步回 MAX(index),防止编号回退重用——编号一旦被外部链接引用就永不回收。
7.3 Comment 多态设计与 CommentType 常量表
7.3.1 为什么用一张表装下所有时间线事件
Issue 详情页的时间线上有正文评论、关闭事件、改标签、改里程碑、改负责人、跨仓引用、代码行评论、评审摘要、锁定、置顶……如果每种事件一张表,详情页查询将是十几个 UNION;如果只存正文评论,事件又需要与评论混排(按时间交错)。Gitea 选择单表多态:comment 表 + type 列区分,时间线一次 ORDER BY created_unix 全量取回。
CommentType 是这套多态的判别联合(discriminated union)标签,完整常量表如下(摘自 models/issues/comment.go,行号区间为该文件常量块):
| 常量 | 值 | 含义 |
|---|---|---|
CommentTypeUndefined | -1 | 未定义(查询占位) |
CommentTypeComment | 0 | 普通正文评论 |
CommentTypeReopen | 1 | 重新打开 |
CommentTypeClose | 2 | 关闭 |
CommentTypeIssueRef | 3 | 被其它 issue 引用 |
CommentTypeCommitRef | 4 | 被 commit 引用 |
CommentTypeCommentRef | 5 | 被评论引用 |
CommentTypePullRef | 6 | 被 PR 引用 |
CommentTypeLabel | 7 | 标签变更(Content "1"=加 "0"=删) |
CommentTypeMilestone | 8 | 里程碑变更 |
CommentTypeAssignees | 9 | 负责人变更 |
CommentTypeChangeTitle | 10 | 标题变更 |
CommentTypeDeleteBranch | 11 | 分支删除 |
CommentTypeStartTracking | 12 | 开始跟踪时间 |
CommentTypeStopTracking | 13 | 停止跟踪时间 |
CommentTypeAddTimeManual | 14 | 手工登记工时 |
CommentTypeCancelTracking | 15 | 取消跟踪 |
CommentTypeAddedDeadline | 16 | 设置截止日期 |
CommentTypeModifiedDeadline | 17 | 修改截止日期 |
CommentTypeRemovedDeadline | 18 | 移除截止日期 |
CommentTypeAddDependency | 19 | 添加依赖 |
CommentTypeRemoveDependency | 20 | 移除依赖 |
CommentTypeCode | 21 | 代码行评论 |
CommentTypeReview | 22 | 评审 |
CommentTypeLock | 23 | 锁定 |
CommentTypeUnlock | 24 | 解锁 |
CommentTypeChangeTargetBranch | 25 | PR 目标分支变更 |
CommentTypeDeleteTimeManual | 26 | 删除登记工时 |
CommentTypeReviewRequest | 27 | 评审请求 |
CommentTypeMergePull | 28 | PR 合并 |
CommentTypePullPush | 29 | PR 推送提交 |
CommentTypeProject | 30 | 项目变更 |
CommentTypeProjectColumn | 31 | 项目列变更(常量名沿用 ProjectBoard,源码内 FIXME 注明旧名) |
CommentTypeDismissedReview | 32 | 评审被驳回 |
CommentTypeChangeIssueRef | 33 | 外部引用编号变更 |
CommentTypePullScheduledMerge | 34 | 定时合并排程 |
CommentTypePullCancelScheduledMerge | 35 | 取消定时合并 |
CommentTypePin | 36 | 置顶 |
CommentTypeUnpin | 37 | 取消置顶 |
CommentTypeChangeTimeEstimate | 38 | 工时预估变更 |
常量之外还有一张字符串映射表 commentStrings,下标与枚举一一对应,提供 AsCommentType(string) 反查——这层映射服务于两处:日志/通知里的类型名,以及把历史遗留字符串恢复为枚举值。
7.3.2 能力方法:把"类型意味着什么"封装成行为
如果每处逻辑都写 switch typ { case Comment, Review: ... },新增类型必然漏改。Gitea 把类型语义收敛为一组能力方法(models/issues/comment.go):
// HasContentSupport returns if the comment type supports content
func (t CommentType) HasContentSupport() bool { ... }
// HasAttachmentSupport returns if the comment type supports attachments
func (t CommentType) HasAttachmentSupport() bool { ... }
// HasMailReplySupport returns if the comment type supports replying via mail
func (t CommentType) HasMailReplySupport() bool { ... }
// CountedAsConversation returns if the comment type is counted in issue's comment counter
func (t CommentType) CountedAsConversation() bool { ... }
// ConversationCountedCommentType returns the comment types that count as conversation
func ConversationCountedCommentType() []CommentType {
return []CommentType{CommentTypeComment, CommentTypeReview}
}
调用方举例如下:
services/issue.UpdateComment用Type.HasContentSupport()判断是否需要保存内容历史(ContentHistory)——改标签事件没有"正文",自然没有编辑历史;services/issue.DeleteComment用CountedAsConversation()判断删这条评论后要不要重算NumComments;issue_view.go渲染循环用HasContentSupport()决定是否走 Markdown 渲染分支;- 邮件回复链路用
HasMailReplySupport()过滤可回复的评论类型。
还有一组判定函数:CommentTypeIsRef(t)(3/4/5/6 四种跨仓引用类型)、CountedAsConversation 的对偶 IsSystemComment 等。问答:为什么跨仓引用要单独一个判定函数而不是给 CommentType 加方法? 因为引用类型需要保持"连续 4 个枚举值"的紧凑区间语义,CommentTypeIsRef 一次区间比较即可,也便于 issue_xref.go 在差集计算中归类。
7.3.3 Comment 结构体:多态字段的共存
Comment 的字段按"所有类型共用一套列"的方式铺开,摘录核心字段:
type Comment struct {
ID int64 `xorm:"pk autoincr"`
Type CommentType `xorm:"INDEX"`
IssueID int64 `xorm:"INDEX"` // issue id
Issue *Issue `xorm:"-"` // issue reference
PosterID int64 `xorm:"INDEX"` // user id of the comment author
Poster *user_model.User `xorm:"-"` // user data of the comment author
OriginalAuthorID int64 `xorm:"INDEX"`
OriginalAuthor string `xorm:"varchar(255)"`
// related issue data, only used by Label/Assignee/... types of comments
LabelID int64 `xorm:"INDEX"`
OldMilestoneID int64
NewMilestoneID int64
AssigneeID int64
AssigneeTeamID int64
RemovedAssignee bool
OldTitle string
NewTitle string
OldRef string
NewRef string
DependentIssueID int64 `xorm:"index"`
Assignee *user_model.User `xorm:"-"`
RemovedAssigneeUser *user_model.User `xorm:"-"`
// related commit data, only used by CommitRef/PullPush comments
CommitID int64 `xorm:"index"`
CommitSHA string `xorm:"varchar(64)"`
// related code comment data, only used by Code/Review comments
Line int64 `xorm:"index"` // -1: previous line / 0: overall comment / 1: proposed line
TreePath string `xorm:"VARCHAR(4000)"`
Content string `xorm:"LONGTEXT"`
ContentVersion int64 `xorm:"NOT NULL DEFAULT 0"`
PatchQuoted string `xorm:"TEXT"` // quoted patch to avoid invalid UTF-8
ReviewID int64 `xorm:"index"`
// related cross reference data, only used by IssueRef/CommitRef/CommentRef/PullRef
RefRepoID int64 `xorm:"index"`
RefIssueID int64 `xorm:"index"`
RefCommentID int64 `xorm:"index"`
RefAction references.XRefAction `xorm:"smallint"`
RefIsPull bool
RefIssue *Issue `xorm:"-"`
RefRepo *repo_model.Repository `xorm:"-"`
CommentMetaData []byte `xorm:"JSON TEXT"`
Invalidated bool `xorm:"NOT NULL DEFAULT false"`
...
}
三个值得注意的设计:
- 一个
Content列服务多种语义:对正文评论是 Markdown 原文;对CommentTypeLabel是 "1"/"0" 增删标记(newIssueLabel插入Content: "1");对时间类评论,旧格式直接存文本、新格式存|分隔的结构(渲染时issue_view.go判断Content[0] != '|'走老分支)。多态表的代价就是列的语义随Type漂移,所以读取处必须先判型。 PatchQuoted的 UTF-8 自救:BeforeInsert/AfterLoad用strconv.Quote处理非法 UTF-8 的代码补丁——数据库 VARCHAR/TEXT 在部分排序规则下拒收非法字节,插入前转义、加载后反转义。Line的符号约定:-1表示 diff 上一行(previous),1表示提议行(proposed),0表示整个文件的总体评论;这与 PR 评审的 diff 折叠展示直接对应。
7.3.4 CreateComment:插入、补充信息与引用的三段式
所有类型评论的落库入口是 issues_model.CreateComment,其结构是"事务内写行 + 补充关联信息 + 事务外发引用":
func CreateComment(ctx context.Context, comment *Comment) (err error) {
if comment.Type == CommentTypeCode {
...
}
return db.WithTx2(ctx, func(ctx context.Context, sess db.Session) error {
if err = db.Insert(ctx, comment, sess); err != nil {
return err
}
if err = updateCommentInfos(ctx, comment, sess); err != nil {
return err
}
// Create cross references in a different transaction,
// because the referenced issues may not exist in this database... (源码注释)
return AddCrossReferences(ctx, comment, sess)
})
}
updateCommentInfos 对 Comment/Code/Review 三类(fallthrough 合并分支)执行 UpdateIssueNumComments 重算物化评论数、把 attachment.uuids 关联到该评论、并 UpdateIssueCols("updated_unix") 刷新 issue 的更新时间——最后这步保证"有人评论"会让 issue 在列表里按更新时间上浮。
评论的乐观并发与 issue 正文同构:
func UpdateComment(ctx context.Context, c *Comment, contentVersion int64) (err error) {
c.ContentVersion = contentVersion + 1
// OriginalContent must be skipped
...
// if the content version is not matched, the comment is not updated
// (affected == 0 → ErrCommentAlreadyChanged)
}
删除则是 DeleteComment:删评论行、删 ContentHistory、若 CountedAsConversation() 则重算 NumComments、把 action 表对应行置 is_deleted、neuteraCrossReferences(把"因这条评论产生"的跨仓引用降级为失效,见 7.6)、删除其 Reaction。这一串清理函数集中体现了"评论不是叶子节点"——它下挂引用、表情、附件、历史。
7.4 创建链路:从表单到数据库的完整时序
7.4.1 三层调用链概览
新建 Issue 在代码中是严格分层的三层,每层职责清晰:
| 层 | 入口 | 文件 | 职责 |
|---|---|---|---|
| handler | NewIssuePost(ctx) | routers/web/repo/issue_new.go | 表单绑定、模板渲染、候选元数据校验 |
| service | issue_service.NewIssue(ctx, doer, repo, issue, labels, uuids, projectIDs) | services/issue/issue.go | blocked 校验、事务编排、notify 派发 |
| model | issues_model.NewIssue(ctx, issue, labels, uuids) | models/issues/issue_update.go | 取号、插行、计数维护、引用扫描 |
先看 handler 侧如何把模板渲染进正文(NewIssuePost 的关键片段):
func NewIssuePost(ctx *context.Context) {
form := *ctx.Form.(forms.CreateIssueForm)
...
if form.TemplateFile != "" {
var template *issue_template.Template
template, err = issue_template.UnmarshalFromRepo(ctx, ctx.Repo.Repository, ctx.Repo.GitRepo, form.TemplateFile)
...
// Render the template with the form data
ctx.Req.Form = issue_template.RenderToMarkdown(template, ctx.Req.Form)
...
}
...
if err := issue_service.NewIssue(ctx, ctx.Doer, ctx.Repo.Repository, issue, labelIDs, attachmentUUIDs, projectIDs); err != nil {
...
}
ctx.JSONRedirect(issue.Link())
}
RenderToMarkdown 的返回值被直接回写 ctx.Req.Form——表单的 content 字段被模板渲染结果覆盖,后续代码对 content 的读取完全无感知。这是模板机制的巧妙处:模板不是旁路功能,而是"提交前的表单改写"(7.10 节展开)。
issue 对象在 handler 中组装,Issue.Title 经过 255 字符截断(与 ChangeIssueTitle 一致),PosterID = ctx.Doer.ID;ValidateRepoMetasForNewIssue 负责把 URL/表单里的 label_ids[]、milestone、assignee_ids[]、project_ids[] 与候选集比对——只接受候选集内的 ID,越权传入别人的 label ID 会直接 ctx.NotFound,这是第 5 章权限过滤思想在表单层的落地。
7.4.2 service 层编排
services/issue/issue.go 的 NewIssue:
func NewIssue(ctx context.Context, doer *user_model.User, repo *repo_model.Repository,
issue *issues_model.Issue, labels []*issues_model.Label, uuids []string, projectIDs []int64,
) (err error) {
// refuse create issue for repos that are blocked due to a takedown request
if err = loadIssueAttributesForCreate(ctx, issue); err != nil {
return err
}
return db.WithTx(ctx, func(ctx context.Context) error {
if err = issues_model.NewIssue(ctx, issue, labels, uuids); err != nil {
return err
}
for _, assignee := range issue.Assignees {
if _, _, err := AddAssigneeIfNotAssigned(ctx, issue, doer, assignee); err != nil {
return err
}
}
// assign the issue to projects (projectIDs)
...
return nil
})
// 事务成功后(WithTx 返回后):
// FindAndUpdateIssueMentions → notify_service.NewIssue → IssueChangeLabels
// → IssueChangeMilestone → IssueChangeAssignee
}
(源码中被摘录处以 … 省略,notify 调用均位于事务提交之后——通知必须在事务成功后发出,否则监听者可能看到尚未提交的数据,这是第 4 章讲过的 notify 纪律,全书通用。)
model 层的 NewIssue(7.2.3 已摘录取号部分)随后 NewIssueWithIndex 完成插行后的全部副作用:
func NewIssueWithIndex(ctx context.Context, issue *Issue, labels []*Label, uuids []string) (err error) {
if issue.Index <= 0 {
return err
}
// Initialize Embeds and Attachments
...
if err = db.Insert(ctx, issue); err != nil {
return err
}
if issue.MilestoneID > 0 {
if _, err := db.GetEngine(ctx).Table("milestone").
Where("id = ?", issue.MilestoneID).
Incr("num_issues", 1).Update(); err != nil { ... }
if err := UpdateMilestoneUserCount(ctx, ...); err != nil { ... }
// also create a CommentTypeMilestone event
}
if err := IncrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, false); err != nil { ... }
// labels: 先 Find 对象,再逐个 newIssueLabel;非法(跨仓)标签静默跳过
for _, label := range labels {
if err := newIssueLabel(ctx, issue, label); err != nil { ... }
}
if err := NewIssueUsers(ctx, issue.Repo, issue); err != nil { ... }
if err := UpdateIssueAttachments(ctx, issue, uuids); err != nil { ... }
if err := issue.LoadAttributes(ctx); err != nil { ... }
return AddCrossReferences(ctx, issue.ToCreateIssueComment(), sess, false)
}
两个细节值得注意:
- 标签非法不回滚整个创建:
newIssueLabel对跨仓/不存在的 label 返回错误时,NewIssueWithIndex的处理是跳过该标签而不是失败——用户拿着过期页面提交,不应导致 issue 创建失败。 NewIssueUsers预插issue_user(models/issues/issue_user.go):把 poster 和仓库默认 assignees 各插一行,为后续is_read、is_mentioned标记占位;其容量预估注释也很有意思——"Leave a seat for poster itself to append later... waste 1 unit is cheaper than re-allocate memory once",先按len(assignees)+1分配切片,poster 已是 assignee 时浪费一个槽位也比重新分配便宜。
7.4.3 创建时序图
sequenceDiagram
participant U as 浏览器
participant H as issue_new.NewIssuePost
participant S as services/issue.NewIssue
participant M as models/issues.NewIssue
participant DB as 数据库
participant N as notify_service
U->>H: POST /:owner/:repo/issues/new (form)
H->>H: issue_template.RenderToMarkdown 覆盖 content
H->>H: ValidateRepoMetasForNewIssue 校验候选元数据
H->>S: NewIssue(doer, repo, issue, labels, uuids, projectIDs)
S->>S: db.WithTx 开启事务
S->>M: NewIssue(issue, labels, uuids)
M->>DB: BEGIN
M->>DB: GetNextResourceIndex("issue_index", repoID) [原子取号]
DB-->>M: index = N
M->>DB: INSERT issue (index=N)
M->>DB: UPDATE milestone SET num_issues+1 (若有)
M->>DB: UPDATE repository SET num_issues+1
M->>DB: INSERT issue_label / issue_user / attachment 关联
M->>M: AddCrossReferences 扫描标题正文引用
M-->>S: 返回
S->>S: 逐个 AddAssigneeIfNotAssigned
S->>DB: COMMIT
S->>S: FindAndUpdateIssueMentions 计算 mention 收件人
S->>N: NewIssue / IssueChangeLabels / IssueChangeAssignee (事务外)
N-->>U: 站内信 / 邮件 / webhook
H-->>U: 302 → /issues/N#issuecomment-M
7.4.4 并发与边界情况
- 编号冲突:两个请求同时创建,
issue_index的原子 +1 保证拿到不同index;即便极端情况下UNIQUE(repo_index)冲突,db.Insert返回唯一键错误、整个事务回滚,用户看到 500 而非脏数据。 - WIP 前缀联动(PR 场景):
ChangeTitle会检测 WIP 前缀变化并触发 CODEOWNERS 评审请求;创建 PR 时NewIssue同样把标题前缀纳入首次评审请求计算。 - blocked 用户:service 层第一件事是 blocked 校验(
IsUserBlockedBy链路),被平台封禁的用户在 handler 通过后仍会被 service 拒绝。 - 无里程碑/无标签:
MilestoneID == 0、空 labels 都是合法默认值,所有计数维护都有> 0守卫。
7.5 关闭、重开与删除:乐观并发、依赖检查与级联清理
7.5.1 关闭的乐观并发
关闭 Issue 不是简单的 UPDATE issue SET is_closed=true,SetIssueAsClosed(models/issues/issue_update.go)要过三道关:
func SetIssueAsClosed(ctx context.Context, issue *Issue) (err error) {
if issue.IsClosed {
return ErrIssueIsClosed{}
}
// 检查未完成的前置依赖:只要还有未关闭的 blocker 就拒绝
has, err := issue.HasDependenciesLeft(ctx) // → ErrDependenciesLeft
if has {
return ErrDependenciesLeft{}
}
// 乐观并发:条件更新,只有仍处于 open 状态的行才会被改到
ctx, sess := db.GetSession(ctx)
count, err := sess.
Where("is_closed = ?", false).
ID(issue.ID).
Cols("is_closed", "closed_unix").
Update(&Issue{IsClosed: true, ClosedUnix: timeutil.GetUnixTime()})
if err != nil { ... }
if count == 0 {
return ErrIssueAlreadyChanged{} // 他人已抢先关闭
}
return updateIssueNumbers(ctx, issue, true) // 事务内收尾
}
三道关分别是:状态前置检查(快速失败给用户可读错误)、依赖检查(issue_dependencies 中作为"被阻塞方"还有未关闭的 blocker 时禁止关闭,防止提前收工)、条件更新(WHERE is_closed = false + affected == 1 判定,双保险:即便两个请求同时通过了第一道检查,数据库层面也只有一个能成功,另一个拿到 ErrIssueAlreadyChanged)。这种"读-改-写"不加锁而用条件更新裁决的模式,与 7.2 的 content_version 是同一族——第 4 章讨论过其适用边界:冲突窗口短、失败可重试的场景。
updateIssueNumbers 是开/关共用的收尾,负责四件事:更新 label 计数(num_closed_issues 与 num_issues 的联动)、UpdateMilestoneCounters 重算里程碑、DecrRepoIssueNumbers/IncrRepoIssueNumbers 维护仓库统计、插入 CommentTypeClose/CommentTypeReopen 时间线事件。注意它只在模型层事务内被调用,事件评论与状态变更同事务,不会出现"状态改了但时间线没事件"的撕裂。
service 层 CloseIssue(services/issue/status.go)再包一层:
func CloseIssue(ctx context.Context, issue *issues_model.Issue, doer *user_model.User, gh *api.GitHook) (err error) {
return db.WithTx(ctx, func(ctx context.Context) error {
if err := issues_model.CloseIssue(ctx, issue); err != nil {
if !errors.Is(err, issues_model.ErrDependenciesLeft{}) {
return err
}
// 即便依赖未清导致关闭失败,也先停掉计时器(源码注释解释了这一取舍)
FinishIssueStopwatch(ctx, issue, doer)
return err
}
FinishIssueStopwatch(ctx, issue, doer)
return nil
})
// 事务外:
// notify_service.StopwatchChanged(...) / IssueChangeStatus(ctx, doer, issue, true, gh)
}
ErrDependenciesLeft 分支里也执行 FinishIssueStopwatch 是一个刻意的 UX 决策:关闭被拒时,正在跑的秒表继续跑没有意义,先停表再把错误抛回给用户。而 ReopenIssue 的源码里留着一条 FIXME 注释,坦承"依赖它的下游 issue 是否应自动重开"尚未处理——重开不会级联,这是当前的真实行为。
7.5.2 web handler 中的状态切换
状态切换在 web 侧的入口有两个:详情页的关闭按钮与列表页的批量操作,但评论型的状态切换走的是一个共享 handler——routers/web/repo/issue_comment.go 的 NewComment:
func NewComment(ctx *context.Context) {
issue := GetActionIssue(ctx) // 从 URL index 载入 issue
...
// 同一个 POST 同时承载"发表评论"与"关闭/重开"
switch ctx.FormString("status") {
case "close":
// 权限:CanWrite 或本人是 poster
if err := issue_service.CloseIssue(ctx, issue, ctx.Doer, ...); err != nil { ... }
case "reopen":
// PR 重开附加检查:未合并(GetUnmergedPullRequest)、GitHub flow ref 一致性
...
}
if ctx.FormString("content") != "" || ... {
// 创建正文评论(7.7 节)
issue_service.CreateIssueComment(ctx, issue, ctx.Doer, content, ...)
}
ctx.JSONRedirect(fmt.Sprintf("%s#issuecomment-%d", issue.HTMLURL(), comment.ID))
}
handler 内还有一处防御性注释值得原样转述:"ATTENTION: 评论已创建后禁用 ctx.JSONError"——评论一旦落库就不可回退,后续步骤(如状态切换)失败时不能再返回 JSON 错误让前端当作"什么都没发生",只能降级为跳转。这种"部分成功后的错误语义"是表单型 handler 的经典难题,源码用注释显式标注了裁决结果。
列表页的批量状态切换在 routers/web/repo/issue_list.go 的 UpdateIssueStatus:遍历选中的 index 逐个 GetIssueByIndex,已合并的 PR 跳过(IsPull && PullRequest.HasMerged),依赖未清的用 ctx.JSONError 返回结构化失败列表——与 NewComment 的单条路径不同,批量路径允许"部分成功"。
7.5.3 删除策略:三层函数与级联清单
删除是级联最重的操作,service 层拆成两半(services/issue/issue.go):
func DeleteIssue(ctx context.Context, doer *user_model.User, issue *issues_model.Issue) error {
if err := issue.LoadAttributes(ctx); err != nil { return err }
if err := issue.LoadPullRequest(ctx); err != nil { return err }
if err := deleteIssue(ctx, issue); err != nil { return err }
// 删除附件的物理文件(数据库行已在 deleteIssue 内删除)
for _, attachment := range issue.Attachments {
if err := attachment.Delete(ctx); err != nil { ... }
}
// PR 额外删除 git 引用
if issue.IsPull { ... pull_service.DeletePullRequest ... }
notify_service.IssueDeleted(ctx, issue, doer)
return nil
}
deleteIssue 是纯数据库事务,db.DeleteBeans 一次声明要删的所有关联(节选,省略号为源码中的后续行):
return db.WithTx2(ctx, func(ctx context.Context, sess db.Session) error {
// 删除 issue 行本身、评论、关联……
if _, err := sess.ID(issue.ID).Delete(&Issue{}); err != nil { ... }
if err := DecrRepoIssueNumbers(ctx, issue.RepoID, issue.IsPull, issue.IsClosed); err != nil { ... }
if err := UpdateMilestoneCounters(ctx, issue.MilestoneID); err != nil { ... }
if err := DeleteIssueActions(ctx, issue.ID); err != nil { ... }
return db.DeleteBeans(ctx, sess,
&ContentHistory{IssueID: issue.ID},
&Comment{IssueID: issue.ID},
&IssueLabel{IssueID: issue.ID},
&IssueDependency{DependencyID: issue.ID},
&IssueAssignees{IssueID: issue.ID},
&IssueUser{IssueID: issue.ID},
&Notification{IssueID: issue.ID},
&Reaction{IssueID: issue.ID},
&IssueWatch{IssueID: issue.ID},
&Stopwatch{IssueID: issue.ID},
&TrackedTime{IssueID: issue.ID},
&ProjectIssue{IssueID: issue.ID},
&Attachment{IssueID: issue.ID},
&PullRequest{IssueID: issue.ID},
&Comment{RefIssueID: issue.ID}, // 引用它的跨仓引用评论
&IssueDependency{DependencyID: issue.ID},
&Comment{DependentIssueID: issue.ID},
&IssuePin{IssueID: issue.ID},
)
})
清单里两组容易被忽略:
Comment{RefIssueID: issue.ID}与Comment{DependentIssueID}:别的 issue 里"引用了本 issue"的评论、以及"以本 issue 为 blocker"的依赖记录都要一并清理——引用是双向影响的单向存储,删除时必须反向扫。Notification:站内通知行随之删除,避免通知中心出现 404。
删除后编号不回收(7.2.3 的 RecalculateIssueIndexForRepo 只保证序号表 ≥ MAX(index)),num_issues/num_closed_issues 通过 DecrRepoIssueNumbers + UpdateMilestoneCounters 归位。孤儿清理还有两个入口:DeleteOrphanedIssues(修复历史脏数据)与 DeleteIssuesByRepoID(删仓库时分批删除,DefaultMaxInSize 分块避免超大 IN 子句)。
问答:为什么附件的物理文件删除放在事务之外? 因为 storage 不是数据库,无法参与事务;先删数据库行(不可恢复指向)再删文件,最坏情况是留下孤儿文件可被后续清扫任务回收;反过来先删文件,一旦事务回滚就会出现"行还在但文件没了"的可见损坏。顺序即故障模型。
7.6 跨仓引用与关键词解析引擎
7.6.1 解析器家族:modules/references 的正则组
modules/references/references.go(约 633 行)是全 Gitea 最精密的文本解析模块之一,它从 issue 正文、评论、commit message 中识别六类引用:
| 正则变量 | 匹配形式 | 例子 |
|---|---|---|
mentionPattern | @user 与 @org/team | @maintainer、@gitea/team-dev |
issueNumericPattern | 本仓 #123 / !123 | #42、!108 |
issueAlphanumericPattern | 外部 tracker [A-Z]{1,10}-[1-9][0-9]* | ABC-1234 |
crossReferenceIssueNumericPattern | 跨仓 owner/repo#N | gitea/gitea#12345 |
crossReferenceCommitPattern | 跨仓提交 owner/repo@sha | go-gitea/gitea@a1b2c3d |
timeLogPattern | 工时 @1.5h | spent @1.5h |
任务描述中提到的 [org#1] 形式经核实并不存在——跨仓引用的真实语法是 owner/repo#N(crossReferenceIssueNumericPattern),org 只出现在 @org/team 的 mention 里。这是一个"Never assume, verify before claim"的典型案例,文中以源码为准。
动作关键词与引用是分离的两层。XRefAction 枚举:
type XRefAction int
const (
XRefActionNone XRefAction = iota // no action, reference only
XRefActionCloses // reference will close the issue
XRefActionReopens // reference will reopen the issue
XRefActionNeutered // reference was removed / invalidated
)
关键词表由 findActionKeywords 支撑,形如 (?i)(?:\s|^|\(|\[)(close|closes|closed|fix|...):?$ 的 makeKeywordsPat,在引用起点前的一个滑动窗口内回溯搜索——窗口宽度在 doNewKeywords 中动态计算为 issueKeywordWindow = max(runeLen*4+3)(按字符长度的 4 倍 + 3 字节),保证中英文混排下关键词不至于贴着引用被漏掉,也不会扫得过远误伤。
7.6.2 三轮扫描的 findAllIssueReferencesBytes
文本解析的主函数 findAllIssueReferencesBytes 对同一段字节流跑三轮:
- 数字引用轮:
#N、!N、外部 trackerABC-1; - 跨仓引用轮:
owner/repo#N、owner/repo@sha; - 链接来源轮:从 Markdown 链接的
href中提取完整 issue URL,调用convertFullHTMLReferencesToShortRefs把 URL 原地改写为owner/repo#N短引用(这样即使用户贴的是完整链接,后续处理与短引用同构),并在此轮做关键词回溯与timeLog归并——@1.5h工时标记吸附到最近的引用上。
FindAllMentionsMarkdown 则先 mdstripper.StripMarkdownBytes 去掉 Markdown 结构再找 mention,避免代码块里的 @user 被误判(代码示例中的 email、装饰符不产生通知)。IsXrefActionable 提供一个全局闸门:来自外部 issue tracker 的引用不允许自动 close/reopen(外部系统的状态权威在外部)。
7.6.3 issue_xref.go:落库与权限降级
解析出引用只是第一步,把引用变成 comment 行的是 models/issues/issue_xref.go。Issue.AddCrossReferences(创建 issue 时)与 Comment.AddCrossReferences(编辑评论时,仅 Type.HasContentSupport())共用 createCrossReferences,核心是差集计算:
// removeOld=true 时(编辑正文场景):
// 1. 查出旧正文已产生的活跃引用(RefAction != Neutered 的 RefComment)
// 2. 解析新正文得到新引用集合
// 3. 旧有新无 → neuterCrossReferences(把该 RefComment 的 action 置 XRefActionNeutered)
// 4. 新有旧无 → CreateComment(RefRepoID/RefIssueID/RefAction...)
// 5. 双方都有 → 保持原评论,仅在动作变化时更新 RefAction
verifyReferencedIssue 是每条新引用的守门员,逐项核对:
func verifyReferencedIssue(ctx context.Context, doer *user_model.User, issue *Issue, crossReference *crossReferencesContext) {
// 1. 用 (repo_id, index) 精确查目标 issue;查不到 → 只记录原始文本
// 2. 关闭/重开动作仅当来源是 PR 时才允许设置(issue 正文不能关掉别的 issue)
// 3. 目标是外部 tracker 的 !N 引用 → 特判转为普通引用
// 4. 权限:doer 对目标 issue 无读权限 → 降级为 XRefActionNone
// 5. doer 被目标 issue 屏蔽(blocked)→ 降级
// 6. doer 对目标无写权限(想 close 但不能写)→ 降级为只读引用
}
源码注释里专门为"PR 引用同仓 issue"留了一个例外说明:新建 PR 的作者可能对目标 issue 没有显式写权限,但 PR 的合并会经由其他流程复核,因此引用动作的授予采用了比"直接关闭"更细的裁决。降级的最终效果是:引用评论照常显示,但目标 issue 的状态不会被联动修改——可见性与可操作性被分离,这是跨仓引用最重要的安全设计。
PullRequest.ResolveCrossReferences 是另一条链:PR 合并时扫描 ref_action IN (closes, reopens) 的引用评论,对每个目标 issue 取最新一条执行状态联动——一个 PR 关闭多个 issue 时逐个结算。
详情页渲染前还有两步引用后处理(routers/web/repo/issue_view.go):
filterXRefComments:引用来自其他仓库时按当前用户对源仓的CanReadIssuesOrPulls权限过滤,无权则从时间线剔除;combineXRefComments:同一目标 issue 的多条引用评论合并成一条显示,动作取"后来者覆盖"且Neutered永不覆盖存活动作(源码注释:a removed mention never overrides a live one)。
7.6.4 引用生命周期总览
stateDiagram-v2
[*] --> 解析: 正文/评论写入
解析 --> 验证: verifyReferencedIssue
验证 --> 活跃引用: RefAction=None/Closes/Reopens
验证 --> 仅记录: 无权限/被屏蔽/外部tracker
活跃引用 --> 失效: 正文编辑后差集为"旧有新无"
失效 --> [*]: neuterCrossReferences 置 Neutered
活跃引用 --> 结算: PR 合并时 ResolveCrossReferences
结算 --> [*]: 目标 issue 开/关
仅记录 --> [*]
7.7 评论提交、Markdown 渲染与编辑删除
7.7.1 提交链:表单 → 服务 → 通知
评论提交的 web 入口是 routers/web/repo/issue_comment.go 的 NewComment(7.5.2 已摘录其状态切换部分),评论本体的核心调用:
comment, err := issue_service.CreateIssueComment(ctx, issue, ctx.Doer,
commentContent, attachments, ... )
service 层 services/issue/comments.go 的 CreateIssueComment:
func CreateIssueComment(ctx context.Context, issue *issues_model.Issue, doer *user_model.User,
content string, attachments []*repo_model.Attachment, ... ) (*issues_model.Comment, error) {
// blocked 校验与 NewIssue 同源
comment := &issues_model.Comment{
Type: issues_model.CommentTypeComment,
IssueID: issue.ID,
PosterID: doer.ID,
Poster: doer,
Content: content,
ContentVersion: 1,
Attachments: attachments,
}
if err := issues_model.CreateComment(ctx, comment); err != nil {
return nil, err
}
// 解析正文里的 @mention → 收件人 ID 集合
if _, _, err := FindAndUpdateIssueMentions(ctx, issue, doer, content); err != nil {
return nil, err
}
// 重新读取 issue:NumComments 是物化列,必须拿到重算后的值
issue, err := issues_model.GetIssueByID(ctx, issue.ID)
...
notify_service.CreateIssueComment(ctx, issue, doer, comment, mentions)
return comment, nil
}
三个动作的顺序即依赖顺序:先落评论行(CreateComment 内部会触发 UpdateIssueNumComments),再解析 mention(依赖评论已存在才能标记 issue_user.is_mentioned),最后重新 GetIssueByID 刷新 issue.NumComments 再发通知——如果直接复用入参的 issue 对象,通知(邮件、webhook payload)里的评论数就是旧值。这个"读后写通知"的细节是物化列体系下必须付出的代价。
updateCommentInfos 在 CreateComment 事务内把 attachments(UUID 列表)关联到新评论,并 UpdateIssueCols("updated_unix") 让 issue 在列表中按最新活动上浮。
7.7.2 Markdown 渲染链:Parser 注册体系
Gitea 的渲染不是一个函数,而是一个可插拔的管线。modules/markup/renderer.go 定义接口:
type Renderer interface {
// Name returns the name of the renderer
Name() string
// FileNamePatterns returns glob patterns to match against file names
FileNamePatterns() []string
// SanitizerRules returns extra sanitizer rules to allow custom markup
SanitizerRules() sanitizer.SanitizerRules
// Render renders the source bytes to HTML
Render(ctx *RenderContext, w io.Writer, input []byte) error
}
各渲染器在包的 init() 里调用 markup.RegisterRenderer(markdown、CSV、代码高亮、mermaid、org-mode 等),全部注册进一个全局 map,RefreshFileNamePatterns 再由文件名模式生成 fileNameRenderers 三级探测表(basename → *.ext1 → *.ext2.ext1),DetectRendererTypeByFilename 据此决定"渲染这段文本该用哪个渲染器"——评论正文永远走 Markdown 渲染器,而 issue 附件预览、仓库内文件走文件名探测。
真正的渲染发生在 modules/markup/render.go 的 RenderWithRenderer,源码注释直接给出了管道拓扑:
input -> (pw1) -> renderer -> (pw2) -> SanitizeReader -> finalProcessor -> output
用 errgroup 并发串起三段:renderer 写入管道写端、SanitizeReader 读出消毒、PostProcessDefault 做后处理(链接化 #123、@user、commit sha 的二次处理),最后落输出。两个可调开关:
- 外部 renderer 可关闭 sanitizer:来自受信插件的渲染器自带安全保证时跳过消毒;
RenderOptions.EnableHeadingIDGeneration:评论页禁用标题 ID 生成——同一页面多条评论各自的 Markdown 都含## 标题,若都生成id="标题"会产生重复 DOM id,源码注释明确说明了这一动机。
RenderContext 携带 Metas(user/repo/teams 等元数据)与 RenderHelper。评论场景的 helper 由 models/renderhelper/repo_comment.go 的 NewRenderContextRepoComment 构造:
func NewRenderContextRepoComment(ctx context.Context, repo *repo_model.Repository, opts ...RepoCommentOptions) *markup.RenderContext {
helper := &RepoComment{opts: util.OptionalArg(opts)}
rctx := markup.NewRenderContext(ctx)
helper.ctx = rctx
helper.commitChecker = newCommitChecker(ctx, repo) // 供渲染时校验 commit sha 是否存在
if repo != nil {
helper.repoLink = repo.Link()
metas = repo.ComposeCommentMetas(ctx) // user/repo/teams...
} else {
// repo 可为 nil:dashboard 上渲染已删除仓库的 commit message
metas = map[string]string{"markdownNewLineHardBreak": "true", "markupAllowShortIssuePattern": "true"}
}
metas["footnoteContextId"] = helper.opts.FootnoteContextID // 脚注编号防串页
return rctx.WithMetas(metas).WithHelper(helper)
}
commitChecker 让渲染器能在 owner/repo@sha 引用时真实查询 git 对象是否存在;footnoteContextId 解决同一页面多段 Markdown 的脚注编号冲突;repo == nil 分支处理"仓库已删但动态还在"的历史数据——这些边界都在这一个构造函数里收口。
7.7.3 mention 与引用的渲染期处理
渲染时的 @user 链接化在 modules/markup/html_mention.go 的 mentionProcessor:
func mentionProcessor(ctx *RenderContext, node *html.Node) {
start := 0
nodeStop := node.NextSibling
for node != nodeStop {
found, loc := references.FindFirstMentionBytes(...)
if !found { ... continue }
mention := node.Data[loc.Start:loc.End]
orgLowerTeams, checkOrgTeams := ctx.RenderOptions.Metas["teams"] // ",team1,team2,...,team-n,"
if checkOrgTeams && strings.Contains(mention, "/") {
// @org/team:只有 org 相等且 team 在 Metas["teams"] 里才生成团队链接
...
}
mentionedUsername := mention[1:]
if DefaultRenderHelperFuncs != nil && DefaultRenderHelperFuncs.IsUsernameMentionable(ctx, mentionedUsername) {
link := "/:root/" + url.PathEscape(mentionedUsername)
replaceContent(node, loc.Start, loc.End, createLink(ctx, link, mention, ""))
...
} else {
start = loc.End // 不可提及 → 保持纯文本
}
}
}
要点:渲染期与通知期是两次独立的 mention 判定。渲染期用 IsUsernameMentionable 决定是否显示为链接(不可见用户不生成链接);通知期(7.8 节)用 ResolveIssueMentionsByVisibility 决定是否发通知。两者共享 references.FindFirstMentionBytes 正则,保证"看到的"和"通知到的"识别规则一致,但权限裁决各走各的。@org/team 的可渲染条件是 Metas["teams"](由 ComposeCommentMetas 按当前观看者权限展开)包含该 team——同一段正文,不同观看者看到的团队链接可能不同。
代码片段引用(PR 中 #L10-L20、commit 引用)的渲染走 PostProcess 阶段的处理器族(html_link.go、html_issue.go、html_commit.go 等),它们与 mention 同属 PostProcess 管道,在 Sanitize 之后执行,产出的链接同样经 ResolveLink(RepoComment.ResolveLink)解析为相对/根路径。
7.7.4 详情页的逐类型渲染
链接化只是"正文内部"的渲染;时间线上每条评论整体如何渲染,由 routers/web/repo/issue_view.go 的 prepareIssueViewCommentsAndSidebarParticipants 决定,它按 CommentType 分支处理(节选其分支结构):
| 评论类型 | 渲染动作 | |
|---|---|---|
Comment/Review | Markdown 渲染正文 + roleDescriptor 计算发帖角色 + 加入 participants | |
Label | LoadLabel 载入标签对象;与 combineLabelComments 合并相邻标签事件 | |
Milestone | LoadMilestone;里程碑被删后用幽灵占位 repo.issues.deleted_milestone | |
Project | 幽灵项目 ghostProject 占位(项目已删) | |
Assignees/ReviewRequest | LoadAssigneeUserAndTeam(支持 team 负责人) | |
Dependency | LoadDepIssueDetails 载入被依赖 issue 摘要 | |
HasContentSupport() 类型 | 走 Markdown 渲染 + LoadReview + LoadResolveDoer | |
PullRequestPush | LoadCommentPushCommits 展开推送的 commit 列表 | |
时间类(StartTracking 等) | `Content[0] != ' | '老格式直接 Sanitize;新格式截掉|` 前缀把秒数交给前端 |
"幽灵占位"(ghost)是一致性策略:关联对象(里程碑/项目)被删除后,历史评论若直接报错或消失会破坏时间线的可读性,于是渲染一个带删除标记的占位对象。combineLabelComments 则是显示层的事件压缩:同一用户 60 秒内的连续标签增删合并为一条(cur.CreatedUnix-prev.CreatedUnix >= 60 则不合并),且在合并时相互抵消——先加后删同一标签的两条事件合并后互相归零,不产生噪音。
roleDescriptor(issue_view.go 顶部)负责给每条评论的发帖者打上角色徽章:RoleRepoOwner(真实仓库管理员,site admin 还要 IsUserRealRepoAdmin 复核)、RoleRepoMember(组织成员)、RoleRepoCollaborator、RoleRepoContributor(有过合并 PR)、RoleRepoFirstTimeContributor(首次提 PR,仅在第一个 PR 上显示)、IsPoster(issue 作者)。查询结果缓存在 permsCache map[int64]Permission,避免时间线上百条评论对同一用户重复查权限。
7.7.5 编辑与删除策略
编辑评论的 web 入口 UpdateCommentContent → service UpdateComment(services/issue/comments.go):
func UpdateComment(ctx context.Context, comment *issues_model.Comment, doer *user_model.User,
edit bool, content string) (*issues_model.Comment, bool, error) {
needsContentHistory := content != comment.Content && comment.Type.HasContentSupport()
if needsContentHistory {
// 编辑前先保存旧内容到 ContentHistory(首条历史)
if err := issues_model.CreateCommentContentHistory(ctx, comment, ...); err != nil { ... }
}
// 条件更新 content_version,冲突返回 ErrCommentAlreadyChanged
if err := issues_model.UpdateComment(ctx, comment, comment.ContentVersion); err != nil { ... }
if needsContentHistory {
// 更新后再存一条新内容历史,形成可回溯链
...
}
// 事务外:重新解析 mention(新增的 @user 也发通知)、notify_service.IssueCommentEdited
...
}
- 双历史策略:编辑前存旧内容、编辑后存新内容,
ContentHistory表因此可以完整重建"初版 → 每次编辑"的链条(详情页的 edit history 展示); HasContentSupport()守卫:改标签事件没有正文,不产生历史;- 乐观并发:
content_version不匹配即ErrCommentAlreadyChanged,前端提示"评论已被他人修改"; - mention 重新解析:编辑正文新增的
@user同样触发通知,删除的不回收已发通知(通知系统不支持撤回,这是普遍取舍)。
删除评论 DeleteComment(service 层)包事务调用 7.3.4 的模型层函数,随后 notify_service.IssueCommentDeleted。权限模型是"poster 或仓库管理员",routers/web/repo/issue_comment.go 的 DeleteComment handler 在进 service 前完成判定;锁定的 issue(IsLocked)禁止任何评论编辑/删除,判定发生在 GetActionIssue 之后的统一守卫里。
7.7.6 评论分页
详情页不对评论做服务端分页——ViewIssue 一次性 FindComments 拉全量(7.2.2 的 issue.Comments != nil 缓存),前端折叠渲染。分页只发生在列表类接口:
- Web 侧:
issue_view.go用不到分页;API 侧ListIssueComments支持since/before游标(按created_unix过滤); - 模型层查询
FindCommentsOptions.ToConds+FindComments的排序固定为:
// Ascending by created time, then by id to keep a stable order
sess.Asc("comment.created_unix").Asc("comment.id")
源码在此处留有 WARNING 注释:comment.created_unix 与 createCodeComment 的时序存在已知的边界问题(代码评审评论的创建时间可能与展示顺序不完全一致),排序键加 id 二级排序正是为了兜底稳定性。
7.8 Reaction、附件、订阅与 @mention 收件人
7.8.1 Reaction:五列联合唯一与哨兵查询
models/issues/reaction.go 的结构体:
type Reaction struct {
ID int64 `xorm:"pk autoincr"`
Type string `xorm:"varchar(255) UNIQUE(s)"`
IssueID int64 `xorm:"INDEX UNIQUE(s)"`
CommentID int64 `xorm:"INDEX UNIQUE(s)"`
UserID int64 `xorm:"INDEX UNIQUE(s)"`
OriginalAuthorID int64 `xorm:"UNIQUE(s)"`
OriginalAuthor string `xorm:"varchar(255)"`
}
UNIQUE(s) 让 (type, issue_id, comment_id, user_id, original_author_id) 五列联合唯一——同一个人对同一目标只能有一种同款表情,重复点击 CreateReaction 返回 ErrReactionAlreadyExist(前端表现为"取消选中"的幂等语义)。CreateReaction 还要先校验 setting.UI.ReactionsLookup:管理员关掉的 emoji 不能被 API 硬塞进来。
查询侧的 FindReactionsOptions.toConds 用 CommentID 做三态哨兵,这是 7.1.2 提到的写法:
| CommentID 取值 | 语义 |
|---|---|
> 0 | 查该评论的表情 |
0 | 不按评论过滤(全表条件) |
-1 | 显式查 issue 级表情(comment_id = 0) |
FindReactions 额外加 In("reaction.type", setting.UI.Reactions) 过滤——历史数据里可能残留已被管理员移除的 emoji,读取时裁剪保证前端永远只拿到可渲染集合。ReactionList 的 GroupByType + LoadUsers 把散行折叠成 {type → [users]} 供模板渲染,LoadUsers 对迁移用户与 GhostUser 做了兼容(原始作者字段优先)。
Issue 级与评论级表情复用同一张表(issue_id 固定 + comment_id=0,或 comment_id>0),loadReactions 依据 CommentID == 0 决定挂到 issue.Reactions 还是 comment.Reactions。Web handler 在 routers/web/repo/issue_comment.go 的 ChangeCommentReaction 与 issue.go 的 ChangeIssueReaction,API 侧各有三个(GET/POST/DELETE,见 7.12 表)。
7.8.2 附件:上传流水线与防重链校验
上传入口 services/attachment/attachment.go:
func UploadAttachmentForIssue(ctx context.Context, uploader *user_model.User, repo *repo_model.Repository,
fileContent io.Reader, uuid string) (*repo_model.Attachment, error) {
// 1. setting.Attachment.AllowedTypes / MaxSize<<20 白名单与配额
...
return uploadAttachment(ctx, uploader, repo, fileContent, uuid, repo.AttachmentUUID)
}
func uploadAttachment(...) (*repo_model.Attachment, error) {
// 2. 读前 1024 字节 sniff → upload.Verify 识别真实 MIME(防改扩展名伪装)
// 3. 大小二次校验,超限返回 MaxBytesError → ErrContentTooLarge
// 4. NewAttachment:uuid.New 生成文件名 → storage.Attachments.Save → db.Insert(同一事务)
}
安全要点是内容嗅探:仅信扩展名允许 evil.html 改名 evil.png 混入,upload.Verify 读取魔数判定真实类型后与 AllowedTypes 白名单比对。存储与数据库行在 NewAttachment 的同一事务/顺序写入(与 7.5.3 的"先库后文件"一致)。
关联到 issue 时有专门的 validateAttachmentForIssue(models/issues/issue_update.go,UpdateIssueAttachments 内调用),它解决的是提交时 UUID 被偷换的攻击面:
func validateAttachmentForIssue(ctx context.Context, issue *Issue, attachment *repo_model.Attachment) error {
// 情形 1:attachment 已属于别的 issue/comment/release → 拒绝(防跨实体重链)
// 情形 2:attachment 属于本仓但尚未关联任何对象(legacy 无 repo_id 的老数据
// 用 LegacyAttachmentMissingRepoIDCutoff 判定)→ "收养",回填 repo_id
// 情形 3:attachment 属于其他仓库 → 拒绝
}
没有这层校验,攻击者可以拿 A 仓上传得到的 UUID 在 B 仓的 issue 提交里引用,把 A 仓私有附件"挂"进 B 仓时间线。校验以 (repo_id, uuid) 归属裁决,跨仓即拒绝。附件与 issue 的关联路径有两条:直接 attachment.issue_id(issue 描述区)与经 comment_id(评论区),issue_view.go 渲染时分别读取。
7.8.3 订阅:IssueWatch 的三层回退
订阅表的真实名确认是 IssueWatch(models/issues/issue_watch.go),结构极简:
type IssueWatch struct {
UserID int64 `xorm:"pk"`
IssueID int64 `xorm:"pk"`
IsWatching bool `xorm:"NOT NULL"`
// UNIQUE(watch): (user_id, issue_id)
}
CreateOrUpdateIssueWatch 的语义是 upsert:行存在则更新 IsWatching,不存在则插入——"显式退订"也是插一行 IsWatching=false,而不是删行(删除会丢失"用户主动退订"这一事实,退回仓库级默认订阅)。
真正体现设计的是 CheckIssueWatch 的三层回退:
func CheckIssueWatch(ctx context.Context, user *user_model.User, issue *Issue) (err error) {
// 第 1 层:issue 级显式记录 → 直接采信
watch, has, err := GetIssueWatch(ctx, user.ID, issue.ID)
if has { issue.IsWatched = watch.IsWatching; return err }
// 第 2 层:仓库级订阅模式
// WatchModeNormal → 订阅(含 IncludeIssues 开关)
// WatchModeAuto → 自动订阅(参与才转正)
// WatchModeNever → 不订阅
...
// 第 3 层:都没有记录 → 判定是否为参与者(poster / 评论者 / 被 mention / 负责人)
issue.IsWatched, err = IsUserParticipantsOfIssue(ctx, user, issue)
}
三层的优先级即用户意图的优先级:显式 > 仓库级 > 参与推断。GetIssueWatchersIDs 取订阅者列表时不 join user 表(源码刻意如此)——拿 ID 列表后由通知层按渠道需要各自加载,避免模型层绑定用户查询。API 侧的 CheckIssueSubscription/AddIssueSubscription/DelIssueSubscription 对应到这套 upsert。
7.8.4 @mention 收件人计算
收件人计算的入口在 models/issues/issue_update.go 的 FindAndUpdateIssueMentions,核心是 ResolveIssueMentionsByVisibility,其判定链(service/model 合作):
// 输入:content 文本 + doer + issue 所属 repo
// 1. references.FindAllMentionsMarkdown 解析出候选用户名/@org/team
// 2. doer 自动加入收件人(自己发的帖也进通知流)
// 3. @org/team 展开:
// - team 必须属于本 issue 所在 org(跨 org 的 team 名不解析)
// - team 的 Unit 启用 TypeIssues 或 TypePullRequests(没开 issue 单元的团队不收)
// - 展开 team 成员逐人过权限
// 4. 普通用户:
// - GetIndividualUserRepoPermission → CanReadIssuesOrPulls(isPull)
// - 被 doer 或被 issue 屏蔽(blocked)→ 剔除
// 5. 输出:uids → UpdateIssueUsersByMentions 标记 issue_user.is_mentioned
// → 返回给 notify_service 作为通知收件人
UpdateIssueUsersByMentions(models/issues/issue_user.go)是 upsert 式标记:issue_user 行已存在就 Cols("is_mentioned") 更新,否则插入新行——所以 7.4.2 说 NewIssueUsers 的预插行只是占位,mention 可以独立补写。GetIssueMentionIDs 供后续查询"所有被 mention 过的人"(筛选器 applyMentionedCondition 依赖 issue_user.is_mentioned,见 7.11)。
权限一致性:渲染期(7.7.3)与通知期对"这个 mention 算不算数"的判定都落到 CanReadIssuesOrPulls 与屏蔽检查,保证"链接显示给谁"与"通知发给谁"不出现交叉矛盾——私有仓里无权用户既看不到链接也不被打扰。
7.8.5 订阅与通知的数据流
flowchart LR
A[正文/评论写入] --> B[FindAndUpdateIssueMentions<br/>解析 @mention]
A --> C[notify_service.CreateIssueComment]
B --> C
C --> D{CheckIssueWatch 三层回退}
D -->|显式订阅| E[IssueWatch.IsWatching=true]
D -->|仓库级 Normal/Auto| F[repo_watch 决策]
D -->|无记录| G[参与者判定]
E & F & G --> H[收件人 UID 集]
H --> I[站内 Notification]
H --> J[邮件 MailSubscription]
H --> K[Webhook / 移动推送]
7.9 标签与里程碑:关联表、互斥标签与物化计数
7.9.1 Label 模型与仓库级/组织级双归属
models/issues/label.go 的结构体:
type Label struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"` // repository ID (0 for org labels)
OrgID int64 `xorm:"INDEX"` // organization ID (0 for repo labels)
Name string `xorm:"varchar(255)"`
Color string `xorm:"varchar(7)"` // #rrggbb
Description string `xorm:"varchar(255)"`
Exclusive bool `xorm:"NOT NULL DEFAULT false"`
ExclusiveOrder int `xorm:"NOT NULL DEFAULT 0"`
NumIssues int `xorm:"NOT NULL DEFAULT 0"` // 物化计数
NumClosedIssues int `xorm:"NOT NULL DEFAULT 0"` // 物化计数
ArchivedUnix int64 `xorm:"NOT NULL DEFAULT 0"`
...
}
- 双归属:
RepoID > 0是仓库标签,OrgID > 0是组织标签(该仓属于此组织时可见),两者互斥使用;Color限长 7 的#rrggbb; Exclusive+ExclusiveOrder:互斥标签组——同组(同 scope)的标签一个 issue 只能挂一个,ExclusiveOrder决定组内展示与切换顺序。ExclusiveScope的规则是"取名字最后一个/之前的前缀":priority/high、priority/low的 scope 都是priority;- 物化计数
NumIssues/NumClosedIssues:标签列表页的"已关闭/总数"角标免聚合查询。
updateLabelCols 是计数重算的统一入口,用两个 SetExpr 子查询而非内存计算:
func updateLabelCols(ctx context.Context, label *Label, cols ...string) error {
// num_issues = (SELECT COUNT(*) FROM issue_label il
// INNER JOIN issue i ON il.issue_id = i.id WHERE il.label_id = ?)
// num_closed_issues = 同上 + i.is_closed = true
...
}
数据库端子查询重算保证任何时候读到的计数都与关联表一致,哪怕中间有过崩溃漏掉 Incr——这是"物化列 + 就地对账"的组合,比纯增量维护多一层自愈。
删除标签 DeleteLabel 的级联有个反直觉细节:
func DeleteLabel(ctx context.Context, label *Label) error {
// 1. 删 label 行
// 2. 删 issue_label 关联行
// 3. UPDATE comment SET label_id = 0 WHERE label_id = label.ID ← 不删评论!
}
历史时间线上的"加标签"事件评论不删除,只是把 comment.label_id 置 0——时间线记录的是"发生过这件事",标签本身没了就渲染成一个不可链接的占位(与 7.7.4 的幽灵里程碑同哲学:历史不可篡改,引用悬空用占位消化)。
查询侧 GetLabelInRepoOrOrgByID 的源码注释还记录了一个安全取舍:"foreign-but-existing label ID indistinguishable from nonexistent"——不提供跨仓枚举 oracle:拿着别仓真实存在的 label ID 来查,与查一个不存在的 ID 返回结果一致,避免用标签查询探测私有仓的存在性。
7.9.2 IssueLabel 关联表与增删语义
models/issues/issue_label.go 的关联结构与写路径:
type IssueLabel struct {
ID int64 `xorm:"pk autoincr"`
IssueID int64 `xorm:"UNIQUE(s) INDEX"` // UNIQUE(s) = (issue_id, label_id)
LabelID int64 `xorm:"UNIQUE(s) INDEX"`
}
newIssueLabel(底层单个插入):插关联行 + 插入一条CommentTypeLabel事件评论(Content: "1") +updateLabelCols重算计数——"加标签产生时间线事件"的三件套在此一处完成;NewIssueLabel(公开入口):先HasIssueLabel幂等短路,再校验 label 属于本仓/本组织(跨仓返回错误),最后RemoveDuplicateExclusiveIssueLabels清理互斥冲突;deleteIssueLabel:count == 0时跳过(幂等),事件评论用Content: "0"标记删除;ReplaceIssueLabels(API 的 PUT 语义):双指针归并现有与目标集合,算出toAdd/toRemove两个差集一次执行,而不是先清空再全加——避免短暂的"标签全消失"状态与多余的事件评论;ClearIssueLabels:清空全部,入口处做权限检查(service 层ClearLabels同样接 notify)。
service 层封装(services/issue/label.go)极薄:AddLabel/AddLabels/RemoveLabel/ReplaceLabels/ClearLabels 各自调用模型函数后派发 notify_service.IssueChangeLabels(ctx, doer, issue, added, removed)——通知的 added/removed 两个列表让 webhook 消费者能区分"变成了这批"与"增加了这批"。RemoveLabel 还包了 db.WithTx 并在事务内复核 CanWriteIssuesOrPulls 权限(权限不足时报 ErrRepoLabelNotExist/ErrOrgLabelNotExist——故意不区分"无权限"与"不存在",避免越权探测标签存在性,与 7.9.1 的 oracle 防护一脉相承)。
7.9.3 Milestone 模型与进度计算
models/issues/milestone.go:
type Milestone struct {
ID int64 `xorm:"pk autoincr"`
RepoID int64 `xorm:"INDEX"`
Name string `xorm:"varchar(255)"`
Content string `xorm:"TEXT"`
IsClosed bool `xorm:"INDEX"`
NumIssues int `xorm:"NOT NULL DEFAULT 0"` // 物化:总 issue 数
NumClosedIssues int `xorm:"NOT NULL DEFAULT 0"` // 物化:已关闭数
NumOpenIssues int `xorm:"-"` // 内存计算 = NumIssues - NumClosed
Completeness int `xorm:"NOT NULL DEFAULT 0"` // 物化:完成百分比 0-100
DeadlineUnix int64
...
IsOverdue bool `xorm:"-"` // AfterLoad 计算
}
三类字段三种维护策略,值得对照阅读:
NumIssues/NumClosedIssues/Completeness落库物化:UpdateMilestoneCounters是唯一重算入口,被 issue 创建/关闭/删除/换里程碑等所有路径调用:
func UpdateMilestoneCounters(ctx context.Context, milestoneID int64) error {
// num_issues = (SELECT COUNT(*) FROM issue WHERE milestone_id = ?)
// num_closed_issues = (SELECT COUNT(*) FROM issue WHERE milestone_id = ? AND is_closed)
// completeness 原始 SQL:
// UPDATE milestone SET completeness =
// (CASE WHEN is_closed AND num_issues = 0 THEN 100
// ELSE 100 * num_closed_issues /
// (CASE WHEN num_issues > 0 THEN num_issues ELSE 1 END) END)
// WHERE id = ?
}
注意 CASE WHEN is_closed AND num_issues = 0 THEN 100——已关闭的空里程碑显示 100% 而不是除零;除数再套一层 CASE 双保险防 0 除。这些是跨 SQLite/MySQL/Postgres/MSSQL 都能跑的方言中立写法,Gitea 不用 ORM 表达式拼这条,正是因为四库的 CASE 支持与舍入行为需要一条可控的原生 SQL 收口。
NumOpenIssues/IsOverdue用xorm:"-"":AfterLoad里现算(NumIssues - NumClosedIssues、DeadlineUnix < now && !IsClosed),不落库——它们是其他两列的确定性函数,存了反而要同步。BeforeUpdate钩子:任何对 milestone 行的常规 Update 都顺带重算Completeness,兜住遗漏调用UpdateMilestoneCounters的路径。
关联维护:NewIssueWithIndex/SetIssueAsClosed 的 updateIssueNumbers 都会 Incr/UpdateMilestoneCounters;删除 issue 时 deleteIssue 里显式调用;删除里程碑 DeleteMilestoneByRepoID 则是三步——更新 repo.num_milestones、UPDATE issue SET milestone_id = 0(issue 上的外键置 0 而非级联删除 issue)、删里程碑行。被摘掉里程碑的 issue 产生一条 CommentTypeMilestone 事件(渲染层用幽灵里程碑占位,见 7.7.4)。
7.9.4 标签与里程碑的 Web 操作链
两者的 CRUD handler 都在 routers/web/repo/ 下独立文件:issue_label.go(Labels 列表页、NewLabel/UpdateLabel/DeleteLabel 表单、UpdateIssueLabel 给 issue 挂标签——后者解析表单的 label_ids 调 ReplaceLabels)与 issue.go 中的 UpdateIssueMilestone(改 issue 里程碑,写 CommentTypeMilestone 事件并重算计数)。API 侧对应 7.12 表中的 issue_label.go 五个 handler 与 /labels、/milestones 两个路由组。所有写路径遵循同一模式:模型层算差集与计数 → 服务层发 notify → handler 重定向回详情页锚点。
7.10 Issue 模板:目录发现、YAML 表单解析与渲染
7.10.1 模板体系的三个真实坐标
任务描述猜测模板解析在 models/issue_template,经核实该包不存在。真实坐标是三处:
| 层 | 文件 | 职责 |
|---|---|---|
| 发现/加载 | services/issue/template.go | 候选目录遍历、config 读取、默认分支解析 |
| 结构化解析 | modules/issue/template/(template.go、unmarshal.go) | 模板校验、YAML 表单反序列化、RenderToMarkdown |
| handler 接线 | routers/web/repo/issue_new.go | setTemplateIfExists、NewIssuePost 表单覆盖 |
7.10.2 目录发现:8 个候选目录与 4 个 config 候选
services/issue/template.go 的两张候选表:
var templateDirCandidates = []string{
"ISSUE_TEMPLATE",
"issue_template",
".gitea/ISSUE_TEMPLATE",
".gitea/issue_template",
".github/ISSUE_TEMPLATE",
".github/issue_template",
".gitlab/ISSUE_TEMPLATE",
".gitlab/issue_template",
}
var templateConfigCandidates = []string{
".gitea/ISSUE_TEMPLATE/config.yml",
".github/ISSUE_TEMPLATE/config.yml",
// …(.gitea/.github 各自的 config.yaml 变体)
}
设计意图直接写在候选表里:兼容三生态——Gitea 自家目录、GitHub Actions 用户迁移零成本(.github/ISSUE_TEMPLATE)、GitLab 迁移用户同样免改仓库结构。大小写双份(ISSUE_TEMPLATE/issue_template)覆盖不同 OS 文件系统与历史仓库习惯。
ParseTemplatesFromDefaultBranch 的执行流程:
func ParseTemplatesFromDefaultBranch(ctx, repo, gitRepo) (ret ParseTemplateResult) {
// 1. 遍历 8 个候选目录在默认分支的文件树
// 2. 每个文件 template.CouldBe → 粗筛(扩展名/内容嗅探)
// 3. template.UnmarshalFromEntry → 精确解析(出错进 ret.TemplateErrors 不阻断)
// 4. config.yml → GetTemplateConfig(blank_issues_enabled、contact_links)
// 5. 模板的 Ref 字段若无 "refs/" 前缀 → 补 "branches/"(源码注释:
// tags 要显式写 refs/tags/<ref>,裸名一律按分支解释)
}
错误处理策略是"坏模板不拖垮好模板":单个模板解析失败记入 TemplateErrors,页面照常渲染其余模板,handler(issue_new.go 的 renderErrorOfTemplates)把错误列表按文件名排序后塞进 Flash 警告——排序是为了输出稳定(sort.Strings 注释:keep the output stable)。
HasTemplatesOrContactLinks 与 GetTemplateConfigFromDefaultBranch 支撑"是否显示模板选择页"的判断,配合 NewIssue 里的这段路由逻辑:
if !issueConfig.BlankIssuesEnabled && hasTemplates && !templateLoaded {
// 禁用空白 issue 且未指定模板 → 303 转到 /issues/new/choose,
// 并原样携带 project / milestone 查询参数(两个页面共享参数)
ctx.Redirect(fmt.Sprintf("%s/issues/new/choose?%s", ctx.Repo.Repository.Link(), ctx.Req.URL.RawQuery), http.StatusSeeOther)
return
}
NewIssueChooseTemplate 则有对称的反向重定向:无模板时从 choose 页回跳 new 页——两个页面互为镜像,参数透传保证"从项目页带 project 参数来建 issue"的链路在重定向中不丢。
7.10.3 YAML 表单模板:校验与渲染
modules/issue/template/template.go 的 Validate 对 YAML 表单做结构校验(节选规则):
name、about必填;body的每个 field:id全局唯一、label必填、type∈ {markdown,textarea,input,dropdown,checkboxes}、required只对输入类生效、options/default越界检查(default 必须在 options 内);contact_link三字段 + URL 格式校验(config.yml 的外链入口)。
渲染入口 RenderToMarkdown(template, form) 把表单值回填进模板生成最终 Markdown 正文,字段取值约定是 form.Get("form-field-" + ID):
// 每类字段的 WriteTo 输出形态:
// input/textarea → "### <label>\n<value 或 _No response_>"
// dropdown → "### <label>\n<选中项>"(并带 list 属性标注)
// checkboxes → "### <label>\n- [x] 选中项\n- [ ] 未选项"
// markdown → 原文透传
// textarea 中含 ``` 时用 minQuotes 生成 ≥3 反引号的围栏,
// 保证用户内容里的三反引号不会提前闭合围栏
minQuotes 围栏是模板渲染的正确性关键:用户在 textarea 里粘贴了代码块(自带 ```),若围栏固定用 3 个反引号,渲染出的 Markdown 会在错误位置闭合;minQuotes 扫描内容后取"不少于内容内最长反引号串"的围栏长度。
7.10.4 从 query 预填到提交覆盖
模板在两个时机介入:
预填(GET /issues/new):setTemplateIfExists(7.4.1 摘录过核心)中,?template= 参数优先,否则按候选文件顺序尝试 UnmarshalFromCommit;YAML 模板还支持 query 覆盖字段默认值——field:<ID> 形式的 query 参数直接写进 field.Attributes["value"],于是 issues/new?template=bug.yml&field:version=1.2.3 可以生成带预置版本号的 bug 报告页。模板声明的 labels、assignees、ref 同步预选(SetSelectedLabelNames、GetUserIDsByNames 转 ID、Ref 补 refs/heads/ 前缀)。
提交覆盖(POST):NewIssuePost 读表单的 template-file 字段 → UnmarshalFromRepo 重新加载模板(不信任客户端传的内容,只信任文件名)→ RenderToMarkdown(template, ctx.Req.Form) 用提交上来的表单值渲染 → 覆盖 content。安全性在于:模板文件永远从默认分支的工作区读取,客户端只能提供文件名与字段值,无法注入模板结构本身。
7.10.5 模板机制的完整数据流
flowchart TD
A[GET /issues/new] --> B[ParseTemplatesFromDefaultBranch<br/>8 候选目录遍历]
B --> C{template 参数 / 候选文件}
C --> D[UnmarshalFromCommit<br/>ErrNotExist 则跳过]
D --> E[预填: labels/assignees/ref/field:ID]
E --> F[渲染新建页 tplIssueNew]
A -->|无模板且禁用空白 issue| G[/issues/new/choose 303/]
G --> H[NewIssueChooseTemplate 选择页]
I[POST NewIssuePost] --> J[template-file → UnmarshalFromRepo<br/>从默认分支重读, 不信任客户端]
J --> K[RenderToMarkdown<br/>form-field- 回填 + minQuotes 围栏]
K --> L[覆盖 content]
L --> M[issue_service.NewIssue 7.4]
7.11 列表筛选与批量操作的 SQL 生成
7.11.1 IssuesOptions:一个结构体描述所有筛选维度
Issue 列表(issues 与 pulls 共用)的查询参数全部装进 models/issues/issue_search.go 的 IssuesOptions:
type IssuesOptions struct {
RepoIDs []int64 // also used by IssueIDs
RepoCond *db.Cond
AssigneeIDs []int64
PosterIDs []int64
MentionedIDs []int64
ReviewerIDs []int64
ReviewedIDs []int64
MilestoneIDs []int64
ProjectIDs []int64
LabelIDs []int64
LabelIDsIn []int64
IncludedLabelNames []string
ExcludedLabelNames []string
ExcludedAnyLabelNames []string
ParticipatorIDs []int64
IssueIDs []int64
...
Keyword string
IsPull *bool
State StateType // open / closed / all
SortBy SortType // created / updated / priority-asc ...
...
}
(表中省略了 IsDraft、StatsByStates、ExternalTrackerPrefix 等次字段;完整定义以源码为准。)
applyConditions 是总装配器,按固定顺序叠加 WHERE 条件:applyRepoCondition → applyIsPullCondition → 状态 → 各维度 applyXxxCondition。每个维度一个私有函数,彼此正交,新增筛选维度就是新增一个 applyXxx + 在装配器挂一行。
7.11.2 关键筛选的 SQL 生成技巧
标签包含/排除:count(*) = N 子查询(applyLabelsCondition)是全文件最巧的一段:
// 多标签"全部包含":
// issue.id IN (SELECT issue_id FROM issue_label
// WHERE label_id IN (1,2,3)
// GROUP BY issue_id HAVING count(*) = 3)
// 多标签"一个都不能有":
// issue.id NOT IN (SELECT issue_id FROM issue_label WHERE label_id IN (...))
// LabelIDs[0] == 0 表示"无标签"哨兵 → NOT IN (SELECT issue_id FROM issue_label)
// 正数列表 = 必须包含(count = N),负数列表 = 必须排除(count = 0)
用 GROUP BY ... HAVING count(*) = N 表达"同时含全部 N 个标签",避免了 N 个 EXISTS 子查询的叠罗汉;标签名维度(IncludedLabelNames 等)则复用 BuildLabelNamesIssueIDsCondition 走 label 表名字匹配,支持组织级标签按名字引用。
负责人特殊值(applyAssigneeCondition):
// "(none)" → 无负责人:issue.id NOT IN (SELECT issue_id FROM issue_assignees)
// "(any)" → 有负责人:issue.id IN (SELECT ...)
// "123" → 具体用户:join issue_assignees WHERE user_id = 123
"(none)"/"(any)" 这种字符串哨兵直接来自前端 query(?assignee=(none)),模型层原样识别;poster 维度的 applyPosterCondition 同理,但其 "(none)" 分支源码注释坦言是"poster_id = 0 永远为空集"的硬编码(poster_id 不可能为 0,"(none)" 实际返回空结果)。里程碑的 db.NoConditionID = -1 表示"无里程碑"(milestone_id = 0)。
订阅筛选(applySubscribedCondition)把 7.8.3 的三层回退翻译成 SQL:
// issue.id NOT IN (SELECT issue_id FROM issue_watch
// WHERE user_id = ? AND is_watch = false) ← 显式退订排除
// AND issue.id IN (
// SELECT issue_id FROM issue_watch WHERE user_id = ? AND is_watch = true ← 显式订阅
// UNION SELECT id FROM issue WHERE poster_id = ? ← 自己发的
// UNION SELECT issue_id FROM comment WHERE poster_id = ? ← 自己评过
// UNION ... repo watch normal/auto 条件 ...)
一个筛选条件 = 五个 UNION 分支,与 CheckIssueWatch 的回退逻辑严格同构——查询语义与订阅语义共享同一份规则文档,否则会出现"通知发了但筛选器里查不到"的不一致。
已评审/请求评审(applyReviewedCondition/applyReviewRequestedCondition)用 review 表的 MAX(id) 子查询取"最后一次评审状态"再判定,避免同一人多轮评审的旧记录干扰。
7.11.3 排序:scope- 前缀与动态 JOIN
applySorts 除常规 created/updated/nearduedate/farduedate 外,还支持 scope- 前缀的标签分组排序:
// SortBy = "scope-priority" 时:
// LEFT JOIN issue_label il ON il.issue_id = issue.id
// LEFT JOIN label ON label.id = il.label_id AND label.name LIKE 'priority/%'
// ORDER BY COALESCE(label.exclusive_order, 2147483647)
COALESCE(exclusive_order, 2147483647) 让没有该 scope 标签的 issue 排在最后(用 int32 最大值垫底);nearduedate/farduedate 用 CASE WHEN 表达"无截止日期的沉底/浮顶"。这正是 7.9.1 的 ExclusiveOrder 字段的消费方——互斥标签的组内序号同时服务于"切换按钮顺序"和"列表排序"两个场景。
7.11.4 索引器与统计:IssuesOptions 的两次登场
列表页的关键词搜索不走 SQL LIKE,而是双阶段(routers/web/repo/issue_list.go 的 prepareIssueFilterAndList):
// 1. Keyword 非空 → issue_indexer.SearchIssues(Elasticsearch/Meilisearch 或 db_indexer)
// 得到命中 issue ID 集 → 塞进 statsOpts.IssueIDs 与 pageOpts.IssueIDs
// 2. IssuesOptions 进入 GetIssueStats / CountIssues:
// applyConditions 生成 WHERE(ID 集与其他维度 AND 叠加)
// 3. 分页列表:db_indexer.GetIndexer().FindWithIssueOptions(pageOpts)
即索引器只负责"关键词 → ID 集"的召回,所有权限/标签/状态维度的精筛仍由 IssuesOptions 的 SQL 完成——这样即使索引器是外部服务,私有仓越权也不会泄漏(第 5 章的权限条件仍在 SQL 层强制执行)。db_indexer(无外部搜索时的内置实现)与数据库同库查询,FindWithIssueOptions 直接复用 IssuesOptions。
统计侧 issue_stats.go:IssueStats 结构体 8 个计数(Open/Closed × 总/我参与/我创建/我被指派),FilterMode* 常量标识分组维度;MaxQueryParameters = 300 把超长 ID 列表分块累加(一次 IN 最多 300 个,防 SQLite/MySQL 的占位符上限);CountIssuesByRepo 用 GROUP BY repo_id 一次拿多仓计数(仪表盘场景)。
7.11.5 批量操作
Web 列表页的批量入口在 routers/web/repo/issue_list.go:
BatchDeleteIssues:解析id数组 → 逐个GetActionIssue级权限校验 →issue_service.DeleteIssue循环执行,任何单个失败中止并报错(删除不可回滚,宁停勿混);UpdateIssueStatus(action=open|close批量开关):遍历 index,IsPull && HasMerged的 PR 跳过,CloseIssue返回ErrDependenciesLeft时收集进失败列表,最后ctx.JSONError一次性返回成功数与失败原因——部分成功语义,与单条路径(7.5.2)不同;- 服务端不为批量操作提供独立事务:每个 issue 一个事务(
CloseIssue自带WithTx),牺牲原子性换取"批量里坏一个不影响其他"——对用户而言批量开关 100 个 issue,其中 3 个有未清依赖,期望的是 97 成功 + 3 报告,而不是全部回滚。
API 侧没有"批量"端点(每个 issue 独立 PATCH/DELETE),批量语义由调用方循环实现——这是刻意的 REST 设计:批量操作的部分成功、逐项错误码无法在单个 REST 资源上优雅表达,与其发明私有协议,不如让客户端显式循环。
7.12 web 与 API 两侧 handler 入口总表
7.12.1 Web 侧:routers/web/repo/issue*.go(19 个文件)
先看路由注册的组织方式(routers/web/web.go 的 addIssuesPullsViewRoutes 与主注册块,{type:issues|pulls} 是同一组 handler 的双路由):
路由(/:owner/:repo/{type}/...) | 方法 | handler(文件.函数) | 权限守卫 | |
|---|---|---|---|---|
"" | GET | issue_list.Issues | 读组 reqRepoIssuesOrPullsReader | |
/{index} | GET | issue_view.ViewIssue | 读组(含 PR 重定向) | |
/title | POST | issue.UpdateIssueTitle | 写组 | |
/content | POST | issue.UpdateIssueContent | 写组 | |
/deadline | POST | issue.UpdateIssueDeadline | 写组 | |
/watch | POST | issue_watch.IssueWatch | 登录 | |
/ref | POST | issue.UpdateIssueRef | 写组 | |
/pin | POST | issue_pin.PinIssue | 管理 | |
/comments | POST | issue_comment.NewComment | MustAllowUserComment + 绑定 CreateCommentForm | |
/comments/{id} | POST | issue_comment.UpdateCommentContent | poster/管理 | |
/comments/{id}/delete | POST | issue_comment.DeleteComment | poster/管理 | |
/comments/{id}/reactions/{action} | POST | issue_comment.ChangeCommentReaction | 登录 | |
/reactions/{action} | POST | issue.ChangeIssueReaction | 登录 | |
/attachments | POST | upload 附件处理 | 写组 + setting.Attachment | |
/labels | POST | issue_label.UpdateIssueLabel | 写组 | |
/milestone | POST | issue.UpdateIssueMilestone | 写组 | |
/projects | POST | issue.UpdateIssueProject | 项目读写 | |
/assignee | POST | issue.UpdateIssueAssignee | 写组 | |
/status | POST | issue_list.UpdateIssueStatus | 写组 | |
/lock /unlock | POST | issue_lock.LockIssue/UnlockIssue | 写组/管理 | |
/delete | POST | issue_new.DeleteIssue | 管理 | |
/time_estimate 等 | POST | issue_timetrack.* | 写组 | |
| `/stopwatch/start | stop` | POST | issue_stopwatch.* | 登录 |
/resolve_conversation | POST | issue_comment.ResolveConversation | 写组 |
写组/读组的完整守卫是 reqRepoIssuesOrPullsReader/reqRepoIssuesOrPullsWriter(web.go 顶层常量),内部按 {type:issues|pulls} 切换单元类型(unit.TypeIssues vs unit.TypePullRequests)——同一段代码同时服务 issues 与 pulls 两个路由树,这是 Gitea 路由的一大复用手法(第 3 章路由篇已见其他例子)。
其余独立 handler 文件的职责速查(函数名取自源码 grep 结果):
| 文件 | 关键 handler | 职责 |
|---|---|---|
issue.go | MustAllowUserComment、MustEnableIssues、GetActionIssue、GetIssueInfo、UpdateIssueTitle/Ref/Content/Deadline/Milestone/Assignee、ChangeIssueReaction、GetIssueAttachments | 单字段编辑与通用守卫;GetActionIssue 是所有写操作的公共取号+权限入口 |
issue_view.go | ViewIssue、ViewPullMergeBox | 详情页组装(7.7.4 的渲染大循环所在) |
issue_list.go | Issues、SearchIssues、prepareIssueFilterAndList、BatchDeleteIssues、UpdateIssueStatus | 列表与批量(7.11) |
issue_new.go | NewIssue、NewIssuePost、NewIssueChooseTemplate、DeleteIssue、ValidateRepoMetasForNewIssue | 创建与模板(7.4、7.10) |
issue_label.go | Labels、NewLabel、UpdateLabel、DeleteLabel、UpdateIssueLabel | 标签 CRUD 与挂载 |
issue_watch.go | IssueWatch | 订阅开关 |
issue_dependency.go | 依赖关系增删查 | issue_dependencies 操作 |
issue_content_history.go | 评论编辑历史查看 | ContentHistory 读取 |
issue_pin.go / issue_lock.go | 置顶 / 锁定 | 管理员操作 |
issue_timetrack.go / issue_stopwatch.go | 工时登记 / 秒表 | TrackedTime / Stopwatch |
issue_suggestions.go | IssueSuggestions | 标题相似度建议(建 issue 时提示"可能已存在") |
issue_poster.go | poster 权限判定辅助 | 删除/编辑的 poster-or-admin 判定 |
issue_page_meta.go | retrieveRepoIssueMetaData | 详情/列表页侧栏元数据(标签/里程碑/负责人候选集) |
7.12.2 API 侧:routers/api/v1/repo/issue*.go(13 个文件、69 个 handler)
API 路由注册在 routers/api/v1/api.go(tokenRequiresScopes(AccessTokenScopeCategoryIssue) 统一要求 issue scope),核心组结构:
// Issue (requires issue scope)
m.Group("/repos", func() {
m.Get("/issues/search", repo.SearchIssues)
m.Group("/{username}/{reponame}", func() {
m.Group("/issues", func() {
m.Combo("").Get(repo.ListIssues).
Post(reqToken(), mustNotBeArchived, bind(api.CreateIssueOption{}),
reqRepoReader(unit.TypeIssues), repo.CreateIssue)
m.Get("/pinned", reqRepoReader(unit.TypeIssues), repo.ListPinnedIssues)
m.Group("/comments", ...) // 评论列表/编辑/删除/表情/附件
m.Group("/{index}", func() {
m.Combo("").Get(repo.GetIssue).
Patch(reqToken(), bind(api.EditIssueOption{}), repo.EditIssue).
Delete(reqToken(), reqAdmin(), context.ReferencesGitRepo(), repo.DeleteIssue)
m.Combo("/assignees")... // 负责人增删查
m.Group("/comments", ...) // ListIssueComments/CreateIssueComment/...
m.Get("/timeline", repo.ListIssueCommentsAndTimeline)
m.Group("/labels", ...) // List/Add(POST)/Replace(PUT)/Clear(DELETE)/Delete
m.Group("/times", ...) // 工时
m.Combo("/deadline")... // 截止日期
m.Group("/stopwatch", ...) // 秒表 start/stop/delete
m.Group("/subscriptions", ...) // Get/Check/Add/Del 订阅
m.Combo("/reactions")... // 表情 GET/POST/DELETE
m.Group("/assets", ...) // 附件 CRUD (mustEnableAttachments)
m.Combo("/dependencies")... // 依赖/blocks
m.Group("/pin", ...) // 置顶 (reqAdmin)
m.Group("/lock", ...) // 锁定 (reqAdmin)
})
}, mustEnableIssuesOrPulls)
m.Group("/labels", ...) // 标签 CRUD(仓级)
m.Group("/milestones", ...) // 里程碑 CRUD(仓级)
m.Group("/projects", ...) // 项目(略,见项目章)
}, repoAssignment(), checkTokenPublicOnly())
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryIssue))
按文件汇总的 handler 入口表(函数名经 grep 核实):
| API 文件 | handler | 职责 |
|---|---|---|
issue.go | SearchIssues、ListIssues、GetIssue、CreateIssue、EditIssue、DeleteIssue、UpdateIssueDeadline | 主资源 CRUD;DeleteIssue 需 reqAdmin + ReferencesGitRepo |
issue_comment.go | ListIssueComments、CreateIssueComment、GetIssueComment、EditIssueComment、EditIssueCommentDeprecated、DeleteIssueComment、DeleteIssueCommentDeprecated、ListRepoIssueComments、ListIssueCommentsAndTimeline、ResolveConversation | 评论读写;Deprecated 对是旧 URL 形态的兼容别名 |
issue_label.go | ListIssueLabels、AddIssueLabels、ReplaceIssueLabels、DeleteIssueLabel、ClearIssueLabels | 与 web 侧同模型(7.9.2),PUT=差集替换 |
issue_reaction.go | GetIssueReactions、PostIssueReaction、DeleteIssueReaction、GetIssueCommentReactions、PostIssueCommentReaction、DeleteIssueCommentReactions | 表情六端点(7.8.1) |
issue_subscription.go | GetIssueSubscribers、CheckIssueSubscription、AddIssueSubscription、DelIssueSubscription | 订阅(7.8.3),Check 是 GET 返回布尔而非 HEAD |
issue_pin.go | ListPinnedIssues、PinIssue、UnpinIssue、MoveIssuePin、PinIssueToTop 等 7 个 | 置顶(reqAdmin) |
issue_dependency.go | GetIssueDependencies、CreateIssueDependency、RemoveIssueDependency、GetIssueBlocks、CreateIssueBlocking、RemoveIssueBlocking | 依赖/被依赖双向端点 |
issue_assignee.go | AddIssueAssignees、DeleteIssueAssignees、CheckIssueAssignee | 负责人(GitHub 语义:传全集替换,7.9 相关) |
issue_attachment.go | ListIssueAttachments、CreateIssueAttachment、GetIssueAttachment、EditIssueAttachment、DeleteIssueAttachment | issue 级附件 |
issue_comment_attachment.go | 同构 5 个 | 评论级附件 |
issue_tracked_time.go | ListTrackedTimes、AddTime、DeleteTime、ResetIssueTime 等 7 个 | 工时 |
issue_stopwatch.go | StartIssueStopwatch、StopIssueStopwatch、DeleteIssueStopwatch 等 4 个 | 秒表 |
issue_lock.go | LockIssue、UnlockIssue | 锁定(reqAdmin + LockIssueOption) |
另有两个模板相关端点挂在 code 单元下:repo.GetIssueTemplates(列出模板)、repo.GetIssueConfig/ValidateIssueConfig(config.yml 读取与校验)。
两侧对照的三个差异值得记住:
- 错误语义:web 侧用
ctx.JSONRedirect/Flash面向浏览器流程;API 侧一律ctx.APIError(http.StatusXxx, ...),状态码语义严格(403 无权限、404 不存在/无权限不区分、409 版本冲突对应ErrIssueAlreadyChanged); - 权限粒度:API 的写操作统一
reqToken()(token scope 必须含 issue 类别)+reqRepoReader/Writer(unit.TypeIssues),删除额外reqAdmin();web 侧则是reqRepoIssuesOrPullsWriter+ 单条路径上的 poster 例外(本人可编辑自己的评论); - 批量:web 有
BatchDeleteIssues/UpdateIssueStatus,API 没有对应端点(7.11.5)。
7.12.3 关闭 Issue 的端到端时序
把 7.5 的各层串起来,以"用户在详情页点击 Close"收束全章:
sequenceDiagram
participant U as 用户
participant H as issue_comment.NewComment
participant S as services/issue.CloseIssue
participant M as models/issues.SetIssueAsClosed
participant DB as 数据库
participant N as notify_service
participant W as webhook/邮件订阅者
U->>H: POST /issues/N (status=close, content 可选)
H->>H: GetActionIssue → 权限(CanWrite 或 poster) → locked 检查
H->>S: CloseIssue(issue, doer)
S->>S: db.WithTx 开启事务
S->>M: CloseIssue → SetIssueAsClosed
M->>M: IsClosed 已关? → ErrIssueIsClosed
M->>DB: SELECT 未关闭的 blocker 依赖
DB-->>M: 有未关闭 blocker
M-->>S: ErrDependenciesLeft
S->>S: 仍执行 FinishIssueStopwatch (停表)
S-->>H: 返回 ErrDependenciesLeft
H-->>U: Flash 提示依赖未清 / JSONError 批量路径
Note over M,DB: ——— 正常路径 ———
M->>DB: UPDATE issue SET is_closed=true WHERE id=? AND is_closed=false
DB-->>M: affected = 1 (0 则 ErrIssueAlreadyChanged)
M->>DB: UPDATE label/milestone/repo 物化计数
M->>DB: INSERT comment (type=close)
S->>S: FinishIssueStopwatch (若在计时)
S->>DB: COMMIT
S->>N: IssueChangeStatus(isClosed=true) + StopwatchChanged (事务外)
N->>W: webhook 事件 / 邮件 / 站内 Notification
H-->>U: JSONRedirect → /issues/N#issuecomment-M
时序图里最值得咀嚼的是错误路径与正常路径的分叉点:ErrDependenciesLeft 是唯一"失败了也要继续做点什么"的错误(停表),而 ErrIssueAlreadyChanged 是纯粹的并发裁决(谁改到是谁的,后到者拿 409/Flash 提示)。这两个错误在 service 层被区别对待,正是 7.5.1"三道关"设计在调用侧的回响。
7.12.4 源码级问答:容易答错的十一个问题
在收束之前,用一组基于真实源码的问答扫掉最常见的误解。每题的答案都能在前文标注的文件里复核。
Q1:Issue 的编号是 issue 表上的自增列吗?
不是。数据库列是 issue.index(Go 字段 Index),它由独立的 issue_index 资源序号表按 repo.ID 分组发放(db.GetNextResourceIndex(ctx, "issue_index", repo.ID),7.2.3),必须在插入 issue 的同一事务内执行。UNIQUE(repo_index) 复合唯一只是第二道防线;删除 issue 后编号不回收,RecalculateIssueIndexForRepo 只把序号表同步到 MAX(index) 防止回退。
Q2:关闭一个 Issue 需要加行锁吗?
不需要。SetIssueAsClosed 用 Where("is_closed = ?", false) 条件更新并检查 affected == 1(7.5.1):两个并发请求都通过 issue.IsClosed 内存检查时,数据库只会让一个的 UPDATE 命中,另一个 affected=0 拿到 ErrIssueAlreadyChanged。与 content_version 编辑乐观锁是同一模式——以条件更新代替 SELECT FOR UPDATE,避免长事务持锁。
Q3:issue.num_comments 包含关闭事件、改标签事件吗?
不包含。ConversationCountedCommentType() 只返回 []CommentType{CommentTypeComment, CommentTypeReview}(7.3.2),UpdateIssueNumCommentsBuilder 的子查询 WHERE type IN (...) 只数这两类。NumComments 是"对话评论数"而非"时间线事件数",两者是不同的口径。
Q4:编辑正文后,之前 closes #5 产生的引用评论会怎样?
不会被删除,而是被"绝育"(neuter):createCrossReferences 对旧活跃引用做差集,"旧有新无"的调用 neuteraCrossReferences 把该 RefComment.RefAction 置为 XRefActionNeutered(7.6.3)。评论行保留(审计需要),但状态联动失效;详情页 combineXRefComments 里 Neutered 永远不覆盖仍存活的动作——显示层也遵守"删除的引用不压过有效的引用"。
Q5:删除一个 Label,历史时间线里它的"加标签"评论会消失吗?
不会。DeleteLabel 只做三件事:删 label 行、删 issue_label 关联、UPDATE comment SET label_id = 0(7.9.1)。事件评论保留、外键指向清零,渲染层展示为不可链接的占位。历史事件不可篡改,悬空引用用占位消化——与幽灵里程碑(repo.issues.deleted_milestone)同一哲学。
Q6:用户显式退订一个 issue 后,issue_watch 表里是删行还是留行?
留行且 IsWatching = false。CreateOrUpdateIssueWatch 是 upsert 语义(7.8.3);删行会丢失"用户主动退订"这一事实,导致 CheckIssueWatch 回退到第 2/3 层(仓库级默认或参与推断),把用户重新订阅回来——退订必须是显式的负记录才能压过下层默认。
Q7:web 列表页的关键词搜索是 SQL LIKE 吗?
不是。prepareIssueFilterAndList 先调 issue_indexer.SearchIssues 得到命中 ID 集,再把 ID 塞进 IssuesOptions.IssueIDs 让所有维度(含权限条件)在 SQL 层叠加精筛(7.11.4)。索引器只做召回,权限与状态过滤永远在数据库查询里强制执行,外部搜索引擎因此无法成为越权泄漏点。
Q8:新建 Issue 时传了一个不存在/别仓的 label ID,会发生什么?
整个创建不会失败。handler 的 ValidateRepoMetasForNewIssue 先用候选集过滤(7.4.1),漏网的到 NewIssueWithIndex 里 newIssueLabel 报错时也只是跳过该标签继续(7.4.2)。取舍是"过期页面提交不应导致 issue 创建失败"——用户拿到 issue,只是少了一个标签。
Q9:CommentTypeProjectBoard 对应哪个事件?
对应项目列(column)变更事件,不是"板"。源码在该常量旁留有 FIXME 注明命名沿革——commentStrings 字符串映射也保持旧名以兼容历史数据。这是"以源码为准"的又一例:按名字望文生义会得出错误结论,实际语义要看渲染分支(issue_view.go 中它与 Project 类型并列处理)。
Q10:新建评论时 ContentVersion 初始化为几?
为 1(services/issue/comments.go 的 CreateIssueComment 构造 Comment 时显式 ContentVersion: 1)。首次编辑走 UpdateComment(ctx, comment, comment.ContentVersion) → 更新为 contentVersion + 1 = 2 并以 WHERE content_version = 1 为条件;若期间他人已编辑(版本已变 2),条件不匹配 affected=0,返回 ErrCommentAlreadyChanged。版本号从 1 起步而非 0,配合 ContentVersion NOT NULL DEFAULT 0 的列默认值——0 留给"从未编辑过的系统生成行",形成可区分的初值约定。
Q11:置顶(pin)在模型层是怎么存的?
独立的 IssuePin 表,deleteIssue 的级联清单里有 &IssuePin{IssueID: issue.ID}(7.5.3),Issue 结构体上的 PinOrder 是 xorm:"-" 内存字段、由 LoadAttributes 装载。置顶因此不污染 issue 行本身(is_pinned 布尔 + 排序位放同一行会让移动置顶序号变成全表更新),单独一张 (issue_id, pin_order) 关系表让 MoveIssuePin(API PATCH /pin/{position}、web MoveIssuePin)只动两行。权限上置顶是 reqAdmin——它是仓库管理动作而非普通协作动作,两侧路由注册(7.12.1、7.12.2)都把它放在管理员守卫组里。
7.12.5 本章收束:Issue 体系的设计主线
回看全章,三条设计主线贯穿始终:
- 物化计数 + 就地对账:
repo.num_*、milestone.num_*、label.num_issues、issue.num_comments全部预计算,由Incr/Decr增量维护、由UpdateMilestoneCounters/updateLabelCols子查询重算兜底——读路径永远免聚合,写路径多付一次 UPDATE,崩溃后可自愈; - 条件更新式乐观并发:关闭用
WHERE is_closed=false、编辑用WHERE content_version=?,不加锁、以affected判定胜负,冲突以类型化错误(ErrIssueAlreadyChanged)上抛——配合短事务与可重试的用户交互,这是比 SELECT FOR UPDATE 更适配 Web 表单场景的裁决方式; - 多态单表 + 能力方法:
comment表装下全部时间线事件,HasContentSupport/CountedAsConversation/CommentTypeIsRef把"类型意味着什么"收敛为方法而非散落的 switch——新增一种事件类型(如ChangeTimeEstimate)时,只需补常量、补字符串映射、按需实现能力方法,旧调用方自动保持正确。
跨仓引用的"解析→验证→降级→差集 neuter"流水线(7.6)与订阅的三层回退(7.8.3),则分别示范了 Gitea 处理跨信任边界文本与用户意图优先级的通用范式:前者永远假设文本来自不可信输入、以权限裁决收口;后者以"显式操作 > 仓库级默认 > 行为推断"排序,任何一层都能被下一层的信息覆盖。理解了这两条,后续 PR 评审、合并流程、通知中心各章的同类问题都能找到对应解法。
本章源码坐标速查(供动手时检索):
| 要做的事 | 从这里开始 |
|---|---|
| 加一种评论类型 | models/issues/comment.go 常量 + commentStrings + 能力方法 |
| 加一个列表筛选维度 | models/issues/issue_search.go 新增 applyXxxCondition 并挂进 applyConditions |
| 加一种跨仓引用语法 | modules/references/references.go 正则族 + findAllIssueReferencesBytes 轮次 |
| 加一种时间线渲染分支 | routers/web/repo/issue_view.go 渲染大循环 |
| 加一种模板目录生态 | services/issue/template.go 的 templateDirCandidates |
| 改编号/计数行为 | models/db/index.go(取号)、issue_update.go(计数) |