功能: 完善账号、工作台与功率计算方案
- 新增账号管理、有效期、启停控制与登录工作台布局 - 新增方案三叶轮转速计算及字段映射和报告支持 - 修复功率曲线断线、图例越界及方案一散点绘制 - 优化计算进度、浅色主题和当前方案提示 - 补充前端测试与部署依赖配置
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(×tamp, &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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user