本文是 guild-go 的 API 契约。所有业务接口前缀为 /api,前端与 API 同源。
浏览器通过 Keycloak OIDC 登录,服务端保持 HttpOnly 会话 Cookie guild_session。只有 ID token 的 realm_access.roles 中存在已识别 permission-* 角色的用户才会被准入。
GET /api/auth/status、GET /api/auth/login、GET /api/auth/callback、GET /api/healthz公开。- 其余接口要求有效会话。
- 所有 POST / PUT / PATCH / DELETE 要求 CSRF:先
GET /api/auth/csrf,再带X-CSRF-Token。 - 会话默认 30 分钟滑动过期,保存在进程内存中;服务重启后失效。
| 状态码 | 含义 |
|---|---|
| 200 | 读取/更新成功 |
| 201 | 创建成功 |
| 204 | 删除/排序/登出成功 |
| 400 | 参数或请求体不合法 |
| 401 | 未登录或会话过期 |
| 403 | 权限不足或 CSRF 无效 |
| 404 | 资源不存在 |
| 413 | 图片/请求体超过大小限制 |
| 500 | 服务器内部错误 |
错误体:
{
"status": 403,
"error": "FORBIDDEN",
"message": "只有节点负责人可以执行此操作"
}CSRF 失败时 error 为 CSRF_INVALID。
- 所有时间字段为 RFC 3339 UTC 字符串,例如
2026-10-21T15:30:00Z。 dueAt是预期完成时间,可为null。completedAt是实际完成时间,null表示未完成;完成时由服务端写入当前时间,重新打开时清空。- 节点、评论、附件 ID 为正整数。
UserSummary:
{
"subject": "b1f0c9d2-...",
"userName": "张三",
"avatar": "https://login.example.com/.../avatar.png",
"positions": [
{
"code": "position-techdept-head",
"label": "技术部部长",
"scope": "department",
"department": "techdept"
}
]
}department 取值:techdept、clinic、digimedia、techsvc。positions 只用于展示,永不参与授权。
Profile 在 UserSummary 基础上增加:
{
"email": "alice@example.com",
"permissions": {
"systemAdmin": false,
"manager": true,
"accessDepartments": ["techdept"],
"managedDepartments": ["techdept"]
}
}email 和 permissions 仅对本人返回;查看他人时为 null。
开始 OIDC 登录。returnTo 必须是本站相对路径;非法值回退为 /。
成功时 302 到 Keycloak。发现失败时 302 到 /login?error=provider_unavailable。
OIDC 回调。校验 state、nonce、issuer、audience、签名和 PKCE。成功后设置会话 Cookie 并跳回 returnTo;失败跳 /login:
not_member:没有已识别的permission-*角色no_roles:ID token 没有 realm rolesprovider_unavailable:无法加载 OIDC discoveryoidc_failed:state/nonce/token 交换等失败
公开,返回当前会话状态:
{"state":"active","user":{ "...Profile..." }}或:
{"state":"anonymous","user":null}Cache-Control: no-store。
要求登录。返回:
{"token":"opaque","headerName":"X-CSRF-Token"}要求登录。返回当前用户 Profile。
要求登录 + CSRF。删除服务端会话并清 Cookie,返回 204。不登出 Keycloak。
要求登录。返回已知用户目录,按 subject 排序。当前用户资料来自会话;其他用户通过 Keycloak Admin API 查询并缓存。Admin API 不可用时回退为 subject、无头像、空职务。
返回 UserSummary[]。
要求登录。返回一个用户的 Profile。未知 subject 返回 404。查看他人时 email 和 permissions 为 null。
要求登录。返回总览数据:
{
"roots": [
{
"id": 1,
"parentId": null,
"title": "技术部",
"dueAt": null,
"completedAt": null,
"tags": ["部门"],
"sortOrder": 0,
"childCount": 5,
"commentCount": 12,
"latestCommentAt": "2026-10-21T15:30:00Z",
"myRelation": "leader",
"updatedAt": "2026-10-21T15:30:00Z",
"leaders": [{ "...UserSummary..." }],
"latestComment": {
"id": 99,
"author": { "...UserSummary..." },
"bodyPreview": "周五晚上可以吗?",
"createdAt": "2026-10-21T15:30:00Z",
"reactionSummary": [
{"emoji": "thumbs_up", "count": 2, "reactedByMe": false}
],
"attachmentCount": 0
}
}
],
"recentComments": [
{
"id": 100,
"nodeId": 13,
"nodeTitle": "面试安排",
"rootTitle": "技术部",
"author": { "...UserSummary..." },
"bodyPreview": "……",
"createdAt": "2026-10-21T16:00:00Z",
"reactionSummary": []
}
]
}myRelation 为 leader、member 或 null;leader 优先。latestComment 为 null 表示暂无评论。
节点权限由有效负责人/有效成员决定:
E_leader(n) = 本节点及所有祖先的直接负责人
E_member(n) = 本节点及所有后代的直接成员(含负责人)∪ E_leader(n)
要求登录。返回全部节点的扁平列表,供侧栏建树、搜索和筛选:
[
{
"id": 12,
"parentId": 3,
"title": "秋季招新",
"dueAt": "2026-11-01T12:00:00Z",
"completedAt": null,
"tags": ["招新", "技术部"],
"sortOrder": 2,
"childCount": 4,
"commentCount": 7,
"latestCommentAt": "2026-10-21T15:30:00Z",
"myRelation": "member",
"updatedAt": "2026-10-21T15:30:00Z"
}
]myRelation 是当前用户对该节点的有效关系。
关系图谱直接复用该接口渲染:一次取回全部节点后在浏览器侧布局,因此没有单独的分页或邻居接口。
要求登录。返回节点详情:
{
"id": 12,
"parentId": 3,
"title": "秋季招新",
"description": "……",
"dueAt": "2026-11-01T12:00:00Z",
"completedAt": null,
"tags": ["招新"],
"sortOrder": 2,
"createdBySubject": "b1f0...",
"createdBy": { "...UserSummary..." },
"createdAt": "2026-10-01T00:00:00Z",
"updatedAt": "2026-10-21T15:30:00Z",
"breadcrumb": [
{"id": 1, "title": "公会"},
{"id": 3, "title": "技术部"}
],
"directLeaders": [{ "...UserSummary..." }],
"directMembers": [],
"inheritedLeaderCount": 1,
"inheritedMemberCount": 3,
"effectiveMemberCount": 5,
"subtreeNodeCount": 14,
"subtreeCommentCount": 23,
"subtreeAttachmentCount": 4,
"permissions": {
"canEdit": true,
"canDelete": true,
"canComment": true,
"canManageMembers": true,
"canAddChild": true,
"canReorderChildren": true
},
"children": [
{
"id": 13,
"parentId": 12,
"title": "面试安排",
"dueAt": null,
"completedAt": null,
"tags": ["面试"],
"sortOrder": 0,
"childCount": 0,
"commentCount": 3,
"latestCommentAt": "2026-10-21T15:00:00Z",
"myRelation": "member",
"updatedAt": "2026-10-21T15:00:00Z",
"latestComment": {
"id": 99,
"author": { "...UserSummary..." },
"bodyPreview": "周五晚上可以吗?",
"createdAt": "2026-10-21T15:00:00Z",
"reactionSummary": [],
"attachmentCount": 0
}
}
]
}breadcrumb只包含祖先,不含当前节点。children按sortOrder、id排序。latestComment为直接子节点最新 1 条评论;bodyPreview截断约 200 字。subtree*统计用于删除确认。permissions.canComment对系统管理员为true,即使不是有效成员。- 前端移动入口复用
permissions.canEdit;移动时仍需验证目标位置的权限。
要求登录 + CSRF。
创建根节点需要 manager(任一部门 admin)或系统管理员;创建子节点需要父节点有效负责人或系统管理员。
请求:
{
"parentId": 3,
"title": "面试安排",
"description": "……",
"dueAt": "2026-11-01T12:00:00Z",
"tags": ["面试"],
"leaderSubjects": ["subject-a"],
"memberSubjects": ["subject-b"]
}parentId为null或省略表示根节点。title1–200 字符;description≤ 20,000;标签 ≤ 10 个、每个 ≤ 32 字符。- 所有 subject 必须是已知用户。
leaderSubjects与memberSubjects不能重叠。- 未指定负责人时,创建者自动成为直接负责人。
memberSubjects省略或为空时,直接普通成员为空,不会自动复制父节点成员。- 新节点追加到同级末尾。
成功返回 201 + NodeDetail。
要求节点有效负责人或系统管理员 + CSRF。替换可编辑字段:
{
"title": "面试安排",
"description": "……",
"dueAt": "2026-11-01T12:00:00Z",
"tags": ["面试"],
"completed": true
}dueAt传null清除预期完成时间。completed=true且当前未完成时写入completedAt=now();已完成再传true不改变原时间;false清空。- 描述有实际变化时,在同一事务内记录操作者、修改前后全文和时间。清空描述也记录;标题、状态等其他字段变化不会产生描述事件。
- 返回更新后的
NodeDetail。
要求节点有效负责人或系统管理员 + CSRF。级联删除子树、成员、评论、反应和附件元数据,并尽力删除 S3 对象。返回 204。
要求源节点和目标父节点的有效负责人,或系统管理员 + CSRF。移动为根节点还要求 manager 或系统管理员。
{"parentId": 3}- 必须显式提供
parentId;正整数表示目标父节点,null表示移动为根节点。省略或无效值返回 400。 - 节点连同整棵子树移动,追加到目标直接子节点/根节点列表末尾;目标与原父节点相同时不改变排序和时间。
- 不能移入自身或任意后代(400);源节点或目标不存在返回 404,权限不足返回 403。
- 保留内容、完成时间、直接成员关系及其建立时间、评论、反应、附件和描述历史;有效权限和祖先路径按新位置实时计算。
- 移动在事务内串行校验层级,防止并发移动形成循环;更新源节点、原父节点和目标父节点的
updatedAt。 - 返回 200 + 更新后的
NodeDetail。
要求登录 + CSRF。
请求:
{"parentId": 3, "ids": [13, 12, 15]}parentId=null表示根节点;根排序要求 manager 或系统管理员。- 子节点排序要求父节点有效负责人或系统管理员。
ids必须完整覆盖该父节点的全部直接子节点/全部根节点,不能重复,否则 400。- 成功 204。
要求登录。返回有效负责人/成员及其来源:
{
"leaders": [
{
"user": { "...UserSummary..." },
"direct": true,
"sources": []
},
{
"user": { "...UserSummary..." },
"direct": false,
"sources": [{"id": 1, "title": "公会"}]
}
],
"members": [
{
"user": { "...UserSummary..." },
"direct": false,
"sources": [{"id": 13, "title": "面试安排"}]
}
]
}leaders 为有效负责人;members 为有效成员中不是负责人的部分。direct=false 表示继承,sources 为来源节点。
要求节点有效负责人或系统管理员 + CSRF。只替换直接关系,继承关系自动重算。
{
"leaders": ["subject-a"],
"members": ["subject-b", "subject-c"]
}leaders不能为空。members省略或为空时,清空直接普通成员,不会自动复制父节点成员。非空时按指定名单保存。- 负责人和成员不能重叠。
- 所有 subject 必须已知。
- 返回更新后的
NodeDetail。
要求登录,与节点详情使用相同的读取权限。返回评论与描述变更,按 createdAt DESC, kind DESC, id DESC 排序。默认 30 条,最多 100 条。
{
"items": [
{
"kind": "description_change",
"descriptionChange": {
"id": 8,
"nodeId": 12,
"author": { "subject": "user-1", "userName": "林晓", "avatar": null, "positions": [] },
"before": "原需求",
"after": "修改后的需求",
"createdAt": "2026-10-25T01:00:00Z"
}
}
],
"hasMore": true,
"nextBefore": { "createdAt": "2026-10-25T01:00:00Z", "kind": "description_change", "id": 8 }
}kind=comment时,comment字段为下方评论接口的完整单条形状;kind=description_change时,使用示例中的descriptionChange字段。- 加载下一页时,将
nextBefore的三个字段分别作为before、beforeKind、beforeId参数传回,三者必须同时提供。时间相同或两种记录的 ID 相同时也不会重复、漏项;游标对应的评论删除后仍可继续翻页。 - 初次添加描述时
before为空;清空描述时after为空。描述事件不能单独编辑、删除或添加反应,删除节点会级联删除这些事件。 - 新节点的非空初始描述会记录。已有节点不补造历史;从第一次修改起保留修改前后文本。
- 手动发布的进展、图片和反应继续使用下方评论接口。
commentCount、subtreeCommentCount和评论预览字段仍只统计用户发布的内容,不包含描述变更;界面不展示这些计数。
评论是线性结构,没有楼中楼。时间倒序分页,默认每页 30 条。
要求登录。返回:
{
"items": [
{
"id": 99,
"nodeId": 12,
"author": { "...UserSummary..." },
"body": "周五晚上可以吗?",
"createdAt": "2026-10-21T15:30:00Z",
"attachments": [
{
"id": 7,
"url": "/api/attachments/7",
"contentType": "image/png",
"sizeBytes": 123456
}
],
"reactions": [
{"emoji": "thumbs_up", "count": 2, "reactedByMe": true}
],
"canDelete": true
}
],
"hasMore": true,
"nextBefore": 71
}before为上一页最后一条评论 ID,可省略。limit默认 30,最大 100。- 评论删除权限:作者或系统管理员。
要求节点有效成员或系统管理员 + CSRF。JSON 请求体:
| 字段 | 说明 |
|---|---|
body |
纯文本,≤ 5000 字,可为空但必须至少有一张图片 |
attachmentIds |
0–4 个已上传图片 ID,按展示顺序排列 |
图片必须由当前用户上传到当前节点,尚未用于其他评论,且上传未超过 24 小时。
评论与图片关联在同一事务中完成;失败时保留已上传图片,重试发表无需再次上传。成功 201 + Comment。
要求节点有效成员或系统管理员 + CSRF。multipart/form-data 的 file 字段只接收一个文件,≤ 5MB。
允许 JPEG、PNG、WebP、GIF;服务端根据文件头检测,拒绝 SVG。
成功 201 + {"id": 123},供发表评论时使用。每张图片独立上传和重试,上传本身不创建评论。
未发表的图片不开放读取,24 小时后过期;服务启动时及每小时清理过期图片。
要求上传者 + CSRF。删除自己尚未发表的图片及 S3 对象,返回 204。 图片不存在、不属于自己或已发表时返回 404。
要求评论作者或系统管理员 + CSRF。删除评论、反应、附件元数据和 S3 对象。返回 204。
要求登录。任意准入成员都可查看指定表情的人员,按回应时间从早到晚排列。
返回 UserSummary[](subject、userName、avatar、positions),不包含邮箱和权限。
无人使用该表情时返回 [];评论不存在返回 404,表情不受支持返回 400。
要求登录 + CSRF。任意准入成员都可反应。emoji 取值:
thumbs_up, thumbs_down, laugh, hooray, confused, heart, rocket, eyes
切换当前用户的反应,返回:
{
"reactions": [
{"emoji": "thumbs_up", "count": 2, "reactedByMe": true}
]
}要求登录。从 S3 流式返回图片:
Content-Type:存储时的媒体类型Cache-Control: private, max-age=3600X-Content-Type-Options: nosniffContent-Disposition: inline
不存在返回 404。
公开。数据库可达时:
{"status":"ok"}不可达时 503。
| 操作 | 条件 |
|---|---|
| 查看节点/描述历史/评论/附件 | 任意准入成员 |
| 加/取消表情 | 任意准入成员 |
| 创建根节点、根排序 | manager 或系统管理员 |
| 创建子节点 | 父节点 E_leader 或系统管理员 |
| 编辑/完成/标签/预期完成时间 | 节点 E_leader 或系统管理员 |
| 删除节点 | 节点 E_leader 或系统管理员 |
| 管理直接负责人/成员 | 节点 E_leader 或系统管理员 |
| 调整直接子节点顺序 | 节点 E_leader 或系统管理员 |
| 发表评论 | 节点 E_member 或系统管理员 |
| 删除评论 | 评论作者或系统管理员 |
有效关系只存直接成员,继承关系实时计算。position-* 只用于展示,永远不授权。