教训:PostgreSQL 事务内错误导致员工 APP 登录失败

记录日期:2026-02-05

Bug PostgreSQL Backend

目录

1. 问题现象

症状:在 iPhone 17 Pro 模拟器上运行员工端 APP(Celoria Employee App), 使用 Test Super Admin 账户(test.superadmin@qqnails.com / test123)登录, 点击 Sign In 后显示 "Login failed. Please try again." 错误提示。

用 curl 直接测试后端 API,返回 500 内部错误:

$ curl -X POST http://localhost:3000/api/auth/employee/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test.superadmin@qqnails.com","password":"test123","tenantId":"qqnails"}'

{
  "success": false,
  "error": {
    "code": "E1003",
    "type": "COMMON_INTERNAL_ERROR",
    "message": "Login failed, please try again later"
  }
}

关键发现:这不是账号密码错误(401),而是服务器内部错误(500),说明问题出在后端逻辑。

2. 定位过程

第一步:确认 APP 请求路径

排查 APP 端代码,确认请求链路:

路径匹配正确,排除了路由问题。

第二步:确认后端服务状态

$ curl http://localhost:3000/api/health
→ 200 OK

后端正在运行,排除了服务未启动的问题。

第三步:检查 iOS ATS 限制

检查了 Info.plist 是否缺少 NSAppTransportSecurity 配置。 但 iOS 模拟器对 localhost 有 ATS 豁免,所以这不是问题。

第四步:直接测试 API 并查看日志

用 curl 测试 API 得到 500 错误后,通过 pm2 logs 查看后端日志,发现了关键线索:

[Login Debug] 密码验证结果: true
[RBAC] 高级角色获取所有店铺: { role: 'super_admin', storeCount: 9 }
📝 令牌签发记录: { userId: 'e2e_test_superadmin', tokenType: 'access' }
📝 令牌签发记录: { userId: 'e2e_test_superadmin', tokenType: 'refresh' }

[error]: 员工登录错误 current transaction is aborted,
         commands ignored until end of transaction block
    → at EmployeeAuthController.login (employee_auth_controller.js:348)

密码验证成功了,token 也生成了,但在第 348 行(UPDATE employees SET last_login_at)崩了。

3. 根本原因

PostgreSQL 错误码 25P02:current transaction is aborted, commands ignored until end of transaction block

问题出在 employee_auth_controller.js 第 296-307 行:

// 在 BEGIN 事务内部...

// 查询是否已设置 PIN(兼容未部署 time-clock 表结构的环境)
try {
  const pinResult = await client.query(
    'SELECT 1 FROM employee_pins WHERE employee_id = $1 LIMIT 1',
    [employee.id]
  );
  hasPinSetup = pinResult.rows.length > 0;
} catch (pinError) {
  if (pinError?.code !== '42P01') {  // 42P01 = 表不存在
    throw pinError;
  }
  hasPinSetup = false;  // 吞掉错误,继续执行
}

这段代码的意图是好的:如果 employee_pins 表还没创建(time-clock 功能还没部署),就忽略错误,默认 PIN 未设置。

但它犯了一个关键错误:JavaScript 的 try-catch 只能控制 JavaScript 的执行流,无法修复 PostgreSQL 事务的状态。

关键概念:在 PostgreSQL 中,事务内的任何 SQL 错误都会让整个事务进入 "aborted" 状态。 一旦 aborted,该事务内后续所有 SQL 命令都会被拒绝,只有 ROLLBACK 能结束这个事务。 这是 PostgreSQL 的设计决策,和 MySQL 不同。

所以虽然 JavaScript 层面的代码继续执行了,但数据库连接上的事务已经废了,后面的 UPDATECOMMIT 全部会失败。

4. 执行时序分析

步骤 操作 代码位置 JS 层面 PG 事务状态
1 BEGIN 第 142 行 OK idle in transaction
2 查询 public.tenants(找到 qqnails) 第 150-155 行 OK idle in transaction
3 SET search_path TO tenant_qqnails 第 168 行 OK idle in transaction
4 查询 employees 表(找到用户) 第 178-190 行 OK idle in transaction
5 检查账户状态 第 201 行 OK idle in transaction
6 bcrypt.compare 验证密码 第 241 行 OK(密码正确) idle in transaction(纯 JS 操作)
7 查询所有 centers 第 266-273 行 OK(9 家店) idle in transaction
8 查询 employee_pins 第 297-300 行 SQL 报错,被 catch 捕获 aborted !!!
9 catch 设置 hasPinSetup = false 第 302-306 行 OK(JS 继续执行) 仍然 aborted
10 生成 JWT access token + refresh token 第 344-345 行 OK(纯内存操作) 仍然 aborted
11 UPDATE employees SET last_login_at 第 348 行 SQL 被拒绝(25P02) aborted
12 进入 catch → ROLLBACK → 返回 500 第 454-461 行 返回错误给客户端 事务结束
JavaScript 执行流: BEGIN → 查租户 → 切schema → 查员工 → 验密码 → 查店铺 → 查PIN表(catch错误,继续) → 生成token → UPDATE → 💥 500错误 PostgreSQL 事务状态: idle ──→ idle in transaction ──────────────────────→ ──→ 查PIN表失败 → [ABORTED] ──→ 后续SQL全部拒绝 → ROLLBACK ↑ 问题在这里:JS 继续了,但 PG 事务已经废了

5. 三种修复方案

方案一:SAVEPOINT(事务存档点)

原理

SAVEPOINT 是 PostgreSQL 提供的"事务内存档机制"。 你可以在危险操作前设一个存档点,如果操作失败,回滚到存档点而不是回滚整个事务,事务继续保持健康状态。

代码示意

// 在查询 employee_pins 之前设置存档点
await client.query('SAVEPOINT before_pin_check');

try {
  const pinResult = await client.query(
    'SELECT 1 FROM employee_pins WHERE employee_id = $1 LIMIT 1',
    [employee.id]
  );
  hasPinSetup = pinResult.rows.length > 0;
  // 成功则释放存档点(可选)
  await client.query('RELEASE SAVEPOINT before_pin_check');
} catch (pinError) {
  // 失败则回滚到存档点,事务恢复健康
  await client.query('ROLLBACK TO SAVEPOINT before_pin_check');
  hasPinSetup = false;
}

SQL 层面发生了什么

BEGIN;
  ... 前面的操作都正常 ...
  SAVEPOINT before_pin_check;            -- 设存档点
  SELECT 1 FROM employee_pins ...;       -- 如果失败...
  ROLLBACK TO SAVEPOINT before_pin_check;-- 回到存档点,事务恢复
  UPDATE employees SET last_login_at ... -- 可以正常执行!
COMMIT;                                   -- 成功!

优点

缺点

方案二:先检查表是否存在

原理

在执行可能出错的查询之前,先去 PostgreSQL 的系统目录(information_schema)查一下目标表存不存在。 如果不存在就直接跳过,根本不执行那条会报错的 SQL,事务始终保持健康。

代码示意

// 先检查表是否存在
const tableCheck = await client.query(`
  SELECT EXISTS (
    SELECT 1 FROM information_schema.tables
    WHERE table_schema = $1 AND table_name = 'employee_pins'
  ) AS exists
`, [schemaName]);

if (tableCheck.rows[0].exists) {
  const pinResult = await client.query(
    'SELECT 1 FROM employee_pins WHERE employee_id = $1 LIMIT 1',
    [employee.id]
  );
  hasPinSetup = pinResult.rows.length > 0;
} else {
  hasPinSetup = false;
}

优点

缺点

方案三:把 PIN 查询移到事务外面

原理

重新审视事务的范围——哪些操作真正需要原子性保证,哪些不需要。

查询 employee_pins 只是为了给前端返回一个 hasPinSetup 布尔值, 它和"验证密码、更新登录时间"等操作之间没有原子性要求。 把它移到事务 COMMIT 之后单独查询,即使失败也不影响登录。

代码示意

// ---- 事务内 ----
await client.query('BEGIN');
  // ... 查租户、查员工、验密码、查店铺 ...
  // ... 更新 last_login_at ...
await client.query('COMMIT');

// ---- 事务外(COMMIT 之后) ----
let hasPinSetup = false;
try {
  const pinResult = await client.query(
    'SELECT 1 FROM employee_pins WHERE employee_id = $1 LIMIT 1',
    [employee.id]
  );
  hasPinSetup = pinResult.rows.length > 0;
} catch (e) {
  hasPinSetup = false;  // 查不到就算了,不影响登录
}

// 生成 token,返回响应...
res.json({ success: true, data: { ..., has_pin_setup: hasPinSetup } });

优点

缺点

6. 方案对比

方案一:SAVEPOINT 方案二:先检查表 方案三:移到事务外
改动量 最小(加 3 行) 小(加一个查询) 中等(调整结构)
理解难度 需要懂 SAVEPOINT 最简单 简单
健壮性 最好
性能 多 2-3 次 DB 往返 多 1 次 DB 往返 无额外开销
适用场景 操作必须在事务内 偶尔一两处 操作本来不需要事务
推荐度 ⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐(推荐)
推荐方案三,因为 employee_pins 查询从业务逻辑上确实不需要在事务里,放外面更合理。 如果追求最小改动,方案一(SAVEPOINT)加 3 行也能解决。

7. 知识补充:PostgreSQL 事务机制

7.1 什么是数据库事务

事务(Transaction)是一组数据库操作的"打包",具有 ACID 四个特性:

特性英文含义比喻
原子性Atomicity 事务内所有操作要么全部成功,要么全部撤销 转账:扣钱和加钱要么都发生,要么都不发生
一致性Consistency 事务执行前后,数据库始终处于合法状态 账户余额不能为负数
隔离性Isolation 并发事务之间互不干扰 两个人同时转账不会互相影响
持久性Durability 事务提交后,修改永久保存 提交后即使断电数据也不丢

7.2 PostgreSQL vs MySQL 的事务错误处理

这是最容易踩坑的地方!PostgreSQL 和 MySQL 在事务内错误处理上行为完全不同。
PostgreSQLMySQL (InnoDB)
事务内单条 SQL 报错 整个事务进入 aborted 状态,后续所有 SQL 被拒绝 只有报错的那条失败,后续 SQL 可以继续执行
恢复方式 必须 ROLLBACK(或使用 SAVEPOINT) 自动恢复,可以继续执行
设计哲学 严格安全:一个错误可能已经破坏了数据假设,不应该继续 宽松灵活:让应用自己决定怎么处理

示例对比:

-- PostgreSQL 行为
BEGIN;
  INSERT INTO orders (id) VALUES (1);    -- OK
  INSERT INTO orders (id) VALUES (1);    -- 主键冲突,报错!
  INSERT INTO orders (id) VALUES (2);    -- 被拒绝!(25P02)
COMMIT;                                   -- 也失败,实际等于 ROLLBACK

-- MySQL 行为
START TRANSACTION;
  INSERT INTO orders (id) VALUES (1);    -- OK
  INSERT INTO orders (id) VALUES (1);    -- 主键冲突,报错
  INSERT INTO orders (id) VALUES (2);    -- 继续执行,成功!
COMMIT;                                   -- 成功(1 和 2 都插入了)

7.3 SAVEPOINT 详解

SAVEPOINT 是 SQL 标准的一部分,用于在事务内创建"存档点"。

BEGIN;
  -- 正常操作
  INSERT INTO users (name) VALUES ('Alice');

  SAVEPOINT my_savepoint;        -- 创建存档点

  -- 可能失败的操作
  INSERT INTO logs (msg) VALUES ('...');  -- 如果这里失败...

  ROLLBACK TO my_savepoint;      -- 回到存档点(只撤销 logs 的插入)
                                  -- users 的插入仍然有效!
                                  -- 事务恢复到 "idle in transaction" 状态

  RELEASE SAVEPOINT my_savepoint; -- 释放存档点(可选)

  -- 可以继续正常操作
  INSERT INTO users (name) VALUES ('Bob');

COMMIT;  -- 成功!Alice 和 Bob 都插入了
SAVEPOINT 的本质:它让 PostgreSQL 知道"这个范围内的错误是可以恢复的"。 ROLLBACK TO SAVEPOINT 会清除 aborted 状态,让事务重新变得可用。

7.4 事务状态机

PostgreSQL 事务有以下状态(可以用 SELECT txid_current_if_assigned() 等方式间接观察):

BEGIN [idle] ─────────────────→ [idle in transaction] │ SQL 成功 → 保持 [idle in transaction] │ SQL 失败 → 进入 [aborted] │ ┌───────────────┤ │ │ ROLLBACK ROLLBACK TO SAVEPOINT │ │ ↓ ↓ [idle] [idle in transaction] (事务结束) (事务恢复,继续)

7.5 search_path 和事务的关系

我们的代码里在事务内执行了 SET search_path TO tenant_qqnails, public。需要知道:

这意味着:方案三(移到事务外)是可行的,因为 COMMIT 之后 search_path 仍然是 tenant_qqnails,PIN 查询会在正确的 schema 下执行。

7.6 编程语言 try-catch vs 数据库事务

这个 bug 的本质是混淆了两个不同层面的错误处理

JavaScript try-catchPostgreSQL 事务
作用域 JavaScript 运行时 数据库连接
catch 后能继续吗? 可以,代码正常往下走 不行,事务已经 aborted
恢复方式 catch 之后自动恢复 必须显式 ROLLBACK 或 ROLLBACK TO SAVEPOINT
常见误解:"我用 try-catch 包住了,数据库错误被处理了"——错! try-catch 只处理了 JavaScript 层面的异常传播,PostgreSQL 事务的 aborted 状态完全不受 JavaScript try-catch 影响。 要恢复事务状态,必须在数据库层面执行 ROLLBACK 或 ROLLBACK TO SAVEPOINT。

8. 经验总结

规则:在 PostgreSQL 事务内执行"可能失败的"查询时,必须选择以下方式之一:
  1. 用 SAVEPOINT 保护——在可能失败的操作前后加存档点
  2. 先检查再操作——确认条件满足后再执行
  3. 移到事务外——如果操作不需要原子性保证,就不要放在事务里
  4. 绝对不要只用 JavaScript try-catch 包住就以为没事了

涉及文件: