# openIssueList **Repository Path**: handloong/openIssueList ## Basic Information - **Project Name**: openIssueList - **Description**: No description available - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-14 - **Last Updated**: 2026-10-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # openIssueList 企业内部通用的 Issue 协作平台。定位是「够用、好维护、能一眼看懂」—— 不追求功能齐全,而是让每个功能都能说清为什么存在。 界面全中文,LDAP 登录对接公司账号体系,Docker Compose 一键部署。 --- ## 快速开始 ```bash cd deploy cp .env.example .env # 至少改这三个:POSTGRES_PASSWORD、RUSTFS_ACCESS_KEY、RUSTFS_SECRET_KEY vi .env docker compose up -d --build ``` 打开 ,用管理员账号登录。 **首次登录**:把 `BOOTSTRAP_ADMIN_USERNAME` 设成你的域账号,该账号首次登录后即为管理员。 **没有 LDAP 也想先看看?** 在 `.env` 里改成开发模式: ```bash APP_ENV=dev DEV_AUTH=true DEV_AUTH_ADMIN=admin ``` 重启容器后用任意用户名即可登录(开发模式不校验密码)。 **这个模式不要在生产使用** —— `APP_ENV=prod` 时应用会拒绝启动,防止误开。 --- ## 功能 **编辑体验** - Markdown 编辑器:工具栏、实时预览、分栏模式、快捷键 - **Ctrl+V 直接粘贴截图**,自动上传并插入正文;拖拽图片同样支持 - 正文里的图片走站内稳定路径,不会因为签名过期变成裂图 **Issue 管理** - 四种类型:缺陷 / 需求 / 任务 / 故障,各自预填描述模板 - 状态机:未关闭 ↔ 已关闭(关闭时记录原因:已完成 / 不予实施) - 优先级、负责人、里程碑、标签、自定义清单 - 评论、活动流、附件 - 编号形如 `PLAT-42`:转移项目时**编号不变只换前缀**,历史引用不会失效 - 软删除 + 回收站,误删可以自己恢复 - 附件永久保留,不随 Issue 删除而删除 **检索** - 中文分词在应用层完成,数据库**不需要装任何扩展** - 搜索词命中处高亮 - 支持状态 / 优先级 / 类型 / 负责人组合筛选,筛选条件写进 URL(刷新和分享都不丢) **协作** - 三级权限:管理员 > 项目维护者 > 项目成员 > 非成员只读 - 用户管理:搜索、改角色、启用停用、查看某人参与的项目。 含「最后一名管理员不可降级/停用」的保护 - 站内通知(被指派、被评论时收到) - Webhook 外部通知,HMAC-SHA256 签名 + 时间戳防重放 **运维** - 单二进制 + 静态资源,无外部运行时依赖 - 健康检查分两级:`/livez`(进程活着)、`/readyz`(依赖可用) - 结构化 JSON 日志,带 request_id 贯穿 - 优雅关闭 --- ## 技术选型 | 方面 | 选择 | 为什么 | |---|---|---| | 后端 | Go + chi + pgx/v5 | 单二进制部署,无 JVM/Node 运行时负担 | | 数据库 | PostgreSQL 16 | 全文检索、`FOR UPDATE` 行锁、`ON CONFLICT` 都是刚需 | | 中文分词 | [gse](https://github.com/go-ego/gse)(纯 Go) | 见下方「为什么不用 pg_jieba」 | | 对象存储 | RustFS(S3 兼容) | 预签名直传,文件流不经过应用进程 | | 前端 | React 18 + TypeScript + Vite | 类型检查能在构建期拦下大部分低级错误 | | 部署 | Docker Compose | 三个容器,一条命令 | ### 为什么不用 pg_jieba 中文检索的常规做法是给 PostgreSQL 装 `pg_jieba` 扩展。我们放弃了它,改为 **在 Go 应用内部分词**,理由是: 1. **部署简单**:不用维护一个带扩展的自建 PG 镜像。扩展需要与 PG 版本严格匹配, 每次升级 PG 都要重建镜像,而自建镜像一旦构建失败(我们实测卡在 CMake 阶段), 整个项目就卡住了。 2. **分词策略可测**:分词是纯函数,能写单元测试,也能在本地改词典立即生效。 数据库扩展的行为只能连上库才能验证。 3. **词典可热更新**:`deploy/dict/user_dict.json` 走 GitOps,改完挂载即可, 不用重建镜像。 代价是每次写入 Issue 都要在应用侧算一遍分词,以及**写入和查询两侧必须用同一套 分词逻辑**——这是这个方案唯一的坑,见下方「设计要点」。 --- ## 目录结构 ``` cmd/openissue/ 单二进制入口:配置 → 日志 → 数据库 → 迁移 → 依赖 → HTTP internal/ api/ HTTP 接口层:解析请求 → 调 store → 组装响应 auth/ LDAP 认证、会话、权限判定、CSRF config/ 环境变量加载与校验 db/ 连接池、迁移、事务、分页基础设施 domain/ 领域模型与业务规则(状态机、编号规则、校验) httpx/ 统一响应与错误格式 middleware/ 日志、panic 恢复、超时、request_id search/ 中文分词器(gse 封装) server/ 路由装配、SPA 托管、健康检查 store/ 仓储层(所有 SQL 都在这里) webhook/ 投递器:签名、SSRF 防护、退避重试 web/ React SPA(源码) db/migrations/ 数据库迁移(前后向各一份) deploy/ Dockerfile、compose、初始化脚本、分词词典 scripts/ 端到端验证与数据灌入脚本 docs/ 设计文档 ``` --- ## 开发 ### 启动依赖 ```bash cd deploy && docker compose up -d postgres rustfs ``` ### 跑应用 ```bash export DATABASE_URL="postgres://openissue:密码@127.0.0.1:5432/openissue?sslmode=disable" export APP_ENV=dev DEV_AUTH=true DEV_AUTH_ADMIN=admin go run ./cmd/openissue ``` ### 前端开发 ```bash cd web npm install npm run dev # 5173 端口,自动代理 /api 到 8080 ``` ### 测试 ```bash # 纯逻辑单测(不需要任何外部依赖) go test ./... # 集成测试(需要真实 PostgreSQL / RustFS) export TEST_DATABASE_URL="postgres://openissue:密码@127.0.0.1:5432/openissue?sslmode=disable" export TEST_RUSTFS_ENDPOINT="http://127.0.0.1:9000" export RUSTFS_ACCESS_KEY=... RUSTFS_SECRET_KEY=... go test ./... # 端到端接口验证(需要应用已在运行) node scripts/e2e.mjs http://127.0.0.1:8080 # 灌入演示数据 node scripts/seed.mjs http://127.0.0.1:8080 ``` 未设置对应环境变量时,集成测试会自动跳过,不会让没有环境的同学莫名失败。 --- ## 设计要点 这些是实际踩过的坑,写下来避免重复踩。 ### 中文检索的三条护栏 1. **写入侧和查询侧必须用同一套分词逻辑。** 数据库的 `to_tsvector('simple', …)` 只按空格切分,不认识中文。所以要先在应用里切成 `前端 列表 分页` 再喂给它。 只做一边的话,写进去的是词、查出来的是整句,永远搜不到。 2. **必须用 `simple` 配置,不能用 `english`。** `english` 会把中文 token 当作 未知词丢掉,检索向量直接变空。 3. **`search_vector` 是普通列,不是生成列。** `to_tsvector(regconfig, text)` 是 STABLE 而非 IMMUTABLE,生成列只接受 IMMUTABLE 表达式,建表时就会失败。 ### SQL 占位符要显式标注类型 pgx 会把 Go 的空字符串、空切片按 `unknown` 类型发给数据库。而 `cardinality(anyarray)`、`websearch_to_tsquery(text, text)` 是多态/重载函数, PostgreSQL 无法从 `unknown` 反推该选哪个重载,直接报: ``` SQLSTATE 42804: could not determine polymorphic type because input has type unknown ``` **这个错误只在「不带筛选条件查列表」时出现**——也就是打开项目首页这个最高频的路径。 所以占位符一律写成 `$2::text`、`$10::bigint[]`。 ### Go 切片遍历赋值要用索引 ``` for _, it := range items { it.DownloadURL = url } // ✗ 改的是副本 for i := range items { items[i].DownloadURL = url } // ✓ ``` 附件下载地址曾经就是这么丢的:接口不报错,只是永远不返回下载链接, 表现是"附件能上传、能看见,就是下载不了"。 ### 附件上传的完整链路 ``` 浏览器 → 申请预签名 URL → 直接 PUT 到对象存储 → 告知应用"传完了" ``` 文件流不经过应用进程,所以上传大文件不会占应用的内存和带宽。两个必须做的动作: - **启动时确保桶存在。** 预签名对不存在的桶也能签发成功(签名计算不联系服务端), 只有浏览器真正 PUT 时才报 `NoSuchBucket`。不提前建桶的话,用户看到的是 "点了上传、进度条走完、然后失败",报错来自对象存储而非应用,极难定位。 - **把文件大小写进签名。** 否则"单文件不超过 100MB"只是个摆设: 用户拿到 URL 后可以 PUT 任意大小的内容。 ### 编号为什么不能重用 Issue 编号(`PLAT-42`)是对外引用锚点——会被写进提交信息、文档、聊天记录。 所以:软删除后新 Issue 拿下一个编号;恢复时重新分配编号;转移项目时编号不变, 只换前缀。宁可编号有空洞,也不能让旧引用指向错误的内容。 ### 权限的真相在服务端 Issue 详情的 URL 里没有 projectID,前端拿不到自己的项目角色, 只能靠"是不是我建的"这类近似判断——结果就是按钮该显示的没显示、 不该显示的点了报 403。所以详情接口直接返回 `can_write` / `can_manage`, 前端照着渲染。 ### 前后端字段命名 Go 结构体没写 json tag 时,编码器输出的是**字面字段名**(`ID`、`OpenIssueCount`), 而手写响应用的是下划线(`csrf_token`、`object_key`)。前端做了统一归一化: - `ID` → `id`(不是 `iD`,也不是 `openissuecount`) - `OpenIssueCount` → `openIssueCount` - `csrf_token` → `csrfToken` 归一化函数处理的是"首字母缩写"这种情况,不能简单整体小写。 ### 客户端输入的解析错误要返回 400 `json.Decoder` 的错误如果不特殊处理,会落到统一的错误映射的 default 分支变成 500, 把"字段名拼错了"显示成"服务器内部错误"。这类错误属于调用方问题,必须回 400 并说清是哪个字段不对。 --- ## 配置项 完整清单见 `deploy/.env.example`,每一项都有注释说明为什么需要它。 几个容易配错的: | 变量 | 说明 | |---|---| | `RUSTFS_PUBLIC_URL` | 浏览器访问对象存储用的地址。配了域名就必须跟着改,否则上传/下载会指向打不开的地址 | | `COOKIE_SECURE` | 用 HTTPS 设 `true`;纯 HTTP 直连必须先设 `false`,否则登录后立刻掉线。生产环境该值为 `false` 时应用会拒绝启动 | | `DATABASE_URL` | 容器里主机名是服务名 `postgres`,不是 `localhost` | | `BOOTSTRAP_ADMIN_USERNAME` | 首个管理员,该用户首次登录时自动获得管理员角色 | ### 适配不同的目录服务 LDAP 的属性名和过滤器都是配置项,切换 AD / OpenLDAP / 389-DS 不需要改代码。 `deploy/.env.example` 里给了 AD 与 OpenLDAP 两套示例配置。 > **当前状态**:LDAP 代码已完成,但**尚未在真实目录服务上验证过** —— > 见 [docs/todo.md](docs/todo.md) 的「待开发」章节。在补上真实环境联调之前, > 请以开发兜底登录使用,不要对外宣称「已支持 LDAP 登录」。 --- ## 安全说明 已实现: - **LDAP 注入防护**:按 RFC 4515 转义过滤器中的 5 个特殊字符 - **会话**:只存 SHA-256 哈希,不存明文 token;账号停用立即吊销全部会话 - **CSRF**:写操作要求 `X-CSRF-Token`,常量时间比较 - **Webhook SSRF 防护**:禁私网/回环/链路本地/云元数据地址, 不跟随重定向,Dial 时重新解析 DNS 防重绑定 - **Webhook 签名**:HMAC-SHA256,签名内容含时间戳防重放 - **附件**:类型白名单、大小写进签名、objectKey 前缀校验防越权挂载 - **附件隔离**:下载走短期签名 URL,桶不公开读 - **CSV 导出**:以 `= + - @` 开头的单元格加单引号前缀,防公式注入 - **错误约定**:`code` 英文供程序判断,`message` 中文供人阅读; 未预期的错误不向外暴露细节,只记服务端日志 需要注意: - `DEV_AUTH=true` 不校验密码,**只能用于开发和演示** - `LDAP_INSECURE_SKIP_VERIFY=true` 会跳过证书校验,只建议在自签证书的内网临时使用 - 默认只监听 `127.0.0.1`,对外服务请配反向代理并启用 HTTPS --- ## 里程碑 | 里程碑 | 内容 | 状态 | |---|---|---| | M0 | 骨架与基础设施 | 已完成 | | M1 | 认证与用户 | 已完成 | | M2 | 项目与权限 | 已完成 | | M3 | Issue 核心(软删除/转移/清单/关系) | 已完成 | | M4 | 附件 | 已完成 | | M5 | 中文检索与筛选 | 已完成 | | M6 | 通知与订阅 | 已完成 | | M6.5 | Webhook 外部通知 | 已完成 | | M7 | 前端界面与部署 | 已完成 | ## 明确不做的功能 多级嵌套工作流状态机 · 自定义字段 · 甘特图与资源排期 · 代码托管与 MR · 邮件 Digest · 实时协同编辑 · API Token 替代方案见 [gap-analysis.md §四](docs/gap-analysis.md)。