|
|
@@ -1,1732 +0,0 @@
|
|
|
-/**
|
|
|
- * web_server.cpp — Web服务实现
|
|
|
- * v43.2 fix18: 4路独立视频页面 + MJPEG流 + ROI手动绘制 + P0重复照片修复
|
|
|
- */
|
|
|
-#include "web_server.h"
|
|
|
-#include "database.h"
|
|
|
-#include "station_lock.h"
|
|
|
-#include "network_client.h"
|
|
|
-#include "feishu_client.h"
|
|
|
-#include "weight_scale.h"
|
|
|
-#include "utils.h"
|
|
|
-#include "rtsp_capture.h"
|
|
|
-#define CPPHTTPLIB_OPENSSL_SUPPORT // fix24-v12: 启用HTTPS/SSL支持
|
|
|
-#include "httplib.h"
|
|
|
-#include <openssl/ssl.h> // fix24-v16: 显式包含OpenSSL头文件以使用SSL_CTX API
|
|
|
-#include <cjson/cJSON.h>
|
|
|
-#include <iostream>
|
|
|
-#include <sstream>
|
|
|
-#include <fstream>
|
|
|
-#include <map>
|
|
|
-#include <set>
|
|
|
-
|
|
|
-// ==================== fix18新增:4路视频辅助函数 ====================
|
|
|
-
|
|
|
-// 根据stream参数获取摄像头帧
|
|
|
-static cv::Mat get_frame_by_stream(const std::string& stream) {
|
|
|
- if (stream == "in-low") {
|
|
|
- return plate_rec_app.capture_front_in->getFrame();
|
|
|
- } else if (stream == "in-high") {
|
|
|
- return plate_rec_app.capture_side_in->getFrame();
|
|
|
- } else if (stream == "out-low") {
|
|
|
- // ✅ fix24-v9: 共享模式下出站未创建独立摄像头,复用进站摄像头画面
|
|
|
- if (plate_rec_app.capture_front_out) {
|
|
|
- return plate_rec_app.capture_front_out->getFrame();
|
|
|
- }
|
|
|
- return plate_rec_app.capture_front_in->getFrame();
|
|
|
- } else if (stream == "out-high") {
|
|
|
- return plate_rec_app.capture_side_out->getFrame();
|
|
|
- }
|
|
|
- return cv::Mat();
|
|
|
-}
|
|
|
-
|
|
|
-// 根据stream参数获取ROI配置(高位摄像头返回nullptr)
|
|
|
-static const ROIConfig* get_roi_by_stream(const std::string& stream) {
|
|
|
- if (stream == "in-low") return &g_roi_in;
|
|
|
- if (stream == "out-low") return &g_roi_out;
|
|
|
- return nullptr;
|
|
|
-}
|
|
|
-
|
|
|
-// ✅ fix24-v10: stream标签使用英文(cv::putText不支持中文会显示问号)
|
|
|
-// 中文标签由前端CSS overlay显示,画面叠加英文缩写供截图/裸流场景识别
|
|
|
-static std::string get_stream_label(const std::string& stream) {
|
|
|
- if (stream == "in-low") return "IN-LOW";
|
|
|
- if (stream == "in-high") return "IN-HIGH";
|
|
|
- if (stream == "out-low") return "OUT-LOW";
|
|
|
- if (stream == "out-high") return "OUT-HIGH";
|
|
|
- return "UNKNOWN";
|
|
|
-}
|
|
|
-
|
|
|
-// ==================== HTML转义 ====================
|
|
|
-
|
|
|
-std::string escape_html(const std::string& str) {
|
|
|
- std::string result;
|
|
|
- for (char c : str) {
|
|
|
- switch (c) {
|
|
|
- case '&': result += "&"; break;
|
|
|
- case '<': result += "<"; break;
|
|
|
- case '>': result += ">"; break;
|
|
|
- case '"': result += """; break;
|
|
|
- case '\'': result += "'"; break;
|
|
|
- default: result += c;
|
|
|
- }
|
|
|
- }
|
|
|
- return result;
|
|
|
-}
|
|
|
-
|
|
|
-std::string generate_monitor_report() {
|
|
|
- std::ostringstream json;
|
|
|
- json << "{";
|
|
|
- json << "\"createbill_success_rate\":" << g_metrics.get_createbill_success_rate() << ",";
|
|
|
- json << "\"createbill_total\":" << g_metrics.createbill_total.load() << ",";
|
|
|
- json << "\"createbill_success\":" << g_metrics.createbill_success.load() << ",";
|
|
|
- json << "\"createbill_business_error\":" << g_metrics.createbill_business_error.load() << ",";
|
|
|
- json << "\"createbill_quota_exceeded\":" << g_metrics.createbill_quota_exceeded.load() << ",";
|
|
|
- json << "\"avg_upload_time\":" << g_metrics.get_avg_upload_time() << ",";
|
|
|
- json << "\"avg_infer_time\":" << g_metrics.get_avg_inference_time() << ",";
|
|
|
- json << "\"last_infer_time\":" << g_metrics.get_last_inference_time() << ",";
|
|
|
- json << "\"time_window_hits\":" << g_metrics.time_window_hits.load() << ",";
|
|
|
- json << "\"in_cache_hit_rate\":" << g_metrics.get_cache_hit_rate(true) << ",";
|
|
|
- json << "\"out_cache_hit_rate\":" << g_metrics.get_cache_hit_rate(false) << ",";
|
|
|
- json << "\"in_cache_hits\":" << g_metrics.in_cache_hits.load() << ",";
|
|
|
- json << "\"in_cache_misses\":" << g_metrics.in_cache_misses.load() << ",";
|
|
|
- json << "\"out_cache_hits\":" << g_metrics.out_cache_hits.load() << ",";
|
|
|
- json << "\"out_cache_misses\":" << g_metrics.out_cache_misses.load() << ",";
|
|
|
- json << "\"in_station_count\":" << g_metrics.in_station_count.load() << ",";
|
|
|
- json << "\"out_station_count\":" << g_metrics.out_station_count.load() << ",";
|
|
|
- json << "\"total_records\":" << db_get_total_count() << ",";
|
|
|
- json << "\"failed_records\":" << db_get_failed_count() << ",";
|
|
|
- json << "\"feishu_success_count\":" << g_metrics.feishu_success_count.load() << ",";
|
|
|
- json << "\"uptime\":\"" << escape_html(g_metrics.get_uptime_string()) << "\",";
|
|
|
- json << "\"upload_errors\":" << g_metrics.upload_errors.load() << ",";
|
|
|
- json << "\"network_timeouts\":" << g_metrics.network_timeouts.load() << ",";
|
|
|
- json << "\"server_errors\":" << g_metrics.server_errors.load() << ",";
|
|
|
- json << "\"photo_lo_success\":" << g_metrics.photo_lo_success.load() << ",";
|
|
|
- json << "\"photo_hi_success\":" << g_metrics.photo_hi_success.load() << ",";
|
|
|
- json << "\"photo_group_success\":" << g_metrics.photo_group_success.load() << ",";
|
|
|
- json << "\"db_duplicate_skip_count\":" << g_metrics.db_duplicate_skip_count.load() << ",";
|
|
|
- json << "\"dir_create_error_count\":" << g_metrics.dir_create_error_count.load() << ",";
|
|
|
- json << "\"db_insert_error_count\":" << g_metrics.db_insert_error_count.load() << ",";
|
|
|
- json << "\"db_insert_retry_success\":" << g_metrics.db_insert_retry_success.load() << ",";
|
|
|
- json << "\"permanent_failure_count\":" << g_metrics.permanent_failure_count.load() << ",";
|
|
|
- json << "\"alert_sent_count\":" << g_metrics.alert_sent_count.load() << ",";
|
|
|
- json << "\"main_loop_block_count\":" << g_metrics.main_loop_block_count.load() << ",";
|
|
|
- json << "\"main_loop_max_block_ms\":" << g_metrics.main_loop_max_block_ms.load() << ",";
|
|
|
- json << "\"station_lock_in_count\":" << g_metrics.station_lock_in_count.load() << ",";
|
|
|
- json << "\"station_lock_out_count\":" << g_metrics.station_lock_out_count.load() << ",";
|
|
|
- json << "\"station_in_block_count\":" << g_metrics.station_in_block_count.load() << ",";
|
|
|
- json << "\"station_out_block_count\":" << g_metrics.station_out_block_count.load() << ",";
|
|
|
- json << "\"station_timeout_cleanup_count\":" << g_metrics.station_timeout_cleanup_count.load() << ",";
|
|
|
- // ✅ v43新增:ROI指标
|
|
|
- json << "\"roi_in_crop_count\":" << g_metrics.roi_in_crop_count.load() << ",";
|
|
|
- json << "\"roi_out_crop_count\":" << g_metrics.roi_out_crop_count.load() << ",";
|
|
|
- json << "\"roi_in_fullframe_count\":" << g_metrics.roi_in_fullframe_count.load() << ",";
|
|
|
- json << "\"roi_out_fullframe_count\":" << g_metrics.roi_out_fullframe_count.load() << ",";
|
|
|
- // ✅ v43新增:飞书/解码/Web指标
|
|
|
- json << "\"feishu_skip_count\":" << g_metrics.feishu_skip_count.load() << ",";
|
|
|
- json << "\"drm_decode_count\":" << g_metrics.drm_decode_count.load() << ",";
|
|
|
- json << "\"soft_decode_count\":" << g_metrics.soft_decode_count.load() << ",";
|
|
|
- json << "\"web_retry_count\":" << g_metrics.web_retry_count.load() << ",";
|
|
|
- json << "\"web_retry_success_count\":" << g_metrics.web_retry_success_count.load() << ",";
|
|
|
- // ✅ v43新增:配置状态
|
|
|
- json << "\"alternating_merge_enabled\":" << (g_alternating_merge_enabled ? "true" : "false") << ",";
|
|
|
- json << "\"emergency_alert_suppressed\":" << (g_suppress_emergency_alert ? "true" : "false") << ",";
|
|
|
- json << "\"roi_in_enabled\":" << (g_roi_in.enabled ? "true" : "false") << ",";
|
|
|
- json << "\"roi_out_enabled\":" << (g_roi_out.enabled ? "true" : "false") << ",";
|
|
|
- json << "\"in_out_interval_sec\":" << g_in_out_interval_sec;
|
|
|
- json << "}";
|
|
|
- return json.str();
|
|
|
-}
|
|
|
-
|
|
|
-std::string generate_message_list_json(int page, const std::string& search_keyword) {
|
|
|
- const int page_size = 20;
|
|
|
- std::vector<DbRecord> records = db_get_records(page, page_size, search_keyword);
|
|
|
- std::ostringstream json;
|
|
|
- json << "{\"page\":" << page << ",\"page_size\":" << page_size;
|
|
|
- json << ",\"records\":[";
|
|
|
- bool first = true;
|
|
|
- for (const auto& rec : records) {
|
|
|
- if (!first) json << ",";
|
|
|
- first = false;
|
|
|
- json << "{";
|
|
|
- json << "\"id\":" << rec.id << ",";
|
|
|
- // ✅ fix24-v13: capture_time存在时用抓拍时刻格式化,否则用create_time
|
|
|
- {
|
|
|
- std::string display_time = rec.create_time;
|
|
|
- if (rec.capture_time > 0) {
|
|
|
- struct tm tm_buf;
|
|
|
- struct tm* tm_ptr = localtime_r(&rec.capture_time, &tm_buf);
|
|
|
- if (tm_ptr) {
|
|
|
- char buf[64];
|
|
|
- strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm_ptr);
|
|
|
- display_time = buf;
|
|
|
- }
|
|
|
- }
|
|
|
- json << "\"create_time\":\"" << escape_html(display_time) << "\",";
|
|
|
- }
|
|
|
- json << "\"capture_time\":" << rec.capture_time << ",";
|
|
|
- json << "\"plate_number\":\"" << escape_html(rec.plate_number) << "\",";
|
|
|
- json << "\"tb_num\":\"" << escape_html(rec.tb_num) << "\",";
|
|
|
- json << "\"station_type\":" << rec.station_type << ",";
|
|
|
- json << "\"lo_photo_path\":\"" << escape_html(rec.lo_photo_path) << "\",";
|
|
|
- json << "\"hi_photo_path\":\"" << escape_html(rec.hi_photo_path) << "\",";
|
|
|
- json << "\"lo_upload_status\":" << rec.lo_upload_status << ",";
|
|
|
- json << "\"hi_upload_status\":" << rec.hi_upload_status << ",";
|
|
|
- json << "\"feishu_status\":" << rec.feishu_status << ",";
|
|
|
- json << "\"retry_count\":" << rec.retry_count;
|
|
|
- json << "}";
|
|
|
- }
|
|
|
- json << "]}";
|
|
|
- return json.str();
|
|
|
-}
|
|
|
-
|
|
|
-bool is_safe_file_path(const std::string& base_dir, const std::string& user_path, std::string& real_path) {
|
|
|
- if (user_path.find("..") != std::string::npos || user_path.find("/") != std::string::npos || user_path.find("\\") != std::string::npos) {
|
|
|
- std::cerr << "[路径校验] 非法字符: " << user_path << std::endl;
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- std::string full_path = base_dir + "/" + user_path;
|
|
|
-
|
|
|
- char abs_path[PATH_MAX];
|
|
|
- if (realpath(full_path.c_str(), abs_path) == nullptr) {
|
|
|
- std::cerr << "[路径校验] 转换绝对路径失败: " << full_path << " - " << strerror(errno) << std::endl;
|
|
|
- return false;
|
|
|
- }
|
|
|
- real_path = abs_path;
|
|
|
-
|
|
|
- std::string base_abs_path;
|
|
|
- if (realpath(base_dir.c_str(), abs_path) == nullptr) {
|
|
|
- std::cerr << "[路径校验] 基础目录不存在: " << base_dir << std::endl;
|
|
|
- return false;
|
|
|
- }
|
|
|
- base_abs_path = abs_path;
|
|
|
- if (base_abs_path.back() != '/') base_abs_path += '/';
|
|
|
-
|
|
|
- if (real_path.find(base_abs_path) != 0) {
|
|
|
- std::cerr << "[路径校验] 非法路径: " << user_path << " (尝试访问基础目录外的文件)" << std::endl;
|
|
|
- return false;
|
|
|
- }
|
|
|
-
|
|
|
- return true;
|
|
|
-}
|
|
|
-
|
|
|
-// fix22: 前向声明
|
|
|
-static std::string generate_config_page();
|
|
|
-static std::string generate_locks_page();
|
|
|
-
|
|
|
-void start_web_server() {
|
|
|
- if (g_web_server_running.load()) return;
|
|
|
- g_web_server_running = true;
|
|
|
- g_metrics.start_time = time(NULL);
|
|
|
-
|
|
|
- // ✅ fix24-v12: 根据SSL配置创建HTTP或HTTPS服务器
|
|
|
- if (g_ssl_enabled && !g_ssl_cert_path.empty() && !g_ssl_key_path.empty()) {
|
|
|
- // 验证证书和私钥文件是否存在
|
|
|
- struct stat st;
|
|
|
- if (stat(g_ssl_cert_path.c_str(), &st) != 0) {
|
|
|
- std::cerr << "[SSL] 证书文件不存在: " << g_ssl_cert_path << ",回退到HTTP模式" << std::endl;
|
|
|
- g_web_server = std::make_unique<httplib::Server>();
|
|
|
- } else if (stat(g_ssl_key_path.c_str(), &st) != 0) {
|
|
|
- std::cerr << "[SSL] 私钥文件不存在: " << g_ssl_key_path << ",回退到HTTP模式" << std::endl;
|
|
|
- g_web_server = std::make_unique<httplib::Server>();
|
|
|
- } else {
|
|
|
- // ✅ fix24-v16: SSL性能优化 - 使用自定义ctx_setup回调,启用会话缓存和快速密码套件
|
|
|
- auto ssl_setup = [](void* ctx) -> bool {
|
|
|
- SSL_CTX* ssl_ctx = static_cast<SSL_CTX*>(ctx);
|
|
|
- // 启用SSL会话缓存,减少重复握手开销
|
|
|
- SSL_CTX_set_session_cache_mode(ssl_ctx, SSL_SESS_CACHE_SERVER);
|
|
|
- SSL_CTX_set_timeout(ssl_ctx, 600); // 会话缓存10分钟
|
|
|
- // 优先使用ECDHE+AESGCM和ChaCha20(AESGCM适合x86,ChaCha20适合ARM/无AES-NI)
|
|
|
- SSL_CTX_set_cipher_list(ssl_ctx,
|
|
|
- "ECDHE-ECDSA-AES128-GCM-SHA256:"
|
|
|
- "ECDHE-RSA-AES128-GCM-SHA256:"
|
|
|
- "ECDHE-ECDSA-CHACHA20-POLY1305:"
|
|
|
- "ECDHE-RSA-CHACHA20-POLY1305:"
|
|
|
- "ECDHE-ECDSA-AES256-GCM-SHA384:"
|
|
|
- "ECDHE-RSA-AES256-GCM-SHA384:"
|
|
|
- "DHE-RSA-AES128-GCM-SHA256:"
|
|
|
- "DHE-RSA-AES256-GCM-SHA384");
|
|
|
- // 启用EC曲线自动选择(优先X25519,握手快)
|
|
|
- SSL_CTX_set_ecdh_auto(ssl_ctx, 1);
|
|
|
- return true;
|
|
|
- };
|
|
|
- auto ssl_server = std::make_unique<httplib::SSLServer>(ssl_setup);
|
|
|
- // 手动加载证书(因为用了callback构造器,需要单独设置证书)
|
|
|
- if (ssl_server->is_valid()) {
|
|
|
- // 使用update_certs_pem加载证书(需要从文件读取)
|
|
|
- std::ifstream cert_file(g_ssl_cert_path);
|
|
|
- std::ifstream key_file(g_ssl_key_path);
|
|
|
- if (cert_file.is_open() && key_file.is_open()) {
|
|
|
- std::string cert_pem((std::istreambuf_iterator<char>(cert_file)),
|
|
|
- std::istreambuf_iterator<char>());
|
|
|
- std::string key_pem((std::istreambuf_iterator<char>(key_file)),
|
|
|
- std::istreambuf_iterator<char>());
|
|
|
- if (ssl_server->update_certs_pem(cert_pem.c_str(), key_pem.c_str())) {
|
|
|
- std::cout << "[SSL] HTTPS服务器创建成功(已优化性能),证书: "
|
|
|
- << g_ssl_cert_path << " 私钥: " << g_ssl_key_path << std::endl;
|
|
|
- g_web_server = std::move(ssl_server);
|
|
|
- } else {
|
|
|
- std::cerr << "[SSL] 证书加载失败,回退到HTTP模式" << std::endl;
|
|
|
- g_web_server = std::make_unique<httplib::Server>();
|
|
|
- }
|
|
|
- } else {
|
|
|
- std::cerr << "[SSL] 证书文件读取失败,回退到HTTP模式" << std::endl;
|
|
|
- g_web_server = std::make_unique<httplib::Server>();
|
|
|
- }
|
|
|
- } else {
|
|
|
- std::cerr << "[SSL] SSL上下文创建失败,回退到HTTP模式" << std::endl;
|
|
|
- g_web_server = std::make_unique<httplib::Server>();
|
|
|
- }
|
|
|
- }
|
|
|
- } else {
|
|
|
- g_web_server = std::make_unique<httplib::Server>();
|
|
|
- }
|
|
|
- auto& svr = *g_web_server;
|
|
|
-
|
|
|
- svr.Get("/", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string html = generate_html_page();
|
|
|
- res.set_content(html, "text/html; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- svr.Get("/api/monitor", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string json = generate_monitor_report();
|
|
|
- res.set_content(json, "application/json; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- svr.Get("/api/station_locks", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::ostringstream json;
|
|
|
- json << "{\"locks\":[";
|
|
|
-
|
|
|
- std::lock_guard<std::mutex> lock(g_station_cache_mtx);
|
|
|
- time_t now = time(NULL);
|
|
|
- bool first = true;
|
|
|
-
|
|
|
- for (const auto& pair : g_plate_station_cache) {
|
|
|
- if (!first) json << ",";
|
|
|
- first = false;
|
|
|
-
|
|
|
- const PlateStationState& state = pair.second;
|
|
|
- time_t last_time = std::max(state.last_in_time, state.last_out_time);
|
|
|
- int elapsed = (last_time > 0) ? static_cast<int>(difftime(now, last_time)) : 0;
|
|
|
-
|
|
|
- std::string status;
|
|
|
- if (state.last_out_time == 0 && state.last_in_time > 0) {
|
|
|
- status = "只进不出";
|
|
|
- } else if (state.last_in_time == 0 && state.last_out_time > 0) {
|
|
|
- status = "只出不进";
|
|
|
- } else {
|
|
|
- status = "正常交替";
|
|
|
- }
|
|
|
-
|
|
|
- json << "{";
|
|
|
- json << "\"plate\":\"" << escape_html(pair.first) << "\",";
|
|
|
- json << "\"current_mode\":" << static_cast<int>(state.current_mode) << ",";
|
|
|
- json << "\"last_in_time\":" << state.last_in_time << ",";
|
|
|
- json << "\"last_out_time\":" << state.last_out_time << ",";
|
|
|
- json << "\"elapsed_seconds\":" << elapsed << ",";
|
|
|
- // ✅ fix24-v7: 计算并输出剩余等待时间
|
|
|
- {
|
|
|
- // 临时释放 station_cache_mtx(避免死锁),因为 get_remaining_wait_time 内部会获取锁
|
|
|
- // 但此处已在 station_cache_mtx 内,改为直接内联计算
|
|
|
- int remaining = -1; // -1 表示无法计算或非时间等待
|
|
|
- if (state.current_mode == StationMode::OUTBOUND_ALLOWED && state.last_in_time > 0) {
|
|
|
- int el = static_cast<int>(difftime(now, state.last_in_time));
|
|
|
- remaining = g_in_out_interval_sec - el;
|
|
|
- if (remaining < 0) remaining = 0;
|
|
|
- } else if (state.current_mode == StationMode::INBOUND_ALLOWED && state.last_out_time > 0) {
|
|
|
- int el = static_cast<int>(difftime(now, state.last_out_time));
|
|
|
- int total_wait = g_in_out_interval_sec + g_time_window_min * 60;
|
|
|
- remaining = total_wait - el;
|
|
|
- if (remaining < 0) remaining = 0;
|
|
|
- } else if (state.current_mode == StationMode::OUTBOUND_ALLOWED && state.last_in_time == 0) {
|
|
|
- remaining = -2; // 严格拦截:需先出站
|
|
|
- } else if (state.current_mode == StationMode::INBOUND_ALLOWED && state.last_out_time == 0) {
|
|
|
- remaining = -2; // 严格拦截:需先进站
|
|
|
- }
|
|
|
- json << "\"remaining_seconds\":" << remaining << ",";
|
|
|
- }
|
|
|
- json << "\"status\":\"" << status << "\"";
|
|
|
- json << "}";
|
|
|
- }
|
|
|
-
|
|
|
- json << "]}";
|
|
|
- res.set_content(json.str(), "application/json; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix24: 清除指定车牌的交替锁定记录
|
|
|
- svr.Post("/api/clear_lock", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string body = req.body;
|
|
|
- // 解析JSON: {"plate":"沪EQ3803"}
|
|
|
- std::string plate;
|
|
|
- size_t pos = body.find("\"plate\"");
|
|
|
- if (pos != std::string::npos) {
|
|
|
- size_t colon = body.find(":", pos);
|
|
|
- if (colon != std::string::npos) {
|
|
|
- size_t q1 = body.find("\"", colon + 1);
|
|
|
- if (q1 != std::string::npos) {
|
|
|
- size_t q2 = body.find("\"", q1 + 1);
|
|
|
- if (q2 != std::string::npos) {
|
|
|
- plate = body.substr(q1 + 1, q2 - q1 - 1);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (plate.empty()) {
|
|
|
- res.set_content("{\"ok\":false,\"error\":\"缺少plate参数\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- bool cleared = clear_station_lock(plate);
|
|
|
- if (cleared) {
|
|
|
- res.set_content("{\"ok\":true,\"message\":\"已清除 " + plate + " 的锁定记录\"}", "application/json");
|
|
|
- } else {
|
|
|
- res.set_content("{\"ok\":false,\"error\":\"" + plate + " 无锁定记录或交替锁定未启用\"}", "application/json");
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- svr.Get("/api/messages", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- int page = 1;
|
|
|
- std::string keyword;
|
|
|
- auto page_it = req.params.find("page");
|
|
|
- if (page_it != req.params.end()) {
|
|
|
- page = std::atoi(page_it->second.c_str());
|
|
|
- if (page < 1) page = 1;
|
|
|
- }
|
|
|
- auto kw_it = req.params.find("keyword");
|
|
|
- if (kw_it != req.params.end()) {
|
|
|
- keyword = kw_it->second;
|
|
|
- }
|
|
|
- std::string json = generate_message_list_json(page, keyword);
|
|
|
- res.set_content(json, "application/json; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- svr.Post("/api/retry", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string body = req.body;
|
|
|
- std::string id_str;
|
|
|
- size_t id_pos = body.find("id=");
|
|
|
- if (id_pos != std::string::npos) {
|
|
|
- id_pos += 3;
|
|
|
- size_t end_pos = body.find('&', id_pos);
|
|
|
- if (end_pos == std::string::npos) end_pos = body.size();
|
|
|
- id_str = body.substr(id_pos, end_pos - id_pos);
|
|
|
- }
|
|
|
- int id = std::atoi(id_str.c_str());
|
|
|
- // ✅ v43: 使用增强重试(重新获取联单编号+重传失败照片+更新DB)
|
|
|
- RetryResult result = enhanced_retry_record(id);
|
|
|
- g_metrics.record_web_retry(result.success);
|
|
|
- std::ostringstream json;
|
|
|
- json << "{\"success\":" << (result.success ? "true" : "false") << ",";
|
|
|
- json << "\"message\":\"" << escape_html(result.message) << "\"";
|
|
|
- if (!result.tb_num.empty()) {
|
|
|
- json << ",\"tb_num\":\"" << escape_html(result.tb_num) << "\"";
|
|
|
- }
|
|
|
- json << ",\"uploaded_photos\":" << result.uploaded_photos;
|
|
|
- json << "}";
|
|
|
- res.set_content(json.str(), "application/json; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix18修改:ROI快照接口支持4路(stream参数)+红框+兼容旧side参数
|
|
|
- svr.Get("/api/roi/snapshot", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- // 支持 stream 参数(新)和 side 参数(旧兼容)
|
|
|
- std::string stream = "in-low";
|
|
|
- auto stream_it = req.params.find("stream");
|
|
|
- auto side_it = req.params.find("side");
|
|
|
- if (stream_it != req.params.end()) {
|
|
|
- stream = stream_it->second;
|
|
|
- } else if (side_it != req.params.end()) {
|
|
|
- // 旧参数兼容:side=in → in-low, side=out → out-low
|
|
|
- stream = (side_it->second == "out") ? "out-low" : "in-low";
|
|
|
- }
|
|
|
-
|
|
|
- cv::Mat frame = get_frame_by_stream(stream);
|
|
|
- if (frame.empty()) {
|
|
|
- res.status = 404;
|
|
|
- // ✅ fix24-v10: 添加charset=utf-8,防止浏览器用GBK解码UTF-8中文导致乱码
|
|
|
- res.set_content("Camera frame unavailable", "text/plain; charset=utf-8");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 低位摄像头画ROI红框(fix18: 绿框→红框)
|
|
|
- const ROIConfig* roi = get_roi_by_stream(stream);
|
|
|
- if (roi && !roi->isFullFrame()) {
|
|
|
- cv::Rect roi_rect = roi->getRect(frame.cols, frame.rows);
|
|
|
- cv::rectangle(frame, roi_rect, cv::Scalar(0, 0, 255), 3);
|
|
|
- cv::putText(frame, "ROI", cv::Point(roi_rect.x + 5, roi_rect.y + 25),
|
|
|
- cv::FONT_HERSHEY_SIMPLEX, 0.8, cv::Scalar(0, 0, 255), 2);
|
|
|
- }
|
|
|
-
|
|
|
- // ✅ fix24-v10: 叠加英文标签(cv::putText不支持中文)
|
|
|
- std::string label = get_stream_label(stream);
|
|
|
- cv::putText(frame, label, cv::Point(10, 30),
|
|
|
- cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255, 255, 255), 2);
|
|
|
-
|
|
|
- std::vector<uchar> buf;
|
|
|
- cv::imencode(".jpg", frame, buf, {cv::IMWRITE_JPEG_QUALITY, 80});
|
|
|
- res.set_content(std::string(buf.begin(), buf.end()), "image/jpeg");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix18修改:ROI配置查询接口,增加4路摄像头分辨率
|
|
|
- svr.Get("/api/roi/config", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- // 获取4路摄像头实际分辨率
|
|
|
- int in_low_w = 0, in_low_h = 0, in_high_w = 0, in_high_h = 0;
|
|
|
- int out_low_w = 0, out_low_h = 0, out_high_w = 0, out_high_h = 0;
|
|
|
-
|
|
|
- cv::Mat frame;
|
|
|
- frame = plate_rec_app.capture_front_in->getFrame();
|
|
|
- if (!frame.empty()) { in_low_w = frame.cols; in_low_h = frame.rows; }
|
|
|
- frame = plate_rec_app.capture_side_in->getFrame();
|
|
|
- if (!frame.empty()) { in_high_w = frame.cols; in_high_h = frame.rows; }
|
|
|
- // ✅ fix24-v9: 共享模式下复用进站分辨率作为出站分辨率
|
|
|
- if (plate_rec_app.capture_front_out) {
|
|
|
- frame = plate_rec_app.capture_front_out->getFrame();
|
|
|
- if (!frame.empty()) { out_low_w = frame.cols; out_low_h = frame.rows; }
|
|
|
- } else if (plate_rec_app.capture_front_in) {
|
|
|
- frame = plate_rec_app.capture_front_in->getFrame();
|
|
|
- if (!frame.empty()) { out_low_w = frame.cols; out_low_h = frame.rows; }
|
|
|
- }
|
|
|
- frame = plate_rec_app.capture_side_out->getFrame();
|
|
|
- if (!frame.empty()) { out_high_w = frame.cols; out_high_h = frame.rows; }
|
|
|
-
|
|
|
- std::ostringstream json;
|
|
|
- json << "{";
|
|
|
- json << "\"roi_in\":{\"x\":" << g_roi_in.x << ",\"y\":" << g_roi_in.y
|
|
|
- << ",\"w\":" << g_roi_in.width << ",\"h\":" << g_roi_in.height
|
|
|
- << ",\"enabled\":" << (g_roi_in.enabled ? "true" : "false") << "},";
|
|
|
- json << "\"roi_out\":{\"x\":" << g_roi_out.x << ",\"y\":" << g_roi_out.y
|
|
|
- << ",\"w\":" << g_roi_out.width << ",\"h\":" << g_roi_out.height
|
|
|
- << ",\"enabled\":" << (g_roi_out.enabled ? "true" : "false") << "},";
|
|
|
- json << "\"roi_debug_enabled\":" << (g_roi_debug_enabled ? "true" : "false") << ",";
|
|
|
- // fix18新增:4路摄像头分辨率,供前端ROI绘制坐标换算
|
|
|
- json << "\"resolution\":{";
|
|
|
- json << "\"in-low\":{\"width\":" << in_low_w << ",\"height\":" << in_low_h << "},";
|
|
|
- json << "\"in-high\":{\"width\":" << in_high_w << ",\"height\":" << in_high_h << "},";
|
|
|
- json << "\"out-low\":{\"width\":" << out_low_w << ",\"height\":" << out_low_h << "},";
|
|
|
- json << "\"out-high\":{\"width\":" << out_high_w << ",\"height\":" << out_high_h << "}";
|
|
|
- json << "},";
|
|
|
- json << "\"shared_mode\":" << (g_shared_capture_mode ? "true" : "false");
|
|
|
- json << "}";
|
|
|
- res.set_content(json.str(), "application/json; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix18新增:单帧JPEG快照(绘制ROI时使用静态帧)
|
|
|
- svr.Get("/api/stream", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string stream = "in-low";
|
|
|
- auto it = req.params.find("stream");
|
|
|
- if (it != req.params.end()) stream = it->second;
|
|
|
-
|
|
|
- cv::Mat frame = get_frame_by_stream(stream);
|
|
|
- if (frame.empty()) {
|
|
|
- res.status = 503;
|
|
|
- // ✅ fix24-v10: 添加charset=utf-8 + 改英文,防止中文乱码
|
|
|
- res.set_content("Camera frame unavailable", "text/plain; charset=utf-8");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 低位摄像头叠加ROI红色边框(仅Web预览,不影响保存照片)
|
|
|
- const ROIConfig* roi = get_roi_by_stream(stream);
|
|
|
- if (roi && roi->enabled && !roi->isFullFrame()) {
|
|
|
- cv::Rect roi_rect = roi->getRect(frame.cols, frame.rows);
|
|
|
- cv::rectangle(frame, roi_rect, cv::Scalar(0, 0, 255), 3);
|
|
|
- cv::putText(frame, "ROI", cv::Point(roi_rect.x + 5, roi_rect.y + 25),
|
|
|
- cv::FONT_HERSHEY_SIMPLEX, 0.8, cv::Scalar(0, 0, 255), 2);
|
|
|
- }
|
|
|
-
|
|
|
- // ✅ fix24-v10: 叠加英文标签(cv::putText不支持中文)
|
|
|
- std::string label = get_stream_label(stream);
|
|
|
- cv::putText(frame, label, cv::Point(10, 30),
|
|
|
- cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255, 255, 255), 2);
|
|
|
-
|
|
|
- std::vector<uchar> buf;
|
|
|
- cv::imencode(".jpg", frame, buf, {cv::IMWRITE_JPEG_QUALITY, 80});
|
|
|
- res.set_content(std::string(buf.begin(), buf.end()), "image/jpeg");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix18新增:MJPEG实时视频流(浏览器<img>标签原生支持)
|
|
|
- svr.Get("/api/mjpeg", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string stream = "in-low";
|
|
|
- auto it = req.params.find("stream");
|
|
|
- if (it != req.params.end()) stream = it->second;
|
|
|
-
|
|
|
- res.set_chunked_content_provider(
|
|
|
- "multipart/x-mixed-replace; boundary=frame",
|
|
|
- [stream](size_t offset, httplib::DataSink& sink) -> bool {
|
|
|
- cv::Mat frame = get_frame_by_stream(stream);
|
|
|
- if (frame.empty()) {
|
|
|
- std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
|
|
- return true; // 继续等待下一帧
|
|
|
- }
|
|
|
-
|
|
|
- // 低位摄像头叠加ROI红色边框(仅Web预览,不影响保存照片)
|
|
|
- const ROIConfig* roi = get_roi_by_stream(stream);
|
|
|
- if (roi && roi->enabled && !roi->isFullFrame()) {
|
|
|
- cv::Rect roi_rect = roi->getRect(frame.cols, frame.rows);
|
|
|
- cv::rectangle(frame, roi_rect, cv::Scalar(0, 0, 255), 3);
|
|
|
- cv::putText(frame, "ROI", cv::Point(roi_rect.x + 5, roi_rect.y + 25),
|
|
|
- cv::FONT_HERSHEY_SIMPLEX, 0.8, cv::Scalar(0, 0, 255), 2);
|
|
|
- }
|
|
|
-
|
|
|
- // ✅ fix24-v10: 叠加英文标签(cv::putText不支持中文)
|
|
|
- std::string label = get_stream_label(stream);
|
|
|
- cv::putText(frame, label, cv::Point(10, 30),
|
|
|
- cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255, 255, 255), 2);
|
|
|
-
|
|
|
- // JPEG编码
|
|
|
- std::vector<uchar> buf;
|
|
|
- cv::imencode(".jpg", frame, buf, {cv::IMWRITE_JPEG_QUALITY, 70});
|
|
|
-
|
|
|
- // MJPEG帧格式
|
|
|
- std::string header = "--frame\r\nContent-Type: image/jpeg\r\n\r\n";
|
|
|
- sink.write(header.c_str(), header.size());
|
|
|
- sink.write(reinterpret_cast<const char*>(buf.data()), buf.size());
|
|
|
- sink.write("\r\n", 2);
|
|
|
-
|
|
|
- // 控制帧率:~5 FPS
|
|
|
- std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
|
|
- return true; // 返回true继续发送
|
|
|
- }
|
|
|
- );
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix18新增:ROI配置更新接口(含写回config.ini持久化)
|
|
|
- svr.Post("/api/roi/update", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string body = req.body;
|
|
|
-
|
|
|
- cJSON* root = cJSON_Parse(body.c_str());
|
|
|
- if (!root) {
|
|
|
- res.set_content("{\"success\":false,\"message\":\"JSON解析失败\"}",
|
|
|
- "application/json; charset=utf-8");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 解析参数
|
|
|
- std::string side = "in";
|
|
|
- int x = 0, y = 0, w = 0, h = 0;
|
|
|
- bool enabled = false;
|
|
|
-
|
|
|
- cJSON* j_side = cJSON_GetObjectItem(root, "side");
|
|
|
- cJSON* j_x = cJSON_GetObjectItem(root, "x");
|
|
|
- cJSON* j_y = cJSON_GetObjectItem(root, "y");
|
|
|
- cJSON* j_w = cJSON_GetObjectItem(root, "w");
|
|
|
- cJSON* j_h = cJSON_GetObjectItem(root, "h");
|
|
|
- cJSON* j_enabled = cJSON_GetObjectItem(root, "enabled");
|
|
|
-
|
|
|
- if (j_side && j_side->valuestring) side = j_side->valuestring;
|
|
|
- if (j_x) x = j_x->valueint;
|
|
|
- if (j_y) y = j_y->valueint;
|
|
|
- if (j_w) w = j_w->valueint;
|
|
|
- if (j_h) h = j_h->valueint;
|
|
|
- if (j_enabled) enabled = (j_enabled->type == cJSON_True || j_enabled->valueint == 1);
|
|
|
-
|
|
|
- // 坐标校验
|
|
|
- if (x < 0 || y < 0 || w < 0 || h < 0) {
|
|
|
- cJSON_Delete(root);
|
|
|
- res.set_content("{\"success\":false,\"message\":\"ROI坐标不能为负数\"}",
|
|
|
- "application/json; charset=utf-8");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- // 更新全局变量
|
|
|
- ROIConfig& roi = (side == "in") ? g_roi_in : g_roi_out;
|
|
|
- roi.x = x;
|
|
|
- roi.y = y;
|
|
|
- roi.width = w;
|
|
|
- roi.height = h;
|
|
|
- roi.enabled = enabled;
|
|
|
-
|
|
|
- // 写回config.ini持久化
|
|
|
- bool save_ok = save_roi_to_config(g_config_ini_path, side, x, y, w, h, enabled);
|
|
|
-
|
|
|
- cJSON_Delete(root);
|
|
|
-
|
|
|
- std::ostringstream json;
|
|
|
- json << "{\"success\":true,\"message\":\"ROI已更新"
|
|
|
- << (save_ok ? "并保存到config.ini" : "(config.ini保存失败,运行时已生效)")
|
|
|
- << "\"}";
|
|
|
- res.set_content(json.str(), "application/json; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix18新增:4路独立视频页面
|
|
|
- svr.Get("/video", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string html = generate_video_page();
|
|
|
- res.set_content(html, "text/html; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix24-v9新增:称重管理页面
|
|
|
- svr.Get("/weight", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string html_path = pathComm + "assets/web/weight.html";
|
|
|
- std::ifstream ifs(html_path);
|
|
|
- if (!ifs.is_open()) {
|
|
|
- res.status = 404;
|
|
|
- res.set_content("称重页面不存在", "text/plain; charset=utf-8");
|
|
|
- return;
|
|
|
- }
|
|
|
- std::string html((std::istreambuf_iterator<char>(ifs)), std::istreambuf_iterator<char>());
|
|
|
- res.set_content(html, "text/html; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix24-v9新增:称重记录查询API
|
|
|
- svr.Get("/api/weight/records", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- int page = 1, page_size = 20;
|
|
|
- std::string keyword;
|
|
|
- auto it = req.params.find("page");
|
|
|
- if (it != req.params.end()) page = std::atoi(it->second.c_str());
|
|
|
- it = req.params.find("size");
|
|
|
- if (it != req.params.end()) page_size = std::atoi(it->second.c_str());
|
|
|
- it = req.params.find("keyword");
|
|
|
- if (it != req.params.end()) keyword = it->second;
|
|
|
-
|
|
|
- auto records = db_get_weight_records(page, page_size, keyword);
|
|
|
- int total = db_get_weight_total_count();
|
|
|
- int failed = db_get_weight_failed_count();
|
|
|
-
|
|
|
- std::ostringstream json;
|
|
|
- json << "{\"page\":" << page << ",\"page_size\":" << page_size
|
|
|
- << ",\"total\":" << total << ",\"failed\":" << failed
|
|
|
- << ",\"records\":[";
|
|
|
- for (size_t i = 0; i < records.size(); i++) {
|
|
|
- const auto& rec = records[i];
|
|
|
- if (i > 0) json << ",";
|
|
|
- double weight = 0;
|
|
|
- try { weight = std::stod(rec.hi_photo_path); } catch (...) {}
|
|
|
- json << "{\"id\":" << rec.id
|
|
|
- << ",\"create_time\":\"" << escape_html(rec.create_time) << "\""
|
|
|
- << ",\"plate_number\":\"" << escape_html(rec.plate_number) << "\""
|
|
|
- << ",\"tb_num\":\"" << escape_html(rec.tb_num) << "\""
|
|
|
- << ",\"station_type\":" << rec.station_type
|
|
|
- << ",\"weight_kg\":" << weight
|
|
|
- << ",\"upload_status\":" << rec.lo_upload_status
|
|
|
- << ",\"retry_count\":" << rec.hi_upload_status
|
|
|
- << ",\"response_msg\":\"" << escape_html(rec.lo_photo_path) << "\"}";
|
|
|
- }
|
|
|
- json << "]}";
|
|
|
- res.set_content(json.str(), "application/json; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix24-v9新增:称重手动重试API
|
|
|
- svr.Post("/api/weight/retry", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- std::string body = req.body;
|
|
|
- cJSON* root = cJSON_Parse(body.c_str());
|
|
|
- if (!root) {
|
|
|
- // 尝试解析 form-urlencoded
|
|
|
- auto it = req.params.find("id");
|
|
|
- if (it == req.params.end()) {
|
|
|
- res.status = 400;
|
|
|
- res.set_content("{\"success\":false,\"message\":\"缺少id参数\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- int id = std::atoi(it->second.c_str());
|
|
|
- if (id <= 0) {
|
|
|
- res.status = 400;
|
|
|
- res.set_content("{\"success\":false,\"message\":\"无效的id\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- bool ok = weight_manual_retry(id);
|
|
|
- std::ostringstream json;
|
|
|
- json << "{\"success\":" << (ok ? "true" : "false") << ",\"message\":\"" << (ok ? "重试成功" : "重试失败") << "\"}";
|
|
|
- res.set_content(json.str(), "application/json; charset=utf-8");
|
|
|
- return;
|
|
|
- }
|
|
|
- cJSON* j_id = cJSON_GetObjectItem(root, "id");
|
|
|
- if (!j_id || !cJSON_IsNumber(j_id)) {
|
|
|
- cJSON_Delete(root);
|
|
|
- res.status = 400;
|
|
|
- res.set_content("{\"success\":false,\"message\":\"缺少id参数\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- int id = j_id->valueint;
|
|
|
- cJSON_Delete(root);
|
|
|
- if (id <= 0) {
|
|
|
- res.status = 400;
|
|
|
- res.set_content("{\"success\":false,\"message\":\"无效的id\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- bool ok = weight_manual_retry(id);
|
|
|
- std::ostringstream json;
|
|
|
- json << "{\"success\":" << (ok ? "true" : "false") << ",\"message\":\"" << (ok ? "重试成功" : "重试失败") << "\"}";
|
|
|
- res.set_content(json.str(), "application/json; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- svr.Get("/photo", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- auto path_it = req.params.find("path");
|
|
|
- if (path_it == req.params.end() || path_it->second.empty()) {
|
|
|
- res.status = 400;
|
|
|
- res.set_content("参数错误: path不能为空", "text/plain; charset=utf-8");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- std::string base_dir = pathComm + "PlateJPG";
|
|
|
- std::string safe_path;
|
|
|
- if (!is_safe_file_path(base_dir, path_it->second, safe_path)) {
|
|
|
- res.status = 403;
|
|
|
- res.set_content("访问拒绝: 非法路径", "text/plain; charset=utf-8");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- FILE* fp = fopen(safe_path.c_str(), "rb");
|
|
|
- if (!fp) {
|
|
|
- res.status = 404;
|
|
|
- res.set_content("文件不存在", "text/plain; charset=utf-8");
|
|
|
- return;
|
|
|
- }
|
|
|
-
|
|
|
- fseek(fp, 0, SEEK_END);
|
|
|
- long file_size = ftell(fp);
|
|
|
- fseek(fp, 0, SEEK_SET);
|
|
|
- std::vector<char> buffer(file_size);
|
|
|
- fread(buffer.data(), 1, file_size, fp);
|
|
|
- fclose(fp);
|
|
|
-
|
|
|
- res.set_content(buffer.data(), file_size, "image/jpeg");
|
|
|
- });
|
|
|
-
|
|
|
- // ==================== fix21: 配置页面与API ====================
|
|
|
- svr.Get("/config", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- res.set_content(generate_config_page(), "text/html; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix24: 锁定管理页面
|
|
|
- svr.Get("/locks", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- res.set_content(generate_locks_page(), "text/html; charset=utf-8");
|
|
|
- });
|
|
|
-
|
|
|
- svr.Get("/api/config", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- if (g_config_ini_path.empty()) {
|
|
|
- res.status = 500;
|
|
|
- res.set_content("{\"error\":\"config.ini路径未设置\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- std::ifstream ifs(g_config_ini_path);
|
|
|
- if (!ifs.is_open()) {
|
|
|
- res.status = 500;
|
|
|
- res.set_content("{\"error\":\"无法打开config.ini\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- // 解析INI为JSON
|
|
|
- cJSON* root = cJSON_CreateObject();
|
|
|
- std::string current_section;
|
|
|
- std::string line;
|
|
|
- while (std::getline(ifs, line)) {
|
|
|
- // 去除首尾空白
|
|
|
- size_t start = line.find_first_not_of(" \t\r\n");
|
|
|
- if (start == std::string::npos) continue;
|
|
|
- line = line.substr(start);
|
|
|
- size_t end = line.find_last_not_of(" \t\r\n");
|
|
|
- if (end != std::string::npos) line = line.substr(0, end + 1);
|
|
|
- if (line.empty() || line[0] == '#' || line[0] == ';') continue;
|
|
|
- if (line[0] == '[') {
|
|
|
- size_t rb = line.find(']');
|
|
|
- if (rb != std::string::npos) current_section = line.substr(1, rb - 1);
|
|
|
- continue;
|
|
|
- }
|
|
|
- size_t eq = line.find('=');
|
|
|
- if (eq == std::string::npos) continue;
|
|
|
- std::string key = line.substr(0, eq);
|
|
|
- std::string value = line.substr(eq + 1);
|
|
|
- // 去除key和value的空白
|
|
|
- auto trim = [](std::string& s) {
|
|
|
- size_t a = s.find_first_not_of(" \t");
|
|
|
- size_t b = s.find_last_not_of(" \t");
|
|
|
- if (a == std::string::npos) { s = ""; return; }
|
|
|
- s = s.substr(a, b - a + 1);
|
|
|
- };
|
|
|
- trim(key); trim(value);
|
|
|
- // 去掉注释
|
|
|
- for (char c : {'#', ';'}) {
|
|
|
- size_t pos = value.find(c);
|
|
|
- if (pos != std::string::npos) {
|
|
|
- value = value.substr(0, pos);
|
|
|
- trim(value);
|
|
|
- }
|
|
|
- }
|
|
|
- std::string sec = current_section.empty() ? "global" : current_section;
|
|
|
- cJSON* sec_obj = cJSON_GetObjectItem(root, sec.c_str());
|
|
|
- if (!sec_obj) {
|
|
|
- sec_obj = cJSON_CreateObject();
|
|
|
- cJSON_AddItemToObject(root, sec.c_str(), sec_obj);
|
|
|
- }
|
|
|
- cJSON_AddStringToObject(sec_obj, key.c_str(), value.c_str());
|
|
|
- }
|
|
|
- char* json_str = cJSON_PrintUnformatted(root);
|
|
|
- res.set_content(json_str, "application/json; charset=utf-8");
|
|
|
- cJSON_free(json_str);
|
|
|
- cJSON_Delete(root);
|
|
|
- });
|
|
|
-
|
|
|
- svr.Post("/api/config", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
- if (g_config_ini_path.empty()) {
|
|
|
- res.status = 500;
|
|
|
- res.set_content("{\"ok\":false,\"error\":\"config.ini路径未设置\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- cJSON* body = cJSON_Parse(req.body.c_str());
|
|
|
- if (!body) {
|
|
|
- res.status = 400;
|
|
|
- res.set_content("{\"ok\":false,\"error\":\"JSON格式错误\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- std::ifstream ifs(g_config_ini_path);
|
|
|
- if (!ifs.is_open()) {
|
|
|
- cJSON_Delete(body);
|
|
|
- res.status = 500;
|
|
|
- res.set_content("{\"ok\":false,\"error\":\"无法打开config.ini\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- std::vector<std::string> lines;
|
|
|
- std::string line;
|
|
|
- while (std::getline(ifs, line)) lines.push_back(line);
|
|
|
- ifs.close();
|
|
|
-
|
|
|
- // Build updates map, and extract plates list from PlateRec section
|
|
|
- std::map<std::string, std::string> updates;
|
|
|
- std::vector<std::string> new_plates;
|
|
|
- bool has_plates_textarea = false;
|
|
|
- cJSON* sec = body->child;
|
|
|
- while (sec) {
|
|
|
- std::string sec_name = sec->string ? sec->string : "global";
|
|
|
- cJSON* kv = sec->child;
|
|
|
- while (kv) {
|
|
|
- std::string key = kv->string ? kv->string : "";
|
|
|
- std::string value = cJSON_IsString(kv) ? kv->valuestring : std::to_string(kv->valueint);
|
|
|
- // Handle __plates__ textarea for PlateRec section
|
|
|
- if (sec_name == "PlateRec" && key == "__plates__") {
|
|
|
- has_plates_textarea = true;
|
|
|
- // Parse newline-separated plate list
|
|
|
- std::istringstream ss(value);
|
|
|
- std::string plate_line;
|
|
|
- while (std::getline(ss, plate_line)) {
|
|
|
- // Trim whitespace
|
|
|
- size_t a = plate_line.find_first_not_of(" \t\r\n");
|
|
|
- size_t b = plate_line.find_last_not_of(" \t\r\n");
|
|
|
- if (a != std::string::npos) {
|
|
|
- new_plates.push_back(plate_line.substr(a, b - a + 1));
|
|
|
- }
|
|
|
- }
|
|
|
- } else if (!(sec_name == "PlateRec" && key.rfind("car[", 0) == 0)) {
|
|
|
- // Skip old car[*] entries from frontend (will be regenerated)
|
|
|
- updates[sec_name + "|" + key] = value;
|
|
|
- }
|
|
|
- kv = kv->next;
|
|
|
- }
|
|
|
- sec = sec->next;
|
|
|
- }
|
|
|
-
|
|
|
- // If we have new plates, update Total and add car entries
|
|
|
- if (has_plates_textarea) {
|
|
|
- updates["PlateRec|Total"] = std::to_string(new_plates.size());
|
|
|
- for (size_t i = 0; i < new_plates.size(); i++) {
|
|
|
- updates["PlateRec|car[" + std::to_string(i) + "]"] = new_plates[i];
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Phase 1: Find and mark car[*] lines in PlateRec section for removal
|
|
|
- std::string current_section;
|
|
|
- std::set<std::string> applied;
|
|
|
- std::set<int> car_lines_to_remove;
|
|
|
- int total_line_in_platerec = -1;
|
|
|
- int last_platerec_content_line = -1;
|
|
|
-
|
|
|
- for (int i = 0; i < (int)lines.size(); i++) {
|
|
|
- std::string trimmed = lines[i];
|
|
|
- size_t s = trimmed.find_first_not_of(" \t\r\n");
|
|
|
- if (s == std::string::npos) continue;
|
|
|
- trimmed = trimmed.substr(s);
|
|
|
- size_t e = trimmed.find_last_not_of(" \t\r\n");
|
|
|
- if (e != std::string::npos) trimmed = trimmed.substr(0, e + 1);
|
|
|
- if (trimmed.empty() || trimmed[0] == '#' || trimmed[0] == ';') continue;
|
|
|
- if (trimmed[0] == '[') {
|
|
|
- size_t rb = trimmed.find(']');
|
|
|
- if (rb != std::string::npos) current_section = trimmed.substr(1, rb - 1);
|
|
|
- continue;
|
|
|
- }
|
|
|
- if (current_section == "PlateRec") {
|
|
|
- size_t eq = trimmed.find('=');
|
|
|
- if (eq != std::string::npos) {
|
|
|
- std::string key = trimmed.substr(0, eq);
|
|
|
- size_t ks2 = key.find_first_not_of(" \t");
|
|
|
- size_t ke2 = key.find_last_not_of(" \t");
|
|
|
- if (ks2 != std::string::npos) key = key.substr(ks2, ke2 - ks2 + 1);
|
|
|
- if (key.rfind("car[", 0) == 0) {
|
|
|
- car_lines_to_remove.insert(i);
|
|
|
- }
|
|
|
- if (key == "Total") {
|
|
|
- total_line_in_platerec = i;
|
|
|
- }
|
|
|
- last_platerec_content_line = i;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Phase 2: Remove old car lines (reverse order to preserve indices)
|
|
|
- for (auto it = car_lines_to_remove.rbegin(); it != car_lines_to_remove.rend(); ++it) {
|
|
|
- lines.erase(lines.begin() + *it);
|
|
|
- // Adjust indices
|
|
|
- if (total_line_in_platerec > *it) total_line_in_platerec--;
|
|
|
- if (last_platerec_content_line > *it) last_platerec_content_line--;
|
|
|
- }
|
|
|
-
|
|
|
- // Phase 3: Update existing keys and track what's applied
|
|
|
- current_section = "";
|
|
|
- for (auto& l : lines) {
|
|
|
- std::string trimmed = l;
|
|
|
- size_t s = trimmed.find_first_not_of(" \t\r\n");
|
|
|
- if (s != std::string::npos) trimmed = trimmed.substr(s);
|
|
|
- size_t e = trimmed.find_last_not_of(" \t\r\n");
|
|
|
- if (e != std::string::npos) trimmed = trimmed.substr(0, e + 1);
|
|
|
- if (trimmed.empty() || trimmed[0] == '#' || trimmed[0] == ';') continue;
|
|
|
- if (trimmed[0] == '[') {
|
|
|
- size_t rb = trimmed.find(']');
|
|
|
- if (rb != std::string::npos) current_section = trimmed.substr(1, rb - 1);
|
|
|
- continue;
|
|
|
- }
|
|
|
- size_t eq = trimmed.find('=');
|
|
|
- if (eq == std::string::npos) continue;
|
|
|
- std::string key = trimmed.substr(0, eq);
|
|
|
- size_t ks2 = key.find_first_not_of(" \t");
|
|
|
- size_t ke2 = key.find_last_not_of(" \t");
|
|
|
- if (ks2 != std::string::npos) key = key.substr(ks2, ke2 - ks2 + 1);
|
|
|
- std::string lookup = current_section + "|" + key;
|
|
|
- auto it = updates.find(lookup);
|
|
|
- if (it != updates.end()) {
|
|
|
- size_t orig_eq = l.find('=');
|
|
|
- if (orig_eq != std::string::npos) {
|
|
|
- std::string prefix = l.substr(0, orig_eq + 1);
|
|
|
- l = prefix + " " + it->second;
|
|
|
- applied.insert(lookup);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Phase 4: Add new car entries and Total if they were new
|
|
|
- std::vector<std::string> new_lines_to_add;
|
|
|
- for (auto& [lookup, value] : updates) {
|
|
|
- if (applied.find(lookup) == applied.end()) {
|
|
|
- // This key wasn't found in existing file, need to add it
|
|
|
- size_t sep = lookup.find('|');
|
|
|
- std::string sec_name = lookup.substr(0, sep);
|
|
|
- std::string key_name = lookup.substr(sep + 1);
|
|
|
- if (sec_name == "PlateRec" && (key_name.rfind("car[", 0) == 0 || key_name == "Total")) {
|
|
|
- new_lines_to_add.push_back(key_name + "=" + value);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Insert new car lines after the last PlateRec content line (or after [PlateRec] header)
|
|
|
- if (!new_lines_to_add.empty()) {
|
|
|
- // Find the [PlateRec] section header line in current (modified) lines
|
|
|
- int insert_pos = -1;
|
|
|
- int platerec_end = (int)lines.size();
|
|
|
- bool in_platerec = false;
|
|
|
- for (int i = 0; i < (int)lines.size(); i++) {
|
|
|
- std::string trimmed = lines[i];
|
|
|
- size_t s = trimmed.find_first_not_of(" \t\r\n");
|
|
|
- if (s != std::string::npos) trimmed = trimmed.substr(s);
|
|
|
- size_t e = trimmed.find_last_not_of(" \t\r\n");
|
|
|
- if (e != std::string::npos) trimmed = trimmed.substr(0, e + 1);
|
|
|
- if (trimmed.empty() || trimmed[0] == '#' || trimmed[0] == ';') continue;
|
|
|
- if (trimmed[0] == '[') {
|
|
|
- if (in_platerec) {
|
|
|
- platerec_end = i;
|
|
|
- break;
|
|
|
- }
|
|
|
- size_t rb = trimmed.find(']');
|
|
|
- if (rb != std::string::npos) {
|
|
|
- std::string sec = trimmed.substr(1, rb - 1);
|
|
|
- if (sec == "PlateRec") {
|
|
|
- in_platerec = true;
|
|
|
- insert_pos = i + 1;
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
- if (in_platerec && insert_pos >= 0) {
|
|
|
- // Insert at the end of PlateRec section (before next section or EOF)
|
|
|
- for (int i = insert_pos; i < (int)lines.size(); i++) {
|
|
|
- std::string trimmed = lines[i];
|
|
|
- size_t s = trimmed.find_first_not_of(" \t\r\n");
|
|
|
- if (s != std::string::npos) trimmed = trimmed.substr(s);
|
|
|
- if (!trimmed.empty() && trimmed[0] == '[') {
|
|
|
- platerec_end = i;
|
|
|
- break;
|
|
|
- }
|
|
|
- }
|
|
|
- // Insert before platerec_end
|
|
|
- for (int i = 0; i < (int)new_lines_to_add.size(); i++) {
|
|
|
- lines.insert(lines.begin() + platerec_end + i, new_lines_to_add[i]);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // Write file
|
|
|
- std::ofstream ofs(g_config_ini_path);
|
|
|
- if (!ofs.is_open()) {
|
|
|
- res.status = 500;
|
|
|
- res.set_content("{\"ok\":false,\"error\":\"无法写入config.ini\"}", "application/json");
|
|
|
- return;
|
|
|
- }
|
|
|
- for (const auto& l : lines) ofs << l << "\n";
|
|
|
- ofs.close();
|
|
|
- res.set_content("{\"ok\":true,\"updated\":" + std::to_string(applied.size() + new_lines_to_add.size()) + "}", "application/json");
|
|
|
- });
|
|
|
-
|
|
|
-
|
|
|
- g_web_server_thread = std::make_unique<std::thread>([&svr]() {
|
|
|
- const char* proto = g_ssl_enabled ? "https" : "http";
|
|
|
- // ✅ fix24-v16: 使用可配置端口替代硬编码
|
|
|
- std::cout << "✅ Web服务启动成功 (" << proto << "),端口 " << g_web_server_port << std::endl;
|
|
|
- g_web_server_initialized = true;
|
|
|
- svr.listen("0.0.0.0", g_web_server_port);
|
|
|
- std::cout << "Web服务已停止监听" << std::endl;
|
|
|
- });
|
|
|
-
|
|
|
- // ✅ fix24-v19: HTTP 到 HTTPS 自动跳转(仅当 SSL 启用时)
|
|
|
- if (g_ssl_enabled && g_web_server_port > 1) {
|
|
|
- int http_port = g_web_server_port - 1;
|
|
|
- g_http_redirect_server = std::make_unique<httplib::Server>();
|
|
|
- auto& http_svr = *g_http_redirect_server;
|
|
|
-
|
|
|
- // 对所有请求返回 301 重定向到 HTTPS
|
|
|
- http_svr.Get(".*", [&http_port](const httplib::Request& req, httplib::Response& res) {
|
|
|
- // 构造 HTTPS URL
|
|
|
- std::string host = req.get_header_value("Host");
|
|
|
- // 移除可能的端口号
|
|
|
- size_t colon_pos = host.find(':');
|
|
|
- if (colon_pos != std::string::npos) {
|
|
|
- host = host.substr(0, colon_pos);
|
|
|
- }
|
|
|
- std::string https_url = "https://" + host + ":" + std::to_string(g_web_server_port) + req.path;
|
|
|
- // ✅ fix24-v19: 转发查询参数(httplib::Request::params 是 multimap)
|
|
|
- if (!req.params.empty()) {
|
|
|
- https_url += "?";
|
|
|
- bool first = true;
|
|
|
- for (const auto& param : req.params) {
|
|
|
- if (!first) https_url += "&";
|
|
|
- https_url += param.first + "=" + param.second;
|
|
|
- first = false;
|
|
|
- }
|
|
|
- }
|
|
|
- res.status = 301;
|
|
|
- res.set_header("Location", https_url);
|
|
|
- res.set_content("Redirecting to HTTPS...", "text/plain");
|
|
|
- });
|
|
|
-
|
|
|
- // HTTP 重定向服务器在独立线程中运行
|
|
|
- g_http_redirect_thread = std::make_unique<std::thread>([http_port]() {
|
|
|
- std::cout << "[HTTP重定向] 启动 HTTP->HTTPS 重定向服务,端口 " << http_port << std::endl;
|
|
|
- if (g_http_redirect_server) {
|
|
|
- g_http_redirect_server->listen("0.0.0.0", http_port);
|
|
|
- }
|
|
|
- std::cout << "[HTTP重定向] HTTP 重定向服务已停止" << std::endl;
|
|
|
- });
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-void stop_web_server() {
|
|
|
- if (!g_web_server_running.load()) return;
|
|
|
- g_web_server_running = false;
|
|
|
-
|
|
|
- while (!g_web_server_initialized.load()) {
|
|
|
- std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
|
- }
|
|
|
-
|
|
|
- if (g_web_server) {
|
|
|
- g_web_server->stop();
|
|
|
- }
|
|
|
-
|
|
|
- if (g_web_server_thread && g_web_server_thread->joinable()) {
|
|
|
- g_web_server_thread->join();
|
|
|
- }
|
|
|
-
|
|
|
- // ✅ fix24-v19: 停止 HTTP 重定向服务器
|
|
|
- if (g_http_redirect_server) {
|
|
|
- g_http_redirect_server->stop();
|
|
|
- }
|
|
|
- if (g_http_redirect_thread && g_http_redirect_thread->joinable()) {
|
|
|
- g_http_redirect_thread->join();
|
|
|
- }
|
|
|
-
|
|
|
- g_web_server.reset();
|
|
|
- g_web_server_thread.reset();
|
|
|
- g_http_redirect_server.reset();
|
|
|
- g_http_redirect_thread.reset();
|
|
|
- g_web_server_initialized = false;
|
|
|
- std::cout << "✅ Web服务已完全停止" << std::endl;
|
|
|
-}
|
|
|
-
|
|
|
-std::string generate_html_page() {
|
|
|
- std::string html_path = pathComm + "assets/web/index.html";
|
|
|
- std::ifstream html_file(html_path);
|
|
|
- if (!html_file.is_open()) {
|
|
|
- std::ostringstream error_html;
|
|
|
- error_html << "<!DOCTYPE html><html><head><title>错误</title></head>"
|
|
|
- << "<body><h1>无法加载页面</h1>"
|
|
|
- << "<p>无法找到 HTML 文件: " << html_path << "</p>"
|
|
|
- << "</body></html>";
|
|
|
- return error_html.str();
|
|
|
- }
|
|
|
- std::ostringstream buffer;
|
|
|
- buffer << html_file.rdbuf();
|
|
|
- html_file.close();
|
|
|
- return buffer.str();
|
|
|
-}
|
|
|
-
|
|
|
-// ✅ fix18新增:生成4路独立视频页面
|
|
|
-std::string generate_video_page() {
|
|
|
- std::string html_path = pathComm + "assets/web/video.html";
|
|
|
- std::ifstream html_file(html_path);
|
|
|
- if (!html_file.is_open()) {
|
|
|
- std::ostringstream error_html;
|
|
|
- error_html << "<!DOCTYPE html><html><head><title>错误</title></head>"
|
|
|
- << "<body><h1>无法加载页面</h1>"
|
|
|
- << "<p>无法找到视频页面: " << html_path << "</p>"
|
|
|
- << "</body></html>";
|
|
|
- return error_html.str();
|
|
|
- }
|
|
|
- std::ostringstream buffer;
|
|
|
- buffer << html_file.rdbuf();
|
|
|
- html_file.close();
|
|
|
- return buffer.str();
|
|
|
-}
|
|
|
-
|
|
|
-// ==================== fix21: 配置页面HTML ====================
|
|
|
-static std::string generate_config_page() {
|
|
|
- return R"HTML(<!DOCTYPE html>
|
|
|
-<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
|
-<title>系统配置 - config.ini</title>
|
|
|
-<style>
|
|
|
-*{box-sizing:border-box;margin:0;padding:0}
|
|
|
-body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333}
|
|
|
-.navbar{background:#1a1a2e;color:#fff;padding:12px 20px;display:flex;align-items:center;gap:20px;box-shadow:0 2px 8px rgba(0,0,0,0.15)}
|
|
|
-.navbar a{color:#ccc;text-decoration:none;padding:6px 12px;border-radius:4px;font-size:14px}
|
|
|
-.navbar a:hover,.navbar a.active{background:rgba(255,255,255,0.15);color:#fff}
|
|
|
-.navbar .brand{font-size:16px;font-weight:bold;color:#4fc3f7;margin-right:10px}
|
|
|
-.container{max-width:960px;margin:20px auto;padding:0 16px}
|
|
|
-.status-bar{background:#fff;border-radius:8px;padding:12px 16px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);display:flex;align-items:center;gap:12px}
|
|
|
-.status-bar .path{color:#666;font-size:13px;flex:1}
|
|
|
-.status-bar .path code{background:#f5f5f5;padding:2px 6px;border-radius:3px;font-size:12px}
|
|
|
-.btn{padding:8px 18px;border:none;border-radius:6px;cursor:pointer;font-size:14px;font-weight:500;transition:all 0.2s}
|
|
|
-.btn-primary{background:#1976d2;color:#fff}.btn-primary:hover{background:#1565c0}
|
|
|
-.btn-success{background:#388e3c;color:#fff}.btn-success:hover{background:#2e7d32}
|
|
|
-.btn:disabled{opacity:0.5;cursor:not-allowed}
|
|
|
-.section{background:#fff;border-radius:8px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);overflow:hidden}
|
|
|
-.section-title{background:#263238;color:#fff;padding:10px 16px;font-size:14px;font-weight:600;display:flex;align-items:center;gap:8px}
|
|
|
-.section-title .badge{background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:10px;font-size:11px}
|
|
|
-.section-body{padding:0}
|
|
|
-.field{display:flex;align-items:center;padding:10px 16px;border-bottom:1px solid #f0f0f0;gap:12px}
|
|
|
-.field:last-child{border-bottom:none}
|
|
|
-.field:hover{background:#fafafa}
|
|
|
-.field-label{min-width:200px;font-size:13px;color:#555;font-family:monospace}
|
|
|
-.field-input{flex:1}
|
|
|
-.field-input input,.field-input textarea{width:100%;padding:7px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;font-family:monospace;transition:border-color 0.2s}
|
|
|
-.field-input textarea{min-height:80px;resize:vertical;line-height:1.6}
|
|
|
-.field-input input:focus,.field-input textarea:focus{outline:none;border-color:#1976d2;box-shadow:0 0 0 2px rgba(25,118,210,0.1)}
|
|
|
-.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;color:#fff;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,0.2);z-index:9999;display:none}
|
|
|
-.toast-success{background:#388e3c}.toast-error{background:#d32f2f}
|
|
|
-.loading{text-align:center;padding:40px;color:#999}
|
|
|
-.spinning{display:inline-block;width:20px;height:20px;border:3px solid #ddd;border-top-color:#1976d2;border-radius:50%;animation:spin 0.8s linear infinite}
|
|
|
-@keyframes spin{to{transform:rotate(360deg)}}
|
|
|
-</style></head><body>
|
|
|
-<nav class="navbar">
|
|
|
- <span class="brand">🚛 车牌识别系统</span>
|
|
|
- <a href="/">监控首页</a>
|
|
|
- <a href="/video">视频预览</a>
|
|
|
- <a href="/config" class="active">系统配置</a>
|
|
|
- <a href="/locks">锁定管理</a>
|
|
|
-</nav>
|
|
|
-<div class="container">
|
|
|
- <div class="status-bar">
|
|
|
- <div class="path">配置文件: <code id="cfgPath">加载中...</code></div>
|
|
|
- <button class="btn btn-primary" id="saveBtn" onclick="saveConfig()" disabled>💾 保存配置</button>
|
|
|
- </div>
|
|
|
- <div id="configForm"><div class="loading"><div class="spinning"></div><p style="margin-top:10px">加载配置文件...</p></div></div>
|
|
|
-</div>
|
|
|
-<div class="toast" id="toast"></div>
|
|
|
-<script>
|
|
|
-const SECTION_LABELS = {
|
|
|
- 'global':'基本配置','config':'运行参数','MQTT':'MQTT上报',
|
|
|
- 'weight':'称重系统','feishu':'飞书告警','LED':'LED显示屏','PlateRec':'车牌白名单'
|
|
|
-};
|
|
|
-const KEY_LABELS = {
|
|
|
- 'project_name':'工程名称','point_number':'工地编号','throughway':'门号',
|
|
|
- 'app_key':'App Key','app_secret':'App Secret','app_api':'平台API',
|
|
|
- 'wType':'工地类型(1=工地/3=消纳)','TestFlag':'测试模式(0=正式/1=测试)',
|
|
|
- 'rtsp_url_front_in':'前方进站RTSP','rtsp_url_front_out':'前方出站RTSP',
|
|
|
- 'rtsp_url_side_in':'侧方进站RTSP','rtsp_url_side_out':'侧方出站RTSP',
|
|
|
- 'RUN_MODE':'运行模式','RECOGNIZE_INTERVAL':'识别间隔(秒)',
|
|
|
- 'PRECISION_INT8':'INT8精度','MAX_INFERENCE_FPS':'最大推理FPS',
|
|
|
- 'MATCH_THRESHOLD':'匹配阈值','MAX_QUEUE_SIZE':'最大队列','INFERENCE_THREADS':'推理线程数',
|
|
|
- 'DETECT_LEVEL':'检测级别','PLATE_CONFIDENCE_THRESHOLD':'车牌置信度阈值',
|
|
|
- 'PLATE_LOG_THRESHOLD':'车牌日志阈值','AlternatingMerge':'交替锁定',
|
|
|
- 'in_out_interval':'进出间隔(秒)','TIME_WINDOW':'时间窗口(分钟)',
|
|
|
- 'PhotoMaxCapacityMB':'照片存储上限(MB)','LogRetentionDays':'日志保留天数',
|
|
|
- 'DEBUG_LOG':'调试日志(0=关/1=开)',
|
|
|
- 'MQTT_HOST':'MQTT服务器','MQTT_PORT':'端口','MQTT_USER':'用户名','MQTT_PASS':'密码',
|
|
|
- 'MQTT_TOPIC':'Topic','MQTT_CLIENT_ID':'Client ID',
|
|
|
- 'PLATE_COLOR':'车牌颜色','VEHICLE_TYPE':'车辆类型',
|
|
|
- 'flagWeight':'启用称重(0/1)','weight_server_ip':'仪表IP','weight_server_port':'仪表端口',
|
|
|
- 'weight_threshold_in':'进站阈值(kg)','weight_threshold_out':'出站阈值(kg)',
|
|
|
- 'weight_detection_time':'检测时间(秒)','tcp_connect_timeout':'TCP超时(秒)',
|
|
|
- 'CANDIDATE_DATA_COUNT':'候选数据数量','STABLE_SAMPLE_COUNT':'稳定样本数',
|
|
|
- 'STABLE_THRESHOLD_KG':'稳定阈值(kg)','MAX_WEIGHT':'最大重量(kg)',
|
|
|
- 'WarningSigns':'告警开关(0/1)','APP_ID':'飞书AppID','APP_SECRET':'飞书AppSecret',
|
|
|
- 'CHAT_ID':'飞书ChatID','TIMEOUT_SECOND':'超时(秒)',
|
|
|
- 'IN_LED_IP':'进站LED IP','IN_LED_PORT':'进站LED端口',
|
|
|
- 'OUT_LED_IP':'出站LED IP','OUT_LED_PORT':'出站LED端口','Total':'白名单总数'
|
|
|
-};
|
|
|
-let configData = {};
|
|
|
-
|
|
|
-async function loadConfig() {
|
|
|
- try {
|
|
|
- const res = await fetch('/api/config');
|
|
|
- if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
|
- configData = await res.json();
|
|
|
- document.getElementById('cfgPath').textContent = location.host + '/config.ini';
|
|
|
- renderForm();
|
|
|
- document.getElementById('saveBtn').disabled = false;
|
|
|
- } catch(e) {
|
|
|
- document.getElementById('configForm').innerHTML = '<div class="loading" style="color:#d32f2f">加载失败: '+e.message+'</div>';
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-function renderForm() {
|
|
|
- const form = document.getElementById('configForm');
|
|
|
- let html = '';
|
|
|
- for (const [section, kvs] of Object.entries(configData)) {
|
|
|
- const label = SECTION_LABELS[section] || section;
|
|
|
- const keys = Object.keys(kvs);
|
|
|
- html += '<div class="section"><div class="section-title">📋 '+label+' <span class="badge">'+section+'</span></div><div class="section-body">';
|
|
|
- // Collect car[*] entries for textarea rendering
|
|
|
- const carEntries = [];
|
|
|
- for (const key of keys) {
|
|
|
- if (/^car\[\d+\]$/.test(key)) {
|
|
|
- carEntries.push({index: parseInt(key.match(/\d+/)[0]), value: kvs[key]});
|
|
|
- }
|
|
|
- }
|
|
|
- carEntries.sort((a,b) => a.index - b.index);
|
|
|
- for (const key of keys) {
|
|
|
- if (/^car\[\d+\]$/.test(key)) continue; // skip, handled below
|
|
|
- const lbl = KEY_LABELS[key] || key;
|
|
|
- const val = kvs[key];
|
|
|
- const id = section+'__'+key;
|
|
|
- html += '<div class="field"><div class="field-label" title="'+key+'">'+lbl+'</div>'
|
|
|
- + '<div class="field-input"><input type="text" id="'+id+'" data-section="'+section+'" data-key="'+key+'" value="'+escAttr(val)+'"></div></div>';
|
|
|
- }
|
|
|
- // Render car entries as textarea
|
|
|
- if (carEntries.length > 0) {
|
|
|
- const platesText = carEntries.map(e => e.value).join('\n');
|
|
|
- html += '<div class="field"><div class="field-label" title="car[*]">车牌白名单<br><small style="color:#999">每行一个车牌号</small></div>'
|
|
|
- + '<div class="field-input"><textarea id="'+section+'____plates__" data-section="'+section+'" data-key="__plates__" placeholder="每行输入一个车牌号">'+escAttr(platesText)+'</textarea></div></div>';
|
|
|
- }
|
|
|
- html += '</div></div>';
|
|
|
- }
|
|
|
- form.innerHTML = html;
|
|
|
-}
|
|
|
-
|
|
|
-function escAttr(s) { return String(s).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); }
|
|
|
-
|
|
|
-async function saveConfig() {
|
|
|
- const btn = document.getElementById('saveBtn');
|
|
|
- btn.disabled = true; btn.textContent = '保存中...';
|
|
|
- const payload = {};
|
|
|
- // Collect regular input fields
|
|
|
- document.querySelectorAll('#configForm input').forEach(inp => {
|
|
|
- const sec = inp.dataset.section, key = inp.dataset.key;
|
|
|
- if (!payload[sec]) payload[sec] = {};
|
|
|
- payload[sec][key] = inp.value;
|
|
|
- });
|
|
|
- // Collect textarea fields (expand to car[0], car[1], ...)
|
|
|
- document.querySelectorAll('#configForm textarea').forEach(ta => {
|
|
|
- const sec = ta.dataset.section, key = ta.dataset.key;
|
|
|
- if (!payload[sec]) payload[sec] = {};
|
|
|
- if (key === '__plates__') {
|
|
|
- // Parse textarea lines into car entries
|
|
|
- payload[sec]['__plates__'] = ta.value;
|
|
|
- }
|
|
|
- });
|
|
|
- try {
|
|
|
- const res = await fetch('/api/config', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
|
|
- const data = await res.json();
|
|
|
- if (data.ok) { showToast('保存成功!更新了 '+data.updated+' 项配置','success'); }
|
|
|
- else { showToast('保存失败: '+(data.error||'未知错误'),'error'); }
|
|
|
- } catch(e) { showToast('保存失败: '+e.message,'error'); }
|
|
|
- btn.disabled = false; btn.textContent = '💾 保存配置';
|
|
|
-}
|
|
|
-
|
|
|
-function showToast(msg, type) {
|
|
|
- const t = document.getElementById('toast');
|
|
|
- t.textContent = msg; t.className = 'toast toast-'+type; t.style.display = 'block';
|
|
|
- setTimeout(() => t.style.display = 'none', 3000);
|
|
|
-}
|
|
|
-
|
|
|
-loadConfig();
|
|
|
-</script></body></html>)HTML";
|
|
|
-}
|
|
|
-
|
|
|
-// ✅ fix24: 锁定管理页面
|
|
|
-static std::string generate_locks_page() {
|
|
|
- return R"HTML(<!DOCTYPE html>
|
|
|
-<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
|
-<title>交替锁定管理</title>
|
|
|
-<style>
|
|
|
-*{box-sizing:border-box;margin:0;padding:0}
|
|
|
-body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333}
|
|
|
-.navbar{background:#1a1a2e;color:#fff;padding:12px 20px;display:flex;align-items:center;gap:20px;box-shadow:0 2px 8px rgba(0,0,0,0.15)}
|
|
|
-.navbar a{color:#ccc;text-decoration:none;padding:6px 12px;border-radius:4px;font-size:14px}
|
|
|
-.navbar a:hover,.navbar a.active{background:rgba(255,255,255,0.15);color:#fff}
|
|
|
-.navbar .brand{font-size:16px;font-weight:bold;color:#4fc3f7;margin-right:10px}
|
|
|
-.container{max-width:960px;margin:20px auto;padding:0 16px}
|
|
|
-.card{background:#fff;border-radius:8px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);overflow:hidden}
|
|
|
-.card-title{background:#263238;color:#fff;padding:10px 16px;font-size:14px;font-weight:600;display:flex;align-items:center;justify-content:space-between}
|
|
|
-.badge{background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:10px;font-size:12px}
|
|
|
-table{width:100%;border-collapse:collapse}
|
|
|
-th{background:#f5f5f5;padding:10px 12px;text-align:left;font-size:13px;color:#555;border-bottom:2px solid #e0e0e0}
|
|
|
-td{padding:10px 12px;border-bottom:1px solid #f0f0f0;font-size:13px}
|
|
|
-tr:hover{background:#fafafa}
|
|
|
-.mode-in{color:#e65100;font-weight:600}
|
|
|
-.mode-out{color:#1565c0;font-weight:600}
|
|
|
-.btn{padding:6px 14px;border:none;border-radius:4px;cursor:pointer;font-size:12px;font-weight:500;transition:all 0.2s}
|
|
|
-.btn-danger{background:#d32f2f;color:#fff}.btn-danger:hover{background:#b71c1c}
|
|
|
-.btn-sm{padding:4px 10px;font-size:11px}
|
|
|
-.empty{text-align:center;padding:40px;color:#999}
|
|
|
-.toast{position:fixed;top:20px;right:20px;padding:12px 20px;border-radius:8px;color:#fff;font-size:14px;box-shadow:0 4px 12px rgba(0,0,0,0.2);z-index:9999;display:none}
|
|
|
-.toast-success{background:#388e3c}.toast-error{background:#d32f2f}
|
|
|
-.loading{text-align:center;padding:40px;color:#999}
|
|
|
-</style></head><body>
|
|
|
-<nav class="navbar">
|
|
|
- <span class="brand">🚛 车牌识别系统</span>
|
|
|
- <a href="/">监控首页</a>
|
|
|
- <a href="/video">视频预览</a>
|
|
|
- <a href="/config">系统配置</a>
|
|
|
- <a href="/locks" class="active">锁定管理</a>
|
|
|
-</nav>
|
|
|
-<div class="container">
|
|
|
- <div class="card">
|
|
|
- <div class="card-title">
|
|
|
- <span>🔒 交替锁定记录 <span class="badge" id="lockCount">加载中...</span></span>
|
|
|
- <span style="font-size:12px;opacity:0.7">超过2小时自动清零 | 手动清除用于解除异常阻塞</span>
|
|
|
- </div>
|
|
|
- <div id="lockList"><div class="loading">加载中...</div></div>
|
|
|
- </div>
|
|
|
-</div>
|
|
|
-<div class="toast" id="toast"></div>
|
|
|
-<script>
|
|
|
-function fmtTime(ts) {
|
|
|
- if (!ts || ts <= 0) return '-';
|
|
|
- const d = new Date(ts * 1000);
|
|
|
- return d.toLocaleString('zh-CN', {hour12:false});
|
|
|
-}
|
|
|
-function fmtDuration(sec) {
|
|
|
- if (sec < 60) return sec + '秒';
|
|
|
- if (sec < 3600) return Math.floor(sec/60) + '分' + (sec%60) + '秒';
|
|
|
- return Math.floor(sec/3600) + '时' + Math.floor((sec%3600)/60) + '分';
|
|
|
-}
|
|
|
-async function loadLocks() {
|
|
|
- try {
|
|
|
- const res = await fetch('/api/station_locks');
|
|
|
- const data = await res.json();
|
|
|
- const locks = data.locks || [];
|
|
|
- document.getElementById('lockCount').textContent = locks.length + '条记录';
|
|
|
- if (locks.length === 0) {
|
|
|
- document.getElementById('lockList').innerHTML = '<div class="empty">✅ 当前无锁定记录</div>';
|
|
|
- return;
|
|
|
- }
|
|
|
- let html = '<table><tr><th>车牌号</th><th>当前状态</th><th>上次进站</th><th>上次出站</th><th>已等待</th><th>剩余时间</th><th>操作</th></tr>';
|
|
|
- for (const lk of locks) {
|
|
|
- const modeClass = lk.status === '只进不出' ? 'mode-in' : 'mode-out';
|
|
|
- html += '<tr>';
|
|
|
- html += '<td><strong>' + lk.plate + '</strong></td>';
|
|
|
- html += '<td class="' + modeClass + '">' + lk.status + '</td>';
|
|
|
- html += '<td>' + fmtTime(lk.last_in_time) + '</td>';
|
|
|
- html += '<td>' + fmtTime(lk.last_out_time) + '</td>';
|
|
|
- html += '<td>' + fmtDuration(lk.elapsed_seconds) + '</td>';
|
|
|
- // ✅ fix24-v7: 显示剩余等待时间
|
|
|
- if (lk.remaining_seconds === -2) {
|
|
|
- html += '<td style="color:#d32f2f;font-weight:600">需对端先操作</td>';
|
|
|
- } else if (lk.remaining_seconds === 0) {
|
|
|
- html += '<td style="color:#388e3c;font-weight:600">可操作 ✅</td>';
|
|
|
- } else if (lk.remaining_seconds > 0) {
|
|
|
- html += '<td style="color:#e65100;font-weight:600">' + fmtDuration(lk.remaining_seconds) + '</td>';
|
|
|
- } else {
|
|
|
- html += '<td>-</td>';
|
|
|
- }
|
|
|
- html += '<td><button class="btn btn-danger btn-sm" onclick="clearLock(\'' + lk.plate + '\')">清除锁定</button></td>';
|
|
|
- html += '</tr>';
|
|
|
- }
|
|
|
- html += '</table>';
|
|
|
- document.getElementById('lockList').innerHTML = html;
|
|
|
- } catch(e) {
|
|
|
- document.getElementById('lockList').innerHTML = '<div class="empty" style="color:#d32f2f">加载失败: ' + e.message + '</div>';
|
|
|
- }
|
|
|
-}
|
|
|
-async function clearLock(plate) {
|
|
|
- if (!confirm('确定要清除 ' + plate + ' 的锁定记录吗?\n清除后该车牌可以立即进行进站/出站操作。')) return;
|
|
|
- try {
|
|
|
- const res = await fetch('/api/clear_lock', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({plate:plate})});
|
|
|
- const data = await res.json();
|
|
|
- if (data.ok) { showToast('已清除 ' + plate + ' 的锁定记录', 'success'); loadLocks(); }
|
|
|
- else { showToast('清除失败: ' + (data.error||'未知错误'), 'error'); }
|
|
|
- } catch(e) { showToast('请求失败: ' + e.message, 'error'); }
|
|
|
-}
|
|
|
-function showToast(msg, type) {
|
|
|
- const t = document.getElementById('toast');
|
|
|
- t.textContent = msg; t.className = 'toast toast-'+type; t.style.display = 'block';
|
|
|
- setTimeout(() => t.style.display = 'none', 3000);
|
|
|
-}
|
|
|
-loadLocks();
|
|
|
-setInterval(loadLocks, 10000);
|
|
|
-</script></body></html>)HTML";
|
|
|
-}
|
|
|
-
|
|
|
-// ==================== 照片上传函数 ====================
|
|
|
-// ✅ 修复:db_id <= 0时都尝试插入,解决插入失败后无法重试的问题
|
|
|
-PhotoUploadResult upload_in_photos(std::shared_ptr<CarPlateInfo> plate_info,
|
|
|
- const std::string& plate,
|
|
|
- const std::string& tb_num,
|
|
|
- const std::string& project_name,
|
|
|
- const std::string& point_number,
|
|
|
- const std::string& throughway,
|
|
|
- bool is_special)
|
|
|
-{
|
|
|
- PhotoUploadResult result;
|
|
|
- std::cout << "[进站] " << plate << " 开始上传2组照片..." << std::endl;
|
|
|
-
|
|
|
- // ✅ 修复:db_id <= 0时都尝试插入(包括db_id == -1的情况)
|
|
|
- if (plate_info->db_id <= 0) {
|
|
|
- // ✅ fix24-v18: 使用照片保存时固定的db_capture_time,防止识别线程竞态覆盖
|
|
|
- time_t capture_ts = 0;
|
|
|
- if (plate_info->db_capture_time > 0) {
|
|
|
- capture_ts = plate_info->db_capture_time;
|
|
|
- } else if (plate_info->cap_info_copy && plate_info->cap_info_copy->capture_time > 0) {
|
|
|
- capture_ts = plate_info->cap_info_copy->capture_time; // fallback
|
|
|
- }
|
|
|
- plate_info->db_id = db_insert_record_with_retry(plate, tb_num, 1,
|
|
|
- plate_info->pic_path_front, plate_info->pic_path_side, 0, 0, 0, capture_ts);
|
|
|
- } else {
|
|
|
- std::cout << "[INFO] " << plate << " 复用已有数据库记录 ID=" << plate_info->db_id << std::endl;
|
|
|
- g_metrics.record_db_duplicate_skip();
|
|
|
- }
|
|
|
-
|
|
|
- for (int group = 0; group < g_max_photo_groups && g_running; ++group) {
|
|
|
- if (g_in_plate_status.is_blocked(plate)) {
|
|
|
- std::cerr << "[进站] " << plate << " 已被临时阻止" << std::endl;
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- bool group_success = false;
|
|
|
- bool lo_success = false;
|
|
|
- bool hi_success = false;
|
|
|
-
|
|
|
- for (int retry = 0; retry < PHOTO_RETRY_COUNT && !group_success && g_running; ++retry) {
|
|
|
- if (retry > 0) {
|
|
|
- int backoff = (1 << retry); // 指数退让: 2, 4, 8秒
|
|
|
- std::this_thread::sleep_for(std::chrono::seconds(backoff));
|
|
|
- }
|
|
|
-
|
|
|
- lo_success = lib_curl_image_upload_request(plate_info, POSITION_LO);
|
|
|
- if (!lo_success) continue;
|
|
|
-
|
|
|
- std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
|
|
-
|
|
|
- hi_success = lib_curl_image_upload_request(plate_info, POSITION_HI);
|
|
|
- if (!hi_success) continue;
|
|
|
-
|
|
|
- group_success = true;
|
|
|
- result.success_groups++;
|
|
|
- // 使用"或"操作,确保只要成功过一次就不会被覆盖
|
|
|
- result.lo_success = result.lo_success || lo_success;
|
|
|
- result.hi_success = result.hi_success || hi_success;
|
|
|
- std::cout << "[进站] " << plate << " 第" << (group + 1) << "组成功" << std::endl;
|
|
|
-
|
|
|
- // ✅ P0修复:计数器已在create_or_get_bill_cache_for_photo中递增
|
|
|
- // 此处不再重复递增,避免计数器超过实际照片组数
|
|
|
- g_metrics.record_photo_upload(lo_success, hi_success, group_success);
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- // ✅ P1修复: 只有组成功时才更新单张照片的成功状态,避免失败组污染全局状态
|
|
|
- // 移除了 "即使组失败,也要更新单张照片的成功状态" 的逻辑
|
|
|
-
|
|
|
- if (result.success_groups >= REQUIRED_SUCCESS_GROUPS) break;
|
|
|
- if (g_running && group < g_max_photo_groups - 1) {
|
|
|
- std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 精确更新每张照片的独立状态
|
|
|
- if (plate_info->db_id > 0) {
|
|
|
- db_update_upload_status(plate_info->db_id,
|
|
|
- result.lo_success ? 1 : 0,
|
|
|
- result.hi_success ? 1 : 0);
|
|
|
- }
|
|
|
-
|
|
|
- {
|
|
|
- std::lock_guard<std::mutex> lock(g_in_record_mtx);
|
|
|
- auto it = g_in_upload_records.find(plate);
|
|
|
- if (it != g_in_upload_records.end()) {
|
|
|
- it->second.photo_success_count += result.success_groups;
|
|
|
- it->second.trigger_time = time(NULL);
|
|
|
- it->second.generation++;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (result.success_groups >= REQUIRED_SUCCESS_GROUPS) {
|
|
|
- bool should_send = false;
|
|
|
- {
|
|
|
- std::lock_guard<std::mutex> lock(g_in_record_mtx);
|
|
|
- auto it = g_in_upload_records.find(plate);
|
|
|
- if (it != g_in_upload_records.end() && !it->second.feishu_sent) {
|
|
|
- should_send = true;
|
|
|
- it->second.feishu_sent = true;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (should_send) { // ✅ FIX9-1: 常规飞书消息不受WarningSigns控制,始终发送
|
|
|
- std::cout << "[进站] " << plate << " 发送飞书消息..." << std::endl;
|
|
|
- std::string token = get_cached_tenant_token();
|
|
|
- if (!token.empty()) {
|
|
|
- std::string platenumcolor = is_special ? "黄色" : plate_info->type;
|
|
|
- std::string vehicleType = is_special ? "渣土车" : "";
|
|
|
- bool feishu_ok = send_feishu_msg(token,
|
|
|
- project_name, plate, get_format_time(),
|
|
|
- point_number, throughway,
|
|
|
- "进站", platenumcolor, vehicleType,
|
|
|
- tb_num);
|
|
|
- if (plate_info->db_id > 0) {
|
|
|
- db_update_feishu_status(plate_info->db_id, feishu_ok ? 1 : 2);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // ✅ fix10: 进站锁定已在 try_lock_in_station 中完成,此处仅确认
|
|
|
- lock_in_station(plate);
|
|
|
- }
|
|
|
-
|
|
|
- return result;
|
|
|
-}
|
|
|
-
|
|
|
-// ✅ 修复:db_id <= 0时都尝试插入,解决插入失败后无法重试的问题
|
|
|
-PhotoUploadResult upload_out_photos(std::shared_ptr<CarPlateInfo> plate_info,
|
|
|
- const std::string& plate,
|
|
|
- const std::string& tb_num,
|
|
|
- const std::string& project_name,
|
|
|
- const std::string& point_number,
|
|
|
- const std::string& throughway,
|
|
|
- bool is_special)
|
|
|
-{
|
|
|
- PhotoUploadResult result;
|
|
|
- std::cout << "[出站] " << plate << " 开始上传2组照片..." << std::endl;
|
|
|
-
|
|
|
- // ✅ 修复:db_id <= 0时都尝试插入(包括db_id == -1的情况)
|
|
|
- if (plate_info->db_id <= 0) {
|
|
|
- // ✅ fix24-v18: 使用照片保存时固定的db_capture_time,防止识别线程竞态覆盖
|
|
|
- time_t capture_ts = 0;
|
|
|
- if (plate_info->db_capture_time > 0) {
|
|
|
- capture_ts = plate_info->db_capture_time;
|
|
|
- } else if (plate_info->cap_info_copy && plate_info->cap_info_copy->capture_time > 0) {
|
|
|
- capture_ts = plate_info->cap_info_copy->capture_time; // fallback
|
|
|
- }
|
|
|
- plate_info->db_id = db_insert_record_with_retry(plate, tb_num, 2,
|
|
|
- plate_info->pic_path_front, plate_info->pic_path_side, 0, 0, 0, capture_ts);
|
|
|
- } else {
|
|
|
- std::cout << "[INFO] " << plate << " 复用已有数据库记录 ID=" << plate_info->db_id << std::endl;
|
|
|
- g_metrics.record_db_duplicate_skip();
|
|
|
- }
|
|
|
-
|
|
|
- for (int group = 0; group < g_max_photo_groups && g_running; ++group) {
|
|
|
- if (g_out_plate_status.is_blocked(plate)) {
|
|
|
- std::cerr << "[出站] " << plate << " 已被临时阻止" << std::endl;
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- bool group_success = false;
|
|
|
- bool lo_success = false;
|
|
|
- bool hi_success = false;
|
|
|
-
|
|
|
- for (int retry = 0; retry < PHOTO_RETRY_COUNT && !group_success && g_running; ++retry) {
|
|
|
- if (retry > 0) {
|
|
|
- int backoff = (1 << retry); // 指数退让: 2, 4, 8秒
|
|
|
- std::this_thread::sleep_for(std::chrono::seconds(backoff));
|
|
|
- }
|
|
|
-
|
|
|
- lo_success = lib_curl_image_upload_request(plate_info, POSITION_LO);
|
|
|
- if (!lo_success) continue;
|
|
|
-
|
|
|
- std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
|
|
-
|
|
|
- hi_success = lib_curl_image_upload_request(plate_info, POSITION_HI);
|
|
|
- if (!hi_success) continue;
|
|
|
-
|
|
|
- group_success = true;
|
|
|
- result.success_groups++;
|
|
|
- // 使用"或"操作,确保只要成功过一次就不会被覆盖
|
|
|
- result.lo_success = result.lo_success || lo_success;
|
|
|
- result.hi_success = result.hi_success || hi_success;
|
|
|
- std::cout << "[出站] " << plate << " 第" << (group + 1) << "组成功" << std::endl;
|
|
|
-
|
|
|
- // ✅ P0修复:计数器已在create_or_get_bill_cache_for_photo中递增
|
|
|
- // 此处不再重复递增,避免计数器超过实际照片组数
|
|
|
- g_metrics.record_photo_upload(lo_success, hi_success, group_success);
|
|
|
- break;
|
|
|
- }
|
|
|
-
|
|
|
- // ✅ P1修复: 只有组成功时才更新单张照片的成功状态,避免失败组污染全局状态
|
|
|
- // 移除了 "即使组失败,也要更新单张照片的成功状态" 的逻辑
|
|
|
-
|
|
|
- if (result.success_groups >= REQUIRED_SUCCESS_GROUPS) break;
|
|
|
- if (g_running && group < g_max_photo_groups - 1) {
|
|
|
- std::this_thread::sleep_for(std::chrono::milliseconds(300));
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // 精确更新每张照片的独立状态
|
|
|
- if (plate_info->db_id > 0) {
|
|
|
- db_update_upload_status(plate_info->db_id,
|
|
|
- result.lo_success ? 1 : 0,
|
|
|
- result.hi_success ? 1 : 0);
|
|
|
- }
|
|
|
-
|
|
|
- {
|
|
|
- std::lock_guard<std::mutex> lock(g_out_record_mtx);
|
|
|
- auto it = g_out_upload_records.find(plate);
|
|
|
- if (it != g_out_upload_records.end()) {
|
|
|
- it->second.photo_success_count += result.success_groups;
|
|
|
- it->second.trigger_time = time(NULL);
|
|
|
- it->second.generation++;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (result.success_groups >= REQUIRED_SUCCESS_GROUPS) {
|
|
|
- bool should_send = false;
|
|
|
- {
|
|
|
- std::lock_guard<std::mutex> lock(g_out_record_mtx);
|
|
|
- auto it = g_out_upload_records.find(plate);
|
|
|
- if (it != g_out_upload_records.end() && !it->second.feishu_sent) {
|
|
|
- should_send = true;
|
|
|
- it->second.feishu_sent = true;
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (should_send) { // ✅ FIX9-1: 常规飞书消息不受WarningSigns控制,始终发送
|
|
|
- std::cout << "[出站] " << plate << " 发送飞书消息..." << std::endl;
|
|
|
- std::string token = get_cached_tenant_token();
|
|
|
- if (!token.empty()) {
|
|
|
- std::string platenumcolor = is_special ? "黄色" : plate_info->type;
|
|
|
- std::string vehicleType = is_special ? "渣土车" : "";
|
|
|
- bool feishu_ok = send_feishu_msg(token,
|
|
|
- project_name, plate, get_format_time(),
|
|
|
- point_number, throughway,
|
|
|
- "出站", platenumcolor, vehicleType,
|
|
|
- tb_num);
|
|
|
- if (plate_info->db_id > 0) {
|
|
|
- db_update_feishu_status(plate_info->db_id, feishu_ok ? 1 : 2);
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- // ✅ fix10: 出站锁定已在 try_lock_out_station 中完成,此处仅确认
|
|
|
- lock_out_station(plate);
|
|
|
- }
|
|
|
-
|
|
|
- return result;
|
|
|
-}
|