记录日期:2026-02-12 影响面:107 处事务调用
500 Internal Server Error,错误消息是"删除店铺失败",完全没有具体原因。
前端收到的响应:
{
"success": false,
"error": {
"code": "E1003",
"type": "COMMON_INTERNAL_ERROR",
"message": "删除店铺失败"
}
}
后端日志中的真实错误:
❌ 删除店铺失败: TransactionError: Transaction failed: 该店铺还有活跃的员工,无法删除
TransactionError,导致 API 层无法识别,最终变成了 500。
完整的错误传播路径:
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
文件: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.js,deleteStore() 方法的 catch 块:
// stores.js
} catch (error) {
// 期望匹配 AppError,但收到的是 TransactionError
if (error.name === 'AppError') { // ← 永远匹配不上!
return res.status(error.statusCode).json(error.toResponse());
}
// 走到这里,变成 500
throw errors.internalError('删除店铺失败', error);
}
| 场景 | 错误传播 | 结果 |
|---|---|---|
| 事务外抛出 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 |
tenantDb.transaction(),分布在 23 个 API 文件中。任何在事务回调中抛出的 AppError 都会被错误地包装成 500。
受影响的典型场景(部分列举):
| API | 事务内的业务校验 | 期望状态码 | 实际状态码 |
|---|---|---|---|
| DELETE /stores/:id | "该店铺有活跃员工" | 400 | 500 ❌ |
| DELETE /stores/:id | "该店铺有未完成预约" | 400 | 500 ❌ |
| POST /cash-drawer | "已有未结束的收银会话" | 400 | 500 ❌ |
| POST /appointments | "预约时间冲突" | 400 | 500 ❌ |
| PUT /promotions/:id | "促销活动已过期" | 400 | 500 ❌ |
| POST /payment/checkout | "余额不足" | 400 | 500 ❌ |
在 tenant-db.js 和 platform-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);
}
这个 bug 的本质是错误分层设计的实现缺漏。理解这个原则可以避免类似问题。
我们的后端分三层,每层产生不同类型的错误:
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
| 错误来源 | 数据访问层应该做什么 | 为什么 |
|---|---|---|
| 连接断开、死锁、Schema 不存在 | 包装成 TransactionError |
这是数据访问层"管辖范围内"的错误 |
| "有活跃员工,无法删除" | 透传(原样抛出) | 这是 API 层的业务逻辑,数据访问层不应该碰 |
TenantAccessDeniedError |
透传(原样抛出) | 虽然是数据访问层定义的,但是有独立的错误身份 |
想象一个快递系统:
❌ Bug 行为:物流中心在运输途中撕掉了商家的 "易碎品" 标签,换成自己的 "运输事故" 标签。客户只看到 "运输事故",以为出了大问题。
✅ 正确行为:物流中心不碰商家的标签,原样交付。只有物流中心自己造成的问题(车坏了、路堵了)才贴 "运输事故" 标签。
事实上,代码已经对两种错误做了透传:
// tenant-db.js — 这段代码说明原作者理解了透传原则
if (error instanceof TenantAccessDeniedError || error instanceof SchemaNotFoundError) {
throw error; // 不包装,原样抛出
}
但遗漏了 AppError。这是"设计意图正确但实现不完整"的典型案例 — 原作者考虑到了数据访问层自己定义的错误类型,但没预见到 API 层的 AppError 也会从事务回调里冒出来。
当你写一个包含 catch + re-throw 的包装方法时,问自己:
| 文件 | 改动 |
|---|---|
backend/database/data-access/tenant-db.js | 在 _executeTransaction catch 块中增加 AppError 透传 |
backend/database/data-access/platform-db.js | 在 transaction catch 块中增加 AppError 透传 |