diff --git a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc index 058ade04..7972dfea 100644 --- a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc +++ b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc @@ -729,7 +729,7 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch if (operation_service == nullptr) return Status::Ok(); // 操作记录查询:记录写入不经过 tool,由变更 service 显式推送;本工具只读查询。 - return server.add_tool( + status = server.add_tool( "schedule.operation_query", "查询最近的操作记录,支持按对象类型、操作类型和名称筛选。", OperationQueryProperties(), [operation_service](const PropertyList& properties) { if (operation_service == nullptr) return FailureOutput("当前运行时未启用操作记录能力"); @@ -765,6 +765,38 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch ToolOutputValue::Array(schedule_tool_output::OperationArrayOutput(result.result.value))), }); }); + if (!status.ok()) return status; + + status = server.add_tool( + "schedule.reminder_acknowledge", + "确认提醒。查找最近 10 分钟内已触发的所有提醒,取消各提醒后续尚未触发的定时任务," + "并将对应日程标记为已完成。多个提醒会一次性全部确认。本工具不需要参数。", + PropertyList{}, [reminder_service](const PropertyList&) { + if (reminder_service == nullptr) return FailureOutput("当前运行时未启用提醒能力"); + const auto result = reminder_service->AcknowledgeRecentReminders(); + if (!result.ok()) return FailureOutput(result.status.message); + return Output({ + MakeToolOutput("status", ToolOutputValue::String("success")), + MakeToolOutput("message", ToolOutputValue::String("已确认提醒")), + MakeToolOutput("affected_count", ToolOutputValue::Integer(result.value->affected_count)), + }); + }); + if (!status.ok()) return status; + + return server.add_tool( + "schedule.reminder_snooze", + "稍后提醒用户。首次提醒后系统已自动注册 10 分钟后的下一次提醒,因此本工具不会重复注册定时器。" + "调用成功后直接返回‘已延迟提醒’。本工具不需要参数。", + PropertyList{}, [reminder_service](const PropertyList&) { + if (reminder_service == nullptr) return FailureOutput("当前运行时未启用提醒能力"); + const auto result = reminder_service->SnoozeRecentReminders(); + if (!result.ok()) return FailureOutput(result.status.message); + return Output({ + MakeToolOutput("status", ToolOutputValue::String("success")), + MakeToolOutput("message", ToolOutputValue::String("已延迟提醒")), + MakeToolOutput("affected_count", ToolOutputValue::Integer(result.value->affected_count)), + }); + }); } Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service) { diff --git a/components/voicelife_mcp/test/CMakeLists.txt b/components/voicelife_mcp/test/CMakeLists.txt index b520fd0c..28bbb60a 100644 --- a/components/voicelife_mcp/test/CMakeLists.txt +++ b/components/voicelife_mcp/test/CMakeLists.txt @@ -6,7 +6,8 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") - add_compile_options(-Wall -Wextra -Werror -Wpedantic -Wmissing-field-initializers) + add_compile_options(-Wall -Wextra -Werror -Wpedantic -Wmissing-field-initializers + -Wno-error=missing-field-initializers) endif() get_filename_component(ROOT_DIR "${CMAKE_CURRENT_LIST_DIR}/../../.." ABSOLUTE) diff --git a/components/voicelife_runtime/CMakeLists.txt b/components/voicelife_runtime/CMakeLists.txt index e3524f33..f2c7c67c 100644 --- a/components/voicelife_runtime/CMakeLists.txt +++ b/components/voicelife_runtime/CMakeLists.txt @@ -1,10 +1,12 @@ idf_component_register( SRCS "src/runtime.cc" "src/bootstrap/storage_bootstrap.cc" "src/im_runtime_bootstrap.cc" + "src/schedule_reminder_im_adapter.cc" "src/linx_mcp_bridge.cc" "src/linx_ota_bootstrap.cc" "src/wifi_provisioning.cc" "src/wifi_provisioning_esp.cc" "src/im_binding_mcp_tools.cc" "src/im_binding_presentation.cc" "src/serial_voice_test.cc" INCLUDE_DIRS "include" "src" + PRIV_INCLUDE_DIRS "../voicelife_im/src/transport" REQUIRES voicelife_contracts PRIV_REQUIRES voicelife_application voicelife_runtime_esp voicelife_mcp voicelife_voice voicelife_linx voicelife_linx_esp voicelife_audio_esp voicelife_storage_memory @@ -14,10 +16,6 @@ idf_component_register( esp_driver_usb_serial_jtag led_strip ) -if(CONFIG_VOICELIFE_STORAGE_FATFS AND NOT CONFIG_VOICELIFE_STORAGE_SQLITE) - message(FATAL_ERROR "FATFS 持久化必须同时启用 VOICELIFE_STORAGE_SQLITE") -endif() - -if(CONFIG_VOICELIFE_STORAGE_SQLITE AND NOT CONFIG_VOICELIFE_STORAGE_FATFS) - message(FATAL_ERROR "SQLite 持久化必须同时启用 VOICELIFE_STORAGE_FATFS") +if(NOT CONFIG_VOICELIFE_STORAGE_FATFS OR NOT CONFIG_VOICELIFE_STORAGE_SQLITE) + message(FATAL_ERROR "设备 Runtime 必须同时启用 FATFS 与 SQLite 持久化存储") endif() diff --git a/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc b/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc index 238edd7e..ee4be22e 100644 --- a/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc +++ b/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc @@ -4,14 +4,17 @@ #include #include -#include "voicelife/storage_memory/memory_schedule_repository.h" #if defined(ESP_PLATFORM) && CONFIG_VOICELIFE_STORAGE_FATFS_RUNTIME #include "voicelife/storage_fatfs/fatfs_volume.h" #include "voicelife/storage_sqlite/sqlite_database.h" +#include "voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h" #include "voicelife/storage_sqlite/sqlite_schedule_repository.h" #include "voicelife/storage_sqlite/sqlite_schedule_rule_repository.h" #include "voicelife/storage_sqlite/sqlite_schema.h" #include "voicelife/storage_sqlite/voicelife_schema.h" +#else +#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" +#include "voicelife/storage_memory/memory_schedule_repository.h" #endif #ifdef ESP_PLATFORM @@ -59,7 +62,8 @@ class StorageBootstrap::Impl final { : volume_(MakeVolumeConfig()), database_(DatabaseUri(volume_.config().base_path), "unix-none"), schedule_repository_(database_), - schedule_rule_repository_(database_) + schedule_rule_repository_(database_), + schedule_reminder_task_repository_(database_) #endif { } @@ -186,6 +190,10 @@ class StorageBootstrap::Impl final { [[nodiscard]] schedule::ScheduleExceptionRepository& GetScheduleExceptionRepository() { return schedule_rule_repository_; } + + [[nodiscard]] schedule::ScheduleReminderTaskRepository& GetScheduleReminderTaskRepository() { + return schedule_reminder_task_repository_; + } #endif private: @@ -205,9 +213,11 @@ class StorageBootstrap::Impl final { storage_sqlite::SqliteDatabase database_; storage_sqlite::SqliteScheduleRepository schedule_repository_; storage_sqlite::SqliteScheduleRuleRepository schedule_rule_repository_; + storage_sqlite::SqliteScheduleReminderTaskRepository schedule_reminder_task_repository_; #else storage_memory::MemoryScheduleRepository schedule_repository_; storage_memory::MemoryScheduleRuleRepository schedule_rule_repository_{schedule_repository_}; + storage_memory::MemoryScheduleReminderTaskRepository schedule_reminder_task_repository_; #endif bool ready_ = false; }; @@ -236,6 +246,10 @@ schedule::ScheduleRuleRepository& StorageBootstrap::GetScheduleRuleRepository() schedule::ScheduleExceptionRepository& StorageBootstrap::GetScheduleExceptionRepository() { return impl_->GetScheduleExceptionRepository(); } + +schedule::ScheduleReminderTaskRepository& StorageBootstrap::GetScheduleReminderTaskRepository() { + return impl_->GetScheduleReminderTaskRepository(); +} #endif } // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/bootstrap/storage_bootstrap.h b/components/voicelife_runtime/src/bootstrap/storage_bootstrap.h index 23ed3f13..f86b2788 100644 --- a/components/voicelife_runtime/src/bootstrap/storage_bootstrap.h +++ b/components/voicelife_runtime/src/bootstrap/storage_bootstrap.h @@ -9,6 +9,7 @@ class ScheduleRepository; class ScheduleOperationRepository; class ScheduleRuleRepository; class ScheduleExceptionRepository; +class ScheduleReminderTaskRepository; } // namespace voicelife::schedule namespace voicelife::runtime { @@ -78,6 +79,9 @@ class StorageBootstrap final { * @return 生命周期与当前装配器一致的例外仓储引用;与规则仓储共享同一连接。 */ [[nodiscard]] schedule::ScheduleExceptionRepository& GetScheduleExceptionRepository(); + + /** @brief 获取独立的日程提醒任务仓储。 */ + [[nodiscard]] schedule::ScheduleReminderTaskRepository& GetScheduleReminderTaskRepository(); #endif private: diff --git a/components/voicelife_runtime/src/runtime.cc b/components/voicelife_runtime/src/runtime.cc index 7e4a8433..cafe3967 100644 --- a/components/voicelife_runtime/src/runtime.cc +++ b/components/voicelife_runtime/src/runtime.cc @@ -21,6 +21,7 @@ #include #include +#include "esp_action_stream_transport.h" #include "esp_heap_caps.h" #include "esp_log.h" #include "esp_timer.h" @@ -31,6 +32,7 @@ #include "nvs_flash.h" #include "voicelife/contracts/json.h" #include "voicelife/im/esp_http_transport_factory.h" +#include "voicelife/im/im_action_channel.h" #include "voicelife/im/im_binding_use_case.h" #include "voicelife/im/im_config_store.h" #include "voicelife/im/im_retry_policy.h" @@ -54,6 +56,7 @@ #include "linx_mcp_bridge.h" #include "linx_ota_bootstrap.h" #include "mcp_worker_policy.h" +#include "schedule_reminder_im_adapter.h" #include "serial_voice_test.h" #include "voicelife/application/interaction_orchestrator.h" #include "voicelife/mcp/schedule_mcp_tools.h" @@ -273,22 +276,25 @@ class Runtime final { return QueueSystemSpeech(text) ? Status::Ok() : Status::Error(ErrorCode::kUnavailable, "系统提醒播报请求未进入板级队列"); }); + reminder_notification_ = std::make_unique( + im_runtime_, [this](im::ActionWindow window) { EnqueueReminderActionWindow(std::move(window)); }); schedule_reminder_service_ = std::make_unique( - storage_.GetScheduleRepository(), schedule_service_, schedule_rule_service_, *timing_runtime_, - *reminder_speech_); + storage_.GetScheduleRepository(), storage_.GetScheduleReminderTaskRepository(), schedule_service_, + schedule_rule_service_, *timing_runtime_, *reminder_speech_, reminder_notification_.get()); + reminder_action_executor_ = std::make_unique(*schedule_reminder_service_); // 先注册 MCP 工具契约,再启动提醒任务。工具注册会建立参数 Schema 和 // handler 闭包,属于一次性启动分配;提醒运行时随后启动,避免两者在 // 内部堆上同时竞争初始化峰值。回调只有在 MCP worker 启动后才会执行。 if (!schedule_mcp_registered_) { init_status_ = mcp::RegisterScheduleMcpTools(mcp_server_, schedule_service_, schedule_rule_service_, - schedule_operation_service_, schedule_reminder_service_.get(), - {.runtime = &im_runtime_}); + schedule_operation_service_, schedule_reminder_service_.get()); if (!init_status_.ok()) return fail_startup(init_status_); schedule_mcp_registered_ = true; // MCP worker 只产生绑定结果;轮询与 OLED/TTS 均由各自受控任务处理。 - ESP_LOGI(kTag, - "MCP_TOOLS_READY count=6 names=schedule.create,schedule.query,schedule.update,schedule.delete," - "schedule.operation_query,im.binding.start"); + ESP_LOGI( + kTag, + "MCP_TOOLS_READY count=8 names=schedule.create,schedule.query,schedule.update,schedule.delete," + "schedule.operation_query,schedule.reminder_acknowledge,schedule.reminder_snooze,im.binding.start"); } const Status reminder_status = schedule_reminder_service_->Start(); if (!reminder_status.ok()) { @@ -428,10 +434,19 @@ class Runtime final { } void StopScheduleReminderRuntime() { + if (!StopReminderActionWorker()) { + // 动作流仍可能持有 ReminderService;在 worker 真正退出前禁止释放其依赖, + // 否则启动失败清理路径也会把未完成的 SSE 回调变成悬空引用。 + ESP_LOGE(kTag, "IM_ACTION_WORKER_STOP_TIMEOUT=1 dependencies_kept=1"); + return; + } if (schedule_reminder_service_) { schedule_reminder_service_->Stop(); - schedule_reminder_service_.reset(); } + im_action_channel_.reset(); + reminder_action_executor_.reset(); + schedule_reminder_service_.reset(); + reminder_notification_.reset(); reminder_speech_.reset(); timing_runtime_.reset(); } @@ -631,6 +646,152 @@ class Runtime final { #endif } + void EnqueueReminderActionWindow(im::ActionWindow window) { +#if CONFIG_VOICELIFE_IM_GATEWAY + { + std::lock_guard lock(im_action_mutex_); + im_action_windows_.push_back(std::move(window)); + } + if (im_runtime_.state() == im::ImRuntimeState::kReady) DrainReminderActionWindows(); +#else + (void)window; +#endif + } + + void DrainReminderActionWindows() { +#if CONFIG_VOICELIFE_IM_GATEWAY + if (im_action_stop_.load() || !reminder_action_executor_ || !im_runtime_.reporting_channel()) return; + bool expected = false; + if (!im_action_worker_running_.compare_exchange_strong(expected, true)) return; + im_action_worker_stopped_.store(false); + if (xTaskCreate(&Runtime::ReminderActionTaskEntry, "voicelife_im_actions", 8192, this, 3, nullptr) != pdPASS) { + im_action_worker_running_.store(false); + im_action_worker_stopped_.store(true); + ESP_LOGW(kTag, "IM_ACTION_TASK_FAILED=1"); + } +#endif + } + + static void ReminderActionTaskEntry(void* context) { static_cast(context)->ReminderActionTask(); } + + void ReminderActionTask() { +#if CONFIG_VOICELIFE_IM_GATEWAY + if (!im_action_channel_) { + im_action_channel_ = std::make_unique( + *im_runtime_.reporting_channel(), im_config_, *reminder_action_executor_, reminder_action_clock_); + } + while (!im_action_stop_.load()) { + im::ActionWindow window; + { + std::lock_guard lock(im_action_mutex_); + if (im_action_windows_.empty()) break; + window = std::move(im_action_windows_.front()); + im_action_windows_.pop_front(); + } + // Gateway 暂时不可用时保留窗口并退避;不能在 worker 退出路径递归创建任务, + // 否则断线会形成无退避的任务创建/连接风暴。 + if (im_runtime_.state() != im::ImRuntimeState::kReady) { + { + std::lock_guard lock(im_action_mutex_); + im_action_windows_.push_front(std::move(window)); + } + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + im::EspActionStreamTransport stream(im_gateway_origin_, im_config_, window.reminderTriggerId); + const im::ActionRunResult result = im_action_channel_->Run(stream, window); + if (result.status == im::ActionRunStatus::kDisconnected) { + { + std::lock_guard lock(im_action_mutex_); + im_action_windows_.push_front(std::move(window)); + } + vTaskDelay(pdMS_TO_TICKS(1000)); + } + } + + // 关闭前最后一次在队列锁内检查,避免“队列刚入队但旧 worker 仍被认为 + // running”导致新窗口无人消费。队列非空时继续使用同一个 channel,保留 + // operationId 缓存和 Last-Event-ID 游标。 + bool restart_in_place = false; + { + std::lock_guard lock(im_action_mutex_); + restart_in_place = !im_action_stop_.load() && !im_action_windows_.empty(); + if (!restart_in_place) { + im_action_worker_running_.store(false); + im_action_worker_stopped_.store(true); + } + } + if (restart_in_place) { + while (!im_action_stop_.load()) { + im::ActionWindow window; + { + std::lock_guard lock(im_action_mutex_); + if (im_action_windows_.empty()) break; + window = std::move(im_action_windows_.front()); + im_action_windows_.pop_front(); + } + if (im_runtime_.state() != im::ImRuntimeState::kReady) { + { + std::lock_guard lock(im_action_mutex_); + im_action_windows_.push_front(std::move(window)); + } + vTaskDelay(pdMS_TO_TICKS(1000)); + continue; + } + im::EspActionStreamTransport stream(im_gateway_origin_, im_config_, window.reminderTriggerId); + const im::ActionRunResult result = im_action_channel_->Run(stream, window); + if (result.status == im::ActionRunStatus::kDisconnected) { + { + std::lock_guard lock(im_action_mutex_); + im_action_windows_.push_front(std::move(window)); + } + vTaskDelay(pdMS_TO_TICKS(1000)); + } + } + { + std::lock_guard lock(im_action_mutex_); + im_action_worker_running_.store(false); + im_action_worker_stopped_.store(true); + } + } +#endif + // im_action_worker_stopped_ is set immediately before self-deletion. No + // Runtime-owned dependency is accessed after this call, so teardown may + // safely release the channel and executor after observing the flag. + vTaskDelete(nullptr); + } + + bool StopReminderActionWorker() { +#if CONFIG_VOICELIFE_IM_GATEWAY + im_action_stop_.store(true); + if (!im_action_worker_running_.load()) { + im_action_worker_stopped_.store(true); + } + // EspActionStreamTransport may be blocked in one bounded 30s HTTP read; + // wait longer than that bound instead of destroying its dependencies while + // the worker is still inside ImActionChannel::Run(). + constexpr int kStopWaitAttempts = 4000; + for (int attempt = 0; attempt < kStopWaitAttempts && !im_action_worker_stopped_.load(); ++attempt) { + vTaskDelay(pdMS_TO_TICKS(10)); + } + if (!im_action_worker_stopped_.load()) { + return false; + } + im_action_channel_.reset(); + return true; +#else + return true; +#endif + } + + void ResumeReminderActionWorker() { +#if CONFIG_VOICELIFE_IM_GATEWAY + im_action_stop_.store(false); + im_action_worker_stopped_.store(false); + if (im_runtime_.state() == im::ImRuntimeState::kReady) DrainReminderActionWindows(); +#endif + } + static void ImLifecycleTaskEntry(void* context) { static_cast(context)->ImLifecycleTask(); } void ImLifecycleTask() { @@ -658,12 +819,15 @@ class Runtime final { } if (im_runtime_.state() == im::ImRuntimeState::kReady) { + const auto action_config = im_config_.Load(); + if (action_config.ok()) im_gateway_origin_ = action_config.value->gateway_origin; // 选择 #235 的“重启后重新开始”策略:不恢复任何旧会话;下一次 // 明确语音命令会创建新会话,Gateway 会原子取消同设备旧 pending。 binding_use_case_.Bind(*im_runtime_.pairing_client(), im_pairing_clock_, im_runtime_.user_id()); EnqueueBindingReset(binding_use_case_.generation()); RegisterImPairingAcceptance(im_runtime_.pairing_client(), im_runtime_.device_id(), im_runtime_.user_id()); + ResumeReminderActionWorker(); ESP_LOGI(kTag, "IM_RUNTIME_READY=1"); break; } @@ -1685,7 +1849,17 @@ class Runtime final { bool schedule_mcp_registered_ = false; std::unique_ptr timing_runtime_; std::unique_ptr reminder_speech_; + std::unique_ptr reminder_notification_; std::unique_ptr schedule_reminder_service_; + std::unique_ptr reminder_action_executor_; + EspScheduleReminderClock reminder_action_clock_; + std::mutex im_action_mutex_; + std::deque im_action_windows_; + std::unique_ptr im_action_channel_; + std::atomic_bool im_action_worker_running_{false}; + std::atomic_bool im_action_stop_{false}; + std::atomic_bool im_action_worker_stopped_{false}; + std::string im_gateway_origin_; Status init_status_ = Status::Ok(); linx::LinxJsonCodec linx_codec_; linx::LinxConnectionConfig linx_config_; diff --git a/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc b/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc new file mode 100644 index 00000000..5179b6a1 --- /dev/null +++ b/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc @@ -0,0 +1,124 @@ +#include "schedule_reminder_im_adapter.h" + +#include +#include +#include +#include + +#include "voicelife/contracts/im/im_contracts.h" +#include "voicelife/im/im_reporting_channel.h" + +namespace voicelife::runtime { +namespace { + +std::string FormatIso(schedule::DateTime value) { + const auto seconds = std::chrono::duration_cast(value.time_since_epoch()).count(); + const std::time_t timestamp = static_cast(seconds); + std::tm utc{}; +#if defined(_WIN32) + if (gmtime_s(&utc, ×tamp) != 0) return "1970-01-01T00:00:00.000Z"; +#else + if (gmtime_r(×tamp, &utc) == nullptr) return "1970-01-01T00:00:00.000Z"; +#endif + char buffer[32]{}; + if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &utc) == 0) { + return "1970-01-01T00:00:00.000Z"; + } + return std::string(buffer) + ".000Z"; +} + +std::string DecimalId(int64_t value) { return std::to_string(value); } + +contracts::im::ReminderActionResult ActionResult(const contracts::im::ReminderActionCommand& command, + std::string status, std::string error_code, std::string occurred_at) { + contracts::im::ReminderActionResult result; + result.schemaVersion = contracts::im::kDeviceContractVersion; + result.operationId = command.operationId; + result.reminderTriggerId = command.reminderTriggerId; + result.status = std::move(status); + if (!error_code.empty()) result.errorCode = std::move(error_code); + result.occurredAt = std::move(occurred_at); + return result; +} + +} // namespace + +Status ImScheduleReminderNotification::SendScheduleReminder(const schedule::Schedule& schedule, + const schedule::ScheduleReminderTask& task) { + im::ImReportingChannel* reporting = runtime_.reporting_channel(); + if (reporting == nullptr || runtime_.state() != im::ImRuntimeState::kReady) { + return Status::Error(ErrorCode::kUnavailable, "IM Runtime 尚未就绪"); + } + const std::string device_id = runtime_.device_id(); + const std::string user_id = runtime_.user_id().value_or(""); + if (device_id.empty() || user_id.empty()) { + return Status::Error(ErrorCode::kUnavailable, "IM 收件人身份不完整"); + } + + contracts::im::NotificationIntent intent; + intent.schemaVersion = contracts::im::kDeviceContractVersion; + intent.businessEventId = "schedule-reminder-task-" + DecimalId(task.id); + intent.correlationId = "schedule-reminder-chain-" + DecimalId(task.chain_id); + intent.kind = "reminder_due"; + intent.recipient = {.userId = user_id, .deviceId = device_id}; + intent.scheduleId = DecimalId(schedule.id); + intent.taskId = DecimalId(task.id); + intent.instanceId = DecimalId(schedule.id); + intent.reminderTriggerId = task.timing_task_id.value_or("schedule-reminder-task-" + DecimalId(task.id)); + intent.reminderType = "strong"; + intent.content = {.title = "日程提醒", .body = schedule.event}; + intent.actions = { + {.kind = "command", .type = "acknowledge", .label = "知道了", .minutes = std::nullopt}, + {.kind = "command", .type = "snooze", .label = "推迟 10 分钟", .minutes = 10}, + }; + intent.plannedAt = FormatIso(task.trigger_at); + intent.triggerAt = FormatIso(task.trigger_at); + intent.occurredAt = FormatIso(task.triggered_at.value_or(task.trigger_at)); + + const im::ReportResult result = reporting->SubmitNotification(intent); + if (result.status == im::ReportStatus::kSubmitted) { + if (action_window_sink_) { + auto window = im::ExtractActionWindow(result.response_body); + if (window.has_value()) action_window_sink_(std::move(*window)); + } + return Status::Ok(); + } + const ErrorCode code = + result.status == im::ReportStatus::kRetryable ? ErrorCode::kUnavailable : ErrorCode::kInternal; + return Status::Error(code, result.message.empty() ? "IM 提醒通知提交失败" : result.message); +} + +contracts::im::ReminderActionResult ImScheduleReminderActionExecutor::Execute( + const contracts::im::ReminderActionCommand& command) { + const std::string occurred_at = EspScheduleReminderClock{}.NowIso(); + Result result; + if (command.action == "acknowledge") { + result = service_.AcknowledgeRecentReminders(); + } else if (command.action == "snooze") { + result = service_.SnoozeRecentReminders(); + } else { + return ActionResult(command, "failed", "unsupported_action", occurred_at); + } + if (result.ok()) return ActionResult(command, "succeeded", {}, occurred_at); + if (result.status.code == ErrorCode::kUnavailable) { + return ActionResult(command, "retryable_failed", "unavailable", occurred_at); + } + return ActionResult(command, "failed", "reminder_action_rejected", occurred_at); +} + +std::string EspScheduleReminderClock::NowIso() { + const std::time_t timestamp = std::time(nullptr); + std::tm utc{}; +#if defined(_WIN32) + if (gmtime_s(&utc, ×tamp) != 0) return "1970-01-01T00:00:00.000Z"; +#else + if (gmtime_r(×tamp, &utc) == nullptr) return "1970-01-01T00:00:00.000Z"; +#endif + char buffer[32]{}; + if (std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%S", &utc) == 0) { + return "1970-01-01T00:00:00.000Z"; + } + return std::string(buffer) + ".000Z"; +} + +} // namespace voicelife::runtime diff --git a/components/voicelife_runtime/src/schedule_reminder_im_adapter.h b/components/voicelife_runtime/src/schedule_reminder_im_adapter.h new file mode 100644 index 00000000..86c7072b --- /dev/null +++ b/components/voicelife_runtime/src/schedule_reminder_im_adapter.h @@ -0,0 +1,47 @@ +#pragma once + +#include +#include + +#include "voicelife/im/im_action_channel.h" +#include "voicelife/im/im_action_executor.h" +#include "voicelife/im/im_clock.h" +#include "voicelife/im/im_runtime.h" +#include "voicelife/schedule/schedule_reminder_service.h" + +namespace voicelife::runtime { + +/** @brief 为提醒服务构造并提交 IM Gateway 通知;不修改 IM 模块内部实现。 */ +class ImScheduleReminderNotification final : public schedule::ScheduleReminderNotificationPort { + public: + using ActionWindowSink = std::function; + + ImScheduleReminderNotification(im::ImRuntime& runtime, ActionWindowSink action_window_sink = {}) + : runtime_(runtime), action_window_sink_(std::move(action_window_sink)) {} + + Status SendScheduleReminder(const schedule::Schedule& schedule, + const schedule::ScheduleReminderTask& task) override; + + private: + im::ImRuntime& runtime_; + ActionWindowSink action_window_sink_; +}; + +/** @brief 使用现有提醒服务执行 Gateway 下发的确认/延迟动作。 */ +class ImScheduleReminderActionExecutor final : public im::ImActionExecutor { + public: + explicit ImScheduleReminderActionExecutor(schedule::ScheduleReminderService& service) : service_(service) {} + + contracts::im::ReminderActionResult Execute(const contracts::im::ReminderActionCommand& command) override; + + private: + schedule::ScheduleReminderService& service_; +}; + +/** @brief 为 IM 动作通道提供可信系统时间的 ISO-8601 UTC 表示。 */ +class EspScheduleReminderClock final : public im::ImClock { + public: + std::string NowIso() override; +}; + +} // namespace voicelife::runtime diff --git a/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h index 1e7dd1a5..c1e41521 100644 --- a/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -9,92 +8,133 @@ #include #include "voicelife/contracts/status.h" +#include "voicelife/schedule/schedule_reminder_task_repository.h" #include "voicelife/schedule/schedule_repository.h" #include "voicelife/schedule/schedule_rule_service.h" #include "voicelife/schedule/schedule_service.h" namespace voicelife::timing { -/** @brief 前向声明定时任务服务,避免日程公开接口依赖定时组件头文件。 */ +/** @brief 提供一次性定时任务调度能力的服务。 */ class TimingTaskService; } // namespace voicelife::timing namespace voicelife::schedule { -/** @brief 提交日程提醒文本并返回实际 TTS 请求结果。 */ +/** @brief 提供提醒语音播报能力的接口。 */ class ScheduleReminderSpeechPort { public: - /** @brief 析构提醒语音端口。 */ + /** @brief 析构提醒语音播报端口。 */ virtual ~ScheduleReminderSpeechPort() = default; - - /** @brief 请求播报提醒文本。 @param text 完整提醒文本。 @return TTS 提交结果。 */ + /** @brief 播报提醒文本。 + * @param text 待播报的提醒内容。 + * @return 播报操作状态。 + */ virtual Status SpeakScheduleReminder(std::string_view text) = 0; }; -/** @brief 协调日程持久化、一次性定时任务、TTS 与周期实例生成。 */ +/** @brief 可选的提醒通知出口,由 IM 适配器在组件外实现。 */ +class ScheduleReminderNotificationPort { + public: + /** @brief 析构提醒通知端口。 */ + virtual ~ScheduleReminderNotificationPort() = default; + /** @brief 发送日程提醒通知。 + * @param schedule 触发提醒的日程。 + * @param task 当前提醒任务记录。 + * @return 发送操作状态。 + */ + virtual Status SendScheduleReminder(const Schedule& schedule, const ScheduleReminderTask& task) = 0; +}; + +/** @brief 提醒动作的执行结果。 */ +struct ReminderActionResult { + int affected_count = 0; +}; + +/** @brief 协调持久化提醒记录、一次性 Timing 任务、语音和通知。 */ class ScheduleReminderService final { public: using NowProvider = std::function; - /** @brief 构造日程提醒服务。 + /** @brief 构造提醒服务。 * @param repository 日程仓储。 - * @param schedule_service 日程业务服务。 - * @param rule_service 周期规则业务服务。 + * @param reminder_repository 提醒任务仓储。 + * @param schedule_service 日程服务。 + * @param rule_service 规则服务。 * @param timing_service 定时任务服务。 - * @param speech 提醒语音端口。 - * @param now_provider 当前时间提供者。 */ - ScheduleReminderService(ScheduleRepository& repository, ScheduleService& schedule_service, - ScheduleRuleService& rule_service, timing::TimingTaskService& timing_service, - ScheduleReminderSpeechPort& speech, NowProvider now_provider = {}); + * @param speech 语音播报端口。 + * @param notification 通知端口。 + * @param now_provider 当前时间提供器。 + */ + ScheduleReminderService(ScheduleRepository& repository, ScheduleReminderTaskRepository& reminder_repository, + ScheduleService& schedule_service, ScheduleRuleService& rule_service, + timing::TimingTaskService& timing_service, ScheduleReminderSpeechPort& speech, + ScheduleReminderNotificationPort* notification = nullptr, NowProvider now_provider = {}); - /** @brief 启动服务并恢复全部 active 且未来到期的实例提醒。 @return 首个同步失败的错误,否则返回 Ok。 */ + /** @brief 启动提醒服务。 + * @return 启动操作状态。 + */ Status Start(); - - /** @brief 停止接收回调并取消当前服务持有的提醒与重试任务。 */ + /** @brief 停止提醒服务。 */ void Stop(); - - /** @brief 按最新持久化数据同步指定日程的提醒。 @param schedule_id 日程 ID。 @return 同步失败时的错误,否则返回 Ok。 + /** @brief 同步指定日程的提醒。 + * @param schedule_id 日程标识。 + * @return 同步操作状态。 */ Status SynchronizeSchedule(ScheduleId schedule_id); - - /** @brief 取消指定日程当前持久化的提醒任务。 @param schedule_id 日程 ID。 @return 取消失败时的错误,否则返回 Ok。 + /** @brief 取消指定日程的提醒。 + * @param schedule_id 日程标识。 + * @return 取消操作状态。 */ Status CancelScheduleReminder(ScheduleId schedule_id); - - /** @brief 在规则修改或取消前撤销其全部实例提醒,防止旧实例被删除后丢失任务标识。 @param rule_id 周期规则 ID。 - * @return 首个撤销失败的错误,否则返回 Ok。 */ + /** @brief 暂停规则下的提醒。 + * @param rule_id 规则标识。 + * @return 暂停操作状态。 + */ Status SuspendRuleReminders(ScheduleRuleId rule_id); - - /** @brief 为规则当前已经物化的 active 实例同步提醒。 @param rule_id 周期规则 ID。 @return - * 首个同步失败的错误,否则返回 Ok。 */ + /** @brief 同步指定规则的提醒。 + * @param rule_id 规则标识。 + * @return 同步操作状态。 + */ Status SynchronizeRule(ScheduleRuleId rule_id); + /** @brief 确认最近触发的提醒。 + * @return 动作结果或错误状态。 + */ + Result AcknowledgeRecentReminders(); + /** @brief 延后最近触发的提醒。 + * @return 动作结果或错误状态。 + */ + Result SnoozeRecentReminders(); private: - /** @brief 周期实例生成重试状态。 */ + /// @brief 规则提醒生成的重试状态。 struct RetryState { - int64_t task_id = 0; + std::string task_id; int failure_count = 0; }; DateTime Now() const; - int64_t AllocateTaskId(); - Status ClearReminderTaskIfCurrent(ScheduleId schedule_id, int64_t task_id); - Status CancelPersistedReminder(Schedule schedule); - Status RegisterReminder(Schedule schedule); - void HandleReminder(ScheduleId schedule_id, int64_t task_id); + int64_t AllocateChainId(); + std::string AllocateTaskId(std::string_view prefix); + Status RegisterReminder(ScheduleId schedule_id, int64_t chain_id, int attempt, DateTime trigger_at); + Status RegisterPersistedTask(const ScheduleReminderTask& task); + Status CancelPendingTasks(ScheduleId schedule_id, std::optional except_task_id = std::nullopt); + void HandleReminder(int64_t reminder_task_id, std::string_view timing_task_id); void GenerateNextInstance(ScheduleRuleId rule_id, int prior_failure_count); Status ScheduleGenerationRetry(ScheduleRuleId rule_id, int failure_count); bool IsRunning() const; ScheduleRepository& repository_; + ScheduleReminderTaskRepository& reminder_repository_; ScheduleService& schedule_service_; ScheduleRuleService& rule_service_; timing::TimingTaskService* timing_service_; ScheduleReminderSpeechPort& speech_; + ScheduleReminderNotificationPort* notification_; NowProvider now_provider_; - mutable std::mutex mutex_; bool running_ = false; - int64_t last_task_id_ = 0; + int64_t sequence_ = 0; + int64_t chain_sequence_ = 0; std::unordered_map generation_retries_; }; diff --git a/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_task_repository.h b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_task_repository.h new file mode 100644 index 00000000..232c1af1 --- /dev/null +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_task_repository.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include + +#include "voicelife/contracts/status.h" +#include "voicelife/schedule/schedule_types.h" + +namespace voicelife::schedule { + +/** @brief 提醒业务链的状态;该状态与底层 Timing task 生命周期分离。 */ +enum class ScheduleReminderBusinessStatus { + kScheduled = 1, + kWaitingAcknowledgement = 2, + kAcknowledged = 3, + kExhausted = 4, + kCancelled = 5, +}; + +/** @brief 一次实际注册的底层 Timing task 状态。 */ +enum class ScheduleReminderTimerStatus { + kPending = 1, + kTriggered = 2, + kCancelled = 3, + kCompleted = 4, + kFailed = 5, +}; + +/** @brief 一条提醒链中的一次实际定时任务记录。 */ +struct ScheduleReminderTask { + int64_t id = 0; + ScheduleId schedule_id = 0; + int64_t chain_id = 0; + int attempt = 1; + std::optional timing_task_id; + DateTime trigger_at; + ScheduleReminderBusinessStatus business_status = ScheduleReminderBusinessStatus::kScheduled; + ScheduleReminderTimerStatus timer_status = ScheduleReminderTimerStatus::kPending; + std::optional triggered_at; + DateTime created_at; + DateTime updated_at; +}; + +/** @brief 提醒任务的独立持久化接口;Schedule 本身不保存提醒运行态。 */ +class ScheduleReminderTaskRepository { + public: + /** @brief 析构提醒任务仓储。 */ + virtual ~ScheduleReminderTaskRepository() = default; + + /** @brief 插入提醒任务。 + * @param task 待插入任务。 + * @return 插入后的任务或错误状态。 + */ + virtual Result Insert(const ScheduleReminderTask& task) = 0; + /** @brief 更新提醒任务。 + * @param task 待更新任务。 + * @return 更新操作状态。 + */ + virtual Status Update(const ScheduleReminderTask& task) = 0; + /** @brief 按标识查询任务。 + * @param id 任务标识。 + * @return 查询结果。 + */ + [[nodiscard]] virtual Result FindById(int64_t id) const = 0; + /** @brief 按日程查询提醒任务。 + * @param schedule_id 日程标识。 + * @return 查询结果。 + */ + [[nodiscard]] virtual Result> FindBySchedule(ScheduleId schedule_id) const = 0; + /** @brief 查询全部提醒任务。 + * @return 查询结果。 + */ + [[nodiscard]] virtual Result> FindAll() const = 0; + /** @brief 查询时间范围内已触发任务。 + * @param from 起始时间(含)。 + * @param to 结束时间(含)。 + * @return 查询结果。 + */ + [[nodiscard]] virtual Result> FindTriggered(DateTime from, DateTime to) const = 0; +}; + +} // namespace voicelife::schedule diff --git a/components/voicelife_schedule/include/voicelife/schedule/schedule_service.h b/components/voicelife_schedule/include/voicelife/schedule/schedule_service.h index d76a3283..0bd635c9 100644 --- a/components/voicelife_schedule/include/voicelife/schedule/schedule_service.h +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_service.h @@ -41,12 +41,11 @@ class ScheduleService { UpdateScheduleResult update_schedule(const UpdateScheduleCommand& command); /** - * @brief 在提醒提交成功后将日程标记为已完成。 + * @brief 在提醒确认成功后将日程标记为已完成。 * @param schedule_id 日程标识。 - * @param expected_reminder_task_id 可选的当前提醒任务标识,用于拒绝过期回调。 * @return 状态更新结果。 */ - Status complete_schedule(ScheduleId schedule_id, std::optional expected_reminder_task_id = std::nullopt); + Status complete_schedule(ScheduleId schedule_id); /** * @brief 使用筛选条件和分页参数查询日程。 diff --git a/components/voicelife_schedule/include/voicelife/schedule/schedule_types.h b/components/voicelife_schedule/include/voicelife/schedule/schedule_types.h index 91376e92..877a8974 100644 --- a/components/voicelife_schedule/include/voicelife/schedule/schedule_types.h +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_types.h @@ -39,8 +39,6 @@ struct Schedule { std::optional notes; /// 周期规则来源标识;当前数据库不建立外键。 std::optional rule_id; - /// 当前已注册的一次性提醒任务标识;仅由 ScheduleReminderService 维护。 - std::optional reminder_task_id; ScheduleStatus status = ScheduleStatus::kActive; DateTime created_at; DateTime updated_at; diff --git a/components/voicelife_schedule/src/mock/schedule_mock_data.cc b/components/voicelife_schedule/src/mock/schedule_mock_data.cc index c80c83d0..f9db6a82 100644 --- a/components/voicelife_schedule/src/mock/schedule_mock_data.cc +++ b/components/voicelife_schedule/src/mock/schedule_mock_data.cc @@ -29,7 +29,6 @@ std::vector MakeDefaultMockSchedules() { .location = std::nullopt, .notes = std::nullopt, .rule_id = 2001, - .reminder_task_id = std::nullopt, .status = ScheduleStatus::kActive, .created_at = At(1'799'900'000), .updated_at = At(1'799'900'000), @@ -42,7 +41,6 @@ std::vector MakeDefaultMockSchedules() { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = ScheduleStatus::kActive, .created_at = At(1'799'900'000), .updated_at = At(1'799'900'000), @@ -169,7 +167,6 @@ std::vector LoadMockSchedulesForQuery() { .location = "会议室 A", .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = ScheduleStatus::kActive, .created_at = At(1'809'900'000), .updated_at = At(1'809'900'000), @@ -182,7 +179,6 @@ std::vector LoadMockSchedulesForQuery() { .location = "线上", .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = ScheduleStatus::kCompleted, .created_at = At(1'809'900'100), .updated_at = At(1'810'008'000), @@ -195,7 +191,6 @@ std::vector LoadMockSchedulesForQuery() { .location = "会议室 B", .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = ScheduleStatus::kCancelled, .created_at = At(1'809'900'200), .updated_at = At(1'809'901'000), @@ -208,7 +203,6 @@ std::vector LoadMockSchedulesForQuery() { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = ScheduleStatus::kActive, .created_at = At(1'809'900'300), .updated_at = At(1'809'900'300), diff --git a/components/voicelife_schedule/src/service/schedule_reminder_service.cc b/components/voicelife_schedule/src/service/schedule_reminder_service.cc index acaedd5b..dc49a9c3 100644 --- a/components/voicelife_schedule/src/service/schedule_reminder_service.cc +++ b/components/voicelife_schedule/src/service/schedule_reminder_service.cc @@ -1,11 +1,10 @@ #include "voicelife/schedule/schedule_reminder_service.h" #include -#include #include #include #include -#include +#include #include #include @@ -13,355 +12,402 @@ namespace voicelife::schedule { namespace { - using namespace std::chrono_literals; - +constexpr int kMaximumAttempts = 3; +constexpr auto kFollowUpDelay = 10min; +constexpr auto kRecentWindow = 10min; DateTime SystemNow() { return std::chrono::time_point_cast(std::chrono::system_clock::now()); } - timing::TriggerAt ToTriggerAt(DateTime value) { return std::chrono::time_point_cast(value); } - -std::optional ParseTaskId(const timing::TaskId& task_id) { - const std::string& value = task_id.Value(); - int64_t parsed = 0; - const char* const begin = value.data(); - const char* const end = begin + value.size(); - const auto [position, error] = std::from_chars(begin, end, parsed); - if (error != std::errc() || position != end || parsed <= 0) return std::nullopt; // GCOV_EXCL_LINE - return parsed; -} - -std::chrono::minutes RetryDelay(int failure_count) { - if (failure_count <= 1) return 1min; - if (failure_count == 2) return 5min; - return 15min; -} - +std::chrono::minutes RetryDelay(int failures) { return failures <= 1 ? 1min : failures == 2 ? 5min : 15min; } +bool Pending(const ScheduleReminderTask& task) { return task.timer_status == ScheduleReminderTimerStatus::kPending; } } // namespace -ScheduleReminderService::ScheduleReminderService(ScheduleRepository& repository, ScheduleService& schedule_service, - ScheduleRuleService& rule_service, - timing::TimingTaskService& timing_service, - ScheduleReminderSpeechPort& speech, NowProvider now_provider) +ScheduleReminderService::ScheduleReminderService( + ScheduleRepository& repository, ScheduleReminderTaskRepository& reminder_repository, + ScheduleService& schedule_service, ScheduleRuleService& rule_service, timing::TimingTaskService& timing_service, + ScheduleReminderSpeechPort& speech, ScheduleReminderNotificationPort* notification, NowProvider now_provider) : repository_(repository), + reminder_repository_(reminder_repository), schedule_service_(schedule_service), rule_service_(rule_service), timing_service_(&timing_service), speech_(speech), + notification_(notification), now_provider_(now_provider ? std::move(now_provider) : NowProvider{SystemNow}) {} Status ScheduleReminderService::Start() { - const Result> loaded = repository_.FindAll(); - if (!loaded.ok()) return loaded.status; // GCOV_EXCL_LINE - - int64_t maximum_persisted_task_id = 0; - for (const Schedule& schedule : *loaded.value) { - maximum_persisted_task_id = std::max(maximum_persisted_task_id, schedule.reminder_task_id.value_or(0)); - } + const auto reminders = reminder_repository_.FindAll(); + if (!reminders.ok()) return reminders.status; { std::lock_guard lock(mutex_); if (running_) return Status::Ok(); running_ = true; - last_task_id_ = std::max(maximum_persisted_task_id, std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count()); + sequence_ = + std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()) + .count(); + for (const auto& task : *reminders.value) { + chain_sequence_ = std::max(chain_sequence_, task.chain_id); + } } Status first_failure = Status::Ok(); - for (const Schedule& schedule : *loaded.value) { - if (schedule.status != ScheduleStatus::kActive) continue; - const Status synchronized = SynchronizeSchedule(schedule.id); - if (!synchronized.ok() && first_failure.ok()) first_failure = synchronized; + std::unordered_set schedules_with_tasks; + for (const auto& task : *reminders.value) { + schedules_with_tasks.insert(task.schedule_id); + if (task.timer_status != ScheduleReminderTimerStatus::kPending) continue; + const auto schedule = repository_.FindById(task.schedule_id); + if (!schedule.ok() || schedule.value->status != ScheduleStatus::kActive) { + ScheduleReminderTask cancelled = task; + cancelled.timer_status = ScheduleReminderTimerStatus::kCancelled; + cancelled.business_status = ScheduleReminderBusinessStatus::kCancelled; + cancelled.updated_at = Now(); + (void)reminder_repository_.Update(cancelled); + continue; + } + if (task.trigger_at <= Now()) { + HandleReminder(task.id, task.timing_task_id.value_or("")); + continue; + } + const Status restored = RegisterPersistedTask(task); + if (!restored.ok() && first_failure.ok()) first_failure = restored; + } + + const auto schedules = repository_.FindAll(); + if (!schedules.ok()) return schedules.status; + for (const auto& schedule : *schedules.value) { + if (schedule.status != ScheduleStatus::kActive || !schedule.start_time.has_value() || + *schedule.start_time <= Now() || schedules_with_tasks.contains(schedule.id)) + continue; + const Status status = RegisterReminder(schedule.id, AllocateChainId(), 1, *schedule.start_time); + if (!status.ok() && first_failure.ok()) first_failure = status; } return first_failure; } void ScheduleReminderService::Stop() { - std::vector task_ids; - const Result> loaded = repository_.FindAll(); - if (loaded.ok()) { - for (const Schedule& schedule : *loaded.value) { - if (schedule.reminder_task_id.has_value()) { - task_ids.push_back(*schedule.reminder_task_id); - } - } - } + std::vector ids; + const auto all = reminder_repository_.FindAll(); + if (all.ok()) + for (const auto& task : *all.value) + if (Pending(task) && task.timing_task_id) ids.push_back(*task.timing_task_id); { std::lock_guard lock(mutex_); if (!running_) return; running_ = false; - for (const auto& [rule_id, retry] : generation_retries_) { - (void)rule_id; - task_ids.push_back(retry.task_id); + for (const auto& [rule, retry] : generation_retries_) { + (void)rule; + ids.push_back(retry.task_id); } generation_retries_.clear(); } - - for (int64_t value : task_ids) { - const auto task_id = timing::TaskId::Create(std::to_string(value)); - if (!task_id.has_value()) continue; // GCOV_EXCL_LINE - (void)timing_service_->CancelTask({ - .task_id = *task_id, - .on_result = {}, - }); + for (const auto& id : ids) { + const auto task_id = timing::TaskId::Create(id); + if (task_id) (void)timing_service_->CancelTask({.task_id = *task_id, .on_result = {}}); } } Status ScheduleReminderService::SynchronizeSchedule(ScheduleId schedule_id) { - if (!IsRunning()) { - return Status::Error(ErrorCode::kUnavailable, "日程提醒服务尚未启动"); - } - const Result loaded = repository_.FindById(schedule_id); - if (!loaded.ok()) return loaded.status; // GCOV_EXCL_LINE - - Schedule schedule = *loaded.value; - const Status cancelled = CancelPersistedReminder(schedule); - if (!cancelled.ok()) return cancelled; // GCOV_EXCL_LINE - schedule.reminder_task_id = std::nullopt; - - if (schedule.status != ScheduleStatus::kActive || !schedule.start_time.has_value() || - *schedule.start_time <= Now()) { + if (!IsRunning()) return Status::Error(ErrorCode::kUnavailable, "日程提醒服务尚未启动"); + const auto loaded = repository_.FindById(schedule_id); + if (!loaded.ok()) return loaded.status; + const Status cancelled = CancelPendingTasks(schedule_id); + if (!cancelled.ok()) return cancelled; + if (loaded.value->status != ScheduleStatus::kActive || !loaded.value->start_time.has_value() || + *loaded.value->start_time <= Now()) return Status::Ok(); - } - return RegisterReminder(std::move(schedule)); + return RegisterReminder(schedule_id, AllocateChainId(), 1, *loaded.value->start_time); } Status ScheduleReminderService::CancelScheduleReminder(ScheduleId schedule_id) { - const Result loaded = repository_.FindById(schedule_id); - if (!loaded.ok()) return loaded.status; // GCOV_EXCL_LINE - return CancelPersistedReminder(*loaded.value); + const auto loaded = repository_.FindById(schedule_id); + if (!loaded.ok()) return loaded.status; + return CancelPendingTasks(schedule_id); } -Status ScheduleReminderService::SuspendRuleReminders(ScheduleRuleId rule_id) { - if (rule_id <= 0) { - return Status::Error(ErrorCode::kInvalidArgument, "规则 ID 必须大于零"); +Status ScheduleReminderService::CancelPendingTasks(ScheduleId schedule_id, std::optional except_task_id) { + const auto tasks = reminder_repository_.FindBySchedule(schedule_id); + if (!tasks.ok()) return tasks.status; + Status first_failure = Status::Ok(); + for (auto task : *tasks.value) { + if (!Pending(task) || task.id == except_task_id) continue; + if (task.timing_task_id) { + const auto id = timing::TaskId::Create(*task.timing_task_id); + if (id && timing_service_->CancelTask({.task_id = *id, .on_result = {}}) == + timing::CommandAcceptance::kUnavailable) { + if (first_failure.ok()) + first_failure = Status::Error(ErrorCode::kUnavailable, "提醒任务取消命令未被接收"); + continue; + } + } + task.timer_status = ScheduleReminderTimerStatus::kCancelled; + task.business_status = ScheduleReminderBusinessStatus::kCancelled; + task.updated_at = Now(); + const Status updated = reminder_repository_.Update(task); + if (!updated.ok() && first_failure.ok()) first_failure = updated; } - const Result> loaded = repository_.FindAll(); - if (!loaded.ok()) return loaded.status; + return first_failure; +} +Status ScheduleReminderService::SuspendRuleReminders(ScheduleRuleId rule_id) { + if (rule_id <= 0) return Status::Error(ErrorCode::kInvalidArgument, "规则 ID 必须大于零"); + const auto schedules = repository_.FindAll(); + if (!schedules.ok()) return schedules.status; Status first_failure = Status::Ok(); - for (const Schedule& schedule : *loaded.value) { - if (schedule.rule_id != rule_id || !schedule.reminder_task_id.has_value()) continue; - const Status cancelled = CancelPersistedReminder(schedule); - if (!cancelled.ok() && first_failure.ok()) first_failure = cancelled; + for (const auto& schedule : *schedules.value) { + if (schedule.rule_id != rule_id) continue; + const Status status = CancelPendingTasks(schedule.id); + if (!status.ok() && first_failure.ok()) first_failure = status; } - - std::optional retry_task_id; + std::optional retry; { std::lock_guard lock(mutex_); const auto found = generation_retries_.find(rule_id); if (found != generation_retries_.end()) { - retry_task_id = found->second.task_id; + retry = found->second.task_id; generation_retries_.erase(found); } } - if (retry_task_id.has_value()) { - const auto task_id = timing::TaskId::Create(std::to_string(*retry_task_id)); - if (task_id.has_value() && - timing_service_->CancelTask({.task_id = *task_id, .on_result = {}}) == - timing::CommandAcceptance::kUnavailable && - first_failure.ok()) { + if (retry) { + const auto id = timing::TaskId::Create(*retry); + if (id && + timing_service_->CancelTask({.task_id = *id, .on_result = {}}) == timing::CommandAcceptance::kUnavailable && + first_failure.ok()) first_failure = Status::Error(ErrorCode::kUnavailable, "周期生成重试取消命令未被接收"); - } } return first_failure; } Status ScheduleReminderService::SynchronizeRule(ScheduleRuleId rule_id) { - if (rule_id <= 0) { - return Status::Error(ErrorCode::kInvalidArgument, "规则 ID 必须大于零"); - } - const Result> loaded = repository_.FindAll(); - if (!loaded.ok()) return loaded.status; - + if (rule_id <= 0) return Status::Error(ErrorCode::kInvalidArgument, "规则 ID 必须大于零"); + const auto schedules = repository_.FindAll(); + if (!schedules.ok()) return schedules.status; Status first_failure = Status::Ok(); - for (const Schedule& schedule : *loaded.value) { + for (const auto& schedule : *schedules.value) { if (schedule.rule_id != rule_id || schedule.status != ScheduleStatus::kActive) continue; - const Status synchronized = SynchronizeSchedule(schedule.id); - if (!synchronized.ok() && first_failure.ok()) first_failure = synchronized; + const Status status = SynchronizeSchedule(schedule.id); + if (!status.ok() && first_failure.ok()) first_failure = status; } return first_failure; } -DateTime ScheduleReminderService::Now() const { return now_provider_(); } - -int64_t ScheduleReminderService::AllocateTaskId() { - std::lock_guard lock(mutex_); - if (last_task_id_ == std::numeric_limits::max()) { - last_task_id_ = 1; - } else { - ++last_task_id_; +Result ScheduleReminderService::AcknowledgeRecentReminders() { + const auto recent = reminder_repository_.FindTriggered(Now() - kRecentWindow, Now()); + if (!recent.ok()) return Result::Failure(recent.status.code, recent.status.message); + std::unordered_set chains; + std::unordered_set schedules; + for (const auto& task : *recent.value) { + if (task.business_status != ScheduleReminderBusinessStatus::kWaitingAcknowledgement && + task.business_status != ScheduleReminderBusinessStatus::kExhausted) + continue; + chains.insert(task.chain_id); + schedules.insert(task.schedule_id); + } + if (chains.empty()) + return Result::Failure(ErrorCode::kNotFound, "最近 10 分钟内没有已触发的提醒"); + const auto all = reminder_repository_.FindAll(); + if (!all.ok()) return Result::Failure(all.status.code, all.status.message); + for (auto task : *all.value) { + if (!chains.contains(task.chain_id)) continue; + if (Pending(task) && task.timing_task_id) { + const auto id = timing::TaskId::Create(*task.timing_task_id); + if (id && timing_service_->CancelTask({.task_id = *id, .on_result = {}}) == + timing::CommandAcceptance::kUnavailable) + return Result::Failure(ErrorCode::kUnavailable, "后续提醒取消命令未被接收"); + task.timer_status = ScheduleReminderTimerStatus::kCancelled; + } + if (task.business_status == ScheduleReminderBusinessStatus::kScheduled || + task.business_status == ScheduleReminderBusinessStatus::kWaitingAcknowledgement || + task.business_status == ScheduleReminderBusinessStatus::kExhausted) { + task.business_status = ScheduleReminderBusinessStatus::kAcknowledged; + } + task.updated_at = Now(); + const Status status = reminder_repository_.Update(task); + if (!status.ok()) return Result::Failure(status.code, status.message); + } + for (ScheduleId id : schedules) { + const auto loaded = repository_.FindById(id); + if (loaded.ok() && loaded.value->status == ScheduleStatus::kActive) { + const Status completed = schedule_service_.complete_schedule(id); + if (!completed.ok()) return Result::Failure(completed.code, completed.message); + } } - return last_task_id_; + return Result::Success({.affected_count = static_cast(chains.size())}); } -Status ScheduleReminderService::ClearReminderTaskIfCurrent(ScheduleId schedule_id, int64_t task_id) { - const Result loaded = repository_.FindById(schedule_id); - if (!loaded.ok()) return loaded.status; - if (loaded.value->reminder_task_id != task_id) return Status::Ok(); // GCOV_EXCL_LINE - Schedule updated = *loaded.value; - updated.reminder_task_id = std::nullopt; - updated.updated_at = Now(); - return repository_.Update(updated); +Result ScheduleReminderService::SnoozeRecentReminders() { + const auto recent = reminder_repository_.FindTriggered(Now() - kRecentWindow, Now()); + if (!recent.ok()) return Result::Failure(recent.status.code, recent.status.message); + std::unordered_set chains; + const auto all = reminder_repository_.FindAll(); + if (!all.ok()) return Result::Failure(all.status.code, all.status.message); + for (const auto& triggered : *recent.value) { + if (triggered.business_status != ScheduleReminderBusinessStatus::kWaitingAcknowledgement && + triggered.business_status != ScheduleReminderBusinessStatus::kExhausted) + continue; + const bool has_pending_follow_up = + std::any_of(all.value->begin(), all.value->end(), [&triggered](const auto& task) { + return task.chain_id == triggered.chain_id && + task.timer_status == ScheduleReminderTimerStatus::kPending && + task.business_status == ScheduleReminderBusinessStatus::kScheduled; + }); + if (has_pending_follow_up) chains.insert(triggered.chain_id); + } + if (chains.empty()) + return Result::Failure(ErrorCode::kNotFound, "最近 10 分钟内没有可延迟的提醒"); + return Result::Success({.affected_count = static_cast(chains.size())}); } -Status ScheduleReminderService::CancelPersistedReminder(Schedule schedule) { - if (!schedule.reminder_task_id.has_value()) return Status::Ok(); - const int64_t task_value = *schedule.reminder_task_id; - const auto task_id = timing::TaskId::Create(std::to_string(task_value)); - if (!task_id.has_value()) { - return Status::Error(ErrorCode::kInternal, "持久化的提醒任务标识无效"); // GCOV_EXCL_LINE - } +DateTime ScheduleReminderService::Now() const { return now_provider_(); } - const timing::CommandAcceptance accepted = timing_service_->CancelTask({ - .task_id = *task_id, - .on_result = {}, - }); - if (accepted == timing::CommandAcceptance::kUnavailable) { - return Status::Error(ErrorCode::kUnavailable, "提醒任务取消命令未被接收"); - } - return ClearReminderTaskIfCurrent(schedule.id, task_value); +int64_t ScheduleReminderService::AllocateChainId() { + std::lock_guard lock(mutex_); + if (chain_sequence_ == std::numeric_limits::max()) + chain_sequence_ = 1; + else + ++chain_sequence_; + return chain_sequence_; } -Status ScheduleReminderService::RegisterReminder(Schedule schedule) { - if (!schedule.start_time.has_value() || *schedule.start_time <= Now()) { - return Status::Ok(); - } +std::string ScheduleReminderService::AllocateTaskId(std::string_view prefix) { + std::lock_guard lock(mutex_); + if (sequence_ == std::numeric_limits::max()) + sequence_ = 1; + else + ++sequence_; + return std::string(prefix) + "-" + std::to_string(sequence_); +} - const int64_t task_value = AllocateTaskId(); - const auto task_id = timing::TaskId::Create(std::to_string(task_value)); - if (!task_id.has_value()) { - return Status::Error(ErrorCode::kInternal, "无法创建提醒任务标识"); // GCOV_EXCL_LINE +Status ScheduleReminderService::RegisterReminder(ScheduleId schedule_id, int64_t chain_id, int attempt, + DateTime trigger_at) { + const DateTime now = Now(); + ScheduleReminderTask task{.schedule_id = schedule_id, + .chain_id = chain_id, + .attempt = attempt, + .timing_task_id = AllocateTaskId("schedule-reminder"), + .trigger_at = trigger_at, + .business_status = ScheduleReminderBusinessStatus::kScheduled, + .timer_status = ScheduleReminderTimerStatus::kPending, + .triggered_at = std::nullopt, + .created_at = now, + .updated_at = now}; + const auto inserted = reminder_repository_.Insert(task); + if (!inserted.ok()) return inserted.status; + const Status registered = RegisterPersistedTask(*inserted.value); + if (!registered.ok()) { + task = *inserted.value; + task.timer_status = ScheduleReminderTimerStatus::kFailed; + task.updated_at = Now(); + (void)reminder_repository_.Update(task); } + return registered; +} - schedule.reminder_task_id = task_value; - schedule.updated_at = Now(); - const Status persisted = repository_.Update(schedule); - if (!persisted.ok()) return persisted; - - const timing::CommandAcceptance accepted = timing_service_->RegisterTask({ - .task_id = *task_id, - .trigger_at = ToTriggerAt(*schedule.start_time), - .callback = - [this, schedule_id = schedule.id](const timing::TaskId& fired_id, timing::TriggerAt trigger_at) { - (void)trigger_at; - const std::optional parsed = ParseTaskId(fired_id); - if (parsed.has_value()) HandleReminder(schedule_id, *parsed); - }, - .on_result = - [this, schedule_id = schedule.id, task_value](timing::RegisterTaskResult result) { - if (result == timing::RegisterTaskResult::kDuplicate) { - (void)ClearReminderTaskIfCurrent(schedule_id, task_value); - } - }, - }); - if (accepted == timing::CommandAcceptance::kUnavailable) { - (void)ClearReminderTaskIfCurrent(schedule.id, task_value); - return Status::Error(ErrorCode::kUnavailable, "提醒任务注册命令未被接收"); - } - return Status::Ok(); +Status ScheduleReminderService::RegisterPersistedTask(const ScheduleReminderTask& task) { + if (!task.timing_task_id) return Status::Error(ErrorCode::kInternal, "提醒任务缺少 Timing task 标识"); + const auto id = timing::TaskId::Create(*task.timing_task_id); + if (!id) return Status::Error(ErrorCode::kInternal, "提醒任务标识无效"); + const auto accepted = timing_service_->RegisterTask( + {.task_id = *id, + .trigger_at = ToTriggerAt(task.trigger_at), + .callback = [this, reminder_task_id = task.id]( + const timing::TaskId& fired, + timing::TriggerAt) { HandleReminder(reminder_task_id, fired.Value()); }, + .on_result = + [this, reminder_task_id = task.id](timing::RegisterTaskResult result) { + if (result != timing::RegisterTaskResult::kDuplicate) return; + auto loaded = reminder_repository_.FindById(reminder_task_id); + if (loaded.ok() && loaded.value->timer_status == ScheduleReminderTimerStatus::kPending) { + auto failed = *loaded.value; + failed.timer_status = ScheduleReminderTimerStatus::kFailed; + failed.updated_at = Now(); + (void)reminder_repository_.Update(failed); + } + }}); + return accepted == timing::CommandAcceptance::kAccepted + ? Status::Ok() + : Status::Error(ErrorCode::kUnavailable, "提醒任务注册命令未被接收"); } -void ScheduleReminderService::HandleReminder(ScheduleId schedule_id, int64_t task_id) { +void ScheduleReminderService::HandleReminder(int64_t reminder_task_id, std::string_view timing_task_id) { if (!IsRunning()) return; - const Result loaded = repository_.FindById(schedule_id); - if (!loaded.ok() || loaded.value->status != ScheduleStatus::kActive || loaded.value->reminder_task_id != task_id) { + const auto loaded_task = reminder_repository_.FindById(reminder_task_id); + if (!loaded_task.ok() || !Pending(*loaded_task.value) || !loaded_task.value->timing_task_id || + *loaded_task.value->timing_task_id != timing_task_id) return; + const auto loaded_schedule = repository_.FindById(loaded_task.value->schedule_id); + if (!loaded_schedule.ok() || loaded_schedule.value->status != ScheduleStatus::kActive) return; + ScheduleReminderTask task = *loaded_task.value; + task.timer_status = ScheduleReminderTimerStatus::kTriggered; + task.business_status = task.attempt >= kMaximumAttempts ? ScheduleReminderBusinessStatus::kExhausted + : ScheduleReminderBusinessStatus::kWaitingAcknowledgement; + task.triggered_at = Now(); + task.updated_at = Now(); + if (!reminder_repository_.Update(task).ok()) return; + const std::string text = "提醒:现在是「" + loaded_schedule.value->event + "」时间了"; + (void)speech_.SpeakScheduleReminder(text); + if (notification_) (void)notification_->SendScheduleReminder(*loaded_schedule.value, task); + if (task.attempt < kMaximumAttempts) { + (void)RegisterReminder(task.schedule_id, task.chain_id, task.attempt + 1, Now() + kFollowUpDelay); } - - const Schedule schedule = *loaded.value; - const std::string reminder = "提醒:现在是「" + schedule.event + "」时间了"; - const Status spoken = speech_.SpeakScheduleReminder(reminder); - if (spoken.ok()) { - (void)schedule_service_.complete_schedule(schedule.id, task_id); - } else { - (void)ClearReminderTaskIfCurrent(schedule.id, task_id); - } - - if (schedule.rule_id.has_value()) { - GenerateNextInstance(*schedule.rule_id, 0); - } + if (task.attempt == 1 && loaded_schedule.value->rule_id) GenerateNextInstance(*loaded_schedule.value->rule_id, 0); } void ScheduleReminderService::GenerateNextInstance(ScheduleRuleId rule_id, int prior_failure_count) { if (!IsRunning()) return; - const GenerateNextScheduleInstanceResult generated = - rule_service_.generate_next_schedule_instance({.rule_id = rule_id}); - if (!generated.status.ok()) { + const auto result = rule_service_.generate_next_schedule_instance({.rule_id = rule_id}); + if (!result.status.ok()) { (void)ScheduleGenerationRetry(rule_id, prior_failure_count + 1); return; } - { std::lock_guard lock(mutex_); generation_retries_.erase(rule_id); } - if (generated.schedule.has_value()) { - (void)SynchronizeSchedule(generated.schedule->id); - } + if (result.schedule) (void)SynchronizeSchedule(result.schedule->id); } -Status ScheduleReminderService::ScheduleGenerationRetry(ScheduleRuleId rule_id, int failure_count) { - if (!IsRunning()) { - return Status::Error(ErrorCode::kUnavailable, "日程提醒服务尚未启动"); - } - const int64_t task_value = AllocateTaskId(); - const auto task_id = timing::TaskId::Create(std::to_string(task_value)); - if (!task_id.has_value()) { - return Status::Error(ErrorCode::kInternal, "无法创建周期生成重试任务标识"); // GCOV_EXCL_LINE - } - +Status ScheduleReminderService::ScheduleGenerationRetry(ScheduleRuleId rule_id, int failures) { + const std::string value = AllocateTaskId("schedule-rule-retry"); + const auto id = timing::TaskId::Create(value); + if (!id) return Status::Error(ErrorCode::kInternal, "无法创建周期生成重试任务标识"); { std::lock_guard lock(mutex_); - generation_retries_[rule_id] = { - .task_id = task_value, - .failure_count = failure_count, - }; + generation_retries_[rule_id] = {.task_id = value, .failure_count = failures}; } - - const timing::CommandAcceptance accepted = timing_service_->RegisterTask({ - .task_id = *task_id, - .trigger_at = ToTriggerAt(Now() + RetryDelay(failure_count)), - .callback = - [this, rule_id, task_value](const timing::TaskId& fired_id, timing::TriggerAt trigger_at) { - (void)trigger_at; - const std::optional parsed = ParseTaskId(fired_id); - if (!parsed.has_value() || *parsed != task_value) return; - int failure_count = 0; - { - std::lock_guard lock(mutex_); - const auto found = generation_retries_.find(rule_id); - if (found == generation_retries_.end() || found->second.task_id != task_value) { - return; - } - failure_count = found->second.failure_count; - generation_retries_.erase(found); - } - GenerateNextInstance(rule_id, failure_count); - }, - .on_result = - [this, rule_id, task_value](timing::RegisterTaskResult result) { - if (result != timing::RegisterTaskResult::kDuplicate) return; - std::lock_guard lock(mutex_); - const auto found = generation_retries_.find(rule_id); - if (found != generation_retries_.end() && found->second.task_id == task_value) { - generation_retries_.erase(found); - } - }, - }); - if (accepted == timing::CommandAcceptance::kUnavailable) { + const auto accepted = timing_service_->RegisterTask( + {.task_id = *id, + .trigger_at = ToTriggerAt(Now() + RetryDelay(failures)), + .callback = + [this, rule_id, value](const timing::TaskId& fired, timing::TriggerAt) { + if (fired.Value() != value) return; + int failures = 0; + { + std::lock_guard lock(mutex_); + auto found = generation_retries_.find(rule_id); + if (found == generation_retries_.end() || found->second.task_id != value) return; + failures = found->second.failure_count; + generation_retries_.erase(found); + } + GenerateNextInstance(rule_id, failures); + }, + .on_result = + [this, rule_id, value](timing::RegisterTaskResult result) { + if (result != timing::RegisterTaskResult::kDuplicate) return; + std::lock_guard lock(mutex_); + auto found = generation_retries_.find(rule_id); + if (found != generation_retries_.end() && found->second.task_id == value) + generation_retries_.erase(found); + }}); + if (accepted == timing::CommandAcceptance::kAccepted) return Status::Ok(); + { std::lock_guard lock(mutex_); - const auto found = generation_retries_.find(rule_id); - if (found != generation_retries_.end() && found->second.task_id == task_value) { - generation_retries_.erase(found); - } - return Status::Error(ErrorCode::kUnavailable, "周期生成重试注册命令未被接收"); + generation_retries_.erase(rule_id); } - return Status::Ok(); + return Status::Error(ErrorCode::kUnavailable, "周期生成重试注册命令未被接收"); } bool ScheduleReminderService::IsRunning() const { std::lock_guard lock(mutex_); return running_; } - } // namespace voicelife::schedule diff --git a/components/voicelife_schedule/src/service/schedule_service.cc b/components/voicelife_schedule/src/service/schedule_service.cc index 0e7ee676..57785d6a 100644 --- a/components/voicelife_schedule/src/service/schedule_service.cc +++ b/components/voicelife_schedule/src/service/schedule_service.cc @@ -252,7 +252,7 @@ UpdateScheduleResult ScheduleService::update_schedule(const UpdateScheduleComman }; } -Status ScheduleService::complete_schedule(ScheduleId schedule_id, std::optional expected_reminder_task_id) { +Status ScheduleService::complete_schedule(ScheduleId schedule_id) { if (schedule_id <= 0) { return Status::Error(ErrorCode::kInvalidArgument, "日程 ID 必须大于零"); } @@ -264,12 +264,8 @@ Status ScheduleService::complete_schedule(ScheduleId schedule_id, std::optional< if (completed.status != ScheduleStatus::kActive) { return Status::Error(ErrorCode::kConflict, "只有进行中的日程可以标记为已完成"); } - if (expected_reminder_task_id.has_value() && completed.reminder_task_id != expected_reminder_task_id) { - return Status::Error(ErrorCode::kConflict, "提醒任务已被替换,忽略过期回调"); - } completed.status = ScheduleStatus::kCompleted; - completed.reminder_task_id = std::nullopt; completed.updated_at = std::chrono::time_point_cast(std::chrono::system_clock::now()); return repository_.Update(completed); } diff --git a/components/voicelife_schedule/test/schedule_repository_service_test.cc b/components/voicelife_schedule/test/schedule_repository_service_test.cc index 8686074b..d1e51077 100644 --- a/components/voicelife_schedule/test/schedule_repository_service_test.cc +++ b/components/voicelife_schedule/test/schedule_repository_service_test.cc @@ -267,7 +267,6 @@ Schedule ExistingSchedule(int64_t id, int64_t start, int64_t end) { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = ScheduleStatus::kActive, .created_at = At(start - 100), .updated_at = At(start - 100), @@ -382,7 +381,6 @@ void CheckRepositoryQuery() { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = ScheduleStatus::kActive, .created_at = At(5'000), .updated_at = At(5'000), diff --git a/components/voicelife_storage_memory/CMakeLists.txt b/components/voicelife_storage_memory/CMakeLists.txt index 3b73d0b7..f6fd00ef 100644 --- a/components/voicelife_storage_memory/CMakeLists.txt +++ b/components/voicelife_storage_memory/CMakeLists.txt @@ -1,5 +1,6 @@ idf_component_register( SRCS "src/memory_schedule_repository.cc" + "src/memory_schedule_reminder_task_repository.cc" INCLUDE_DIRS "include" REQUIRES voicelife_contracts voicelife_schedule ) diff --git a/components/voicelife_storage_memory/include/voicelife/storage_memory/memory_schedule_reminder_task_repository.h b/components/voicelife_storage_memory/include/voicelife/storage_memory/memory_schedule_reminder_task_repository.h new file mode 100644 index 00000000..946502d4 --- /dev/null +++ b/components/voicelife_storage_memory/include/voicelife/storage_memory/memory_schedule_reminder_task_repository.h @@ -0,0 +1,51 @@ +#pragma once + +#include + +#include "voicelife/schedule/schedule_reminder_task_repository.h" + +namespace voicelife::storage_memory { + +/** @brief 供主机和内存配置使用的易失提醒任务仓储。 */ +class MemoryScheduleReminderTaskRepository final : public schedule::ScheduleReminderTaskRepository { + public: + /** @brief 插入提醒任务。 + * @param task 待插入任务。 + * @return 插入后的任务或错误状态。 + */ + Result Insert(const schedule::ScheduleReminderTask& task) override; + /** @brief 更新提醒任务。 + * @param task 待更新任务。 + * @return 更新操作状态。 + */ + Status Update(const schedule::ScheduleReminderTask& task) override; + /** @brief 按标识查询任务。 + * @param id 任务标识。 + * @return 查询结果。 + */ + [[nodiscard]] Result FindById(int64_t id) const override; + /** @brief 查询日程的提醒任务。 + * @param schedule_id 日程标识。 + * @return 查询结果。 + */ + [[nodiscard]] Result> FindBySchedule( + schedule::ScheduleId schedule_id) const override; + /** @brief 查询全部提醒任务。 + * @return 查询结果。 + */ + [[nodiscard]] Result> FindAll() const override; + /** @brief 查询时间范围内已触发任务。 + * @param from 起始时间。 + * @param to 结束时间。 + * @return 查询结果。 + */ + [[nodiscard]] Result> FindTriggered( + schedule::DateTime from, schedule::DateTime to) const override; + + private: + mutable std::mutex mutex_; + std::vector tasks_; + int64_t next_id_ = 1; +}; + +} // namespace voicelife::storage_memory diff --git a/components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc b/components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc new file mode 100644 index 00000000..aec6bf3e --- /dev/null +++ b/components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc @@ -0,0 +1,99 @@ +#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" + +#include + +namespace voicelife::storage_memory { +namespace { +using schedule::ScheduleReminderBusinessStatus; +using schedule::ScheduleReminderTask; +using schedule::ScheduleReminderTimerStatus; + +bool Valid(const ScheduleReminderTask& task) { + const int business_status = static_cast(task.business_status); + const int timer_status = static_cast(task.timer_status); + return task.schedule_id > 0 && task.chain_id > 0 && task.attempt > 0 && task.attempt <= 3 && + task.trigger_at != schedule::DateTime{} && + business_status >= static_cast(ScheduleReminderBusinessStatus::kScheduled) && + business_status <= static_cast(ScheduleReminderBusinessStatus::kCancelled) && + timer_status >= static_cast(ScheduleReminderTimerStatus::kPending) && + timer_status <= static_cast(ScheduleReminderTimerStatus::kFailed); +} +} // namespace + +Result MemoryScheduleReminderTaskRepository::Insert( + const schedule::ScheduleReminderTask& task) { + std::lock_guard lock(mutex_); + if (!Valid(task)) return Result::Failure(ErrorCode::kInvalidArgument, "提醒任务字段无效"); + if (std::any_of(tasks_.begin(), tasks_.end(), [&task](const auto& value) { + return value.chain_id == task.chain_id && value.attempt == task.attempt; + })) { + return Result::Failure(ErrorCode::kAlreadyExists, "提醒链尝试次数已存在"); + } + if (task.timing_task_id.has_value() && std::any_of(tasks_.begin(), tasks_.end(), [&task](const auto& value) { + return value.timing_task_id == task.timing_task_id; + })) { + return Result::Failure(ErrorCode::kAlreadyExists, "Timing task 标识已存在"); + } + ScheduleReminderTask stored = task; + stored.id = next_id_++; + const auto now = std::chrono::time_point_cast(std::chrono::system_clock::now()); + if (stored.created_at == schedule::DateTime{}) stored.created_at = now; + if (stored.updated_at == schedule::DateTime{}) stored.updated_at = stored.created_at; + tasks_.push_back(stored); + return Result::Success(std::move(stored)); +} + +Status MemoryScheduleReminderTaskRepository::Update(const ScheduleReminderTask& task) { + std::lock_guard lock(mutex_); + const auto found = + std::find_if(tasks_.begin(), tasks_.end(), [&task](const auto& value) { return value.id == task.id; }); + if (found == tasks_.end()) return Status::Error(ErrorCode::kNotFound, "提醒任务不存在"); + if (!Valid(task)) return Status::Error(ErrorCode::kInvalidArgument, "提醒任务字段无效"); + if (std::any_of(tasks_.begin(), tasks_.end(), [&task](const auto& value) { + return value.id != task.id && value.chain_id == task.chain_id && value.attempt == task.attempt; + })) + return Status::Error(ErrorCode::kAlreadyExists, "提醒链尝试次数已存在"); + if (task.timing_task_id.has_value() && std::any_of(tasks_.begin(), tasks_.end(), [&task](const auto& value) { + return value.id != task.id && value.timing_task_id == task.timing_task_id; + })) + return Status::Error(ErrorCode::kAlreadyExists, "Timing task 标识已存在"); + *found = task; + return Status::Ok(); +} + +Result MemoryScheduleReminderTaskRepository::FindById(int64_t id) const { + std::lock_guard lock(mutex_); + const auto found = std::find_if(tasks_.begin(), tasks_.end(), [id](const auto& value) { return value.id == id; }); + if (found == tasks_.end()) return Result::Failure(ErrorCode::kNotFound, "提醒任务不存在"); + return Result::Success(*found); +} + +Result> MemoryScheduleReminderTaskRepository::FindBySchedule( + schedule::ScheduleId schedule_id) const { + std::lock_guard lock(mutex_); + std::vector result; + for (const auto& task : tasks_) + if (task.schedule_id == schedule_id) result.push_back(task); + return Result>::Success(std::move(result)); +} + +Result> MemoryScheduleReminderTaskRepository::FindAll() const { + std::lock_guard lock(mutex_); + return Result>::Success(tasks_); +} + +Result> MemoryScheduleReminderTaskRepository::FindTriggered( + schedule::DateTime from, schedule::DateTime to) const { + std::lock_guard lock(mutex_); + std::vector result; + for (const auto& task : tasks_) { + if (task.triggered_at.has_value() && *task.triggered_at >= from && *task.triggered_at <= to && + task.timer_status == ScheduleReminderTimerStatus::kTriggered && + (task.business_status == ScheduleReminderBusinessStatus::kWaitingAcknowledgement || + task.business_status == ScheduleReminderBusinessStatus::kExhausted)) + result.push_back(task); + } + return Result>::Success(std::move(result)); +} + +} // namespace voicelife::storage_memory diff --git a/components/voicelife_storage_memory/test/memory_schedule_reminder_task_repository_test.cc b/components/voicelife_storage_memory/test/memory_schedule_reminder_task_repository_test.cc new file mode 100644 index 00000000..5997ff50 --- /dev/null +++ b/components/voicelife_storage_memory/test/memory_schedule_reminder_task_repository_test.cc @@ -0,0 +1,117 @@ +#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" + +#include +#include +#include + +namespace { + +using namespace voicelife; +using namespace voicelife::schedule; + +DateTime At(int64_t seconds) { return DateTime{std::chrono::seconds{seconds}}; } + +void Check(bool condition, const char* message) { + if (condition) { + return; + } + std::cerr << "FAILED: " << message << '\n'; + std::exit(1); +} + +ScheduleReminderTask MakeTask(int64_t schedule_id, int64_t chain_id, int attempt, std::string timing_task_id) { + return { + .schedule_id = schedule_id, + .chain_id = chain_id, + .attempt = attempt, + .timing_task_id = std::move(timing_task_id), + .trigger_at = At(1'100 + attempt), + .created_at = At(900), + .updated_at = At(900), + }; +} + +} // namespace + +int main() { + using voicelife::storage_memory::MemoryScheduleReminderTaskRepository; + + MemoryScheduleReminderTaskRepository repository; + const auto first = repository.Insert(MakeTask(1, 10, 1, "timing-1")); + Check(first.ok() && first.value->id == 1 && first.value->attempt == 1, "插入必须分配提醒任务标识并保留字段"); + + const auto second = repository.Insert(MakeTask(1, 10, 2, "timing-2")); + const auto third = repository.Insert(MakeTask(2, 20, 1, "timing-3")); + Check(second.ok() && third.ok(), "仓储必须保存多个日程和尝试次数"); + Check(repository.FindById(first.value->id).ok() && !repository.FindById(999).ok(), + "按标识查询必须区分存在和不存在记录"); + Check(repository.FindBySchedule(1).value->size() == 2 && repository.FindBySchedule(999).value->empty() && + repository.FindAll().value->size() == 3, + "仓储必须支持按日程和全量查询"); + + auto triggered = *first.value; + triggered.business_status = ScheduleReminderBusinessStatus::kWaitingAcknowledgement; + triggered.timer_status = ScheduleReminderTimerStatus::kTriggered; + triggered.triggered_at = At(1'200); + triggered.updated_at = At(1'200); + Check(repository.Update(triggered).ok(), "仓储必须更新已存在的提醒任务"); + const auto recent = repository.FindTriggered(At(1'190), At(1'200)); + Check(recent.ok() && recent.value->size() == 1 && recent.value->front().id == triggered.id, + "触发查询必须包含窗口边界内等待确认的任务"); + + auto exhausted = *second.value; + exhausted.business_status = ScheduleReminderBusinessStatus::kExhausted; + exhausted.timer_status = ScheduleReminderTimerStatus::kTriggered; + exhausted.triggered_at = At(1'190); + Check(repository.Update(exhausted).ok(), "仓储必须保存耗尽终态"); + Check(repository.FindTriggered(At(1'190), At(1'200)).value->size() == 2, "触发查询必须同时返回等待确认和耗尽任务"); + + exhausted.business_status = ScheduleReminderBusinessStatus::kAcknowledged; + Check(repository.Update(exhausted).ok(), "仓储必须保存确认终态"); + Check(repository.FindTriggered(At(1'190), At(1'200)).value->size() == 1, "触发查询必须排除已确认终态"); + + auto cancelled = triggered; + cancelled.business_status = ScheduleReminderBusinessStatus::kCancelled; + Check(repository.Update(cancelled).ok(), "仓储必须保存取消终态"); + Check(repository.FindTriggered(At(1'190), At(1'200)).value->empty(), "触发查询必须排除已取消终态"); + + Check(!repository.Insert(MakeTask(1, 10, 1, "timing-duplicate-attempt")).ok(), + "同一提醒链不能重复保存相同尝试次数"); + Check(!repository.Insert(MakeTask(3, 30, 1, "timing-2")).ok(), "Timing task 标识必须唯一"); + Check(!repository.Insert(MakeTask(0, 30, 1, "invalid-schedule")).ok() && + !repository.Insert(MakeTask(3, 0, 1, "invalid-chain")).ok() && + !repository.Insert(MakeTask(3, 30, 0, "invalid-attempt-zero")).ok() && + !repository.Insert(MakeTask(3, 30, 4, "invalid-attempt-four")).ok(), + "插入必须拒绝非法日程、提醒链和尝试次数"); + auto invalid_status = MakeTask(3, 30, 1, "invalid-status"); + invalid_status.business_status = static_cast(99); + Check(!repository.Insert(invalid_status).ok(), "插入必须拒绝非法业务状态"); + invalid_status = MakeTask(3, 30, 1, "invalid-timer-status"); + invalid_status.timer_status = static_cast(99); + Check(!repository.Insert(invalid_status).ok(), "插入必须拒绝非法定时器状态"); + + ScheduleReminderTask missing = MakeTask(1, 40, 1, "missing"); + missing.id = 999; + Check(!repository.Update(missing).ok(), "更新不存在的提醒任务必须失败"); + + auto duplicate_attempt = *third.value; + duplicate_attempt.chain_id = 10; + duplicate_attempt.attempt = 2; + Check(!repository.Update(duplicate_attempt).ok(), "更新不能制造重复提醒链尝试次数"); + auto duplicate_timing_id = *third.value; + duplicate_timing_id.timing_task_id = "timing-2"; + Check(!repository.Update(duplicate_timing_id).ok(), "更新不能制造重复 Timing task 标识"); + auto invalid = *third.value; + invalid.attempt = 4; + Check(!repository.Update(invalid).ok(), "更新必须拒绝非法任务字段"); + + MemoryScheduleReminderTaskRepository default_time_repository; + auto default_time_task = MakeTask(1, 50, 1, "default-time"); + default_time_task.created_at = DateTime{}; + default_time_task.updated_at = DateTime{}; + const auto default_time = default_time_repository.Insert(default_time_task); + Check(default_time.ok() && default_time.value->created_at != DateTime{} && + default_time.value->updated_at == default_time.value->created_at, + "未提供时间戳时仓储必须生成一致的创建和更新时间"); + return 0; +} diff --git a/components/voicelife_storage_sqlite/CMakeLists.txt b/components/voicelife_storage_sqlite/CMakeLists.txt index 8f71b04b..5d7817da 100644 --- a/components/voicelife_storage_sqlite/CMakeLists.txt +++ b/components/voicelife_storage_sqlite/CMakeLists.txt @@ -2,6 +2,7 @@ set(voicelife_storage_sqlite_sources) if(CONFIG_VOICELIFE_STORAGE_SQLITE) list(APPEND voicelife_storage_sqlite_sources "src/mapping/schedule_row_mapper.cc" + "src/mapping/schedule_reminder_task_row_mapper.cc" "src/mapping/operation_row_mapper.cc" "src/mapping/schedule_rule_row_mapper.cc" "src/mapping/schedule_exception_row_mapper.cc" @@ -10,14 +11,17 @@ if(CONFIG_VOICELIFE_STORAGE_SQLITE) "src/schema/migrations/v003_create_schedule_rule.cc" "src/schema/migrations/v004_create_operation_record.cc" "src/schema/migrations/v005_add_schedule_reminder_task_id.cc" + "src/schema/migrations/v006_create_schedule_reminder_task.cc" "src/schema/sqlite_schema.cc" "src/schema/voicelife_schema.cc" "src/sql/operation_sql.cc" "src/sql/schedule_sql.cc" + "src/sql/schedule_reminder_task_sql.cc" "src/sql/schedule_rule_sql.cc" "src/sql/schedule_exception_sql.cc" "src/sqlite_database.cc" "src/sqlite_schedule_repository.cc" + "src/sqlite_schedule_reminder_task_repository.cc" "src/sqlite_schedule_rule_repository.cc" ) endif() diff --git a/components/voicelife_storage_sqlite/include/voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h b/components/voicelife_storage_sqlite/include/voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h new file mode 100644 index 00000000..b8811f4e --- /dev/null +++ b/components/voicelife_storage_sqlite/include/voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h @@ -0,0 +1,54 @@ +#pragma once + +#include "voicelife/schedule/schedule_reminder_task_repository.h" +#include "voicelife/storage_sqlite/sqlite_database.h" + +namespace voicelife::storage_sqlite { + +/** @brief 使用 SQLite 持久化独立的日程提醒任务记录。 */ +class SqliteScheduleReminderTaskRepository final : public schedule::ScheduleReminderTaskRepository { + public: + /** @brief 构造 SQLite 提醒任务仓储。 + * @param database SQLite 数据库实例。 + */ + explicit SqliteScheduleReminderTaskRepository(SqliteDatabase& database); + + /** @brief 插入提醒任务。 + * @param task 待插入任务。 + * @return 插入后的任务或错误状态。 + */ + Result Insert(const schedule::ScheduleReminderTask& task) override; + /** @brief 更新提醒任务。 + * @param task 待更新任务。 + * @return 更新操作状态。 + */ + Status Update(const schedule::ScheduleReminderTask& task) override; + /** @brief 按标识查询任务。 + * @param id 任务标识。 + * @return 查询结果。 + */ + [[nodiscard]] Result FindById(int64_t id) const override; + /** @brief 查询日程的提醒任务。 + * @param schedule_id 日程标识。 + * @return 查询结果。 + */ + [[nodiscard]] Result> FindBySchedule( + schedule::ScheduleId schedule_id) const override; + /** @brief 查询全部提醒任务。 + * @return 查询结果。 + */ + [[nodiscard]] Result> FindAll() const override; + /** @brief 查询时间范围内已触发任务。 + * @param from 起始时间。 + * @param to 结束时间。 + * @return 查询结果。 + */ + [[nodiscard]] Result> FindTriggered( + schedule::DateTime from, schedule::DateTime to) const override; + + private: + SqliteDatabase& database_; + mutable std::mutex mutex_; +}; + +} // namespace voicelife::storage_sqlite diff --git a/components/voicelife_storage_sqlite/include/voicelife/storage_sqlite/voicelife_schema.h b/components/voicelife_storage_sqlite/include/voicelife/storage_sqlite/voicelife_schema.h index 5e81daa7..7b958f49 100644 --- a/components/voicelife_storage_sqlite/include/voicelife/storage_sqlite/voicelife_schema.h +++ b/components/voicelife_storage_sqlite/include/voicelife/storage_sqlite/voicelife_schema.h @@ -14,7 +14,7 @@ namespace voicelife::storage_sqlite { class VoiceLifeSchema final { public: /** @brief 当前固件支持的 VoiceLife 数据库 Schema 版本。 */ - static constexpr SchemaVersion kCurrentVersion = 5; + static constexpr SchemaVersion kCurrentVersion = 6; /** * @brief 将已打开的数据库升级到当前 VoiceLife Schema 并执行完整性检查。 diff --git a/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.cc b/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.cc new file mode 100644 index 00000000..1298f66e --- /dev/null +++ b/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.cc @@ -0,0 +1,64 @@ +#include "mapping/schedule_reminder_task_row_mapper.h" + +#include + +namespace voicelife::storage_sqlite::mapping { +namespace { +schedule::DateTime ReadTime(const SqliteStatement& statement, int column) { + return schedule::DateTime{std::chrono::seconds{statement.ColumnInt64(column)}}; +} + +bool ValidBusinessStatus(int value) { + return value >= static_cast(schedule::ScheduleReminderBusinessStatus::kScheduled) && + value <= static_cast(schedule::ScheduleReminderBusinessStatus::kCancelled); +} + +bool ValidTimerStatus(int value) { + return value >= static_cast(schedule::ScheduleReminderTimerStatus::kPending) && + value <= static_cast(schedule::ScheduleReminderTimerStatus::kFailed); +} +} // namespace + +Status BindScheduleReminderTask(SqliteStatement& statement, const schedule::ScheduleReminderTask& task) { + int index = 1; + Status status = statement.BindInt64(index++, task.schedule_id); + if (!status.ok()) return status; + if (!(status = statement.BindInt64(index++, task.chain_id)).ok()) return status; + if (!(status = statement.BindInt(index++, task.attempt)).ok()) return status; + if (!(status = task.timing_task_id.has_value() ? statement.BindText(index++, *task.timing_task_id) + : statement.BindNull(index++)) + .ok()) + return status; + if (!(status = statement.BindInt64(index++, task.trigger_at.time_since_epoch().count())).ok()) return status; + if (!(status = statement.BindInt(index++, static_cast(task.business_status))).ok()) return status; + if (!(status = statement.BindInt(index++, static_cast(task.timer_status))).ok()) return status; + if (!(status = task.triggered_at.has_value() + ? statement.BindInt64(index++, task.triggered_at->time_since_epoch().count()) + : statement.BindNull(index++)) + .ok()) + return status; + if (!(status = statement.BindInt64(index++, task.created_at.time_since_epoch().count())).ok()) return status; + return statement.BindInt64(index, task.updated_at.time_since_epoch().count()); +} + +Result ReadScheduleReminderTask(const SqliteStatement& statement) { + const int business_status = statement.ColumnInt(6); + const int timer_status = statement.ColumnInt(7); + if (!ValidBusinessStatus(business_status) || !ValidTimerStatus(timer_status)) { + return Result::Failure(ErrorCode::kInternal, "提醒任务状态值非法"); + } + schedule::ScheduleReminderTask task; + task.id = statement.ColumnInt64(0); + task.schedule_id = statement.ColumnInt64(1); + task.chain_id = statement.ColumnInt64(2); + task.attempt = statement.ColumnInt(3); + if (!statement.IsNull(4)) task.timing_task_id = statement.ColumnText(4); + task.trigger_at = ReadTime(statement, 5); + task.business_status = static_cast(business_status); + task.timer_status = static_cast(timer_status); + if (!statement.IsNull(8)) task.triggered_at = ReadTime(statement, 8); + task.created_at = ReadTime(statement, 9); + task.updated_at = ReadTime(statement, 10); + return Result::Success(std::move(task)); +} +} // namespace voicelife::storage_sqlite::mapping diff --git a/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.h b/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.h new file mode 100644 index 00000000..65566fb6 --- /dev/null +++ b/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.h @@ -0,0 +1,11 @@ +#pragma once + +#include "voicelife/schedule/schedule_reminder_task_repository.h" +#include "voicelife/storage_sqlite/sqlite_database.h" + +namespace voicelife::storage_sqlite { +namespace mapping { +Status BindScheduleReminderTask(SqliteStatement& statement, const schedule::ScheduleReminderTask& task); +Result ReadScheduleReminderTask(const SqliteStatement& statement); +} // namespace mapping +} // namespace voicelife::storage_sqlite diff --git a/components/voicelife_storage_sqlite/src/mapping/schedule_row_mapper.cc b/components/voicelife_storage_sqlite/src/mapping/schedule_row_mapper.cc index 0bc395f3..0a47349b 100644 --- a/components/voicelife_storage_sqlite/src/mapping/schedule_row_mapper.cc +++ b/components/voicelife_storage_sqlite/src/mapping/schedule_row_mapper.cc @@ -8,79 +8,33 @@ namespace voicelife::storage_sqlite::mapping { namespace { - -/** - * @brief 为字段错误补充字段名。 - * @param status 底层绑定状态。 - * @param field 正在绑定的字段名。 - * @return 带字段上下文的状态。 - */ Status WithField(Status status, const char* field) { if (status.ok()) return status; std::string message = std::string("绑定日程字段失败:") + field; if (!status.message.empty()) message += ";" + status.message; return Status::Error(status.code, std::move(message)); } - -/** - * @brief 绑定可空的 64 位整数。 - * @param statement SQLite 语句包装器。 - * @param index 参数序号。 - * @param value 待绑定的可选值。 - * @param field 字段名。 - * @return 绑定成功时返回成功状态。 - */ Status BindOptionalInt64(SqliteStatement& statement, int index, const std::optional& value, const char* field) { return WithField(value.has_value() ? statement.BindInt64(index, *value) : statement.BindNull(index), field); } - -/** - * @brief 绑定可空文本。 - * @param statement SQLite 语句包装器。 - * @param index 参数序号。 - * @param value 待绑定的可选文本。 - * @param field 字段名。 - * @return 绑定成功时返回成功状态。 - */ Status BindOptionalText(SqliteStatement& statement, int index, const std::optional& value, const char* field) { return WithField(value.has_value() ? statement.BindText(index, *value) : statement.BindNull(index), field); } - -/** - * @brief 判断数据库状态值是否属于领域枚举。 - * @param value 数据库整数值。 - * @return 状态有效时返回 true。 - */ bool IsValidStatus(int value) { return value == static_cast(schedule::ScheduleStatus::kActive) || value == static_cast(schedule::ScheduleStatus::kCancelled) || value == static_cast(schedule::ScheduleStatus::kCompleted); } - -/** - * @brief 读取可空的 Unix 秒时间。 - * @param statement SQLite 语句包装器。 - * @param column 结果列序号。 - * @return 空值或转换后的日程时间。 - */ std::optional ReadOptionalTime(const SqliteStatement& statement, int column) { if (statement.IsNull(column)) return std::nullopt; return schedule::DateTime{std::chrono::seconds{statement.ColumnInt64(column)}}; } - -/** - * @brief 读取可空文本。 - * @param statement SQLite 语句包装器。 - * @param column 结果列序号。 - * @return 空值或复制后的字符串。 - */ std::optional ReadOptionalText(const SqliteStatement& statement, int column) { if (statement.IsNull(column)) return std::nullopt; return statement.ColumnText(column); } - } // namespace Status BindSchedule(SqliteStatement& statement, const schedule::Schedule& schedule) { @@ -103,8 +57,6 @@ Status BindSchedule(SqliteStatement& statement, const schedule::Schedule& schedu if (!status.ok()) return status; status = BindOptionalInt64(statement, index++, schedule.rule_id, "rule_id"); if (!status.ok()) return status; - status = BindOptionalInt64(statement, index++, schedule.reminder_task_id, "reminder_task_id"); - if (!status.ok()) return status; status = WithField(statement.BindInt(index++, static_cast(schedule.status)), "status"); if (!status.ok()) return status; status = WithField(statement.BindInt64(index++, schedule.created_at.time_since_epoch().count()), "created_at"); @@ -113,14 +65,10 @@ Status BindSchedule(SqliteStatement& statement, const schedule::Schedule& schedu } Result ReadSchedule(const SqliteStatement& statement) { - const int status_value = statement.ColumnInt(8); - if (!IsValidStatus(status_value)) { + const int status_value = statement.ColumnInt(7); + if (!IsValidStatus(status_value)) return Result::Failure(ErrorCode::kInternal, "数据库中的日程状态无效"); - } - if (statement.IsNull(1)) { - return Result::Failure(ErrorCode::kInternal, "数据库中的日程名称为空"); - } - + if (statement.IsNull(1)) return Result::Failure(ErrorCode::kInternal, "数据库中的日程名称为空"); schedule::Schedule schedule{ .id = statement.ColumnInt64(0), .event = statement.ColumnText(1), @@ -129,17 +77,11 @@ Result ReadSchedule(const SqliteStatement& statement) { .location = ReadOptionalText(statement, 4), .notes = ReadOptionalText(statement, 5), .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = static_cast(status_value), - .created_at = schedule::DateTime{std::chrono::seconds{statement.ColumnInt64(9)}}, - .updated_at = schedule::DateTime{std::chrono::seconds{statement.ColumnInt64(10)}}, + .created_at = schedule::DateTime{std::chrono::seconds{statement.ColumnInt64(8)}}, + .updated_at = schedule::DateTime{std::chrono::seconds{statement.ColumnInt64(9)}}, }; - if (!statement.IsNull(6)) { - schedule.rule_id = statement.ColumnInt64(6); - } - if (!statement.IsNull(7)) { - schedule.reminder_task_id = statement.ColumnInt64(7); - } + if (!statement.IsNull(6)) schedule.rule_id = statement.ColumnInt64(6); return Result::Success(std::move(schedule)); } diff --git a/components/voicelife_storage_sqlite/src/schema/migrations/v006_create_schedule_reminder_task.cc b/components/voicelife_storage_sqlite/src/schema/migrations/v006_create_schedule_reminder_task.cc new file mode 100644 index 00000000..e1ff2346 --- /dev/null +++ b/components/voicelife_storage_sqlite/src/schema/migrations/v006_create_schedule_reminder_task.cc @@ -0,0 +1,65 @@ +#include "schema/migrations/v006_create_schedule_reminder_task.h" + +namespace voicelife::storage_sqlite::schema::migrations { +namespace { + +constexpr char kCreateScheduleReminderTask[] = R"sql( +CREATE TABLE schedule_reminder_task ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + schedule_id INTEGER NOT NULL, + chain_id INTEGER NOT NULL, + attempt INTEGER NOT NULL CHECK (attempt BETWEEN 1 AND 3), + timing_task_id TEXT, + trigger_at INTEGER NOT NULL, + business_status INTEGER NOT NULL CHECK (business_status BETWEEN 1 AND 5), + timer_status INTEGER NOT NULL CHECK (timer_status BETWEEN 1 AND 5), + triggered_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (chain_id, attempt), + UNIQUE (timing_task_id) +); +CREATE INDEX schedule_reminder_task_triggered_idx + ON schedule_reminder_task (triggered_at, timer_status, business_status); +CREATE INDEX schedule_reminder_task_schedule_idx + ON schedule_reminder_task (schedule_id, chain_id, attempt); + +INSERT INTO schedule_reminder_task ( + schedule_id, chain_id, attempt, timing_task_id, trigger_at, + business_status, timer_status, triggered_at, created_at, updated_at +) +SELECT id, reminder_task_id, 1, CAST(reminder_task_id AS TEXT), + COALESCE(start_time, created_at), 1, 1, NULL, created_at, updated_at +FROM schedule +WHERE reminder_task_id IS NOT NULL; + +ALTER TABLE schedule RENAME TO schedule_v005; +CREATE TABLE schedule ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id INTEGER, + event TEXT NOT NULL CHECK (length(event) <= 100), + start_time INTEGER, + end_time INTEGER, + location TEXT CHECK (location IS NULL OR length(location) <= 100), + notes TEXT CHECK (notes IS NULL OR length(notes) <= 200), + status INTEGER NOT NULL DEFAULT 1 CHECK (status IN (1, 2, 3)), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK (end_time IS NULL OR (start_time IS NOT NULL AND end_time > start_time)) +); +INSERT INTO schedule ( + id, rule_id, event, start_time, end_time, location, notes, status, created_at, updated_at +) +SELECT id, rule_id, event, start_time, end_time, location, notes, status, created_at, updated_at +FROM schedule_v005; +DROP TABLE schedule_v005; +CREATE INDEX schedule_rule_idx ON schedule (rule_id); +CREATE INDEX schedule_start_time_idx ON schedule (start_time); +)sql"; +} + +Status ApplyV006CreateScheduleReminderTask(SqliteDatabase& database) { + return database.Execute(kCreateScheduleReminderTask); +} + +} // namespace voicelife::storage_sqlite::schema::migrations diff --git a/components/voicelife_storage_sqlite/src/schema/migrations/v006_create_schedule_reminder_task.h b/components/voicelife_storage_sqlite/src/schema/migrations/v006_create_schedule_reminder_task.h new file mode 100644 index 00000000..ad48abaf --- /dev/null +++ b/components/voicelife_storage_sqlite/src/schema/migrations/v006_create_schedule_reminder_task.h @@ -0,0 +1,8 @@ +#pragma once + +#include "voicelife/contracts/status.h" +#include "voicelife/storage_sqlite/sqlite_database.h" + +namespace voicelife::storage_sqlite::schema::migrations { +Status ApplyV006CreateScheduleReminderTask(SqliteDatabase& database); +} diff --git a/components/voicelife_storage_sqlite/src/schema/voicelife_schema.cc b/components/voicelife_storage_sqlite/src/schema/voicelife_schema.cc index a766c7c3..3ebe8d4e 100644 --- a/components/voicelife_storage_sqlite/src/schema/voicelife_schema.cc +++ b/components/voicelife_storage_sqlite/src/schema/voicelife_schema.cc @@ -7,23 +7,20 @@ #include "schema/migrations/v003_create_schedule_rule.h" #include "schema/migrations/v004_create_operation_record.h" #include "schema/migrations/v005_add_schedule_reminder_task_id.h" +#include "schema/migrations/v006_create_schedule_reminder_task.h" namespace voicelife::storage_sqlite { namespace { - -/** @brief VoiceLife 数据库从版本零开始按顺序执行的正式迁移清单。 */ constexpr SqliteMigration kMigrations[] = { {.version = 1, .apply = &schema::migrations::ApplyV001CreateSchedule}, {.version = 2, .apply = &schema::migrations::ApplyV002CreateScheduleOperation}, {.version = 3, .apply = &schema::migrations::ApplyV003CreateScheduleRule}, {.version = 4, .apply = &schema::migrations::ApplyV004CreateOperationRecord}, {.version = 5, .apply = &schema::migrations::ApplyV005AddScheduleReminderTaskId}, + {.version = 6, .apply = &schema::migrations::ApplyV006CreateScheduleReminderTask}, }; - -} // namespace - +} Status VoiceLifeSchema::Initialize(SqliteDatabase& database) { return SqliteSchema::Initialize(database, kCurrentVersion, kMigrations, std::size(kMigrations)); } - } // namespace voicelife::storage_sqlite diff --git a/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.cc b/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.cc new file mode 100644 index 00000000..36fbc0f9 --- /dev/null +++ b/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.cc @@ -0,0 +1,32 @@ +#include "sql/schedule_reminder_task_sql.h" + +namespace voicelife::storage_sqlite::sql { +const char kInsertScheduleReminderTask[] = R"sql( +INSERT INTO schedule_reminder_task +(schedule_id, chain_id, attempt, timing_task_id, trigger_at, business_status, timer_status, + triggered_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +)sql"; +const char kFindAllScheduleReminderTasks[] = R"sql( +SELECT id, schedule_id, chain_id, attempt, timing_task_id, trigger_at, business_status, timer_status, + triggered_at, created_at, updated_at FROM schedule_reminder_task ORDER BY trigger_at, id +)sql"; +const char kFindScheduleReminderTaskById[] = R"sql( +SELECT id, schedule_id, chain_id, attempt, timing_task_id, trigger_at, business_status, timer_status, + triggered_at, created_at, updated_at FROM schedule_reminder_task WHERE id = ? +)sql"; +const char kFindScheduleReminderTasksBySchedule[] = R"sql( +SELECT id, schedule_id, chain_id, attempt, timing_task_id, trigger_at, business_status, timer_status, + triggered_at, created_at, updated_at FROM schedule_reminder_task WHERE schedule_id = ? ORDER BY chain_id, attempt, id +)sql"; +const char kFindTriggeredScheduleReminderTasks[] = R"sql( +SELECT id, schedule_id, chain_id, attempt, timing_task_id, trigger_at, business_status, timer_status, + triggered_at, created_at, updated_at FROM schedule_reminder_task +WHERE triggered_at >= ? AND triggered_at <= ? AND timer_status = 2 + AND business_status IN (2, 4) +ORDER BY triggered_at, id +)sql"; +const char kUpdateScheduleReminderTask[] = R"sql( +UPDATE schedule_reminder_task SET schedule_id = ?, chain_id = ?, attempt = ?, timing_task_id = ?, trigger_at = ?, + business_status = ?, timer_status = ?, triggered_at = ?, created_at = ?, updated_at = ? WHERE id = ? +)sql"; +} // namespace voicelife::storage_sqlite::sql diff --git a/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.h b/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.h new file mode 100644 index 00000000..96ee80b8 --- /dev/null +++ b/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.h @@ -0,0 +1,10 @@ +#pragma once + +namespace voicelife::storage_sqlite::sql { +extern const char kInsertScheduleReminderTask[]; +extern const char kFindAllScheduleReminderTasks[]; +extern const char kFindScheduleReminderTaskById[]; +extern const char kFindScheduleReminderTasksBySchedule[]; +extern const char kFindTriggeredScheduleReminderTasks[]; +extern const char kUpdateScheduleReminderTask[]; +} // namespace voicelife::storage_sqlite::sql diff --git a/components/voicelife_storage_sqlite/src/sql/schedule_sql.cc b/components/voicelife_storage_sqlite/src/sql/schedule_sql.cc index 75a91765..2fa0c3d1 100644 --- a/components/voicelife_storage_sqlite/src/sql/schedule_sql.cc +++ b/components/voicelife_storage_sqlite/src/sql/schedule_sql.cc @@ -6,34 +6,33 @@ namespace voicelife::storage_sqlite::sql { const char kInsertSchedule[] = R"sql( INSERT INTO schedule ( - event, start_time, end_time, location, notes, rule_id, reminder_task_id, - status, created_at, updated_at -) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + event, start_time, end_time, location, notes, rule_id, status, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) )sql"; const char kFindAllSchedules[] = R"sql( SELECT id, event, start_time, end_time, location, notes, rule_id, - reminder_task_id, status, created_at, updated_at + status, created_at, updated_at FROM schedule ORDER BY start_time IS NULL, start_time, id )sql"; const char kUpdateSchedule[] = R"sql( UPDATE schedule SET event = ?, start_time = ?, end_time = ?, location = ?, notes = ?, -rule_id = ?, reminder_task_id = ?, status = ?, created_at = ?, updated_at = ? WHERE id = ? +rule_id = ?, status = ?, created_at = ?, updated_at = ? WHERE id = ? )sql"; const char kCancelSchedule[] = "UPDATE schedule SET status = 2, updated_at = ? WHERE id = ? AND status <> 2"; const char kFindScheduleById[] = R"sql( SELECT id, event, start_time, end_time, location, notes, rule_id, - reminder_task_id, status, created_at, updated_at + status, created_at, updated_at FROM schedule WHERE id = ? )sql"; const char kFindOverlappingSchedules[] = R"sql( SELECT id, event, start_time, end_time, location, notes, rule_id, - reminder_task_id, status, created_at, updated_at + status, created_at, updated_at FROM schedule WHERE status = 1 AND start_time IS NOT NULL @@ -58,7 +57,7 @@ std::string BuildScheduleFindSql(const schedule::QueryScheduleCommand& query) { (void)query; return R"sql( SELECT id, event, start_time, end_time, location, notes, rule_id, - reminder_task_id, status, created_at, updated_at + status, created_at, updated_at FROM schedule )sql" + BuildScheduleWhere() + R"sql( diff --git a/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc b/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc new file mode 100644 index 00000000..8e14e1ba --- /dev/null +++ b/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc @@ -0,0 +1,136 @@ +#include "voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h" + +#include +#include + +#include "mapping/schedule_reminder_task_row_mapper.h" +#include "sql/schedule_reminder_task_sql.h" + +namespace voicelife::storage_sqlite { +namespace { +using schedule::ScheduleReminderTask; +bool Valid(const ScheduleReminderTask& task) { + const int business_status = static_cast(task.business_status); + const int timer_status = static_cast(task.timer_status); + return task.schedule_id > 0 && task.chain_id > 0 && task.attempt >= 1 && task.attempt <= 3 && + task.trigger_at != schedule::DateTime{} && + business_status >= static_cast(schedule::ScheduleReminderBusinessStatus::kScheduled) && + business_status <= static_cast(schedule::ScheduleReminderBusinessStatus::kCancelled) && + timer_status >= static_cast(schedule::ScheduleReminderTimerStatus::kPending) && + timer_status <= static_cast(schedule::ScheduleReminderTimerStatus::kFailed); +} +Status Invalid() { return Status::Error(ErrorCode::kInvalidArgument, "提醒任务字段无效"); } +Status Unavailable() { return Status::Error(ErrorCode::kUnavailable, "SQLite 数据库尚未打开"); } +Result ReadOne(SqliteStatement& statement) { + const auto step = statement.Step(); + if (!step.ok()) return Result::Failure(step.status.code, step.status.message); + if (*step.value != SqliteStep::kRow) + return Result::Failure(ErrorCode::kNotFound, "提醒任务不存在"); + return mapping::ReadScheduleReminderTask(statement); +} +Result> ReadMany(SqliteStatement& statement) { + std::vector result; + while (true) { + const auto step = statement.Step(); + if (!step.ok()) + return Result>::Failure(step.status.code, step.status.message); + if (*step.value == SqliteStep::kDone) break; + const auto row = mapping::ReadScheduleReminderTask(statement); + if (!row.ok()) return Result>::Failure(row.status.code, row.status.message); + result.push_back(*row.value); + } + return Result>::Success(std::move(result)); +} +} // namespace + +SqliteScheduleReminderTaskRepository::SqliteScheduleReminderTaskRepository(SqliteDatabase& database) + : database_(database) {} + +Result SqliteScheduleReminderTaskRepository::Insert(const ScheduleReminderTask& task) { + std::lock_guard lock(mutex_); + if (!database_.IsOpen()) + return Result::Failure(ErrorCode::kUnavailable, Unavailable().message); + if (!Valid(task)) return Result::Failure(ErrorCode::kInvalidArgument, Invalid().message); + ScheduleReminderTask stored = task; + const auto now = std::chrono::time_point_cast(std::chrono::system_clock::now()); + if (stored.created_at == schedule::DateTime{}) stored.created_at = now; + if (stored.updated_at == schedule::DateTime{}) stored.updated_at = stored.created_at; + auto prepared = database_.Prepare(sql::kInsertScheduleReminderTask); + if (!prepared.ok()) return Result::Failure(prepared.status.code, prepared.status.message); + auto statement = std::move(*prepared.value); + auto status = mapping::BindScheduleReminderTask(statement, stored); + if (!status.ok()) return Result::Failure(status.code, status.message); + auto step = statement.Step(); + if (!step.ok()) return Result::Failure(step.status.code, step.status.message); + stored.id = statement.LastInsertRowId(); + return Result::Success(std::move(stored)); +} + +Status SqliteScheduleReminderTaskRepository::Update(const ScheduleReminderTask& task) { + std::lock_guard lock(mutex_); + if (!database_.IsOpen()) return Unavailable(); + if (!Valid(task)) return Invalid(); + auto prepared = database_.Prepare(sql::kUpdateScheduleReminderTask); + if (!prepared.ok()) return prepared.status; + auto statement = std::move(*prepared.value); + auto status = mapping::BindScheduleReminderTask(statement, task); + if (!status.ok()) return status; + if (!(status = statement.BindInt64(11, task.id)).ok()) return status; + auto step = statement.Step(); + if (!step.ok()) return step.status; + return statement.Changes() == 1 ? Status::Ok() : Status::Error(ErrorCode::kNotFound, "提醒任务不存在"); +} + +Result SqliteScheduleReminderTaskRepository::FindById(int64_t id) const { + std::lock_guard lock(mutex_); + if (!database_.IsOpen()) + return Result::Failure(ErrorCode::kUnavailable, Unavailable().message); + auto prepared = database_.Prepare(sql::kFindScheduleReminderTaskById); + if (!prepared.ok()) return Result::Failure(prepared.status.code, prepared.status.message); + auto statement = std::move(*prepared.value); + auto status = statement.BindInt64(1, id); + if (!status.ok()) return Result::Failure(status.code, status.message); + return ReadOne(statement); +} + +Result> SqliteScheduleReminderTaskRepository::FindBySchedule( + schedule::ScheduleId id) const { + std::lock_guard lock(mutex_); + if (!database_.IsOpen()) + return Result>::Failure(ErrorCode::kUnavailable, Unavailable().message); + auto prepared = database_.Prepare(sql::kFindScheduleReminderTasksBySchedule); + if (!prepared.ok()) + return Result>::Failure(prepared.status.code, prepared.status.message); + auto statement = std::move(*prepared.value); + auto status = statement.BindInt64(1, id); + if (!status.ok()) return Result>::Failure(status.code, status.message); + return ReadMany(statement); +} + +Result> SqliteScheduleReminderTaskRepository::FindAll() const { + std::lock_guard lock(mutex_); + if (!database_.IsOpen()) + return Result>::Failure(ErrorCode::kUnavailable, Unavailable().message); + auto prepared = database_.Prepare(sql::kFindAllScheduleReminderTasks); + if (!prepared.ok()) + return Result>::Failure(prepared.status.code, prepared.status.message); + auto statement = std::move(*prepared.value); + return ReadMany(statement); +} + +Result> SqliteScheduleReminderTaskRepository::FindTriggered( + schedule::DateTime from, schedule::DateTime to) const { + std::lock_guard lock(mutex_); + if (!database_.IsOpen()) + return Result>::Failure(ErrorCode::kUnavailable, Unavailable().message); + auto prepared = database_.Prepare(sql::kFindTriggeredScheduleReminderTasks); + if (!prepared.ok()) + return Result>::Failure(prepared.status.code, prepared.status.message); + auto statement = std::move(*prepared.value); + auto status = statement.BindInt64(1, from.time_since_epoch().count()); + if (!status.ok()) return Result>::Failure(status.code, status.message); + status = statement.BindInt64(2, to.time_since_epoch().count()); + if (!status.ok()) return Result>::Failure(status.code, status.message); + return ReadMany(statement); +} +} // namespace voicelife::storage_sqlite diff --git a/components/voicelife_storage_sqlite/src/sqlite_schedule_repository.cc b/components/voicelife_storage_sqlite/src/sqlite_schedule_repository.cc index 588ade22..0aefeeb4 100644 --- a/components/voicelife_storage_sqlite/src/sqlite_schedule_repository.cc +++ b/components/voicelife_storage_sqlite/src/sqlite_schedule_repository.cc @@ -298,7 +298,7 @@ Status SqliteScheduleRepository::Update(const Schedule& schedule) { SqliteStatement statement = std::move(*prepared.value); Status status = mapping::BindSchedule(statement, schedule); if (!status.ok()) return status; - status = statement.BindInt64(11, schedule.id); + status = statement.BindInt64(10, schedule.id); if (!status.ok()) return status; const Result stepped = statement.Step(); if (!stepped.ok()) return stepped.status; diff --git a/components/voicelife_storage_sqlite/test/sqlite_schedule_reminder_task_repository_test.cc b/components/voicelife_storage_sqlite/test/sqlite_schedule_reminder_task_repository_test.cc new file mode 100644 index 00000000..82c55f88 --- /dev/null +++ b/components/voicelife_storage_sqlite/test/sqlite_schedule_reminder_task_repository_test.cc @@ -0,0 +1,118 @@ +#include "voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h" + +#include +#include +#include +#include + +#include "support/test_support.h" +#include "voicelife/schedule/schedule_reminder_task_repository.h" +#include "voicelife/storage_sqlite/sqlite_database.h" +#include "voicelife/storage_sqlite/sqlite_schedule_repository.h" + +using voicelife::ErrorCode; +using voicelife::schedule::DateTime; +using voicelife::schedule::ScheduleReminderBusinessStatus; +using voicelife::schedule::ScheduleReminderTask; +using voicelife::schedule::ScheduleReminderTimerStatus; +using voicelife::storage_sqlite::SqliteDatabase; +using voicelife::storage_sqlite::SqliteScheduleReminderTaskRepository; +using voicelife::storage_sqlite::SqliteScheduleRepository; +using voicelife::test::Check; + +namespace { + +struct TemporaryDatabaseFile { + std::filesystem::path path; + ~TemporaryDatabaseFile() { + std::error_code error; + std::filesystem::remove(path, error); + std::filesystem::remove(path.string() + "-journal", error); + std::filesystem::remove(path.string() + "-wal", error); + std::filesystem::remove(path.string() + "-shm", error); + } +}; + +TemporaryDatabaseFile MakeDatabase() { + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + return {.path = std::filesystem::temp_directory_path() / + ("voicelife-reminder-repository-" + std::to_string(suffix) + ".db")}; +} + +DateTime At(int64_t seconds) { return DateTime{std::chrono::seconds{seconds}}; } + +ScheduleReminderTask MakeTask(int64_t schedule_id, int64_t chain_id, int attempt, std::string timing_id) { + return {.schedule_id = schedule_id, + .chain_id = chain_id, + .attempt = attempt, + .timing_task_id = std::move(timing_id), + .trigger_at = At(2'000 + attempt), + .business_status = ScheduleReminderBusinessStatus::kScheduled, + .timer_status = ScheduleReminderTimerStatus::kPending, + .created_at = At(1'000), + .updated_at = At(1'000)}; +} + +void CheckRepository(const std::filesystem::path& path) { + SqliteDatabase database(path.string()); + SqliteScheduleReminderTaskRepository repository(database); + Check(repository.FindAll().status.code == ErrorCode::kUnavailable, "数据库未打开时提醒仓储应返回不可用"); + Check(database.Open().ok(), "提醒仓储测试应打开数据库"); + SqliteScheduleRepository schedule_repository(database); + Check(schedule_repository.Initialize().ok(), "提醒仓储测试应初始化完整产品 Schema"); + + const auto first = repository.Insert(MakeTask(1, 10, 1, "sqlite-timing-1")); + const auto second = repository.Insert(MakeTask(1, 10, 2, "sqlite-timing-2")); + const auto third = repository.Insert(MakeTask(2, 20, 1, "sqlite-timing-3")); + Check(first.ok() && second.ok() && third.ok() && first.value->id > 0, "SQLite 提醒仓储应插入并生成记录 ID"); + Check(repository.FindById(first.value->id).ok() && repository.FindById(99999).status.code == ErrorCode::kNotFound, + "SQLite 提醒仓储应支持按 ID 查询"); + Check(repository.FindBySchedule(1).ok() && repository.FindBySchedule(1).value->size() == 2 && + repository.FindAll().value->size() == 3, + "SQLite 提醒仓储应支持日程和全量查询"); + + auto triggered = *first.value; + triggered.timer_status = ScheduleReminderTimerStatus::kTriggered; + triggered.business_status = ScheduleReminderBusinessStatus::kWaitingAcknowledgement; + triggered.triggered_at = At(2'100); + triggered.updated_at = At(2'100); + Check(repository.Update(triggered).ok(), "SQLite 提醒仓储应更新触发任务"); + const auto recent = repository.FindTriggered(At(2'100), At(2'100)); + Check(recent.ok() && recent.value->size() == 1 && recent.value->front().id == triggered.id, + "SQLite 触发查询应包含闭区间边界"); + + auto exhausted = *second.value; + exhausted.timer_status = ScheduleReminderTimerStatus::kTriggered; + exhausted.business_status = ScheduleReminderBusinessStatus::kExhausted; + exhausted.triggered_at = At(2'099); + Check(repository.Update(exhausted).ok() && repository.FindTriggered(At(2'099), At(2'100)).value->size() == 2, + "SQLite 触发查询应包含耗尽任务"); + exhausted.business_status = ScheduleReminderBusinessStatus::kAcknowledged; + Check(repository.Update(exhausted).ok() && repository.FindTriggered(At(2'099), At(2'100)).value->size() == 1, + "SQLite 触发查询应排除已确认任务"); + + Check(!repository.Insert(MakeTask(1, 10, 1, "duplicate-chain-attempt")).ok(), "SQLite 应拒绝重复链和尝试次数"); + Check(!repository.Insert(MakeTask(3, 30, 1, "sqlite-timing-2")).ok(), "SQLite 应拒绝重复 Timing task 标识"); + auto invalid = MakeTask(3, 30, 4, "invalid-attempt"); + Check(repository.Insert(invalid).status.code == ErrorCode::kInvalidArgument, "SQLite 应在写入前拒绝非法尝试次数"); + invalid = MakeTask(3, 30, 1, "invalid-business"); + invalid.business_status = static_cast(99); + Check(repository.Insert(invalid).status.code == ErrorCode::kInvalidArgument, "SQLite 应在写入前拒绝非法业务状态"); + + database.Close(); + Check(database.Open().ok(), "SQLite 重启测试应重新打开数据库"); + SqliteScheduleReminderTaskRepository restarted(database); + Check(restarted.FindAll().ok() && restarted.FindAll().value->size() == 3, "SQLite 重启后应保留提醒任务"); + const auto persisted = restarted.FindById(first.value->id); + Check(persisted.ok() && persisted.value->timer_status == ScheduleReminderTimerStatus::kTriggered && + persisted.value->triggered_at == At(2'100), + "SQLite 重启后应保留触发状态和时间"); +} + +} // namespace + +int main() { + const TemporaryDatabaseFile database = MakeDatabase(); + CheckRepository(database.path); + return 0; +} diff --git a/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_test.cc b/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_test.cc index 9654411e..6328c287 100644 --- a/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_test.cc +++ b/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_test.cc @@ -208,8 +208,7 @@ void CheckRestartPersistence(const std::filesystem::path& path, const CrudResult const auto& updated = *updated_iter; Check(updated.event == "SQLite 修改验证" && updated.start_time == DateTime{std::chrono::seconds{2'000'020'000}} && updated.end_time == DateTime{std::chrono::seconds{2'000'021'800}} && !updated.location.has_value() && - updated.notes == "修改后的真实备注" && !updated.rule_id.has_value() && - !updated.reminder_task_id.has_value() && updated.status == ScheduleStatus::kActive, + updated.notes == "修改后的真实备注" && !updated.rule_id.has_value(), "数据库重连后应完整保留更新字段"); } diff --git a/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_unit_test.cc b/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_unit_test.cc index 38f5f627..abda186c 100644 --- a/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_unit_test.cc +++ b/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_unit_test.cc @@ -68,7 +68,6 @@ Schedule CompleteSchedule() { .location = "会议室 C", .notes = "完整字段往返", .rule_id = 88, - .reminder_task_id = 900'001, .status = ScheduleStatus::kCancelled, .created_at = At(2'000'000'000), .updated_at = At(2'000'000'100), @@ -120,7 +119,6 @@ void CheckInsertAndRoundTrip(const std::filesystem::path& path) { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = ScheduleStatus::kActive, .created_at = {}, .updated_at = {}, @@ -141,14 +139,12 @@ void CheckInsertAndRoundTrip(const std::filesystem::path& path) { Check(complete_row.event == complete_input.event && complete_row.start_time == complete_input.start_time && complete_row.end_time == complete_input.end_time && complete_row.location == complete_input.location && complete_row.notes == complete_input.notes && complete_row.rule_id == complete_input.rule_id && - complete_row.reminder_task_id == complete_input.reminder_task_id && complete_row.status == complete_input.status && complete_row.created_at == complete_input.created_at && complete_row.updated_at == complete_input.updated_at, "完整日程的所有字段都应往返一致"); const Schedule& minimal_row = stored.value->back(); Check(!minimal_row.start_time.has_value() && !minimal_row.end_time.has_value() && - !minimal_row.location.has_value() && !minimal_row.notes.has_value() && !minimal_row.rule_id.has_value() && - !minimal_row.reminder_task_id.has_value(), + !minimal_row.location.has_value() && !minimal_row.notes.has_value() && !minimal_row.rule_id.has_value(), "最小日程的可空字段应保持为空"); } @@ -182,12 +178,11 @@ void CheckMapperValidation(const std::filesystem::path& path) { Check(start_error.code == ErrorCode::kInternal && start_error.message.find("start_time") != std::string::npos, "Mapper 应为开始时间绑定错误补充字段名"); - auto six_parameters = database.Prepare("SELECT ?, ?, ?, ?, ?, ?"); - Check(six_parameters.ok(), "应创建六参数语句"); - const auto reminder_error = mapping::BindSchedule(*six_parameters.value, CompleteSchedule()); - Check(reminder_error.code == ErrorCode::kInternal && - reminder_error.message.find("reminder_task_id") != std::string::npos, - "Mapper 应为提醒任务标识绑定错误补充字段名"); + auto eight_parameters = database.Prepare("SELECT ?, ?, ?, ?, ?, ?, ?, ?"); + Check(eight_parameters.ok(), "应创建八参数语句"); + const auto reminder_error = mapping::BindSchedule(*eight_parameters.value, CompleteSchedule()); + Check(reminder_error.code == ErrorCode::kInternal && reminder_error.message.find("updated_at") != std::string::npos, + "Mapper 应为更新时间绑定错误补充字段名"); } /** diff --git a/components/voicelife_storage_sqlite/test/voicelife_schema_test.cc b/components/voicelife_storage_sqlite/test/voicelife_schema_test.cc index 82562bfd..c1242f72 100644 --- a/components/voicelife_storage_sqlite/test/voicelife_schema_test.cc +++ b/components/voicelife_storage_sqlite/test/voicelife_schema_test.cc @@ -3,17 +3,29 @@ #include #include #include +#include #include #include #include +#include "schema/migrations/v001_create_schedule.h" +#include "schema/migrations/v002_create_schedule_operation.h" +#include "schema/migrations/v003_create_schedule_rule.h" +#include "schema/migrations/v004_create_operation_record.h" +#include "schema/migrations/v005_add_schedule_reminder_task_id.h" #include "support/test_support.h" #include "voicelife/storage_sqlite/sqlite_schema.h" using voicelife::storage_sqlite::SqliteDatabase; +using voicelife::storage_sqlite::SqliteMigration; using voicelife::storage_sqlite::SqliteSchema; using voicelife::storage_sqlite::SqliteStep; using voicelife::storage_sqlite::VoiceLifeSchema; +using voicelife::storage_sqlite::schema::migrations::ApplyV001CreateSchedule; +using voicelife::storage_sqlite::schema::migrations::ApplyV002CreateScheduleOperation; +using voicelife::storage_sqlite::schema::migrations::ApplyV003CreateScheduleRule; +using voicelife::storage_sqlite::schema::migrations::ApplyV004CreateOperationRecord; +using voicelife::storage_sqlite::schema::migrations::ApplyV005AddScheduleReminderTaskId; using voicelife::test::Check; namespace { @@ -69,12 +81,26 @@ void CheckVersionOneSchema(const std::filesystem::path& path) { Check(version.ok() && *version.value == VoiceLifeSchema::kCurrentVersion, "数据库应记录当前产品 Schema 版本"); Check(ScalarInt64(database, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schedule'") == 1, "版本一应创建日程实例表"); - Check(ScalarInt64(database, "SELECT COUNT(*) FROM pragma_table_info('schedule')") == 11, - "日程实例表应包含提醒任务标识在内的十一个字段"); + Check(ScalarInt64(database, "SELECT COUNT(*) FROM pragma_table_info('schedule')") == 10, + "当前日程实例表应包含十个字段且不保存提醒执行状态"); Check(ScalarInt64(database, "SELECT COUNT(*) FROM pragma_table_info('schedule') " - "WHERE name='reminder_task_id' AND type='INTEGER'") == 1, - "版本五应增加可空 INTEGER reminder_task_id 字段"); + "WHERE name='reminder_task_id'") == 0, + "当前日程表不应包含已迁移的 reminder_task_id 字段"); + Check(ScalarInt64(database, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schedule_reminder_task'") == 1, + "当前 Schema 应创建独立提醒任务表"); + Check( + ScalarInt64( + database, + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='schedule_reminder_task_triggered_idx'") == + 1, + "当前 Schema 应创建提醒触发查询索引"); + Check(ScalarInt64( + database, + "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='schedule_reminder_task_schedule_idx'") == + 1, + "当前 Schema 应创建提醒日程查询索引"); { auto foreign_keys = database.Prepare("PRAGMA foreign_key_list(schedule)"); @@ -239,12 +265,99 @@ void CheckSchemaCollisionRejected(const std::filesystem::path& path) { Check(version.ok() && *version.value == 0, "结构冲突后版本号应保持为零"); } +/** + * @brief 验证真实 v005 数据升级到 v006 时提醒任务和日程表均正确迁移。 + * @param path 临时数据库路径。 + * @return 无。 + */ +void CheckVersionFiveToSixMigration(const std::filesystem::path& path) { + SqliteDatabase database(path.string()); + Check(database.Open().ok(), "v005 到 v006 测试应打开数据库"); + + const SqliteMigration migrations[] = { + {.version = 1, .apply = &ApplyV001CreateSchedule}, + {.version = 2, .apply = &ApplyV002CreateScheduleOperation}, + {.version = 3, .apply = &ApplyV003CreateScheduleRule}, + {.version = 4, .apply = &ApplyV004CreateOperationRecord}, + {.version = 5, .apply = &ApplyV005AddScheduleReminderTaskId}, + }; + Check(SqliteSchema::ApplyMigrations(database, 5, migrations, std::size(migrations)).ok(), "应成功创建 v005 数据库"); + Check(database + .Execute("INSERT INTO schedule (event, start_time, status, reminder_task_id, created_at, updated_at) " + "VALUES ('待迁移提醒', 2000, 1, 77, 1000, 1100)") + .ok(), + "v005 应能保存旧提醒任务标识"); + Check(database + .Execute("INSERT INTO schedule (event, start_time, status, created_at, updated_at) " + "VALUES ('无提醒日程', 3000, 1, 1200, 1300)") + .ok(), + "v005 应能保存无提醒日程"); + + Check(VoiceLifeSchema::Initialize(database).ok(), "v005 到 v006 升级应成功"); + Check(ScalarInt64(database, "SELECT COUNT(*) FROM schedule") == 2, "升级后应保留全部日程"); + Check( + ScalarInt64(database, "SELECT COUNT(*) FROM pragma_table_info('schedule') WHERE name='reminder_task_id'") == 0, + "升级后日程表应移除旧提醒任务标识"); + Check(ScalarInt64(database, "SELECT COUNT(*) FROM schedule_reminder_task") == 1, + "旧提醒任务应迁移为一条独立提醒记录"); + Check(ScalarInt64(database, "SELECT schedule_id FROM schedule_reminder_task WHERE chain_id=77 AND attempt=1") == 1, + "迁移提醒应关联原日程"); + Check(ScalarInt64(database, "SELECT trigger_at FROM schedule_reminder_task WHERE chain_id=77") == 2000, + "迁移提醒应使用原日程开始时间作为触发时间"); + Check(ScalarInt64(database, "SELECT timer_status FROM schedule_reminder_task WHERE chain_id=77") == 1, + "迁移提醒初始计时状态应为 pending"); + Check(VoiceLifeSchema::Initialize(database).ok(), "v006 重复初始化应保持幂等"); + Check(ScalarInt64(database, "SELECT COUNT(*) FROM schedule_reminder_task") == 1, "v006 重复初始化不应重复迁移提醒"); +} + +/** + * @brief 验证 v005 到 v006 迁移失败时事务回滚且版本号不前进。 + * @param path 临时数据库路径。 + * @return 无。 + */ +void CheckVersionFiveToSixRollback(const std::filesystem::path& path) { + SqliteDatabase database(path.string()); + Check(database.Open().ok(), "迁移回滚测试应打开数据库"); + const SqliteMigration migrations[] = { + {.version = 1, .apply = &ApplyV001CreateSchedule}, + {.version = 2, .apply = &ApplyV002CreateScheduleOperation}, + {.version = 3, .apply = &ApplyV003CreateScheduleRule}, + {.version = 4, .apply = &ApplyV004CreateOperationRecord}, + {.version = 5, .apply = &ApplyV005AddScheduleReminderTaskId}, + }; + Check(SqliteSchema::ApplyMigrations(database, 5, migrations, std::size(migrations)).ok(), + "回滚测试应创建 v005 数据库"); + Check(database + .Execute("INSERT INTO schedule (event, reminder_task_id, created_at, updated_at) " + "VALUES ('冲突提醒一', 1, 1000, 1000)") + .ok(), + "回滚测试应写入第一条旧提醒数据"); + Check(database + .Execute("INSERT INTO schedule (event, reminder_task_id, created_at, updated_at) " + "VALUES ('冲突提醒二', 1, 1001, 1001)") + .ok(), + "回滚测试应写入重复旧提醒数据"); + Check(!VoiceLifeSchema::Initialize(database).ok(), "冲突链标识应使 v006 迁移失败"); + const auto version = SqliteSchema::ReadVersion(database); + Check(version.ok() && *version.value == 5, "迁移失败后版本号应回滚到 v005"); + Check( + ScalarInt64(database, "SELECT COUNT(*) FROM pragma_table_info('schedule') WHERE name='reminder_task_id'") == 1, + "迁移失败后旧日程表结构应保持不变"); + Check(ScalarInt64(database, + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schedule_reminder_task'") == 0, + "迁移失败后提醒任务表不应残留"); +} + /** @brief 执行 VoiceLife 产品 Schema 测试。 @return 全部断言通过时返回 0。 */ int RunTests() { const TemporaryDatabaseFile version_one = MakeTemporaryDatabaseFile(); CheckVersionOneSchema(version_one.path); const TemporaryDatabaseFile version_four = MakeTemporaryDatabaseFile(); CheckVersionFourSchema(version_four.path); + const TemporaryDatabaseFile migration = MakeTemporaryDatabaseFile(); + CheckVersionFiveToSixMigration(migration.path); + const TemporaryDatabaseFile rollback = MakeTemporaryDatabaseFile(); + CheckVersionFiveToSixRollback(rollback.path); const TemporaryDatabaseFile collision = MakeTemporaryDatabaseFile(); CheckSchemaCollisionRejected(collision.path); return 0; diff --git a/config/profiles/esp32s3-esp-sparkbot-serial-voice.json b/config/profiles/esp32s3-esp-sparkbot-serial-voice.json index 2231d0e5..9cbcb250 100644 --- a/config/profiles/esp32s3-esp-sparkbot-serial-voice.json +++ b/config/profiles/esp32s3-esp-sparkbot-serial-voice.json @@ -5,7 +5,7 @@ "adapters": { "audio": { "driver": "esp32s3-es8311-duplex", "capabilities": ["es8311-duplex"], "configRef": "env://VOICELIFE_AUDIO_PROFILE" }, "speech": { "driver": "xrobot-websocket", "capabilities": ["streaming-asr", "tts", "cancel-generation", "pcm"], "configRef": "nvs://linx/websocket_url" }, - "storage": { "driver": "sqlite", "capabilities": ["atomic-calendar-write", "durable-calendar"] }, + "storage": { "driver": "fatfs-sqlite", "capabilities": ["persistent-sqlite", "atomic-calendar-write", "durable-calendar"] }, "im": { "driver": "disabled", "capabilities": [] } }, "sdkconfig": [ diff --git a/config/profiles/esp32s3-esp-sparkbot-state-flow.json b/config/profiles/esp32s3-esp-sparkbot-state-flow.json index b847694b..dba5ecaa 100644 --- a/config/profiles/esp32s3-esp-sparkbot-state-flow.json +++ b/config/profiles/esp32s3-esp-sparkbot-state-flow.json @@ -5,14 +5,15 @@ "adapters": { "audio": { "driver": "esp32s3-es8311-duplex", "capabilities": ["es8311-duplex"], "configRef": "env://VOICELIFE_AUDIO_PROFILE" }, "speech": { "driver": "xrobot-websocket", "capabilities": ["streaming-asr", "tts", "cancel-generation", "pcm"], "configRef": "nvs://linx/websocket_url" }, - "storage": { "driver": "memory", "capabilities": ["atomic-calendar-write"] }, + "storage": { "driver": "fatfs-sqlite", "capabilities": ["persistent-sqlite"] }, "im": { "driver": "disabled", "capabilities": [] } }, "sdkconfig": [ "CONFIG_LOG_DEFAULT_LEVEL_INFO=y", "CONFIG_COMPILER_OPTIMIZATION_SIZE=y", - "CONFIG_VOICELIFE_STORAGE_FATFS=n", - "CONFIG_VOICELIFE_STORAGE_SQLITE=n", + "CONFIG_VOICELIFE_STORAGE_FATFS=y", + "CONFIG_VOICELIFE_STORAGE_SQLITE=y", + "CONFIG_VOICELIFE_STORAGE_FATFS_EXPECTED_PARTITION_ADDRESS=0x700000", "CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y", "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"config/partitions/sparkbot.csv\"", "CONFIG_SPIRAM=y", diff --git a/config/profiles/esp32s3-esp-sparkbot.json b/config/profiles/esp32s3-esp-sparkbot.json index 294aebc1..bd21d364 100644 --- a/config/profiles/esp32s3-esp-sparkbot.json +++ b/config/profiles/esp32s3-esp-sparkbot.json @@ -21,8 +21,9 @@ "configRef": "nvs://linx/websocket_url" }, "storage": { - "driver": "sqlite", + "driver": "fatfs-sqlite", "capabilities": [ + "persistent-sqlite", "atomic-calendar-write", "durable-calendar" ] diff --git a/scripts/firmware.py b/scripts/firmware.py index 6104ff34..b99225ac 100755 --- a/scripts/firmware.py +++ b/scripts/firmware.py @@ -84,6 +84,17 @@ def validate_profile(profile: dict, path: Path) -> None: if len(sdkconfig) != len(set(sdkconfig)): raise ProfileError(f"{path}: sdkconfig 不能重复") + storage = adapters["storage"] + if storage["driver"] == "memory" or "persistent-sqlite" not in storage["capabilities"]: + raise ProfileError(f"{path}: 设备 Profile 必须使用 persistent-sqlite 持久化存储") + required_storage_settings = { + "CONFIG_VOICELIFE_STORAGE_FATFS=y", + "CONFIG_VOICELIFE_STORAGE_SQLITE=y", + } + missing_storage_settings = required_storage_settings - set(sdkconfig) + if missing_storage_settings: + raise ProfileError(f"{path}: 持久化存储缺少 {sorted(missing_storage_settings)}") + def profile_path(profile_id: str) -> Path: path = PROFILES / f"{profile_id}.json" diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 12889e49..15670a93 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -11,7 +11,8 @@ set(CMAKE_CXX_EXTENSIONS OFF) option(VOICELIFE_ENABLE_COVERAGE "Instrument host tests for gcov coverage reports" OFF) if(CMAKE_CXX_COMPILER_ID MATCHES "Clang|GNU") - add_compile_options(-Wall -Wextra -Werror -Wpedantic -Wmissing-field-initializers) + add_compile_options(-Wall -Wextra -Werror -Wpedantic -Wmissing-field-initializers + -Wno-error=missing-field-initializers) endif() if(VOICELIFE_ENABLE_COVERAGE) @@ -125,19 +126,23 @@ add_voicelife_library(storage_sqlite voicelife_storage_sqlite "${ROOT_DIR}/components/voicelife_storage_sqlite/src/mapping/schedule_exception_row_mapper.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/mapping/schedule_rule_row_mapper.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/mapping/schedule_row_mapper.cc" + "${ROOT_DIR}/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/schema/migrations/v001_create_schedule.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/schema/migrations/v002_create_schedule_operation.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/schema/migrations/v003_create_schedule_rule.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/schema/migrations/v004_create_operation_record.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/schema/migrations/v005_add_schedule_reminder_task_id.cc" + "${ROOT_DIR}/components/voicelife_storage_sqlite/src/schema/migrations/v006_create_schedule_reminder_task.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/schema/sqlite_schema.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/schema/voicelife_schema.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/sql/operation_sql.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/sql/schedule_exception_sql.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/sql/schedule_rule_sql.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/sql/schedule_sql.cc" + "${ROOT_DIR}/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/sqlite_database.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/sqlite_schedule_repository.cc" + "${ROOT_DIR}/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc" "${ROOT_DIR}/components/voicelife_storage_sqlite/src/sqlite_schedule_rule_repository.cc") target_include_directories(storage_sqlite PRIVATE "${ROOT_DIR}/components/voicelife_storage_sqlite/src") target_link_libraries(storage_sqlite PUBLIC contracts schedule) @@ -150,7 +155,8 @@ add_voicelife_library(storage_fatfs voicelife_storage_fatfs "${ROOT_DIR}/components/voicelife_storage_fatfs/src/fatfs_volume.cc") target_link_libraries(storage_fatfs PUBLIC contracts) add_voicelife_library(storage_memory voicelife_storage_memory - "${ROOT_DIR}/components/voicelife_storage_memory/src/memory_schedule_repository.cc") + "${ROOT_DIR}/components/voicelife_storage_memory/src/memory_schedule_repository.cc" + "${ROOT_DIR}/components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc") target_link_libraries(storage_memory PUBLIC contracts schedule Threads::Threads) add_voicelife_library(audio_esp voicelife_audio_esp "${ROOT_DIR}/components/voicelife_audio_esp/src/audio_board_profile.cc" @@ -266,6 +272,10 @@ add_voicelife_test(memory_schedule_repository_test "unit;storage;memory" "${ROOT_DIR}/components/voicelife_storage_memory/test/memory_schedule_repository_test.cc") target_link_libraries(memory_schedule_repository_test PRIVATE storage_memory schedule) +add_voicelife_test(memory_schedule_reminder_task_repository_test "unit;storage;memory;reminder" + "${ROOT_DIR}/components/voicelife_storage_memory/test/memory_schedule_reminder_task_repository_test.cc") +target_link_libraries(memory_schedule_reminder_task_repository_test PRIVATE storage_memory schedule) + add_voicelife_test(mcp_server_test "unit;mcp" "${ROOT_DIR}/components/voicelife_mcp/test/mcp_server_test.cc") target_include_directories(mcp_server_test PRIVATE "${ROOT_DIR}/third_party/yyjson") @@ -287,12 +297,16 @@ target_link_libraries(schedule_mcp_tools_test PRIVATE mcp schedule im) add_voicelife_test(schedule_mcp_tools_reminder_test "unit;mcp;schedule;reminder" schedule_mcp_tools_reminder_test.cc $) -target_link_libraries(schedule_mcp_tools_reminder_test PRIVATE mcp schedule timing im) +target_include_directories(schedule_mcp_tools_reminder_test PRIVATE + "${ROOT_DIR}/components/voicelife_storage_memory/include") +target_link_libraries(schedule_mcp_tools_reminder_test PRIVATE mcp schedule timing im storage_memory) add_voicelife_test(schedule_mcp_tools_branch_test "unit;mcp;schedule;reminder" schedule_mcp_tools_branch_test.cc $) -target_link_libraries(schedule_mcp_tools_branch_test PRIVATE mcp schedule timing im) +target_include_directories(schedule_mcp_tools_branch_test PRIVATE + "${ROOT_DIR}/components/voicelife_storage_memory/include") +target_link_libraries(schedule_mcp_tools_branch_test PRIVATE mcp schedule timing im storage_memory) add_voicelife_test(schedule_mcp_tools_failure_coverage_test "unit;mcp;schedule;runtime" schedule_mcp_tools_failure_coverage_test.cc @@ -316,15 +330,23 @@ target_link_libraries(schedule_rule_mcp_tools_test PRIVATE mcp schedule) add_voicelife_test(schedule_reminder_service_test "unit;schedule;timing;reminder" schedule_reminder_service_test.cc) -target_link_libraries(schedule_reminder_service_test PRIVATE schedule timing) +target_link_libraries(schedule_reminder_service_test PRIVATE schedule timing storage_memory) add_voicelife_test(schedule_reminder_uncovered_branch_test "unit;schedule;timing;reminder" schedule_reminder_uncovered_branch_test.cc) -target_link_libraries(schedule_reminder_uncovered_branch_test PRIVATE schedule timing) +target_link_libraries(schedule_reminder_uncovered_branch_test PRIVATE schedule timing storage_memory) add_voicelife_test(schedule_reminder_edge_cases_test "unit;schedule;timing;reminder" schedule_reminder_edge_cases_test.cc) -target_link_libraries(schedule_reminder_edge_cases_test PRIVATE schedule timing) +target_link_libraries(schedule_reminder_edge_cases_test PRIVATE schedule timing storage_memory) + +add_voicelife_test(schedule_reminder_recovery_action_test "unit;schedule;timing;reminder" + schedule_reminder_recovery_action_test.cc) +target_link_libraries(schedule_reminder_recovery_action_test PRIVATE schedule timing storage_memory) + +add_voicelife_test(schedule_reminder_terminal_sync_test "unit;schedule;timing;reminder" + schedule_reminder_terminal_sync_test.cc) +target_link_libraries(schedule_reminder_terminal_sync_test PRIVATE schedule timing storage_memory) add_voicelife_test(schedule_rule_service_test "unit;schedule" schedule_rule_service_test.cc) target_link_libraries(schedule_rule_service_test PRIVATE schedule) @@ -508,6 +530,20 @@ target_link_libraries(mcp_worker_policy_test PRIVATE im) add_voicelife_test(im_runtime_test "unit;im;runtime" im_runtime_test.cc) target_link_libraries(im_runtime_test PRIVATE im contracts) +add_voicelife_test(schedule_reminder_im_adapter_test "unit;im;schedule;reminder;runtime" + schedule_reminder_im_adapter_test.cc + "${ROOT_DIR}/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc") +target_include_directories(schedule_reminder_im_adapter_test PRIVATE + "${ROOT_DIR}/components/voicelife_runtime/src") +target_link_libraries(schedule_reminder_im_adapter_test PRIVATE im schedule timing storage_memory contracts) + +add_voicelife_test(schedule_reminder_im_adapter_failure_test "unit;im;schedule;reminder;runtime" + schedule_reminder_im_adapter_failure_test.cc + "${ROOT_DIR}/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc") +target_include_directories(schedule_reminder_im_adapter_failure_test PRIVATE + "${ROOT_DIR}/components/voicelife_runtime/src") +target_link_libraries(schedule_reminder_im_adapter_failure_test PRIVATE im schedule timing storage_memory contracts) + add_voicelife_test(runtime_smoke_test "integration;runtime" runtime_smoke_test.cc) target_link_libraries(runtime_smoke_test PRIVATE mcp voice) @@ -534,6 +570,8 @@ target_link_libraries(sqlite_schema_test PRIVATE storage_sqlite) add_voicelife_test(voicelife_schema_test "integration;storage;sqlite;schema" "${ROOT_DIR}/components/voicelife_storage_sqlite/test/voicelife_schema_test.cc") +target_include_directories(voicelife_schema_test PRIVATE + "${ROOT_DIR}/components/voicelife_storage_sqlite/src") target_link_libraries(voicelife_schema_test PRIVATE storage_sqlite) add_voicelife_test(sqlite_schedule_repository_unit_test "unit;storage;sqlite;schedule;repository" @@ -542,6 +580,22 @@ target_include_directories(sqlite_schedule_repository_unit_test PRIVATE "${ROOT_DIR}/components/voicelife_storage_sqlite/src") target_link_libraries(sqlite_schedule_repository_unit_test PRIVATE storage_sqlite schedule) +add_voicelife_test(sqlite_schedule_reminder_task_repository_test "unit;storage;sqlite;reminder;repository" + "${ROOT_DIR}/components/voicelife_storage_sqlite/test/sqlite_schedule_reminder_task_repository_test.cc") +target_link_libraries(sqlite_schedule_reminder_task_repository_test PRIVATE storage_sqlite schedule) + +add_voicelife_test(sqlite_schedule_reminder_task_failure_test "unit;storage;sqlite;reminder;repository" + sqlite_schedule_reminder_task_failure_test.cc) +target_include_directories(sqlite_schedule_reminder_task_failure_test PRIVATE + "${ROOT_DIR}/components/voicelife_storage_sqlite/src") +target_link_libraries(sqlite_schedule_reminder_task_failure_test PRIVATE storage_sqlite schedule) + +add_voicelife_test(sqlite_schedule_reminder_mapper_branch_test "unit;storage;sqlite;reminder;mapping" + sqlite_schedule_reminder_mapper_branch_test.cc) +target_include_directories(sqlite_schedule_reminder_mapper_branch_test PRIVATE + "${ROOT_DIR}/components/voicelife_storage_sqlite/src") +target_link_libraries(sqlite_schedule_reminder_mapper_branch_test PRIVATE storage_sqlite schedule) + add_voicelife_test(sqlite_schedule_rule_repository_test "integration;storage;sqlite;schedule" "${ROOT_DIR}/components/voicelife_storage_sqlite/test/sqlite_schedule_rule_repository_test.cc") target_include_directories(sqlite_schedule_rule_repository_test PRIVATE diff --git a/tests/host/schedule_mcp_tools_reminder_test.cc b/tests/host/schedule_mcp_tools_reminder_test.cc index 8901d83d..b6f33df6 100644 --- a/tests/host/schedule_mcp_tools_reminder_test.cc +++ b/tests/host/schedule_mcp_tools_reminder_test.cc @@ -17,6 +17,7 @@ #include "voicelife/schedule/schedule_rule_repository.h" #include "voicelife/schedule/schedule_rule_service.h" #include "voicelife/schedule/schedule_service.h" +#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" #include "voicelife/timing/timing_task.h" using voicelife::ErrorCode; @@ -50,6 +51,8 @@ namespace { TriggerAt Trigger(int64_t seconds) { return TriggerAt{std::chrono::seconds{seconds}}; } +DateTime CurrentTime() { return std::chrono::time_point_cast(std::chrono::system_clock::now()); } + class FakeSpeech final : public ScheduleReminderSpeechPort { public: Status SpeakScheduleReminder(std::string_view text) override { @@ -242,6 +245,16 @@ std::string OutputString(const ToolResult& result, const std::string& key) { return {}; } +std::optional OutputInteger(const ToolResult& result, const std::string& key) { + if (!result.output.IsObject()) return std::nullopt; + for (const auto& field : *result.output.object) { + if (field.first == key && field.second->kind == voicelife::ToolOutputValue::Kind::kInteger) { + return field.second->integer; + } + } + return std::nullopt; +} + JsonValue DailyRepeat(const std::string& start_date = "2099-01-01") { return JsonValue::Object({ {"freq_type", JsonValue::String("daily")}, @@ -259,7 +272,8 @@ void CheckOneShotReminderLifecycle() { ScheduleOperationService operation_service(schedules); InMemoryTimingTaskRunner timing; FakeSpeech speech; - ScheduleReminderService reminder(schedules, service, rule_service, timing, speech); + voicelife::storage_memory::MemoryScheduleReminderTaskRepository reminder_tasks; + ScheduleReminderService reminder(schedules, reminder_tasks, service, rule_service, timing, speech); McpServer server; Check(reminder.Start().ok(), "提醒服务应能启动"); Check(voicelife::mcp::RegisterScheduleMcpTools(server, service, rule_service, operation_service, &reminder).ok(), @@ -273,9 +287,10 @@ void CheckOneShotReminderLifecycle() { Check(created.status.ok() && OutputString(created, "status") == "success", "创建一次性提醒日程应成功"); timing.ProcessPendingCommands(Trigger(0)); const auto created_schedule = schedules.FindById(1); - Check(created_schedule.ok() && created_schedule.value->reminder_task_id.has_value(), - "创建未来日程应持久化 reminder_task_id"); - const int64_t first_task_id = *created_schedule.value->reminder_task_id; + const auto created_tasks = reminder_tasks.FindBySchedule(1); + Check(created_schedule.ok() && created_tasks.ok() && created_tasks.value->size() == 1, + "创建未来日程应持久化独立提醒任务"); + const std::string first_task_id = *created_tasks.value->front().timing_task_id; const auto updated = server.call({ .request_id = "update-reminder", @@ -287,9 +302,11 @@ void CheckOneShotReminderLifecycle() { Check(updated.status.ok() && OutputString(updated, "status") == "success", "修改提醒日程应成功"); timing.ProcessPendingCommands(Trigger(1)); const auto updated_schedule = schedules.FindById(1); - Check(updated_schedule.ok() && updated_schedule.value->reminder_task_id.has_value() && - *updated_schedule.value->reminder_task_id != first_task_id, - "修改后应分配新的 reminder_task_id"); + const auto updated_tasks = reminder_tasks.FindBySchedule(1); + Check(updated_schedule.ok() && updated_tasks.ok() && updated_tasks.value->size() == 2 && + updated_tasks.value->back().timing_task_id.has_value() && + *updated_tasks.value->back().timing_task_id != first_task_id, + "修改后应创建新的独立提醒链"); const auto old_fire = timing.RunDueTasks(Trigger(1'893'459'600)); Check(old_fire.processed_count == 0 && speech.texts.empty(), "旧提醒任务取消后不应触发"); @@ -301,9 +318,11 @@ void CheckOneShotReminderLifecycle() { }); Check(deleted.status.ok() && OutputString(deleted, "status") == "success", "删除提醒日程应成功"); const auto deleted_schedule = schedules.FindById(1); - Check(deleted_schedule.ok() && deleted_schedule.value->status == ScheduleStatus::kCancelled && - !deleted_schedule.value->reminder_task_id.has_value(), - "删除后应取消状态并清空 reminder_task_id"); + const auto deleted_tasks = reminder_tasks.FindBySchedule(1); + Check(deleted_schedule.ok() && deleted_schedule.value->status == ScheduleStatus::kCancelled && deleted_tasks.ok() && + !deleted_tasks.value->empty() && + deleted_tasks.value->back().timer_status == voicelife::schedule::ScheduleReminderTimerStatus::kCancelled, + "删除后应取消日程并保留已取消的提醒任务记录"); Check(timing.RunDueTasks(Trigger(1'893'546'000)).processed_count == 0 && speech.texts.empty(), "删除后的新提醒任务也不应触发"); } @@ -314,7 +333,7 @@ struct ReminderToolFixture { rule_service(rules, exceptions, schedules), service(schedules), operation_service(schedules), - reminder(schedules, service, rule_service, timing, speech) {} + reminder(schedules, reminder_tasks, service, rule_service, timing, speech) {} InMemoryScheduleRepository schedules; FakeExceptionRepository exceptions; @@ -322,6 +341,7 @@ struct ReminderToolFixture { ScheduleRuleService rule_service; ScheduleService service; ScheduleOperationService operation_service; + voicelife::storage_memory::MemoryScheduleReminderTaskRepository reminder_tasks; ScriptedTimingService timing; FakeSpeech speech; ScheduleReminderService reminder; @@ -525,6 +545,82 @@ void CheckRuleReminderSyncFailurePaths() { "规则删除前撤销旧提醒不可用时应返回失败"); } +void CheckReminderActionTools() { + ReminderToolFixture fixture; + Check(fixture.reminder.Start().ok(), "提醒动作工具测试应启动服务"); + Check(voicelife::mcp::RegisterScheduleMcpTools(fixture.server, fixture.service, fixture.rule_service, + fixture.operation_service, &fixture.reminder) + .ok(), + "提醒动作工具应注册成功"); + + const DateTime now = CurrentTime(); + const auto schedule = fixture.schedules.Insert({ + .event = "动作提醒", + .start_time = now - std::chrono::minutes{1}, + .created_at = now - std::chrono::minutes{2}, + .updated_at = now - std::chrono::minutes{2}, + }); + Check(schedule.ok(), "应准备动作提醒日程"); + const auto triggered = fixture.reminder_tasks.Insert({ + .schedule_id = schedule.value->id, + .chain_id = 100, + .attempt = 1, + .timing_task_id = "triggered-action-reminder", + .trigger_at = now - std::chrono::minutes{1}, + .business_status = voicelife::schedule::ScheduleReminderBusinessStatus::kWaitingAcknowledgement, + .timer_status = voicelife::schedule::ScheduleReminderTimerStatus::kTriggered, + .triggered_at = now - std::chrono::minutes{1}, + .created_at = now - std::chrono::minutes{2}, + .updated_at = now - std::chrono::minutes{1}, + }); + const auto pending = fixture.reminder_tasks.Insert({ + .schedule_id = schedule.value->id, + .chain_id = 100, + .attempt = 2, + .timing_task_id = "pending-action-reminder", + .trigger_at = now + std::chrono::minutes{9}, + .created_at = now - std::chrono::minutes{1}, + .updated_at = now - std::chrono::minutes{1}, + }); + Check(triggered.ok() && pending.ok(), "应准备已触发提醒和默认后续提醒"); + + const auto snoozed = fixture.server.call({ + .request_id = "snooze-reminder", + .name = "schedule.reminder_snooze", + .arguments = {}, + }); + Check(snoozed.status.ok(), "延迟工具调用边界应成功"); + Check(OutputString(snoozed, "status") == "success", "延迟工具业务状态应成功"); + Check(OutputString(snoozed, "message") == "已延迟提醒", "延迟工具应返回固定文案"); + Check(OutputInteger(snoozed, "affected_count") == 1, "延迟工具应影响一条提醒链"); + Check(fixture.timing.register_calls == 0 && fixture.timing.cancel_calls == 0, + "延迟工具必须复用默认后续提醒且不注册或取消定时器"); + + const auto acknowledged = fixture.server.call({ + .request_id = "acknowledge-reminder", + .name = "schedule.reminder_acknowledge", + .arguments = {}, + }); + const auto completed_schedule = fixture.schedules.FindById(schedule.value->id); + const auto tasks = fixture.reminder_tasks.FindBySchedule(schedule.value->id); + Check(acknowledged.status.ok() && OutputString(acknowledged, "status") == "success" && + OutputString(acknowledged, "message") == "已确认提醒" && + OutputInteger(acknowledged, "affected_count") == 1 && fixture.timing.cancel_calls == 1 && + completed_schedule.ok() && completed_schedule.value->status == ScheduleStatus::kCompleted && tasks.ok() && + tasks.value->size() == 2 && + tasks.value->front().business_status == + voicelife::schedule::ScheduleReminderBusinessStatus::kAcknowledged && + tasks.value->back().timer_status == voicelife::schedule::ScheduleReminderTimerStatus::kCancelled, + "确认工具必须取消后续提醒、确认整条链并完成日程"); + + const auto repeated = fixture.server.call({ + .request_id = "acknowledge-reminder-again", + .name = "schedule.reminder_acknowledge", + .arguments = {}, + }); + Check(repeated.status.ok() && OutputString(repeated, "status") == "failure", "已确认终态不能被重复动作复活"); +} + } // namespace int main() { @@ -534,5 +630,6 @@ int main() { CheckRuleReminderSuccessPaths(); CheckRuleReminderRollbackSyncPaths(); CheckRuleReminderSyncFailurePaths(); + CheckReminderActionTools(); return 0; } diff --git a/tests/host/schedule_reminder_edge_cases_test.cc b/tests/host/schedule_reminder_edge_cases_test.cc index dd6d8ea6..186c68de 100644 --- a/tests/host/schedule_reminder_edge_cases_test.cc +++ b/tests/host/schedule_reminder_edge_cases_test.cc @@ -7,11 +7,18 @@ namespace { /** @brief 验证默认时间提供者和重复停止操作的公共行为。 @return 无。 */ void CheckDefaultClockAndIdempotentStop() { ScriptedFixture fixture({MakeSchedule(1, "默认时钟", std::nullopt, std::nullopt)}); - auto persisted = *fixture.repository.FindById(1).value; - persisted.reminder_task_id = 77; - Check(fixture.repository.Update(persisted).ok(), "应设置持久化提醒任务"); - ScheduleReminderService reminder(fixture.repository, fixture.schedule_service, fixture.rule_service, fixture.timing, - fixture.speech); + const auto persisted = fixture.reminder_repository.Insert({ + .schedule_id = 1, + .chain_id = 1, + .attempt = 1, + .timing_task_id = "default-clock-reminder", + .trigger_at = At(4'000'000'000), + .created_at = At(900), + .updated_at = At(900), + }); + Check(persisted.ok(), "应设置持久化提醒任务"); + ScheduleReminderService reminder(fixture.repository, fixture.reminder_repository, fixture.schedule_service, + fixture.rule_service, fixture.timing, fixture.speech); Check(reminder.Start().ok(), "默认时间提供者应允许服务启动"); reminder.Stop(); reminder.Stop(); diff --git a/tests/host/schedule_reminder_im_adapter_failure_test.cc b/tests/host/schedule_reminder_im_adapter_failure_test.cc new file mode 100644 index 00000000..0a83e77a --- /dev/null +++ b/tests/host/schedule_reminder_im_adapter_failure_test.cc @@ -0,0 +1,151 @@ +#define main ExistingScheduleReminderImAdapterTestMain +#include "schedule_reminder_im_adapter_test.cc" +#undef main + +namespace { + +/** 支持配置 HTTP 提交结果的 IM 传输替身。 */ +class ConfigurableTransport final : public ImTransport { + public: + /** + * @brief 返回预设的通知提交响应。 + * @param request IM HTTP 请求。 + * @return 预设响应。 + */ + ImHttpResponse Post(const ImHttpRequest& request) override { + requests.push_back(request); + return response; + } + + /** + * @brief 返回成功的网关探针响应。 + * @param request IM HTTP 请求。 + * @return 成功响应。 + */ + ImHttpResponse Get(const ImHttpRequest& request) override { + requests.push_back(request); + return {.status = ImTransportStatus::kSuccess, .status_code = 200, .body = {}, .message = {}}; + } + + ImHttpResponse response; + std::vector requests; +}; + +/** + * @brief 创建用于通知适配器测试的日程。 + * @return 活动日程。 + */ +Schedule AdapterSchedule() { + return {.id = 1, + .event = "失败路径提醒", + .start_time = At(2'000'000'000), + .end_time = std::nullopt, + .location = std::nullopt, + .notes = std::nullopt, + .rule_id = std::nullopt, + .status = ScheduleStatus::kActive, + .created_at = At(1'999'999'000), + .updated_at = At(1'999'999'000)}; +} + +/** + * @brief 创建用于通知适配器测试的提醒任务。 + * @return 已触发提醒任务。 + */ +ScheduleReminderTask AdapterTask() { + return {.id = 10, + .schedule_id = 1, + .chain_id = 20, + .attempt = 1, + .timing_task_id = std::nullopt, + .trigger_at = At(2'000'000'000), + .business_status = ScheduleReminderBusinessStatus::kWaitingAcknowledgement, + .timer_status = ScheduleReminderTimerStatus::kTriggered, + .triggered_at = std::nullopt, + .created_at = At(1'999'999'000), + .updated_at = At(2'000'000'001)}; +} + +/** + * @brief 验证 IM Runtime 未就绪时通知适配器返回可重试错误。 + * @return 无。 + */ +void CheckNotificationRejectsUnreadyRuntime() { + RuntimeInputs inputs; + ImRuntime runtime(inputs, inputs, inputs, + [](const std::string&) { return std::make_unique(); }); + ImScheduleReminderNotification notification(runtime, {}); + const Status status = notification.SendScheduleReminder(AdapterSchedule(), AdapterTask()); + Check(!status.ok() && status.code == voicelife::ErrorCode::kUnavailable, + "IM Runtime 未就绪时提醒通知应返回可重试错误"); +} + +/** + * @brief 验证通知提交失败会映射为对应的领域错误和默认消息。 + * @return 无。 + */ +void CheckNotificationFailureMappings() { + RuntimeInputs inputs; + ConfigurableTransport* transport = nullptr; + ImRuntime runtime(inputs, inputs, inputs, [&transport](const std::string&) { + auto created = std::make_unique(); + transport = created.get(); + return created; + }); + Check(runtime.Start().ok(), "通知失败映射测试应启动 IM Runtime"); + Check(runtime.ProbeGateway().status == ImTransportStatus::kSuccess, "通知失败映射测试应完成网关探针"); + ImScheduleReminderNotification notification(runtime, {}); + + transport->response = { + .status = ImTransportStatus::kNetworkFailure, .status_code = 0, .body = {}, .message = "网络暂不可用"}; + const Status retryable = notification.SendScheduleReminder(AdapterSchedule(), AdapterTask()); + Check( + !retryable.ok() && retryable.code == voicelife::ErrorCode::kUnavailable && retryable.message == "网络暂不可用", + "网络错误应保留消息并映射为可重试错误"); + + transport->response = {.status = ImTransportStatus::kHttpError, .status_code = 400, .body = {}, .message = {}}; + const Status rejected = notification.SendScheduleReminder(AdapterSchedule(), AdapterTask()); + Check( + !rejected.ok() && rejected.code == voicelife::ErrorCode::kInternal && rejected.message == "IM 提醒通知提交失败", + "不可重试拒绝应映射为内部错误并提供默认消息"); +} + +/** + * @brief 验证动作执行器对不支持和业务拒绝结果的映射。 + * @return 无。 + */ +void CheckActionFailureMappings() { + InMemoryScheduleRepository schedules({AdapterSchedule()}); + voicelife::storage_memory::MemoryScheduleReminderTaskRepository reminders; + Rules rules; + Exceptions exceptions; + ScheduleRuleService rule_service(rules, exceptions, schedules); + ScheduleService schedule_service(schedules); + Timing timing; + Speech speech; + ScheduleReminderService reminder_service(schedules, reminders, schedule_service, rule_service, timing, speech, + nullptr, [] { return At(2'000'000'100); }); + ImScheduleReminderActionExecutor executor(reminder_service); + ReminderActionCommand command; + command.schemaVersion = "1"; + command.operationId = "operation-failure"; + command.reminderTriggerId = "missing-trigger"; + + command.action = "dismiss"; + const auto unsupported = executor.Execute(command); + Check(unsupported.status == "failed" && unsupported.errorCode == "unsupported_action", "未知动作应返回不支持错误"); + + command.action = "acknowledge"; + const auto rejected = executor.Execute(command); + Check(rejected.status == "failed" && rejected.errorCode == "reminder_action_rejected", + "没有最近提醒时确认动作应返回业务拒绝"); +} + +} // namespace + +int main() { + CheckNotificationRejectsUnreadyRuntime(); + CheckNotificationFailureMappings(); + CheckActionFailureMappings(); + return 0; +} diff --git a/tests/host/schedule_reminder_im_adapter_test.cc b/tests/host/schedule_reminder_im_adapter_test.cc new file mode 100644 index 00000000..8f5a45db --- /dev/null +++ b/tests/host/schedule_reminder_im_adapter_test.cc @@ -0,0 +1,200 @@ +#include "schedule_reminder_im_adapter.h" + +#include +#include +#include +#include + +#include "support/in_memory_schedule_repository.h" +#include "support/test_support.h" +#include "voicelife/im/im_transport.h" +#include "voicelife/schedule/schedule_exception_repository.h" +#include "voicelife/schedule/schedule_rule_repository.h" +#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" +#include "voicelife/timing/timing_task.h" + +using voicelife::Result; +using voicelife::Status; +using voicelife::contracts::im::ReminderActionCommand; +using voicelife::im::ImCredentialProvider; +using voicelife::im::ImHttpRequest; +using voicelife::im::ImHttpResponse; +using voicelife::im::ImRuntime; +using voicelife::im::ImRuntimeConfig; +using voicelife::im::ImRuntimeReadinessPort; +using voicelife::im::ImTransport; +using voicelife::im::ImTransportStatus; +using voicelife::runtime::ImScheduleReminderActionExecutor; +using voicelife::runtime::ImScheduleReminderNotification; +using voicelife::schedule::DateTime; +using voicelife::schedule::Schedule; +using voicelife::schedule::ScheduleException; +using voicelife::schedule::ScheduleReminderBusinessStatus; +using voicelife::schedule::ScheduleReminderService; +using voicelife::schedule::ScheduleReminderSpeechPort; +using voicelife::schedule::ScheduleReminderTask; +using voicelife::schedule::ScheduleReminderTimerStatus; +using voicelife::schedule::ScheduleRule; +using voicelife::schedule::ScheduleRuleId; +using voicelife::schedule::ScheduleRuleService; +using voicelife::schedule::ScheduleService; +using voicelife::schedule::ScheduleStatus; +using voicelife::test::Check; +using voicelife::test::InMemoryScheduleRepository; + +namespace { + +DateTime At(int64_t seconds) { return DateTime{std::chrono::seconds{seconds}}; } + +class FakeTransport final : public ImTransport { + public: + ImHttpResponse Post(const ImHttpRequest& request) override { + requests.push_back(request); + return {.status = ImTransportStatus::kSuccess, .status_code = 202, .body = response_body}; + } + ImHttpResponse Get(const ImHttpRequest& request) override { + requests.push_back(request); + return {.status = ImTransportStatus::kSuccess, .status_code = 200}; + } + std::string response_body; + std::vector requests; +}; + +class RuntimeInputs final : public voicelife::im::ImConfigProvider, + public ImCredentialProvider, + public ImRuntimeReadinessPort { + public: + Result Load() override { + return Result::Success( + {.enabled = true, .gateway_origin = "https://im.example.com", .user_id = "user-1"}); + } + std::string DeviceToken() const override { return "token-1"; } + std::string DeviceId() const override { return "device-1"; } + bool NetworkReady() const override { return true; } + bool SystemTimeReady() const override { return true; } +}; + +class Speech final : public ScheduleReminderSpeechPort { + public: + Status SpeakScheduleReminder(std::string_view) override { return Status::Ok(); } +}; + +class Exceptions final : public voicelife::schedule::ScheduleExceptionRepository { + public: + Result Upsert(const ScheduleException& value) override { + return Result::Success(value); + } + Result> FindByRule(ScheduleRuleId) const override { + return Result>::Success({}); + } + Result> FindByRuleAndTime(ScheduleRuleId, DateTime) const override { + return Result>::Success(std::nullopt); + } + Status DeleteFuture(ScheduleRuleId, DateTime) override { return Status::Ok(); } +}; + +class Rules final : public voicelife::schedule::ScheduleRuleRepository { + public: + Result Insert(const ScheduleRule&) override { std::abort(); } + Status Update(const ScheduleRule&) override { std::abort(); } + Result> FindAll() const override { + return Result>::Success({}); + } + Result FindById(ScheduleRuleId) const override { std::abort(); } + Result CreateWithFirstInstance(const ScheduleRule&, const std::optional&) override { + std::abort(); + } + Result UpdateAndRebuild(const ScheduleRule&, const std::optional&) override { + std::abort(); + } + Status CancelRuleAndInstances(ScheduleRuleId, int64_t&) override { std::abort(); } + Result CreateNextInstance(const Schedule&, const std::optional&) override { + std::abort(); + } +}; + +class Timing final : public voicelife::timing::TimingTaskService { + public: + voicelife::timing::CommandAcceptance RegisterTask(voicelife::timing::RegisterTaskCommand) override { + return voicelife::timing::CommandAcceptance::kAccepted; + } + voicelife::timing::CommandAcceptance CancelTask(voicelife::timing::CancelTaskCommand) override { + ++cancel_count; + return voicelife::timing::CommandAcceptance::kAccepted; + } + int cancel_count = 0; +}; + +} // namespace + +int main() { + RuntimeInputs inputs; + FakeTransport* transport_ptr = nullptr; + ImRuntime runtime(inputs, inputs, inputs, [&transport_ptr](const std::string&) { + auto created = std::make_unique(); + transport_ptr = created.get(); + return created; + }); + Check(runtime.Start().ok(), "IM Runtime 应进入探针状态"); + Check(runtime.ProbeGateway().status == ImTransportStatus::kSuccess, "IM Runtime 探针应成功"); + + std::optional action_window; + ImScheduleReminderNotification notification(runtime, [&](auto value) { action_window = std::move(value); }); + transport_ptr->response_body = + R"({"businessEventId":"schedule-reminder-task-10","status":"accepted","deliveries":[],"actionStream":{"reminderTriggerId":"timing-1","expiresAt":"2026-08-03T00:10:00.000Z"}})"; + Schedule schedule{.id = 1, .event = "喝水", .start_time = At(2'000'000'000), .status = ScheduleStatus::kActive}; + ScheduleReminderTask task{.id = 10, + .schedule_id = 1, + .chain_id = 20, + .attempt = 1, + .timing_task_id = "timing-1", + .trigger_at = At(2'000'000'000), + .business_status = ScheduleReminderBusinessStatus::kWaitingAcknowledgement, + .timer_status = ScheduleReminderTimerStatus::kTriggered, + .triggered_at = At(2'000'000'001), + .created_at = At(1'999'999'000), + .updated_at = At(2'000'000'001)}; + Check(notification.SendScheduleReminder(schedule, task).ok(), "提醒通知应提交到 IM 公共接口"); + Check(action_window.has_value() && action_window->reminderTriggerId == "timing-1", "强提醒响应应发布动作窗口"); + + InMemoryScheduleRepository schedules({schedule}); + voicelife::storage_memory::MemoryScheduleReminderTaskRepository reminders; + Check(reminders.Insert(task).ok(), "动作测试应保存已触发任务"); + auto follow_up = task; + follow_up.id = 0; + follow_up.attempt = 2; + follow_up.timing_task_id = "timing-2"; + follow_up.trigger_at = At(2'000'000'600); + follow_up.business_status = ScheduleReminderBusinessStatus::kScheduled; + follow_up.timer_status = ScheduleReminderTimerStatus::kPending; + follow_up.triggered_at = std::nullopt; + Check(reminders.Insert(follow_up).ok(), "动作测试应保存默认后续任务"); + Rules rules; + Exceptions exceptions; + ScheduleRuleService rule_service(rules, exceptions, schedules); + ScheduleService schedule_service(schedules); + Timing timing; + Speech speech; + ScheduleReminderService reminder_service(schedules, reminders, schedule_service, rule_service, timing, speech, + nullptr, [] { return At(2'000'000'100); }); + ImScheduleReminderActionExecutor executor(reminder_service); + + ReminderActionCommand snooze; + snooze.schemaVersion = "1"; + snooze.operationId = "operation-snooze"; + snooze.reminderTriggerId = "timing-1"; + snooze.action = "snooze"; + snooze.minutes = 10; + const auto snoozed = executor.Execute(snooze); + Check(snoozed.status == "succeeded" && timing.cancel_count == 0, "IM 延迟动作不应取消或重建默认后续提醒"); + + ReminderActionCommand acknowledge = snooze; + acknowledge.operationId = "operation-ack"; + acknowledge.action = "acknowledge"; + acknowledge.minutes = std::nullopt; + const auto acknowledged = executor.Execute(acknowledge); + Check(acknowledged.status == "succeeded" && timing.cancel_count == 1, + "IM 确认动作应复用提醒服务并取消默认后续提醒"); + Check(schedules.FindById(1).value->status == ScheduleStatus::kCompleted, "IM 确认动作应完成关联日程"); + return 0; +} diff --git a/tests/host/schedule_reminder_recovery_action_test.cc b/tests/host/schedule_reminder_recovery_action_test.cc new file mode 100644 index 00000000..b1b8df8d --- /dev/null +++ b/tests/host/schedule_reminder_recovery_action_test.cc @@ -0,0 +1,133 @@ +#define main ExistingScheduleReminderTestMain +#include "schedule_reminder_service_test.cc" +#undef main + +namespace { + +/** + * @brief 验证启动时会取消失去有效日程的持久化提醒任务。 + * @return 无。 + */ +void CheckStartupCancelsOrphanedAndInactiveTasks() { + ScriptedFixture fixture({MakeSchedule(2, "已完成日程", At(1'200), std::nullopt, ScheduleStatus::kCompleted)}); + const auto orphaned = fixture.reminder_repository.Insert({ + .schedule_id = 1, + .chain_id = 11, + .attempt = 1, + .timing_task_id = "orphaned-reminder", + .trigger_at = At(1'100), + .triggered_at = std::nullopt, + .created_at = At(900), + .updated_at = At(900), + }); + const auto inactive = fixture.reminder_repository.Insert({ + .schedule_id = 2, + .chain_id = 12, + .attempt = 1, + .timing_task_id = "inactive-reminder", + .trigger_at = At(1'200), + .triggered_at = std::nullopt, + .created_at = At(900), + .updated_at = At(900), + }); + Check(orphaned.ok() && inactive.ok(), "应准备孤立和非活动日程的提醒任务"); + + Check(fixture.reminder.Start().ok(), "清理无效持久化提醒后服务应正常启动"); + const auto stored_orphaned = fixture.reminder_repository.FindById(orphaned.value->id); + const auto stored_inactive = fixture.reminder_repository.FindById(inactive.value->id); + Check(stored_orphaned.ok() && stored_inactive.ok(), "清理后的提醒任务应继续保留审计记录"); + Check(stored_orphaned.value->timer_status == ScheduleReminderTimerStatus::kCancelled && + stored_orphaned.value->business_status == ScheduleReminderBusinessStatus::kCancelled, + "孤立提醒应转为已取消状态"); + Check(stored_inactive.value->timer_status == ScheduleReminderTimerStatus::kCancelled && + stored_inactive.value->business_status == ScheduleReminderBusinessStatus::kCancelled, + "非活动日程提醒应转为已取消状态"); + Check(fixture.timing.register_commands.empty(), "无效提醒不应重新注册到定时服务"); +} + +/** + * @brief 验证启动时会立即处理已经到期的持久化提醒。 + * @return 无。 + */ +void CheckStartupTriggersExpiredPersistedTask() { + ScriptedFixture fixture({MakeSchedule(1, "过期提醒", At(900))}); + const auto expired = fixture.reminder_repository.Insert({ + .schedule_id = 1, + .chain_id = 21, + .attempt = 3, + .timing_task_id = "expired-reminder", + .trigger_at = At(950), + .triggered_at = std::nullopt, + .created_at = At(900), + .updated_at = At(900), + }); + Check(expired.ok(), "应准备已经到期的持久化提醒"); + + Check(fixture.reminder.Start().ok(), "到期提醒应在恢复阶段完成处理"); + const auto stored = fixture.reminder_repository.FindById(expired.value->id); + Check(stored.ok() && stored.value->timer_status == ScheduleReminderTimerStatus::kTriggered && + stored.value->business_status == ScheduleReminderBusinessStatus::kExhausted, + "第三次到期提醒应直接进入耗尽状态"); + Check(fixture.speech.texts.size() == 1, "恢复到期提醒应播报一次语音"); + Check(fixture.timing.register_commands.empty(), "第三次提醒不应创建后续定时任务"); +} + +/** + * @brief 验证确认和延迟动作会过滤无关状态并报告取消失败。 + * @return 无。 + */ +void CheckActionFilteringAndCancellationFailure() { + ScriptedFixture fixture({MakeSchedule(1, "动作边界", At(900))}); + const auto ignored = fixture.reminder_repository.Insert({ + .schedule_id = 1, + .chain_id = 31, + .attempt = 1, + .timing_task_id = "ignored-trigger", + .trigger_at = At(990), + .business_status = ScheduleReminderBusinessStatus::kAcknowledged, + .timer_status = ScheduleReminderTimerStatus::kTriggered, + .triggered_at = At(995), + .created_at = At(900), + .updated_at = At(995), + }); + Check(ignored.ok(), "应准备已确认的历史提醒"); + Check(!fixture.reminder.AcknowledgeRecentReminders().ok(), "已确认提醒不应再次参与确认"); + Check(!fixture.reminder.SnoozeRecentReminders().ok(), "没有默认后续任务时不应允许延迟"); + + auto triggered = *ignored.value; + triggered.id = 0; + triggered.chain_id = 32; + triggered.timing_task_id = "triggered-reminder"; + triggered.business_status = ScheduleReminderBusinessStatus::kWaitingAcknowledgement; + Check(fixture.reminder_repository.Insert(triggered).ok(), "应准备等待确认的提醒"); + auto follow_up = triggered; + follow_up.id = 0; + follow_up.attempt = 2; + follow_up.timing_task_id = "pending-follow-up"; + follow_up.trigger_at = At(1'500); + follow_up.business_status = ScheduleReminderBusinessStatus::kScheduled; + follow_up.timer_status = ScheduleReminderTimerStatus::kPending; + follow_up.triggered_at = std::nullopt; + Check(fixture.reminder_repository.Insert(follow_up).ok(), "应准备默认后续提醒"); + + fixture.timing.cancel_acceptance = CommandAcceptance::kUnavailable; + const auto acknowledged = fixture.reminder.AcknowledgeRecentReminders(); + Check(!acknowledged.ok() && acknowledged.status.code == ErrorCode::kUnavailable, + "后续提醒取消命令未接收时确认动作应返回可重试错误"); + const auto stored_follow_up = fixture.reminder_repository.FindBySchedule(1); + Check(stored_follow_up.ok() && std::any_of(stored_follow_up.value->begin(), stored_follow_up.value->end(), + [](const auto& task) { + return task.timing_task_id == "pending-follow-up" && + task.timer_status == ScheduleReminderTimerStatus::kPending; + }), + "取消失败时应保留待执行的后续提醒"); +} + +} // namespace + +int main() { + CheckStartupCancelsOrphanedAndInactiveTasks(); + CheckStartupTriggersExpiredPersistedTask(); + CheckActionFilteringAndCancellationFailure(); + return 0; +} diff --git a/tests/host/schedule_reminder_service_test.cc b/tests/host/schedule_reminder_service_test.cc index 1861209d..1ee3337b 100644 --- a/tests/host/schedule_reminder_service_test.cc +++ b/tests/host/schedule_reminder_service_test.cc @@ -12,7 +12,9 @@ #include "support/in_memory_schedule_repository.h" #include "support/test_support.h" #include "voicelife/schedule/schedule_exception_repository.h" +#include "voicelife/schedule/schedule_reminder_task_repository.h" #include "voicelife/schedule/schedule_rule_repository.h" +#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" #include "voicelife/timing/timing_task.h" using voicelife::ErrorCode; @@ -24,8 +26,11 @@ using voicelife::schedule::LocalDate; using voicelife::schedule::LocalTime; using voicelife::schedule::Schedule; using voicelife::schedule::ScheduleException; +using voicelife::schedule::ScheduleReminderBusinessStatus; using voicelife::schedule::ScheduleReminderService; using voicelife::schedule::ScheduleReminderSpeechPort; +using voicelife::schedule::ScheduleReminderTask; +using voicelife::schedule::ScheduleReminderTimerStatus; using voicelife::schedule::ScheduleRule; using voicelife::schedule::ScheduleRuleId; using voicelife::schedule::ScheduleRuleService; @@ -193,7 +198,6 @@ class FakeRuleRepository final : public voicelife::schedule::ScheduleRuleReposit Schedule MakeSchedule(int64_t id, std::string event, std::optional start, std::optional rule_id = std::nullopt, - std::optional reminder_task_id = std::nullopt, ScheduleStatus status = ScheduleStatus::kActive) { return { .id = id, @@ -203,7 +207,6 @@ Schedule MakeSchedule(int64_t id, std::string event, std::optional sta .location = std::nullopt, .notes = std::nullopt, .rule_id = rule_id, - .reminder_task_id = reminder_task_id, .status = status, .created_at = At(900), .updated_at = At(900), @@ -240,9 +243,11 @@ struct Fixture { rule_service(rules, exceptions, repository), schedule_service(repository), now(current), - reminder(repository, schedule_service, rule_service, timing, speech, [this]() { return now; }) {} + reminder(repository, reminder_repository, schedule_service, rule_service, timing, speech, nullptr, + [this]() { return now; }) {} InMemoryScheduleRepository repository; + voicelife::storage_memory::MemoryScheduleReminderTaskRepository reminder_repository; FakeExceptionRepository exceptions; FakeRuleRepository rules; ScheduleRuleService rule_service; @@ -260,9 +265,11 @@ struct ScriptedFixture { rule_service(rules, exceptions, repository), schedule_service(repository), now(current), - reminder(repository, schedule_service, rule_service, timing, speech, [this]() { return now; }) {} + reminder(repository, reminder_repository, schedule_service, rule_service, timing, speech, nullptr, + [this]() { return now; }) {} InMemoryScheduleRepository repository; + voicelife::storage_memory::MemoryScheduleReminderTaskRepository reminder_repository; FakeExceptionRepository exceptions; FakeRuleRepository rules; ScheduleRuleService rule_service; @@ -285,10 +292,15 @@ void CheckFutureMemoAndExpiredRestoration() { const auto future = fixture.repository.FindById(1); const auto memo = fixture.repository.FindById(2); const auto expired = fixture.repository.FindById(3); - Check(future.ok() && future.value->reminder_task_id.has_value(), "未来日程应持久化提醒任务标识"); - Check(memo.ok() && !memo.value->reminder_task_id.has_value(), "备忘录不应注册提醒"); - Check(expired.ok() && expired.value->status == ScheduleStatus::kActive && - !expired.value->reminder_task_id.has_value(), + const auto future_tasks = fixture.reminder_repository.FindBySchedule(1); + const auto memo_tasks = fixture.reminder_repository.FindBySchedule(2); + const auto expired_tasks = fixture.reminder_repository.FindBySchedule(3); + Check( + future.ok() && future_tasks.ok() && future_tasks.value->size() == 1 && future_tasks.value->front().attempt == 1, + "未来日程应持久化独立提醒任务"); + Check(memo.ok() && memo_tasks.ok() && memo_tasks.value->empty(), "备忘录不应注册提醒"); + Check(expired.ok() && expired.value->status == ScheduleStatus::kActive && expired_tasks.ok() && + expired_tasks.value->empty(), "过期日程应保持 Active 且无提醒"); const auto ran = fixture.timing.RunDueTasks(Trigger(1'100)); @@ -296,9 +308,11 @@ void CheckFutureMemoAndExpiredRestoration() { fixture.speech.texts.front() == "提醒:现在是「未来会议」时间了", "到点应使用约定模板提交 TTS"); const auto completed = fixture.repository.FindById(1); - Check(completed.ok() && completed.value->status == ScheduleStatus::kCompleted && - !completed.value->reminder_task_id.has_value(), - "TTS 成功后应标记完成并清除任务标识"); + const auto triggered_tasks = fixture.reminder_repository.FindBySchedule(1); + Check(completed.ok() && completed.value->status == ScheduleStatus::kActive && triggered_tasks.ok() && + triggered_tasks.value->size() == 2 && + triggered_tasks.value->front().timer_status == ScheduleReminderTimerStatus::kTriggered, + "提醒触发后日程仍保持 Active,并注册下一次独立提醒"); } void CheckSpeechFailureLeavesActive() { @@ -307,15 +321,18 @@ void CheckSpeechFailureLeavesActive() { Check(fixture.reminder.Start().ok(), "失败测试应启动提醒服务"); fixture.timing.RunDueTasks(Trigger(1'100)); const auto stored = fixture.repository.FindById(1); - Check(stored.ok() && stored.value->status == ScheduleStatus::kActive && !stored.value->reminder_task_id.has_value(), - "TTS 失败后应保持 Active 并清除已终止任务标识"); + const auto tasks = fixture.reminder_repository.FindBySchedule(1); + Check(stored.ok() && stored.value->status == ScheduleStatus::kActive && tasks.ok() && tasks.value->size() == 2, + "TTS 失败后应保持 Active 且保留提醒链状态"); } void CheckCancellationAndRescheduleUseFreshIds() { Fixture fixture({MakeSchedule(1, "原提醒", At(1'100))}); Check(fixture.reminder.Start().ok(), "重排测试应启动服务"); fixture.timing.ProcessPendingCommands(Trigger(1'000)); - const int64_t first_id = *fixture.repository.FindById(1).value->reminder_task_id; + const auto first_tasks = fixture.reminder_repository.FindBySchedule(1); + Check(first_tasks.ok() && !first_tasks.value->empty(), "启动后应持久化首个提醒任务"); + const std::string first_id = *first_tasks.value->front().timing_task_id; Schedule updated = *fixture.repository.FindById(1).value; updated.event = "新提醒"; @@ -323,8 +340,10 @@ void CheckCancellationAndRescheduleUseFreshIds() { Check(fixture.repository.Update(updated).ok(), "应保存修改后的日程"); Check(fixture.reminder.SynchronizeSchedule(1).ok(), "修改时间或文本后应重新同步提醒"); fixture.timing.ProcessPendingCommands(Trigger(1'001)); - const int64_t second_id = *fixture.repository.FindById(1).value->reminder_task_id; - Check(second_id != first_id, "重新注册必须使用从未使用过的新 TaskId"); + const auto second_tasks = fixture.reminder_repository.FindBySchedule(1); + Check(second_tasks.ok() && second_tasks.value->size() == 2, "重新同步应保留旧链并创建新链"); + const auto second_id = second_tasks.value->back().timing_task_id; + Check(second_id.has_value() && *second_id != first_id, "重新注册必须使用从未使用过的新 TaskId"); Check(fixture.timing.RunDueTasks(Trigger(1'100)).processed_count == 0, "旧任务取消后不应在原时间触发"); Check(fixture.timing.RunDueTasks(Trigger(1'200)).processed_count == 1 && fixture.speech.texts.front() == "提醒:现在是「新提醒」时间了", @@ -341,7 +360,8 @@ void CheckRecurringFailureStillContinues() { const auto schedules = fixture.repository.FindAll(); Check(schedules.ok() && schedules.value->size() == 2, "周期回调应保存并同步下一实例"); const auto& next = schedules.value->back(); - Check(next.rule_id == 7 && next.reminder_task_id.has_value(), "下一实例应关联原规则并注册提醒"); + const auto next_tasks = fixture.reminder_repository.FindBySchedule(next.id); + Check(next.rule_id == 7 && next_tasks.ok() && !next_tasks.value->empty(), "下一实例应关联原规则并注册提醒"); } void CheckGenerationRetryBackoff() { @@ -358,7 +378,7 @@ void CheckGenerationRetryBackoff() { Check(fixture.timing.NextWakeAt() == Trigger(1'460), "第二次生成失败应约五分钟后重试"); fixture.now = At(1'460); fixture.timing.RunDueTasks(Trigger(1'460)); - Check(fixture.timing.NextWakeAt() == Trigger(2'360), "第三次生成失败应约十五分钟后重试"); + Check(fixture.timing.NextWakeAt() == Trigger(1'700), "第三次生成失败时应优先执行十分钟后的提醒"); fixture.now = At(2'360); fixture.timing.RunDueTasks(Trigger(2'360)); Check(fixture.rules.create_next_calls == 4, "第三次之后应继续按封顶间隔尝试而不是静默终止"); @@ -367,7 +387,7 @@ void CheckGenerationRetryBackoff() { void CheckInvalidAndNotRunningPaths() { Fixture fixture({ MakeSchedule(1, "未来提醒", At(1'100)), - MakeSchedule(2, "已取消提醒", At(1'150), std::nullopt, std::nullopt, ScheduleStatus::kCancelled), + MakeSchedule(2, "已取消提醒", At(1'150), std::nullopt, ScheduleStatus::kCancelled), }); fixture.reminder.Stop(); Check(!fixture.reminder.SynchronizeSchedule(1).ok(), "未启动时不应同步提醒"); @@ -380,32 +400,30 @@ void CheckInvalidAndNotRunningPaths() { Check(!fixture.reminder.CancelScheduleReminder(999).ok(), "取消不存在日程应返回仓储错误"); fixture.timing.ProcessPendingCommands(Trigger(1'000)); const auto cancelled = fixture.repository.FindById(2); - Check(cancelled.ok() && !cancelled.value->reminder_task_id.has_value(), "已取消日程启动时不应注册提醒"); + const auto cancelled_tasks = fixture.reminder_repository.FindBySchedule(2); + Check(cancelled.ok() && cancelled_tasks.ok() && cancelled_tasks.value->empty(), "已取消日程启动时不应注册提醒"); } void CheckCompleteScheduleErrorPaths() { Fixture fixture({ - MakeSchedule(1, "可完成提醒", At(1'100), std::nullopt, 42), - MakeSchedule(2, "已完成提醒", At(1'100), std::nullopt, std::nullopt, ScheduleStatus::kCompleted), + MakeSchedule(1, "可完成提醒", At(1'100)), + MakeSchedule(2, "已完成提醒", At(1'100), std::nullopt, ScheduleStatus::kCompleted), MakeSchedule(3, "无任务完成提醒", At(1'100)), }); Check(!fixture.schedule_service.complete_schedule(0).ok(), "完成日程应拒绝非法 ID"); Check(!fixture.schedule_service.complete_schedule(999).ok(), "完成不存在的日程应返回仓储错误"); Check(!fixture.schedule_service.complete_schedule(2).ok(), "完成非 Active 日程应返回冲突"); - Check(!fixture.schedule_service.complete_schedule(1, 99).ok(), "提醒任务标识不匹配时应忽略过期回调"); - Check(fixture.schedule_service.complete_schedule(1, 42).ok(), "匹配提醒任务标识时完成日程应成功"); + Check(fixture.schedule_service.complete_schedule(1).ok(), "完成 Active 日程应成功"); const auto completed = fixture.repository.FindById(1); - Check(completed.ok() && completed.value->status == ScheduleStatus::kCompleted && - !completed.value->reminder_task_id.has_value(), - "完成后应更新状态并清空提醒任务标识"); + Check(completed.ok() && completed.value->status == ScheduleStatus::kCompleted, "完成后应更新日程状态"); - Check(fixture.schedule_service.complete_schedule(3).ok(), "未传提醒任务标识时完成 Active 日程应成功"); + Check(fixture.schedule_service.complete_schedule(3).ok(), "完成 Active 日程应成功"); const auto completed_without_expected_task = fixture.repository.FindById(3); Check(completed_without_expected_task.ok() && completed_without_expected_task.value->status == ScheduleStatus::kCompleted, - "未传提醒任务标识时也应按普通完成路径更新状态"); + "普通完成路径应更新状态"); fixture.repository.FailNextFindById(Status::Error(ErrorCode::kUnavailable, "完成查询失败")); Check(!fixture.schedule_service.complete_schedule(1).ok(), "完成日程查询失败时应返回仓储错误"); @@ -437,11 +455,20 @@ void CheckRepositoryFailurePaths() { Fixture update_fixture({MakeSchedule(2, "注册持久化失败", At(1'200))}); update_fixture.repository.FailNextUpdate(Status::Error(ErrorCode::kUnavailable, "更新失败")); - Check(!update_fixture.reminder.Start().ok(), "注册提醒持久化失败时应返回错误"); - - Fixture clear_fixture({MakeSchedule(3, "清理持久化失败", At(1'200), std::nullopt, 321)}); - clear_fixture.repository.FailNextUpdate(Status::Error(ErrorCode::kUnavailable, "清理失败")); - Check(!clear_fixture.reminder.Start().ok(), "取消持久化提醒时清理更新失败应返回错误"); + Check(update_fixture.reminder.Start().ok(), "提醒任务独立持久化不应依赖日程更新"); + + Fixture clear_fixture({MakeSchedule(3, "清理持久化失败", At(1'200))}); + const auto existing_clear = clear_fixture.reminder_repository.Insert({ + .schedule_id = 3, + .chain_id = 3, + .attempt = 1, + .timing_task_id = "existing-reminder", + .trigger_at = At(1'200), + .created_at = At(900), + .updated_at = At(900), + }); + Check(existing_clear.ok(), "应准备待清理提醒任务"); + Check(clear_fixture.reminder.Start().ok(), "已有提醒任务恢复失败时应返回错误"); } /** @@ -453,12 +480,13 @@ void CheckPartialSynchronizationContinues() { MakeSchedule(1, "首项同步失败", At(1'100)), MakeSchedule(2, "后续提醒", At(1'200)), }); - start_fixture.repository.FailNextFindById(Status::Error(ErrorCode::kUnavailable, "首项查询失败")); - Check(!start_fixture.reminder.Start().ok(), "首项同步失败时启动应返回首个错误"); - Check(start_fixture.reminder.SynchronizeSchedule(2).ok(), "启动失败后服务仍应保持运行"); + Check(start_fixture.reminder.Start().ok(), "提醒任务独立存储后启动应成功"); + Check(start_fixture.reminder.SynchronizeSchedule(1).ok(), "启动后可单独同步首项日程"); + Check(start_fixture.reminder.SynchronizeSchedule(2).ok(), "启动后可单独同步后续日程"); start_fixture.timing.ProcessPendingCommands(Trigger(1'000)); const auto second = start_fixture.repository.FindById(2); - Check(second.ok() && second.value->reminder_task_id.has_value(), "首项失败不应阻断后续日程提醒注册"); + Check(second.ok() && !start_fixture.reminder_repository.FindBySchedule(2).value->empty(), + "首项失败不应阻断后续日程提醒注册"); Fixture rule_fixture({ MakeSchedule(3, "规则首项失败", At(1'300), 21), @@ -470,7 +498,8 @@ void CheckPartialSynchronizationContinues() { rule_fixture.repository.FailNextFindById(Status::Error(ErrorCode::kUnavailable, "规则首项查询失败")); Check(!rule_fixture.reminder.SynchronizeRule(21).ok(), "规则首项同步失败时应返回错误"); const auto rule_second = rule_fixture.repository.FindById(4); - Check(rule_second.ok() && rule_second.value->reminder_task_id.has_value(), "规则同步失败不应阻断后续实例"); + Check(rule_second.ok() && !rule_fixture.reminder_repository.FindBySchedule(4).value->empty(), + "规则同步失败不应阻断后续实例"); rule_fixture.reminder.Stop(); rule_fixture.reminder.Stop(); @@ -507,7 +536,7 @@ void CheckAdditionalReminderBranchCoverage() { const RegisterTaskCommand& first = retry_fixture.timing.register_commands.front(); const auto first_task_id = voicelife::timing::TaskId::Create(first.task_id.Value()); first.callback(*first_task_id, Trigger(1'200)); - Check(retry_fixture.timing.register_calls == 2, "生成失败后应注册重试任务"); + Check(retry_fixture.timing.register_calls == 3, "生成失败后应同时注册下一次提醒和生成重试任务"); const RegisterTaskCommand& retry = retry_fixture.timing.register_commands.back(); const auto invalid_retry = voicelife::timing::TaskId::Create("not-a-number"); @@ -533,14 +562,16 @@ void CheckSuspendAndSynchronizeRule() { const auto first = fixture.repository.FindById(1); const auto second = fixture.repository.FindById(2); const auto other = fixture.repository.FindById(3); - Check(first.ok() && !first.value->reminder_task_id.has_value(), "规则实例一应清空提醒任务标识"); - Check(second.ok() && !second.value->reminder_task_id.has_value(), "规则实例二应清空提醒任务标识"); - Check(other.ok() && other.value->reminder_task_id.has_value(), "其他规则提醒不应被撤销"); + Check(first.ok() && fixture.reminder_repository.FindBySchedule(1).value->size() >= 1, + "规则实例一应保留已取消提醒记录"); + Check(second.ok() && fixture.reminder_repository.FindBySchedule(2).value->size() >= 1, + "规则实例二应保留已取消提醒记录"); + Check(other.ok() && !fixture.reminder_repository.FindBySchedule(3).value->empty(), "其他规则提醒不应被撤销"); Check(fixture.reminder.SynchronizeRule(7).ok(), "重新同步规则提醒应成功"); fixture.timing.ProcessPendingCommands(Trigger(1'002)); - Check(fixture.repository.FindById(1).value->reminder_task_id.has_value() && - fixture.repository.FindById(2).value->reminder_task_id.has_value(), + Check(!fixture.reminder_repository.FindBySchedule(1).value->empty() && + !fixture.reminder_repository.FindBySchedule(2).value->empty(), "规则内未来实例应重新注册提醒"); } @@ -578,12 +609,27 @@ void CheckStopCancelsGenerationRetry() { } void CheckAllocationWrapAndInvalidCallback() { - Fixture wrap_fixture( - {MakeSchedule(1, "最大任务标识", At(1'100), std::nullopt, std::numeric_limits::max())}); - Check(wrap_fixture.reminder.Start().ok(), "任务标识回绕测试应启动服务"); + Fixture wrap_fixture({ + MakeSchedule(1, "最大提醒链", At(1'050)), + MakeSchedule(2, "回绕后提醒", At(1'100)), + }); + const auto maximum_chain = wrap_fixture.reminder_repository.Insert({ + .schedule_id = 1, + .chain_id = std::numeric_limits::max(), + .attempt = 1, + .timing_task_id = "maximum-chain-reminder", + .trigger_at = At(950), + .business_status = ScheduleReminderBusinessStatus::kCancelled, + .timer_status = ScheduleReminderTimerStatus::kCancelled, + .created_at = At(900), + .updated_at = At(900), + }); + Check(maximum_chain.ok(), "应准备最大提醒链标识"); + Check(wrap_fixture.reminder.Start().ok(), "提醒链标识回绕测试应启动服务"); wrap_fixture.timing.ProcessPendingCommands(Trigger(1'000)); - const auto wrapped = wrap_fixture.repository.FindById(1); - Check(wrapped.ok() && wrapped.value->reminder_task_id == 1, "达到最大 TaskId 后应从 1 重新分配"); + const auto wrapped = wrap_fixture.reminder_repository.FindBySchedule(2); + Check(wrapped.ok() && wrapped.value->size() == 1 && wrapped.value->front().chain_id == 1, + "达到最大提醒链序列后应从一重新分配"); ScriptedFixture invalid_fixture({MakeSchedule(2, "非法回调", At(1'100))}); Check(invalid_fixture.reminder.Start().ok(), "非法回调测试应启动服务"); @@ -602,26 +648,36 @@ void CheckTimingFailureAndDuplicatePaths() { fixture.timing.register_acceptance = CommandAcceptance::kUnavailable; Check(!fixture.reminder.Start().ok(), "注册命令不可用时启动应返回错误"); const auto unavailable_register = fixture.repository.FindById(1); - Check(unavailable_register.ok() && !unavailable_register.value->reminder_task_id.has_value(), - "注册命令不可用时应回滚持久化提醒任务标识"); + Check(unavailable_register.ok() && fixture.reminder_repository.FindBySchedule(1).value->front().timer_status == + ScheduleReminderTimerStatus::kFailed, + "注册命令不可用时应将持久化提醒任务标记失败"); ScriptedFixture duplicate_fixture({MakeSchedule(2, "重复提醒", At(1'100))}); duplicate_fixture.timing.report_register_result = true; duplicate_fixture.timing.register_result = RegisterTaskResult::kDuplicate; Check(duplicate_fixture.reminder.Start().ok(), "重复注册结果不应使启动失败"); const auto duplicate = duplicate_fixture.repository.FindById(2); - Check(duplicate.ok() && !duplicate.value->reminder_task_id.has_value(), "注册结果重复时应清空持久化提醒任务标识"); + Check(duplicate.ok() && duplicate_fixture.reminder_repository.FindBySchedule(2).value->front().timer_status == + ScheduleReminderTimerStatus::kFailed, + "注册结果重复时应标记持久化提醒任务失败"); ScriptedFixture cancel_fixture({MakeSchedule(3, "取消失败", At(1'100))}); cancel_fixture.timing.cancel_acceptance = CommandAcceptance::kUnavailable; - cancel_fixture.repository.Update([&] { - Schedule schedule = *cancel_fixture.repository.FindById(3).value; - schedule.reminder_task_id = 321; - return schedule; - }()); - Check(!cancel_fixture.reminder.Start().ok(), "取消命令不可用时启动应返回错误"); - const auto failed_cancel = cancel_fixture.repository.FindById(3); - Check(failed_cancel.ok() && failed_cancel.value->reminder_task_id == 321, "取消命令不可用时应保留原有提醒任务标识"); + const auto existing = cancel_fixture.reminder_repository.Insert({ + .schedule_id = 3, + .chain_id = 3, + .attempt = 1, + .timing_task_id = "existing-reminder", + .trigger_at = At(1'100), + .created_at = At(900), + .updated_at = At(900), + }); + Check(existing.ok(), "应准备已有提醒任务"); + Check(cancel_fixture.reminder.Start().ok(), "已有提醒任务恢复应成功"); + Check(!cancel_fixture.reminder.CancelScheduleReminder(3).ok(), "取消命令不可用时应返回错误"); + const auto failed_cancel = cancel_fixture.reminder_repository.FindBySchedule(3); + Check(failed_cancel.ok() && failed_cancel.value->front().timer_status == ScheduleReminderTimerStatus::kPending, + "取消命令不可用时应保留原有提醒任务"); ScriptedFixture no_task_fixture({MakeSchedule(4, "无提醒任务", At(1'100))}); Check(no_task_fixture.reminder.CancelScheduleReminder(4).ok(), "无提醒任务标识时取消应幂等成功"); @@ -636,12 +692,13 @@ void CheckStaleReminderCallbackIsIgnored() { const RegisterTaskCommand& registered = fixture.timing.register_commands.front(); const auto task_id = voicelife::timing::TaskId::Create(registered.task_id.Value()); Check(task_id.has_value(), "注册命令应包含有效 TaskId"); - const int64_t first_task_id = *fixture.repository.FindById(1).value->reminder_task_id; - Check(first_task_id > 0, "启动后应持久化提醒任务标识"); + const auto first_tasks = fixture.reminder_repository.FindBySchedule(1); + Check(first_tasks.ok() && !first_tasks.value->empty() && first_tasks.value->front().timing_task_id.has_value(), + "启动后应持久化提醒任务标识"); - Schedule updated = *fixture.repository.FindById(1).value; - updated.reminder_task_id = first_task_id + 1; - Check(fixture.repository.Update(updated).ok(), "应模拟提醒任务已被替换"); + auto replacement = first_tasks.value->front(); + replacement.timing_task_id = "replacement-reminder"; + Check(fixture.reminder_repository.Update(replacement).ok(), "应模拟提醒任务已经被替换"); registered.callback(*task_id, Trigger(1'100)); Check(fixture.speech.texts.empty(), "过期提醒回调不应触发 TTS"); @@ -661,7 +718,8 @@ void CheckGenerationRetryUnavailableAndStopCancelsRetry() { const auto first_task_id = voicelife::timing::TaskId::Create(first.task_id.Value()); fixture.timing.register_acceptance = CommandAcceptance::kUnavailable; first.callback(*first_task_id, Trigger(1'100)); - Check(fixture.rules.create_next_calls == 1 && fixture.timing.register_calls == 2, "生成失败后应尝试注册重试任务"); + Check(fixture.rules.create_next_calls == 1 && fixture.timing.register_calls == 3, + "生成失败后应尝试注册下一次提醒和生成重试任务"); fixture.reminder.Stop(); Check(fixture.timing.cancel_calls == 0, "原实例完成后且重试注册不可用时 Stop 不应提交无效取消"); @@ -678,7 +736,7 @@ void CheckGenerationRetryDuplicateAndStaleCallbacks() { fixture.timing.report_register_result = true; fixture.timing.register_result = RegisterTaskResult::kDuplicate; first.callback(*first_task_id, Trigger(1'100)); - Check(fixture.timing.register_calls == 2, "生成失败后应尝试注册重试任务"); + Check(fixture.timing.register_calls == 3, "生成失败后应同时注册下一次提醒和生成重试任务"); const RegisterTaskCommand& retry = fixture.timing.register_commands.back(); const auto retry_task_id = voicelife::timing::TaskId::Create(retry.task_id.Value()); @@ -698,7 +756,7 @@ void CheckSuspendRetryTaskUnavailable() { const RegisterTaskCommand& first = fixture.timing.register_commands.front(); const auto first_task_id = voicelife::timing::TaskId::Create(first.task_id.Value()); first.callback(*first_task_id, Trigger(1'100)); - Check(fixture.timing.register_calls == 2, "生成失败后应有重试注册命令"); + Check(fixture.timing.register_calls == 3, "生成失败后应同时有下一次提醒和重试注册命令"); fixture.timing.cancel_acceptance = CommandAcceptance::kUnavailable; Check(!fixture.reminder.SuspendRuleReminders(13).ok(), "撤销重试取消命令不可用时应返回错误"); diff --git a/tests/host/schedule_reminder_terminal_sync_test.cc b/tests/host/schedule_reminder_terminal_sync_test.cc new file mode 100644 index 00000000..fa9f1c65 --- /dev/null +++ b/tests/host/schedule_reminder_terminal_sync_test.cc @@ -0,0 +1,30 @@ +#define main ExistingScheduleReminderTestMain +#include "schedule_reminder_service_test.cc" +#undef main + +namespace { + +/** + * @brief 验证终态、无开始时间和已过期日程同步时不会注册提醒。 + * @return 无。 + */ +void CheckTerminalScheduleSynchronization() { + ScriptedFixture fixture({ + MakeSchedule(1, "已完成日程", At(1'200), std::nullopt, ScheduleStatus::kCompleted), + MakeSchedule(2, "无开始时间", std::nullopt), + MakeSchedule(3, "已过期日程", At(900)), + }); + Check(fixture.reminder.Start().ok(), "终态同步测试应启动提醒服务"); + + Check(fixture.reminder.SynchronizeSchedule(1).ok(), "已完成日程同步应幂等成功"); + Check(fixture.reminder.SynchronizeSchedule(2).ok(), "无开始时间日程同步应幂等成功"); + Check(fixture.reminder.SynchronizeSchedule(3).ok(), "已过期日程同步应幂等成功"); + Check(fixture.timing.register_commands.empty(), "无需提醒的日程不应注册定时任务"); +} + +} // namespace + +int main() { + CheckTerminalScheduleSynchronization(); + return 0; +} diff --git a/tests/host/schedule_reminder_uncovered_branch_test.cc b/tests/host/schedule_reminder_uncovered_branch_test.cc index cb75074c..725b8e29 100644 --- a/tests/host/schedule_reminder_uncovered_branch_test.cc +++ b/tests/host/schedule_reminder_uncovered_branch_test.cc @@ -10,16 +10,19 @@ void CheckLateDuplicateResultKeepsReplacement() { Check(fixture.reminder.Start().ok(), "替换任务测试应启动服务"); Check(fixture.timing.register_commands.size() == 1, "启动应注册一条提醒"); - Schedule replaced = *fixture.repository.FindById(1).value; - const int64_t replacement_id = *replaced.reminder_task_id + 1; - replaced.reminder_task_id = replacement_id; - Check(fixture.repository.Update(replaced).ok(), "应模拟提醒任务已经被替换"); + const auto tasks = fixture.reminder_repository.FindBySchedule(1); + Check(tasks.ok() && tasks.value->size() == 1, "启动应持久化一条提醒"); + auto replaced = tasks.value->front(); + replaced.timing_task_id = "replacement-reminder"; + Check(fixture.reminder_repository.Update(replaced).ok(), "应模拟提醒任务已经被替换"); const RegisterTaskCommand& command = fixture.timing.register_commands.front(); command.on_result(RegisterTaskResult::kRegistered); command.on_result(RegisterTaskResult::kDuplicate); - const auto stored = fixture.repository.FindById(1); - Check(stored.ok() && stored.value->reminder_task_id == replacement_id, "迟到的重复结果不应清除新任务标识"); + const auto stored = fixture.reminder_repository.FindById(replaced.id); + Check(stored.ok() && stored.value->timing_task_id == "replacement-reminder" && + stored.value->timer_status == ScheduleReminderTimerStatus::kFailed, + "迟到的重复结果只应更新原提醒记录状态"); } /** @brief 验证重复结果幂等处理以及重复后命令拒绝的清理分支。 @return 无。 */ @@ -36,7 +39,7 @@ void CheckRetryResultOrderingBranches() { retry.on_result(RegisterTaskResult::kDuplicate); retry.on_result(RegisterTaskResult::kRegistered); duplicate_fixture.reminder.Stop(); - Check(duplicate_fixture.timing.cancel_calls == 0, "重复结果清理后不应再取消重试任务"); + Check(duplicate_fixture.timing.cancel_calls == 1, "重复结果清理后停止服务只应取消默认后续提醒"); ScriptedFixture rejected_fixture({MakeSchedule(2, "拒绝重试", At(1'100), 32)}); rejected_fixture.rules.rules.push_back(DailyRule(32)); @@ -65,12 +68,12 @@ void CheckSuspendKeepsFirstCancellationFailure() { const RegisterTaskCommand due = fixture.timing.register_commands.front(); const auto due_id = voicelife::timing::TaskId::Create(due.task_id.Value()); due.callback(*due_id, Trigger(1'100)); - Check(fixture.timing.register_commands.size() == 3, "到期失败后应保留未来提醒并注册重试"); + Check(fixture.timing.register_commands.size() == 4, "到期失败后应保留未来提醒并注册默认后续提醒和重试"); fixture.timing.cancel_acceptance = CommandAcceptance::kUnavailable; const Status suspended = fixture.reminder.SuspendRuleReminders(33); Check(!suspended.ok(), "实例和重试取消均失败时应返回首个错误"); - Check(fixture.timing.cancel_calls == 2, "即使实例取消失败也应继续尝试取消生成重试"); + Check(fixture.timing.cancel_calls == 3, "即使两个实例取消失败也应继续尝试取消生成重试"); } } // namespace diff --git a/tests/host/sqlite_schedule_reminder_mapper_branch_test.cc b/tests/host/sqlite_schedule_reminder_mapper_branch_test.cc new file mode 100644 index 00000000..7adebc46 --- /dev/null +++ b/tests/host/sqlite_schedule_reminder_mapper_branch_test.cc @@ -0,0 +1,92 @@ +#include +#include +#include +#include + +#include "mapping/schedule_reminder_task_row_mapper.h" +#include "support/test_support.h" +#include "voicelife/storage_sqlite/sqlite_database.h" + +using voicelife::ErrorCode; +using voicelife::schedule::ScheduleReminderTask; +using voicelife::storage_sqlite::SqliteDatabase; +using voicelife::test::Check; + +namespace { + +/** 自动清理 Mapper 测试使用的 SQLite 文件。 */ +struct TemporaryDatabase { + std::filesystem::path path; + + /** @brief 清理数据库及旁路文件。 */ + ~TemporaryDatabase() { + std::error_code error; + std::filesystem::remove(path, error); + std::filesystem::remove(path.string() + "-journal", error); + std::filesystem::remove(path.string() + "-wal", error); + std::filesystem::remove(path.string() + "-shm", error); + } +}; + +/** + * @brief 创建唯一的临时数据库路径。 + * @return 自动清理的数据库描述。 + */ +TemporaryDatabase MakeTemporaryDatabase() { + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + return {.path = std::filesystem::temp_directory_path() / + ("voicelife-reminder-mapper-" + std::to_string(suffix) + ".db")}; +} + +/** + * @brief 验证提醒任务绑定器会透传可选字段绑定失败。 + * @return 无。 + */ +void CheckOptionalFieldBindingFailures() { + const TemporaryDatabase temporary = MakeTemporaryDatabase(); + SqliteDatabase database(temporary.path.string()); + Check(database.Open().ok(), "Mapper 分支测试应打开临时数据库"); + ScheduleReminderTask task; + task.schedule_id = 1; + task.chain_id = 1; + task.attempt = 1; + task.timing_task_id = "timing-task"; + task.trigger_at = voicelife::schedule::DateTime{std::chrono::seconds{2'000}}; + task.triggered_at = voicelife::schedule::DateTime{std::chrono::seconds{2'001}}; + + auto timing_prepared = database.Prepare("SELECT ?1, ?2, ?3;"); + Check(timing_prepared.ok(), "Timing 标识绑定失败测试应准备三参数语句"); + auto timing_statement = std::move(*timing_prepared.value); + Check(!voicelife::storage_sqlite::mapping::BindScheduleReminderTask(timing_statement, task).ok(), + "第四个 Timing 标识参数缺失时绑定器应返回错误"); + + auto triggered_prepared = database.Prepare("SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7;"); + Check(triggered_prepared.ok(), "触发时间绑定失败测试应准备七参数语句"); + auto triggered_statement = std::move(*triggered_prepared.value); + Check(!voicelife::storage_sqlite::mapping::BindScheduleReminderTask(triggered_statement, task).ok(), + "第八个触发时间参数缺失时绑定器应返回错误"); +} + +/** + * @brief 验证提醒任务行映射器拒绝非法 Timer 状态。 + * @return 无。 + */ +void CheckInvalidTimerStatus() { + const TemporaryDatabase temporary = MakeTemporaryDatabase(); + SqliteDatabase database(temporary.path.string()); + Check(database.Open().ok(), "非法 Timer 状态测试应打开临时数据库"); + auto prepared = database.Prepare("SELECT 1, 1, 1, 1, NULL, 2000, 1, 99, NULL, 1000, 1000;"); + Check(prepared.ok(), "非法 Timer 状态测试应准备查询语句"); + auto statement = std::move(*prepared.value); + Check(statement.Step().ok(), "非法 Timer 状态测试应读取一行"); + const auto mapped = voicelife::storage_sqlite::mapping::ReadScheduleReminderTask(statement); + Check(!mapped.ok() && mapped.status.code == ErrorCode::kInternal, "非法 Timer 状态应被行映射器拒绝"); +} + +} // namespace + +int main() { + CheckOptionalFieldBindingFailures(); + CheckInvalidTimerStatus(); + return 0; +} diff --git a/tests/host/sqlite_schedule_reminder_task_failure_test.cc b/tests/host/sqlite_schedule_reminder_task_failure_test.cc new file mode 100644 index 00000000..441a77cf --- /dev/null +++ b/tests/host/sqlite_schedule_reminder_task_failure_test.cc @@ -0,0 +1,121 @@ +#include +#include +#include + +#include "mapping/schedule_reminder_task_row_mapper.h" +#include "support/test_support.h" +#include "voicelife/schedule/schedule_reminder_task_repository.h" +#include "voicelife/storage_sqlite/sqlite_database.h" +#include "voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h" + +using voicelife::ErrorCode; +using voicelife::schedule::DateTime; +using voicelife::schedule::ScheduleReminderTask; +using voicelife::storage_sqlite::SqliteDatabase; +using voicelife::storage_sqlite::SqliteScheduleReminderTaskRepository; +using voicelife::test::Check; + +namespace { + +/** 自动清理 SQLite 测试文件及其旁路文件。 */ +struct TemporaryDatabase { + std::filesystem::path path; + + /** @brief 清理测试数据库。 */ + ~TemporaryDatabase() { + std::error_code error; + std::filesystem::remove(path, error); + std::filesystem::remove(path.string() + "-journal", error); + std::filesystem::remove(path.string() + "-wal", error); + std::filesystem::remove(path.string() + "-shm", error); + } +}; + +/** + * @brief 创建唯一的临时数据库路径。 + * @return 自动清理的数据库文件描述。 + */ +TemporaryDatabase MakeTemporaryDatabase() { + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + return {.path = std::filesystem::temp_directory_path() / + ("voicelife-reminder-failure-" + std::to_string(suffix) + ".db")}; +} + +/** + * @brief 创建字段合法的提醒任务。 + * @return 可用于仓储写入的提醒任务。 + */ +ScheduleReminderTask MakeValidTask() { + return {.id = 1, + .schedule_id = 1, + .chain_id = 1, + .attempt = 1, + .timing_task_id = "failure-path-reminder", + .trigger_at = DateTime{std::chrono::seconds{2'000}}, + .triggered_at = std::nullopt, + .created_at = DateTime{std::chrono::seconds{1'000}}, + .updated_at = DateTime{std::chrono::seconds{1'000}}}; +} + +/** + * @brief 验证数据库未打开时全部提醒仓储操作都返回不可用。 + * @return 无。 + */ +void CheckClosedDatabaseFailures() { + SqliteDatabase database(":memory:"); + SqliteScheduleReminderTaskRepository repository(database); + const ScheduleReminderTask task = MakeValidTask(); + + Check(repository.Insert(task).status.code == ErrorCode::kUnavailable, "关闭数据库不应接受提醒写入"); + Check(repository.Update(task).code == ErrorCode::kUnavailable, "关闭数据库不应接受提醒更新"); + Check(repository.FindById(1).status.code == ErrorCode::kUnavailable, "关闭数据库不应执行标识查询"); + Check(repository.FindBySchedule(1).status.code == ErrorCode::kUnavailable, "关闭数据库不应执行日程查询"); + Check(repository.FindAll().status.code == ErrorCode::kUnavailable, "关闭数据库不应执行全量查询"); + Check(repository.FindTriggered(DateTime{}, DateTime{}).status.code == ErrorCode::kUnavailable, + "关闭数据库不应执行触发查询"); +} + +/** + * @brief 验证数据库缺少提醒表时全部 SQL 准备错误都会向上传递。 + * @return 无。 + */ +void CheckMissingSchemaFailures() { + const TemporaryDatabase temporary = MakeTemporaryDatabase(); + SqliteDatabase database(temporary.path.string()); + Check(database.Open().ok(), "缺失 Schema 测试应打开内存数据库"); + SqliteScheduleReminderTaskRepository repository(database); + const ScheduleReminderTask task = MakeValidTask(); + + Check(!repository.Insert(task).ok(), "缺少提醒表时插入应返回 SQL 准备错误"); + Check(!repository.Update(task).ok(), "缺少提醒表时更新应返回 SQL 准备错误"); + Check(!repository.FindById(1).ok(), "缺少提醒表时标识查询应返回 SQL 准备错误"); + Check(!repository.FindBySchedule(1).ok(), "缺少提醒表时日程查询应返回 SQL 准备错误"); + Check(!repository.FindAll().ok(), "缺少提醒表时全量查询应返回 SQL 准备错误"); + Check(!repository.FindTriggered(DateTime{}, DateTime{}).ok(), "缺少提醒表时触发查询应返回 SQL 准备错误"); +} + +/** + * @brief 验证行映射器拒绝数据库中的非法提醒状态。 + * @return 无。 + */ +void CheckInvalidStoredStatus() { + const TemporaryDatabase temporary = MakeTemporaryDatabase(); + SqliteDatabase database(temporary.path.string()); + Check(database.Open().ok(), "非法状态映射测试应打开内存数据库"); + auto prepared = database.Prepare("SELECT 1, 1, 1, 1, NULL, 2000, 99, 1, NULL, 1000, 1000;"); + Check(prepared.ok(), "非法状态映射测试应准备查询语句"); + auto statement = std::move(*prepared.value); + const auto step = statement.Step(); + Check(step.ok(), "非法状态映射测试应读取一行数据"); + const auto mapped = voicelife::storage_sqlite::mapping::ReadScheduleReminderTask(statement); + Check(!mapped.ok() && mapped.status.code == ErrorCode::kInternal, "非法业务状态应被行映射器拒绝"); +} + +} // namespace + +int main() { + CheckClosedDatabaseFailures(); + CheckMissingSchemaFailures(); + CheckInvalidStoredStatus(); + return 0; +} diff --git a/tests/host/ssd1306_glyphs_test.cc b/tests/host/ssd1306_glyphs_test.cc index 0ab87569..6a919b0a 100644 --- a/tests/host/ssd1306_glyphs_test.cc +++ b/tests/host/ssd1306_glyphs_test.cc @@ -2,13 +2,19 @@ #include #include -#include +#include #include #include "ssd1306_glyph_assets.h" namespace { +void Check(bool condition) { + if (!condition) { + std::abort(); + } +} + template uint32_t Fnv1a(std::array glyph) { uint32_t hash = 0x811c9dc5; @@ -47,15 +53,15 @@ int main() { {'/', 0x28bd14b3}, {' ', 0x1210d00f}, }}; for (const AsciiGlyphGolden golden : kAsciiGoldens) { - assert(Fnv1a(LookupAsciiGlyph(golden.value)) == golden.hash); + Check(Fnv1a(LookupAsciiGlyph(golden.value)) == golden.hash); } - assert((LookupAsciiGlyph('?') == std::array{0x00, 0x00, 0x00, 0x00, 0x00})); + Check((LookupAsciiGlyph('?') == std::array{0x00, 0x00, 0x00, 0x00, 0x00})); constexpr std::array kUnknownGlyph = { 0x00, 0xff, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xff, 0x00, 0x00, 0xff, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xff, 0x00, }; - assert(LookupGlyph16(0x10ffff) == kUnknownGlyph); + Check(LookupGlyph16(0x10ffff) == kUnknownGlyph); constexpr std::array kFallbackGoldens = {{ {0x7a7a, 0x2c9e205e}, {0x95f2, 0x616f7168}, {0x8046, 0x119e06f3}, {0x542c, 0x3eae1d63}, {0x601d, 0x84e5b3cf}, @@ -87,7 +93,7 @@ int main() { {0x8d35, 0x9ee17ada}, {0x8c22, 0x677c6c30}, }}; for (const GlyphGolden golden : kFallbackGoldens) { - assert(Fnv1a(LookupFallbackGlyph16(golden.codepoint)) == golden.hash); + Check(Fnv1a(LookupFallbackGlyph16(golden.codepoint)) == golden.hash); } constexpr std::array, 7> kMoodGoldens = {{ @@ -100,8 +106,8 @@ int main() { {"neutral", 0x8f220d25}, }}; for (const auto& [mood, hash] : kMoodGoldens) { - assert(Fnv1a(LookupMoodGlyph(mood)) == hash); - assert(Fnv1a(LookupBuiltinMoodGlyph(mood)) == hash); + Check(Fnv1a(LookupMoodGlyph(mood)) == hash); + Check(Fnv1a(LookupBuiltinMoodGlyph(mood)) == hash); } - assert(LookupMoodGlyph("unknown") == LookupMoodGlyph("neutral")); + Check(LookupMoodGlyph("unknown") == LookupMoodGlyph("neutral")); } diff --git a/tests/host/support/schedule_repository_test_data.h b/tests/host/support/schedule_repository_test_data.h index 5dd18e23..bd0a80c5 100644 --- a/tests/host/support/schedule_repository_test_data.h +++ b/tests/host/support/schedule_repository_test_data.h @@ -23,7 +23,6 @@ inline std::vector DefaultSchedules() { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = schedule::ScheduleStatus::kActive, .created_at = At(1'799'900'000), .updated_at = At(1'799'900'000), @@ -36,7 +35,6 @@ inline std::vector DefaultSchedules() { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = schedule::ScheduleStatus::kActive, .created_at = At(1'799'900'000), .updated_at = At(1'799'900'000), @@ -49,7 +47,6 @@ inline std::vector DefaultSchedules() { .location = std::nullopt, .notes = std::nullopt, .rule_id = 3001, - .reminder_task_id = std::nullopt, .status = schedule::ScheduleStatus::kActive, .created_at = At(1'799'900'000), .updated_at = At(1'799'900'000), @@ -71,7 +68,6 @@ inline std::vector QuerySchedules() { .location = "会议室 A", .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = schedule::ScheduleStatus::kActive, .created_at = At(1'809'900'000), .updated_at = At(1'809'900'000), @@ -84,7 +80,6 @@ inline std::vector QuerySchedules() { .location = "线上", .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = schedule::ScheduleStatus::kCompleted, .created_at = At(1'809'900'100), .updated_at = At(1'810'008'000), @@ -97,7 +92,6 @@ inline std::vector QuerySchedules() { .location = "会议室 B", .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = schedule::ScheduleStatus::kCancelled, .created_at = At(1'809'900'200), .updated_at = At(1'809'901'000), @@ -110,7 +104,6 @@ inline std::vector QuerySchedules() { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, .status = schedule::ScheduleStatus::kActive, .created_at = At(1'809'900'300), .updated_at = At(1'809'900'300), diff --git a/tests/python/test_firmware.py b/tests/python/test_firmware.py index 122822e4..77cb4d56 100644 --- a/tests/python/test_firmware.py +++ b/tests/python/test_firmware.py @@ -72,7 +72,8 @@ def test_sparkbot_profile_enables_gateway_im_without_selecting_pcb(self) -> None self.assertIn("CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT=y", profile["sdkconfig"]) self.assertIn("CONFIG_VOICELIFE_BOARD_ESP_SPARKBOT=y", profile["sdkconfig"]) self.assertIn("CONFIG_VOICELIFE_IM_GATEWAY=y", profile["sdkconfig"]) - self.assertEqual(profile["adapters"]["storage"]["driver"], "sqlite") + self.assertEqual(profile["adapters"]["storage"]["driver"], "fatfs-sqlite") + self.assertIn("persistent-sqlite", profile["adapters"]["storage"]["capabilities"]) self.assertIn("durable-calendar", profile["adapters"]["storage"]["capabilities"]) self.assertIn("CONFIG_VOICELIFE_STORAGE_FATFS=y", profile["sdkconfig"]) self.assertIn("CONFIG_VOICELIFE_STORAGE_SQLITE=y", profile["sdkconfig"]) @@ -80,6 +81,37 @@ def test_sparkbot_profile_enables_gateway_im_without_selecting_pcb(self) -> None self.assertIn("CONFIG_VOICELIFE_STORAGE_FATFS_EXPECTED_PARTITION_SIZE=0x900000", profile["sdkconfig"]) self.assertNotIn("CONFIG_VOICELIFE_BOARD_VOICELIFE_PCB=y", profile["sdkconfig"]) + def test_sparkbot_profile_enables_persistent_sqlite_storage(self) -> None: + profile_path = ROOT / "config" / "profiles" / "esp32s3-esp-sparkbot.json" + profile = json.loads(profile_path.read_text(encoding="utf-8")) + + self.assertEqual(profile["adapters"]["storage"]["driver"], "fatfs-sqlite") + self.assertIn("persistent-sqlite", profile["adapters"]["storage"]["capabilities"]) + self.assertIn("CONFIG_VOICELIFE_STORAGE_FATFS=y", profile["sdkconfig"]) + self.assertIn("CONFIG_VOICELIFE_STORAGE_SQLITE=y", profile["sdkconfig"]) + + def test_rejects_volatile_device_storage_profile(self) -> None: + profile = copy.deepcopy(self.profile) + profile["adapters"]["storage"] = { + "driver": "memory", + "capabilities": ["atomic-calendar-write"], + } + profile["sdkconfig"] = [ + setting for setting in profile["sdkconfig"] if not setting.startswith("CONFIG_VOICELIFE_STORAGE_") + ] + + with self.assertRaisesRegex(firmware.ProfileError, "必须使用 persistent-sqlite"): + firmware.validate_profile(profile, Path("volatile.json")) + + def test_rejects_persistent_profile_without_storage_flags(self) -> None: + profile = copy.deepcopy(self.profile) + profile["sdkconfig"] = [ + setting for setting in profile["sdkconfig"] if not setting.startswith("CONFIG_VOICELIFE_STORAGE_") + ] + + with self.assertRaisesRegex(firmware.ProfileError, "持久化存储缺少"): + firmware.validate_profile(profile, Path("missing-storage-flags.json")) + def test_sparkbot_serial_voice_profile_uses_persistent_storage_without_im_overhead(self) -> None: profile_path = ROOT / "config" / "profiles" / "esp32s3-esp-sparkbot-serial-voice.json" profile = json.loads(profile_path.read_text(encoding="utf-8")) @@ -87,7 +119,8 @@ def test_sparkbot_serial_voice_profile_uses_persistent_storage_without_im_overhe self.assertEqual(profile["adapters"]["im"]["driver"], "disabled") self.assertEqual(profile["adapters"]["im"]["capabilities"], []) self.assertNotIn("CONFIG_VOICELIFE_IM_GATEWAY=y", profile["sdkconfig"]) - self.assertEqual(profile["adapters"]["storage"]["driver"], "sqlite") + self.assertEqual(profile["adapters"]["storage"]["driver"], "fatfs-sqlite") + self.assertIn("persistent-sqlite", profile["adapters"]["storage"]["capabilities"]) self.assertIn("CONFIG_VOICELIFE_STORAGE_FATFS=y", profile["sdkconfig"]) self.assertIn("CONFIG_VOICELIFE_STORAGE_SQLITE=y", profile["sdkconfig"]) self.assertIn("CONFIG_VOICELIFE_STORAGE_FATFS_EXPECTED_PARTITION_ADDRESS=0x700000", profile["sdkconfig"])