Commit 69da729e by DaiJiezhang

fix: 登录锁定返回 429 与具体等待时间,补齐其余错误文案

用「业务文案覆盖率」做了一次整体核对:提取 Java 侧 service 与 auth 目录下的全部
59 条业务文案,与 NestJS 实际抛出的文案比对,找出未覆盖项逐条追查。
这个方法能发现契约对拍覆盖不到的规则——对拍只走典型路径,
触发条件苛刻的分支(例如连续失败 20 次)不会被跑到。

发现并修复:登录锁定的响应完全不同
  Java    429「登录失败次数过多,账号已被临时锁定,请约 X 小时后重试或联系管理员解锁」
  NestJS  401「账号或密码错误」
被锁定与密码错误是两回事:使用者必须知道自己被锁了、还要等多久、可以找谁解锁,
否则只会反复重试。剩余小时向上取整且至少为 1,避免出现「请约 0 小时后重试」。
已用连续 20 次失败的实测验证两边一致:第 20 次 401,第 21 次 429 且文案逐字相同。

一并补齐
- 数据库结构与代码不匹配(Prisma P2021/P2022)时返回「数据库字段未同步,
  请完成数据库迁移后重试」,而不是笼统的服务器故障,提示指向真正的原因
- 图片目录创建失败、图片写入失败、缩略图回写失败三处 IO 异常的文案与 Java 对齐

核对后确认无需处理的 8 条
「XX 新增失败」五条是 Java 的 insert 计数防御检查,Prisma 写入失败会直接抛异常,
两边最终都返回 500 通用文案,行为一致;「认证签名密钥未配置」同样走 500 兜底;
「页面权限无法保存」是 JSON 序列化失败的兜底,Prisma 侧不存在这一环节。

回归:契约比对 31/31、设备流程 15/15、企微规则 10/10、单元测试 25/25 全部通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent f4dc0834
......@@ -27,9 +27,22 @@ interface Attempt { failures: number; lockedUntil: number | null; lastFailureAt:
export interface LockedAccount { username: string; failures: number; lockedUntil: string; lastFailureAt: string; }
/** 账号被锁定时抛出,HTTP 状态与文案由全局过滤器统一处理。 */
/**
* 账号被锁定时抛出,返回 429 并告知大约还要等多久。
*
* 注意这里不能沿用「账号或密码错误」:被锁定与密码错误是两回事,
* 使用者必须知道自己是被锁了、还要等多久、以及可以找管理员解锁,
* 否则会一直重试,反而让锁定窗口不断延后(实际不会延长,但使用者并不知情)。
*
* 剩余小时向上取整且至少为 1,避免出现「请约 0 小时后重试」。
*/
export class LoginThrottledException extends BusinessException {
constructor(readonly lockedUntil: number) { super('账号或密码错误', 401); this.name = 'LoginThrottledException'; }
constructor(readonly lockedUntil: number) {
const minutes = Math.max(0, Math.round((lockedUntil - Date.now()) / 60000));
const hours = Math.max(1, Math.ceil(minutes / 60));
super(`登录失败次数过多,账号已被临时锁定,请约 ${hours} 小时后重试或联系管理员解锁`, 429);
this.name = 'LoginThrottledException';
}
}
@Injectable()
......
......@@ -13,6 +13,8 @@ import { failure } from './api-response';
/** Prisma 唯一约束冲突的错误码,对应 Java 的 DuplicateKeyException。 */
const PRISMA_UNIQUE_VIOLATION = 'P2002';
/** 表或列与代码对不上时 Prisma 报的错误码,对应 Java 的 BadSqlGrammarException。 */
const PRISMA_SCHEMA_MISMATCH = new Set(['P2021', 'P2022']);
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
......@@ -33,6 +35,11 @@ export class AllExceptionsFilter implements ExceptionFilter {
return { status: 409, message };
}
// 数据库结构落后于代码时,提示应指向真正的原因,而不是笼统的服务器故障
if (PRISMA_SCHEMA_MISMATCH.has(error?.code)) {
return { status: 500, message: '数据库字段未同步,请完成数据库迁移后重试' };
}
if (error instanceof HttpException) {
const status = error.getStatus();
// 校验失败由 ValidationPipe 抛出,Java 侧统一为这句,不暴露具体字段
......
......@@ -39,7 +39,8 @@ export class DeviceAssetFileStorageService {
}
async ensureRoot(): Promise<void> {
await fs.mkdir(this.root, { recursive: true });
try { await fs.mkdir(this.root, { recursive: true }); }
catch { throw new BusinessException('设备图片目录无法创建'); }
}
/** 保存一张原图并同时产出缩略图,返回不透明标识。 */
......@@ -54,8 +55,13 @@ export class DeviceAssetFileStorageService {
const target = this.resolveInsideRoot(identifier);
// 先确认确实是可解码的图片,再落盘,避免留下无法读取的残file
const thumbnail = await this.renderThumbnail(image.buffer);
try {
await fs.writeFile(target, image.buffer);
await fs.writeFile(this.thumbnailPathFor(identifier), thumbnail);
} catch {
await this.cleanupNewFile(identifier);
throw new BusinessException('设备图片保存失败');
}
return identifier;
}
......@@ -79,7 +85,8 @@ export class DeviceAssetFileStorageService {
catch { /* 继续走补生成 */ }
const original = await this.readOriginal(identifier);
const thumbnail = await this.renderThumbnail(original);
await fs.writeFile(thumbnailPath, thumbnail);
try { await fs.writeFile(thumbnailPath, thumbnail); }
catch { throw new BusinessException('图片读取失败'); }
return thumbnail;
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment