Skip to content

设计:批量写路由的 405 兜底与导入不对称 —— data-objectstack 直接 rethrow,用户看到硬报错 #3757

Description

@os-zhuang

问题

同样是「对象未开放某操作」的 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(feat: single-source API-method derivation contract (#3391 P1) #3498),前端已经能知道某对象不支持 bulk —— 那么"多选删除"按钮本可以像 Import/Export 一样按 effective 集隐藏/禁用,根本不让用户点到 405。这才是与 设计:UI 操作按钮与 enable.apiMethods 白名单的前后端一致性契约 #3026 契约一致的做法(按钮谓词 = effective.api(op) ∧ affordance(op) ∧ can(user, 基础权限)),405 文案只作为兜底。倾向:两者都做 —— 按钮层压制是主路径,405 文案是兜底(权限可能在页面打开后才收紧)。
  4. createMany 呢? 上表只列了 update/delete,createMany 的路径要一并确认是否同样 rethrow。

可复用的东西

  • isImportNotAllowed 谓词(objectui,ImportWizard)—— 405 判定逻辑可直接复用,注意不要并进 isUnsupportedImportJob:404(路由不存在→回退)与 405(未开放→终止)语义相反,这个坑 跟踪:UI 操作按钮与 apiMethods 白名单一致性契约落地(#3026 设计定稿) #3391 P0 已经踩过一次。
  • resolveCrudAffordances(obj, effectiveApiOperations?) 第二参(objectui#2823 已铺路)—— 问题 3 的按钮层压制接这里,和 Import/Export 同一条路。
  • getObjectApiOperations + check() 映射(objectui permissions)。

关联

Activity

  1. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    ContributorAuthor

    更正:本 issue 的前提是错的,现在拿到 objectui 源码核实了

    开这个 issue 时我没有 objectui 源码,依据的是 #3391 P1 方案评论里的一句勘查结论。现在源码在手,逐条走完调用链后确认:没有任何 UI 路径会把批量写的 405 抛给用户。原正文说的「适配器直接 rethrow → Setup 网格多选删除硬报错」不成立。

    实际的调用链

    层 行为
    data-objectstack bulkUpdate/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 也不例外
    console 对象网格的多选删除(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 恢复可用),只是没有当时描述的那种用户可见故障。

    剩下的真问题(比原正文小得多,但仍值得定)

    1. 回退是完全静默的。 executeBulkBatch 的 catch {} 不区分原因:bulk 未授予、网络错、鉴权失败,一律变成 N 次逐行写。代价是丢原子性(一次 deleteMany 拆成 N 个独立事务)且无任何信号。至少该区分「405 = 该对象没开批量,回退是预期的」与「其它错 = 真故障」。
    2. 按钮层其实已经可以压制了。 /me/permissions 已下发 apiOperations,resolveCrudAffordances 的第二参 AND 已经在跑 —— 我在浏览器里实测过:两个同为 platform 桶、UI 轴完全相同的对象,仅因服务端 effective 集不同,New/Import 按钮就一个显示一个隐藏(截图见 refactor(metadata-core): 回收 sys_view_definition 的等价全开白名单 + 派生契约端到端实测 (#3026) #3772)。同样的机制可以用来在 bulk 不在 effective 集时直接不渲染批量操作入口,而不是让它先发请求再静默降级。
    3. createMany 那条无回退分支目前没有调用方,属于潜在坑而非现存问题 —— 要么接上回退,要么删掉。

    建议

    原正文的 4 个决策点里,第 1 条(回退还是终止)现在有了确定答案:已经在回退了,要定的是「回退该不该有声音、以及只对 405 回退还是对所有错回退」。第 3 条(按钮层压制)机制已就绪,是最小改动的正解。第 2 条(文案落点)只在决定要出声时才需要。第 4 条已查明:createMany 无调用方。

    正文我不改,保留原始记录;以本条评论为准。


    Generated by Claude Code

  2. os-zhuang commented on Jul 28, 2026

    @os-zhuang
    ContributorAuthor

    二次更正:准备动手实现时发现两半都不成立,建议关掉

    拿到 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions