功能: 完善账号、工作台与功率计算方案

- 新增账号管理、有效期、启停控制与登录工作台布局
- 新增方案三叶轮转速计算及字段映射和报告支持
- 修复功率曲线断线、图例越界及方案一散点绘制
- 优化计算进度、浅色主题和当前方案提示
- 补充前端测试与部署依赖配置
This commit is contained in:
cloud
2026-09-16 15:14:45 +08:00
parent befbf6c029
commit 58be5a34f6
26 changed files with 3357 additions and 297 deletions
+4
View File
@@ -35,6 +35,8 @@ endif()
find_package(Drogon CONFIG REQUIRED)
find_package(Threads REQUIRED)
find_path(SQLITE3_INCLUDE_DIR sqlite3.h REQUIRED)
find_library(SQLITE3_LIBRARY sqlite3 REQUIRED)
# libxlsxwriter 使用常量内存模式把大型工作簿逐行落盘,避免报告导出占满服务进程内存。
set(BUILD_TESTS OFF CACHE BOOL "" FORCE)
@@ -56,10 +58,12 @@ add_executable(wind_server ${SOURCES})
target_include_directories(wind_server PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${REPO_ROOT}/third_party
${SQLITE3_INCLUDE_DIR}
)
target_link_libraries(wind_server PRIVATE
Drogon::Drogon
${SQLITE3_LIBRARY}
Threads::Threads
xlsxwriter
)
+446
View File
@@ -0,0 +1,446 @@
#include "AuthManager.h"
#include <trantor/utils/Logger.h>
#include <ctime>
#include <filesystem>
#include <regex>
#include <string>
#include <drogon/utils/Utilities.h>
#include <sqlite3.h>
namespace {
namespace fs = std::filesystem;
using json = nlohmann::json;
constexpr int kPasswordHashRounds = 20000;
constexpr auto kSessionLifetime = std::chrono::hours(12);
const fs::path kDatabasePath = fs::path("data") / "wind_power.db";
struct AccountRecord {
std::string username;
std::string password_salt;
std::string password_hash;
bool is_admin = false;
std::string expires_on;
std::string created_at;
bool enabled = true;
};
class Statement {
public:
Statement(sqlite3* database, const char* sql) {
if (sqlite3_prepare_v2(database, sql, -1, &statement_, nullptr) != SQLITE_OK) {
statement_ = nullptr;
}
}
~Statement() {
if (statement_ != nullptr) {
sqlite3_finalize(statement_);
}
}
Statement(const Statement&) = delete;
Statement& operator=(const Statement&) = delete;
sqlite3_stmt* Get() const {
return statement_;
}
explicit operator bool() const {
return statement_ != nullptr;
}
private:
sqlite3_stmt* statement_ = nullptr;
};
std::string ColumnText(sqlite3_stmt* statement, int column) {
const auto* value = sqlite3_column_text(statement, column);
return value == nullptr ? "" : reinterpret_cast<const char*>(value);
}
std::string CurrentDate() {
const auto now = std::time(nullptr);
std::tm local_time = {};
localtime_r(&now, &local_time);
char buffer[11] = {};
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &local_time);
return buffer;
}
std::string CurrentDateTime() {
const auto now = std::time(nullptr);
std::tm local_time = {};
localtime_r(&now, &local_time);
char buffer[20] = {};
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &local_time);
return buffer;
}
bool IsValidDate(const std::string& value) {
if (!std::regex_match(value, std::regex(R"(^\d{4}-\d{2}-\d{2}$)"))) {
return false;
}
std::tm parsed = {};
if (strptime(value.c_str(), "%Y-%m-%d", &parsed) == nullptr) {
return false;
}
parsed.tm_isdst = -1;
const auto timestamp = std::mktime(&parsed);
if (timestamp == static_cast<std::time_t>(-1)) {
return false;
}
std::tm normalized = {};
localtime_r(&timestamp, &normalized);
char buffer[11] = {};
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &normalized);
return value == buffer;
}
bool IsExpired(const AccountRecord& account) {
return !account.expires_on.empty() && CurrentDate() > account.expires_on;
}
std::string HashPassword(const std::string& password, const std::string& salt) {
std::string digest = password + ":" + salt;
for (int round = 0; round < kPasswordHashRounds; ++round) {
digest = drogon::utils::getSha256(salt + ":" + digest);
}
return digest;
}
bool ConstantTimeEquals(const std::string& left, const std::string& right) {
if (left.size() != right.size()) {
return false;
}
unsigned char difference = 0;
for (size_t index = 0; index < left.size(); ++index) {
difference |= static_cast<unsigned char>(left[index] ^ right[index]);
}
return difference == 0;
}
bool ValidateUsername(const std::string& username, std::string& error) {
if (!std::regex_match(username, std::regex(R"(^[A-Za-z0-9_.-]{3,32}$)"))) {
error = "账号只能包含字母、数字、点、下划线或短横线,长度为 3-32 位";
return false;
}
return true;
}
bool ValidatePassword(const std::string& password, std::string& error) {
if (password.size() < 6 || password.size() > 128) {
error = "密码长度必须为 6-128 位";
return false;
}
return true;
}
AuthUser ToAuthUser(const AccountRecord& account) {
return AuthUser{
account.username,
account.is_admin,
account.expires_on,
account.enabled,
};
}
std::optional<AccountRecord> FindAccount(sqlite3* database, const std::string& username) {
Statement statement(database,
"SELECT username, password_salt, password_hash, is_admin, expires_on, created_at, "
"enabled "
"FROM accounts WHERE username = ?");
if (!statement) {
return std::nullopt;
}
sqlite3_bind_text(statement.Get(), 1, username.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(statement.Get()) != SQLITE_ROW) {
return std::nullopt;
}
return AccountRecord{
ColumnText(statement.Get(), 0),
ColumnText(statement.Get(), 1),
ColumnText(statement.Get(), 2),
sqlite3_column_int(statement.Get(), 3) != 0,
ColumnText(statement.Get(), 4),
ColumnText(statement.Get(), 5),
sqlite3_column_int(statement.Get(), 6) != 0,
};
}
bool HasColumn(sqlite3* database, const std::string& table, const std::string& column) {
Statement statement(database, ("PRAGMA table_info(" + table + ")").c_str());
if (!statement) {
return false;
}
while (sqlite3_step(statement.Get()) == SQLITE_ROW) {
if (ColumnText(statement.Get(), 1) == column) {
return true;
}
}
return false;
}
} // namespace
AuthManager& AuthManager::Instance() {
static AuthManager instance;
return instance;
}
AuthManager::~AuthManager() {
if (database_ != nullptr) {
sqlite3_close(database_);
}
}
bool AuthManager::Initialize() {
std::lock_guard<std::mutex> lock(mutex_);
return OpenDatabaseLocked() && CreateSchemaLocked() && BootstrapAdminLocked();
}
bool AuthManager::OpenDatabaseLocked() {
if (database_ != nullptr) {
return true;
}
fs::create_directories(kDatabasePath.parent_path());
if (sqlite3_open_v2(kDatabasePath.c_str(),
&database_,
SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX,
nullptr) != SQLITE_OK) {
return false;
}
sqlite3_busy_timeout(database_, 5000);
return sqlite3_exec(database_, "PRAGMA journal_mode=WAL;", nullptr, nullptr, nullptr) ==
SQLITE_OK;
}
bool AuthManager::CreateSchemaLocked() {
constexpr const char* kSchema =
"CREATE TABLE IF NOT EXISTS accounts ("
"username TEXT PRIMARY KEY NOT NULL,"
"password_salt TEXT NOT NULL,"
"password_hash TEXT NOT NULL,"
"is_admin INTEGER NOT NULL DEFAULT 0,"
"expires_on TEXT NOT NULL DEFAULT '',"
"created_at TEXT NOT NULL,"
"enabled INTEGER NOT NULL DEFAULT 1"
");";
if (sqlite3_exec(database_, kSchema, nullptr, nullptr, nullptr) != SQLITE_OK) {
return false;
}
if (HasColumn(database_, "accounts", "enabled")) {
return true;
}
constexpr const char* kAddEnabled =
"ALTER TABLE accounts ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1";
return sqlite3_exec(database_, kAddEnabled, nullptr, nullptr, nullptr) == SQLITE_OK;
}
bool AuthManager::BootstrapAdminLocked() {
if (FindAccount(database_, "admin").has_value()) {
return true;
}
const auto salt = drogon::utils::secureRandomString(32);
const auto password_hash = HashPassword("nwl888888", salt);
const auto created_at = CurrentDateTime();
Statement statement(database_,
"INSERT INTO accounts "
"(username, password_salt, password_hash, is_admin, expires_on, created_at) "
"VALUES (?, ?, ?, 1, '', ?)");
if (!statement) {
return false;
}
sqlite3_bind_text(statement.Get(), 1, "admin", -1, SQLITE_STATIC);
sqlite3_bind_text(statement.Get(), 2, salt.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(statement.Get(), 3, password_hash.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(statement.Get(), 4, created_at.c_str(), -1, SQLITE_TRANSIENT);
return sqlite3_step(statement.Get()) == SQLITE_DONE;
}
AuthResult AuthManager::Login(const std::string& username,
const std::string& password,
std::string& token) {
std::lock_guard<std::mutex> lock(mutex_);
const auto account = FindAccount(database_, username);
if (!account.has_value() ||
!ConstantTimeEquals(HashPassword(password, account->password_salt),
account->password_hash)) {
return {};
}
if (!account->enabled) {
return {AuthStatus::kDisabled, ToAuthUser(account.value())};
}
if (IsExpired(account.value())) {
return {AuthStatus::kExpired, ToAuthUser(account.value())};
}
token = drogon::utils::secureRandomString(48);
sessions_[token] = Session{username, std::chrono::system_clock::now() + kSessionLifetime};
return {AuthStatus::kAuthenticated, ToAuthUser(account.value())};
}
AuthResult AuthManager::Authenticate(const std::string& token) {
std::lock_guard<std::mutex> lock(mutex_);
if (token.empty()) {
return {};
}
const auto session = sessions_.find(token);
if (session == sessions_.end() || session->second.expires_at <= std::chrono::system_clock::now()) {
if (session != sessions_.end()) {
sessions_.erase(session);
}
return {};
}
const auto account = FindAccount(database_, session->second.username);
if (!account.has_value()) {
sessions_.erase(session);
return {};
}
if (!account->enabled) {
sessions_.erase(session);
return {AuthStatus::kDisabled, ToAuthUser(account.value())};
}
if (IsExpired(account.value())) {
sessions_.erase(session);
return {AuthStatus::kExpired, ToAuthUser(account.value())};
}
return {AuthStatus::kAuthenticated, ToAuthUser(account.value())};
}
void AuthManager::Logout(const std::string& token) {
std::lock_guard<std::mutex> lock(mutex_);
sessions_.erase(token);
}
nlohmann::json AuthManager::ListAccounts() {
std::lock_guard<std::mutex> lock(mutex_);
json result = json::array();
Statement statement(database_,
"SELECT username, is_admin, expires_on, created_at, enabled "
"FROM accounts ORDER BY is_admin DESC, username");
if (!statement) {
return result;
}
while (sqlite3_step(statement.Get()) == SQLITE_ROW) {
AccountRecord account;
account.username = ColumnText(statement.Get(), 0);
account.is_admin = sqlite3_column_int(statement.Get(), 1) != 0;
account.expires_on = ColumnText(statement.Get(), 2);
account.created_at = ColumnText(statement.Get(), 3);
account.enabled = sqlite3_column_int(statement.Get(), 4) != 0;
result.push_back({
{"username", account.username},
{"is_admin", account.is_admin},
{"expires_on", account.expires_on},
{"permanent", account.expires_on.empty()},
{"enabled", account.enabled},
{"created_at", account.created_at},
{"expired", IsExpired(account)},
});
}
return result;
}
bool AuthManager::CreateAccount(const std::string& username,
const std::string& password,
const std::string& expires_on,
std::string& error) {
std::lock_guard<std::mutex> lock(mutex_);
if (!ValidateUsername(username, error) || !ValidatePassword(password, error)) {
return false;
}
if (!expires_on.empty() &&
(!IsValidDate(expires_on) || expires_on < CurrentDate())) {
error = "有效期必须是今天或之后的有效日期";
return false;
}
if (FindAccount(database_, username).has_value()) {
error = "账号已存在";
return false;
}
const auto salt = drogon::utils::secureRandomString(32);
const auto password_hash = HashPassword(password, salt);
const auto created_at = CurrentDateTime();
Statement statement(database_,
"INSERT INTO accounts "
"(username, password_salt, password_hash, is_admin, expires_on, created_at) "
"VALUES (?, ?, ?, 0, ?, ?)");
if (!statement) {
error = "保存账号失败";
return false;
}
sqlite3_bind_text(statement.Get(), 1, username.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(statement.Get(), 2, salt.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(statement.Get(), 3, password_hash.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(statement.Get(), 4, expires_on.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(statement.Get(), 5, created_at.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(statement.Get()) != SQLITE_DONE) {
error = "保存账号失败";
return false;
}
return true;
}
bool AuthManager::UpdateAccount(const std::string& username,
const std::optional<std::string>& password,
const std::string& expires_on,
const std::optional<bool>& enabled,
std::string& error) {
std::lock_guard<std::mutex> lock(mutex_);
const auto account = FindAccount(database_, username);
if (!account.has_value()) {
error = "账号不存在";
return false;
}
if (account->is_admin) {
error = "内置管理员账号不能修改有效期";
return false;
}
if (!expires_on.empty() && !IsValidDate(expires_on)) {
error = "请输入有效日期";
return false;
}
if (password.has_value() && !password->empty() &&
!ValidatePassword(password.value(), error)) {
return false;
}
std::string salt = account->password_salt;
std::string password_hash = account->password_hash;
const bool account_enabled = enabled.value_or(account->enabled);
if (password.has_value() && !password->empty()) {
salt = drogon::utils::secureRandomString(32);
password_hash = HashPassword(password.value(), salt);
}
Statement statement(database_,
"UPDATE accounts SET password_salt = ?, password_hash = ?, expires_on = ?, "
"enabled = ? WHERE username = ?");
if (!statement) {
error = "保存账号失败";
return false;
}
sqlite3_bind_text(statement.Get(), 1, salt.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(statement.Get(), 2, password_hash.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(statement.Get(), 3, expires_on.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(statement.Get(), 4, account_enabled ? 1 : 0);
sqlite3_bind_text(statement.Get(), 5, username.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(statement.Get()) != SQLITE_DONE) {
error = "保存账号失败";
return false;
}
InvalidateUserSessionsLocked(username);
return true;
}
void AuthManager::InvalidateUserSessionsLocked(const std::string& username) {
for (auto iterator = sessions_.begin(); iterator != sessions_.end();) {
if (iterator->second.username == username) {
iterator = sessions_.erase(iterator);
} else {
++iterator;
}
}
}
+76
View File
@@ -0,0 +1,76 @@
#ifndef AUTHMANAGER_H
#define AUTHMANAGER_H
#include <chrono>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
#include <nlohmann/json.hpp>
struct sqlite3;
struct AuthUser {
std::string username;
bool is_admin = false;
std::string expires_on;
bool enabled = true;
};
enum class AuthStatus {
kAuthenticated,
kUnauthenticated,
kExpired,
kDisabled,
};
struct AuthResult {
AuthStatus status = AuthStatus::kUnauthenticated;
AuthUser user;
};
class AuthManager {
public:
static constexpr const char* kSessionCookie = "wind_session";
static AuthManager& Instance();
bool Initialize();
AuthResult Login(const std::string& username,
const std::string& password,
std::string& token);
AuthResult Authenticate(const std::string& token);
void Logout(const std::string& token);
nlohmann::json ListAccounts();
bool CreateAccount(const std::string& username,
const std::string& password,
const std::string& expires_on,
std::string& error);
bool UpdateAccount(const std::string& username,
const std::optional<std::string>& password,
const std::string& expires_on,
const std::optional<bool>& enabled,
std::string& error);
private:
struct Session {
std::string username;
std::chrono::system_clock::time_point expires_at;
};
AuthManager() = default;
~AuthManager();
bool OpenDatabaseLocked();
bool CreateSchemaLocked();
bool BootstrapAdminLocked();
void InvalidateUserSessionsLocked(const std::string& username);
std::mutex mutex_;
sqlite3* database_ = nullptr;
std::unordered_map<std::string, Session> sessions_;
};
#endif
+185
View File
@@ -0,0 +1,185 @@
#include "AuthController.h"
#include <trantor/utils/Logger.h>
#include <optional>
#include <string>
#include <drogon/Cookie.h>
#include <nlohmann/json.hpp>
#include "auth/AuthManager.h"
#include "utils/ResponseUtil.h"
namespace {
using json = nlohmann::json;
std::optional<json> ParseBody(const drogon::HttpRequestPtr& req) {
try {
return json::parse(req->body());
} catch (const std::exception&) {
return std::nullopt;
}
}
json UserJson(const AuthUser& user) {
return {
{"username", user.username},
{"is_admin", user.is_admin},
{"expires_on", user.expires_on},
{"permanent", user.expires_on.empty()},
{"enabled", user.enabled},
};
}
void SendAuthError(DrogonCallback& callback, const AuthResult& auth) {
if (auth.status == AuthStatus::kExpired || auth.status == AuthStatus::kDisabled) {
SendError(callback, 4, "账号异常请联系管理员", drogon::k401Unauthorized);
} else {
SendError(callback, 2, "请先登录", drogon::k401Unauthorized);
}
}
std::optional<AuthUser> RequireAdmin(const drogon::HttpRequestPtr& req,
DrogonCallback& callback) {
const auto auth = AuthManager::Instance().Authenticate(
req->getCookie(AuthManager::kSessionCookie));
if (auth.status != AuthStatus::kAuthenticated) {
SendAuthError(callback, auth);
return std::nullopt;
}
if (!auth.user.is_admin) {
SendForbidden(callback, "仅管理员可管理账号");
return std::nullopt;
}
return auth.user;
}
void AddSessionCookie(const drogon::HttpResponsePtr& response,
const std::string& token,
int max_age) {
drogon::Cookie cookie(AuthManager::kSessionCookie, token);
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSameSite(drogon::Cookie::SameSite::kLax);
cookie.setMaxAge(max_age);
response->addCookie(std::move(cookie));
}
drogon::HttpResponsePtr MakeJsonResponse(const json& body) {
auto response = drogon::HttpResponse::newHttpResponse();
response->setContentTypeCode(drogon::CT_APPLICATION_JSON);
response->setBody(body.dump());
return response;
}
} // namespace
void AuthController::Login(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback) {
const auto body = ParseBody(req);
if (!body.has_value() || !body->is_object()) {
SendError(callback, 1, "登录参数格式错误");
return;
}
const auto username = body->value("username", "");
const auto password = body->value("password", "");
std::string token;
const auto auth = AuthManager::Instance().Login(username, password, token);
if (auth.status == AuthStatus::kExpired || auth.status == AuthStatus::kDisabled) {
SendError(callback, 4, "账号异常请联系管理员", drogon::k401Unauthorized);
return;
}
if (auth.status != AuthStatus::kAuthenticated) {
SendError(callback, 2, "账号或密码错误", drogon::k401Unauthorized);
return;
}
auto response = MakeJsonResponse(
ResponseUtil::GenerateSuccessResponse(UserJson(auth.user)));
AddSessionCookie(response, token, 12 * 60 * 60);
callback(response);
}
void AuthController::Logout(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback) {
AuthManager::Instance().Logout(req->getCookie(AuthManager::kSessionCookie));
auto response = MakeJsonResponse(ResponseUtil::GenerateSuccessResponse());
AddSessionCookie(response, "", 0);
callback(response);
}
void AuthController::GetCurrentUser(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback) {
const auto auth = AuthManager::Instance().Authenticate(
req->getCookie(AuthManager::kSessionCookie));
if (auth.status != AuthStatus::kAuthenticated) {
SendAuthError(callback, auth);
return;
}
SendSuccess(callback, UserJson(auth.user));
}
void AuthController::ListAccounts(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback) {
if (!RequireAdmin(req, callback).has_value()) {
return;
}
SendSuccess(callback, json{{"accounts", AuthManager::Instance().ListAccounts()}});
}
void AuthController::CreateAccount(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback) {
if (!RequireAdmin(req, callback).has_value()) {
return;
}
const auto body = ParseBody(req);
if (!body.has_value() || !body->is_object()) {
SendError(callback, 1, "账号参数格式错误");
return;
}
std::string error;
if (!AuthManager::Instance().CreateAccount(
body->value("username", ""),
body->value("password", ""),
body->value("expires_on", ""),
error)) {
SendError(callback, 1, error);
return;
}
SendSuccess(callback);
}
void AuthController::UpdateAccount(
const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback,
const std::string& username) {
if (!RequireAdmin(req, callback).has_value()) {
return;
}
const auto body = ParseBody(req);
if (!body.has_value() || !body->is_object()) {
SendError(callback, 1, "账号参数格式错误");
return;
}
std::optional<std::string> password;
if (body->contains("password") && (*body)["password"].is_string()) {
password = (*body)["password"].get<std::string>();
}
std::optional<bool> enabled;
if (body->contains("enabled") && (*body)["enabled"].is_boolean()) {
enabled = (*body)["enabled"].get<bool>();
}
std::string error;
if (!AuthManager::Instance().UpdateAccount(
username, password, body->value("expires_on", ""), enabled, error)) {
SendError(callback, 1, error);
return;
}
SendSuccess(callback);
}
+34
View File
@@ -0,0 +1,34 @@
#ifndef AUTHCONTROLLER_H
#define AUTHCONTROLLER_H
#include <drogon/HttpController.h>
class AuthController : public drogon::HttpController<AuthController, false> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(AuthController::Login, "/api/auth/login", drogon::Post);
ADD_METHOD_TO(AuthController::Logout, "/api/auth/logout", drogon::Post);
ADD_METHOD_TO(AuthController::GetCurrentUser, "/api/auth/me", drogon::Get);
ADD_METHOD_TO(AuthController::ListAccounts, "/api/admin/accounts", drogon::Get);
ADD_METHOD_TO(AuthController::CreateAccount, "/api/admin/accounts", drogon::Post);
ADD_METHOD_TO(AuthController::UpdateAccount,
"/api/admin/accounts/{1}",
drogon::Put);
METHOD_LIST_END
void Login(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback);
void Logout(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback);
void GetCurrentUser(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback);
void ListAccounts(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback);
void CreateAccount(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback);
void UpdateAccount(const drogon::HttpRequestPtr& req,
std::function<void(const drogon::HttpResponsePtr&)>&& callback,
const std::string& username);
};
#endif
+394 -64
View File
@@ -9,6 +9,7 @@
#include <filesystem>
#include <fstream>
#include <iomanip>
#include <map>
#include <mutex>
#include <optional>
#include <random>
@@ -16,6 +17,7 @@
#include <sstream>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <xlsxwriter/format.h>
@@ -36,11 +38,15 @@ constexpr auto kUploadIdleTimeout = std::chrono::minutes(30);
constexpr auto kCompletedJobRetention = std::chrono::hours(24);
constexpr const char* kDefaultSchemeId = "scheme_one";
constexpr const char* kSchemeTwoId = "scheme_two";
constexpr const char* kSchemeThreeId = "scheme_three";
constexpr const char* kSchemeOneName = "方案一";
constexpr const char* kSchemeTwoName = "方案二";
constexpr const char* kSchemeThreeName = "方案三";
constexpr const char* kSchemeOneDefaultDescription = "通用方案";
constexpr const char* kSchemeTwoDefaultDescription =
"桨角筛选方案,使用三支叶片角度平均值、600s 平均风速和手填转速/功率参数";
constexpr const char* kSchemeThreeDefaultDescription =
"叶轮转速方案,叶尖速比直接使用叶轮转速计算,不使用齿轮箱传动比";
struct RawRow {
std::string time;
@@ -48,6 +54,7 @@ struct RawRow {
double wind_speed = 0.0;
double active_power = 0.0;
double generator_speed = 0.0;
double rotor_speed = 0.0;
};
struct ValidRow {
@@ -58,6 +65,7 @@ struct ValidRow {
double wind_speed = 0.0;
double active_power = 0.0;
double generator_speed = 0.0;
double rotor_speed = 0.0;
double blade_pitch_1 = 0.0;
double blade_pitch_2 = 0.0;
double blade_pitch_3 = 0.0;
@@ -125,6 +133,17 @@ struct SchemeInfo {
double rated_generator_speed = 1755.0;
double rated_power = 2000.0;
double scheme_two_report_wind_speed_interval = 0.25;
double scheme_three_rated_power = 4800.0;
double scheme_three_rated_wind_speed = 14.0;
double scheme_three_power_step = 5.0;
double scheme_three_cleaning_wind_speed_step = 0.25;
double scheme_three_wind_speed_change_threshold = 1.0;
double scheme_three_iqr_lower_multiplier = 1.2;
double scheme_three_iqr_upper_multiplier = 2.0;
double scheme_three_minimum_generator_speed = 1.0;
double scheme_three_generator_speed_k = 0.9;
double scheme_three_rotor_radius = 78.0;
double scheme_three_report_wind_speed_interval = 0.25;
};
constexpr double kRatedCornerWindBefore = 0.5;
@@ -170,6 +189,14 @@ fs::path JobResultPath(const std::string& job_id) {
return JobDir(job_id) / "result.json";
}
fs::path JobPointsDir(const std::string& job_id) {
return JobDir(job_id) / "points";
}
fs::path JobFanPointsPath(const std::string& job_id, size_t fan_index) {
return JobPointsDir(job_id) / ("fan_" + std::to_string(fan_index) + ".json");
}
std::mutex g_task_mutex;
std::string g_active_job_id;
std::chrono::steady_clock::time_point g_active_since;
@@ -279,6 +306,28 @@ std::string FileNameForFan(const std::string& fan_id) {
return output.empty() ? "wind_turbine" : output;
}
std::string PointIdFor(const json& point, const std::string& fan_id) {
const auto text = [&point](const char* field) {
return point.contains(field) ? JsonText(point[field]) : "";
};
const auto point_fan_id = text("fan_id");
return (point_fan_id.empty() ? fan_id : point_fan_id) + "|" + text("time") + "|" +
text("wind_speed") + "|" + text("active_power") + "|" + text("reason");
}
std::unordered_set<std::string> PointIdSet(const json& body, const char* field) {
std::unordered_set<std::string> ids;
if (!body.contains(field) || !body[field].is_array()) {
return ids;
}
for (const auto& value : body[field]) {
if (value.is_string()) {
ids.insert(value.get<std::string>());
}
}
return ids;
}
fs::path SchemeConfigPath() {
return fs::path("data") / "wind_schemes.json";
}
@@ -485,7 +534,13 @@ bool SaveChartOptionsToFile(const json& options) {
}
std::string NormalizeSchemeId(const std::string& scheme_id) {
return scheme_id == kSchemeTwoId ? kSchemeTwoId : kDefaultSchemeId;
if (scheme_id == kSchemeTwoId) {
return kSchemeTwoId;
}
if (scheme_id == kSchemeThreeId) {
return kSchemeThreeId;
}
return kDefaultSchemeId;
}
std::vector<SchemeInfo> DefaultSchemes() {
@@ -498,7 +553,12 @@ std::vector<SchemeInfo> DefaultSchemes() {
scheme_two.id = kSchemeTwoId;
scheme_two.name = kSchemeTwoName;
scheme_two.description = kSchemeTwoDefaultDescription;
return {scheme_one, scheme_two};
SchemeInfo scheme_three;
scheme_three.id = kSchemeThreeId;
scheme_three.name = kSchemeThreeName;
scheme_three.description = kSchemeThreeDefaultDescription;
return {scheme_one, scheme_two, scheme_three};
}
std::optional<SchemeInfo> FindScheme(const std::vector<SchemeInfo>& schemes,
@@ -538,6 +598,20 @@ json SchemeToJson(const SchemeInfo& scheme) {
data["parameters"]["rated_power"] = scheme.rated_power;
data["parameters"]["report_wind_speed_interval"] =
scheme.scheme_two_report_wind_speed_interval;
} else if (scheme.id == kSchemeThreeId) {
data["parameters"] = {
{"rated_power", scheme.scheme_three_rated_power},
{"rated_wind_speed", scheme.scheme_three_rated_wind_speed},
{"power_step", scheme.scheme_three_power_step},
{"cleaning_wind_speed_step", scheme.scheme_three_cleaning_wind_speed_step},
{"wind_speed_change_threshold", scheme.scheme_three_wind_speed_change_threshold},
{"iqr_lower_multiplier", scheme.scheme_three_iqr_lower_multiplier},
{"iqr_upper_multiplier", scheme.scheme_three_iqr_upper_multiplier},
{"minimum_generator_speed", scheme.scheme_three_minimum_generator_speed},
{"generator_speed_k", scheme.scheme_three_generator_speed_k},
{"rotor_radius", scheme.scheme_three_rotor_radius},
{"report_wind_speed_interval", scheme.scheme_three_report_wind_speed_interval},
};
}
return data;
}
@@ -615,6 +689,40 @@ std::vector<SchemeInfo> LoadSchemes() {
value.has_value() && value.value() > 0.0 && value.value() <= 2.0) {
scheme.scheme_two_report_wind_speed_interval = value.value();
}
} else if (scheme.id == kSchemeThreeId) {
const auto load_positive = [&](const std::string& field, double& target) {
if (const auto value = GetNumberField(params, field);
value.has_value() && std::isfinite(value.value()) &&
value.value() > 0.0) {
target = value.value();
}
};
const auto load_non_negative = [&](const std::string& field,
double& target) {
if (const auto value = GetNumberField(params, field);
value.has_value() && std::isfinite(value.value()) &&
value.value() >= 0.0) {
target = value.value();
}
};
load_positive("rated_power", scheme.scheme_three_rated_power);
load_positive("rated_wind_speed", scheme.scheme_three_rated_wind_speed);
load_positive("power_step", scheme.scheme_three_power_step);
load_positive("cleaning_wind_speed_step",
scheme.scheme_three_cleaning_wind_speed_step);
load_non_negative("wind_speed_change_threshold",
scheme.scheme_three_wind_speed_change_threshold);
load_non_negative("iqr_lower_multiplier",
scheme.scheme_three_iqr_lower_multiplier);
load_non_negative("iqr_upper_multiplier",
scheme.scheme_three_iqr_upper_multiplier);
load_non_negative("minimum_generator_speed",
scheme.scheme_three_minimum_generator_speed);
load_non_negative("generator_speed_k",
scheme.scheme_three_generator_speed_k);
load_positive("rotor_radius", scheme.scheme_three_rotor_radius);
load_positive("report_wind_speed_interval",
scheme.scheme_three_report_wind_speed_interval);
}
}
}
@@ -713,6 +821,53 @@ std::optional<std::time_t> ParseTime(std::string value) {
return std::mktime(&tm);
}
std::string NormalizeRawTime(const json& value) {
if (value.is_number()) {
// Excel serial dates use 1899-12-30 as the practical epoch.
const auto seconds = static_cast<std::time_t>((value.get<double>() - 25569.0) * 86400.0);
std::tm tm = {};
localtime_r(&seconds, &tm);
std::ostringstream output;
output << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
return output.str();
}
return JsonText(value);
}
std::optional<json> BuildStandardRow(const json& raw_row, const json& metadata) {
if (!raw_row.is_object() || !raw_row.contains("values") || !raw_row["values"].is_array()) {
return std::nullopt;
}
const auto headers = metadata.value("raw_headers", json::array());
const auto mapping = metadata.value("mapping", json::object());
if (!headers.is_array() || !mapping.is_object()) {
return std::nullopt;
}
const auto& values = raw_row["values"];
json row = json::object();
for (const char* field : {"time", "fan_id", "wind_speed", "active_power", "generator_speed",
"rotor_speed", "blade_pitch_1", "blade_pitch_2", "blade_pitch_3"}) {
const auto header = mapping.value(field, "");
size_t index = headers.size();
for (size_t i = 0; i < headers.size(); ++i) {
if (headers[i].is_string() && headers[i].get<std::string>() == header) {
index = i;
break;
}
}
if (index >= values.size()) {
row[field] = nullptr;
} else if (std::string(field) == "time") {
row[field] = NormalizeRawTime(values[index]);
} else if (std::string(field) == "fan_id") {
row[field] = Trim(JsonText(values[index]));
} else {
row[field] = values[index];
}
}
return row;
}
json CounterJson(const std::unordered_map<std::string, int>& counters) {
json data = json::object();
for (const auto& item : counters) {
@@ -851,6 +1006,10 @@ bool IsSchemeTwo(const CalculationOptions& options) {
return options.scheme_id == kSchemeTwoId;
}
bool IsSchemeThree(const CalculationOptions& options) {
return options.scheme_id == kSchemeThreeId;
}
void AddInvalid(std::unordered_map<std::string, int>& counters, const std::string& reason) {
counters[reason] += 1;
}
@@ -1021,18 +1180,21 @@ std::vector<ValidRow> FilterByWindBinIqr(const std::vector<ValidRow>& rows,
max_wind = std::max(max_wind, row.wind_speed);
}
std::map<long long, std::vector<ValidRow>> buckets;
for (const auto& row : rows) {
const auto bucket = static_cast<long long>(std::floor(
(row.wind_speed - min_wind) / options.cleaning_wind_speed_step + 1e-9));
buckets[bucket].push_back(row);
}
std::vector<ValidRow> result;
result.reserve(rows.size());
for (double interval = min_wind; interval <= max_wind;
interval += options.cleaning_wind_speed_step) {
std::vector<ValidRow> interval_rows;
for (const auto& item : buckets) {
const auto& interval_rows = item.second;
std::vector<double> values;
for (const auto& row : rows) {
if (row.wind_speed >= interval &&
row.wind_speed < interval + options.cleaning_wind_speed_step) {
interval_rows.push_back(row);
values.push_back(value_getter(row));
}
values.reserve(interval_rows.size());
for (const auto& row : interval_rows) {
values.push_back(value_getter(row));
}
if (interval_rows.empty()) {
@@ -1074,18 +1236,22 @@ std::vector<CurveBin> BuildMedianCurveBins(const std::vector<ValidRow>& rows,
const double curve_min = 1.0 - wind_speed_step * 0.5;
const double curve_max = 25.0 + wind_speed_step * 0.5;
std::vector<CurveBin> bins;
for (double start = curve_min; start < curve_max; start += wind_speed_step) {
const double end = start + wind_speed_step;
std::vector<double> values;
for (const auto& row : rows) {
if (row.wind_speed > start && row.wind_speed <= end) {
values.push_back(row.active_power);
}
const auto bucket_count = static_cast<size_t>(std::ceil((curve_max - curve_min) / wind_speed_step));
std::vector<std::vector<double>> values_by_bucket(bucket_count);
for (const auto& row : rows) {
const auto bucket = static_cast<long long>(std::ceil((row.wind_speed - curve_min) /
wind_speed_step) - 1.0);
if (bucket >= 0 && static_cast<size_t>(bucket) < values_by_bucket.size()) {
values_by_bucket[static_cast<size_t>(bucket)].push_back(row.active_power);
}
}
std::vector<CurveBin> bins;
for (size_t index = 0; index < values_by_bucket.size(); ++index) {
const auto& values = values_by_bucket[index];
if (!values.empty()) {
CurveBin bin;
bin.wind_speed = (start + end) / 2.0;
const double start = curve_min + static_cast<double>(index) * wind_speed_step;
bin.wind_speed = start + wind_speed_step / 2.0;
bin.median_power = Quantile(values, 0.5);
bin.sample_count = values.size();
bins.push_back(bin);
@@ -1371,14 +1537,14 @@ void WindPowerController::SaveSchemeDescription(
return;
}
std::optional<json> scheme_one_parameters;
std::optional<json> scheme_base_parameters;
std::optional<double> grid_connected_speed;
std::optional<double> rated_generator_speed;
std::optional<double> rated_power;
std::optional<double> report_wind_speed_interval;
if (normalized_id == kDefaultSchemeId) {
if (normalized_id == kDefaultSchemeId || normalized_id == kSchemeThreeId) {
if (!body.value().contains("parameters") || !body.value()["parameters"].is_object()) {
SendError(callback, kErrorInvalidRequest, "方案参数格式错误");
SendError(callback, kErrorInvalidRequest, "方案参数格式错误");
return;
}
const auto& params = body.value()["parameters"];
@@ -1392,24 +1558,25 @@ void WindPowerController::SaveSchemeDescription(
};
if (!valid_positive("rated_power") || !valid_positive("rated_wind_speed") ||
!valid_positive("power_step") || !valid_positive("cleaning_wind_speed_step") ||
!valid_positive("rotor_radius") || !valid_positive("gearbox_ratio") ||
!valid_positive("rotor_radius") ||
(normalized_id == kDefaultSchemeId && !valid_positive("gearbox_ratio")) ||
!valid_positive("report_wind_speed_interval") ||
!valid_non_negative("wind_speed_change_threshold") ||
!valid_non_negative("iqr_lower_multiplier") ||
!valid_non_negative("iqr_upper_multiplier") ||
!valid_non_negative("minimum_generator_speed") ||
!valid_non_negative("generator_speed_k")) {
SendError(callback, kErrorInvalidRequest, "方案参数必须为合法数值");
SendError(callback, kErrorInvalidRequest, "方案参数必须为合法数值");
return;
}
if (GetDoubleField(params, "cleaning_wind_speed_step").value() > 2.0 ||
GetDoubleField(params, "report_wind_speed_interval").value() > 2.0 ||
GetDoubleField(params, "iqr_lower_multiplier").value() > 10.0 ||
GetDoubleField(params, "iqr_upper_multiplier").value() > 10.0) {
SendError(callback, kErrorInvalidRequest, "方案参数超出允许范围");
SendError(callback, kErrorInvalidRequest, "方案参数超出允许范围");
return;
}
scheme_one_parameters = params;
scheme_base_parameters = params;
} else if (normalized_id == kSchemeTwoId && body.value().contains("parameters")) {
if (!body.value()["parameters"].is_object()) {
SendError(callback, kErrorInvalidRequest, "方案参数格式错误");
@@ -1436,7 +1603,7 @@ void WindPowerController::SaveSchemeDescription(
if (scheme.id == normalized_id) {
scheme.description = description.value();
if (scheme.id == kDefaultSchemeId) {
const auto& params = scheme_one_parameters.value();
const auto& params = scheme_base_parameters.value();
scheme.scheme_one_rated_power = GetDoubleField(params, "rated_power").value();
scheme.scheme_one_rated_wind_speed = GetDoubleField(params, "rated_wind_speed").value();
scheme.scheme_one_power_step = GetDoubleField(params, "power_step").value();
@@ -1470,6 +1637,30 @@ void WindPowerController::SaveSchemeDescription(
scheme.scheme_two_report_wind_speed_interval =
report_wind_speed_interval.value();
}
} else if (scheme.id == kSchemeThreeId) {
const auto& params = scheme_base_parameters.value();
scheme.scheme_three_rated_power =
GetDoubleField(params, "rated_power").value();
scheme.scheme_three_rated_wind_speed =
GetDoubleField(params, "rated_wind_speed").value();
scheme.scheme_three_power_step =
GetDoubleField(params, "power_step").value();
scheme.scheme_three_cleaning_wind_speed_step =
GetDoubleField(params, "cleaning_wind_speed_step").value();
scheme.scheme_three_wind_speed_change_threshold =
GetDoubleField(params, "wind_speed_change_threshold").value();
scheme.scheme_three_iqr_lower_multiplier =
GetDoubleField(params, "iqr_lower_multiplier").value();
scheme.scheme_three_iqr_upper_multiplier =
GetDoubleField(params, "iqr_upper_multiplier").value();
scheme.scheme_three_minimum_generator_speed =
GetDoubleField(params, "minimum_generator_speed").value();
scheme.scheme_three_generator_speed_k =
GetDoubleField(params, "generator_speed_k").value();
scheme.scheme_three_rotor_radius =
GetDoubleField(params, "rotor_radius").value();
scheme.scheme_three_report_wind_speed_interval =
GetDoubleField(params, "report_wind_speed_interval").value();
}
updated = true;
break;
@@ -1588,8 +1779,8 @@ void WindPowerController::UploadChunk(
SendError(callback, kErrorJobNotFound, "计算任务不存在");
return;
}
if (!body->contains("rows") || !(*body)["rows"].is_array()) {
SendError(callback, kErrorInvalidRequest, "缺少 rows 参数");
if (!body->contains("raw_rows") || !(*body)["raw_rows"].is_array()) {
SendError(callback, kErrorInvalidRequest, "缺少 raw_rows 参数");
return;
}
if (IsTaskBusyFor(job_id.value())) {
@@ -1597,22 +1788,26 @@ void WindPowerController::UploadChunk(
return;
}
try {
json metadata;
{
std::ifstream meta(JobDir(job_id.value()) / "metadata.json");
meta >> metadata;
}
std::ofstream out(JobRowsPath(job_id.value()), std::ios::app);
std::ofstream raw_out(JobRawRowsPath(job_id.value()), std::ios::app);
int accepted = 0;
for (const auto& row : (*body)["rows"]) {
if (!row.is_object()) {
for (const auto& raw_row : (*body)["raw_rows"]) {
if (!raw_row.is_object()) {
continue;
}
out << row.dump() << '\n';
++accepted;
}
out.close();
if (body->contains("raw_rows") && (*body)["raw_rows"].is_array()) {
for (const auto& row : (*body)["raw_rows"]) {
if (row.is_object()) raw_out << row.dump() << '\n';
raw_out << raw_row.dump() << '\n';
const auto row = BuildStandardRow(raw_row, metadata);
if (row.has_value()) {
out << row->dump() << '\n';
++accepted;
}
}
out.close();
raw_out.close();
TouchTask(job_id.value());
@@ -1645,6 +1840,7 @@ void WindPowerController::FinishJob(
return;
}
TaskReleaseGuard finish_guard(job_id.value());
const auto calculation_started = std::chrono::steady_clock::now();
const CalculationOptions options = ParseOptions(*body);
if (body->contains("options") && (*body)["options"].is_object() &&
@@ -1668,6 +1864,7 @@ void WindPowerController::FinishJob(
std::vector<ValidRow> parsed_rows;
std::unordered_map<std::string, int> invalid_reasons;
int raw_rows = 0;
const auto read_started = std::chrono::steady_clock::now();
try {
std::ifstream in(JobRowsPath(job_id.value()));
@@ -1691,6 +1888,7 @@ void WindPowerController::FinishJob(
const auto wind_speed = GetDoubleField(row, "wind_speed");
const auto active_power = GetDoubleField(row, "active_power");
const auto generator_speed = GetDoubleField(row, "generator_speed");
const auto rotor_speed = GetDoubleField(row, "rotor_speed");
const auto blade_pitch_1 = GetDoubleField(row, "blade_pitch_1");
const auto blade_pitch_2 = GetDoubleField(row, "blade_pitch_2");
const auto blade_pitch_3 = GetDoubleField(row, "blade_pitch_3");
@@ -1706,6 +1904,12 @@ void WindPowerController::FinishJob(
AddInvalid(invalid_reasons, "invalid_generator_speed");
continue;
}
if (IsSchemeThree(options) &&
(!rotor_speed.has_value() || !std::isfinite(rotor_speed.value()) ||
rotor_speed.value() <= 0.0)) {
AddInvalid(invalid_reasons, "invalid_rotor_speed");
continue;
}
if (IsSchemeTwo(options) &&
(!blade_pitch_1.has_value() || !std::isfinite(blade_pitch_1.value()) ||
!blade_pitch_2.has_value() || !std::isfinite(blade_pitch_2.value()) ||
@@ -1740,6 +1944,9 @@ void WindPowerController::FinishJob(
valid_row.wind_speed = wind_speed.value();
valid_row.active_power = active_power.value();
valid_row.generator_speed = generator_speed.value();
if (rotor_speed.has_value() && std::isfinite(rotor_speed.value())) {
valid_row.rotor_speed = rotor_speed.value();
}
if (IsSchemeTwo(options)) {
valid_row.blade_pitch_1 = blade_pitch_1.value();
valid_row.blade_pitch_2 = blade_pitch_2.value();
@@ -1755,6 +1962,8 @@ void WindPowerController::FinishJob(
SendError(callback, kErrorServer, "读取任务数据失败");
return;
}
const auto read_parse_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - read_started).count();
std::sort(parsed_rows.begin(), parsed_rows.end(), [](const ValidRow& left, const ValidRow& right) {
if (left.fan_id != right.fan_id) {
@@ -1787,8 +1996,7 @@ void WindPowerController::FinishJob(
json fans = json::array();
json curves = json::object();
json bins = json::object();
json scatter_points = json::object();
json filtered_points = json::object();
json point_files = json::object();
json estimated_params = json::object();
std::vector<std::string> fan_ids;
@@ -1809,7 +2017,10 @@ void WindPowerController::FinishJob(
int scheme_two_low_speed_pitch_count = 0;
int scheme_two_low_power_pitch_count = 0;
int cleaned_rows_count = 0;
for (const auto& fan_id : fan_ids) {
const auto cleaning_started = std::chrono::steady_clock::now();
fs::create_directories(JobPointsDir(job_id.value()));
for (size_t fan_index = 0; fan_index < fan_ids.size(); ++fan_index) {
const auto& fan_id = fan_ids[fan_index];
fans.push_back(fan_id);
auto fan_rows = rows_by_fan[fan_id];
std::vector<RemovedPoint> fan_removed_points;
@@ -1836,9 +2047,11 @@ void WindPowerController::FinishJob(
limit_power_count += removed;
for (auto& row : fan_rows) {
row.tip_speed_ratio = row.generator_speed * 3.14 * options.gearbox_ratio *
options.rotor_radius * 30.0 /
row.wind_speed;
const double speed = IsSchemeThree(options)
? row.rotor_speed
: row.generator_speed * options.gearbox_ratio;
row.tip_speed_ratio = speed * 3.14 * options.rotor_radius * 30.0 /
row.wind_speed;
}
fan_rows = FilterByWindBinIqr(
@@ -1913,11 +2126,10 @@ void WindPowerController::FinishJob(
point["wind_speed"] = row.wind_speed;
point["active_power"] = row.active_power;
point["generator_speed"] = row.generator_speed;
point["rotor_speed"] = row.rotor_speed;
point["pitch_angle_average"] = row.pitch_angle_average;
fan_scatter.push_back(point);
}
scatter_points[fan_id] = fan_scatter;
json fan_filtered = json::array();
for (const auto& removed_point : fan_removed_points) {
json point;
@@ -1926,11 +2138,19 @@ void WindPowerController::FinishJob(
point["wind_speed"] = removed_point.row.wind_speed;
point["active_power"] = removed_point.row.active_power;
point["generator_speed"] = removed_point.row.generator_speed;
point["rotor_speed"] = removed_point.row.rotor_speed;
point["pitch_angle_average"] = removed_point.row.pitch_angle_average;
point["reason"] = removed_point.reason;
fan_filtered.push_back(point);
}
filtered_points[fan_id] = fan_filtered;
json point_data;
point_data["fan_id"] = fan_id;
point_data["scatter_points"] = std::move(fan_scatter);
point_data["filtered_points"] = std::move(fan_filtered);
const auto point_path = JobFanPointsPath(job_id.value(), fan_index);
std::ofstream point_out(point_path, std::ios::trunc);
point_out << point_data.dump();
point_files[fan_id] = point_path.filename().string();
json fan_curve = json::array();
json fan_bins_json = json::array();
@@ -2008,9 +2228,14 @@ void WindPowerController::FinishJob(
data["fans"] = fans;
data["curves"] = curves;
data["bins"] = bins;
data["scatter_points"] = scatter_points;
data["filtered_points"] = filtered_points;
data["point_files"] = point_files;
data["estimated_params"] = estimated_params;
data["timings_ms"]["read_parse"] = read_parse_ms;
data["timings_ms"]["cleaning_and_points"] = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - cleaning_started).count();
const auto calculation_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - calculation_started).count();
data["timings_ms"]["calculation"] = calculation_ms;
try {
std::ofstream result_file(JobResultPath(job_id.value()), std::ios::trunc);
@@ -2020,7 +2245,49 @@ void WindPowerController::FinishJob(
return;
}
SendSuccess(callback, data);
json response_data = data;
if (!fan_ids.empty()) {
std::ifstream point_input(JobFanPointsPath(job_id.value(), 0));
json initial_points;
point_input >> initial_points;
response_data["initial_fan_id"] = fan_ids.front();
response_data["scatter_points"] = json::object({{fan_ids.front(), initial_points["scatter_points"]}});
response_data["filtered_points"] = json::object({{fan_ids.front(), initial_points["filtered_points"]}});
}
LOG_INFO << "wind job " << job_id.value() << " completed in "
<< calculation_ms << " ms";
SendSuccess(callback, response_data);
}
void WindPowerController::GetFanPoints(
const HttpRequestPtr&,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& job_id,
const std::string& fan_id) {
if (!IsSafeJobId(job_id) || !fs::exists(JobResultPath(job_id))) {
SendError(callback, kErrorJobNotFound, "计算任务不存在或结果已过期");
return;
}
try {
json result;
std::ifstream result_input(JobResultPath(job_id));
result_input >> result;
const auto point_file = result.value("point_files", json::object()).value(fan_id, "");
if (point_file.empty() || point_file.find("..") != std::string::npos) {
SendError(callback, kErrorInvalidRequest, "风机不存在");
return;
}
std::ifstream point_input(JobPointsDir(job_id) / point_file);
if (!point_input.good()) {
SendError(callback, kErrorServer, "风机点数据不可用");
return;
}
json data;
point_input >> data;
SendSuccess(callback, data);
} catch (const std::exception&) {
SendError(callback, kErrorServer, "读取风机点数据失败");
}
}
void WindPowerController::ExportReport(
@@ -2038,8 +2305,7 @@ void WindPowerController::ExportReport(
return;
}
const auto fan_id = GetStringField(*body, "fan_id");
if (!fan_id.has_value() || !body->contains("effective_rows") ||
!(*body)["effective_rows"].is_array() || !body->contains("report_rows") ||
if (!fan_id.has_value() || !body->contains("report_rows") ||
!(*body)["report_rows"].is_array()) {
SendError(callback, kErrorInvalidRequest, "报告参数不完整");
return;
@@ -2049,6 +2315,22 @@ void WindPowerController::ExportReport(
return;
}
TaskReleaseGuard report_guard(job_id);
const auto export_started = std::chrono::steady_clock::now();
const auto cache_key = std::to_string(std::hash<std::string>{}(body->dump()));
const auto report_path = JobDir(job_id) / (
"report_" + FileNameForFan(fan_id.value()) + "_" + cache_key + ".xlsx");
if (fs::exists(report_path)) {
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - export_started).count();
auto response = HttpResponse::newFileResponse(report_path.string(),
"完整功率曲线报告_" + FileNameForFan(fan_id.value()) + ".xlsx",
CT_CUSTOM,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response->addHeader("Server-Timing", "report-cache;dur=" + std::to_string(elapsed_ms));
callback(response);
return;
}
try {
json metadata;
@@ -2075,7 +2357,26 @@ void WindPowerController::ExportReport(
return;
}
const auto report_path = JobDir(job_id) / ("report_" + FileNameForFan(fan_id.value()) + ".xlsx");
const auto point_file = result.value("point_files", json::object()).value(fan_id.value(), "");
if (point_file.empty() || point_file.find("..") != std::string::npos) {
SendError(callback, kErrorJobNotFound, "风机点数据不存在或已过期");
return;
}
json point_data;
{ std::ifstream input(JobPointsDir(job_id) / point_file); input >> point_data; }
const auto restored_ids = PointIdSet(*body, "restored_point_ids");
const auto erased_ids = PointIdSet(*body, "erased_point_ids");
json effective_rows = json::array();
for (const auto& point : point_data.value("scatter_points", json::array())) {
if (point.is_object() && erased_ids.count(PointIdFor(point, fan_id.value())) == 0) {
effective_rows.push_back(point);
}
}
for (const auto& point : point_data.value("filtered_points", json::array())) {
if (point.is_object() && restored_ids.count(PointIdFor(point, fan_id.value())) != 0) {
effective_rows.push_back(point);
}
}
lxw_workbook_options options{};
const auto temp_dir = JobDir(job_id).string();
options.constant_memory = LXW_TRUE;
@@ -2092,23 +2393,41 @@ void WindPowerController::ExportReport(
lxw_worksheet* detail = workbook_add_worksheet(workbook, "筛选后的数据");
worksheet_freeze_panes(detail, 1, 0);
const bool include_pitch = result.value("scheme", json::object()).value("id", "") == kSchemeTwoId;
const std::vector<std::string> detail_headers = include_pitch
? std::vector<std::string>{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速", "3个叶片变桨角平均值"}
: std::vector<std::string>{"风机编号", "采样时间", "平均功率", "平均转速", "平均风速"};
const auto report_scheme_id =
result.value("scheme", json::object()).value("id", "");
const bool include_pitch = report_scheme_id == kSchemeTwoId;
const bool include_rotor_speed = report_scheme_id == kSchemeThreeId;
std::vector<std::string> detail_headers =
{"风机编号", "采样时间", "平均功率", "平均转速"};
if (include_rotor_speed) {
detail_headers.push_back("叶轮转速");
}
detail_headers.push_back("平均风速");
if (include_pitch) {
detail_headers.push_back("3个叶片变桨角平均值");
}
for (size_t i = 0; i < detail_headers.size(); ++i) {
worksheet_write_string(detail, 0, i, detail_headers[i].c_str(), header_format);
worksheet_set_column(detail, i, i, i == 1 ? 22 : 16, nullptr);
}
lxw_row_t detail_row = 1;
for (const auto& point : (*body)["effective_rows"]) {
for (const auto& point : effective_rows) {
if (!point.is_object()) continue;
worksheet_write_string(detail, detail_row, 0, point.value("fan_id", fan_id.value()).c_str(), nullptr);
worksheet_write_string(detail, detail_row, 1, point.value("time", "").c_str(), nullptr);
worksheet_write_number(detail, detail_row, 2, point.value("active_power", 0.0), number_format);
worksheet_write_number(detail, detail_row, 3, point.value("generator_speed", 0.0), number_format);
worksheet_write_number(detail, detail_row, 4, point.value("wind_speed", 0.0), number_format);
if (include_pitch) worksheet_write_number(detail, detail_row, 5, point.value("pitch_angle_average", 0.0), number_format);
lxw_col_t detail_column = 4;
if (include_rotor_speed) {
worksheet_write_number(detail, detail_row, detail_column++,
point.value("rotor_speed", 0.0), number_format);
}
worksheet_write_number(detail, detail_row, detail_column++,
point.value("wind_speed", 0.0), number_format);
if (include_pitch) {
worksheet_write_number(detail, detail_row, detail_column,
point.value("pitch_angle_average", 0.0), number_format);
}
++detail_row;
}
worksheet_autofilter(detail, 0, 0, std::max<lxw_row_t>(1, detail_row - 1), detail_headers.size() - 1);
@@ -2163,7 +2482,12 @@ void WindPowerController::ExportReport(
worksheet_write_number(curve, curve_row, 1, point.value("wind_speed", 0.0), number_format);
const auto frequency = "=(COUNTIFS('筛选前的数据'!" + raw_wind + ":" + raw_wind + ",\">=\"&B" + std::to_string(excel_row) + "-" + interval_text + ",'筛选前的数据'!" + raw_wind + ":" + raw_wind + ",\"<\"&B" + std::to_string(excel_row) + "+" + interval_text + ")/COUNT('筛选前的数据'!" + raw_wind + ":" + raw_wind + "))*8760";
worksheet_write_formula(curve, curve_row, 2, frequency.c_str(), number_format);
const auto actual = "=IFERROR(AVERAGEIFS('筛选后的数据'!$C:$C,'筛选后的数据'!$E:$E,\">=\"&B" + std::to_string(excel_row) + "-" + interval_text + ",'筛选后的数据'!$E:$E,\"<\"&B" + std::to_string(excel_row) + "+" + interval_text + "),0)";
const auto detail_wind_column = include_rotor_speed ? "$F:$F" : "$E:$E";
const auto actual = std::string(
"=IFERROR(AVERAGEIFS('筛选后的数据'!$C:$C,'筛选后的数据'!") +
detail_wind_column + ",\">=\"&B" + std::to_string(excel_row) + "-" +
interval_text + ",'筛选后的数据'!" + detail_wind_column + ",\"<\"&B" +
std::to_string(excel_row) + "+" + interval_text + "),0)";
worksheet_write_formula(curve, curve_row, 3, actual.c_str(), number_format);
worksheet_write_number(curve, curve_row, 4, point.value("design_power", 0.0), number_format);
const auto generated = "=IFERROR(ROUND(C" + std::to_string(excel_row) + "*D" + std::to_string(excel_row) + "/1000,4),0)";
@@ -2195,10 +2519,16 @@ void WindPowerController::ExportReport(
&image_options);
}
if (workbook_close(workbook) != LXW_NO_ERROR) throw std::runtime_error("写入 Excel 文件失败");
callback(HttpResponse::newFileResponse(report_path.string(),
const auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - export_started).count();
LOG_INFO << "wind report " << job_id << "/" << fan_id.value()
<< " generated in " << elapsed_ms << " ms, rows=" << raw_row;
auto response = HttpResponse::newFileResponse(report_path.string(),
"完整功率曲线报告_" + FileNameForFan(fan_id.value()) + ".xlsx",
CT_CUSTOM,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response->addHeader("Server-Timing", "report-generate;dur=" + std::to_string(elapsed_ms));
callback(response);
} catch (const std::exception&) {
SendError(callback, kErrorServer, "完整报告生成失败,请稍后重试");
}
@@ -14,6 +14,8 @@ public:
ADD_METHOD_TO(WindPowerController::StartJob, "/api/wind/jobs/start", Post);
ADD_METHOD_TO(WindPowerController::UploadChunk, "/api/wind/jobs/chunk", Post);
ADD_METHOD_TO(WindPowerController::FinishJob, "/api/wind/jobs/finish", Post);
ADD_METHOD_TO(WindPowerController::GetFanPoints,
"/api/wind/jobs/{1}/fans/{2}/points", Get);
ADD_METHOD_TO(WindPowerController::ExportReport, "/api/wind/jobs/{1}/report", Post);
ADD_METHOD_TO(WindPowerController::DeleteJob, "/api/wind/jobs/{1}", Delete);
ADD_METHOD_TO(WindPowerController::GetSchemes, "/api/wind/schemes", Get);
@@ -39,6 +41,10 @@ public:
std::function<void(const HttpResponsePtr&)>&& callback);
void FinishJob(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback);
void GetFanPoints(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& job_id,
const std::string& fan_id);
void ExportReport(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& job_id);
+42 -2
View File
@@ -2,19 +2,59 @@
#include <trantor/utils/Logger.h>
#include <memory>
#include <string>
#include "auth/AuthManager.h"
#include "controllers/AuthController.h"
#include "controllers/WindPowerController.h"
#include "utils/ResponseUtil.h"
using namespace drogon;
int main() {
// 抑制 Drogon/trantor 内部日志噪声
trantor::Logger::setLogLevel(trantor::Logger::kFatal);
// 保留任务耗时日志,便于定位上传、清洗与回传瓶颈。
trantor::Logger::setLogLevel(trantor::Logger::kInfo);
// 加载 Drogon 配置(监听端口 / CORS / 静态资源根目录)
LOG_INFO << "Loading server configuration...";
app().loadConfigFile("config/server_config.json");
if (!AuthManager::Instance().Initialize()) {
LOG_ERROR << "Failed to initialize account database";
return 1;
}
app().registerController(std::make_shared<AuthController>());
app().registerController(std::make_shared<WindPowerController>());
app().registerPreRoutingAdvice(
[](const HttpRequestPtr& req,
AdviceCallback&& stop,
AdviceChainCallback&& next) {
const std::string path = req->path();
const bool public_api = path == "/api/auth/login" ||
path == "/api/auth/logout" || path == "/api/auth/me" ||
path == "/api/system/health" || path == "/api/system/version";
if (req->method() == Options || path.rfind("/api/", 0) != 0 || public_api) {
next();
return;
}
const auto auth = AuthManager::Instance().Authenticate(
req->getCookie(AuthManager::kSessionCookie));
if (auth.status == AuthStatus::kAuthenticated) {
next();
return;
}
const bool account_abnormal = auth.status == AuthStatus::kExpired ||
auth.status == AuthStatus::kDisabled;
const auto message = account_abnormal
? "账号异常请联系管理员"
: "请先登录";
const auto code = account_abnormal ? 4 : 2;
auto response = HttpResponse::newHttpResponse();
response->setContentTypeCode(CT_APPLICATION_JSON);
response->setBody(
ResponseUtil::GenerateErrorResponse(code, message).dump());
response->setStatusCode(k401Unauthorized);
stop(response);
});
// SPA 前端路由回退:未匹配路径统一返回 index.html,交由前端路由处理
app().setCustom404Page(