Gitea源码深度剖析-第7章 Issue 体系:数据模型、生命周期与渲染链路

2 阅读1小时+

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*.goWeb 侧 handler(19 个文件)
routers/api/v1/repo/issue*.goAPI 侧 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.goIssue/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.goIssue↔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.goIssue 级订阅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 取号"

需要注意图中两处容易误读的地方:

  1. Issue 与 repository 是严格的多对一,但编号不随 issue 行自增,而是由独立的 issue_index 资源序号表按仓库分组发放(7.2 节);
  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未定义(查询占位)
CommentTypeComment0普通正文评论
CommentTypeReopen1重新打开
CommentTypeClose2关闭
CommentTypeIssueRef3被其它 issue 引用
CommentTypeCommitRef4被 commit 引用
CommentTypeCommentRef5被评论引用
CommentTypePullRef6被 PR 引用
CommentTypeLabel7标签变更(Content "1"=加 "0"=删)
CommentTypeMilestone8里程碑变更
CommentTypeAssignees9负责人变更
CommentTypeChangeTitle10标题变更
CommentTypeDeleteBranch11分支删除
CommentTypeStartTracking12开始跟踪时间
CommentTypeStopTracking13停止跟踪时间
CommentTypeAddTimeManual14手工登记工时
CommentTypeCancelTracking15取消跟踪
CommentTypeAddedDeadline16设置截止日期
CommentTypeModifiedDeadline17修改截止日期
CommentTypeRemovedDeadline18移除截止日期
CommentTypeAddDependency19添加依赖
CommentTypeRemoveDependency20移除依赖
CommentTypeCode21代码行评论
CommentTypeReview22评审
CommentTypeLock23锁定
CommentTypeUnlock24解锁
CommentTypeChangeTargetBranch25PR 目标分支变更
CommentTypeDeleteTimeManual26删除登记工时
CommentTypeReviewRequest27评审请求
CommentTypeMergePull28PR 合并
CommentTypePullPush29PR 推送提交
CommentTypeProject30项目变更
CommentTypeProjectColumn31项目列变更(常量名沿用 ProjectBoard,源码内 FIXME 注明旧名)
CommentTypeDismissedReview32评审被驳回
CommentTypeChangeIssueRef33外部引用编号变更
CommentTypePullScheduledMerge34定时合并排程
CommentTypePullCancelScheduledMerge35取消定时合并
CommentTypePin36置顶
CommentTypeUnpin37取消置顶
CommentTypeChangeTimeEstimate38工时预估变更

常量之外还有一张字符串映射表 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"`
	...
}

三个值得注意的设计:

  1. 一个 Content 列服务多种语义:对正文评论是 Markdown 原文;对 CommentTypeLabel 是 "1"/"0" 增删标记(newIssueLabel 插入 Content: "1");对时间类评论,旧格式直接存文本、新格式存 | 分隔的结构(渲染时 issue_view.go 判断 Content[0] != '|' 走老分支)。多态表的代价就是列的语义随 Type 漂移,所以读取处必须先判型。
  2. PatchQuoted 的 UTF-8 自救:BeforeInsert/AfterLoad 用 strconv.Quote 处理非法 UTF-8 的代码补丁——数据库 VARCHAR/TEXT 在部分排序规则下拒收非法字节,插入前转义、加载后反转义。
  3. 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 在代码中是严格分层的三层,每层职责清晰:

层入口文件职责
handlerNewIssuePost(ctx)routers/web/repo/issue_new.go表单绑定、模板渲染、候选元数据校验
serviceissue_service.NewIssue(ctx, doer, repo, issue, labels, uuids, projectIDs)services/issue/issue.goblocked 校验、事务编排、notify 派发
modelissues_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(&#34;issue_index&#34;, 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#Ngitea/gitea#12345
crossReferenceCommitPattern跨仓提交 owner/repo@shago-gitea/gitea@a1b2c3d
timeLogPattern工时 @1.5hspent @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 对同一段字节流跑三轮:

  1. 数字引用轮:#N、!N、外部 tracker ABC-1;
  2. 跨仓引用轮:owner/repo#N、owner/repo@sha;
  3. 链接来源轮:从 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
    活跃引用 --> 失效: 正文编辑后差集为&#34;旧有新无&#34;
    失效 --> [*]: 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/ReviewMarkdown 渲染正文 + roleDescriptor 计算发帖角色 + 加入 participants
LabelLoadLabel 载入标签对象;与 combineLabelComments 合并相邻标签事件
MilestoneLoadMilestone;里程碑被删后用幽灵占位 repo.issues.deleted_milestone
Project幽灵项目 ghostProject 占位(项目已删)
Assignees/ReviewRequestLoadAssigneeUserAndTeam(支持 team 负责人)
DependencyLoadDepIssueDetails 载入被依赖 issue 摘要
HasContentSupport() 类型走 Markdown 渲染 + LoadReview + LoadResolveDoer
PullRequestPushLoadCommentPushCommits 展开推送的 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.gosetTemplateIfExists、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(文件.函数)权限守卫
""GETissue_list.Issues读组 reqRepoIssuesOrPullsReader
/{index}GETissue_view.ViewIssue读组(含 PR 重定向)
/titlePOSTissue.UpdateIssueTitle写组
/contentPOSTissue.UpdateIssueContent写组
/deadlinePOSTissue.UpdateIssueDeadline写组
/watchPOSTissue_watch.IssueWatch登录
/refPOSTissue.UpdateIssueRef写组
/pinPOSTissue_pin.PinIssue管理
/commentsPOSTissue_comment.NewCommentMustAllowUserComment + 绑定 CreateCommentForm
/comments/{id}POSTissue_comment.UpdateCommentContentposter/管理
/comments/{id}/deletePOSTissue_comment.DeleteCommentposter/管理
/comments/{id}/reactions/{action}POSTissue_comment.ChangeCommentReaction登录
/reactions/{action}POSTissue.ChangeIssueReaction登录
/attachmentsPOSTupload 附件处理写组 + setting.Attachment
/labelsPOSTissue_label.UpdateIssueLabel写组
/milestonePOSTissue.UpdateIssueMilestone写组
/projectsPOSTissue.UpdateIssueProject项目读写
/assigneePOSTissue.UpdateIssueAssignee写组
/statusPOSTissue_list.UpdateIssueStatus写组
/lock /unlockPOSTissue_lock.LockIssue/UnlockIssue写组/管理
/deletePOSTissue_new.DeleteIssue管理
/time_estimate 等POSTissue_timetrack.*写组
`/stopwatch/startstop`POSTissue_stopwatch.*登录
/resolve_conversationPOSTissue_comment.ResolveConversation写组

写组/读组的完整守卫是 reqRepoIssuesOrPullsReader/reqRepoIssuesOrPullsWriter(web.go 顶层常量),内部按 {type:issues|pulls} 切换单元类型(unit.TypeIssues vs unit.TypePullRequests)——同一段代码同时服务 issues 与 pulls 两个路由树,这是 Gitea 路由的一大复用手法(第 3 章路由篇已见其他例子)。

其余独立 handler 文件的职责速查(函数名取自源码 grep 结果):

文件关键 handler职责
issue.goMustAllowUserComment、MustEnableIssues、GetActionIssue、GetIssueInfo、UpdateIssueTitle/Ref/Content/Deadline/Milestone/Assignee、ChangeIssueReaction、GetIssueAttachments单字段编辑与通用守卫;GetActionIssue 是所有写操作的公共取号+权限入口
issue_view.goViewIssue、ViewPullMergeBox详情页组装(7.7.4 的渲染大循环所在)
issue_list.goIssues、SearchIssues、prepareIssueFilterAndList、BatchDeleteIssues、UpdateIssueStatus列表与批量(7.11)
issue_new.goNewIssue、NewIssuePost、NewIssueChooseTemplate、DeleteIssue、ValidateRepoMetasForNewIssue创建与模板(7.4、7.10)
issue_label.goLabels、NewLabel、UpdateLabel、DeleteLabel、UpdateIssueLabel标签 CRUD 与挂载
issue_watch.goIssueWatch订阅开关
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.goIssueSuggestions标题相似度建议(建 issue 时提示"可能已存在")
issue_poster.goposter 权限判定辅助删除/编辑的 poster-or-admin 判定
issue_page_meta.goretrieveRepoIssueMetaData详情/列表页侧栏元数据(标签/里程碑/负责人候选集)

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.goSearchIssues、ListIssues、GetIssue、CreateIssue、EditIssue、DeleteIssue、UpdateIssueDeadline主资源 CRUD;DeleteIssue 需 reqAdmin + ReferencesGitRepo
issue_comment.goListIssueComments、CreateIssueComment、GetIssueComment、EditIssueComment、EditIssueCommentDeprecated、DeleteIssueComment、DeleteIssueCommentDeprecated、ListRepoIssueComments、ListIssueCommentsAndTimeline、ResolveConversation评论读写;Deprecated 对是旧 URL 形态的兼容别名
issue_label.goListIssueLabels、AddIssueLabels、ReplaceIssueLabels、DeleteIssueLabel、ClearIssueLabels与 web 侧同模型(7.9.2),PUT=差集替换
issue_reaction.goGetIssueReactions、PostIssueReaction、DeleteIssueReaction、GetIssueCommentReactions、PostIssueCommentReaction、DeleteIssueCommentReactions表情六端点(7.8.1)
issue_subscription.goGetIssueSubscribers、CheckIssueSubscription、AddIssueSubscription、DelIssueSubscription订阅(7.8.3),Check 是 GET 返回布尔而非 HEAD
issue_pin.goListPinnedIssues、PinIssue、UnpinIssue、MoveIssuePin、PinIssueToTop 等 7 个置顶(reqAdmin)
issue_dependency.goGetIssueDependencies、CreateIssueDependency、RemoveIssueDependency、GetIssueBlocks、CreateIssueBlocking、RemoveIssueBlocking依赖/被依赖双向端点
issue_assignee.goAddIssueAssignees、DeleteIssueAssignees、CheckIssueAssignee负责人(GitHub 语义:传全集替换,7.9 相关)
issue_attachment.goListIssueAttachments、CreateIssueAttachment、GetIssueAttachment、EditIssueAttachment、DeleteIssueAttachmentissue 级附件
issue_comment_attachment.go同构 5 个评论级附件
issue_tracked_time.goListTrackedTimes、AddTime、DeleteTime、ResetIssueTime 等 7 个工时
issue_stopwatch.goStartIssueStopwatch、StopIssueStopwatch、DeleteIssueStopwatch 等 4 个秒表
issue_lock.goLockIssue、UnlockIssue锁定(reqAdmin + LockIssueOption)

另有两个模板相关端点挂在 code 单元下:repo.GetIssueTemplates(列出模板)、repo.GetIssueConfig/ValidateIssueConfig(config.yml 读取与校验)。

两侧对照的三个差异值得记住:

  1. 错误语义:web 侧用 ctx.JSONRedirect/Flash 面向浏览器流程;API 侧一律 ctx.APIError(http.StatusXxx, ...),状态码语义严格(403 无权限、404 不存在/无权限不区分、409 版本冲突对应 ErrIssueAlreadyChanged);
  2. 权限粒度:API 的写操作统一 reqToken()(token scope 必须含 issue 类别)+ reqRepoReader/Writer(unit.TypeIssues),删除额外 reqAdmin();web 侧则是 reqRepoIssuesOrPullsWriter + 单条路径上的 poster 例外(本人可编辑自己的评论);
  3. 批量: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 体系的设计主线

回看全章,三条设计主线贯穿始终:

  1. 物化计数 + 就地对账:repo.num_*、milestone.num_*、label.num_issues、issue.num_comments 全部预计算,由 Incr/Decr 增量维护、由 UpdateMilestoneCounters/updateLabelCols 子查询重算兜底——读路径永远免聚合,写路径多付一次 UPDATE,崩溃后可自愈;
  2. 条件更新式乐观并发:关闭用 WHERE is_closed=false、编辑用 WHERE content_version=?,不加锁、以 affected 判定胜负,冲突以类型化错误(ErrIssueAlreadyChanged)上抛——配合短事务与可重试的用户交互,这是比 SELECT FOR UPDATE 更适配 Web 表单场景的裁决方式;
  3. 多态单表 + 能力方法: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(计数)