diff --git a/src/app/login/LoginWindow.hpp b/src/app/login/LoginWindow.hpp index 2e97312..510e249 100644 --- a/src/app/login/LoginWindow.hpp +++ b/src/app/login/LoginWindow.hpp @@ -35,7 +35,7 @@ public: private: void refreshCaptcha(); // 拉新验证码并重绘图片 - void attemptLogin(); // 校验输入并发起阻塞登录 + void attemptLogin(); // 校验输入并发起异步登录 void showError(const QString& msg); geopro::net::AuthService& auth_; diff --git a/src/net/ApiChain.cpp b/src/net/ApiChain.cpp index fa8909f..1590fc0 100644 --- a/src/net/ApiChain.cpp +++ b/src/net/ApiChain.cpp @@ -7,6 +7,9 @@ ApiChain::ApiChain(QList steps, Predicate isFailure, QObject* paren : QObject(parent), steps_(std::move(steps)), isFailure_(std::move(isFailure)) { Q_ASSERT(!steps_.isEmpty()); // 契约:至少一步(空链永不发 succeeded) Q_ASSERT(isFailure_); + // 首步同步契约:此处同步执行首个 step 工厂。调用方须在 new ApiChain 之前连接 + // succeeded/failed,且首个工厂不得同步抛出、其 IApiCall 不得同步 emit finished。 + // 详见 ApiChain.hpp 首步同步契约说明。 runNext(); } diff --git a/src/net/ApiChain.hpp b/src/net/ApiChain.hpp index f0b3f4c..281aa7c 100644 --- a/src/net/ApiChain.hpp +++ b/src/net/ApiChain.hpp @@ -13,6 +13,18 @@ namespace geopro::net { // 与 ApiBatch 对称:同 Predicate、同信号面、同安全约束。 // 注意:Part A(导航)暂未接入生产调用,首个生产使用见 Part B 登录(AuthService 串行链); // 已由 tests/net/test_api_chain.cpp 覆盖,请勿当死代码删除。 +// +// ── 首步同步契约(调用方必读)────────────────────────────────────────────────── +// ctor 内同步调用 runNext(),立即执行首个 step 工厂。 +// 因此调用方须在 new ApiChain(...) 之前连接 succeeded/failed 信号——否则若首步工厂 +// 同步抛异常(catch 后 emit failed),信号会在连接建立前发出而丢失。 +// 契约: +// 1. 首个 step 工厂不得同步抛异常(生产路径:verifyCodeCheck 不抛,满足)。 +// 2. 首个 step 工厂返回的 IApiCall 不得同步 emit finished(生产路径:真实网络请求,满足)。 +// 若将来需要可同步完成的首步,应将 ctor 内 runNext() 改为 +// QMetaObject::invokeMethod(this, &ApiChain::runNext, Qt::QueuedConnection) +// 以推迟首拍到事件循环,届时调用方已完成信号连接。 +// ──────────────────────────────────────────────────────────────────────────────── class ApiChain : public QObject { Q_OBJECT public: diff --git a/src/net/AuthLoads.cpp b/src/net/AuthLoads.cpp index d5132f6..b92498e 100644 --- a/src/net/AuthLoads.cpp +++ b/src/net/AuthLoads.cpp @@ -18,9 +18,10 @@ QString reasonOf(const ApiResponse& resp, const QString& fallback) { } // namespace CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), call_(call) { - QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) { + QObject::connect(call_, &IApiCall::finished, this, [this](const ApiResponse& resp) { if (aborted_) return; // §5.0 入口守卫 if (resp.code != 200 || !resp.rawError.isEmpty()) { + aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退 emit failed(reasonOf(resp, QStringLiteral("获取验证码失败"))); deleteLater(); return; @@ -28,6 +29,7 @@ CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), cal AuthService::Captcha cap; cap.codeId = resp.data.value(QStringLiteral("id")).toString(); cap.code = resp.data.value(QStringLiteral("code")).toString(); + aborted_ = true; // 终态置位:到达 done 终态后 abort() 早退 emit done(cap); deleteLater(); }); @@ -41,7 +43,7 @@ void CaptchaLoad::abort() { } LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_(chain) { - QObject::connect(chain, &ApiChain::succeeded, this, + QObject::connect(chain_, &ApiChain::succeeded, this, [this](const QList& responses) { if (aborted_) return; // §5.0 入口守卫 const QString token = @@ -49,15 +51,18 @@ LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_ ? QString() : responses.last().data.value(QStringLiteral("accessToken")).toString(); if (token.isEmpty()) { + aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退 emit failed(QStringLiteral("登录成功但缺少 accessToken")); deleteLater(); return; } + aborted_ = true; // 终态置位:到达 done 终态后 abort() 早退 emit done(token); deleteLater(); }); - QObject::connect(chain, &ApiChain::failed, this, [this](int, const ApiResponse& resp) { + QObject::connect(chain_, &ApiChain::failed, this, [this](int, const ApiResponse& resp) { if (aborted_) return; // §5.0 入口守卫 + aborted_ = true; // 终态置位:到达 failed 终态后 abort() 早退 emit failed(reasonOf(resp, QStringLiteral("登录失败"))); deleteLater(); }); diff --git a/tests/net/test_auth_loads.cpp b/tests/net/test_auth_loads.cpp index 2f67ea5..bc369ee 100644 --- a/tests/net/test_auth_loads.cpp +++ b/tests/net/test_auth_loads.cpp @@ -2,6 +2,8 @@ // 用 FakeApiCall + 真 ApiChain 离线驱动,不联网。QSignalSpy 需 Qt6::Test。 #include +#include + #include #include "ApiChain.hpp" @@ -127,3 +129,25 @@ TEST(LoginLoad, EmitsFailedWhenTokenMissing) { EXPECT_EQ(doneSpy.count(), 0); ASSERT_EQ(failSpy.count(), 1); } + +// M-2:step 工厂抛异常(模拟 RSA 失败)时 LoginLoad 应 emit failed。 +// 第一步用 FakeApiCall(不同步 fire,由测试手动触发),第二步工厂抛异常。 +// 构造顺序:new ApiChain(同步执行 step1 工厂,s1 已建立但未 fire) +// → new LoginLoad(连接 chain 的 succeeded/failed) +// → s1->fire(plainOk())(step1 成功 → step2 工厂抛 → ApiChain emit failed +// → LoginLoad 已连接,收到 failed)。 +TEST(LoginLoad, EmitsFailedWhenStepFactoryThrows) { + auto* s1 = new FakeApiCall; + QList steps{ + [&](const QList&) -> IApiCall* { return s1; }, + [&](const QList&) -> IApiCall* { + throw std::runtime_error("rsa fail"); + }}; + auto* chain = new ApiChain(steps, isFailure); + auto* load = new LoginLoad(chain); + QSignalSpy doneSpy(load, &LoginLoad::done); + QSignalSpy failSpy(load, &LoginLoad::failed); + s1->fire(plainOk()); // step1 成功 → step2 工厂抛异常 → ApiChain emit failed + ASSERT_EQ(failSpy.count(), 1); + EXPECT_EQ(doneSpy.count(), 0); +}