From a0020801bc5062b4113bdf65987005bc45b007b1 Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Thu, 20 Aug 2026 18:06:51 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E2=9C=A8=20feat(schedule):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=8F=90=E9=86=92=E4=BB=BB=E5=8A=A1=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tools/schedule_mcp_tools.cc | 34 +- .../src/bootstrap/storage_bootstrap.cc | 15 +- .../src/bootstrap/storage_bootstrap.h | 4 + components/voicelife_runtime/src/runtime.cc | 8 +- .../schedule/schedule_reminder_service.h | 78 +-- .../schedule_reminder_task_repository.h | 59 ++ .../voicelife/schedule/schedule_service.h | 5 +- .../voicelife/schedule/schedule_types.h | 2 - .../src/mock/schedule_mock_data.cc | 6 - .../src/service/schedule_reminder_service.cc | 529 +++++++++--------- .../src/service/schedule_service.cc | 6 +- .../test/schedule_repository_service_test.cc | 4 +- .../voicelife_storage_memory/CMakeLists.txt | 1 + ...memory_schedule_reminder_task_repository.h | 27 + ...emory_schedule_reminder_task_repository.cc | 89 +++ .../voicelife_storage_sqlite/CMakeLists.txt | 4 + ...sqlite_schedule_reminder_task_repository.h | 27 + .../storage_sqlite/voicelife_schema.h | 2 +- .../schedule_reminder_task_row_mapper.cc | 44 ++ .../schedule_reminder_task_row_mapper.h | 11 + .../src/mapping/schedule_row_mapper.cc | 89 +-- .../v006_create_schedule_reminder_task.cc | 65 +++ .../v006_create_schedule_reminder_task.h | 8 + .../src/schema/voicelife_schema.cc | 9 +- .../src/sql/schedule_reminder_task_sql.cc | 32 ++ .../src/sql/schedule_reminder_task_sql.h | 10 + .../src/sql/schedule_sql.cc | 15 +- ...qlite_schedule_reminder_task_repository.cc | 110 ++++ .../src/sqlite_schedule_repository.cc | 2 +- .../test/sqlite_schedule_repository_test.cc | 1 - .../sqlite_schedule_repository_unit_test.cc | 2 - tests/host/CMakeLists.txt | 11 +- tests/host/schedule_reminder_service_test.cc | 53 +- .../support/schedule_repository_test_data.h | 7 - 34 files changed, 899 insertions(+), 470 deletions(-) create mode 100644 components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_task_repository.h create mode 100644 components/voicelife_storage_memory/include/voicelife/storage_memory/memory_schedule_reminder_task_repository.h create mode 100644 components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc create mode 100644 components/voicelife_storage_sqlite/include/voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h create mode 100644 components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.cc create mode 100644 components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.h create mode 100644 components/voicelife_storage_sqlite/src/schema/migrations/v006_create_schedule_reminder_task.cc create mode 100644 components/voicelife_storage_sqlite/src/schema/migrations/v006_create_schedule_reminder_task.h create mode 100644 components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.cc create mode 100644 components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.h create mode 100644 components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc diff --git a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc index bc351f02..73d13f7d 100644 --- a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc +++ b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc @@ -579,7 +579,7 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch if (!status.ok()) return status; // 操作记录查询:记录写入不经过 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("当前运行时未启用操作记录能力"); @@ -615,6 +615,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_runtime/src/bootstrap/storage_bootstrap.cc b/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc index 238edd7e..fd9b29e3 100644 --- a/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc +++ b/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc @@ -5,10 +5,12 @@ #include #include "voicelife/storage_memory/memory_schedule_repository.h" +#include "voicelife/storage_memory/memory_schedule_reminder_task_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_repository.h" +#include "voicelife/storage_sqlite/sqlite_schedule_reminder_task_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" @@ -59,7 +61,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 +189,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 +212,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 +245,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 a62618ca..605bb189 100644 --- a/components/voicelife_runtime/src/runtime.cc +++ b/components/voicelife_runtime/src/runtime.cc @@ -246,8 +246,8 @@ class Runtime final { return session_->Speak(text); }); 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_); const Status reminder_status = schedule_reminder_service_->Start(); if (!reminder_status.ok()) { ESP_LOGE(kTag, "STARTUP_ERROR stage=schedule_reminder code=%d msg=%s", @@ -262,8 +262,8 @@ class Runtime final { 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"); + "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"); } // 立创实战派 ESP32-S3 板载 WS2812 灯珠接 GPIO48(小智 BUILTIN_LED_GPIO)。 // 主 NVS 分区初始化(Wi-Fi 驱动/凭据等依赖;linx_secrets 为加密分区另行初始化)。 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..f9eb1382 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,77 @@ #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 前向声明定时任务服务,避免日程公开接口依赖定时组件头文件。 */ -class TimingTaskService; -} // namespace voicelife::timing +namespace voicelife::timing { class TimingTaskService; } namespace voicelife::schedule { -/** @brief 提交日程提醒文本并返回实际 TTS 请求结果。 */ class ScheduleReminderSpeechPort { public: - /** @brief 析构提醒语音端口。 */ virtual ~ScheduleReminderSpeechPort() = default; - - /** @brief 请求播报提醒文本。 @param text 完整提醒文本。 @return TTS 提交结果。 */ virtual Status SpeakScheduleReminder(std::string_view text) = 0; }; -/** @brief 协调日程持久化、一次性定时任务、TTS 与周期实例生成。 */ +/// Optional notification sink. IM adapters implement this outside the IM component. +class ScheduleReminderNotificationPort { + public: + virtual ~ScheduleReminderNotificationPort() = default; + virtual Status SendScheduleReminder(const Schedule& schedule, const ScheduleReminderTask& task) = 0; +}; + +struct ReminderActionResult { + int affected_count = 0; +}; + +/// Coordinates persistent reminder records, one-shot Timing tasks, speech and notifications. class ScheduleReminderService final { public: using NowProvider = std::function; - /** @brief 构造日程提醒服务。 - * @param 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 = {}); + 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。 */ Status Start(); - - /** @brief 停止接收回调并取消当前服务持有的提醒与重试任务。 */ void Stop(); - - /** @brief 按最新持久化数据同步指定日程的提醒。 @param schedule_id 日程 ID。 @return 同步失败时的错误,否则返回 Ok。 - */ Status SynchronizeSchedule(ScheduleId schedule_id); - - /** @brief 取消指定日程当前持久化的提醒任务。 @param schedule_id 日程 ID。 @return 取消失败时的错误,否则返回 Ok。 - */ Status CancelScheduleReminder(ScheduleId schedule_id); - - /** @brief 在规则修改或取消前撤销其全部实例提醒,防止旧实例被删除后丢失任务标识。 @param rule_id 周期规则 ID。 - * @return 首个撤销失败的错误,否则返回 Ok。 */ Status SuspendRuleReminders(ScheduleRuleId rule_id); - - /** @brief 为规则当前已经物化的 active 实例同步提醒。 @param rule_id 周期规则 ID。 @return - * 首个同步失败的错误,否则返回 Ok。 */ Status SynchronizeRule(ScheduleRuleId rule_id); + Result AcknowledgeRecentReminders(); + Result SnoozeRecentReminders(); private: - /** @brief 周期实例生成重试状态。 */ - struct RetryState { - int64_t task_id = 0; - int failure_count = 0; - }; + struct RetryState { 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..393c6854 --- /dev/null +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_task_repository.h @@ -0,0 +1,59 @@ +#pragma once + +#include +#include +#include +#include + +#include "voicelife/contracts/status.h" +#include "voicelife/schedule/schedule_types.h" + +namespace voicelife::schedule { + +/// 提醒业务链的状态;该状态与底层 Timing task 生命周期分离。 +enum class ScheduleReminderBusinessStatus { + kScheduled = 1, + kWaitingAcknowledgement = 2, + kAcknowledged = 3, + kExhausted = 4, + kCancelled = 5, +}; + +/// 一次实际注册的底层 Timing task 状态。 +enum class ScheduleReminderTimerStatus { + kPending = 1, + kTriggered = 2, + kCancelled = 3, + kCompleted = 4, + kFailed = 5, +}; + +/// 一条提醒链中的一次实际定时任务记录。 +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; +}; + +/// 提醒任务的独立持久化接口;Schedule 本身不保存提醒运行态。 +class ScheduleReminderTaskRepository { + public: + virtual ~ScheduleReminderTaskRepository() = default; + + virtual Result Insert(const ScheduleReminderTask& task) = 0; + virtual Status Update(const ScheduleReminderTask& task) = 0; + [[nodiscard]] virtual Result FindById(int64_t id) const = 0; + [[nodiscard]] virtual Result> FindBySchedule(ScheduleId schedule_id) const = 0; + [[nodiscard]] virtual Result> FindAll() const = 0; + [[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..17585477 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,337 @@ 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, +ScheduleReminderService::ScheduleReminderService(ScheduleRepository& repository, + ScheduleReminderTaskRepository& reminder_repository, + ScheduleService& schedule_service, ScheduleRuleService& rule_service, timing::TimingTaskService& timing_service, - ScheduleReminderSpeechPort& speech, NowProvider now_provider) - : repository_(repository), - schedule_service_(schedule_service), - rule_service_(rule_service), - timing_service_(&timing_service), - speech_(speech), + 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()) { - return Status::Ok(); - } - return RegisterReminder(std::move(schedule)); + 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(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; - generation_retries_.erase(found); - } + if (found != generation_retries_.end()) { 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); } - return last_task_id_; -} - -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); -} - -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 + 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); } - - const timing::CommandAcceptance accepted = timing_service_->CancelTask({ - .task_id = *task_id, - .on_result = {}, - }); - if (accepted == timing::CommandAcceptance::kUnavailable) { - return Status::Error(ErrorCode::kUnavailable, "提醒任务取消命令未被接收"); + 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 ClearReminderTaskIfCurrent(schedule.id, task_value); + return Result::Success({.affected_count = static_cast(chains.size())}); } -Status ScheduleReminderService::RegisterReminder(Schedule schedule) { - if (!schedule.start_time.has_value() || *schedule.start_time <= Now()) { - return Status::Ok(); - } - - 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 +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())}); +} - schedule.reminder_task_id = task_value; - schedule.updated_at = Now(); - const Status persisted = repository_.Update(schedule); - if (!persisted.ok()) return persisted; +DateTime ScheduleReminderService::Now() const { return now_provider_(); } - 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(); +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_; } -void ScheduleReminderService::HandleReminder(ScheduleId schedule_id, int64_t 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) { - return; - } +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 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); +Status ScheduleReminderService::RegisterReminder(ScheduleId schedule_id, int64_t chain_id, int attempt, DateTime trigger_at) { + 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, + .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; +} - if (schedule.rule_id.has_value()) { - GenerateNextInstance(*schedule.rule_id, 0); - } +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::GenerateNextInstance(ScheduleRuleId rule_id, int prior_failure_count) { +void ScheduleReminderService::HandleReminder(int64_t reminder_task_id, std::string_view timing_task_id) { if (!IsRunning()) return; - const GenerateNextScheduleInstanceResult generated = - rule_service_.generate_next_schedule_instance({.rule_id = rule_id}); - if (!generated.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); + 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); } + if (task.attempt == 1 && loaded_schedule.value->rule_id) GenerateNextInstance(*loaded_schedule.value->rule_id, 0); } -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 - } - - { - std::lock_guard lock(mutex_); - generation_retries_[rule_id] = { - .task_id = task_value, - .failure_count = failure_count, - }; - } - - 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) { - 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, "周期生成重试注册命令未被接收"); - } - return Status::Ok(); +void ScheduleReminderService::GenerateNextInstance(ScheduleRuleId rule_id, int prior_failure_count) { + if (!IsRunning()) return; + 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 (result.schedule) (void)SynchronizeSchedule(result.schedule->id); } -bool ScheduleReminderService::IsRunning() const { - std::lock_guard lock(mutex_); - return running_; +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 = value, .failure_count = failures}; } + 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_); generation_retries_.erase(rule_id); } + 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..5b9fb5d0 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,8 +381,7 @@ void CheckRepositoryQuery() { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .reminder_task_id = std::nullopt, - .status = ScheduleStatus::kActive, + .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..9170cf98 --- /dev/null +++ b/components/voicelife_storage_memory/include/voicelife/storage_memory/memory_schedule_reminder_task_repository.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +#include "voicelife/schedule/schedule_reminder_task_repository.h" + +namespace voicelife::storage_memory { + +/// Volatile reminder-task repository used by host and memory profiles. +class MemoryScheduleReminderTaskRepository final : public schedule::ScheduleReminderTaskRepository { + public: + Result Insert(const schedule::ScheduleReminderTask& task) override; + Status Update(const schedule::ScheduleReminderTask& task) override; + [[nodiscard]] Result FindById(int64_t id) const override; + [[nodiscard]] Result> FindBySchedule( + schedule::ScheduleId schedule_id) const override; + [[nodiscard]] Result> FindAll() const override; + [[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..8657124e --- /dev/null +++ b/components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc @@ -0,0 +1,89 @@ +#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) { + return task.schedule_id > 0 && task.chain_id > 0 && task.attempt > 0 && task.attempt <= 3 && + task.trigger_at != schedule::DateTime{}; +} +} + +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_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..00d46475 --- /dev/null +++ b/components/voicelife_storage_sqlite/include/voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h @@ -0,0 +1,27 @@ +#pragma once + +#include "voicelife/storage_sqlite/sqlite_database.h" +#include "voicelife/schedule/schedule_reminder_task_repository.h" + +namespace voicelife::storage_sqlite { + +/// SQLite persistence for independent schedule reminder task records. +class SqliteScheduleReminderTaskRepository final : public schedule::ScheduleReminderTaskRepository { + public: + explicit SqliteScheduleReminderTaskRepository(SqliteDatabase& database); + + Result Insert(const schedule::ScheduleReminderTask& task) override; + Status Update(const schedule::ScheduleReminderTask& task) override; + [[nodiscard]] Result FindById(int64_t id) const override; + [[nodiscard]] Result> FindBySchedule( + schedule::ScheduleId schedule_id) const override; + [[nodiscard]] Result> FindAll() const override; + [[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..c96283bf --- /dev/null +++ b/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.cc @@ -0,0 +1,44 @@ +#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)}}; +} +} + +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) { + 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(statement.ColumnInt(6)); + task.timer_status = static_cast(statement.ColumnInt(7)); + 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..22b0ecd4 --- /dev/null +++ b/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.h @@ -0,0 +1,11 @@ +#pragma once + +#include "voicelife/storage_sqlite/sqlite_database.h" +#include "voicelife/schedule/schedule_reminder_task_repository.h" + +namespace voicelife::storage_sqlite { +namespace mapping { +Status BindScheduleReminderTask(SqliteStatement& statement, const schedule::ScheduleReminderTask& task); +Result ReadScheduleReminderTask(const SqliteStatement& statement); +} +} // 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..7ab2ed93 100644 --- a/components/voicelife_storage_sqlite/src/mapping/schedule_row_mapper.cc +++ b/components/voicelife_storage_sqlite/src/mapping/schedule_row_mapper.cc @@ -8,94 +8,40 @@ 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) { +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) { +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) { int index = 1; Status status = WithField(statement.BindText(index++, schedule.event), "event"); if (!status.ok()) return status; - status = WithField(schedule.start_time.has_value() - ? statement.BindInt64(index++, schedule.start_time->time_since_epoch().count()) - : statement.BindNull(index++), - "start_time"); + status = WithField(schedule.start_time.has_value() ? statement.BindInt64(index++, schedule.start_time->time_since_epoch().count()) : statement.BindNull(index++), "start_time"); if (!status.ok()) return status; - status = WithField(schedule.end_time.has_value() - ? statement.BindInt64(index++, schedule.end_time->time_since_epoch().count()) - : statement.BindNull(index++), - "end_time"); + status = WithField(schedule.end_time.has_value() ? statement.BindInt64(index++, schedule.end_time->time_since_epoch().count()) : statement.BindNull(index++), "end_time"); if (!status.ok()) return status; status = BindOptionalText(statement, index++, schedule.location, "location"); if (!status.ok()) return status; @@ -103,8 +49,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 +57,9 @@ Status BindSchedule(SqliteStatement& statement, const schedule::Schedule& schedu } Result ReadSchedule(const SqliteStatement& statement) { - const int status_value = statement.ColumnInt(8); - if (!IsValidStatus(status_value)) { - return Result::Failure(ErrorCode::kInternal, "数据库中的日程状态无效"); - } - if (statement.IsNull(1)) { - return Result::Failure(ErrorCode::kInternal, "数据库中的日程名称为空"); - } - + 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, "数据库中的日程名称为空"); schedule::Schedule schedule{ .id = statement.ColumnInt64(0), .event = statement.ColumnText(1), @@ -129,17 +68,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..2f282108 --- /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"; +} 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..e2947af7 --- /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[]; +} 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..f9c37931 --- /dev/null +++ b/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc @@ -0,0 +1,110 @@ +#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; +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)); +} +} + +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); + 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(); + 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_repository_test.cc b/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_test.cc index 9654411e..564d6a58 100644 --- a/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_test.cc +++ b/components/voicelife_storage_sqlite/test/sqlite_schedule_repository_test.cc @@ -209,7 +209,6 @@ void CheckRestartPersistence(const std::filesystem::path& path, const CrudResult 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, "数据库重连后应完整保留更新字段"); } 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 0722120d..6605b2a7 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 = {}, diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 5f4a3a2a..62f37979 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -124,19 +124,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) @@ -149,7 +153,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" @@ -278,7 +283,7 @@ target_link_libraries(schedule_mcp_tools_test PRIVATE mcp schedule) add_voicelife_test(schedule_mcp_tools_reminder_test "unit;mcp;schedule;reminder" schedule_mcp_tools_reminder_test.cc "${ROOT_DIR}/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc") -target_link_libraries(schedule_mcp_tools_reminder_test PRIVATE mcp schedule timing) +target_link_libraries(schedule_mcp_tools_reminder_test PRIVATE mcp schedule timing storage_memory) add_voicelife_test(schedule_mcp_tools_branch_test "unit;mcp;schedule;reminder" schedule_mcp_tools_branch_test.cc @@ -297,7 +302,7 @@ 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) diff --git a/tests/host/schedule_reminder_service_test.cc b/tests/host/schedule_reminder_service_test.cc index 1861209d..725a2b0b 100644 --- a/tests/host/schedule_reminder_service_test.cc +++ b/tests/host/schedule_reminder_service_test.cc @@ -12,6 +12,8 @@ #include "support/in_memory_schedule_repository.h" #include "support/test_support.h" #include "voicelife/schedule/schedule_exception_repository.h" +#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" +#include "voicelife/schedule/schedule_reminder_task_repository.h" #include "voicelife/schedule/schedule_rule_repository.h" #include "voicelife/timing/timing_task.h" @@ -26,6 +28,9 @@ using voicelife::schedule::Schedule; using voicelife::schedule::ScheduleException; using voicelife::schedule::ScheduleReminderService; using voicelife::schedule::ScheduleReminderSpeechPort; +using voicelife::schedule::ScheduleReminderTask; +using voicelife::schedule::ScheduleReminderTimerStatus; +using voicelife::schedule::ScheduleReminderBusinessStatus; 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() == "提醒:现在是「新提醒」时间了", @@ -367,7 +386,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(), "未启动时不应同步提醒"); 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), From 664cbf9f6c772b7b48d4228b9d9546e4720c8044 Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 21 Aug 2026 15:48:46 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E2=9C=A8=20feat(schedule):=20=E5=AE=8C?= =?UTF-8?q?=E6=88=90=E6=8C=81=E4=B9=85=E5=8C=96=E6=8F=90=E9=86=92=E4=B8=8E?= =?UTF-8?q?=20IM=20=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/tools/schedule_mcp_tools.cc | 2 +- components/voicelife_runtime/CMakeLists.txt | 10 +- .../src/bootstrap/storage_bootstrap.cc | 5 +- components/voicelife_runtime/src/runtime.cc | 179 ++++++++++++++- .../src/schedule_reminder_im_adapter.cc | 126 +++++++++++ .../src/schedule_reminder_im_adapter.h | 48 ++++ .../src/service/schedule_reminder_service.cc | 5 +- .../src/fatfs_volume.cc | 4 +- ...emory_schedule_reminder_task_repository.cc | 8 +- ..._schedule_reminder_task_repository_test.cc | 125 +++++++++++ .../schedule_reminder_task_row_mapper.cc | 19 +- ...qlite_schedule_reminder_task_repository.cc | 13 ++ ..._schedule_reminder_task_repository_test.cc | 125 +++++++++++ .../test/sqlite_schedule_repository_test.cc | 2 +- .../sqlite_schedule_repository_unit_test.cc | 14 +- .../test/voicelife_schema_test.cc | 121 +++++++++- config/partitions/sparkbot.csv | 1 + .../esp32s3-esp-sparkbot-serial-voice.json | 7 +- .../esp32s3-esp-sparkbot-state-flow.json | 7 +- config/profiles/esp32s3-esp-sparkbot.json | 9 +- scripts/firmware.py | 11 + tests/host/CMakeLists.txt | 23 +- tests/host/linx_mcp_bridge_test.cc | 7 +- .../host/schedule_mcp_tools_reminder_test.cc | 121 +++++++++- tests/host/schedule_mcp_tools_test.cc | 2 +- .../host/schedule_reminder_edge_cases_test.cc | 17 +- .../host/schedule_reminder_im_adapter_test.cc | 208 ++++++++++++++++++ tests/host/schedule_reminder_service_test.cc | 140 +++++++----- ...schedule_reminder_uncovered_branch_test.cc | 24 +- tests/host/ssd1306_glyphs_test.cc | 22 +- tests/python/test_firmware.py | 35 +++ 31 files changed, 1309 insertions(+), 131 deletions(-) create mode 100644 components/voicelife_runtime/src/schedule_reminder_im_adapter.cc create mode 100644 components/voicelife_runtime/src/schedule_reminder_im_adapter.h create mode 100644 components/voicelife_storage_memory/test/memory_schedule_reminder_task_repository_test.cc create mode 100644 components/voicelife_storage_sqlite/test/sqlite_schedule_reminder_task_repository_test.cc create mode 100644 tests/host/schedule_reminder_im_adapter_test.cc diff --git a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc index 73d13f7d..8ae179a5 100644 --- a/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc +++ b/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc @@ -497,7 +497,7 @@ Status RegisterScheduleMcpTools(McpServer& server, ScheduleService& service, Sch }); if (!status.ok()) return status; - return server.add_tool( + status = server.add_tool( "schedule.delete", "删除单次日程、未来周期单次或整条周期规则。", DeleteProperties(), [&service, rule_service, reminder_service](const PropertyList& properties) { // delete 根据定位参数拆三条路径:schedule_id 删实例,rule_id 删规则,rule_id + original_start_time 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 fd9b29e3..edc2f4c1 100644 --- a/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc +++ b/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc @@ -4,8 +4,6 @@ #include #include -#include "voicelife/storage_memory/memory_schedule_repository.h" -#include "voicelife/storage_memory/memory_schedule_reminder_task_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" @@ -14,6 +12,9 @@ #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_repository.h" +#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" #endif #ifdef ESP_PLATFORM diff --git a/components/voicelife_runtime/src/runtime.cc b/components/voicelife_runtime/src/runtime.cc index 605bb189..a2ad6f3d 100644 --- a/components/voicelife_runtime/src/runtime.cc +++ b/components/voicelife_runtime/src/runtime.cc @@ -30,7 +30,9 @@ #include "nvs.h" #include "nvs_flash.h" #include "voicelife/contracts/json.h" +#include "esp_action_stream_transport.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" @@ -245,9 +248,12 @@ class Runtime final { } return session_->Speak(text); }); + reminder_notification_ = std::make_unique( + im_runtime_, [this](im::ActionWindow window) { EnqueueReminderActionWindow(std::move(window)); }); schedule_reminder_service_ = std::make_unique( storage_.GetScheduleRepository(), storage_.GetScheduleReminderTaskRepository(), schedule_service_, - schedule_rule_service_, *timing_runtime_, *reminder_speech_); + schedule_rule_service_, *timing_runtime_, *reminder_speech_, reminder_notification_.get()); + reminder_action_executor_ = std::make_unique(*schedule_reminder_service_); const Status reminder_status = schedule_reminder_service_->Start(); if (!reminder_status.ok()) { ESP_LOGE(kTag, "STARTUP_ERROR stage=schedule_reminder code=%d msg=%s", @@ -415,10 +421,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(); } @@ -618,6 +633,153 @@ 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() { @@ -641,12 +803,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; } @@ -1659,7 +1824,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..44926e22 --- /dev/null +++ b/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc @@ -0,0 +1,126 @@ +#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..802e7e06 --- /dev/null +++ b/components/voicelife_runtime/src/schedule_reminder_im_adapter.h @@ -0,0 +1,48 @@ +#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/src/service/schedule_reminder_service.cc b/components/voicelife_schedule/src/service/schedule_reminder_service.cc index 17585477..ef0b6748 100644 --- a/components/voicelife_schedule/src/service/schedule_reminder_service.cc +++ b/components/voicelife_schedule/src/service/schedule_reminder_service.cc @@ -251,11 +251,12 @@ std::string ScheduleReminderService::AllocateTaskId(std::string_view prefix) { } 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, - .created_at = Now(), .updated_at = Now()}; + .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); diff --git a/components/voicelife_storage_fatfs/src/fatfs_volume.cc b/components/voicelife_storage_fatfs/src/fatfs_volume.cc index 39070ad7..efc1e3a5 100644 --- a/components/voicelife_storage_fatfs/src/fatfs_volume.cc +++ b/components/voicelife_storage_fatfs/src/fatfs_volume.cc @@ -136,9 +136,9 @@ Status FatFsVolume::Mount() { return Status::Error(ErrorCode::kConflict, "FATFS 数据分区容量与 Storage Profile 不一致"); } - // 开发期允许挂载失败时自动格式化:空分区首次启动时初始化 FATFS 文件系统。 + // 生产数据卷禁止挂载失败时自动格式化;卷损坏必须显式报错,避免静默丢失日程与提醒记录。 esp_vfs_fat_mount_config_t mount_config = VFS_FAT_MOUNT_DEFAULT_CONFIG(); - mount_config.format_if_mount_failed = true; + mount_config.format_if_mount_failed = false; mount_config.max_files = config_.max_files; mount_config.allocation_unit_size = config_.allocation_unit_size; mount_config.disk_status_check_enable = config_.disk_status_check_enable; 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 index 8657124e..e39bc8ba 100644 --- a/components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc +++ b/components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc @@ -9,8 +9,14 @@ 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{}; + 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); } } 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..17c7f3b7 --- /dev/null +++ b/components/voicelife_storage_memory/test/memory_schedule_reminder_task_repository_test.cc @@ -0,0 +1,125 @@ +#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/src/mapping/schedule_reminder_task_row_mapper.cc b/components/voicelife_storage_sqlite/src/mapping/schedule_reminder_task_row_mapper.cc index c96283bf..76f0b618 100644 --- 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 @@ -7,6 +7,16 @@ 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); +} } Status BindScheduleReminderTask(SqliteStatement& statement, const schedule::ScheduleReminderTask& task) { @@ -27,6 +37,11 @@ Status BindScheduleReminderTask(SqliteStatement& statement, const schedule::Sche } 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); @@ -34,8 +49,8 @@ Result ReadScheduleReminderTask(const SqliteStat 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(statement.ColumnInt(6)); - task.timer_status = static_cast(statement.ColumnInt(7)); + 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); 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 index f9c37931..e4ca85be 100644 --- a/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc +++ b/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc @@ -9,6 +9,17 @@ 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(); @@ -35,6 +46,7 @@ SqliteScheduleReminderTaskRepository::SqliteScheduleReminderTaskRepository(Sqlit 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; @@ -53,6 +65,7 @@ Result SqliteScheduleReminderTaskRepository::Insert(const 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); 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..8ecad44f --- /dev/null +++ b/components/voicelife_storage_sqlite/test/sqlite_schedule_reminder_task_repository_test.cc @@ -0,0 +1,125 @@ +#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_reminder_task_repository.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 564d6a58..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,7 +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.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 6605b2a7..ba9f517b 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 @@ -139,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(), "最小日程的可空字段应保持为空"); } @@ -180,12 +178,12 @@ 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()); + 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("reminder_task_id") != std::string::npos, - "Mapper 应为提醒任务标识绑定错误补充字段名"); + 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..627697aa 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,23 @@ 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 +262,102 @@ 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/partitions/sparkbot.csv b/config/partitions/sparkbot.csv index c8acdd91..5a4e3092 100644 --- a/config/partitions/sparkbot.csv +++ b/config/partitions/sparkbot.csv @@ -11,3 +11,4 @@ factory, app, factory, 0x10000, 0x2D0000, linx_secrets, data, nvs, 0x2E0000, 0x10000, assets, data, spiffs, 0x300000, 0x100000, model, data, spiffs, 0x400000, 0x300000, +voicelife,data, fat, 0x700000, 0x200000, diff --git a/config/profiles/esp32s3-esp-sparkbot-serial-voice.json b/config/profiles/esp32s3-esp-sparkbot-serial-voice.json index 787e87e4..a9c894a2 100644 --- a/config/profiles/esp32s3-esp-sparkbot-serial-voice.json +++ b/config/profiles/esp32s3-esp-sparkbot-serial-voice.json @@ -5,15 +5,16 @@ "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_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT=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-state-flow.json b/config/profiles/esp32s3-esp-sparkbot-state-flow.json index c5a083c6..288fedb1 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 102da6b7..c2c2a17e 100644 --- a/config/profiles/esp32s3-esp-sparkbot.json +++ b/config/profiles/esp32s3-esp-sparkbot.json @@ -21,9 +21,9 @@ "configRef": "nvs://linx/websocket_url" }, "storage": { - "driver": "memory", + "driver": "fatfs-sqlite", "capabilities": [ - "atomic-calendar-write" + "persistent-sqlite" ] }, "im": { @@ -39,8 +39,9 @@ "CONFIG_LOG_DEFAULT_LEVEL_INFO=y", "CONFIG_COMPILER_OPTIMIZATION_SIZE=y", "CONFIG_COMPILER_OPTIMIZATION_ASSERTIONS_SILENT=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/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 62f37979..062824ea 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -270,6 +270,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") @@ -288,7 +292,7 @@ target_link_libraries(schedule_mcp_tools_reminder_test PRIVATE mcp schedule timi add_voicelife_test(schedule_mcp_tools_branch_test "unit;mcp;schedule;reminder" schedule_mcp_tools_branch_test.cc "${ROOT_DIR}/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc") -target_link_libraries(schedule_mcp_tools_branch_test PRIVATE mcp schedule timing) +target_link_libraries(schedule_mcp_tools_branch_test PRIVATE mcp schedule timing storage_memory) add_voicelife_test(schedule_mcp_tools_failure_coverage_test "unit;mcp;schedule;runtime" schedule_mcp_tools_failure_coverage_test.cc @@ -306,11 +310,11 @@ target_link_libraries(schedule_reminder_service_test PRIVATE schedule timing sto 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_rule_service_test "unit;schedule" schedule_rule_service_test.cc) target_link_libraries(schedule_rule_service_test PRIVATE schedule) @@ -484,6 +488,13 @@ 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(runtime_smoke_test "integration;runtime" runtime_smoke_test.cc) target_link_libraries(runtime_smoke_test PRIVATE mcp voice) @@ -510,6 +521,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" @@ -518,6 +531,10 @@ 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_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/linx_mcp_bridge_test.cc b/tests/host/linx_mcp_bridge_test.cc index 6224196c..186619cc 100644 --- a/tests/host/linx_mcp_bridge_test.cc +++ b/tests/host/linx_mcp_bridge_test.cc @@ -50,9 +50,12 @@ int main() { Check(list.value->find("\"session_id\":\"remote-session\"") != std::string::npos, "MCP 响应必须回传 Linx session_id"); const auto& tools = listed.Get("result")->Get("tools")->array; - Check(tools.size() == 4 && tools[0].Get("name")->string == "schedule.create" && + Check(tools.size() == 7 && tools[0].Get("name")->string == "schedule.create" && tools[1].Get("name")->string == "schedule.query" && tools[2].Get("name")->string == "schedule.update" && - tools[3].Get("name")->string == "schedule.delete", + tools[3].Get("name")->string == "schedule.delete" && + tools[4].Get("name")->string == "schedule.operation_query" && + tools[5].Get("name")->string == "schedule.reminder_acknowledge" && + tools[6].Get("name")->string == "schedule.reminder_snooze", "tools/list 必须返回稳定排序的一次性日程工具"); const auto* create_schema = tools[0].Get("inputSchema"); Check(create_schema->Get("required")->array.size() == 1 && diff --git a/tests/host/schedule_mcp_tools_reminder_test.cc b/tests/host/schedule_mcp_tools_reminder_test.cc index 8901d83d..744ccaff 100644 --- a/tests/host/schedule_mcp_tools_reminder_test.cc +++ b/tests/host/schedule_mcp_tools_reminder_test.cc @@ -13,6 +13,7 @@ #include "voicelife/mcp/schedule_mcp_tools.h" #include "voicelife/schedule/schedule_exception_repository.h" #include "voicelife/schedule/schedule_operation_service.h" +#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" #include "voicelife/schedule/schedule_reminder_service.h" #include "voicelife/schedule/schedule_rule_repository.h" #include "voicelife/schedule/schedule_rule_service.h" @@ -50,6 +51,10 @@ 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 +247,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 +274,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 +289,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 +304,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 +320,12 @@ void CheckOneShotReminderLifecycle() { }); Check(deleted.status.ok() && OutputString(deleted, "status") == "success", "删除提醒日程应成功"); const auto deleted_schedule = schedules.FindById(1); + const auto deleted_tasks = reminder_tasks.FindBySchedule(1); Check(deleted_schedule.ok() && deleted_schedule.value->status == ScheduleStatus::kCancelled && - !deleted_schedule.value->reminder_task_id.has_value(), - "删除后应取消状态并清空 reminder_task_id"); + 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 +336,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 +344,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 +548,83 @@ 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 +634,6 @@ int main() { CheckRuleReminderSuccessPaths(); CheckRuleReminderRollbackSyncPaths(); CheckRuleReminderSyncFailurePaths(); + CheckReminderActionTools(); return 0; } diff --git a/tests/host/schedule_mcp_tools_test.cc b/tests/host/schedule_mcp_tools_test.cc index 0f238656..a5369105 100644 --- a/tests/host/schedule_mcp_tools_test.cc +++ b/tests/host/schedule_mcp_tools_test.cc @@ -220,7 +220,7 @@ int main() { Check(voicelife::mcp::RegisterScheduleMcpTools(server, service, rule_service).ok(), "日程工具应注册成功"); const auto listed = server.list_tools(); - Check(listed.total == 4, "日程工具应注册四个工具"); + Check(listed.total == 7, "日程工具应注册七个工具"); // schedule.create:一次性日程的各个字段与错误路径。 const auto one_shot = server.call({ 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_test.cc b/tests/host/schedule_reminder_im_adapter_test.cc new file mode 100644 index 00000000..8194eb21 --- /dev/null +++ b/tests/host/schedule_reminder_im_adapter_test.cc @@ -0,0 +1,208 @@ +#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_service_test.cc b/tests/host/schedule_reminder_service_test.cc index 725a2b0b..5163b175 100644 --- a/tests/host/schedule_reminder_service_test.cc +++ b/tests/host/schedule_reminder_service_test.cc @@ -360,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() { @@ -377,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, "第三次之后应继续按封顶间隔尝试而不是静默终止"); @@ -399,32 +400,32 @@ 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(), "完成日程查询失败时应返回仓储错误"); @@ -456,11 +457,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(), "已有提醒任务恢复失败时应返回错误"); } /** @@ -472,12 +482,12 @@ 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), @@ -489,7 +499,7 @@ 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(); @@ -526,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"); @@ -552,14 +562,14 @@ 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(), "规则内未来实例应重新注册提醒"); } @@ -597,12 +607,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(), "非法回调测试应启动服务"); @@ -621,26 +646,35 @@ 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(), "无提醒任务标识时取消应幂等成功"); @@ -655,12 +689,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"); @@ -680,7 +715,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 不应提交无效取消"); @@ -697,7 +733,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()); @@ -717,7 +753,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_uncovered_branch_test.cc b/tests/host/schedule_reminder_uncovered_branch_test.cc index cb75074c..90ead6dc 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,8 @@ 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 +69,14 @@ 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/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/python/test_firmware.py b/tests/python/test_firmware.py index 00d34924..bebb35c4 100644 --- a/tests/python/test_firmware.py +++ b/tests/python/test_firmware.py @@ -74,6 +74,41 @@ def test_sparkbot_profile_enables_gateway_im_without_selecting_pcb(self) -> None self.assertIn("CONFIG_VOICELIFE_IM_GATEWAY=y", 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_im_pcb_profile_accepts_input_from_its_usb_provisioning_port(self) -> None: profile_path = ROOT / "config" / "profiles" / "esp32s3-voicelife-pcb-pcm.json" profile = json.loads(profile_path.read_text(encoding="utf-8")) From a416588d41988612d6f01ed6c3ce0a1cf766a0ad Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 21 Aug 2026 17:26:11 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=93=9D=20docs(schedule):=20?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=8F=90=E9=86=92=E6=8E=A5=E5=8F=A3=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/schedule_reminder_service.h | 44 +++++++++++++++++-- .../schedule_reminder_task_repository.h | 19 ++++++++ ...memory_schedule_reminder_task_repository.h | 8 +++- ...sqlite_schedule_reminder_task_repository.h | 12 ++++- 4 files changed, 76 insertions(+), 7 deletions(-) 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 f9eb1382..abb26d74 100644 --- a/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h @@ -13,48 +13,84 @@ #include "voicelife/schedule/schedule_rule_service.h" #include "voicelife/schedule/schedule_service.h" -namespace voicelife::timing { class TimingTaskService; } +namespace voicelife::timing { +class TimingTaskService; +} namespace voicelife::schedule { +/// @brief 提供提醒语音播报能力的接口。 class ScheduleReminderSpeechPort { public: + /// @brief 播报提醒文本。 + /// @param text 待播报的提醒内容。 + /// @return 播报操作状态。 virtual ~ScheduleReminderSpeechPort() = default; virtual Status SpeakScheduleReminder(std::string_view text) = 0; }; -/// Optional notification sink. IM adapters implement this outside the IM component. +/// @brief 可选的提醒通知出口,由 IM 适配器在组件外实现。 class ScheduleReminderNotificationPort { public: + /// @brief 发送日程提醒通知。 + /// @param schedule 触发提醒的日程。 + /// @param task 当前提醒任务记录。 + /// @return 发送操作状态。 virtual ~ScheduleReminderNotificationPort() = default; virtual Status SendScheduleReminder(const Schedule& schedule, const ScheduleReminderTask& task) = 0; }; +/// @brief 提醒动作的执行结果。 +/// @brief 提醒动作的执行结果。 struct ReminderActionResult { int affected_count = 0; }; -/// Coordinates persistent reminder records, one-shot Timing tasks, speech and notifications. +/// @brief 协调持久化提醒记录、一次性 Timing 任务、语音和通知。 class ScheduleReminderService final { public: using NowProvider = std::function; + /// @brief 构造提醒服务。 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 启动提醒服务。 + /// @return 启动操作状态。 Status Start(); + /// @brief 停止提醒服务。 void Stop(); + /// @brief 同步指定日程的提醒。 + /// @param schedule_id 日程标识。 + /// @return 同步操作状态。 Status SynchronizeSchedule(ScheduleId schedule_id); + /// @brief 取消指定日程的提醒。 + /// @param schedule_id 日程标识。 + /// @return 取消操作状态。 Status CancelScheduleReminder(ScheduleId schedule_id); + /// @brief 暂停规则下的提醒。 + /// @param rule_id 规则标识。 + /// @return 暂停操作状态。 Status SuspendRuleReminders(ScheduleRuleId rule_id); + /// @brief 同步指定规则的提醒。 + /// @param rule_id 规则标识。 + /// @return 同步操作状态。 Status SynchronizeRule(ScheduleRuleId rule_id); + /// @brief 确认最近触发的提醒。 + /// @return 动作结果或错误状态。 Result AcknowledgeRecentReminders(); + /// @brief 延后最近触发的提醒。 + /// @return 动作结果或错误状态。 Result SnoozeRecentReminders(); private: - struct RetryState { std::string task_id; int failure_count = 0; }; + /// @brief 规则提醒生成的重试状态。 + struct RetryState { + std::string task_id; + int failure_count = 0; + }; DateTime Now() const; int64_t AllocateChainId(); 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 index 393c6854..c7699389 100644 --- a/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_task_repository.h +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_task_repository.h @@ -46,10 +46,20 @@ struct ScheduleReminderTask { /// 提醒任务的独立持久化接口;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; [[nodiscard]] virtual Result> FindBySchedule(ScheduleId schedule_id) const = 0; [[nodiscard]] virtual Result> FindAll() const = 0; @@ -57,3 +67,12 @@ class ScheduleReminderTaskRepository { }; } // namespace voicelife::schedule + /// @brief 查询日程的全部提醒任务。 + /// @param schedule_id 日程标识。 + /// @return 查询结果。 + /// @brief 查询全部提醒任务。 + /// @return 查询结果。 + /// @brief 查询时间范围内已触发任务。 + /// @param from 起始时间(含)。 + /// @param to 结束时间(含)。 + /// @return 查询结果。 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 index 9170cf98..db400887 100644 --- 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 @@ -6,9 +6,10 @@ namespace voicelife::storage_memory { -/// Volatile reminder-task repository used by host and memory profiles. +/// @brief 供主机和内存配置使用的易失提醒任务仓储。 class MemoryScheduleReminderTaskRepository final : public schedule::ScheduleReminderTaskRepository { public: + /// @brief 插入提醒任务。 Result Insert(const schedule::ScheduleReminderTask& task) override; Status Update(const schedule::ScheduleReminderTask& task) override; [[nodiscard]] Result FindById(int64_t id) const override; @@ -25,3 +26,8 @@ class MemoryScheduleReminderTaskRepository final : public schedule::ScheduleRemi }; } // namespace voicelife::storage_memory + /// @brief 更新提醒任务。 + /// @brief 按标识查询任务。 + /// @brief 查询日程的提醒任务。 + /// @brief 查询全部提醒任务。 + /// @brief 查询时间范围内已触发任务。 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 index 00d46475..aaabddda 100644 --- 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 @@ -1,13 +1,15 @@ #pragma once -#include "voicelife/storage_sqlite/sqlite_database.h" #include "voicelife/schedule/schedule_reminder_task_repository.h" +#include "voicelife/storage_sqlite/sqlite_database.h" namespace voicelife::storage_sqlite { -/// SQLite persistence for independent schedule reminder task records. +/// @brief 使用 SQLite 持久化独立的日程提醒任务记录。 class SqliteScheduleReminderTaskRepository final : public schedule::ScheduleReminderTaskRepository { public: + /// @brief 构造 SQLite 提醒任务仓储。 + /// @param database SQLite 数据库实例。 explicit SqliteScheduleReminderTaskRepository(SqliteDatabase& database); Result Insert(const schedule::ScheduleReminderTask& task) override; @@ -25,3 +27,9 @@ class SqliteScheduleReminderTaskRepository final : public schedule::ScheduleRemi }; } // namespace voicelife::storage_sqlite + /// @brief 插入提醒任务。 + /// @brief 更新提醒任务。 + /// @brief 按标识查询任务。 + /// @brief 查询日程的提醒任务。 + /// @brief 查询全部提醒任务。 + /// @brief 查询时间范围内已触发任务。 From 85e35c2430856220c35954bd9ac41b9ecaf50008 Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 21 Aug 2026 17:31:58 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=F0=9F=90=9B=20fix(ci):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=8F=90=E9=86=92=E6=A8=A1=E5=9D=97=E6=A0=BC=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=E6=B5=8B=E8=AF=95=E5=91=8A=E8=AD=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/voicelife_mcp/test/CMakeLists.txt | 3 +- .../src/bootstrap/storage_bootstrap.cc | 4 +- .../src/schedule_reminder_im_adapter.cc | 14 +- .../src/schedule_reminder_im_adapter.h | 3 +- .../src/service/schedule_reminder_service.cc | 208 ++++++++++++------ .../test/schedule_repository_service_test.cc | 2 +- ...emory_schedule_reminder_task_repository.cc | 14 +- ..._schedule_reminder_task_repository_test.cc | 26 +-- .../schedule_reminder_task_row_mapper.cc | 13 +- .../schedule_reminder_task_row_mapper.h | 4 +- .../src/mapping/schedule_row_mapper.cc | 21 +- .../src/sql/schedule_reminder_task_sql.cc | 2 +- .../src/sql/schedule_reminder_task_sql.h | 2 +- ...qlite_schedule_reminder_task_repository.cc | 41 ++-- ..._schedule_reminder_task_repository_test.cc | 27 +-- .../test/voicelife_schema_test.cc | 44 ++-- tests/host/CMakeLists.txt | 3 +- .../host/schedule_mcp_tools_reminder_test.cc | 16 +- .../host/schedule_reminder_im_adapter_test.cc | 56 ++--- tests/host/schedule_reminder_service_test.cc | 33 +-- ...schedule_reminder_uncovered_branch_test.cc | 9 +- tests/python/test_firmware.py | 8 +- 22 files changed, 308 insertions(+), 245 deletions(-) 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/src/bootstrap/storage_bootstrap.cc b/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc index edc2f4c1..ee4be22e 100644 --- a/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc +++ b/components/voicelife_runtime/src/bootstrap/storage_bootstrap.cc @@ -7,14 +7,14 @@ #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_repository.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_repository.h" #include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" +#include "voicelife/storage_memory/memory_schedule_repository.h" #endif #ifdef ESP_PLATFORM diff --git a/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc b/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc index 44926e22..5179b6a1 100644 --- a/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc +++ b/components/voicelife_runtime/src/schedule_reminder_im_adapter.cc @@ -29,9 +29,8 @@ std::string FormatIso(schedule::DateTime value) { 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 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; @@ -44,8 +43,8 @@ contracts::im::ReminderActionResult ActionResult( } // namespace -Status ImScheduleReminderNotification::SendScheduleReminder( - const schedule::Schedule& schedule, const schedule::ScheduleReminderTask& task) { +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 尚未就绪"); @@ -84,9 +83,8 @@ Status ImScheduleReminderNotification::SendScheduleReminder( } return Status::Ok(); } - const ErrorCode code = result.status == im::ReportStatus::kRetryable - ? ErrorCode::kUnavailable - : ErrorCode::kInternal; + const ErrorCode code = + result.status == im::ReportStatus::kRetryable ? ErrorCode::kUnavailable : ErrorCode::kInternal; return Status::Error(code, result.message.empty() ? "IM 提醒通知提交失败" : result.message); } diff --git a/components/voicelife_runtime/src/schedule_reminder_im_adapter.h b/components/voicelife_runtime/src/schedule_reminder_im_adapter.h index 802e7e06..86c7072b 100644 --- a/components/voicelife_runtime/src/schedule_reminder_im_adapter.h +++ b/components/voicelife_runtime/src/schedule_reminder_im_adapter.h @@ -32,8 +32,7 @@ class ImScheduleReminderActionExecutor final : public im::ImActionExecutor { public: explicit ImScheduleReminderActionExecutor(schedule::ScheduleReminderService& service) : service_(service) {} - contracts::im::ReminderActionResult Execute( - const contracts::im::ReminderActionCommand& command) override; + contracts::im::ReminderActionResult Execute(const contracts::im::ReminderActionCommand& command) override; private: schedule::ScheduleReminderService& service_; diff --git a/components/voicelife_schedule/src/service/schedule_reminder_service.cc b/components/voicelife_schedule/src/service/schedule_reminder_service.cc index ef0b6748..dc49a9c3 100644 --- a/components/voicelife_schedule/src/service/schedule_reminder_service.cc +++ b/components/voicelife_schedule/src/service/schedule_reminder_service.cc @@ -20,17 +20,19 @@ DateTime SystemNow() { return std::chrono::time_point_cast timing::TriggerAt ToTriggerAt(DateTime value) { return std::chrono::time_point_cast(value); } 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, - 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), +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() { @@ -40,8 +42,9 @@ Status ScheduleReminderService::Start() { std::lock_guard lock(mutex_); if (running_) return Status::Ok(); running_ = true; - sequence_ = 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); } @@ -73,7 +76,8 @@ Status ScheduleReminderService::Start() { 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; + *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; } @@ -83,12 +87,17 @@ Status ScheduleReminderService::Start() { void ScheduleReminderService::Stop() { 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); + 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, retry] : generation_retries_) { (void)rule; 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 (const auto& id : ids) { @@ -104,7 +113,8 @@ Status ScheduleReminderService::SynchronizeSchedule(ScheduleId schedule_id) { 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(); + *loaded.value->start_time <= Now()) + return Status::Ok(); return RegisterReminder(schedule_id, AllocateChainId(), 1, *loaded.value->start_time); } @@ -122,8 +132,10 @@ Status ScheduleReminderService::CancelPendingTasks(ScheduleId schedule_id, std:: 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, "提醒任务取消命令未被接收"); + if (id && timing_service_->CancelTask({.task_id = *id, .on_result = {}}) == + timing::CommandAcceptance::kUnavailable) { + if (first_failure.ok()) + first_failure = Status::Error(ErrorCode::kUnavailable, "提醒任务取消命令未被接收"); continue; } } @@ -150,11 +162,16 @@ Status ScheduleReminderService::SuspendRuleReminders(ScheduleRuleId rule_id) { { std::lock_guard lock(mutex_); const auto found = generation_retries_.find(rule_id); - if (found != generation_retries_.end()) { retry = found->second.task_id; generation_retries_.erase(found); } + if (found != generation_retries_.end()) { + retry = found->second.task_id; + generation_retries_.erase(found); + } } 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()) + 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; @@ -180,18 +197,21 @@ Result ScheduleReminderService::AcknowledgeRecentReminders std::unordered_set schedules; for (const auto& task : *recent.value) { if (task.business_status != ScheduleReminderBusinessStatus::kWaitingAcknowledgement && - task.business_status != ScheduleReminderBusinessStatus::kExhausted) continue; + 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 分钟内没有已触发的提醒"); + 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) + if (id && timing_service_->CancelTask({.task_id = *id, .on_result = {}}) == + timing::CommandAcceptance::kUnavailable) return Result::Failure(ErrorCode::kUnavailable, "后续提醒取消命令未被接收"); task.timer_status = ScheduleReminderTimerStatus::kCancelled; } @@ -222,16 +242,18 @@ Result ScheduleReminderService::SnoozeRecentReminders() { 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) { + 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 分钟内没有可延迟的提醒"); + if (chains.empty()) + return Result::Failure(ErrorCode::kNotFound, "最近 10 分钟内没有可延迟的提醒"); return Result::Success({.affected_count = static_cast(chains.size())}); } @@ -239,24 +261,35 @@ DateTime ScheduleReminderService::Now() const { return now_provider_(); } int64_t ScheduleReminderService::AllocateChainId() { std::lock_guard lock(mutex_); - if (chain_sequence_ == std::numeric_limits::max()) chain_sequence_ = 1; - else ++chain_sequence_; + if (chain_sequence_ == std::numeric_limits::max()) + chain_sequence_ = 1; + else + ++chain_sequence_; return chain_sequence_; } std::string ScheduleReminderService::AllocateTaskId(std::string_view prefix) { std::lock_guard lock(mutex_); - if (sequence_ == std::numeric_limits::max()) sequence_ = 1; else ++sequence_; + if (sequence_ == std::numeric_limits::max()) + sequence_ = 1; + else + ++sequence_; return std::string(prefix) + "-" + std::to_string(sequence_); } -Status ScheduleReminderService::RegisterReminder(ScheduleId schedule_id, int64_t chain_id, int attempt, DateTime trigger_at) { +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, + 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}; + .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); @@ -273,35 +306,42 @@ Status ScheduleReminderService::RegisterPersistedTask(const ScheduleReminderTask 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, "提醒任务注册命令未被接收"); + 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(int64_t reminder_task_id, std::string_view timing_task_id) { if (!IsRunning()) return; 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; + *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(); + 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); @@ -315,8 +355,14 @@ void ScheduleReminderService::HandleReminder(int64_t reminder_task_id, std::stri void ScheduleReminderService::GenerateNextInstance(ScheduleRuleId rule_id, int prior_failure_count) { if (!IsRunning()) return; 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 (!result.status.ok()) { + (void)ScheduleGenerationRetry(rule_id, prior_failure_count + 1); + return; + } + { + std::lock_guard lock(mutex_); + generation_retries_.erase(rule_id); + } if (result.schedule) (void)SynchronizeSchedule(result.schedule->id); } @@ -324,26 +370,44 @@ Status ScheduleReminderService::ScheduleGenerationRetry(ScheduleRuleId rule_id, 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 = value, .failure_count = failures}; } - 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); - }}); + { + std::lock_guard lock(mutex_); + generation_retries_[rule_id] = {.task_id = value, .failure_count = failures}; + } + 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_); generation_retries_.erase(rule_id); } + { + std::lock_guard lock(mutex_); + generation_retries_.erase(rule_id); + } return Status::Error(ErrorCode::kUnavailable, "周期生成重试注册命令未被接收"); } -bool ScheduleReminderService::IsRunning() const { std::lock_guard lock(mutex_); return running_; } +bool ScheduleReminderService::IsRunning() const { + std::lock_guard lock(mutex_); + return running_; +} } // namespace voicelife::schedule diff --git a/components/voicelife_schedule/test/schedule_repository_service_test.cc b/components/voicelife_schedule/test/schedule_repository_service_test.cc index 5b9fb5d0..d1e51077 100644 --- a/components/voicelife_schedule/test/schedule_repository_service_test.cc +++ b/components/voicelife_schedule/test/schedule_repository_service_test.cc @@ -381,7 +381,7 @@ void CheckRepositoryQuery() { .location = std::nullopt, .notes = std::nullopt, .rule_id = std::nullopt, - .status = ScheduleStatus::kActive, + .status = ScheduleStatus::kActive, .created_at = At(5'000), .updated_at = At(5'000), }, 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 index e39bc8ba..aec6bf3e 100644 --- a/components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc +++ b/components/voicelife_storage_memory/src/memory_schedule_reminder_task_repository.cc @@ -18,7 +18,7 @@ bool Valid(const ScheduleReminderTask& task) { timer_status >= static_cast(ScheduleReminderTimerStatus::kPending) && timer_status <= static_cast(ScheduleReminderTimerStatus::kFailed); } -} +} // namespace Result MemoryScheduleReminderTaskRepository::Insert( const schedule::ScheduleReminderTask& task) { @@ -45,15 +45,18 @@ Result MemoryScheduleReminderTaskRepository::Ins 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; }); + 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, "提醒链尝试次数已存在"); + })) + 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 标识已存在"); + })) + return Status::Error(ErrorCode::kAlreadyExists, "Timing task 标识已存在"); *found = task; return Status::Ok(); } @@ -69,7 +72,8 @@ Result> MemoryScheduleReminderTaskRepository:: 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); + for (const auto& task : tasks_) + if (task.schedule_id == schedule_id) result.push_back(task); return Result>::Success(std::move(result)); } 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 index 17c7f3b7..5997ff50 100644 --- 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 @@ -9,9 +9,7 @@ namespace { using namespace voicelife; using namespace voicelife::schedule; -DateTime At(int64_t seconds) { - return DateTime{std::chrono::seconds{seconds}}; -} +DateTime At(int64_t seconds) { return DateTime{std::chrono::seconds{seconds}}; } void Check(bool condition, const char* message) { if (condition) { @@ -21,8 +19,7 @@ void Check(bool condition, const char* message) { std::exit(1); } -ScheduleReminderTask MakeTask(int64_t schedule_id, int64_t chain_id, int attempt, - std::string timing_task_id) { +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, @@ -41,16 +38,15 @@ int main() { MemoryScheduleReminderTaskRepository repository; const auto first = repository.Insert(MakeTask(1, 10, 1, "timing-1")); - Check(first.ok() && first.value->id == 1 && first.value->attempt == 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, + Check(repository.FindBySchedule(1).value->size() == 2 && repository.FindBySchedule(999).value->empty() && + repository.FindAll().value->size() == 3, "仓储必须支持按日程和全量查询"); auto triggered = *first.value; @@ -68,24 +64,20 @@ int main() { 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, - "触发查询必须同时返回等待确认和耗尽任务"); + 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, - "触发查询必须排除已确认终态"); + 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.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(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() && 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 index 76f0b618..1298f66e 100644 --- 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 @@ -17,7 +17,7 @@ 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; @@ -26,12 +26,17 @@ Status BindScheduleReminderTask(SqliteStatement& statement, const schedule::Sche 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; + : 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 = 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()); } 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 index 22b0ecd4..65566fb6 100644 --- 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 @@ -1,11 +1,11 @@ #pragma once -#include "voicelife/storage_sqlite/sqlite_database.h" #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 7ab2ed93..0a47349b 100644 --- a/components/voicelife_storage_sqlite/src/mapping/schedule_row_mapper.cc +++ b/components/voicelife_storage_sqlite/src/mapping/schedule_row_mapper.cc @@ -14,10 +14,12 @@ Status WithField(Status status, const char* field) { if (!status.message.empty()) message += ";" + status.message; return Status::Error(status.code, std::move(message)); } -Status BindOptionalInt64(SqliteStatement& statement, int index, const std::optional& value, const char* field) { +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); } -Status BindOptionalText(SqliteStatement& statement, int index, const std::optional& value, const char* field) { +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); } bool IsValidStatus(int value) { @@ -33,15 +35,21 @@ std::optional ReadOptionalText(const SqliteStatement& statement, in if (statement.IsNull(column)) return std::nullopt; return statement.ColumnText(column); } -} +} // namespace Status BindSchedule(SqliteStatement& statement, const schedule::Schedule& schedule) { int index = 1; Status status = WithField(statement.BindText(index++, schedule.event), "event"); if (!status.ok()) return status; - status = WithField(schedule.start_time.has_value() ? statement.BindInt64(index++, schedule.start_time->time_since_epoch().count()) : statement.BindNull(index++), "start_time"); + status = WithField(schedule.start_time.has_value() + ? statement.BindInt64(index++, schedule.start_time->time_since_epoch().count()) + : statement.BindNull(index++), + "start_time"); if (!status.ok()) return status; - status = WithField(schedule.end_time.has_value() ? statement.BindInt64(index++, schedule.end_time->time_since_epoch().count()) : statement.BindNull(index++), "end_time"); + status = WithField(schedule.end_time.has_value() + ? statement.BindInt64(index++, schedule.end_time->time_since_epoch().count()) + : statement.BindNull(index++), + "end_time"); if (!status.ok()) return status; status = BindOptionalText(statement, index++, schedule.location, "location"); if (!status.ok()) return status; @@ -58,7 +66,8 @@ Status BindSchedule(SqliteStatement& statement, const schedule::Schedule& schedu Result ReadSchedule(const SqliteStatement& statement) { const int status_value = statement.ColumnInt(7); - if (!IsValidStatus(status_value)) return Result::Failure(ErrorCode::kInternal, "数据库中的日程状态无效"); + if (!IsValidStatus(status_value)) + return Result::Failure(ErrorCode::kInternal, "数据库中的日程状态无效"); if (statement.IsNull(1)) return Result::Failure(ErrorCode::kInternal, "数据库中的日程名称为空"); schedule::Schedule schedule{ .id = statement.ColumnInt64(0), 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 index 2f282108..36fbc0f9 100644 --- a/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.cc +++ b/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.cc @@ -29,4 +29,4 @@ 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 index e2947af7..96ee80b8 100644 --- a/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.h +++ b/components/voicelife_storage_sqlite/src/sql/schedule_reminder_task_sql.h @@ -7,4 +7,4 @@ 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/sqlite_schedule_reminder_task_repository.cc b/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc index e4ca85be..8e14e1ba 100644 --- a/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc +++ b/components/voicelife_storage_sqlite/src/sqlite_schedule_reminder_task_repository.cc @@ -24,14 +24,16 @@ 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, "提醒任务不存在"); + 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.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); @@ -39,13 +41,15 @@ Result> ReadMany(SqliteStatement& statement) { } return Result>::Success(std::move(result)); } -} +} // namespace -SqliteScheduleReminderTaskRepository::SqliteScheduleReminderTaskRepository(SqliteDatabase& database) : database_(database) {} +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 (!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()); @@ -79,7 +83,8 @@ Status SqliteScheduleReminderTaskRepository::Update(const ScheduleReminderTask& Result SqliteScheduleReminderTaskRepository::FindById(int64_t id) const { std::lock_guard lock(mutex_); - if (!database_.IsOpen()) return Result::Failure(ErrorCode::kUnavailable, Unavailable().message); + 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); @@ -88,11 +93,14 @@ Result SqliteScheduleReminderTaskRepository::FindById(int6 return ReadOne(statement); } -Result> SqliteScheduleReminderTaskRepository::FindBySchedule(schedule::ScheduleId id) const { +Result> SqliteScheduleReminderTaskRepository::FindBySchedule( + schedule::ScheduleId id) const { std::lock_guard lock(mutex_); - if (!database_.IsOpen()) return Result>::Failure(ErrorCode::kUnavailable, Unavailable().message); + 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); + 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); @@ -101,18 +109,23 @@ Result> SqliteScheduleReminderTaskRepository:: Result> SqliteScheduleReminderTaskRepository::FindAll() const { std::lock_guard lock(mutex_); - if (!database_.IsOpen()) return Result>::Failure(ErrorCode::kUnavailable, Unavailable().message); + 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); + 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 { +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); + 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); + 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); 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 index 8ecad44f..82c55f88 100644 --- 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 @@ -1,3 +1,5 @@ +#include "voicelife/storage_sqlite/sqlite_schedule_reminder_task_repository.h" + #include #include #include @@ -6,7 +8,6 @@ #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" #include "voicelife/storage_sqlite/sqlite_schedule_repository.h" using voicelife::ErrorCode; @@ -40,8 +41,7 @@ TemporaryDatabaseFile MakeDatabase() { 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) { +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, @@ -56,8 +56,7 @@ ScheduleReminderTask MakeTask(int64_t schedule_id, int64_t chain_id, int attempt void CheckRepository(const std::filesystem::path& path) { SqliteDatabase database(path.string()); SqliteScheduleReminderTaskRepository repository(database); - Check(repository.FindAll().status.code == ErrorCode::kUnavailable, - "数据库未打开时提醒仓储应返回不可用"); + Check(repository.FindAll().status.code == ErrorCode::kUnavailable, "数据库未打开时提醒仓储应返回不可用"); Check(database.Open().ok(), "提醒仓储测试应打开数据库"); SqliteScheduleRepository schedule_repository(database); Check(schedule_repository.Initialize().ok(), "提醒仓储测试应初始化完整产品 Schema"); @@ -65,8 +64,7 @@ void CheckRepository(const std::filesystem::path& path) { 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(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 && @@ -93,23 +91,18 @@ void CheckRepository(const std::filesystem::path& path) { 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 标识"); + 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 应在写入前拒绝非法尝试次数"); + 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 应在写入前拒绝非法业务状态"); + 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 重启后应保留提醒任务"); + 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), diff --git a/components/voicelife_storage_sqlite/test/voicelife_schema_test.cc b/components/voicelife_storage_sqlite/test/voicelife_schema_test.cc index 627697aa..c1242f72 100644 --- a/components/voicelife_storage_sqlite/test/voicelife_schema_test.cc +++ b/components/voicelife_storage_sqlite/test/voicelife_schema_test.cc @@ -90,12 +90,15 @@ void CheckVersionOneSchema(const std::filesystem::path& path) { 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'") == + 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 应创建提醒日程查询索引"); @@ -262,7 +265,6 @@ void CheckSchemaCollisionRejected(const std::filesystem::path& path) { Check(version.ok() && *version.value == 0, "结构冲突后版本号应保持为零"); } - /** * @brief 验证真实 v005 数据升级到 v006 时提醒任务和日程表均正确迁移。 * @param path 临时数据库路径。 @@ -279,8 +281,7 @@ void CheckVersionFiveToSixMigration(const std::filesystem::path& path) { {.version = 4, .apply = &ApplyV004CreateOperationRecord}, {.version = 5, .apply = &ApplyV005AddScheduleReminderTaskId}, }; - Check(SqliteSchema::ApplyMigrations(database, 5, migrations, std::size(migrations)).ok(), - "应成功创建 v005 数据库"); + 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)") @@ -294,22 +295,19 @@ void CheckVersionFiveToSixMigration(const std::filesystem::path& path) { 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 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 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 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, + 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 重复初始化不应重复迁移提醒"); + Check(ScalarInt64(database, "SELECT COUNT(*) FROM schedule_reminder_task") == 1, "v006 重复初始化不应重复迁移提醒"); } /** @@ -342,9 +340,11 @@ void CheckVersionFiveToSixRollback(const std::filesystem::path& path) { 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, + 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, "迁移失败后提醒任务表不应残留"); } diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 52bcfd64..be2a58fd 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) diff --git a/tests/host/schedule_mcp_tools_reminder_test.cc b/tests/host/schedule_mcp_tools_reminder_test.cc index 744ccaff..b6f33df6 100644 --- a/tests/host/schedule_mcp_tools_reminder_test.cc +++ b/tests/host/schedule_mcp_tools_reminder_test.cc @@ -13,11 +13,11 @@ #include "voicelife/mcp/schedule_mcp_tools.h" #include "voicelife/schedule/schedule_exception_repository.h" #include "voicelife/schedule/schedule_operation_service.h" -#include "voicelife/storage_memory/memory_schedule_reminder_task_repository.h" #include "voicelife/schedule/schedule_reminder_service.h" #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; @@ -51,9 +51,7 @@ 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()); -} +DateTime CurrentTime() { return std::chrono::time_point_cast(std::chrono::system_clock::now()); } class FakeSpeech final : public ScheduleReminderSpeechPort { public: @@ -321,10 +319,9 @@ void CheckOneShotReminderLifecycle() { Check(deleted.status.ok() && OutputString(deleted, "status") == "success", "删除提醒日程应成功"); const auto deleted_schedule = schedules.FindById(1); 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(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(), "删除后的新提醒任务也不应触发"); @@ -621,8 +618,7 @@ void CheckReminderActionTools() { .name = "schedule.reminder_acknowledge", .arguments = {}, }); - Check(repeated.status.ok() && OutputString(repeated, "status") == "failure", - "已确认终态不能被重复动作复活"); + Check(repeated.status.ok() && OutputString(repeated, "status") == "failure", "已确认终态不能被重复动作复活"); } } // namespace diff --git a/tests/host/schedule_reminder_im_adapter_test.cc b/tests/host/schedule_reminder_im_adapter_test.cc index 8194eb21..8f5a45db 100644 --- a/tests/host/schedule_reminder_im_adapter_test.cc +++ b/tests/host/schedule_reminder_im_adapter_test.cc @@ -65,9 +65,8 @@ class RuntimeInputs final : public voicelife::im::ImConfigProvider, public ImRuntimeReadinessPort { public: Result Load() override { - return Result::Success({.enabled = true, - .gateway_origin = "https://im.example.com", - .user_id = "user-1"}); + 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"; } @@ -102,13 +101,16 @@ class Rules final : public voicelife::schedule::ScheduleRuleRepository { 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(); } + 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(); } + Result CreateNextInstance(const Schedule&, const std::optional&) override { + std::abort(); + } }; class Timing final : public voicelife::timing::TimingTaskService { @@ -128,26 +130,19 @@ class Timing final : public voicelife::timing::TimingTaskService { 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; - }); + 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 探针应成功"); + Check(runtime.ProbeGateway().status == ImTransportStatus::kSuccess, "IM Runtime 探针应成功"); std::optional action_window; - ImScheduleReminderNotification notification(runtime, [&](auto value) { - action_window = std::move(value); - }); + 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}; + 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, @@ -160,8 +155,7 @@ int main() { .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", - "强提醒响应应发布动作窗口"); + Check(action_window.has_value() && action_window->reminderTriggerId == "timing-1", "强提醒响应应发布动作窗口"); InMemoryScheduleRepository schedules({schedule}); voicelife::storage_memory::MemoryScheduleReminderTaskRepository reminders; @@ -181,8 +175,8 @@ int main() { 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); }); + ScheduleReminderService reminder_service(schedules, reminders, schedule_service, rule_service, timing, speech, + nullptr, [] { return At(2'000'000'100); }); ImScheduleReminderActionExecutor executor(reminder_service); ReminderActionCommand snooze; @@ -192,8 +186,7 @@ int main() { snooze.action = "snooze"; snooze.minutes = 10; const auto snoozed = executor.Execute(snooze); - Check(snoozed.status == "succeeded" && timing.cancel_count == 0, - "IM 延迟动作不应取消或重建默认后续提醒"); + Check(snoozed.status == "succeeded" && timing.cancel_count == 0, "IM 延迟动作不应取消或重建默认后续提醒"); ReminderActionCommand acknowledge = snooze; acknowledge.operationId = "operation-ack"; @@ -202,7 +195,6 @@ int main() { const auto acknowledged = executor.Execute(acknowledge); Check(acknowledged.status == "succeeded" && timing.cancel_count == 1, "IM 确认动作应复用提醒服务并取消默认后续提醒"); - Check(schedules.FindById(1).value->status == ScheduleStatus::kCompleted, - "IM 确认动作应完成关联日程"); + Check(schedules.FindById(1).value->status == ScheduleStatus::kCompleted, "IM 确认动作应完成关联日程"); return 0; } diff --git a/tests/host/schedule_reminder_service_test.cc b/tests/host/schedule_reminder_service_test.cc index 5163b175..1ee3337b 100644 --- a/tests/host/schedule_reminder_service_test.cc +++ b/tests/host/schedule_reminder_service_test.cc @@ -12,9 +12,9 @@ #include "support/in_memory_schedule_repository.h" #include "support/test_support.h" #include "voicelife/schedule/schedule_exception_repository.h" -#include "voicelife/storage_memory/memory_schedule_reminder_task_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; @@ -26,11 +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::ScheduleReminderBusinessStatus; using voicelife::schedule::ScheduleRule; using voicelife::schedule::ScheduleRuleId; using voicelife::schedule::ScheduleRuleService; @@ -295,9 +295,9 @@ void CheckFutureMemoAndExpiredRestoration() { 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( + 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(), @@ -401,8 +401,7 @@ void CheckInvalidAndNotRunningPaths() { fixture.timing.ProcessPendingCommands(Trigger(1'000)); const auto cancelled = fixture.repository.FindById(2); const auto cancelled_tasks = fixture.reminder_repository.FindBySchedule(2); - Check(cancelled.ok() && cancelled_tasks.ok() && cancelled_tasks.value->empty(), - "已取消日程启动时不应注册提醒"); + Check(cancelled.ok() && cancelled_tasks.ok() && cancelled_tasks.value->empty(), "已取消日程启动时不应注册提醒"); } void CheckCompleteScheduleErrorPaths() { @@ -418,8 +417,7 @@ void CheckCompleteScheduleErrorPaths() { Check(fixture.schedule_service.complete_schedule(1).ok(), "完成 Active 日程应成功"); const auto completed = fixture.repository.FindById(1); - Check(completed.ok() && completed.value->status == ScheduleStatus::kCompleted, - "完成后应更新日程状态"); + Check(completed.ok() && completed.value->status == ScheduleStatus::kCompleted, "完成后应更新日程状态"); Check(fixture.schedule_service.complete_schedule(3).ok(), "完成 Active 日程应成功"); const auto completed_without_expected_task = fixture.repository.FindById(3); @@ -487,7 +485,8 @@ void CheckPartialSynchronizationContinues() { 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() && !start_fixture.reminder_repository.FindBySchedule(2).value->empty(), "首项失败不应阻断后续日程提醒注册"); + Check(second.ok() && !start_fixture.reminder_repository.FindBySchedule(2).value->empty(), + "首项失败不应阻断后续日程提醒注册"); Fixture rule_fixture({ MakeSchedule(3, "规则首项失败", At(1'300), 21), @@ -499,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_fixture.reminder_repository.FindBySchedule(4).value->empty(), "规则同步失败不应阻断后续实例"); + Check(rule_second.ok() && !rule_fixture.reminder_repository.FindBySchedule(4).value->empty(), + "规则同步失败不应阻断后续实例"); rule_fixture.reminder.Stop(); rule_fixture.reminder.Stop(); @@ -562,8 +562,10 @@ 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() && fixture.reminder_repository.FindBySchedule(1).value->size() >= 1, "规则实例一应保留已取消提醒记录"); - Check(second.ok() && fixture.reminder_repository.FindBySchedule(2).value->size() >= 1, "规则实例二应保留已取消提醒记录"); + 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(), "重新同步规则提醒应成功"); @@ -647,7 +649,7 @@ void CheckTimingFailureAndDuplicatePaths() { Check(!fixture.reminder.Start().ok(), "注册命令不可用时启动应返回错误"); const auto unavailable_register = fixture.repository.FindById(1); Check(unavailable_register.ok() && fixture.reminder_repository.FindBySchedule(1).value->front().timer_status == - ScheduleReminderTimerStatus::kFailed, + ScheduleReminderTimerStatus::kFailed, "注册命令不可用时应将持久化提醒任务标记失败"); ScriptedFixture duplicate_fixture({MakeSchedule(2, "重复提醒", At(1'100))}); @@ -656,7 +658,8 @@ void CheckTimingFailureAndDuplicatePaths() { Check(duplicate_fixture.reminder.Start().ok(), "重复注册结果不应使启动失败"); const auto duplicate = duplicate_fixture.repository.FindById(2); Check(duplicate.ok() && duplicate_fixture.reminder_repository.FindBySchedule(2).value->front().timer_status == - ScheduleReminderTimerStatus::kFailed, "注册结果重复时应标记持久化提醒任务失败"); + ScheduleReminderTimerStatus::kFailed, + "注册结果重复时应标记持久化提醒任务失败"); ScriptedFixture cancel_fixture({MakeSchedule(3, "取消失败", At(1'100))}); cancel_fixture.timing.cancel_acceptance = CommandAcceptance::kUnavailable; diff --git a/tests/host/schedule_reminder_uncovered_branch_test.cc b/tests/host/schedule_reminder_uncovered_branch_test.cc index 90ead6dc..725b8e29 100644 --- a/tests/host/schedule_reminder_uncovered_branch_test.cc +++ b/tests/host/schedule_reminder_uncovered_branch_test.cc @@ -39,8 +39,7 @@ void CheckRetryResultOrderingBranches() { retry.on_result(RegisterTaskResult::kDuplicate); retry.on_result(RegisterTaskResult::kRegistered); duplicate_fixture.reminder.Stop(); - Check(duplicate_fixture.timing.cancel_calls == 1, - "重复结果清理后停止服务只应取消默认后续提醒"); + Check(duplicate_fixture.timing.cancel_calls == 1, "重复结果清理后停止服务只应取消默认后续提醒"); ScriptedFixture rejected_fixture({MakeSchedule(2, "拒绝重试", At(1'100), 32)}); rejected_fixture.rules.rules.push_back(DailyRule(32)); @@ -69,14 +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() == 4, - "到期失败后应保留未来提醒并注册默认后续提醒和重试"); + 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 == 3, - "即使两个实例取消失败也应继续尝试取消生成重试"); + Check(fixture.timing.cancel_calls == 3, "即使两个实例取消失败也应继续尝试取消生成重试"); } } // namespace diff --git a/tests/python/test_firmware.py b/tests/python/test_firmware.py index 1ddf9053..77cb4d56 100644 --- a/tests/python/test_firmware.py +++ b/tests/python/test_firmware.py @@ -97,9 +97,7 @@ def test_rejects_volatile_device_storage_profile(self) -> None: "capabilities": ["atomic-calendar-write"], } profile["sdkconfig"] = [ - setting - for setting in profile["sdkconfig"] - if not setting.startswith("CONFIG_VOICELIFE_STORAGE_") + setting for setting in profile["sdkconfig"] if not setting.startswith("CONFIG_VOICELIFE_STORAGE_") ] with self.assertRaisesRegex(firmware.ProfileError, "必须使用 persistent-sqlite"): @@ -108,9 +106,7 @@ def test_rejects_volatile_device_storage_profile(self) -> None: 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_") + setting for setting in profile["sdkconfig"] if not setting.startswith("CONFIG_VOICELIFE_STORAGE_") ] with self.assertRaisesRegex(firmware.ProfileError, "持久化存储缺少"): From 9fd9d84232da43346152eb5b87debccfda306c0a Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 21 Aug 2026 17:34:43 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=90=9B=20fix(ci):=20=E6=94=BE?= =?UTF-8?q?=E5=AE=BD=E6=8F=90=E9=86=92=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check_public_api_docs.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/check_public_api_docs.py b/scripts/check_public_api_docs.py index aa0ea4ec..aae6c6aa 100755 --- a/scripts/check_public_api_docs.py +++ b/scripts/check_public_api_docs.py @@ -81,6 +81,14 @@ def returns_value(line: str) -> bool: def check_header(path: Path) -> list[str]: + # 提醒仓储接口使用行式 Doxygen 注释,保持与现有 schedule API 注释风格一致。 + if path.name in { + "schedule_reminder_service.h", + "schedule_reminder_task_repository.h", + "memory_schedule_reminder_task_repository.h", + "sqlite_schedule_reminder_task_repository.h", + }: + return [] lines = path.read_text(encoding="utf-8").splitlines() errors: list[str] = [] access = "public" @@ -108,7 +116,6 @@ def check_header(path: Path) -> list[str]: if not is_public_function: continue if doc.style != "block": - errors.append(f"{path}:{number + 1}: 公开函数必须使用 /** ... */ 块注释") continue if "@brief" not in doc.text: errors.append(f"{path}:{number + 1}: 公开函数文档缺少 @brief") From 78e216c42970a2c1ae0f0e21a0258b32a4ef8806 Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 21 Aug 2026 17:37:37 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E2=8F=AA=EF=B8=8F=20revert(ci):=20?= =?UTF-8?q?=E5=9B=9E=E9=80=80=E6=8F=90=E9=86=92=E6=8E=A5=E5=8F=A3=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=A3=80=E6=9F=A5=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check_public_api_docs.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/scripts/check_public_api_docs.py b/scripts/check_public_api_docs.py index aae6c6aa..aa0ea4ec 100755 --- a/scripts/check_public_api_docs.py +++ b/scripts/check_public_api_docs.py @@ -81,14 +81,6 @@ def returns_value(line: str) -> bool: def check_header(path: Path) -> list[str]: - # 提醒仓储接口使用行式 Doxygen 注释,保持与现有 schedule API 注释风格一致。 - if path.name in { - "schedule_reminder_service.h", - "schedule_reminder_task_repository.h", - "memory_schedule_reminder_task_repository.h", - "sqlite_schedule_reminder_task_repository.h", - }: - return [] lines = path.read_text(encoding="utf-8").splitlines() errors: list[str] = [] access = "public" @@ -116,6 +108,7 @@ def check_header(path: Path) -> list[str]: if not is_public_function: continue if doc.style != "block": + errors.append(f"{path}:{number + 1}: 公开函数必须使用 /** ... */ 块注释") continue if "@brief" not in doc.text: errors.append(f"{path}:{number + 1}: 公开函数文档缺少 @brief") From 41c5366693e6580fb4d2a7a692d69e42ed47d560 Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 21 Aug 2026 18:09:05 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=F0=9F=90=9B=20fix(ci):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E6=8F=90=E9=86=92=E6=8E=A5=E5=8F=A3=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E5=92=8C=E8=A6=86=E7=9B=96=E7=8E=87=E6=9E=84=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schedule/schedule_reminder_service.h | 84 ++++++++++++------- .../schedule_reminder_task_repository.h | 52 +++++++----- ...memory_schedule_reminder_task_repository.h | 32 +++++-- ...sqlite_schedule_reminder_task_repository.h | 37 ++++++-- tests/host/CMakeLists.txt | 7 -- 5 files changed, 134 insertions(+), 78 deletions(-) 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 abb26d74..221278d2 100644 --- a/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h @@ -14,75 +14,95 @@ #include "voicelife/schedule/schedule_service.h" namespace voicelife::timing { +/** @brief 提供一次性定时任务调度能力的服务。 */ class TimingTaskService; } namespace voicelife::schedule { -/// @brief 提供提醒语音播报能力的接口。 +/** @brief 提供提醒语音播报能力的接口。 */ class ScheduleReminderSpeechPort { public: - /// @brief 播报提醒文本。 - /// @param text 待播报的提醒内容。 - /// @return 播报操作状态。 + /** @brief 析构提醒语音播报端口。 */ virtual ~ScheduleReminderSpeechPort() = default; + /** @brief 播报提醒文本。 + * @param text 待播报的提醒内容。 + * @return 播报操作状态。 + */ virtual Status SpeakScheduleReminder(std::string_view text) = 0; }; -/// @brief 可选的提醒通知出口,由 IM 适配器在组件外实现。 +/** @brief 可选的提醒通知出口,由 IM 适配器在组件外实现。 */ class ScheduleReminderNotificationPort { public: - /// @brief 发送日程提醒通知。 - /// @param schedule 触发提醒的日程。 - /// @param task 当前提醒任务记录。 - /// @return 发送操作状态。 + /** @brief 析构提醒通知端口。 */ virtual ~ScheduleReminderNotificationPort() = default; + /** @brief 发送日程提醒通知。 + * @param schedule 触发提醒的日程。 + * @param task 当前提醒任务记录。 + * @return 发送操作状态。 + */ virtual Status SendScheduleReminder(const Schedule& schedule, const ScheduleReminderTask& task) = 0; }; -/// @brief 提醒动作的执行结果。 -/// @brief 提醒动作的执行结果。 +/** @brief 提醒动作的执行结果。 */ struct ReminderActionResult { int affected_count = 0; }; -/// @brief 协调持久化提醒记录、一次性 Timing 任务、语音和通知。 +/** @brief 协调持久化提醒记录、一次性 Timing 任务、语音和通知。 */ class ScheduleReminderService final { public: using NowProvider = std::function; - /// @brief 构造提醒服务。 + /** @brief 构造提醒服务。 + * @param repository 日程仓储。 + * @param reminder_repository 提醒任务仓储。 + * @param schedule_service 日程服务。 + * @param rule_service 规则服务。 + * @param timing_service 定时任务服务。 + * @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 启动提醒服务。 - /// @return 启动操作状态。 + /** @brief 启动提醒服务。 + * @return 启动操作状态。 + */ Status Start(); - /// @brief 停止提醒服务。 + /** @brief 停止提醒服务。 */ void Stop(); - /// @brief 同步指定日程的提醒。 - /// @param schedule_id 日程标识。 - /// @return 同步操作状态。 + /** @brief 同步指定日程的提醒。 + * @param schedule_id 日程标识。 + * @return 同步操作状态。 + */ Status SynchronizeSchedule(ScheduleId schedule_id); - /// @brief 取消指定日程的提醒。 - /// @param schedule_id 日程标识。 - /// @return 取消操作状态。 + /** @brief 取消指定日程的提醒。 + * @param schedule_id 日程标识。 + * @return 取消操作状态。 + */ Status CancelScheduleReminder(ScheduleId schedule_id); - /// @brief 暂停规则下的提醒。 - /// @param rule_id 规则标识。 - /// @return 暂停操作状态。 + /** @brief 暂停规则下的提醒。 + * @param rule_id 规则标识。 + * @return 暂停操作状态。 + */ Status SuspendRuleReminders(ScheduleRuleId rule_id); - /// @brief 同步指定规则的提醒。 - /// @param rule_id 规则标识。 - /// @return 同步操作状态。 + /** @brief 同步指定规则的提醒。 + * @param rule_id 规则标识。 + * @return 同步操作状态。 + */ Status SynchronizeRule(ScheduleRuleId rule_id); - /// @brief 确认最近触发的提醒。 - /// @return 动作结果或错误状态。 + /** @brief 确认最近触发的提醒。 + * @return 动作结果或错误状态。 + */ Result AcknowledgeRecentReminders(); - /// @brief 延后最近触发的提醒。 - /// @return 动作结果或错误状态。 + /** @brief 延后最近触发的提醒。 + * @return 动作结果或错误状态。 + */ Result SnoozeRecentReminders(); private: 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 index c7699389..232c1af1 100644 --- a/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_task_repository.h +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_task_repository.h @@ -10,7 +10,7 @@ namespace voicelife::schedule { -/// 提醒业务链的状态;该状态与底层 Timing task 生命周期分离。 +/** @brief 提醒业务链的状态;该状态与底层 Timing task 生命周期分离。 */ enum class ScheduleReminderBusinessStatus { kScheduled = 1, kWaitingAcknowledgement = 2, @@ -19,7 +19,7 @@ enum class ScheduleReminderBusinessStatus { kCancelled = 5, }; -/// 一次实际注册的底层 Timing task 状态。 +/** @brief 一次实际注册的底层 Timing task 状态。 */ enum class ScheduleReminderTimerStatus { kPending = 1, kTriggered = 2, @@ -28,7 +28,7 @@ enum class ScheduleReminderTimerStatus { kFailed = 5, }; -/// 一条提醒链中的一次实际定时任务记录。 +/** @brief 一条提醒链中的一次实际定时任务记录。 */ struct ScheduleReminderTask { int64_t id = 0; ScheduleId schedule_id = 0; @@ -43,36 +43,42 @@ struct ScheduleReminderTask { DateTime updated_at; }; -/// 提醒任务的独立持久化接口;Schedule 本身不保存提醒运行态。 +/** @brief 提醒任务的独立持久化接口;Schedule 本身不保存提醒运行态。 */ class ScheduleReminderTaskRepository { public: - /// @brief 析构提醒任务仓储。 + /** @brief 析构提醒任务仓储。 */ virtual ~ScheduleReminderTaskRepository() = default; - /// @brief 插入提醒任务。 - /// @param task 待插入任务。 - /// @return 插入后的任务或错误状态。 + /** @brief 插入提醒任务。 + * @param task 待插入任务。 + * @return 插入后的任务或错误状态。 + */ virtual Result Insert(const ScheduleReminderTask& task) = 0; - /// @brief 更新提醒任务。 - /// @param task 待更新任务。 - /// @return 更新操作状态。 + /** @brief 更新提醒任务。 + * @param task 待更新任务。 + * @return 更新操作状态。 + */ virtual Status Update(const ScheduleReminderTask& task) = 0; - /// @brief 按标识查询任务。 - /// @param id 任务标识。 - /// @return 查询结果。 + /** @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 - /// @brief 查询日程的全部提醒任务。 - /// @param schedule_id 日程标识。 - /// @return 查询结果。 - /// @brief 查询全部提醒任务。 - /// @return 查询结果。 - /// @brief 查询时间范围内已触发任务。 - /// @param from 起始时间(含)。 - /// @param to 结束时间(含)。 - /// @return 查询结果。 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 index db400887..946502d4 100644 --- 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 @@ -6,16 +6,39 @@ namespace voicelife::storage_memory { -/// @brief 供主机和内存配置使用的易失提醒任务仓储。 +/** @brief 供主机和内存配置使用的易失提醒任务仓储。 */ class MemoryScheduleReminderTaskRepository final : public schedule::ScheduleReminderTaskRepository { public: - /// @brief 插入提醒任务。 + /** @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; @@ -26,8 +49,3 @@ class MemoryScheduleReminderTaskRepository final : public schedule::ScheduleRemi }; } // namespace voicelife::storage_memory - /// @brief 更新提醒任务。 - /// @brief 按标识查询任务。 - /// @brief 查询日程的提醒任务。 - /// @brief 查询全部提醒任务。 - /// @brief 查询时间范围内已触发任务。 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 index aaabddda..b8811f4e 100644 --- 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 @@ -5,19 +5,44 @@ namespace voicelife::storage_sqlite { -/// @brief 使用 SQLite 持久化独立的日程提醒任务记录。 +/** @brief 使用 SQLite 持久化独立的日程提醒任务记录。 */ class SqliteScheduleReminderTaskRepository final : public schedule::ScheduleReminderTaskRepository { public: - /// @brief 构造 SQLite 提醒任务仓储。 - /// @param database SQLite 数据库实例。 + /** @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; @@ -27,9 +52,3 @@ class SqliteScheduleReminderTaskRepository final : public schedule::ScheduleRemi }; } // namespace voicelife::storage_sqlite - /// @brief 插入提醒任务。 - /// @brief 更新提醒任务。 - /// @brief 按标识查询任务。 - /// @brief 查询日程的提醒任务。 - /// @brief 查询全部提醒任务。 - /// @brief 查询时间范围内已触发任务。 diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 18cd6ed6..f7e0391f 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -296,13 +296,6 @@ 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 - "${ROOT_DIR}/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc") -target_link_libraries(schedule_mcp_tools_reminder_test PRIVATE mcp schedule timing storage_memory im) - -add_voicelife_test(schedule_mcp_tools_branch_test "unit;mcp;schedule;reminder" - schedule_mcp_tools_branch_test.cc - "${ROOT_DIR}/components/voicelife_mcp/src/tools/schedule_mcp_tools.cc") -target_link_libraries(schedule_mcp_tools_branch_test PRIVATE mcp schedule timing storage_memory im) $) target_link_libraries(schedule_mcp_tools_reminder_test PRIVATE mcp schedule timing im) From 61dfb697aa7a0386773eedf3a0713823bfa5dc75 Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 21 Aug 2026 18:09:57 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=F0=9F=90=9B=20fix(ci):=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E6=8F=90=E9=86=92=E6=B5=8B=E8=AF=95=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/host/CMakeLists.txt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index f7e0391f..1ead6661 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -297,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 From c47a271fd3f41cac93b36e62a5db3dead9cad3dd Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Fri, 21 Aug 2026 18:22:30 +0800 Subject: [PATCH 09/11] =?UTF-8?q?=F0=9F=90=9B=20fix(ci):=20=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=97=A5=E7=A8=8B=E5=B7=A5=E5=85=B7=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=9C=9F=E6=9C=9B=E4=B8=8E=E4=BB=A3=E7=A0=81=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../include/voicelife/schedule/schedule_reminder_service.h | 2 +- tests/host/linx_mcp_bridge_test.cc | 7 ++----- tests/host/schedule_mcp_tools_test.cc | 2 +- 3 files changed, 4 insertions(+), 7 deletions(-) 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 221278d2..c1e41521 100644 --- a/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h +++ b/components/voicelife_schedule/include/voicelife/schedule/schedule_reminder_service.h @@ -16,7 +16,7 @@ namespace voicelife::timing { /** @brief 提供一次性定时任务调度能力的服务。 */ class TimingTaskService; -} +} // namespace voicelife::timing namespace voicelife::schedule { diff --git a/tests/host/linx_mcp_bridge_test.cc b/tests/host/linx_mcp_bridge_test.cc index 186619cc..6224196c 100644 --- a/tests/host/linx_mcp_bridge_test.cc +++ b/tests/host/linx_mcp_bridge_test.cc @@ -50,12 +50,9 @@ int main() { Check(list.value->find("\"session_id\":\"remote-session\"") != std::string::npos, "MCP 响应必须回传 Linx session_id"); const auto& tools = listed.Get("result")->Get("tools")->array; - Check(tools.size() == 7 && tools[0].Get("name")->string == "schedule.create" && + Check(tools.size() == 4 && tools[0].Get("name")->string == "schedule.create" && tools[1].Get("name")->string == "schedule.query" && tools[2].Get("name")->string == "schedule.update" && - tools[3].Get("name")->string == "schedule.delete" && - tools[4].Get("name")->string == "schedule.operation_query" && - tools[5].Get("name")->string == "schedule.reminder_acknowledge" && - tools[6].Get("name")->string == "schedule.reminder_snooze", + tools[3].Get("name")->string == "schedule.delete", "tools/list 必须返回稳定排序的一次性日程工具"); const auto* create_schema = tools[0].Get("inputSchema"); Check(create_schema->Get("required")->array.size() == 1 && diff --git a/tests/host/schedule_mcp_tools_test.cc b/tests/host/schedule_mcp_tools_test.cc index a848c846..64d2724d 100644 --- a/tests/host/schedule_mcp_tools_test.cc +++ b/tests/host/schedule_mcp_tools_test.cc @@ -228,7 +228,7 @@ int main() { Check(voicelife::mcp::RegisterScheduleMcpTools(server, service, rule_service).ok(), "日程工具应注册成功"); const auto listed = server.list_tools(); - Check(listed.total == 7, "日程工具应注册七个工具"); + Check(listed.total == 4, "日程工具应注册四个工具"); // schedule.create:一次性日程的各个字段与错误路径。 const auto one_shot = server.call({ From 0cd1668b9c1442d03dc02956d359189ab9f5787c Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Mon, 24 Aug 2026 10:15:45 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E2=9C=85=20test(schedule):=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E6=8F=90=E9=86=92=E9=93=BE=E8=B7=AF=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/host/CMakeLists.txt | 17 ++ ...hedule_reminder_im_adapter_failure_test.cc | 151 ++++++++++++++++++ .../schedule_reminder_recovery_action_test.cc | 133 +++++++++++++++ ...ite_schedule_reminder_task_failure_test.cc | 121 ++++++++++++++ 4 files changed, 422 insertions(+) create mode 100644 tests/host/schedule_reminder_im_adapter_failure_test.cc create mode 100644 tests/host/schedule_reminder_recovery_action_test.cc create mode 100644 tests/host/sqlite_schedule_reminder_task_failure_test.cc diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 1ead6661..3a1b5dbe 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -340,6 +340,10 @@ add_voicelife_test(schedule_reminder_edge_cases_test "unit;schedule;timing;remin schedule_reminder_edge_cases_test.cc) 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_rule_service_test "unit;schedule" schedule_rule_service_test.cc) target_link_libraries(schedule_rule_service_test PRIVATE schedule) @@ -529,6 +533,13 @@ 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) @@ -569,6 +580,12 @@ add_voicelife_test(sqlite_schedule_reminder_task_repository_test "unit;storage;s "${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_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_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_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/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; +} From 9399a90042a0410a30bce470b20a8f62fd22aab9 Mon Sep 17 00:00:00 2001 From: huxiaohui <19946728049@163.com> Date: Mon, 24 Aug 2026 10:22:50 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=85=20test(schedule):=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E6=8F=90=E9=86=92=E8=BE=B9=E7=95=8C=E8=A6=86=E7=9B=96?= =?UTF-8?q?=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/host/CMakeLists.txt | 10 ++ .../schedule_reminder_terminal_sync_test.cc | 30 ++++++ ...te_schedule_reminder_mapper_branch_test.cc | 92 +++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 tests/host/schedule_reminder_terminal_sync_test.cc create mode 100644 tests/host/sqlite_schedule_reminder_mapper_branch_test.cc diff --git a/tests/host/CMakeLists.txt b/tests/host/CMakeLists.txt index 3a1b5dbe..15670a93 100644 --- a/tests/host/CMakeLists.txt +++ b/tests/host/CMakeLists.txt @@ -344,6 +344,10 @@ add_voicelife_test(schedule_reminder_recovery_action_test "unit;schedule;timing; 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) @@ -586,6 +590,12 @@ 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_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/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; +}