bim-issue-platform

设计决策记录(为什么这样设计)

本项目在实现过程中对每个关键取舍都留了理由。这里是把散落在过程文档里的重要决策提炼成一份「为什么」,按主题归类。想做类似系统、或想改某处行为前,先看这里 —— 多数「看起来奇怪」的实现都是有意为之。

权限与角色

为什么 AI 用「代理执行」单模式,而不是给 AI 独立账号权限

AI 在 users 表占一行但不能登录、不持有凭据、不加入项目,唯一写通道是借人的 Token + X-Agent-Mode: on-behalf + X-Agent-Id,权限 = 发令人权限 ∩ AI 允许动作清单。曾设计过第二种「AI 自主运行」模式(按 AI 自己在项目内的角色执行定时巡检),后来整体删除 —— 它的唯一收益是无人值守任务(本期不做),而概念成本是两套清单、两种权限算法、两套留痕形态。底层的三身份留痕字段都还在,将来要做无人值守可再加模式,不用推翻。

为什么不担心 X-Agent-* 头被伪造:这两个头是「声明」不是「防伪」,服务端无法验证请求是否真由 AI 发出 —— 但它们只会让权限变小(取交集),不会提权,无恶意利用价值。

为什么「关闭 / 重开」只认执行人,负责人也不行

状态流转权绑定 executor_id 而非角色:负责人不改派就能关别人的问题,会让「执行人」形同虚设;而工程师对自己提的问题默认自任执行人,闭环成立。甲方 / 协同方即便被指为执行人也无权关闭(先判角色再判执行人)—— 外部参与方不能单方面终结问题。

为什么平台管理员不管分类标准

平台级「标准模板表」会引出「已建项目是否跟随变更」等连环问题;改为建项目时幂等 seed 41 项,之后归项目负责人 / 专业负责人维护。平台管理员职责收敛为三件:建项目、管账号、指派负责人 —— 不介入项目内事务。

为什么甲方 / 协同方「可答复但不可创建、不可导出」

可答复:外部参与方需要沟通通道,纯只读会造成大量线下往返。不可创建 / 导出:问题条目与全量清单属于内部工作产品,导出权服务端直接 403(不只是隐藏按钮)。

数据设计

为什么编号不复用、超限报错而不是扩位

问题编号 4 位、项目内递增、删除留空号。口语讨论时 4 位数好念;单项目上千条是常态、上万是峰值,超 9999 显式报错(ISSUE_NUMBER_EXHAUSTED)好过静默回绕造成两条问题同号。取号走 projects.issue_seq 事务内自增,杜绝 MAX(number)+1 并发重号。项目编号同理(YYYY-NNN,系统生成不可改 —— 它是对账锚点)。

为什么软删之后磁盘文件也永不物理删除

问题永远只软删(可回溯是硬需求),附件与问题绑定 → 文件同样不物理删,否则「全量留痕」断在文件层。磁盘代价经实测可控(单项目常态 77 MB ~ 0.94 GB)。定时任务只清理真正的垃圾:上传中断的半截文件与「磁盘有、库里无」的孤儿。

为什么附件放弃哈希去重

同图去重需要引用计数(一条删除时另一条还在引用同一物理文件),复杂度不值当 —— 同一张图传两次的场景本来就少。文件名改用记录 ID 后每条独立存一份,删除零顾虑;file_hash 降级为完整性校验与孤儿比对。

为什么文件路径用 ULID 不用项目编号

data/uploads/<项目ULID>/...:编号是展示物,可改名可复用(删项目后新建项目可能复用 2026-001),一旦复用,新文件会落进旧编号目录、跨项目隔离从物理层被破;且编号是自由文本,未约束文件系统安全字符。ULID 不可变,「改名 / 编号复用不影响已存文件」永久成立。「不好认」由界面解决(项目卡片同时展示编号与 ID,点击复制)。

为什么子表冗余 project_id 并做复合外键

(issue_id, project_id) → issues(id, project_id) 这样的复合外键让「A 项目的问题挂 B 项目的类目」在数据库层直接被拒 —— 不靠代码记得查,AI 批量灌数据也灌不坏。代价是 SQLite 需要给被指向列加辅助唯一索引,值得。

为什么枚举用 VARCHAR + CHECK 不用 MySQL ENUM

保证 SQLite / PostgreSQL 兼容;加取值只改代码常量、不用发迁移。

为什么「当前项目」存服务端而不进 URL

users.current_project_id:换设备 / 刷新 / 多标签页全部一致,且前端路由保持平层(/issues 而非 /p/:id/issues),换项目是低频动作、MVP 无分享链接需求。代价是多标签页无法各持一个项目 —— 明确接受。

为什么项目说明 / 指标单独成表而不是塞进 projects

指标一行一条(可排序、带单位、让 AI 逐项读写「把总建筑面积改成 12.8 万」),塞进项目表等于每来一个新指标就 ALTER TABLE。说明按(板块类型 + 专业)唯一,discipline 用空字符串而非 NULL —— 两库的 UNIQUE 都允许多个 NULL 并存,用 NULL「概况」能插进第二条。

AI 与数据写入

为什么 AI 写入免人工确认但要留痕标注

项目说明 / 指标类内容若每次 AI 写入都要人确认,AI 的效率优势就没了;代价由显著标注(界面徽标「AI 写入」)+ 全量留痕(操作记录里 AI 身份、发令人、时间、改动字段俱全)来对冲。问题侧更严格:AI 只能动清单内的动作,删除降级为「待删清单由人确认」。

为什么留痕要分 actor / commander / agent 三个字段

「谁做的」与「谁的权限生效」是两件事。AI 代执行时:动手的是 AI(actor),权限按发令人算(commander),被代理的 AI 是 agent。人直连时 commander / agent 为空 —— 早期设计把两者挤进同一个字段,导致「人在网页上操作 = 自己命令自己」的语义不通,修正为三字段分列。

为什么幂等键作用域是 (actor_id, key)

幂等防的是「同一操作者的重试重复落库」。挂在发令人上会出现 NULL 不受唯一约束的漏洞(人直连时发令人为空);按实际操作者隔离后,两个调用方各自用同一个 key 互不干扰。

为什么 dryRun 不消耗幂等键

「先预览再真跑」是正常用法(预览时 dryRun 写在请求体里,两次请求指纹不同)。若预览也落键,真跑必然撞 409 —— 不耗键不是图省事,是让正常用法不被幂等机制误伤。

文件与图片

为什么截图转 WebP、实拍照片另走一路

实测三类样本(CAD 图纸 74 KB / Revit 三维视图 560 KB / 手机实拍 4.7 MB)转码后:截图类 WebP 无损 −59%~−85% 画质零损失(JPEG 对图纸反而 +54% 且糊线);实拍照片 WebP 无损反而 +41%(传感器噪点让无损编码效率极低),必须走「长边缩到 1920 + 有损 q80」(4.7 MB → 80 KB,−98%)。两类图压缩路径刻意分开。缩略图 320~480px,列表页加载体积差 10~40 倍。

为什么压缩前端为主、服务端兜底

前端压缩省流量;但 AI / MCP 客户端直传、旧版页面没有前端压缩 → 服务端 normalizeShot 兜底(已是 WebP 且 ≤1920 原样存不二次损失,否则解码重压,失败原样存绝不丢图)。缩略图始终由服务端生成。

为什么上传走 multipart 单请求而不是「先传图再建问题」

暂存上传方案需要临时区、TTL 清理任务、逐张重试机制;改单请求后这些全部消失,代价是失败要整体重传 —— 靠幂等键保证重试不重复建数据、前端提交前本地预检(6 个 / 10 MB 上限)减少整体重传概率。单次上限 6 个文件即由此来。

为什么图片访问必须过鉴权、不做静态目录

uploads/ 若静态暴露,知道文件名即可绕过「不能跨项目查看」的隔离(附件含未公开工程资料)。所有文件访问走接口鉴权 + blob 化展示;过程附件下载强制 Content-Disposition: attachment(允许 inline 会让上传的 .html / .svg 变成存储型 XSS)。

为什么过程附件限制 10 MB 且不做在线预览

刻意设小,引导用户精简(别把与问题无关的内容往上传);不做预览免去浏览器渲染兼容与安全面,全部只给下载。类型走白名单(扩展名 + 魔数双判,不信任 Content-Type)。

前端工程

为什么筛选条件不进 URL

明确取舍:条件存内存态,刷新丢、后退直接退出列表页、链接不能复现都接受 —— 换来零成本、不污染 URL。真需要时加 sessionStorage 约 3 行即可补上(按标签页隔离)。

为什么图片 blob 化且不做缓存

<img src> 直挂接口地址在失败时只显示破图(分不清「无图 204」与「无权 403」)、做不了占位与统一错误处理。每次显示重新拉取、用完 revoke:缓存必须管「何时销毁」,时机错了内存泄漏,出错代价高于重复下载(内网传输量可忽略)。

为什么批量按钮「按角色粗判显隐、结果逐条反馈」

行选择是动态的,按钮跟着勾选逐条跳变会让人困惑;且「仅执行人」「仅自己创建」本就逐条判定,按钮层无法预判。因此按钮只按角色粗判显隐,提交后逐条展示成功 / 失败原因 —— 部分成功是这个接口的既定语义,只弹一句「已处理」会让人误以为全部成功。

为什么界面文案禁止出现「契约 1.2」「removed_at」「409」这类开发口径

给人看的句子只说结果与下一步(「移出后他看不到本项目的任何内容,随时可以再请回来」);表字段名、HTTP 码、契约条款号一律退回代码注释。实测中用户当面抓出过一整族这类文案。

运维与部署

为什么默认 SQLite、MySQL 并存

交付对象是 BIM 团队:SQLite 零配置开箱即用(备份 = 拷目录);开发期多人共享或接入既有 MySQL 基础设施时一行配置切换。ORM 层约束为跨方言,多库支持的坑(类型映射、时间形态、分页语法)在开发期就持续验证而非交付时才发现。

为什么不用云 CI 而用本地 git 钩子

目标托管平台(Gitee Go)的构建镜像 Node 版本过低(≤17),跑不动 Nuxt 4 + node:sqlite(需 22)。本地门禁(pre-commit 秒级对拍、pre-push 全量检查)效果等效且拦截更早。

前端 JS 读不到 HttpOnly Cookie,XSS 偷不走;nuxt-auth-utils 兼容 ssr:false(CSR 下自动在客户端取会话,无 hydration 问题)。注意它是会话机制不是权限系统 —— RBAC 仍靠自建(角色入会话 → 前端显隐 + 服务端逐请求校验)。

为什么会话里不存「会变的用户资料」

会话是登录那一刻的快照(存在 Cookie 里),管理员被降级、用户改了姓名,界面若读会话就会骗人(显示旧角色 / 旧名字)。纪律:useUserSession() 只判登录态,一切业务资料从 GET /me 现取。