feat(net+app): AuthService/登录异步化(CaptchaLoad/LoginLoad+ApiChain, LoginWindow 不冻可取消, test_auth 异步化)
B1: AuthService 改异步——新增 net 层句柄 CaptchaLoad/LoginLoad(AuthLoads.{hpp,cpp}),
fetchCaptchaAsync 返回 CaptchaLoad;loginAsync 用 ApiChain 编排 verifyCodeCheck->RSA->login2
返回 LoginLoad。删同步 fetchCaptcha/login/LoginResult。句柄遵 spec §5.0(aborted_ 守卫/deleteLater)。
B2: LoginWindow 异步化——refreshCaptcha/attemptLogin 连句柄信号(身份比对),删 repaint() hack,
析构 abort 在飞句柄(退出契约)。公共 API(token/remember/exec)不变。
B4: test_auth.cpp 改 QSignalSpy::wait 异步等待(仍 live)。
新增离线句柄单测 test_auth_loads.cpp(CaptchaLoad/LoginLoad done/failed/abort)。
[B3 删 ApiClient 同步 get/postJson 因 ProjectListDialog/ApiProjectRepository 仍同步而 BLOCKED,故保留]
This commit is contained in:
parent
4ca5893800
commit
d1c1bf96b1
|
|
@ -17,6 +17,7 @@
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
||||||
|
#include "AuthLoads.hpp"
|
||||||
#include "AuthService.hpp"
|
#include "AuthService.hpp"
|
||||||
#include "Theme.hpp"
|
#include "Theme.hpp"
|
||||||
|
|
||||||
|
|
@ -219,20 +220,36 @@ LoginWindow::LoginWindow(geopro::net::AuthService& auth, QWidget* parent)
|
||||||
userEdit_->setFocus(); // 焦点落在第一个待填字段
|
userEdit_->setFocus(); // 焦点落在第一个待填字段
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LoginWindow::~LoginWindow()
|
||||||
|
{
|
||||||
|
// 退出契约:窗口析构时 abort 在飞句柄,防回调打到已析构窗口(spec §5.0)。
|
||||||
|
if (captchaLoad_) captchaLoad_->abort();
|
||||||
|
if (loginLoad_) loginLoad_->abort();
|
||||||
|
}
|
||||||
|
|
||||||
void LoginWindow::refreshCaptcha()
|
void LoginWindow::refreshCaptcha()
|
||||||
{
|
{
|
||||||
codeEdit_->clear();
|
codeEdit_->clear();
|
||||||
try {
|
if (captchaLoad_) captchaLoad_->abort(); // 取消上一张在飞请求
|
||||||
const auto cap = auth_.fetchCaptcha();
|
refreshBtn_->setEnabled(false);
|
||||||
codeId_ = cap.codeId;
|
|
||||||
captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code));
|
auto* l = auth_.fetchCaptchaAsync();
|
||||||
} catch (const std::exception& e) {
|
captchaLoad_ = l;
|
||||||
showError(QStringLiteral("获取验证码失败:%1").arg(QString::fromUtf8(e.what())));
|
connect(l, &geopro::net::CaptchaLoad::done, this,
|
||||||
|
[this, l](const geopro::net::AuthService::Captcha& cap) {
|
||||||
|
if (l != captchaLoad_) return; // 身份比对:仅处理最新请求
|
||||||
|
captchaLoad_.clear();
|
||||||
|
codeId_ = cap.codeId;
|
||||||
|
captchaLabel_->setPixmap(renderCaptchaPixmap(cap.code));
|
||||||
|
refreshBtn_->setEnabled(true);
|
||||||
|
});
|
||||||
|
connect(l, &geopro::net::CaptchaLoad::failed, this, [this, l](const QString& msg) {
|
||||||
|
if (l != captchaLoad_) return;
|
||||||
|
captchaLoad_.clear();
|
||||||
|
showError(QStringLiteral("获取验证码失败:%1").arg(msg));
|
||||||
captchaLabel_->setText(QStringLiteral("加载失败"));
|
captchaLabel_->setText(QStringLiteral("加载失败"));
|
||||||
} catch (...) {
|
refreshBtn_->setEnabled(true);
|
||||||
showError(QStringLiteral("获取验证码失败"));
|
});
|
||||||
captchaLabel_->setText(QStringLiteral("加载失败"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void LoginWindow::attemptLogin()
|
void LoginWindow::attemptLogin()
|
||||||
|
|
@ -249,31 +266,25 @@ void LoginWindow::attemptLogin()
|
||||||
errorLabel_->clear();
|
errorLabel_->clear();
|
||||||
loginBtn_->setEnabled(false);
|
loginBtn_->setEnabled(false);
|
||||||
const QString origText = loginBtn_->text();
|
const QString origText = loginBtn_->text();
|
||||||
loginBtn_->setText(QStringLiteral("登录中..."));
|
loginBtn_->setText(QStringLiteral("登录中...")); // 异步不冻 UI,无需 repaint hack
|
||||||
loginBtn_->repaint(); // 同步阻塞前刷新按钮文案
|
|
||||||
|
|
||||||
geopro::net::AuthService::LoginResult result;
|
if (loginLoad_) loginLoad_->abort(); // 取消上一次在飞登录
|
||||||
try {
|
auto* l = auth_.loginAsync(user, pwd, code, codeId_);
|
||||||
result = auth_.login(user, pwd, code, codeId_);
|
loginLoad_ = l;
|
||||||
} catch (const std::exception& e) {
|
connect(l, &geopro::net::LoginLoad::done, this, [this, l](const QString& token) {
|
||||||
result.ok = false;
|
if (l != loginLoad_) return; // 身份比对
|
||||||
result.error = QStringLiteral("登录异常:%1").arg(QString::fromUtf8(e.what()));
|
loginLoad_.clear();
|
||||||
} catch (...) {
|
token_ = token;
|
||||||
result.ok = false;
|
|
||||||
result.error = QStringLiteral("登录发生未知错误");
|
|
||||||
}
|
|
||||||
|
|
||||||
loginBtn_->setText(origText);
|
|
||||||
loginBtn_->setEnabled(true);
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
token_ = result.token;
|
|
||||||
accept();
|
accept();
|
||||||
return;
|
});
|
||||||
}
|
connect(l, &geopro::net::LoginLoad::failed, this, [this, l, origText](const QString& msg) {
|
||||||
|
if (l != loginLoad_) return;
|
||||||
showError(result.error.isEmpty() ? QStringLiteral("登录失败") : result.error);
|
loginLoad_.clear();
|
||||||
refreshCaptcha(); // 失败刷新验证码
|
loginBtn_->setText(origText);
|
||||||
|
loginBtn_->setEnabled(true);
|
||||||
|
showError(msg.isEmpty() ? QStringLiteral("登录失败") : msg);
|
||||||
|
refreshCaptcha(); // 失败刷新验证码
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
bool LoginWindow::remember() const
|
bool LoginWindow::remember() const
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
// 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。
|
// 成功后 accept() 并经 token() 暴露 accessToken 给主流程注入 ApiClient。
|
||||||
|
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
|
#include <QPointer>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
||||||
class QCheckBox;
|
class QCheckBox;
|
||||||
|
|
@ -13,6 +14,8 @@ class QPushButton;
|
||||||
|
|
||||||
namespace geopro::net {
|
namespace geopro::net {
|
||||||
class AuthService;
|
class AuthService;
|
||||||
|
class CaptchaLoad;
|
||||||
|
class LoginLoad;
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace geopro::app {
|
namespace geopro::app {
|
||||||
|
|
@ -22,6 +25,7 @@ class LoginWindow : public QDialog {
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit LoginWindow(geopro::net::AuthService& auth, QWidget* parent = nullptr);
|
explicit LoginWindow(geopro::net::AuthService& auth, QWidget* parent = nullptr);
|
||||||
|
~LoginWindow() override;
|
||||||
|
|
||||||
// 登录成功后的 accessToken(形如 "Geomative <hash>");未登录为空。
|
// 登录成功后的 accessToken(形如 "Geomative <hash>");未登录为空。
|
||||||
QString token() const { return token_; }
|
QString token() const { return token_; }
|
||||||
|
|
@ -37,6 +41,8 @@ private:
|
||||||
geopro::net::AuthService& auth_;
|
geopro::net::AuthService& auth_;
|
||||||
QString token_;
|
QString token_;
|
||||||
QString codeId_;
|
QString codeId_;
|
||||||
|
QPointer<geopro::net::CaptchaLoad> captchaLoad_;
|
||||||
|
QPointer<geopro::net::LoginLoad> loginLoad_;
|
||||||
|
|
||||||
QLineEdit* userEdit_ = nullptr;
|
QLineEdit* userEdit_ = nullptr;
|
||||||
QLineEdit* pwdEdit_ = nullptr;
|
QLineEdit* pwdEdit_ = nullptr;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
#include "AuthLoads.hpp"
|
||||||
|
|
||||||
|
#include "ApiChain.hpp"
|
||||||
|
#include "ApiClient.hpp" // geopro::net::ApiResponse
|
||||||
|
#include "IApiCall.hpp"
|
||||||
|
|
||||||
|
namespace geopro::net {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 统一的失败文案:优先服务端 msg,否则回退传输层 rawError,最后给通用文案。
|
||||||
|
QString reasonOf(const ApiResponse& resp, const QString& fallback) {
|
||||||
|
if (!resp.msg.isEmpty()) return resp.msg;
|
||||||
|
if (!resp.rawError.isEmpty()) return resp.rawError;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
CaptchaLoad::CaptchaLoad(IApiCall* call, QObject* parent) : QObject(parent), call_(call) {
|
||||||
|
QObject::connect(call, &IApiCall::finished, this, [this](const ApiResponse& resp) {
|
||||||
|
if (aborted_) return; // §5.0 入口守卫
|
||||||
|
if (resp.code != 200 || !resp.rawError.isEmpty()) {
|
||||||
|
emit failed(reasonOf(resp, QStringLiteral("获取验证码失败")));
|
||||||
|
deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AuthService::Captcha cap;
|
||||||
|
cap.codeId = resp.data.value(QStringLiteral("id")).toString();
|
||||||
|
cap.code = resp.data.value(QStringLiteral("code")).toString();
|
||||||
|
emit done(cap);
|
||||||
|
deleteLater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void CaptchaLoad::abort() {
|
||||||
|
if (aborted_) return;
|
||||||
|
aborted_ = true;
|
||||||
|
if (call_) call_->abort();
|
||||||
|
deleteLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
LoginLoad::LoginLoad(ApiChain* chain, QObject* parent) : QObject(parent), chain_(chain) {
|
||||||
|
QObject::connect(chain, &ApiChain::succeeded, this,
|
||||||
|
[this](const QList<ApiResponse>& responses) {
|
||||||
|
if (aborted_) return; // §5.0 入口守卫
|
||||||
|
const QString token =
|
||||||
|
responses.isEmpty()
|
||||||
|
? QString()
|
||||||
|
: responses.last().data.value(QStringLiteral("accessToken")).toString();
|
||||||
|
if (token.isEmpty()) {
|
||||||
|
emit failed(QStringLiteral("登录成功但缺少 accessToken"));
|
||||||
|
deleteLater();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit done(token);
|
||||||
|
deleteLater();
|
||||||
|
});
|
||||||
|
QObject::connect(chain, &ApiChain::failed, this, [this](int, const ApiResponse& resp) {
|
||||||
|
if (aborted_) return; // §5.0 入口守卫
|
||||||
|
emit failed(reasonOf(resp, QStringLiteral("登录失败")));
|
||||||
|
deleteLater();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoginLoad::abort() {
|
||||||
|
if (aborted_) return;
|
||||||
|
aborted_ = true;
|
||||||
|
if (chain_) chain_->abort();
|
||||||
|
deleteLater();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace geopro::net
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
#include "AuthService.hpp" // geopro::net::AuthService::Captcha
|
||||||
|
|
||||||
|
namespace geopro::net {
|
||||||
|
|
||||||
|
class IApiCall;
|
||||||
|
class ApiChain;
|
||||||
|
|
||||||
|
// 验证码加载句柄(net 层,自管理):接管一个 getImageCode 的 IApiCall,
|
||||||
|
// 完成后解析 {data.id, data.code} -> done(Captcha);失败 -> failed(msg)。
|
||||||
|
// 安全不变量见 spec §5.0:aborted_ 入口守卫 + 一律 deleteLater(禁同步 delete)。
|
||||||
|
class CaptchaLoad : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit CaptchaLoad(IApiCall* call, QObject* parent = nullptr);
|
||||||
|
void abort();
|
||||||
|
signals:
|
||||||
|
void done(const geopro::net::AuthService::Captcha& captcha);
|
||||||
|
void failed(const QString& message);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QPointer<IApiCall> call_;
|
||||||
|
bool aborted_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 登录加载句柄(net 层,自管理):接管一个 verifyCodeCheck->RSA->login2 的 ApiChain,
|
||||||
|
// succeeded 取末步 data.accessToken -> done(token);缺 token 或 failed -> failed(msg)。
|
||||||
|
class LoginLoad : public QObject {
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
explicit LoginLoad(ApiChain* chain, QObject* parent = nullptr);
|
||||||
|
void abort();
|
||||||
|
signals:
|
||||||
|
void done(const QString& token);
|
||||||
|
void failed(const QString& message);
|
||||||
|
|
||||||
|
private:
|
||||||
|
QPointer<ApiChain> chain_;
|
||||||
|
bool aborted_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace geopro::net
|
||||||
|
|
@ -2,9 +2,11 @@
|
||||||
|
|
||||||
#include <QJsonObject>
|
#include <QJsonObject>
|
||||||
#include <QJsonValue>
|
#include <QJsonValue>
|
||||||
#include <stdexcept>
|
#include <string>
|
||||||
|
|
||||||
|
#include "ApiChain.hpp"
|
||||||
#include "ApiClient.hpp"
|
#include "ApiClient.hpp"
|
||||||
|
#include "AuthLoads.hpp"
|
||||||
#include "crypto/RsaEncryptor.hpp"
|
#include "crypto/RsaEncryptor.hpp"
|
||||||
|
|
||||||
namespace geopro::net {
|
namespace geopro::net {
|
||||||
|
|
@ -17,64 +19,39 @@ const char* const kPathImageCode = "/business/system/personalUser/getImageCode";
|
||||||
const char* const kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck";
|
const char* const kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck";
|
||||||
const char* const kPathLogin = "/admin/tenant/auth/login2";
|
const char* const kPathLogin = "/admin/tenant/auth/login2";
|
||||||
|
|
||||||
// 统一的错误信息:优先用服务端 msg,否则回退到传输层 rawError,最后给通用文案。
|
|
||||||
QString errorFrom(const ApiResponse& resp, const QString& fallback) {
|
|
||||||
if (!resp.msg.isEmpty()) return resp.msg;
|
|
||||||
if (!resp.rawError.isEmpty()) return resp.rawError;
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem)
|
AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem)
|
||||||
: api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {}
|
: api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {}
|
||||||
|
|
||||||
AuthService::Captcha AuthService::fetchCaptcha() {
|
CaptchaLoad* AuthService::fetchCaptchaAsync() {
|
||||||
const ApiResponse resp = api_.get(QString::fromLatin1(kPathImageCode));
|
IApiCall* call = api_.getAsync(QString::fromLatin1(kPathImageCode));
|
||||||
Captcha cap;
|
return new CaptchaLoad(call);
|
||||||
if (resp.code != kCodeSuccess) {
|
|
||||||
return cap; // 失败时返回空 codeId/code,调用方据此判断
|
|
||||||
}
|
|
||||||
cap.codeId = resp.data.value(QStringLiteral("id")).toString();
|
|
||||||
cap.code = resp.data.value(QStringLiteral("code")).toString();
|
|
||||||
return cap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthService::LoginResult AuthService::login(const QString& username, const QString& password,
|
LoginLoad* AuthService::loginAsync(const QString& username, const QString& password,
|
||||||
const QString& code, const QString& codeId) {
|
const QString& code, const QString& codeId) {
|
||||||
// 1) 校验验证码(与 captcha 同会话)。
|
// 失败判定与同步版一致:服务端 code != 200 或存在传输层 rawError。
|
||||||
const QJsonObject verifyBody{{QStringLiteral("code"), code},
|
auto isFailure = [](const ApiResponse& r) { return r.code != kCodeSuccess || !r.rawError.isEmpty(); };
|
||||||
{QStringLiteral("codeId"), codeId}};
|
|
||||||
const ApiResponse verify = api_.postJson(QString::fromLatin1(kPathVerifyCode), verifyBody);
|
|
||||||
if (verify.code != kCodeSuccess) {
|
|
||||||
return {false, QString(), errorFrom(verify, QStringLiteral("verifyCodeCheck failed"))};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) RSA 加密密码(PKCS#1 v1.5 -> base64)。
|
// step1:校验验证码(与 captcha 同会话)。
|
||||||
std::string encrypted;
|
ApiChain::StepFactory step1 = [this, code, codeId](const QList<ApiResponse>&) -> IApiCall* {
|
||||||
try {
|
const QJsonObject body{{QStringLiteral("code"), code}, {QStringLiteral("codeId"), codeId}};
|
||||||
|
return api_.postJsonAsync(QString::fromLatin1(kPathVerifyCode), body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// step2:RSA 加密密码(PKCS#1 v1.5 -> base64,可抛 std::exception → ApiChain 转 failed)-> login2。
|
||||||
|
ApiChain::StepFactory step2 = [this, username, password](const QList<ApiResponse>&) -> IApiCall* {
|
||||||
RsaEncryptor enc(rsaPublicKeyPem_);
|
RsaEncryptor enc(rsaPublicKeyPem_);
|
||||||
encrypted = enc.encryptBase64(password.toStdString());
|
const std::string encrypted = enc.encryptBase64(password.toStdString());
|
||||||
} catch (const std::exception& e) {
|
const QJsonObject body{{QStringLiteral("username"), username},
|
||||||
return {false, QString(),
|
{QStringLiteral("password"), QString::fromStdString(encrypted)},
|
||||||
QStringLiteral("RSA encryption failed: %1").arg(QString::fromUtf8(e.what()))};
|
{QStringLiteral("checkCode"), QString()}};
|
||||||
}
|
return api_.postJsonAsync(QString::fromLatin1(kPathLogin), body);
|
||||||
|
};
|
||||||
|
|
||||||
// 3) login2:checkCode 传空串。
|
auto* chain = new ApiChain({step1, step2}, isFailure);
|
||||||
const QJsonObject loginBody{
|
return new LoginLoad(chain);
|
||||||
{QStringLiteral("username"), username},
|
|
||||||
{QStringLiteral("password"), QString::fromStdString(encrypted)},
|
|
||||||
{QStringLiteral("checkCode"), QString()}};
|
|
||||||
const ApiResponse login = api_.postJson(QString::fromLatin1(kPathLogin), loginBody);
|
|
||||||
if (login.code != kCodeSuccess) {
|
|
||||||
return {false, QString(), errorFrom(login, QStringLiteral("login2 failed"))};
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString token = login.data.value(QStringLiteral("accessToken")).toString();
|
|
||||||
if (token.isEmpty()) {
|
|
||||||
return {false, QString(), QStringLiteral("login2 succeeded but accessToken missing")};
|
|
||||||
}
|
|
||||||
return {true, token, QString()};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace geopro::net
|
} // namespace geopro::net
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,13 @@
|
||||||
namespace geopro::net {
|
namespace geopro::net {
|
||||||
|
|
||||||
class ApiClient;
|
class ApiClient;
|
||||||
|
class CaptchaLoad;
|
||||||
|
class LoginLoad;
|
||||||
|
|
||||||
// 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。
|
// 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。
|
||||||
// 依赖外部注入的 ApiClient(单实例、共享会话);RSA 公钥 PEM 由调用方读取后传入。
|
// 依赖外部注入的 ApiClient(单实例、共享会话);RSA 公钥 PEM 由调用方读取后传入。
|
||||||
|
// 异步:fetchCaptchaAsync/loginAsync 立即返回自管理句柄(net 层 CaptchaLoad/LoginLoad),
|
||||||
|
// 不阻塞 UI;句柄 done/failed 后自 deleteLater。AuthService 自身只创建句柄,无需是 QObject。
|
||||||
class AuthService {
|
class AuthService {
|
||||||
public:
|
public:
|
||||||
AuthService(ApiClient& api, std::string rsaPublicKeyPem);
|
AuthService(ApiClient& api, std::string rsaPublicKeyPem);
|
||||||
|
|
@ -18,18 +22,14 @@ public:
|
||||||
QString codeId;
|
QString codeId;
|
||||||
QString code;
|
QString code;
|
||||||
};
|
};
|
||||||
Captcha fetchCaptcha();
|
|
||||||
|
|
||||||
struct LoginResult {
|
// 异步拉验证码:GET getImageCode;返回句柄,连 CaptchaLoad::done(Captcha)/failed(QString)。
|
||||||
bool ok = false;
|
CaptchaLoad* fetchCaptchaAsync();
|
||||||
QString token;
|
|
||||||
QString error;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 校验验证码 -> RSA 加密密码 -> login2。成功返回 {true, accessToken, ""};
|
// 异步登录:verifyCodeCheck -> RSA 加密密码 -> login2(依赖链)。
|
||||||
// 任一步失败返回 {false, "", <服务端 msg 或本地错误>}。
|
// 返回句柄,连 LoginLoad::done(token)/failed(QString)。
|
||||||
LoginResult login(const QString& username, const QString& password, const QString& code,
|
LoginLoad* loginAsync(const QString& username, const QString& password, const QString& code,
|
||||||
const QString& codeId);
|
const QString& codeId);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
ApiClient& api_;
|
ApiClient& api_;
|
||||||
|
|
@ -37,3 +37,6 @@ private:
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace geopro::net
|
} // namespace geopro::net
|
||||||
|
|
||||||
|
#include <QMetaType>
|
||||||
|
Q_DECLARE_METATYPE(geopro::net::AuthService::Captcha)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ add_library(geopro_net STATIC
|
||||||
ApiCall.cpp
|
ApiCall.cpp
|
||||||
ApiBatch.cpp
|
ApiBatch.cpp
|
||||||
ApiChain.cpp
|
ApiChain.cpp
|
||||||
AuthService.cpp)
|
AuthService.cpp
|
||||||
|
AuthLoads.cpp)
|
||||||
target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(geopro_net PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network)
|
target_link_libraries(geopro_net PUBLIC OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network)
|
||||||
target_compile_features(geopro_net PUBLIC cxx_std_17)
|
target_compile_features(geopro_net PUBLIC cxx_std_17)
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ target_sources(geopro_tests PRIVATE net/test_auth.cpp)
|
||||||
target_sources(geopro_tests PRIVATE net/test_api_batch.cpp)
|
target_sources(geopro_tests PRIVATE net/test_api_batch.cpp)
|
||||||
# ApiChain 离线单测(顺序依赖链:顺序/失败短路/abort闸门/工厂抛异常)。
|
# ApiChain 离线单测(顺序依赖链:顺序/失败短路/abort闸门/工厂抛异常)。
|
||||||
target_sources(geopro_tests PRIVATE net/test_api_chain.cpp)
|
target_sources(geopro_tests PRIVATE net/test_api_chain.cpp)
|
||||||
|
# AuthLoads 离线单测(CaptchaLoad/LoginLoad 句柄:done/failed/abort 闸门)。
|
||||||
|
target_sources(geopro_tests PRIVATE net/test_auth_loads.cpp)
|
||||||
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test)
|
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network Qt6::Test)
|
||||||
|
|
||||||
# geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时
|
# geopro_data 链 Qt6::Core,测试 exe 运行(含 gtest 发现)需要 Qt6Core.dll 等运行时
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
// net 层端到端登录连通测试(真实站点)。
|
// net 层端到端登录连通测试(真实站点)。
|
||||||
// 复刻已实测通过的流程:getImageCode -> verifyCodeCheck -> RSA -> login2,
|
// 复刻已实测通过的流程:getImageCode -> verifyCodeCheck -> RSA -> login2,
|
||||||
// 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。
|
// 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。
|
||||||
// 需要 QCoreApplication 提供事件循环(ApiClient 用 QEventLoop 同步等待)。
|
// 异步化:fetchCaptchaAsync/loginAsync 返回自管理句柄,QSignalSpy::wait 驱动事件循环等待。
|
||||||
// 网络不可达时本用例会失败(属环境问题,非逻辑问题)。
|
// 需要 QCoreApplication 提供事件循环。网络不可达时本用例会失败(属环境问题,非逻辑问题)。
|
||||||
|
|
||||||
#include <gtest/gtest.h>
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
#include <QCoreApplication>
|
#include <QCoreApplication>
|
||||||
|
#include <QSignalSpy>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "ApiClient.hpp"
|
#include "ApiClient.hpp"
|
||||||
|
#include "AuthLoads.hpp"
|
||||||
#include "AuthService.hpp"
|
#include "AuthService.hpp"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
@ -35,11 +37,24 @@ TEST(AuthLiveTest, FullLoginFlowReturnsToken) {
|
||||||
geopro::net::AuthService auth(
|
geopro::net::AuthService auth(
|
||||||
api, slurp("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem"));
|
api, slurp("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem"));
|
||||||
|
|
||||||
auto cap = auth.fetchCaptcha();
|
auto* cl = auth.fetchCaptchaAsync();
|
||||||
|
QSignalSpy capDone(cl, &geopro::net::CaptchaLoad::done);
|
||||||
|
QSignalSpy capFail(cl, &geopro::net::CaptchaLoad::failed);
|
||||||
|
ASSERT_TRUE(capDone.wait(10000) || capFail.count() > 0);
|
||||||
|
ASSERT_EQ(capDone.count(), 1)
|
||||||
|
<< (capFail.count() ? capFail.takeFirst().at(0).toString().toStdString() : "captcha failed");
|
||||||
|
auto cap = capDone.takeFirst().at(0).value<geopro::net::AuthService::Captcha>();
|
||||||
ASSERT_FALSE(cap.codeId.isEmpty());
|
ASSERT_FALSE(cap.codeId.isEmpty());
|
||||||
ASSERT_FALSE(cap.code.isEmpty());
|
ASSERT_FALSE(cap.code.isEmpty());
|
||||||
|
|
||||||
auto r = auth.login("sydk", "123456", cap.code, cap.codeId);
|
auto* ll = auth.loginAsync("sydk", "123456", cap.code, cap.codeId);
|
||||||
EXPECT_TRUE(r.ok) << r.error.toStdString();
|
QSignalSpy loginDone(ll, &geopro::net::LoginLoad::done);
|
||||||
EXPECT_TRUE(r.token.startsWith("Geomative ")) << r.token.toStdString();
|
QSignalSpy loginFail(ll, &geopro::net::LoginLoad::failed);
|
||||||
|
ASSERT_TRUE(loginDone.wait(10000) || loginFail.count() > 0);
|
||||||
|
EXPECT_EQ(loginDone.count(), 1)
|
||||||
|
<< (loginFail.count() ? loginFail.takeFirst().at(0).toString().toStdString() : "");
|
||||||
|
if (loginDone.count()) {
|
||||||
|
auto token = loginDone.takeFirst().at(0).toString();
|
||||||
|
EXPECT_TRUE(token.startsWith("Geomative ")) << token.toStdString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
// AuthLoads 离线单测:CaptchaLoad/LoginLoad 句柄行为(done/failed/abort 闸门),
|
||||||
|
// 用 FakeApiCall + 真 ApiChain 离线驱动,不联网。QSignalSpy 需 Qt6::Test。
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QSignalSpy>
|
||||||
|
|
||||||
|
#include "ApiChain.hpp"
|
||||||
|
#include "ApiClient.hpp"
|
||||||
|
#include "AuthLoads.hpp"
|
||||||
|
#include "AuthService.hpp"
|
||||||
|
#include "net/FakeApiCall.hpp"
|
||||||
|
|
||||||
|
using namespace geopro::net;
|
||||||
|
using geopro::net::test::FakeApiCall;
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
ApiResponse captchaOk() {
|
||||||
|
ApiResponse r;
|
||||||
|
r.code = 200;
|
||||||
|
r.httpStatus = 200;
|
||||||
|
r.data = QJsonObject{{"id", "cid-1"}, {"code", "AB12"}};
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse tokenOk() {
|
||||||
|
ApiResponse r;
|
||||||
|
r.code = 200;
|
||||||
|
r.httpStatus = 200;
|
||||||
|
r.data = QJsonObject{{"accessToken", "Geomative deadbeef"}};
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse plainOk() {
|
||||||
|
ApiResponse r;
|
||||||
|
r.code = 200;
|
||||||
|
r.httpStatus = 200;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse failResp() {
|
||||||
|
ApiResponse r;
|
||||||
|
r.code = 500;
|
||||||
|
r.httpStatus = 200;
|
||||||
|
r.msg = QStringLiteral("验证码错误");
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto isFailure = [](const ApiResponse& r) { return r.code != 200 || !r.rawError.isEmpty(); };
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(CaptchaLoad, ParsesCaptchaOnSuccess) {
|
||||||
|
auto* call = new FakeApiCall;
|
||||||
|
auto* load = new CaptchaLoad(call);
|
||||||
|
QSignalSpy doneSpy(load, &CaptchaLoad::done);
|
||||||
|
QSignalSpy failSpy(load, &CaptchaLoad::failed);
|
||||||
|
call->fire(captchaOk());
|
||||||
|
ASSERT_EQ(doneSpy.count(), 1);
|
||||||
|
EXPECT_EQ(failSpy.count(), 0);
|
||||||
|
auto cap = doneSpy.takeFirst().at(0).value<AuthService::Captcha>();
|
||||||
|
EXPECT_EQ(cap.codeId, QStringLiteral("cid-1"));
|
||||||
|
EXPECT_EQ(cap.code, QStringLiteral("AB12"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CaptchaLoad, EmitsFailedOnErrorResponse) {
|
||||||
|
auto* call = new FakeApiCall;
|
||||||
|
auto* load = new CaptchaLoad(call);
|
||||||
|
QSignalSpy doneSpy(load, &CaptchaLoad::done);
|
||||||
|
QSignalSpy failSpy(load, &CaptchaLoad::failed);
|
||||||
|
call->fire(failResp());
|
||||||
|
EXPECT_EQ(doneSpy.count(), 0);
|
||||||
|
ASSERT_EQ(failSpy.count(), 1);
|
||||||
|
EXPECT_EQ(failSpy.takeFirst().at(0).toString(), QStringLiteral("验证码错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CaptchaLoad, AbortGateSuppressesLateSignal) {
|
||||||
|
auto* call = new FakeApiCall;
|
||||||
|
auto* load = new CaptchaLoad(call);
|
||||||
|
QSignalSpy doneSpy(load, &CaptchaLoad::done);
|
||||||
|
load->abort();
|
||||||
|
EXPECT_TRUE(call->aborted);
|
||||||
|
call->fire(captchaOk()); // 迟到
|
||||||
|
EXPECT_EQ(doneSpy.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(LoginLoad, EmitsTokenOnChainSuccess) {
|
||||||
|
auto* s1 = new FakeApiCall;
|
||||||
|
auto* s2 = new FakeApiCall;
|
||||||
|
QList<ApiChain::StepFactory> steps{
|
||||||
|
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; },
|
||||||
|
[&](const QList<ApiResponse>&) -> IApiCall* { return s2; }};
|
||||||
|
auto* chain = new ApiChain(steps, isFailure);
|
||||||
|
auto* load = new LoginLoad(chain);
|
||||||
|
QSignalSpy doneSpy(load, &LoginLoad::done);
|
||||||
|
QSignalSpy failSpy(load, &LoginLoad::failed);
|
||||||
|
s1->fire(plainOk()); // verifyCodeCheck 通过 → 触发 step2
|
||||||
|
s2->fire(tokenOk()); // login2 返回 token
|
||||||
|
ASSERT_EQ(doneSpy.count(), 1);
|
||||||
|
EXPECT_EQ(failSpy.count(), 0);
|
||||||
|
EXPECT_EQ(doneSpy.takeFirst().at(0).toString(), QStringLiteral("Geomative deadbeef"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(LoginLoad, EmitsFailedWhenChainFails) {
|
||||||
|
auto* s1 = new FakeApiCall;
|
||||||
|
QList<ApiChain::StepFactory> steps{
|
||||||
|
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; }};
|
||||||
|
auto* chain = new ApiChain(steps, isFailure);
|
||||||
|
auto* load = new LoginLoad(chain);
|
||||||
|
QSignalSpy doneSpy(load, &LoginLoad::done);
|
||||||
|
QSignalSpy failSpy(load, &LoginLoad::failed);
|
||||||
|
s1->fire(failResp());
|
||||||
|
EXPECT_EQ(doneSpy.count(), 0);
|
||||||
|
ASSERT_EQ(failSpy.count(), 1);
|
||||||
|
EXPECT_EQ(failSpy.takeFirst().at(0).toString(), QStringLiteral("验证码错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(LoginLoad, EmitsFailedWhenTokenMissing) {
|
||||||
|
auto* s1 = new FakeApiCall;
|
||||||
|
QList<ApiChain::StepFactory> steps{
|
||||||
|
[&](const QList<ApiResponse>&) -> IApiCall* { return s1; }};
|
||||||
|
auto* chain = new ApiChain(steps, isFailure);
|
||||||
|
auto* load = new LoginLoad(chain);
|
||||||
|
QSignalSpy doneSpy(load, &LoginLoad::done);
|
||||||
|
QSignalSpy failSpy(load, &LoginLoad::failed);
|
||||||
|
s1->fire(plainOk()); // 成功但无 accessToken
|
||||||
|
EXPECT_EQ(doneSpy.count(), 0);
|
||||||
|
ASSERT_EQ(failSpy.count(), 1);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue