记录日期:2026-02-05
Bug PostgreSQL Backend
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),说明问题出在后端逻辑。
排查 APP 端代码,确认请求链路:
http://localhost:3000/api(env_config.dart)/auth/employee/login(api_endpoints.dart)http://localhost:3000/api/auth/employee/loginapp.use('/api/auth/employee', ...)(server.js:1082)路径匹配正确,排除了路由问题。
$ curl http://localhost:3000/api/health
→ 200 OK
后端正在运行,排除了服务未启动的问题。
检查了 Info.plist 是否缺少 NSAppTransportSecurity 配置。
但 iOS 模拟器对 localhost 有 ATS 豁免,所以这不是问题。
用 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)崩了。
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 事务的状态。
ROLLBACK 能结束这个事务。
这是 PostgreSQL 的设计决策,和 MySQL 不同。
所以虽然 JavaScript 层面的代码继续执行了,但数据库连接上的事务已经废了,后面的 UPDATE 和 COMMIT 全部会失败。
| 步骤 | 操作 | 代码位置 | 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 行 | 返回错误给客户端 | 事务结束 |
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;
}
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;
}
information_schema 查询重新审视事务的范围——哪些操作真正需要原子性保证,哪些不需要。
查询 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 } });
client 的 search_path 是否还是 tenant_qqnails(通常是的,因为 search_path 是 session 级别设置,不受事务回滚影响)| 方案一:SAVEPOINT | 方案二:先检查表 | 方案三:移到事务外 | |
|---|---|---|---|
| 改动量 | 最小(加 3 行) | 小(加一个查询) | 中等(调整结构) |
| 理解难度 | 需要懂 SAVEPOINT | 最简单 | 简单 |
| 健壮性 | 好 | 好 | 最好 |
| 性能 | 多 2-3 次 DB 往返 | 多 1 次 DB 往返 | 无额外开销 |
| 适用场景 | 操作必须在事务内 | 偶尔一两处 | 操作本来不需要事务 |
| 推荐度 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐(推荐) |
employee_pins 查询从业务逻辑上确实不需要在事务里,放外面更合理。
如果追求最小改动,方案一(SAVEPOINT)加 3 行也能解决。
事务(Transaction)是一组数据库操作的"打包",具有 ACID 四个特性:
| 特性 | 英文 | 含义 | 比喻 |
|---|---|---|---|
| 原子性 | Atomicity | 事务内所有操作要么全部成功,要么全部撤销 | 转账:扣钱和加钱要么都发生,要么都不发生 |
| 一致性 | Consistency | 事务执行前后,数据库始终处于合法状态 | 账户余额不能为负数 |
| 隔离性 | Isolation | 并发事务之间互不干扰 | 两个人同时转账不会互相影响 |
| 持久性 | Durability | 事务提交后,修改永久保存 | 提交后即使断电数据也不丢 |
| PostgreSQL | MySQL (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 都插入了)
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 都插入了
ROLLBACK TO SAVEPOINT 会清除 aborted 状态,让事务重新变得可用。
PostgreSQL 事务有以下状态(可以用 SELECT txid_current_if_assigned() 等方式间接观察):
我们的代码里在事务内执行了 SET search_path TO tenant_qqnails, public。需要知道:
SET search_path 是 session 级别的设置,不属于事务search_path 的改变不会被撤销SET LOCAL search_path,则是事务级别的,ROLLBACK 会撤销search_path
仍然是 tenant_qqnails,PIN 查询会在正确的 schema 下执行。
这个 bug 的本质是混淆了两个不同层面的错误处理:
| JavaScript try-catch | PostgreSQL 事务 | |
|---|---|---|
| 作用域 | JavaScript 运行时 | 数据库连接 |
| catch 后能继续吗? | 可以,代码正常往下走 | 不行,事务已经 aborted |
| 恢复方式 | catch 之后自动恢复 | 必须显式 ROLLBACK 或 ROLLBACK TO SAVEPOINT |
涉及文件:
backend/auth/employee_auth_controller.js 第 296-307 行(PIN 查询)backend/auth/employee_auth_controller.js 第 348 行(UPDATE 失败点)employee_mobile_app/lib/features/auth/(APP 端认证流程)