Mini-Kanban
拖拽式任务看板:Next.js + @dnd-kit 前端 + Go Gin + GORM + PostgreSQL 后端,自带回收箱与操作日志
技術栈
- Next.js 14
- TypeScript
- Tailwind CSS
- @dnd-kit
- Go
- Gin
- GORM
- PostgreSQL
- Redis
- JWT
包含登录、用户 CRUD、部门管理、项目 CRUD、任务 CRUD、四列拖拽看板、附件上传、回收箱(软删除)与操作日志(含物理删除快照)。
界面预览
登录(支持中英文与浅色 / 深色主题)
四列拖拽看板(待办 / 进行中 / 已完成 / 已验收,任务卡带负责人、附件数与截止时间,可按「全部 / 我创建的 / 指派给我的」筛选)
任务详情(富文本描述、附件列表、负责人 / 所在列 / 截止时间)
项目列表与项目资料(资料支持富文本、内嵌图片、附件与按标题检索)
回收箱(软删除数据的恢复 / 彻底删除,项目条目带同批任务数)
用户管理与操作日志(管理员)
深色主题
目录结构
server/ Go Gin 后端(模块名 mini-kanban)
main.go 启动入口:加载配置 / 未配置时进入初始化向导
config.example.yaml 配置模板(入库)
config.yaml 本地配置(.gitignore 忽略)
internal/config 配置加载、校验、向导渲染写回
internal/setup 首次启动的配置向导(页面 + 探测 + 写回)
internal/db GORM 连接、建表 / 补列 / 补索引、初始管理员
internal/cache Redis 封装(Get/Set/DeletePrefix)
internal/i18n 接口文案多语言:语言协商 + 英文目录(以中文原文为键)
internal/storage 附件落盘、扩展名白名单、随机文件名
internal/models User / Department / Project / Task /
ProjectMember / TaskAttachment / OperationLog
internal/middleware Auth(JWT)、Admin、CORS、Redis GET 缓存
internal/handlers auth / user / project / task / project_doc /
department / trash / file / log
internal/oplog 操作日志写入 + 物理删除的数据快照
internal/router 路由与中间件集中注册
internal/utils JWT、邮箱与密码校验、文件名清理
webstatic.go 托管 web-dist 目录里的前端静态站点(NoRoute + SPA 兜底)
web-dist/ 前端静态导出产物目录(构建生成,.gitignore 忽略)
data/uploads/ 附件存放目录(storage.dir,.gitignore 忽略)
build.sh 交叉编译并打包可分发归档(带 web-dist 目录,产物名带版本号)
build-web-static.sh 前端静态导出并拷进 server/web-dist(build.sh 的前置步骤)
build-web-static.bat ↑ 的 Windows 版(GBK 编码,可双击)
tools/release/ 归档里的启动脚本(start.sh / start.bat)
docs/screenshots/ README 用的界面截图
docs/release-notes/ 各版本 Release 说明(推标签时自动取用)
web/ Next.js 前端(App Router + TS + Tailwind)
app/login 登录
app/ 首页(按登录态跳转 /projects 或 /login)
app/projects 项目列表(增删改)
app/projects/[id] 看板(四列拖拽 + 附件 + 富文本)
app/departments 部门管理(管理员)
app/users 用户管理(管理员)
app/trash 回收箱(恢复 / 彻底删除)
app/logs 操作日志(管理员,含数据快照查看)
components/Board.tsx @dnd-kit 跨列 / 同列排序
components/ AuthProvider / AuthGuard / NavBar / TaskCard /
TaskModal / ProjectModal / ProjectDocsModal /
MemberPicker / AttachmentList / RichTextEditor /
Modal / ConfirmProvider / UserSelect /
ThemeProvider / ThemeToggle /
I18nProvider / LanguageToggle /
DocumentTitle(标签页标题)
lib/i18n/ 多语言:zh-CN.ts(源语言 + 全部键)/
en.ts / index.ts(协商、插值、持久化)/
pageTitle.ts(路由 → 页面名)/ server.ts(服务端语言与页面 metadata)
lib/labels.ts 业务枚举 → 文案(列名 / 角色 / 日志类型与动作)
lib/api.ts 请求封装、token(localStorage)、fileUrl
lib/types.ts 类型与看板列定义(COLUMNS)
lib/html.ts · date.ts · validate.ts 富文本处理 / 日期 / 表单校验
下载与部署(最快路径)
发布包在 Releases,每个版本只有一种产物:
一个可执行文件 + 一个 web-dist/ 页面目录,由同一个进程、同一个端口提供接口与页面,
目标机不需要 Node、不需要额外的静态目录,也不需要 nginx。
| 平台 | 产物 |
|---|---|
| Windows x64 / ARM64 | mini-kanban-v1.0.0-windows-amd64.zip / mini-kanban-v1.0.0-windows-arm64.zip |
| Linux x64 / ARM64 | mini-kanban-v1.0.0-linux-amd64.tar.gz / mini-kanban-v1.0.0-linux-arm64.tar.gz |
| macOS Intel / Apple Silicon | mini-kanban-v1.0.0-darwin-amd64.tar.gz / mini-kanban-v1.0.0-darwin-arm64.tar.gz |
文件名里的版本号就是发布版本号(例如
v1.0.0),顶层目录与压缩包同名,包内还有一份version.txt; 启动脚本启动时会打印版本号,所以下载的是哪一版一目了然。checksums.txt是全部归档的 SHA256, 可用sha256sum -c checksums.txt校验。
三步跑起来:
- 完整解压对应平台的归档(
web-dist/必须与可执行文件在同一目录); - 运行
start.sh(Windows 双击start.bat); - 浏览器打开 http://localhost:8281。目录里没有
config.yaml时会自动进入初始化向导 (http://localhost:8281/setup),填好数据库 / Redis 并保存,配置写入后弹窗显示服务就绪倒计时, 3.5 秒后自动打开状态页。
前置条件只有一个:PostgreSQL 数据库需要提前创建(服务只建表不建库);Redis 可选,留空即关闭缓存。
想自己打包 / 改前端后再打包,见下面的「部署」章节;只想跑起来的话上面这三步就够了。
环境要求
- Go 1.21+
- Node.js 18.17+(Next.js 14 要求)
- PostgreSQL(数据库需提前创建,服务只建表不建库)
- Redis(可选:
redis.host留空即关闭缓存,服务照常运行)
快速开始
命令约定:标
bash的代码块在 macOS / Linux 终端可直接执行,Windows 用户在 PowerShell 里同样可用(cd、go run .、npm run dev这类命令是通用的); 存在平台差异的操作(复制文件、交叉编译)会分别给出两版。
方式一:初始化配置向导(推荐首次部署)
不提供 config.yaml 时,后端会先启动一个只用于填写配置的临时服务:
cd server
go run .
浏览器打开 http://localhost:8281/setup(端口可用 SETUP_PORT 指定;在服务器上启动时,日志会打印可远程访问的 IP)。
向导提供:数据库 / Redis 连接测试、配置校验与写回(按 config.example.yaml 模板渲染,保留注释与排版)。数据库连不上会拦下保存,可勾选忽略;Redis 连不上只提示,缓存降级运行。保存成功后向导自动关闭,业务服务按正式配置启动。
重新配置:用 SETUP_FORCE=1 启动,原配置会备份为 config.yaml.bak。已配置完成后访问 /setup 会看到「服务运行正常」的反馈页。
注意:向导表单只包含
server/database/redis/jwt/seed五段;storage与database.logLevel刻意不进表单,向导写回时按模板值补齐,需要修改请直接编辑config.yaml。
方式二:手动复制模板
# macOS / Linux
cd server
cp config.example.yaml config.yaml
cd ../web
cp .env.example .env.local
# Windows PowerShell
cd server
Copy-Item config.example.yaml config.yaml
cd ../web
Copy-Item .env.example .env.local
配置文件
| 文件 | 作用 |
|---|---|
server/config.yaml |
后端配置(已被 .gitignore 忽略,不入库):端口/模式、PostgreSQL、Redis 与缓存时长、JWT、初始管理员、附件存储、日志级别 |
server/config.example.yaml |
配置模板,同时是初始化向导写回配置时的底稿(向导会保留其注释与排版) |
web/.env.local |
前端本地配置(已被 .gitignore 忽略,不入库):NEXT_PUBLIC_API_BASE(后端接口地址) |
web/.env.example |
前端环境变量模板,首次使用复制为 .env.local |
server/config.yaml 完整字段:
server:
port: "8281" # 必填。HTTP 监听端口(字符串)
mode: "release" # 必填。debug | release | test,其他值启动报错
database:
# dsn 非空时优先使用(此时下面 host/port/user/dbname/sslmode 不再校验)
dsn: "" # 例:postgres://postgres:123456@localhost:5432/mini-kanban?sslmode=disable
host: "localhost" # dsn 留空时必填
port: 5432 # dsn 留空时必填
user: "postgres" # dsn 留空时必填
password: "123456" # dsn 留空时必填;含特殊字符无需手工转义(拼装时自动 URL 编码)
dbname: "mini-kanban" # dsn 留空时必填;库需提前创建,服务只建表不建库
sslmode: "disable" # dsn 留空时必填
preferSimpleProtocol: false # 出现 pgx 协议兼容问题时设为 true(关闭扩展查询协议)
logLevel: "warn" # silent | error | warn | info,其他值启动报错;向导不展示
redis:
# 可选组件:host 留空即关闭缓存(其余字段不再校验,服务照常运行)
host: "localhost"
port: 6379 # host 非空时必填
password: "" # 无密码留空
db: 0
cacheTTL: "30s" # host 非空时必填且需 > 0:项目 / 任务列表缓存时长
userCacheTTL: "1m" # host 非空时必填且需 > 0:用户 / 部门列表缓存时长
jwt:
secret: "换成足够随机的长字符串" # 必填;修改后已签发的 token 全部失效
ttlHours: 24 # 必填且需 > 0;token 有效期(小时)
seed:
# 仅在「用户表为空」时写入初始管理员;写入成功后自动清空本段(避免明文密码留在磁盘)
enabled: false # 设为 true 时下面三项必填
adminEmail: "" # 初始管理员登录邮箱
adminUsername: "" # 初始管理员显示名
adminPassword: "" # 初始管理员密码,建议 8 位以上
storage:
# 附件与富文本内嵌图片目录;向导不展示,只能在本文件里维护
dir: "data/uploads" # 必填;相对路径按后端工作目录解析,不存在会自动创建(需可写)
maxSizeMB: 10 # 必填且需 > 0;单次最多 10 个文件,请求体上限 = maxSizeMB × 10
加载规则:
- 查找顺序:
CONFIG_FILE环境变量 →config.yaml→config.yml→configs/config.yaml→server/config.yaml→../config.yaml→../server/config.yaml(因此从仓库根目录或server/目录启动都能找到) - 环境变量优先级更高:
PORT、DB_DSN、REDIS_ADDR(host:port)、REDIS_PASSWORD、JWT_SECRET - 没有代码内兜底值:必需项缺失或格式非法会直接报错退出,不会静默使用默认值;
redis.host留空是「关闭缓存」的合法写法 - 其他环境变量:
SETUP_FORCE(强制重新进入初始化向导,原配置备份为.bak)、SETUP_PORT(向导自身端口,默认 8281)、SNOWFLAKE_NODE_ID(雪花 ID 节点号,0~1023;多实例部署时必须各不相同,否则各实例生成的 ID 会冲突,单机保持默认 0 即可)
更详细的逐字段说明(含取值范围与注意事项)见 server/config.example.yaml。
前端 web/.env.local:
NEXT_PUBLIC_API_BASE=http://localhost:8281/api # 端口需与 server.port 一致
NEXT_PUBLIC_*是构建期内联的:改了地址必须重新npm run build,运行时再设置环境变量无效(详见「部署」章节)。
启动
后端:
cd server
go run . # 或先编译:go build -o mini-kanban .
启动日志会打印端口、数据库、Redis 与缓存状态;GET /healthz 返回服务与 Redis 状态。
前端:
cd web
npm install
npm run dev # http://localhost:3201
如后端不在本机 8281,修改 web/.env.local:
NEXT_PUBLIC_API_BASE=http://localhost:8281/api
初始管理员账号
默认不写入任何账号(seed.enabled: false)。需要初始化时先开启并填好三项:
seed:
enabled: true
adminEmail: "[email protected]"
adminUsername: "Admin"
adminPassword: "至少 8 位"
规则:
- 仅当用户表为空时创建;已有用户则跳过,启动日志会打印创建的邮箱
- 创建成功后会自动把
seed段清空(enabled=false,邮箱 / 用户名 / 密码置空),避免初始密码长期以明文留在磁盘上;需要再次 seed 时重新填写 - 其余成员账号由管理员在「用户管理」页面创建
附件存储
任务附件、项目资料附件与富文本内嵌图片统一落在 storage.dir:
-
单次最多 10 个文件,整个请求体上限 =
storage.maxSizeMB × 10 -
扩展名白名单:图片(png / jpg / jpeg / gif / webp / bmp / ico)、文档(pdf / txt / md / csv / doc / docx / xls / xlsx / ppt / pptx)、压缩包(zip / 7z / rar / gz)、音视频(mp4 / mp3 / wav)。
.svg不允许(可携带脚本) -
落盘文件名由服务端生成:32 位随机 hex + 白名单扩展名,用户提供的文件名不进入路径(防目录穿越)
-
对外地址为
/api/files/<32位随机名>,前端用fileUrl()按 API 基址补全 -
下载路由 需要登录态,但 URL 里带的不是登录 token,而是一张专用的「下载票据」:登录后前端调
POST /api/files/ticket换取(aud=file,默认 2 小时),再拼成/api/files/<随机名>?ticket=... -
这样隔离的原因是富文本
<img src>与附件链接由浏览器直接发起、带不了Authorization头,凭据只能进 URL:票据只能读附件、不能调用其他接口,所以下载地址被转发 / 记录也不等于登录态泄露。登录 token 不会出现在任何 URL 中 -
票据 JTI 与登录会话的 jti 相同:登出后登录 token 进 Redis 黑名单,票据随之立即失效
-
下载还要过数据权限(票据只说明「谁在下载」,不说明「能不能下这个文件」): 服务端按文件的引用关系反查归属,再套用对应入口的可见性,原则是**「即看即下」**——
- 资料附件、资料正文里的内嵌图片:按资料所属项目的可见性(能看到项目就能看资料);
- 任务附件、任务描述里的内嵌图片:按任务可见性(管理员 / 任务创建人 / 被指派人 / 项目创建人), 即看板里看得见这个任务的人才能下它的附件。
引用它的数据已被删除(资料 / 任务软删)时一律 404;还没被任何数据引用的临时文件 (编辑器里刚粘贴的图片、上传后尚未保存的附件)只允许上传者本人下载 —— 上传时会在
uploaded_files登记上传者。鉴权通过但没有数据权限时同样返回 404,不泄露文件是否存在 -
票据用独立密钥签名:密钥由
jwt.secret经 HMAC-SHA256 单向派生(标签mini-kanban/file-ticket/v1),不新增配置项。因此登录 token 过不了下载校验、票据也过不了登录校验,即使某一侧漏检aud也无法互换使用;aud=file与AuthRequired的显式拒绝作为纵深防御 -
两类凭据统一限定
HS256且必须带exp(防算法混淆与永不过期票据) -
前端
AuthProvider负责换取票据并在临近过期时静默续期(fileUrl()/resolveFileUrls()自动拼上当前票据,TaskModal在票据续期后刷新描述里的图片地址);富文本保存前toStoredHtml()会把票据剥掉,入库的仍是纯/api/files/<随机名> -
响应带
X-Content-Type-Options: nosniff;鉴权失败为Cache-Control: no-store,正常响应为private, max-age=3600(避免被代理 / CDN 缓存后绕过鉴权)。图片 / 视频内联展示,其他类型强制下载并尽量还原原始文件名
接口
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/auth/login | 登录,返回 JWT |
| GET | /api/auth/me | 当前用户 |
| POST | /api/auth/logout | 登出(token 加入 Redis 黑名单) |
| PUT | /api/auth/profile | 修改本人资料(用户名 / 邮箱 / 密码,不含角色);前端暂无入口 |
| GET | /api/files/:name | 附件下载(需登录 + 数据权限:?ticket=,见「附件存储」) |
| POST | /api/files/ticket | 换取附件下载票据(登录态,默认 2 小时) |
| GET | /api/departments | 部门列表 |
| GET | /api/departments/:id | 部门详情 |
| POST | /api/departments | 新建部门(管理员) |
| PUT | /api/departments/:id | 编辑部门(管理员) |
| DELETE | /api/departments/:id | 删除部门(管理员,有成员时拒绝) |
| GET | /api/users | 用户列表 |
| GET | /api/users/:id | 用户详情 |
| POST | /api/users | 新建用户(管理员) |
| PUT | /api/users/:id | 编辑用户(管理员) |
| DELETE | /api/users/:id | 删除用户(管理员,含数据快照) |
| GET | /api/projects | 项目列表 |
| POST | /api/projects | 新建项目 |
| GET | /api/projects/:id | 项目详情 |
| PUT | /api/projects/:id | 编辑项目 |
| DELETE | /api/projects/:id | 软删除项目 |
| GET | /api/projects/:id/tasks | 任务列表 |
| POST | /api/projects/:id/tasks | 新建任务 |
| GET | /api/projects/:id/docs | 项目资料列表(需求文档 / 会议纪要 / 设计稿 / 项目密钥 / 测试账密等) |
| POST | /api/projects/:id/docs | 新建项目资料(项目成员) |
| PUT | /api/docs/:id | 编辑项目资料(项目成员中的资料创建人 / 项目创建人 / 管理员) |
| DELETE | /api/docs/:id | 删除项目资料(软删除,权限同上,可在回收箱恢复) |
| POST | /api/uploads | 附件上传(multipart/form-data,字段名 files,最多 10 个) |
| GET | /api/tasks/:id | 任务详情 |
| PUT / PATCH | /api/tasks/:id | 编辑任务 |
| DELETE | /api/tasks/:id | 软删除任务 |
| PATCH | /api/tasks/:id/move | 看板拖拽落位({status, index}),服务端重排 position |
| GET | /api/trash | 回收箱:已删项目 + 已删任务 + 已删项目资料(附同批数量 / 所属项目标题) |
| POST | /api/trash/projects/:id/restore | 恢复项目(连同同批删除的任务、资料与附件) |
| DELETE | /api/trash/projects/:id | 彻底删除项目(物理删除项目及其任务、资料、附件、成员关联) |
| POST | /api/trash/tasks/:id/restore | 恢复任务(连同同批删除的附件) |
| DELETE | /api/trash/tasks/:id | 彻底删除任务(物理删除任务及附件) |
| POST | /api/trash/docs/:id/restore | 恢复项目资料(连同同批删除的附件) |
| DELETE | /api/trash/docs/:id | 彻底删除项目资料(物理删除资料及附件) |
| GET | /api/logs | 操作日志分页(管理员):targetType / action / operatorId / keyword / page / pageSize(默认 20,上限 100) |
| GET | /api/logs/:id | 操作日志详情(管理员),物理删除的条目含 snapshot 数据快照 |
| GET | /healthz | 健康检查(含 Redis 状态) |
| GET | /setup | 配置完成后的反馈页 |
回收箱(软删除)
删除项目 / 任务 / 项目资料只写 deleted_at,数据留在库中,默认查询自动过滤(GORM 软删除)。
同一批被删的记录共用同一个 deleted_at 时间戳,恢复时按该时间戳精确还原:
- 恢复项目会带回随它一起删掉的任务、项目资料与附件;在项目删除之前单独删掉的任务 / 资料 不会被误恢复,它们仍留在回收箱的任务区 / 资料区,可以单独恢复。
- 项目删除时任务与资料跟着删,它们只在回收箱的项目条目里体现(
taskCount/docCount就是恢复时会回来的任务数与资料数),不重复出现在任务区 / 资料区。 - 资料与任务一样支持单独恢复 / 彻底删除;随项目一起被删的资料与项目同批次,恢复项目时一并还原。
- 「彻底删除」是物理删除(
Unscoped),项目会连带其下全部任务、项目资料、附件与成员关联一起清掉,不可恢复, 因此会在操作日志里留下整棵数据的快照(见下节)。 - 可见范围与处置权限对称:管理员看全部,其他用户只看自己有权处置的数据(项目按创建人; 任务按创建人 / 被指派人 / 项目创建人;资料按资料创建人 / 项目创建人,且必须仍是项目成员)。
操作日志
覆盖项目 / 任务 / 项目资料 / 用户 / 部门五类对象的操作留痕,写库表为 operation_logs,
在「操作日志」页面(仅管理员可见)按类型 / 动作 / 关键词检索。
- 记录内容:操作人(id + 当时的用户名快照)、对象类型与名称、动作、变更摘要、时间。
摘要由服务端按字段级差异生成,例如
状态:待办 → 进行中;负责人:张三 → 李四; 富文本正文只在变更时提示「描述已更新」,不把正文写进日志。 - 动作取值:
create新建、update修改、move看板拖拽改状态(仅任务)、delete删除、restore回收箱恢复、purge回收箱彻底删除。 - 物理删除留数据快照:
delete用户、delete部门、purge项目 / 任务 / 项目资料这几类操作会把被删数据 整份序列化进snapshot(JSON 文本列),因为删掉之后无从追溯:- 用户快照:账号信息(不含密码)+ 所属部门名 + 被指派 / 创建任务数、拥有项目数、项目成员关联数;
- 部门快照:部门信息 + 删除时剩余人数(有成员时不允许删除,正常为 0);
- 项目快照:项目 + 成员 + 其下全部任务与项目资料及其附件(彻底删除项目会连带清掉这些数据);
- 任务快照:任务 + 全部附件 + 所属项目标题;
- 资料快照:项目资料 + 全部附件 + 所属项目标题。
软删除(
delete项目 / 任务 / 项目资料)的数据仍在库中,只记动作、不留快照。
- 写入时机:普通操作在业务成功后异步补记(失败只打服务端日志,不影响接口结果); 物理删除与它的快照写在同一个事务里,避免出现「数据删了、快照却没留下」。
- 日志只存操作人 id 与用户名快照、不建外键关联,因此删除用户不会连带删掉它的历史日志。
- 列表接口不下发
snapshot(单条可能很大,彻底删除项目会带上全部任务), 点「查看」调详情接口取单条。日志接口不挂缓存中间件,保证审计实时性。
数据库迁移
服务启动时自动执行,无需手动操作:
- 建表与补列:按
Department → User → Project → Task → ProjectMember → TaskAttachment → ProjectDoc → ProjectDocAttachment → UploadedFile → OperationLog顺序建表;已存在的表按模型字段补缺失列(规避 PostgreSQL 18 + pgx 组合下 AutoMigrate 的报错) - 补索引:项目 / 任务 / 附件 / 项目资料及其附件的
deleted_at软删除索引按需创建 - 数据补齐:老任务缺失的
creator_id回退为所属项目创建人(幂等,只补空值) - 首次出现
project_members表时,把已有项目初始化为「全员可见」,避免历史项目突然对所有人不可见
前端页面与权限
| 路由 | 页面 | 可见范围 |
|---|---|---|
| /login | 登录 | 公开 |
| / | 首页(按登录态跳转) | — |
| /projects | 项目列表 | 登录用户 |
| /projects/[id] | 四列看板(待办 / 进行中 / 已完成 / 已验收)+ 顶部「项目资料」入口(需求文档 / 会议纪要 / 设计稿 / 项目密钥 / 测试账密,支持富文本、附件与按标题检索),任务可按「全部 / 我创建的 / 指派给我的」筛选 | 登录用户 |
| /trash | 回收箱(项目 / 任务 / 项目资料,可恢复或彻底删除) | 登录用户(按权限过滤) |
| /users | 用户管理 | 管理员 |
| /departments | 部门管理 | 管理员 |
| /logs | 操作日志 | 管理员 |
前端支持浅色 / 深色主题切换,token 存 localStorage(键名 mini-kanban_token),401 时自动清除并跳回登录页。
多语言
界面支持简体中文与 English 两种语言,默认中文;语言切换按钮在导航栏右上角(登录页也有), URL 不随语言变化,刷新 / 分享出去的链接对方看到的是自己 cookie 里的语言。
语言怎么存、怎么生效
- cookie
mini-kanban-lang(值zh-CN/en,一年,SameSite=Lax,HTTPS 下加Secure)是唯一口径: 根布局在服务端读它,用来定<html lang>与页面标题,正文文案也跟着首屏就渲染成对应语言, 不会出现「先闪一下中文再切成英文」。localStorage里存一份同名镜像,只在 cookie 被清掉时兜底。 - 切换语言时 cookie / localStorage /
<html lang>/ 请求头一起更新,无需刷新页面。 - 标签页标题固定是
页面名 - {app.title}(如登录 - Mini-Kanban):页面名在各自路由段的layout.tsx里用pageMetadata()声明成键(页面本身是"use client",不能导出metadata),客户端跳转 / 切换语言时 由DocumentTitle按同一张路由映射刷新,两处共用lib/i18n/pageTitle.ts。 - 代价是路由从静态预渲染变为按请求渲染(用了
cookies())。前端本来就依赖登录态取数,影响可忽略。 - 静态导出模式下这一条不成立(
output: "export"没有请求上下文):首屏只能按默认语言渲染, 由<head>里的内联脚本先纠正<html lang>,挂载后I18nProvider再按 cookie / localStorage 切换文案 —— 因此非默认语言会闪一下。发布包用的就是静态导出(页面要随包分发); 如果更在意首屏不闪,就用仓库根的 Docker Compose 那套部署,服务端渲染版本不受这条限制。
前端文案
| 文件 | 作用 |
|---|---|
web/lib/i18n/zh-CN.ts |
源语言,同时是全部文案键的定义(域.用途 扁平点号,{name} 是插值占位符) |
web/lib/i18n/en.ts |
英文,类型是 Record<MessageKey, string> —— 中文新增 / 改名的键这里没跟上,npm run build 直接报缺键 |
web/lib/i18n/index.ts |
语言常量、字典查找与插值、cookie / localStorage 读写、tNow() |
web/lib/i18n/pageTitle.ts |
路由 → 页面名文案键的映射,以及 页面名 - {app.title} 的拼装(服务端与客户端共用这一张表) |
web/lib/i18n/server.ts |
服务端读语言 cookie(currentLang())与路由段 layout 用的 pageMetadata() |
web/components/DocumentTitle.tsx |
客户端按当前语言刷新标签页标题(静态导出产物构建期只能写死默认语言) |
web/components/I18nProvider.tsx |
全局语言 Provider:useI18n() 取 { lang, locale, setLang, t } |
web/lib/labels.ts |
业务枚举 → 文案(看板列名、角色、操作日志的对象类型 / 动作),未知值原样回显 |
用法约定:
- 渲染时用
const { t } = useI18n(),带参数时t("key", { name }); - 非渲染路径(
lib/api.ts的兜底文案、异步回调里的失败提示)用tNow(...)—— 它是模块级的当前语言,不参与渲染,因此不会因为切语言触发无谓的重新取数; - 日期时间统一走
lib/date.ts的formatDateTime(iso, locale),locale取useI18n().locale, 中文输出2026/09/20 14:30、英文输出09/20/2026, 14:30; - 涉及数量的英文文案刻意写成
Tasks: {count}这类,避开单复数变化,因此没有引入 ICU 复数规则。
新增一种语言:加一份字典 → 在 lib/i18n/index.ts 的 LANGS / DICTS / LANG_LABELS /
LANG_LOCALES 里登记 → 切换控件自动多出一个选项。
后端接口文案
后端的 error / message 也按语言返回,协商顺序(internal/i18n):
Accept-Language头 —— 前端lib/api.ts的每个请求都会带上当前界面语言;mini-kanban-langcookie —— 附件下载(/api/files/xxx?ticket=)由浏览器直接发起、带不了自定义头,靠它兜底;- 都没有 → 中文。
英文目录在 server/internal/i18n/messages.go,以中文原文为键,因此 fail / badRequest /
okMessage 这些出口内部统一查表即可,调用方零改动;带动态内容的消息(保存失败: <详情>、
一次最多上传 10 个文件、附件「a.png」不存在)用「前缀 + 尾缀 + 「」」三类规则拼装。
未收录的原文(例如数据库返回的英文错误)原样透传。
护栏:go test ./internal/i18n/ 会扫描 internal/handlers 里写死的中文文案,
逐条确认能在英文目录里找到译文 —— 新增 / 修改文案忘记翻译会在测试里直接失败。
已知边界(刻意不做):
- 操作日志的
summary/ 变更项与数据快照是写入时落库的文本,保持中文: 否则同一条日志会随查看者语言变化,审计语义就不稳了。日志页其余文案(表头、筛选、分页)都是英文的。 - 初始化配置向导
/setup是一次性运维页面,保持中文。 Provider里useXxx 必须在 XxxProvider 内使用这类断言只会在控制台出现,不参与翻译。
Redis 的用途
- 缓存中间件:
middleware.Cache(ttl, namespace)缓存 GET 响应(按用户 + 路径 + 查询串生成 key), 命中时返回X-Cache: HIT;所有写操作通过cache.DeletePrefix("httpcache:<namespace>:")主动失效。 - 登出黑名单:
auth:blacklist:<jti>保存至 token 自然过期,鉴权中间件会拒绝已登出 token。 - Redis 不可用时自动降级为无缓存模式,服务仍可运行(
GET /healthz返回redis状态)。
自检与测试
改动后端或前端后,建议先跑这几项再部署:
# 后端:静态检查 + 单元测试
cd server
go vet ./...
go test ./...
其中 internal/i18n 的测试是一道文案护栏:它扫描 internal/handlers 里写死的中文文案,
逐条确认英文目录(internal/i18n/messages.go)里有对应译文 —— 新增接口文案忘了翻译会在这里失败。
# 前端:类型检查(不产出文件)
cd web
npx tsc --noEmit
前端的多语言完整性也由类型系统兜底:lib/i18n/zh-CN.ts 导出 MessageKey 联合类型,
en.ts 声明为 Record<MessageKey, string>,所以英文缺键、组件里写错文案键都会在 tsc --noEmit(以及 npm run build)阶段直接报错。
同理,lib/api.ts 的请求参数与返回类型写错也会被这一步拦下。
服务起来后的快速冒烟:
curl http://localhost:8281/healthz # {"redis":true,"status":"ok"}
部署
发布的唯一产物是「开箱即用」包:后端可执行文件 + web-dist/ 页面目录,
由同一个进程、同一个端口提供接口与页面。目标机不需要 Node、不需要额外的静态目录,也不需要 nginx 与 CORS。
为什么不把前端
go:embed进二进制:那样每个平台的可执行文件会凭空多出约 1MB, 六平台交叉编译时这部分要被反复写进每一个产物,而且改一次前端就要把 Go 侧全量重编一遍。 改成随包分发后,可执行文件里只剩业务代码,页面文件还能被网关 / CDN 直接读盘缓存。 代价是web-dist/必须与可执行文件同目录(可以用WEB_DIST_DIR指向别处); 缺了它服务仍能启动,但只提供接口、非/api路径返回 404。
源码层面仍支持把前端单独跑起来(
npm run dev联调、或仓库根的 Docker Compose), 但发布只发这一个包,避免用户挑错。Docker Compose 那套是给容器化部署用的另一条路,与发布包无关。
自己打一个发布包
bash build-web-static.sh # 1) 前端静态导出到 web/out,再拷进 server/web-dist
bash build.sh -v v1.0.0 # 2) 交叉编译 + 打包(把 web-dist/ 拷进每个归档)
Windows 上等价于:
build-web-static.bat :: 静态导出并拷进 server\web-dist
bash build.sh -v v1.0.0 :: Git Bash 里跑;也可以 cd server && go build -o mini-kanban.exe .
产物落在 dist/,每个平台一个归档,顶层目录名与归档名一致:
dist/
├── mini-kanban-v1.0.0-windows-amd64.zip
├── mini-kanban-v1.0.0-windows-arm64.zip
├── mini-kanban-v1.0.0-linux-amd64.tar.gz
├── mini-kanban-v1.0.0-linux-arm64.tar.gz
├── mini-kanban-v1.0.0-darwin-amd64.tar.gz
├── mini-kanban-v1.0.0-darwin-arm64.tar.gz
└── checksums.txt # 全部归档的 SHA256(sha256sum -c 可校验)
每个归档解开是:
mini-kanban-v1.0.0-linux-amd64/
├── mini-kanban(.exe) 可执行文件(只有后端代码)
├── web-dist/ 前端静态产物,必须与可执行文件同目录
├── start.sh / start.bat 启动脚本(打印版本号,异常退出时列出常见原因)
├── version.txt 本包版本号
├── config.example.yaml 配置模板(复制为 config.yaml 后按环境修改)
├── README.md / README.en.md
└── LICENSE
-v省略时自动取最近的 git 标签,所以在打好标签的工作区里直接bash build.sh也不会漏版本号; 版本号里的非法字符会被拒绝(避免拼出奇怪的文件名)。- 前端静态产物是硬前提:
server/web-dist/index.html不存在时build.sh直接报错退出, 不会再产出「只有接口没有页面」的包。 - 改前端不用重编 Go:
web-dist不参与编译,next build完直接bash build.sh即可, Go 侧命中编译缓存;这比go:embed方案快得多(embed 的内容一变,整个包就要重编)。 - Git Bash 默认不带
zip命令,build.sh会自动退回用 Python 的zipfile打 Windows 归档,产物结构一致。 - 推送
v*标签即触发 GitHub Actions:跑静态检查与单元测试 → 构建前端 → 交叉编译 → 校验产物 (校验和、数量、文件名是否带标签、包内是否带web-dist/index.html与README.en.md) → 解包真跑一遍冒烟(页面、深链、缓存头、接口 404)→ 创建 / 更新 Release。
跑起来是什么样
浏览器 → mini-kanban :8281
├── /api/* → 接口
├── /healthz → 健康检查
├── /setup → 初始化向导
└── 其他 → web-dist 里的静态页面(未命中的前端路由回退 index.html)
- 实现见
server/webstatic.go:启动时ResolveWebDist()解析一次目录(默认工作目录下的web-dist/, 可用WEB_DIST_DIR覆盖),结果挂在r.NoRoute上,因此/api、/healthz、/setup仍由原路由优先匹配;/api/前缀未命中时返回 JSON 404,不会把页面当接口响应返回。 - 目录缺失时自动降级:启动日志写明
前端: 未找到(仅提供接口)并打印它找的那个绝对路径, 非/api路径返回 404,接口行为完全不变。发布包不会出现这种状态,build.sh已经卡住了这一步。 - 缓存策略:
/_next/static/*文件名带内容哈希,发max-age=31536000, immutable; 页面与其他产物发no-cache,避免换版本后浏览器拿着旧index.html去请求已删除的 chunk。 - 看板深链
/projects/<id>是唯一带动态段的路由:静态导出要求动态段至少预渲染一条路径才肯构建 (Next 用「预渲染路径数 > 0」判断页面有没有generateStaticParams),所以web/app/projects/[id]/page.tsx预渲染了一份占位页/projects/_/;server/webstatic.go把真实的/projects/<id>映射到这份占位页, 项目 id 由前端从地址栏解析(静态产物里useParams()拿到的只是构建期占位值, 见ProjectBoard.tsx的resolveProjectId)。 - 因为页面与接口同源,不再需要 nginx 与 CORS;要加 TLS / gzip 再在前面挂一层网关即可(见「前面挂网关」)。
方式一:Docker Compose 一键部署(容器化场景)
想用容器跑(自带 PostgreSQL / Redis,或想把前端单独扩多实例),走仓库根目录的 docker-compose.yml:
一次拉起 5 个服务 —— nginx(统一入口)、web(Next.js)、server(Go)、postgres、redis。
# macOS / Linux
cd <仓库根目录>
cp .env.example.docker-compose .env # 改掉里面的数据库 / Redis 密码与 JWT_SECRET
docker compose up -d --build
# 浏览器打开 http://<服务器IP>:8080(宿主机端口由 .env 的 HTTP_PORT 决定)
# Windows PowerShell
cd <仓库根目录>
Copy-Item .env.example.docker-compose .env # 改掉里面的数据库 / Redis 密码与 JWT_SECRET
docker compose up -d --build
# 浏览器打开 http://<服务器IP>:8080(宿主机端口由 .env 的 HTTP_PORT 决定)
Linux 服务器上也可以直接跑一键脚本,它会把上面几步连起来做(生成 .env、随机化数据库 / Redis 密码与 JWT 密钥、生成带随机密码的初始管理员、挑一个未被占用的宿主机端口、构建并启动),并且重复执行安全:
cd <仓库根目录>
# 下面两条二选一,首次部署前执行其中一条即可(管理员密码均由脚本随机生成)
bash deploy/deploy.sh # 默认管理员:[email protected] / Admin
bash deploy/deploy.sh --admin-email [email protected] --admin-user kevin # 自定义管理员的邮箱与显示名
管理员账号相关说明:
- 上面两条命令是二选一,不是先后执行:脚本只负责把邮箱 / 显示名写进
.env,而账号本身是后端首次启动(用户表为空)时才创建的。 - 密码由脚本随机生成(16 位字母数字,排除了
0 O 1 l I这类易混淆字符),同时也写进.env的SEED_ADMIN_*,忘了随时可查。只有全新数据库(数据卷不存在)时才会把密码打印到终端;重复部署时因为账号已经建好,只提示一句"账号已存在",不再输出密码。 - 该账号只在用户表为空时创建一次:重复执行脚本、后续重启或重建容器都不会重建它,也不会改掉已生效的密码;后端创建成功后还会把容器内
config.yaml的 seed 段自动清空。 - 想换邮箱或显示名,要在首次启动之前用
--admin-email/--admin-user指定;账号已经创建后再改,只能在「用户管理」页面里改。 - 不用脚本、走手工部署时,等价做法仍然是把
.env的SEED_ENABLED改为true并填好SEED_ADMIN_*三项。 - 忘了管理员密码:不必清库,执行
bash deploy/deploy.sh --reset-admin即可。它会把SEED_ADMIN_EMAIL指定的账号重置为管理员并换一个随机密码(该邮箱已不存在时重新创建),其他数据不受影响,新密码会打印在部署结果里。对应的后端配置是seed.forceReset(环境变量SEED_FORCE_RESET),只在本次启动生效——脚本启动后会自动把它改回false,后端处理完也会清空容器内config.yaml的 seed 段。
要点:
- 宿主机端口冲突:
HTTP_PORT(默认 8080)是整份 compose 里唯一会映射到宿主机、因而可能与既有服务冲突的端口;SERVER_PORT/DB_PORT/REDIS_PORT/ 前端 3201 都是容器内端口,与宿主机上跑着什么无关。冲突时改.env的HTTP_PORT(只重建 nginx:docker compose up -d nginx),或用bash deploy/pick-port.sh自动挑一个空闲端口写回.env(deploy/deploy.sh已内置这一步)。 - 后端配置由环境变量生成:容器入口脚本
server/docker-entrypoint.sh把SERVER_*/DB_*/REDIS_*/JWT_*/SEED_*/STORAGE_*渲染成/app/config.yaml。这么做是因为后端只认配置文件(config.seed段没有环境变量入口)。想完全自定义就挂载自己的config.yaml到/app/config.yaml,脚本检测到文件已存在会跳过生成。 - 前端接口地址是构建期内联的:
NEXT_PUBLIC_API_BASE经 compose 的 build args 传入,默认/api(同源反代,天然规避 CORS)。改了地址必须重建前端:docker compose up -d --build web。 web/.dockerignore排除了.env*:本地.env.local(http://localhost:8281/api)的优先级高于构建参数,一旦随构建上下文进镜像就会把 localhost 写进产物。- 数据都在命名卷里:
pgdata(数据库)、redisdata(AOF)、uploads(附件)。docker compose down不删卷,需要清空时加-v。 - 数据库密码只在数据卷首次初始化时生效:
POSTGRES_PASSWORD是 PostgreSQL 在数据卷为空时写入的,之后改.env不会同步到库里,表现是服务反复重启并报password authentication failed for user "mini-kanban" (SQLSTATE 28P01)。库里没有需要保留的数据时,最省事的修法是:bash deploy/deploy.sh --reset # 先 docker compose down -v,再重新部署(数据库 / Redis / 附件都会删除)deploy/deploy.sh检测到「数据卷已存在 + 密码仍是模板占位值」时会拦下并提示这一点;若是你自己改过密码导致对不上,则先docker compose down -v清卷,或把.env改回初始化时用的值。要在不删数据的前提下改密码:docker compose exec postgres psql -U postgres -c "ALTER USER \"mini-kanban\" WITH PASSWORD '新密码';"(容器内本地连接是 trust,不需要密码)。 - 常用命令:
docker compose ps看健康状态、docker compose logs -f server看后端日志、docker compose up -d --build <服务名>只重建单个服务。 - 附件上限有两处要同步:
.env的STORAGE_MAX_SIZE_MB(请求体上限 = 该值 × 10)与deploy/nginx.conf的client_max_body_size。 - 镜像拉取慢或失败:国内直连 Docker Hub 经常超时,报
TLS handshake timeout或EOF,这与本项目的配置无关,重试或配个加速器即可。Docker Desktop 在 Settings → Docker Engine 里加"registry-mirrors": ["https://<加速地址>"]后 Apply & Restart;Linux 服务器改/etc/docker/daemon.json后systemctl restart docker。也可以先单独把基础镜像拉好再启动:postgres:16-alpine、redis:7-alpine、nginx:1.27-alpine、golang:1.22-alpine、node:20-alpine、alpine:3.20。
方式二:手工部署前端(仅在需要把前端单独跑时)
发布包已经把前端页面目录(web-dist/)一起带着,正常部署用不到这一节。只有下面这些情况才需要单独跑前端:
本地 npm run dev 联调、或用 Docker Compose 那种多服务部署。
# macOS / Linux
cd web
# NEXT_PUBLIC_* 是构建期内联的:必须先写好 .env.production 再 build,构建后改环境变量无效
echo "NEXT_PUBLIC_API_BASE=/api" > .env.production
npm ci
npm run build
pm2 start npm --name mini-kanban-web -- run start # 或 systemd
# Windows PowerShell
cd web
# 注意别用 > 重定向:PowerShell 会写成 UTF-16,Next 读出来是乱码。
# 这里内容本身是纯 ASCII,用 ascii 编码不会带 BOM。
Set-Content -Path .env.production -Value "NEXT_PUBLIC_API_BASE=/api" -Encoding ascii
npm ci
npm run build
# pm2 在 Windows 上启动 npm 脚本不稳定,建议改用 NSSM:
# nssm install mini-kanban-web "C:\Program Files\nodejs\node.exe" "node_modules\next\dist\bin\next start -p 3201"
NEXT_PUBLIC_API_BASE=/api配合同域反代,可顺带规避 CORS- 生产运行需要
.next/+node_modules/(生产依赖)+package.json - 建议在目标机器上执行
npm ci && npm run build:.next含构建期产物,跨 Node 版本 / 架构搬运可能不兼容 - 想改成静态产物(交给任意静态服务器,或由后端从目录读取):
bash build-web-static.sh
方式三:手工部署后端
# 页面同源提供(发布包形态):先出前端静态产物到 server/web-dist,再编译;
# 运行时把它与二进制放在一起(或用 WEB_DIST_DIR 指定)
bash build-web-static.sh
cd server && go build -o mini-kanban .
# 只提供接口(页面由前端服务负责,或本地联调)
cd server && go build -o mini-kanban .
cd server
# 若在 Windows 上交叉编译出 Linux 二进制
$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o mini-kanban .
- 一并部署
config.yaml与附件目录(storage.dir,默认data/uploads) storage.dir的相对路径按后端工作目录解析,注意启动目录- 生产环境不要误开
SETUP_FORCE
前面挂网关(可选)
一体化部署本身就是完整入口,不需要 nginx。要加 TLS、gzip 或多域名,再在前面挂一层网关即可 —— 此时只有一个上游端口:
server {
listen 80;
server_name mini-kanban.example.com;
# 页面与接口同一个二进制、同一个端口,全部转发过去即可
location / {
proxy_pass http://127.0.0.1:8281;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
client_max_body_size 100m; # 需不小于 storage.maxSizeMB × 10(默认 10 → 100MB)
}
}
上线前检查
jwt.secret换成随机长字符串,server.mode设为releaseconfig.yaml含数据库明文密码,文件权限收紧;它已被.gitignore忽略,不要入库- 后端 CORS 中间件会回显请求的
Origin(便于本地联调),同域反代部署时不受影响;如前后端分域部署且需收紧来源,需自行调整internal/middleware/cors.go /setup向导不应暴露到公网(配置就绪后访问只会显示反馈页,但仍建议在网关层限制)- 附件下载是「票据 + 随机文件名」两道防线:URL 必须带上登录后换取的下载票据(
?ticket=,见「附件存储」), 落盘名是 32 位随机 hex;不要把文件名改成可预测命名,也不要去掉middleware.FileTicketRequired()
MIT © 2026 kite88
最近更新 · 文档同步于








