feat(net): ApiClient(QtNetwork 共享会话) + AuthService(验证码+RSA+login2)

This commit is contained in:
gaozheng 2026-06-07 21:26:23 +08:00
parent d32cbbf7c4
commit 3d59387ab1
7 changed files with 330 additions and 3 deletions

110
src/net/ApiClient.cpp Normal file
View File

@ -0,0 +1,110 @@
#include "ApiClient.hpp"
#include <QByteArray>
#include <QEventLoop>
#include <QJsonDocument>
#include <QJsonParseError>
#include <QJsonValue>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
namespace geopro::net {
namespace {
constexpr int kHttpStatusUnset = 0;
const char* const kContentTypeJson = "application/json";
const char* const kTokenHeader = "geomativeauthorization";
// 把响应体 JSON 解析进信封。解析失败或非对象时把原文/错误写入 rawError。
void parseBody(const QByteArray& body, ApiResponse& resp) {
if (body.isEmpty()) {
resp.rawError = QStringLiteral("empty response body");
return;
}
QJsonParseError perr{};
const QJsonDocument doc = QJsonDocument::fromJson(body, &perr);
if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
resp.rawError = QStringLiteral("JSON parse error: %1; body: %2")
.arg(perr.errorString(), QString::fromUtf8(body));
return;
}
const QJsonObject obj = doc.object();
resp.code = obj.value(QStringLiteral("code")).toInt();
resp.msg = obj.value(QStringLiteral("msg")).toString();
const QJsonValue dataVal = obj.value(QStringLiteral("data"));
if (dataVal.isObject()) {
resp.data = dataVal.toObject();
} else {
// data 可能是标量(如 verifyCodeCheck 返回 true。包成 {"value": <data>}
// 便于上层统一通过 data["value"] 取用,同时不丢信息。
resp.data = QJsonObject{{QStringLiteral("value"), dataVal}};
}
}
} // namespace
struct ApiClient::Impl {
QString baseUrl;
QString token;
QNetworkAccessManager nam; // 唯一实例:保证 cookie / JSESSIONID 全程共享
explicit Impl(QString url) : baseUrl(std::move(url)) {}
QNetworkRequest buildRequest(const QString& path) const {
QNetworkRequest req{QUrl(baseUrl + path)};
req.setHeader(QNetworkRequest::ContentTypeHeader, QString::fromLatin1(kContentTypeJson));
if (!token.isEmpty()) {
req.setRawHeader(QByteArray(kTokenHeader), token.toUtf8());
}
return req;
}
// 阻塞等待 reply 完成,解析为 ApiResponse。调用方负责 reply->deleteLater()。
static ApiResponse await(QNetworkReply* reply) {
QEventLoop loop;
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
ApiResponse resp;
const QVariant statusVar = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute);
resp.httpStatus = statusVar.isValid() ? statusVar.toInt() : kHttpStatusUnset;
const QByteArray body = reply->readAll();
if (reply->error() != QNetworkReply::NoError) {
resp.rawError = reply->errorString();
}
// 即便有传输层错误,服务端仍可能回带 JSON 体(如 4xx尽量解析。
if (!body.isEmpty()) {
parseBody(body, resp);
}
return resp;
}
};
ApiClient::ApiClient(QString baseUrl) : impl_(std::make_unique<Impl>(std::move(baseUrl))) {}
ApiClient::~ApiClient() = default;
void ApiClient::setToken(const QString& token) { impl_->token = token; }
ApiResponse ApiClient::get(const QString& path) {
QNetworkRequest req = impl_->buildRequest(path);
QNetworkReply* reply = impl_->nam.get(req);
ApiResponse resp = Impl::await(reply);
reply->deleteLater();
return resp;
}
ApiResponse ApiClient::postJson(const QString& path, const QJsonObject& body) {
QNetworkRequest req = impl_->buildRequest(path);
const QByteArray payload = QJsonDocument(body).toJson(QJsonDocument::Compact);
QNetworkReply* reply = impl_->nam.post(req, payload);
ApiResponse resp = Impl::await(reply);
reply->deleteLater();
return resp;
}
} // namespace geopro::net

46
src/net/ApiClient.hpp Normal file
View File

@ -0,0 +1,46 @@
#pragma once
#include <QJsonObject>
#include <QString>
#include <memory>
class QNetworkAccessManager;
namespace geopro::net {
// 服务端统一响应信封:{code, data, msg}。
// httpStatus 为 HTTP 状态码(如 200code/data/msg 解析自响应体 JSON。
// 网络层错误连接失败、超时、JSON 解析失败)写入 rawError此时 httpStatus 可能为 0。
struct ApiResponse {
int httpStatus = 0;
int code = 0;
QJsonObject data;
QString msg;
QString rawError;
};
// QtNetwork 的同步 HTTP 封装。
// 内部持有【唯一一个】QNetworkAccessManager 成员,默认共享 cookie jar
// 因此同一 ApiClient 实例发出的多次请求处于同一会话(共享 JSESSIONID
// 这是登录流程 getImageCode -> verifyCodeCheck -> login2 串联的关键。
class ApiClient {
public:
explicit ApiClient(QString baseUrl);
~ApiClient();
ApiClient(const ApiClient&) = delete;
ApiClient& operator=(const ApiClient&) = delete;
// 设置令牌;注入请求头 geomativeauthorization。token 值本身已含 "Geomative " 前缀。
void setToken(const QString& token);
// 同步 GET / POST(JSON)。用 QNetworkReply + QEventLoop 阻塞等待响应。
ApiResponse get(const QString& path);
ApiResponse postJson(const QString& path, const QJsonObject& body);
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace geopro::net

80
src/net/AuthService.cpp Normal file
View File

@ -0,0 +1,80 @@
#include "AuthService.hpp"
#include <QJsonObject>
#include <QJsonValue>
#include <stdexcept>
#include "ApiClient.hpp"
#include "crypto/RsaEncryptor.hpp"
namespace geopro::net {
namespace {
constexpr int kCodeSuccess = 200;
const char* const kPathImageCode = "/business/system/personalUser/getImageCode";
const char* const kPathVerifyCode = "/business/system/personalUser/verifyCodeCheck";
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
AuthService::AuthService(ApiClient& api, std::string rsaPublicKeyPem)
: api_(api), rsaPublicKeyPem_(std::move(rsaPublicKeyPem)) {}
AuthService::Captcha AuthService::fetchCaptcha() {
const ApiResponse resp = api_.get(QString::fromLatin1(kPathImageCode));
Captcha cap;
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,
const QString& code, const QString& codeId) {
// 1) 校验验证码(与 captcha 同会话)。
const QJsonObject verifyBody{{QStringLiteral("code"), code},
{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
std::string encrypted;
try {
RsaEncryptor enc(rsaPublicKeyPem_);
encrypted = enc.encryptBase64(password.toStdString());
} catch (const std::exception& e) {
return {false, QString(),
QStringLiteral("RSA encryption failed: %1").arg(QString::fromUtf8(e.what()))};
}
// 3) login2checkCode 传空串。
const QJsonObject loginBody{
{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

39
src/net/AuthService.hpp Normal file
View File

@ -0,0 +1,39 @@
#pragma once
#include <QString>
#include <string>
namespace geopro::net {
class ApiClient;
// 登录编排:复刻已实测通过的 getImageCode -> verifyCodeCheck -> RSA -> login2 流程。
// 依赖外部注入的 ApiClient单实例、共享会话RSA 公钥 PEM 由调用方读取后传入。
class AuthService {
public:
AuthService(ApiClient& api, std::string rsaPublicKeyPem);
// 验证码服务端把答案明文回传data.code答案 + data.id 用于下一步校验。
struct Captcha {
QString codeId;
QString code;
};
Captcha fetchCaptcha();
struct LoginResult {
bool ok = false;
QString token;
QString error;
};
// 校验验证码 -> RSA 加密密码 -> login2。成功返回 {true, accessToken, ""}
// 任一步失败返回 {false, "", <服务端 msg 或本地错误>}。
LoginResult login(const QString& username, const QString& password, const QString& code,
const QString& codeId);
private:
ApiClient& api_;
std::string rsaPublicKeyPem_;
};
} // namespace geopro::net

View File

@ -1,6 +1,10 @@
find_package(OpenSSL REQUIRED) find_package(OpenSSL REQUIRED)
add_library(geopro_net STATIC crypto/RsaEncryptor.cpp) find_package(Qt6 COMPONENTS Core Network REQUIRED)
add_library(geopro_net STATIC
crypto/RsaEncryptor.cpp
ApiClient.cpp
AuthService.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) 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)
set_target_properties(geopro_net PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF) set_target_properties(geopro_net PROPERTIES AUTOMOC OFF AUTOUIC OFF AUTORCC OFF)

View File

@ -40,8 +40,11 @@ target_link_libraries(geopro_tests PRIVATE geopro_data)
# net RSA OpenSSL / find_package # net RSA OpenSSL / find_package
# OpenSSLgeopro_net PUBLIC # OpenSSLgeopro_net PUBLIC
find_package(OpenSSL REQUIRED) find_package(OpenSSL REQUIRED)
find_package(Qt6 COMPONENTS Core Network REQUIRED)
target_sources(geopro_tests PRIVATE net/test_rsa.cpp) target_sources(geopro_tests PRIVATE net/test_rsa.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto) # ApiClient + AuthService Qt6::Core/Network
target_sources(geopro_tests PRIVATE net/test_auth.cpp)
target_link_libraries(geopro_tests PRIVATE geopro_net OpenSSL::SSL OpenSSL::Crypto Qt6::Core Qt6::Network)
# geopro_data Qt6::Core exe gtest Qt6Core.dll # geopro_data Qt6::Core exe gtest Qt6Core.dll
# DLL app TARGET_RUNTIME_DLLS POST_BUILD # DLL app TARGET_RUNTIME_DLLS POST_BUILD

45
tests/net/test_auth.cpp Normal file
View File

@ -0,0 +1,45 @@
// net 层端到端登录连通测试(真实站点)。
// 复刻已实测通过的流程getImageCode -> verifyCodeCheck -> RSA -> login2
// 关键在 ApiClient 内单一 QNetworkAccessManager 共享 JSESSIONID 会话。
// 需要 QCoreApplication 提供事件循环ApiClient 用 QEventLoop 同步等待)。
// 网络不可达时本用例会失败(属环境问题,非逻辑问题)。
#include <gtest/gtest.h>
#include <QCoreApplication>
#include <fstream>
#include <sstream>
#include <string>
#include "ApiClient.hpp"
#include "AuthService.hpp"
namespace {
// 读取整个文件内容PEM 公钥)。
std::string slurp(const char* path) {
std::ifstream f(path);
std::stringstream s;
s << f.rdbuf();
return s.str();
}
} // namespace
TEST(AuthLiveTest, FullLoginFlowReturnsToken) {
int argc = 0;
char** argv = nullptr;
QCoreApplication app(argc, argv);
geopro::net::ApiClient api("http://tenant.geomative.cn/pop-api");
geopro::net::AuthService auth(
api, slurp("D:/Git/lanbingtech/geopro/resources/rsa_public_key.pem"));
auto cap = auth.fetchCaptcha();
ASSERT_FALSE(cap.codeId.isEmpty());
ASSERT_FALSE(cap.code.isEmpty());
auto r = auth.login("sydk", "123456", cap.code, cap.codeId);
EXPECT_TRUE(r.ok) << r.error.toStdString();
EXPECT_TRUE(r.token.startsWith("Geomative ")) << r.token.toStdString();
}