教训:事务层吞掉业务错误,所有校验失败都返回 500

记录日期:2026-02-12   影响面:107 处事务调用

1. 问题现象

症状:前端删除店铺时,即使店铺有活跃员工(应该返回 400 + 明确提示),用户看到的却是 500 Internal Server Error,错误消息是"删除店铺失败",完全没有具体原因。

前端收到的响应:

{
  "success": false,
  "error": {
    "code": "E1003",
    "type": "COMMON_INTERNAL_ERROR",
    "message": "删除店铺失败"
  }
}

后端日志中的真实错误:

❌ 删除店铺失败: TransactionError: Transaction failed: 该店铺还有活跃的员工,无法删除
关键线索:日志里明明写着"该店铺还有活跃的员工"——一个清晰的业务校验错误——但它被包装成了 TransactionError,导致 API 层无法识别,最终变成了 500。

2. 根本原因

2.1 错误传播链

完整的错误传播路径:

sequenceDiagram
    participant F as 前端
    participant A as API 层 (stores.js)
    participant T as 事务层 (tenant-db.js)
    participant DB as PostgreSQL

    F->>A: DELETE /api/stores/:id
    A->>T: tenantDb.transaction(req, callback)
    T->>T: BEGIN TRANSACTION
    T->>DB: SELECT ... FROM employees WHERE center_id = $1
    DB-->>T: 返回活跃员工
    Note over T: callback 内部抛出
AppError("有活跃员工") (400) T->>T: ROLLBACK Note over T: catch 块把 AppError
包装成 TransactionError T-->>A: throw TransactionError Note over A: catch 检查 error.name === 'AppError'
❌ 失败!这是 TransactionError A-->>F: 500 Internal Server Error

2.2 Bug 所在代码

文件:backend/database/data-access/tenant-db.js_executeTransaction() 方法的 catch 块:

// tenant-db.js (修复前)
} catch (error) {
  // 回滚...

  // ✅ 这两个错误被透传了
  if (error instanceof TenantAccessDeniedError || error instanceof SchemaNotFoundError) {
    throw error;
  }

  // ❌ AppError 没有被透传!
  // 所有业务错误都被包装成 TransactionError
  throw new TransactionError('execute', `Transaction failed: ${error.message}`, error);
}

文件:backend/api/stores.jsdeleteStore() 方法的 catch 块:

// stores.js
} catch (error) {
  // 期望匹配 AppError,但收到的是 TransactionError
  if (error.name === 'AppError') {    // ← 永远匹配不上!
    return res.status(error.statusCode).json(error.toResponse());
  }
  // 走到这里,变成 500
  throw errors.internalError('删除店铺失败', error);
}

2.3 为什么只有事务内的业务错误受影响?

场景 错误传播 结果
事务外抛出 AppError AppError → API catch → error.name === 'AppError' 正确返回 400/404
事务内抛出 AppError(BUG) AppError → TransactionError → API catch → error.name === 'AppError' 错误返回 500
事务内真正的 DB 错误 PG Error → TransactionError → API catch → 500 正确返回 500

3. 影响范围

这不是个别问题,而是系统级 bug。后端有 107 处使用了 tenantDb.transaction(),分布在 23 个 API 文件中。任何在事务回调中抛出的 AppError 都会被错误地包装成 500。

受影响的典型场景(部分列举):

API事务内的业务校验期望状态码实际状态码
DELETE /stores/:id"该店铺有活跃员工"400500 ❌
DELETE /stores/:id"该店铺有未完成预约"400500 ❌
POST /cash-drawer"已有未结束的收银会话"400500 ❌
POST /appointments"预约时间冲突"400500 ❌
PUT /promotions/:id"促销活动已过期"400500 ❌
POST /payment/checkout"余额不足"400500 ❌

4. 修复方案

tenant-db.jsplatform-db.js 的事务 catch 块中,增加 AppError 透传:

// tenant-db.js (修复后)
} catch (error) {
  // 回滚...

  // 数据访问层自己的错误 → 透传
  if (error instanceof TenantAccessDeniedError || error instanceof SchemaNotFoundError) {
    throw error;
  }

  // ✅ 新增:业务逻辑错误 → 透传
  if (error && error.name === 'AppError') {
    throw error;
  }

  // 真正的基础设施错误 → 包装成 TransactionError
  throw new TransactionError('execute', `Transaction failed: ${errorMessage}`, error);
}
修复效果:一行判断修复了所有 107 处事务调用中的业务错误返回问题。不需要修改任何 API 文件。

5. 设计原则:错误分层与职责边界

这个 bug 的本质是错误分层设计的实现缺漏。理解这个原则可以避免类似问题。

5.1 分层架构中的错误所有权

我们的后端分三层,每层产生不同类型的错误:

graph TB
    subgraph API["API 层 (stores.js, appointments.js, ...)"]
        A1["职责:业务逻辑、数据校验"]
        A2["产生的错误:AppError"]
        A3["400 没权限 / 404 找不到 / 409 冲突"]
    end

    subgraph DAL["数据访问层 (tenant-db.js, platform-db.js)"]
        D1["职责:连接管理、Schema 切换、事务管理"]
        D2["产生的错误:TransactionError, SchemaNotFoundError"]
        D3["500 连接断开 / 事务死锁"]
    end

    subgraph DB["PostgreSQL 数据库"]
        B1["职责:执行 SQL"]
        B2["产生的错误:连接超时、语法错误"]
    end

    API --> DAL --> DB

5.2 核心原则:每层只包装自己的错误

错误来源数据访问层应该做什么为什么
连接断开、死锁、Schema 不存在 包装TransactionError 这是数据访问层"管辖范围内"的错误
"有活跃员工,无法删除" 透传(原样抛出) 这是 API 层的业务逻辑,数据访问层不应该碰
TenantAccessDeniedError 透传(原样抛出) 虽然是数据访问层定义的,但是有独立的错误身份

5.3 快递比喻

想象一个快递系统:

Bug 行为:物流中心在运输途中撕掉了商家的 "易碎品" 标签,换成自己的 "运输事故" 标签。客户只看到 "运输事故",以为出了大问题。

正确行为:物流中心不碰商家的标签,原样交付。只有物流中心自己造成的问题(车坏了、路堵了)才贴 "运输事故" 标签。

5.4 代码里已有的"透传先例"

事实上,代码已经对两种错误做了透传:

// tenant-db.js — 这段代码说明原作者理解了透传原则
if (error instanceof TenantAccessDeniedError || error instanceof SchemaNotFoundError) {
  throw error;  // 不包装,原样抛出
}

但遗漏了 AppError。这是"设计意图正确但实现不完整"的典型案例 — 原作者考虑到了数据访问层自己定义的错误类型,但没预见到 API 层的 AppError 也会从事务回调里冒出来。

6. 防范措施

6.1 编码规范

规则:任何封装了 try-catch 的"中间层"方法(事务、重试、缓存包装器等),在 re-throw 错误时,必须检查是否应该透传上层的业务错误,而非无差别包装。

6.2 检查清单

当你写一个包含 catch + re-throw 的包装方法时,问自己:

  1. 这个 catch 块会捕获到哪些类型的错误?(自己的?上层传下来的?外部服务的?)
  2. 哪些错误应该透传?(上层有意抛出的业务错误)
  3. 哪些错误应该包装?(自己职责范围内的基础设施错误)
  4. 包装后,上层还能区分错误类型吗?(如果不能,就说明包装过度了)

7. 修改文件清单

文件改动
backend/database/data-access/tenant-db.js_executeTransaction catch 块中增加 AppError 透传
backend/database/data-access/platform-db.jstransaction catch 块中增加 AppError 透传