一个用于记录和跟踪任务状态的看板应用。固定五列(未规划 / 规划中 / 进行中 / 已完成 / 失败),卡片支持跨列拖拽变更状态,进入「失败」列必须先填写失败原因,数据经 MCP → MySQL 持久化,刷新页面不丢失。
| 能力 | 说明 |
|---|---|
| 多用户认证 | 注册 / 登录 / 改密,JWT httpOnly cookie 会话;共享团队看板——登录用户同看一块板 |
| 多看板 workspace | 顶栏切换器多块看板并存:新建 / 删除(任务级联、至少保留一块)、当前选择跨标签页跟随与持久化;AI 经 MCP 同一套看板能力 |
| 看板成员与角色 | owner(管理成员/删板)/ member(读写)/ viewer(只读)三级角色;护栏在 MCP 数据层——人与 AI 同受治理,AI 客户端的身份取自 Bearer Token,伪造参数无效;新注册用户自动加入默认看板 |
| 五列固定看板 | 未规划 → 规划中 → 进行中 → 已完成 / 失败 |
| 拖拽流转 | 支持跨列移动与列内排序;鼠标走原生拖拽,移动端长按卡片即可拖拽 |
| 拖拽视觉反馈 | 目标列整体高亮(进入失败列时为红色高亮)+ 虚线占位块实时跟随 |
| 失败原因强校验 | 拖入「失败」列弹出原因弹窗;未填写则拒绝移动并给出明确提示 |
| 卡片字段 | #编号、类型(🐞Bug/✨需求/🔧任务/🧹杂务/📝文档)、标题、描述、优先级、负责人(关联真实用户)、标签、截止日期 |
| 详情抽屉与评论 | 点卡片标题侧滑出详情:字段一览、评论(可删自己的)、活动时间线;AI 经 MCP 留的评论同样留名 |
| Markdown 描述 | 详情抽屉按 Markdown 渲染描述(标题/列表/代码块/粗体/链接等);零依赖受限渲染器,先转义后解析 + URL 白名单,XSS 注入无效 |
| 状态变更记录 | 每次状态变动自动追加「变更时间 + 原状态 + 新状态」;因失败变更时一并显示失败原因 |
| 增删改 | 新建 / 编辑 / 删除,删除需二次确认 |
| 批量操作 | 卡片复选框或列头全选后,可一次性批量移动到目标列或批量删除;移入「失败」列同样要求填写原因 |
| 乐观更新 | 拖拽落位后立即本地生效,不等服务端往返;请求失败自动回滚并重新拉取 |
| 跨标签页同步 | 同一浏览器多开时,一处改动其他标签页自动跟上;切回前台再兜底拉一次 |
| 并发保护 | 任务带 version 版本号,编辑/移动/批量操作均携带;被他人抢先修改则返回 409,不静默覆盖 |
| 泳道 | 看板列内按标签或负责人横向分组(视图分组,不改变任务归属);拖拽落位自动归到卡片自身组,组内排序 / 跨列移动照常;偏好持久化 |
| 站内通知 | 状态流转/指派/评论自动通知看板成员(排除操作者,AI 动作同样触发并带自报名快照);顶栏铃铛未读徽标 + 下拉列表,单条已读跳转 issue、一键全部已读 |
| Webhook 外发 | 看板级订阅端点(通用 JSON / 钉钉 / 企微 / 飞书机器人),事件外发;通用格式带 HMAC-SHA256 签名头,钉钉支持加签;分发独立于写路径(重试 5 次),AI 与人的动作同源外发 |
| 组合筛选 | 关键词(标题 & 描述)+ 优先级 + 标签 + 负责人,多条件 AND 组合,支持一键清空 |
| CSV 导出 | 按当前筛选条件一键导出任务清单(UTF-8 BOM,Excel 直接打开);与看板同一份过滤口径,所见即所得 |
| 列计数 | 每列顶部实时显示 筛选后数量/总数,如 3/10 |
| 持久化 | MySQL 8,数据读写全部经 MCP;仅在数据表为空时初始化示例任务 |
| 安全加固 | helmet 安全头、接口限流、错误信息脱敏、XSS 转义、环境变量 fail-fast 校验 |
| 工程设施 | GitHub Actions CI、ESLint + Prettier、Docker 一键部署(应用 + 数据库)、结构化日志 |
| 设计风格 | Apple 设计语言:充足留白、大圆角、浅色系、分层细腻阴影、SF 字体栈 |
| 响应式 | 桌面端五列并排;移动端横向滚动(scroll-snap)+ 顶部列切换标签联动 |
- 前端:原生 HTML / CSS / ES Modules(零构建、零框架依赖)
- 后端:Node.js 22 / Express 4
- 数据层:MCP(Model Context Protocol)
@modelcontextprotocol/sdkv1.32 - 数据库:MySQL 8(驱动
mysql2) - 认证:jsonwebtoken(httpOnly cookie)+ bcryptjs
- 安全:helmet / express-rate-limit / cookie-parser
- 参数校验:Zod(含环境变量 fail-fast 校验)
- 日志:pino(JSON 结构化,带 requestId)
- 质量工具:ESLint 9 + Prettier + GitHub Actions CI
cloudissue/
├── package.json # 依赖与启动脚本
├── .env.example # 环境变量模板
├── Dockerfile # 应用镜像(多阶段、非 root、healthcheck)
├── docker-compose.yml # 一键启动整套(app + MySQL 8)
├── eslint.config.js # ESLint 9 flat config
├── .github/workflows/ci.yml # CI:lint + 三套测试 + 镜像构建
├── db/
│ └── schema.sql # 建表 DDL(幂等,可重复执行;含 users 表)
├── shared/
│ └── constants.js # 前后端共用的枚举与文案
├── mcp-server/
│ ├── index.js # MCP Server:唯一访问 MySQL 的组件
│ └── db.js # 连接池 / 事务 / 建库建表 / env 校验
├── server/
│ ├── index.js # Express:REST API + 静态资源(不直连 DB)
│ ├── auth.js # 注册/登录/会话/改密(JWT + bcrypt)
│ ├── mcp-client.js # MCP Client 封装:断连重连 + callTool
│ └── logger.js # pino 结构化日志
└── public/
├── index.html
├── styles.css
└── src/ # 按职责拆分的 ES 模块(见下)
public/src/ 模块划分:utils / toast / api / state / filters /
dropdowns / render / dnd / modals / sync / main(入口)。
浏览器 (public/)
│ fetch /api/* (JWT httpOnly cookie 会话)
▼
Express HTTP 层 (server/index.js) ← 路由 / 鉴权 / 限流 / 静态资源,持有 MCP Client
│ MCP over stdio(断连自动重连)
▼
MCP 数据服务 (mcp-server/index.js) ← 唯一持有 MySQL 连接池,暴露 23 个 tools
│ mysql2
▼
MySQL 8 (issue_board)
HTTP 层没有任何
mysql2引用。所有读写都必须经 MCP tools,这样数据访问口径唯一、可审计、可被任意 MCP 客户端复用。
应用 + MySQL 全部容器化,一条命令拉起:
cd cloudissue
# 1) 准备环境变量(必须设置 JWT_SECRET 与数据库口令)
cp .env.example .env
# 编辑 .env:
# - JWT_SECRET=$(openssl rand -hex 32)
# - DB_PASSWORD / MYSQL_ROOT_PASSWORD 设为强口令
# 2) 构建并启动(app + mysql,含 healthcheck 依赖编排)
docker compose up -d --build浏览器打开 **http://localhost:5173**,注册一个账号即可开始使用。
docker compose logs -f app # 查看应用日志
docker compose down # 停止(数据保留在 mysql-data 卷)应用镜像为多阶段构建(node:22-alpine)、非 root 运行,容器内自带
/api/health healthcheck;MySQL 默认只绑定宿主机回环地址(MYSQL_PUBLISH 可改)。
# 1) 启动一个 MySQL 8(可用 npm run db:up 只起数据库容器)
cp .env.example .env # 编辑 .env,填入 DB_* 连接信息与 JWT_SECRET
npm run db:up
# 2) 安装依赖并启动
npm install
npm start # 开发热重载可用 npm run dev应用启动时会自动调用 MCP 的 board_bootstrap 完成建库、建表与按需初始化,
无需手动执行 schema.sql。
| 变量 | 默认值 | 说明 |
|---|---|---|
DB_HOST |
127.0.0.1 |
MySQL 主机(compose 内自动覆盖为 mysql) |
DB_PORT |
3306 |
MySQL 端口 |
DB_USER |
(必填) | 数据库用户名,禁止默认 root 空密码 |
DB_PASSWORD |
(必填) | 数据库密码 |
DB_NAME |
issue_board |
数据库名 |
JWT_SECRET |
(必填) | JWT 签名密钥,至少 16 位:openssl rand -hex 32 |
JWT_EXPIRES |
7d |
会话有效期 |
COOKIE_SECURE |
false |
HTTPS 部署时设 true(cookie secure 标记) |
PORT |
5173 |
HTTP 服务端口 |
LOG_LEVEL |
info |
pino 日志级别 |
TRUST_PROXY |
false |
反向代理后设 true,限流按真实客户端 IP 计数 |
API_RATE_LIMIT |
300 |
全局 API 限流(次/分钟/IP) |
AUTH_RATE_LIMIT |
20 |
认证接口限流(次/15 分钟/IP);跑集成测试调高 |
MYSQL_ROOT_PASSWORD |
(compose 用) | MySQL 容器 root 口令 |
npm run mcp
# 或使用官方 Inspector:
npx @modelcontextprotocol/inspector node mcp-server/index.js- 注册 / 登录:打开首页即见登录视图,没有账号切到「注册」标签(用户名 3-32 位、密码至少 8 位);注册成功自动进入看板。看板为共享团队模式——所有登录用户操作同一块板。
- 首次启动:任务表为空 → 自动写入 10 条示例任务,覆盖全部五列与三档优先级(其中「失败」列示例已带失败原因)。
- 新建任务:点击右上角「新建任务」,填写标题(必填)、描述、优先级、负责人、状态、截止日期、标签(逗号分隔)。
- 若状态选择「失败」,表单会展开「失败原因」输入框,未填写将无法保存。
- 拖拽流转:桌面端按住卡片拖到目标列;移动端长按卡片约 0.2 秒后拖动。目标列高亮并显示虚线占位块;松手即完成移动。
- 拖入「失败」列 → 弹出「标记为失败」弹窗 → 填写原因后确认移动;取消或留空则拒绝移动,并提示「已取消移动:未填写失败原因,任务保留在原列」。
- 查看变更记录:点击卡片底部「变更 N」展开时间线,显示每次变更的
原状态 → 新状态、时间,失败变更额外显示原因。 - 筛选:在筛选栏输入关键词,或在优先级 / 标签 / 负责人下拉中多选(同类 OR,跨类 AND);列头计数实时变为
筛选后/总数;点「清空筛选」一键还原。 - 编辑 / 删除:卡片右下角的铅笔 / 垃圾桶图标;删除会弹出二次确认。
- 退出登录:顶栏「退出」按钮;修改密码后所有旧会话(含其他设备)立即失效。
移动端:长按卡片即可拖拽跨列,拖到屏幕边缘会自动横向滚动;列之间支持横向滑动并通过顶部标签联动定位。
┌──────────────────────┐
│ users │ 认证身份(共享看板:任务不挂归属)
│ PK id │
│ UK username │
└──────────────────────┘
┌──────────────────────┐
│ issues │
│ (任务主表) │
│ PK id │
└──────────┬───────────┘
│ 1 : N
├──────────────────────────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ issue_tags │ │ issue_status_logs │
│ PK (issue_id, tag) │ │ PK id │
│ FK issue_id │ │ FK issue_id │
└──────────────────────┘ └──────────────────────────┘
│ │
└──── ON DELETE CASCADE ───┘
┌──────────────────────┐
│ board_meta │ 看板元数据(记录示例数据初始化状态)
│ PK meta_key │
└──────────────────────┘
两张子表均通过外键 ON DELETE CASCADE 关联主表——删除任务时,标签与变更记录自动清理,不会出现孤儿数据。
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
BIGINT UNSIGNED |
PK, AUTO_INCREMENT | 用户唯一标识 |
username |
VARCHAR(32) |
NOT NULL, UNIQUE | 登录名,3-32 位字母/数字/下划线/连字符 |
display_name |
VARCHAR(64) |
NOT NULL, DEFAULT '' |
顶栏展示名 |
password_hash |
VARCHAR(255) |
NOT NULL | bcrypt 哈希(cost 10),明文永不落库 |
token_version |
INT UNSIGNED |
NOT NULL, DEFAULT 0 |
JWT 吊销版本号;改密 +1,旧会话立即失效 |
created_at |
DATETIME |
DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
id |
BIGINT UNSIGNED |
PK, AUTO_INCREMENT | 任务唯一标识 |
title |
VARCHAR(255) |
NOT NULL | 任务标题 |
description |
TEXT |
NULL | 任务描述 |
priority |
ENUM('high','medium','low') |
NOT NULL, DEFAULT medium |
优先级:高 / 中 / 低。用 ENUM 约束,避免脏值 |
assignee |
VARCHAR(64) |
NOT NULL, DEFAULT '' |
负责人,空串表示未指派(前端显示为「未指派」) |
due_date |
DATE |
NULL | 截止日期,可为空(表示无期限) |
status |
ENUM('backlog','planning','in_progress','done','failed') |
NOT NULL, DEFAULT backlog |
状态列,五值固定 |
position |
INT |
NOT NULL, DEFAULT 0 |
同列内的排序位,越小越靠前;每次移动后由服务端重排为连续整数 |
version |
INT UNSIGNED |
NOT NULL, DEFAULT 1 |
乐观锁版本号,每次更新 +1;拖拽排序不波及其他卡片 |
created_at |
DATETIME |
DEFAULT CURRENT_TIMESTAMP | 创建时间 |
updated_at |
DATETIME |
DEFAULT CURRENT_TIMESTAMP ON UPDATE | 最后更新时间 |
索引:
idx_issues_status_pos (status, position)—— 看板按列取卡片并按序渲染的主查询路径idx_issues_updated (updated_at)—— 按更新时间排序/筛选
| 字段 | 类型 | 说明 |
|---|---|---|
issue_id |
BIGINT UNSIGNED |
复合主键一部分,外键 → issues.id |
tag |
VARCHAR(48) |
复合主键一部分,标签名 |
- 复合主键
(issue_id, tag)天然去重:同一任务不会重复打同一个标签。 - 独立索引
idx_tags_tag支撑「按标签筛选」和标签选项聚合。 - 一个任务多个标签 = 多行,满足「标签(可多个)」要求。
| 字段 | 类型 | 说明 |
|---|---|---|
id |
BIGINT UNSIGNED |
PK |
issue_id |
BIGINT UNSIGNED |
外键 → issues.id |
from_status |
VARCHAR(16) |
原状态;'' 表示任务新建 |
to_status |
VARCHAR(16) |
新状态 |
reason |
TEXT |
失败原因,仅当 to_status='failed' 时非空 |
changed_at |
DATETIME |
变更时间 |
- 设计上做成追加式日志(append-only),只 INSERT 不 UPDATE,天然保留完整轨迹。
from_status用空串而非 NULL 表示「新建」,避免与真正的"无原状态"混淆,也简化前端渲染(fromStatus ? label : '新建')。- 索引
idx_logs_issue_time (issue_id, changed_at)支撑按任务取时间线。
| 字段 | 类型 | 说明 |
|---|---|---|
meta_key |
VARCHAR(64) |
PK |
meta_value |
VARCHAR(255) |
值 |
当前仅用 seeded_at 记录示例数据初始化时间,配合**「表为空才写入」**的判断,双重保证不会覆盖用户数据。
| 枚举值 | 显示名 | 语义 |
|---|---|---|
backlog |
未规划 | 已记录但未排期 |
planning |
规划中 | 正在评估方案 / 拆解 |
in_progress |
进行中 | 正在执行 |
done |
已完成 | 已交付 |
failed |
失败 | 终止,必须携带失败原因 |
前端列顺序即数组 STATUSES 顺序,定义在 shared/constants.js,前后端共用一份,避免文案漂移。
high(红) / medium(橙) / low(绿)。卡片左侧色条与右上角 chip 同时体现,扫视即可分辨。
- 只在同一列内有意义;跨列移动时会重新编号。
- 服务端在事务内把目标列全部 id 取出 → 按目标位置插入 → 统一重排为
0..n-1。这样 position 永远是稠密连续整数,避免"插入中间没有整数可用"的问题。
- 每次编辑(含状态变更)后 +1;客户端把自己看到的版本通过
expectedVersion带回服务端。 - 服务端在事务内
SELECT ... FOR UPDATE后比对:不一致则返回conflict,HTTP 层映射为 409,前端提示「任务已被其他人修改」并自动拉一次最新数据。 - 列内排序不 bump 其他卡片的 version —— 重排只是位置变化,不该让整列的乐观锁集体失效;只有真正被移动的那张卡 +1。
- 参数可选:不传就退化为「后写覆盖」,老客户端与脚本不受影响。
前端不传"索引",而是传 "插入到哪个卡片的后面"。原因是:筛选生效时可见卡片只是子集,索引会在服务端错位;而"插入到某张具体卡片之后"在任何筛选状态下都语义唯一、可靠。
afterId = null→ 插到列首(或空列的第一个位置)afterId = <id>→ 插到该卡片之后;找不到则追加到末尾
- 只在
to_status = 'failed'时写入,其他情况为NULL。 - 校验在服务端完成(MCP tool
issue_move/issue_update内),前端拦截只是体验优化,绕过前端也无法写入非法数据。
除 /api/health 外,所有接口都需要登录(JWT httpOnly cookie ci_session),
未登录返回 401。认证接口受独立限流保护(默认 20 次 / 15 分钟 / IP)。
| 方法 | 路径 | 说明 |
|---|---|---|
GET |
/api/health |
健康检查(经 MCP 探测数据库,公开) |
POST |
/api/auth/register |
注册:{ username, password, displayName? },成功即建立会话 |
POST |
/api/auth/login |
登录:{ username, password };不区分「用户不存在/密码错」防枚举 |
POST |
/api/auth/logout |
登出(清 cookie) |
GET |
/api/auth/me |
当前会话用户 |
PUT |
/api/auth/password |
改密:{ oldPassword, newPassword },所有旧会话立即失效 |
GET |
/api/issues |
返回任务列表(含 tags/logs/commentCount);?boardId= 只取该看板 |
GET |
/api/issues/:id |
issue 详情(含评论全文) |
GET |
/api/users |
用户列表(负责人选择器,脱敏) |
GET |
/api/boards |
看板列表(多看板切换器与 AI 发现入口) |
POST |
/api/boards |
新建看板:{ name, description? },name 唯一 |
DELETE |
/api/boards/:id |
删除看板(owner 专属;任务级联删除;至少保留一块) |
GET |
/api/boards/:id/members |
成员列表(任何成员可看) |
POST |
/api/boards/:id/members |
添加/改角色:{ userId, role }(owner 专属;保护最后一个 owner) |
DELETE |
/api/boards/:id/members/:userId |
移除成员(owner 专属;不能移除最后一个 owner) |
GET |
/api/export/csv |
按当前筛选导出 CSV:?boardId=&q=&priority=&tag=&assignee=&aiOnly=(与看板同口径) |
POST |
/api/issues/:id/comments |
添加评论:{ body } |
DELETE |
/api/comments/:id?issueId= |
删除评论(仅作者本人,否则 403) |
POST |
/api/issues |
新建任务 |
PUT |
/api/issues/:id |
更新任务字段(含状态变更) |
POST |
/api/issues/:id/move |
移动 / 排序:{ toStatus, afterId, failReason, expectedVersion? };版本冲突返回 409 |
POST |
/api/issues/batch/move |
批量移动:{ items: [{id, expectedVersion?}], toStatus, failReason? }(兼容旧 ids[]) |
POST |
/api/issues/batch/delete |
批量删除:{ ids[] } |
DELETE |
/api/issues/:id |
删除任务 |
批量接口不新增 MCP tool:服务端只是对同一批 id 循环调用既有的
issue_move/issue_delete, 校验(状态枚举、失败原因必填)依旧留在 MCP 内。两个路由注册在/api/issues/:id/move之前, 否则batch会被:id吃掉。
返回的任务对象示例:
{
"id": 5,
"title": "MySQL 持久化与 MCP 数据层",
"description": "建表、连接池封装,并把全部读写收敛到 MCP tools。",
"priority": "high",
"assignee": "周野",
"tags": ["后端", "数据库", "MCP"],
"dueDate": "2026-11-01",
"status": "in_progress",
"position": 0,
"createdAt": "2026-10-16T09:12:03.000Z",
"updatedAt": "2026-10-29T08:00:00.000Z",
"logs": [
{
"id": 12,
"fromStatus": "",
"toStatus": "backlog",
"reason": "",
"changedAt": "2026-10-16T09:12:03.000Z"
},
{
"id": 18,
"fromStatus": "backlog",
"toStatus": "in_progress",
"reason": null,
"changedAt": "2026-10-20T10:00:00.000Z"
}
]
}| Tool | 入参 | 说明 |
|---|---|---|
db_health |
— | 检查 MySQL 连接 |
board_bootstrap |
— | 建库 + 建表;仅当任务表为空时写入示例任务 |
board_list |
— | 返回全部看板(含任务数) |
board_create |
name, description? |
新建看板(name 唯一) |
board_delete |
id |
删除看板(任务级联删除;至少保留一块) |
issue_list |
boardId? |
返回任务列表;传 boardId 只取该看板 |
issue_create |
title, description, priority, assignee, tags, dueDate, status, boardId?, failReason |
新建(boardId 缺省为默认看板) |
issue_update |
id + 待更新字段 + boardId? + expectedVersion? |
更新(boardId 可跨看板移动;冲突返回 conflict) |
issue_move |
id, toStatus, afterId, failReason, expectedVersion? |
移动 / 排序(进入失败列需原因) |
issue_delete |
id |
删除 |
user_create |
username, displayName, passwordHash |
创建用户(username 唯一) |
user_get_by_username |
username |
按用户名查找(登录用) |
user_get_by_id |
id |
按 id 查找(鉴权中间件校验 token_version 用) |
user_bump_token_version |
id |
吊销版本号 +1 |
user_update_auth |
id, passwordHash, bumpTokenVersion? |
更新口令哈希(改密) |
业务校验失败统一抛 McpError(InvalidParams),HTTP 层映射为 400 并把文案透出;
系统故障(数据库不可用等)映射为 502 通用文案,细节只进日志。
看板为「人 + AI 共用」设计:人走 Web UI(cookie 会话),AI 走 /mcp(API Token),
两条入口落到同一套 MCP tools 与同一块 MySQL,业务校验完全同源。
登录后在 GET /api/auth/token 取个人 Token(形如 ci_…),在任意支持
Streamable HTTP 的 MCP 客户端中配置:
{
"mcpServers": {
"cloudissue": {
"type": "http",
"url": "http://localhost:5173/mcp",
"headers": { "Authorization": "Bearer ci_xxxxxx…" }
}
}
}| 类别 | Tools | 说明 |
|---|---|---|
| 概览 | board_summary(boardId?) |
一次调用看懂看板:看板清单、列计数、逾期、今日到期、最近变更 |
| 看板 | board_list / board_create / board_delete |
多看板 workspace:与 Web 同源的新建/删除护栏 |
| 任务 | issue_list(boardId?)/ issue_search(boardId?)/ issue_create / issue_update / issue_move / issue_delete / issue_get |
与 Web 完全同源的业务校验(失败原因必填、乐观锁 409) |
| 系统 | db_health / board_bootstrap |
健康检查 / 建库建表 |
| 用户内部 | user_*(7 个) |
供 HTTP 层认证使用,AI 客户端一般无需直接调用 |
- AI 留名:写操作接受可选
actor参数,会显示在 Web 时间线的操作者徽标上(AI 为紫色标识); 建议 AI 客户端自报身份(如ai:my-agent),Web 端的操作自动记录登录用户名; Web 端「AI 动态」按钮可一键过滤所有被 AI 碰过的 issue。 - 先读后写:AI 应先调
board_summary(紧凑、含下一步建议)再按需issue_list, 避免一次性拉全量明细。 - 乐观锁:并发场景下把读到的
version作为expectedVersion传回, 被人抢先修改会得到 409 冲突而不是静默覆盖。 - 权限同治理:AI 的身份取自 Bearer Token 对应的用户,对看板的可见范围与读写权限(owner/member/viewer)与该用户在 Web 端完全一致;JSON-RPC 参数里伪造 actorId 无效。
- Token 卫生:
POST /api/auth/token/rotate可随时轮换(旧 Token 立即失效); 改密不影响 API Token,两者独立吊销。
# 快速自检:用 curl 走一遍 MCP 握手
curl -X POST http://localhost:5173/mcp \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'- 失败原因双重校验:前端拖拽时弹窗拦截;MCP tool 服务端二次校验,任一环节缺失都会拒绝并提示。
- 并发安全:移动与更新均在事务内
SELECT ... FOR UPDATE锁定目标行,再做排序重排,避免并发拖拽产生重复 position。 - 标签归一化:支持数组或逗号/空格分隔字符串;自动
trim、截断到 48 字符、去重、丢弃空值。 - 示例数据只写一次:
COUNT(*) = 0才写入;非空则原样保留用户数据,并在启动日志中说明。 - XSS 防护:所有用户内容渲染前统一经过
esc()转义。 - 级联清理:删除任务时通过外键级联清理标签与变更记录。
- 日期语义:
due_date为纯日期字符串YYYY-MM-DD;created_at/changed_at为 ISO 8601,前端统一格式化。 - 乐观更新:拖拽落位先改本地状态并立即重绘,再提交服务端;请求失败用快照回滚,并重新拉一次服务端数据兜底,避免出现「假成功」。失败原因闸门前置到乐观更新之前,取消填原因不会产生任何本地变化。
- 跨标签页同步:写库成功后往
localStorage写一个修订号(挂在api.js的request()上,单一收口),其他标签页收到storage事件即刷新;连续变更做 80ms 合并。标签页切回前台时再兜底拉一次,覆盖后台事件被节流、以及其他端改动的情况。 - 并发安全:编辑与移动携带
version,服务端在事务内比对,冲突返回 409 而非覆盖;落位重排不动其他卡片的版本号。 - 触屏拖拽:HTML5 DnD 在触屏上不可用,因此按
pointerType分流——触屏与触控笔走 Pointer Events,长按 220ms 激活,激活前的滑动一律让给页面滚动;靠近左右边缘自动横向滚动以便跨列,拖拽期间临时关闭scroll-snap。落位后与鼠标走同一套performMove。 - 可访问性:弹窗支持
Esc关闭,交互元素带aria-*属性;prefers-reduced-motion下关闭动效。 - 批量操作:勾选态放在
state.selection,卡片复选框与列头全选(部分勾选显示半选态)都能写它;底部批量条只在有勾选时出现。批量移动复用同一套失败原因闸门,删除复用同一个二次确认弹窗;清空筛选与重新拉取时会清理失效勾选,避免误伤被筛掉或已被删除的卡片。 - 认证与会话:JWT 放 httpOnly + SameSite=Lax cookie(抗 XSS);payload 带
token_version,鉴权中间件每次请求经 MCP 回库校验,改密 bump 版本号即可全端下线。登录错误不区分「用户不存在/密码错」,防用户名枚举。口令哈希(bcryptjs, cost 10)只在 HTTP 层,MCP 只存取哈希串。 - 安全加固:helmet 安全头(CSP 限 self);全局 API 300 次/分钟、认证接口 20 次/15 分钟限流(阈值可配);5xx 一律返回通用文案,不向客户端泄漏内部错误;环境变量 zod fail-fast,缺 JWT_SECRET 直接拒启。
- 进程韧性:MCP 子进程死亡时 client
onclose失效缓存,下一个请求自动重连;SIGTERM/SIGINT 优雅停机(停接请求 → 关 MCP client → 子进程清池退出);uncaughtException/unhandledRejection兜底退出,交给容器重启。 - 结构化日志:pino JSON 行 + requestId(响应头
x-request-id回带),MCP 子进程诊断日志走 stderr(stdout 被协议占用)。 [hidden]语义优先:全局[hidden]{display:none!important},防止任何组件的display:flex/grid覆盖隐藏态(曾三处踩坑:modal-root / fm-row / btn);测试里有静态回归断言守着。
项目附带三个测试脚本:
npm run lint # ESLint 检查
npm run test:mcp # MCP 链路冒烟:验证 stdio 传输正常、20 个 tools 全部注册(不需服务)
npm test # 端到端集成测试:全链路、业务规则、认证与 AI 接入(121 项断言,需先起服务)
npm run test:ui # UI 冒烟(jsdom,86 项断言,不需服务与数据库)跑 npm test 前请调高认证限流(认证套件会发起大量登录尝试):
AUTH_RATE_LIMIT=10000 node server/index.js &
npm testnpm run test:ui 覆盖 npm test 够不到的那一层:骨架渲染、卡片复用、状态下拉填充、
筛选与列计数、失败原因闸门、乐观更新与回滚、跨标签页同步、冲突提示、触屏拖拽、批量选择与批量条、
以及 [hidden] 的 CSS 静态回归断言。
npm test 覆盖的用例:
- 健康检查经 MCP 返回数据库信息;
- 认证:未登录 401、注册入参校验、重复用户名、错误密码、me / 登出 / 重登、共享看板互见、改密后旧会话立即失效、新旧密码切换;
- 首次启动写入 10 条示例任务,覆盖五列与三档优先级;
- 新建任务:返回自增 ID、标签正确拆分、自动产生首条变更记录;
- 未填写失败原因移动到「失败」列被拒绝,且卡片停留原列;空白字符串同样被拒;
- 填写原因后移动成功,变更记录完整写入「原状态 / 新状态 / 时间 / 失败原因」;
afterId排序语义正确(A、B、C → 插到 A 后 → A、C、B);- 更新任务:字段与标签整体替换、离开失败列产生新记录;
- 编辑时改为「失败」但未填原因同样被拒;
- 删除成功,且总数回到 10 —— 证明数据未被重复初始化覆盖;
- 非法状态枚举被拒绝;
- 乐观并发控制:列表返回版本号、版本一致时更新成功并 +1、版本过期时更新/移动返回 409、冲突不覆盖他人改动;
- 批量操作:未选任务被拒、批量移入「失败」列未填原因被拒且三张卡停留原列、填写原因后成功、按勾选顺序追加到目标列尾部、变更记录写入批量原因、批量删除后总数回到 10、删除不存在的任务返回 400;
- 批量乐观锁(items 形态):版本全部过期时整体被拒并逐卡汇总、混合版本时正确的卡片成功而过期的进 failed 汇总。
- Node.js v22+ / macOS (Apple Silicon)
- MySQL 8.4
- MCP SDK v1.32.2
Q:启动报 数据库初始化失败?
检查 .env 中 MySQL 连接信息,并确认数据库可访问。若用 Docker,执行 docker compose ps 确认容器已 healthy(首次启动需等待约 20–30 秒)。
Q:想彻底重置示例数据?
DELETE FROM issue_tags;
DELETE FROM issue_status_logs;
DELETE FROM issues;
DELETE FROM board_meta WHERE meta_key = 'seeded_at';重启应用即会重新初始化。
Q:移动端怎么拖拽卡片? 长按卡片约 0.2 秒即可进入拖拽,手指拖动到目标列松手;靠近屏幕左右边缘会自动横向滚动,方便跨列投放。 直接滑动仍然是页面滚动,不会误触发拖拽。HTML5 原生拖拽在触屏上不被支持,因此触屏走的是 Pointer Events 实现, 落位逻辑与桌面端完全一致(同样受失败原因校验约束)。
Q:启动报 环境变量错误 JWT_SECRET?
.env 缺少 JWT_SECRET 或长度不足 16 位。生成一个:openssl rand -hex 32,写入 .env 后重启。
Q:忘记密码怎么办? 密码以 bcrypt 哈希存储、无法找回。管理员可直连数据库删除该用户行后重新注册;看板数据不受影响。
Q:部署在反向代理(nginx)后面要注意什么?
设 TRUST_PROXY=true(限流按真实客户端 IP 计数);HTTPS 时设 COOKIE_SECURE=true。
- SSE 实时推送:登录后自动订阅
/api/events——任何写操作(Web 或 AI)成功后 全端 <100ms 收到changed事件并自动重拉;AI 动作由分发器轮询兜底(≤10s); 30s 轮询保留为断线兜底。反向代理需关闭缓冲(X-Accel-Buffering: no已内置)。 - 服务端检索:
GET /api/issues/search——与 Web 筛选同源的维度 (q/priority/tag/assignee/aiOnly/boardId)+ 排序(sort/order)+ 分页(limit/offset), 返回total。列表视图的数据源即此端点:筛选/排序/分页全部服务端化, 大数据量下列表视图不再受客户端内存约束;看板视图保持全量(拖拽排序语义依赖)。 - issue_list 安全阀:
?limit=&offset=可选截断(缺省全量)。
支持第三方授权码登录(当前内置 GitHub;注册表已为飞书/企微预留)。配置环境变量后, 登录卡片自动出现「使用 GitHub 登录」按钮:
# .env(GitHub → Settings → Developer settings → OAuth Apps 创建应用)
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx
# 回调地址填:http://<你的部署地址>/api/auth/oauth/github/callback
# (可选)指定只接受某分支推送的 webhook 分支:GITHUB_WEBHOOK_BRANCH=main- 新用户:第三方登录自动建号(
gith_<login>用户名、随机不可登录口令), 可之后用「改密」设置密码; - 老用户绑定:登录后再次点击第三方登录,身份绑定到当前账号;
- 冲突保护:第三方身份已被其他账号绑定时,登录态下回调返回 409 而非静默切号;
- 安全:state 双保险(HMAC 签名 10 分钟时效 + httpOnly cookie 双比对)、 client_secret 永不出服务、身份表唯一约束。
看板所有者在顶栏「看板切换器 → Webhook 通知」配置端点。事件(状态流转 / 指派 / 评论) 发生时由分发器向订阅端点 POST JSON——人与 AI 的动作都在同一条事件流上(AI 经 MCP 的写操作同样外发,身份快照为其自报名)。
通用格式(generic):
{
"event": "status",
"title": "「#12 修复登录」未规划 → 进行中",
"actor": "ai:my-agent",
"issueId": 12,
"boardId": 1,
"createdAt": "2026-11-08T12:00:00.000Z"
}配置 secret 后携带防伪造签名头:X-CloudIssue-Signature: HMAC-SHA256(secret, body)、
X-CloudIssue-Event: <事件类型>。
IM 机器人:直接粘贴官方文档给出的 Webhook 地址(钉钉加签机器人把 SEC 密钥填入 secret 即可,分发器自动完成 timestamp+sign)。
可靠性:每个(通知 × 端点)独立记录投递状态,失败重试最多 5 次,不影响站内通知本身。
npm run backup # mysqldump 导出 + 各表行数 manifest → ./backups/- 产物:
backups/cloudissue-<时间戳>.sql.gz+ 同名.manifest.json(行数清单) - 保留策略:
KEEP_BACKUPS(默认 14 份),超出自动清理旧档 - 连接参数取自
.env(DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME)
npm run backup:verify -- backups/cloudissue-<时间戳>.sql.gz脚本会起一个临时 MySQL 实例(独立 datadir,不碰生产库),导入备份后做三层校验:
核心表齐全 → 各表行数与 manifest 逐一比对 → 子表无孤儿行。退出码 0 才算备份可信。
每次 push,CI 的 backup-restore job 都会自动跑一遍这个流程。
docker compose --profile tools up -d backup # 每 6h(BACKUP_INTERVAL 可调)备份到 backups 卷
docker compose --profile tools run --rm backup /scripts/backup.sh # 手动触发一次docker compose down(停止写入)- 重建数据库卷:
docker volume rm cloudissue_mysql-data && docker compose up -d mysql - 导入备份:
gunzip -c backups/cloudissue-<ts>.sql.gz | docker exec -i cloudissue-mysql mysql -uroot -p<MYSQL_ROOT_PASSWORD> issue_board npm run backup:verify -- backups/cloudissue-<ts>.sql.gz(对恢复结果再验一遍)docker compose up -d app