## 问题 同样是「对象未开放某操作」的 405(`OBJECT_API_METHOD_NOT_ALLOWED`),前端两条路径的处理**方向相反**: | 路径 | 405 处理 | 用户看到 | |---|---|---| | **导入**(ImportWizard) | 有独立谓词 `isImportNotAllowed`,四个 catch 点先于 unsupported 判定,命中即终止并出 `grid.import.notAllowed` 专用文案(objectui#2823 / #3391 P0) | 「该对象未开放导入」 | | **批量写**(`updateMany` / `deleteMany`) | `data-objectstack` 适配器**直接 rethrow**(`packages/data-objectstack/src/index.ts` 约 `:1100-1102` / `:1150-1152`);逐行回退只在**客户端方法不存在**时触发,405 不触发 | 一个原始错误弹窗 | 这个不对称正是 #3745 那个 bug**表现成"硬报错"而不是"降级慢一点"**的原因:8 个对象缺 `bulk` 原语 → 多选删除 → 405 → 适配器 rethrow → Setup 网格直接报错。#3745 修了那 8 个对象,但**适配器这一侧的行为没动** —— 下一个缺 `bulk` 的对象(或第三方应用里刻意只开单条写的对象)还会撞上同样的体验。 ## 需要先定的设计问题 不是「照抄一行 405 判定」那么直接,建议先定调: 1. **回退还是终止?** 逐行回退能让用户"慢一点但成功",但代价是**丢掉原子性** —— 后端一次 `deleteMany` 是一个批,拆成 N 次单删就是 N 个独立事务,中途失败会留下部分完成的状态。批量删除尤其危险。倾向:**不回退,给独立文案**(与导入侧的 405 处理同构),但需要明确写下来。 2. **文案落在哪一层?** 导入侧的文案在 ImportWizard 里(`grid.import.notAllowed` + 10 locale)。批量写的调用点分散(网格多选工具栏、列表页、可能还有详情页),文案是放适配器(统一但离 UI 远)还是放各调用点(贴近场景但要维护 N 处)? 3. **要不要在按钮层就压制?** `/me/permissions` 已下发 per-object `apiOperations`(#3498),前端已经能知道某对象不支持 `bulk` —— 那么"多选删除"按钮本可以像 Import/Export 一样按 effective 集隐藏/禁用,**根本不让用户点到 405**。这才是与 #3026 契约一致的做法(按钮谓词 = `effective.api(op) ∧ affordance(op) ∧ can(user, 基础权限)`),405 文案只作为兜底。倾向:**两者都做** —— 按钮层压制是主路径,405 文案是兜底(权限可能在页面打开后才收紧)。 4. **`createMany` 呢?** 上表只列了 update/delete,`createMany` 的路径要一并确认是否同样 rethrow。 ## 可复用的东西 - `isImportNotAllowed` 谓词(objectui,ImportWizard)—— 405 判定逻辑可直接复用,注意**不要**并进 `isUnsupportedImportJob`:404(路由不存在→回退)与 405(未开放→终止)语义相反,这个坑 #3391 P0 已经踩过一次。 - `resolveCrudAffordances(obj, effectiveApiOperations?)` 第二参(objectui#2823 已铺路)—— 问题 3 的按钮层压制接这里,和 Import/Export 同一条路。 - `getObjectApiOperations` + `check()` 映射(objectui permissions)。 ## 关联 - #3745(8 个对象缺 `bulk`,本 issue 的触发案例)、#3026(UI 按钮 ↔ 白名单一致性契约)、#3391 P1(bulk 门禁 `bulk ∧ child`;P0 的导入侧 405 文案) - objectui#2823(前端消费 effective 集 + ImportWizard 405 文案) - 主要改动落在 objectui 仓,本 issue 记在框架仓是因为契约与触发案例都在这里;定调后可拆一个 objectui 侧的实现 issue。
Activity
os-zhuang commentedon Jul 28, 2026
更正:本 issue 的前提是错的,现在拿到 objectui 源码核实了
开这个 issue 时我没有 objectui 源码,依据的是 #3391 P1 方案评论里的一句勘查结论。现在源码在手,逐条走完调用链后确认:没有任何 UI 路径会把批量写的 405 抛给用户。原正文说的「适配器直接 rethrow → Setup 网格多选删除硬报错」不成立。
实际的调用链
data-objectstackbulkUpdate/bulkDelete(packages/data-objectstack/src/index.ts:1100-1102、:1150-1152)throw normaliseClientError(err)—— 原正文这一句是对的useBulkExecutor(packages/plugin-grid/src/hooks/useBulkExecutor.ts:191-195)→executeBulkBatch(packages/core/src/actions/bulkFastPath.ts:93-110)try { bulkCall } catch { /* Fall through to per-row */ }—— 对任何异常都回退逐行,405 也不例外packages/app-shell/src/hooks/useObjectActions.ts:96-100)Promise.allSettled(records.map(r => dataSource.delete(...)))—— 根本不调bulkDelete,一开始就是逐行单删bulkOperation()(create分支无回退)packages/apps里 零调用方所以适配器那个 rethrow 被上游两层各自兜住了:一层显式 catch 回退,另一层压根不走批量路由。
这也意味着 #3745 的用户影响被我说大了
#3745(补
bulk原语)修的 API 层不对称是真的,我用真实服务打过 14/14 的矩阵:deleteMany在那 8 个对象上确实 405,而同样的单条写完全开放。但那个 PR 说明里「Setup 网格多选删除会直接报错」是我沿用了同一条错误前提 —— 实际表现是静默降级为逐行删除,用户看不出异常。修复本身仍然正确(API 契约自洽、/batch恢复可用),只是没有当时描述的那种用户可见故障。剩下的真问题(比原正文小得多,但仍值得定)
executeBulkBatch的catch {}不区分原因:bulk未授予、网络错、鉴权失败,一律变成 N 次逐行写。代价是丢原子性(一次deleteMany拆成 N 个独立事务)且无任何信号。至少该区分「405 = 该对象没开批量,回退是预期的」与「其它错 = 真故障」。/me/permissions已下发apiOperations,resolveCrudAffordances的第二参 AND 已经在跑 —— 我在浏览器里实测过:两个同为 platform 桶、UI 轴完全相同的对象,仅因服务端 effective 集不同,New/Import按钮就一个显示一个隐藏(截图见 refactor(metadata-core): 回收 sys_view_definition 的等价全开白名单 + 派生契约端到端实测 (#3026) #3772)。同样的机制可以用来在bulk不在 effective 集时直接不渲染批量操作入口,而不是让它先发请求再静默降级。createMany那条无回退分支目前没有调用方,属于潜在坑而非现存问题 —— 要么接上回退,要么删掉。建议
原正文的 4 个决策点里,第 1 条(回退还是终止)现在有了确定答案:已经在回退了,要定的是「回退该不该有声音、以及只对 405 回退还是对所有错回退」。第 3 条(按钮层压制)机制已就绪,是最小改动的正解。第 2 条(文案落点)只在决定要出声时才需要。第 4 条已查明:
createMany无调用方。正文我不改,保留原始记录;以本条评论为准。
Generated by Claude Code
os-zhuang commentedon Jul 28, 2026
二次更正:准备动手实现时发现两半都不成立,建议关掉
拿到 objectui push 权限、准备实现上一条评论列的两项时,又核实了一遍源码。两项都站不住。
❌ 「按钮层按
bulk压制」—— 已经做了,而且按bulk压是错的已经做了:
packages/plugin-grid/src/ObjectGrid.tsx:1591-1602(#3720)已经把内建批量删除入口挂在objectCanDelete上,而objectCanDelete来自resolveRowCrudAffordances({ …, effectiveApiOperations })(rowCrudAffordances.ts:75-80)—— 正是/me/permissions下发的 effective 集。注释写得很明确:author 声明的bulkActions: ['delete']是接线声明,不是权限授予,verdict 关掉时会被过滤掉。而且按
bulk压会是回归:门控挂的是子动词delete,不是bulk。这是对的 ——bulk未授予但delete已授予时,逐行回退让这个操作照常可用;按bulk隐藏入口等于把一个能用的功能藏起来。我上一条建议这么做,是错的。❌ 「回退是静默的,该区分 405 与真故障」—— 前提也不准
executeBulkBatch的catch {}确实吞掉异常对象,但错误并没有消失:回退后的Promise.allSettled会把每一行的失败连同res.reason.message收进outcome.errors(bulkFastPath.ts:112-123),经BulkResult.errors报给用户。所以 403/400/500 的场景不是"静默降级",而是"从 1 条批量错误变成 N 条逐行错误" —— 而那句注释// Fall through to per-row — gives the user actionable detail说的正是这个有意的取舍:逐行能告诉用户哪几行、为什么失败,比一条不透明的批量错误信息量更大。原子性那条也比我想的弱:回退只在批量已经失败之后触发,此时 all-or-nothing 已经落到 "nothing",逐行把它变成 "some",且每行结果如实上报。走 bulkCall 的只有 update/delete(
useBulkExecutor.ts:191-195),没有 create,所以不存在重复创建的风险。剩下的真实代价只有:403 这类"逐行必然也失败"的情况会白烧 N 个请求。这不值得为它改动一条有明确设计理由的容错路径。
结论
本 issue 从开出来就建立在一条我未经核实就沿用的勘查结论上(#3391 P1 方案评论),两轮核实后:用户可见故障不存在、按钮门控已就位且形态正确、静默回退的指控不成立。建议以
not planned关闭。唯一残留的小事,不足以撑起一个 issue,记在这里备查:
data-objectstack的bulkOperation()里create分支没有逐行回退,而该函数在packages/apps全仓零调用方 —— 是个没被触发的坑,将来若接线需注意,或直接清理掉。Generated by Claude Code
fix(spec): correct the mispriced SINGLE_RECORD_WRITE_ONLY pricing com…