initial commit

This commit is contained in:
Kayashov.SM
2026-08-27 12:50:22 +04:00
commit a1beeb949e
27 changed files with 1310 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
# schedule-service
Self-hosted сервис адаптивного расписания дня, синхронизируемого с CalDAV-календарём (Baikal → нативная синхронизация с Календарём iPhone).
## Идея
- Шаблон дня хранится в Postgres как набор блоков двух типов:
- **RELATIVE** — смещение от времени пробуждения (например, "завтрак: wake+105 мин")
- **FIXED** — жёсткая привязка к часу и дню недели (например, дейли в 10:15 по будням)
- По триггеру "проснулся" (через iOS Shortcuts → `POST /api/wake`) сервис вычисляет реальные времена на сегодня и создаёт/обновляет события в Baikal через CalDAV.
- `POST /api/shift` пересчитывает и сдвигает ещё не начавшиеся сегодняшние события.
- Список покупок (`shopping_item`) пополняется голосом через кастомный навык Алисы (вебхук `/api/alice/webhook`), читается бэкендом при наступлении блока категории `SHOPPING_TRIP`.
- Фронт на React для ручного администрирования шаблона — собирается в один jar вместе с бэкендом.
## Стек
- Spring Boot 3.3, Java 17
- Spring Data JPA + PostgreSQL (`ddl-auto: update`, без Flyway — осознанное упрощение для домашнего проекта)
- RestTemplate + ical4j для CalDAV-синхронизации с Baikal
- React (сборка встраивается в `classpath:/static`)
## Текущий статус
Готово:
- [x] Entities: `ScheduleBlock`, `ShoppingItem`
- [x] Repositories
- [x] `CaldavProperties` / `RestTemplateConfig`
- [x] Базовый `application.yml`
В работе / следующие шаги:
- [ ] `ScheduleCalculationService` — расчёт конкретных времён на день из шаблона
- [ ] `CaldavSyncService` — построение VEVENT (ical4j) и PUT в Baikal, детерминированный UID (например `sha256(date + title)`) для идемпотентного обновления
- [ ] `WakeController``POST /api/wake`, `POST /api/shift`
- [ ] `ScheduleBlockController`, `ShoppingController` — CRUD для фронта
- [ ] `AliceWebhookController` — приём команд от навыка Алисы
- [ ] React-фронт для администрирования
- [ ] Dockerfile + манифесты Deployment/Service для k3s (namespace `media`)
## Локальный запуск
```bash
# 1. Прокинуть Postgres из кластера
kubectl port-forward -n media svc/postgres-postgresql 5432:5432
# 2. Создать БД и пользователя (один раз)
psql -h localhost -U postgres -c "CREATE DATABASE schedule;"
psql -h localhost -U postgres -c "CREATE USER schedule_user WITH PASSWORD 'changeme';"
psql -h localhost -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE schedule TO schedule_user;"
# 3. Поправить src/main/resources/application.yml (пароли caldav/db)
# 4. Запуск
mvn spring-boot:run
```
Baikal доступен напрямую по NodePort (`192.168.1.100:30019`), отдельный port-forward не нужен.
## Референсные данные шаблона (обсуждены, вводятся вручную через фронт/API после его готовности)
Пробуждение ~9:00, отбой ~23:30. Полный список блоков и офсетов — см. историю разработки проекта, часть значений помечена как оценочная и подлежит уточнению по факту использования.
Статические события:
| Событие | Время | Дни |
|---|---|---|
| Дейли НСИ | 10:15 | будни |
| Дейли Mesh | 12:30 | будни |
| Созвон НСИ→ПГ | 15:00 | вторник |
+118
View File
@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version>
<relativePath/>
</parent>
<groupId>ru.kayashov</groupId>
<artifactId>schedule-service</artifactId>
<version>0.1.0</version>
<name>schedule-service</name>
<description>Adaptive daily schedule service with CalDAV sync</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<!-- Сборка React-фронта и копирование в classpath:/static -->
<!-- Раскомментировать, когда появится реальный код во frontend/ -->
<!--
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.15.0</version>
<configuration>
<workingDirectory>frontend</workingDirectory>
</configuration>
<executions>
<execution>
<id>install node and npm</id>
<goals><goal>install-node-and-npm</goal></goals>
<configuration>
<nodeVersion>v20.15.1</nodeVersion>
</configuration>
</execution>
<execution>
<id>npm install</id>
<goals><goal>npm</goal></goals>
<configuration><arguments>install</arguments></configuration>
</execution>
<execution>
<id>npm run build</id>
<goals><goal>npm</goal></goals>
<configuration><arguments>run build</arguments></configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<executions>
<execution>
<id>copy-frontend-build</id>
<phase>process-classes</phase>
<goals><goal>copy-resources</goal></goals>
<configuration>
<outputDirectory>${project.build.outputDirectory}/static</outputDirectory>
<resources>
<resource><directory>frontend/build</directory></resource>
</resources>
</configuration>
</execution>
</executions>
</plugin>
-->
</plugins>
</build>
</project>
@@ -0,0 +1,11 @@
package ru.kayashov.schedule;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ScheduleServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ScheduleServiceApplication.class, args);
}
}
@@ -0,0 +1,17 @@
package ru.kayashov.schedule.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "caldav")
@Getter
@Setter
public class CaldavProperties {
/** Например: http://192.168.1.100:30019/dav.php/calendars/sergey/default */
private String baseUrl;
private String username;
private String password;
}
@@ -0,0 +1,17 @@
package ru.kayashov.schedule.config;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestTemplateConfig {
@Bean
public RestTemplate caldavRestTemplate(RestTemplateBuilder builder, CaldavProperties props) {
return builder
.basicAuthentication(props.getUsername(), props.getPassword())
.build();
}
}
@@ -0,0 +1,36 @@
package ru.kayashov.schedule.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* Состояние текущего дня: время пробуждения и накопленный сдвиг расписания.
* По одной записи на дату — обновляется при /wake (создание) и /shift (правка shiftMinutes).
*/
@Entity
@Table(name = "daily_state")
@Getter
@Setter
public class DailyState {
@Id
private LocalDate date;
@Column(nullable = false)
private LocalTime wakeTime;
/** Суммарный сдвиг в минутах, накопленный за счёт вызовов /shift в течение дня. */
@Column(nullable = false)
private Integer shiftMinutes = 0;
@Column(nullable = false)
private LocalDateTime updatedAt = LocalDateTime.now();
}
@@ -0,0 +1,80 @@
package ru.kayashov.schedule.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* Конкретный экземпляр блока расписания на конкретную дату — источник истории
* и будущей статистики. Хранит и плановые времена (из шаблона на момент расчёта),
* и фактические (заполняются позже — вручную, по голосовой команде Алисе, кнопкой
* в Shortcuts и т.д.), поэтому actualStart/actualEnd nullable.
* <p>
* Название блока и категория денормализованы (blockTitleSnapshot) — если шаблон
* потом переименуют или удалят, история и статистика по старым записям не потеряются.
*/
@Entity
@Table(name = "event_occurrence", indexes = {
@Index(name = "idx_occurrence_date", columnList = "occurrenceDate"),
@Index(name = "idx_occurrence_title", columnList = "blockTitleSnapshot")
})
@Getter
@Setter
public class EventOccurrence {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* Ссылка на исходный блок шаблона. Nullable и без каскадного удаления —
* если блок в шаблоне удалят, история occurrence'ов должна пережить это
* (title/category уже денормализованы отдельно).
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "block_id")
private ScheduleBlock block;
@Column(nullable = false)
private String blockTitleSnapshot;
@Column(nullable = false)
private String category;
@Column(nullable = false)
private int reminderMinutesBefore = 0;
@Column(nullable = false)
private LocalDate occurrenceDate;
@Column(nullable = false)
private LocalDateTime plannedStart;
@Column(nullable = false)
private LocalDateTime plannedEnd;
/** Заполняется позже, когда появится механизм отметки факта (голос/кнопка). */
private LocalDateTime actualStart;
/** Заполняется позже, когда появится механизм отметки факта (голос/кнопка). */
private LocalDateTime actualEnd;
/** UID события в Baikal — для корреляции и последующего удаления при необходимости. */
@Column(nullable = false)
private String caldavUid;
@Column(nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
}
@@ -0,0 +1,71 @@
package ru.kayashov.schedule.domain;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalTime;
import java.util.List;
@Entity
@Table(name = "schedule_block")
@Getter
@Setter
public class ScheduleBlock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ScheduleType scheduleType;
/** Минут от момента пробуждения. Заполняется только для RELATIVE. */
private Integer startOffsetMin;
/** Длительность блока в минутах. */
private Integer durationMin;
/** Точное время начала. Заполняется только для FIXED. */
private LocalTime fixedTime;
/**
* Дни недели, когда блок актуален. Заполняется только для FIXED.
* Значения: MON, TUE, WED, THU, FRI, SAT, SUN либо специальное "WEEKDAY".
*/
@ElementCollection
@CollectionTable(name = "schedule_block_days", joinColumns = @JoinColumn(name = "block_id"))
@Column(name = "day_of_week")
private List<String> daysOfWeek;
/**
* За сколько минут до начала показывать уведомление на устройстве.
* 0 — уведомление ровно в момент начала. null трактуется как 0.
*/
private Integer reminderMinutesBefore = 0;
/** Категория блока, например REGULAR, SHOPPING_TRIP, WORK — используется для триггеров. */
@Column(nullable = false)
private String category = "REGULAR";
@Column(nullable = false)
private boolean active = true;
/** Порядок отображения на фронте (не влияет на расчёт времени). */
@Column(nullable = false)
private Integer sortOrder = 0;
}
@@ -0,0 +1,8 @@
package ru.kayashov.schedule.domain;
public enum ScheduleType {
/** Смещение от времени пробуждения (например, "завтрак: wake+105 минут") */
RELATIVE,
/** Жёсткая привязка к конкретному часу и дню недели (например, дейли в 10:15 по будням) */
FIXED
}
@@ -0,0 +1,32 @@
package ru.kayashov.schedule.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "shopping_item")
@Getter
@Setter
public class ShoppingItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String item;
@Column(nullable = false)
private LocalDateTime addedAt = LocalDateTime.now();
@Column(nullable = false)
private boolean bought = false;
}
@@ -0,0 +1,17 @@
package ru.kayashov.schedule.dto;
import java.time.LocalDateTime;
/**
* Единый формат ответа об ошибке для всех эндпоинтов API.
*/
public record ErrorResponse(
LocalDateTime timestamp,
int status,
String error,
String message
) {
public static ErrorResponse of(int status, String error, String message) {
return new ErrorResponse(LocalDateTime.now(), status, error, message);
}
}
@@ -0,0 +1,17 @@
package ru.kayashov.schedule.dto;
import java.util.List;
/**
* Ответ /api/wake и /api/shift — что реально было рассчитано и отправлено в Baikal,
* чтобы Shortcuts (или сам пользователь при отладке) сразу видел результат,
* не заходя отдельно в календарь.
*/
public record ScheduleResponse(
int totalEvents,
List<ScheduledEvent> events
) {
public static ScheduleResponse of(List<ScheduledEvent> events) {
return new ScheduleResponse(events.size(), events);
}
}
@@ -0,0 +1,23 @@
package ru.kayashov.schedule.dto;
import java.time.LocalDateTime;
/**
* Результат расчёта: конкретный блок расписания, привязанный к реальной дате и времени.
*
* @param uid детерминированный идентификатор события для CalDAV
* @param title название блока
* @param start время начала
* @param end время окончания (start + duration)
* @param category категория блока (REGULAR, SHOPPING_TRIP, WORK и т.д.)
* @param reminderMinutesBefore за сколько минут до start показывать уведомление (0 — в момент начала)
*/
public record ScheduledEvent(
String uid,
String title,
LocalDateTime start,
LocalDateTime end,
String category,
int reminderMinutesBefore
) {
}
@@ -0,0 +1,14 @@
package ru.kayashov.schedule.dto;
import jakarta.validation.constraints.NotNull;
/**
* Тело запроса POST /api/shift — сдвинуть ещё не начавшиеся сегодняшние блоки.
*
* @param minutes на сколько минут сдвинуть; отрицательное значение сдвигает назад
* (например, если что-то освободилось раньше ожидаемого)
*/
public record ShiftRequest(
@NotNull Integer minutes
) {
}
@@ -0,0 +1,23 @@
package ru.kayashov.schedule.dto;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;
import java.time.LocalTime;
/**
* Тело запроса POST /api/wake — сигнал "проснулся" от iOS Shortcuts.
*
* @param wakeTime время пробуждения (обязательно)
* @param date дата, на которую считаем расписание; если не передана — берём сегодняшнюю
* (поле необязательно специально для простоты вызова из Shortcuts, где
* удобнее передавать только время)
*/
public record WakeRequest(
@NotNull LocalTime wakeTime,
LocalDate date
) {
public LocalDate resolvedDate() {
return date != null ? date : LocalDate.now();
}
}
@@ -0,0 +1,9 @@
package ru.kayashov.schedule.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.kayashov.schedule.domain.DailyState;
import java.time.LocalDate;
public interface DailyStateRepository extends JpaRepository<DailyState, LocalDate> {
}
@@ -0,0 +1,36 @@
package ru.kayashov.schedule.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import ru.kayashov.schedule.domain.EventOccurrence;
import ru.kayashov.schedule.repository.projection.AverageDuration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public interface EventOccurrenceRepository extends JpaRepository<EventOccurrence, Long> {
List<EventOccurrence> findByOccurrenceDate(LocalDate date);
/** Блоки на сегодня, которые ещё не начались (для /shift). */
List<EventOccurrence> findByOccurrenceDateAndPlannedStartAfterAndActualStartIsNull(
LocalDate date, LocalDateTime now);
/**
* Средняя фактическая длительность (в минутах) по каждому названию блока —
* основа для будущей автокоррекции шаблона на базе реальной статистики.
* Учитываются только occurrence'ы, где факт уже отмечен (actual_start/actual_end заполнены).
*/
@Query(value = """
SELECT block_title_snapshot AS title,
AVG(EXTRACT(EPOCH FROM (actual_end - actual_start)) / 60) AS avgMinutes,
COUNT(*) AS sampleCount
FROM event_occurrence
WHERE actual_start IS NOT NULL AND actual_end IS NOT NULL
GROUP BY block_title_snapshot
""", nativeQuery = true)
List<AverageDuration> findAverageDurationsByTitle();
java.util.Optional<EventOccurrence> findByOccurrenceDateAndBlockTitleSnapshot(LocalDate date, String title);
}
@@ -0,0 +1,10 @@
package ru.kayashov.schedule.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.kayashov.schedule.domain.ScheduleBlock;
import java.util.List;
public interface ScheduleBlockRepository extends JpaRepository<ScheduleBlock, Long> {
List<ScheduleBlock> findByActiveTrueOrderBySortOrderAsc();
}
@@ -0,0 +1,10 @@
package ru.kayashov.schedule.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import ru.kayashov.schedule.domain.ShoppingItem;
import java.util.List;
public interface ShoppingItemRepository extends JpaRepository<ShoppingItem, Long> {
List<ShoppingItem> findByBoughtFalseOrderByAddedAtAsc();
}
@@ -0,0 +1,11 @@
package ru.kayashov.schedule.repository.projection;
/**
* Проекция для нативного запроса статистики средней длительности блоков.
* Spring Data сам замаппит колонки алиасов (title, avgMinutes, sampleCount) на геттеры.
*/
public interface AverageDuration {
String getTitle();
Double getAvgMinutes();
Long getSampleCount();
}
@@ -0,0 +1,193 @@
package ru.kayashov.schedule.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import ru.kayashov.schedule.config.CaldavProperties;
import ru.kayashov.schedule.dto.ScheduledEvent;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* Синхронизирует вычисленные события ({@link ScheduledEvent}) с CalDAV-календарём Baikal.
* <p>
* iCalendar-текст формируется вручную по RFC 5545, без сторонней библиотеки — для
* нашего простого случая (одно событие, без повторений/вложений) это надёжнее, чем
* подстраиваться под нестабильный между версиями API ical4j.
* <p>
* Каждое событие кладётся отдельным HTTP PUT по адресу {baseUrl}/{uid}.ics — SabreDAV
* (на котором построен Baikal) создаёт ресурс, если его ещё нет, и перезаписывает, если
* он уже существует. Поскольку UID детерминирован (см. {@link ScheduleCalculationService}),
* повторный вызов на ту же дату идемпотентно обновляет существующее событие.
*/
@Service
@Slf4j
public class CaldavSyncService {
/** Часовой пояс, в котором вычисляется расписание. Совпадает с TZ сервисов в кластере. */
private static final ZoneId ZONE = ZoneId.of("Europe/Samara");
/** Формат времени в iCalendar для абсолютного UTC-момента: 20260824T090000Z */
private static final DateTimeFormatter ICAL_UTC_FORMAT =
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'");
/** DTSTAMP формируется в момент генерации файла, тоже в UTC. */
private static final DateTimeFormatter ICAL_UTC_STAMP_FORMAT = ICAL_UTC_FORMAT;
private final RestTemplate caldavRestTemplate;
private final CaldavProperties caldavProperties;
public CaldavSyncService(RestTemplate caldavRestTemplate, CaldavProperties caldavProperties) {
this.caldavRestTemplate = caldavRestTemplate;
this.caldavProperties = caldavProperties;
}
/**
* Синхронизирует весь список событий. Продолжает синхронизацию остальных событий,
* даже если одно из них не удалось отправить — ошибка логируется, но не прерывает цикл.
* Если не удалось отправить ни одного события при непустом списке — это, вероятнее всего,
* означает, что Baikal целиком недоступен, и тогда бросаем исключение, чтобы клиент
* получил внятный 502 через GlobalExceptionHandler, а не тихий "успех".
*/
public void syncEvents(List<ScheduledEvent> events) {
if (events.isEmpty()) {
return;
}
int success = 0;
Exception lastError = null;
for (ScheduledEvent event : events) {
try {
syncEvent(event);
success++;
} catch (Exception e) {
lastError = e;
log.error("Не удалось синхронизировать событие '{}' (uid={}): {}",
event.title(), event.uid(), e.getMessage(), e);
}
}
log.info("Синхронизация завершена: {}/{} событий отправлено в Baikal", success, events.size());
if (success == 0) {
throw new RestClientException(
"Не удалось отправить ни одного события в Baikal (" + events.size() + " попыток), "
+ "похоже, календарь недоступен", lastError);
}
}
/**
* Отправляет одно событие в Baikal через CalDAV PUT.
*/
public void syncEvent(ScheduledEvent event) {
String ics = buildIcs(event);
String url = buildEventUrl(event.uid());
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType("text/calendar;charset=UTF-8"));
HttpEntity<String> request = new HttpEntity<>(ics, headers);
caldavRestTemplate.exchange(url, HttpMethod.PUT, request, Void.class);
log.debug("Событие '{}' отправлено: {}", event.title(), url);
}
/**
* Удаляет событие из Baikal — пригодится, когда блок стал неактивным
* или пользователь вручную убрал его из шаблона после того, как он уже был засинкан.
*/
public void deleteEvent(String uid) {
String url = buildEventUrl(uid);
try {
caldavRestTemplate.delete(url);
log.debug("Событие удалено: {}", url);
} catch (Exception e) {
log.warn("Не удалось удалить событие {} (возможно, его и не было): {}", url, e.getMessage());
}
}
private String buildEventUrl(String uid) {
String base = caldavProperties.getBaseUrl();
if (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
return base + "/" + uid + ".ics";
}
/**
* Собирает валидный по RFC 5545 .ics-файл с одним VEVENT.
* Строки в iCalendar должны разделяться CRLF ("\r\n"), не просто "\n".
*/
private String buildIcs(ScheduledEvent event) {
String nowUtc = LocalDateTime.now(ZONE).atZone(ZONE)
.withZoneSameInstant(ZoneId.of("UTC"))
.format(ICAL_UTC_STAMP_FORMAT);
StringBuilder sb = new StringBuilder();
sb.append("BEGIN:VCALENDAR\r\n");
sb.append("VERSION:2.0\r\n");
sb.append("PRODID:-//kayashov//schedule-service//RU\r\n");
sb.append("CALSCALE:GREGORIAN\r\n");
sb.append("BEGIN:VEVENT\r\n");
sb.append("UID:").append(event.uid()).append("\r\n");
sb.append("DTSTAMP:").append(nowUtc).append("\r\n");
sb.append("DTSTART:").append(toIcalUtc(event.start())).append("\r\n");
sb.append("DTEND:").append(toIcalUtc(event.end())).append("\r\n");
sb.append("SUMMARY:").append(escapeText(event.title())).append("\r\n");
sb.append(buildValarm(event));
sb.append("END:VEVENT\r\n");
sb.append("END:VCALENDAR\r\n");
return sb.toString();
}
/**
* Компонент напоминания по RFC 5545 §3.6.6. TRIGGER задаётся отрицательной
* длительностью относительно начала события: -PT10M — за 10 минут до начала,
* PT0S — ровно в момент начала.
*/
private String buildValarm(ScheduledEvent event) {
String trigger = event.reminderMinutesBefore() <= 0
? "PT0S"
: "-PT" + event.reminderMinutesBefore() + "M";
StringBuilder sb = new StringBuilder();
sb.append("BEGIN:VALARM\r\n");
sb.append("ACTION:DISPLAY\r\n");
sb.append("DESCRIPTION:").append(escapeText(event.title())).append("\r\n");
sb.append("TRIGGER:").append(trigger).append("\r\n");
sb.append("END:VALARM\r\n");
return sb.toString();
}
/**
* Переводит локальное время расписания в абсолютное UTC-время формата iCalendar
* (с суффиксом Z). Такой подход не требует прикладывать компонент VTIMEZONE —
* валидно по RFC 5545 и корректно отображается в Календаре iPhone с автоматическим
* пересчётом в текущий часовой пояс устройства.
*/
private String toIcalUtc(LocalDateTime local) {
return local.atZone(ZONE)
.withZoneSameInstant(ZoneId.of("UTC"))
.format(ICAL_UTC_FORMAT);
}
/**
* Экранирование спецсимволов в TEXT-значениях по RFC 5545 §3.3.11:
* обратный слэш, запятая, точка с запятой и перевод строки.
*/
private String escapeText(String text) {
return text
.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
.replace("\n", "\\n");
}
}
@@ -0,0 +1,203 @@
package ru.kayashov.schedule.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import ru.kayashov.schedule.domain.DailyState;
import ru.kayashov.schedule.domain.EventOccurrence;
import ru.kayashov.schedule.dto.ScheduledEvent;
import ru.kayashov.schedule.repository.DailyStateRepository;
import ru.kayashov.schedule.repository.EventOccurrenceRepository;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
/**
* Оркестратор дня: связывает чистый расчёт расписания ({@link ScheduleCalculationService}),
* персистентность истории ({@link EventOccurrence}/{@link DailyState}) и синхронизацию
* с календарём ({@link CaldavSyncService}).
*/
@Service
@Slf4j
public class DailyScheduleService {
private final ScheduleCalculationService calculationService;
private final CaldavSyncService caldavSyncService;
private final DailyStateRepository dailyStateRepository;
private final EventOccurrenceRepository eventOccurrenceRepository;
public DailyScheduleService(ScheduleCalculationService calculationService,
CaldavSyncService caldavSyncService,
DailyStateRepository dailyStateRepository,
EventOccurrenceRepository eventOccurrenceRepository) {
this.calculationService = calculationService;
this.caldavSyncService = caldavSyncService;
this.dailyStateRepository = dailyStateRepository;
this.eventOccurrenceRepository = eventOccurrenceRepository;
}
/**
* Обрабатывает сигнал "проснулся": фиксирует время пробуждения на сегодня,
* рассчитывает полное расписание дня, сохраняет/обновляет occurrence'ы в БД
* и синхронизирует всё с Baikal.
* <p>
* Идемпотентен: повторный вызов на ту же дату обновляет существующие occurrence'ы
* (по дате+названию блока), а не создаёт дубликаты, и повторно синкает те же
* детерминированные UID в Baikal.
*/
@Transactional
public List<ScheduledEvent> wake(LocalDate date, LocalTime wakeTime) {
upsertDailyState(date, wakeTime);
List<ScheduledEvent> events = calculationService.calculateForDay(date, wakeTime);
persistOccurrences(date, events);
caldavSyncService.syncEvents(events);
log.info("Wake обработан: date={}, wakeTime={}, событий={}", date, wakeTime, events.size());
return events;
}
/**
* Сдвигает ещё не начавшиеся сегодняшние блоки на заданное число минут.
* Требует, чтобы на эту дату уже был вызван {@link #wake}.
*
* @throws IllegalStateException если на дату ещё не было пробуждения
*/
@Transactional
public List<ScheduledEvent> shift(LocalDate date, int minutes) {
DailyState state = dailyStateRepository.findById(date)
.orElseThrow(() -> new IllegalStateException(
"Нет записи о пробуждении на " + date + " — сначала вызовите /api/wake"));
state.setShiftMinutes(state.getShiftMinutes() + minutes);
state.setUpdatedAt(LocalDateTime.now());
dailyStateRepository.save(state);
LocalDateTime now = LocalDateTime.now();
List<EventOccurrence> allToday = eventOccurrenceRepository.findByOccurrenceDate(date).stream()
.sorted(Comparator.comparing(EventOccurrence::getPlannedStart))
.toList();
List<EventOccurrence> notStarted = allToday.stream()
.filter(o -> o.getPlannedStart().isAfter(now) && o.getActualStart() == null)
.toList();
for (EventOccurrence occurrence : notStarted) {
occurrence.setPlannedStart(occurrence.getPlannedStart().plusMinutes(minutes));
occurrence.setPlannedEnd(occurrence.getPlannedEnd().plusMinutes(minutes));
}
eventOccurrenceRepository.saveAll(notStarted);
closeGap(allToday, notStarted);
List<ScheduledEvent> fullDay = eventOccurrenceRepository.findByOccurrenceDate(date).stream()
.sorted(Comparator.comparing(EventOccurrence::getPlannedStart))
.map(this::toScheduledEvent)
.toList();
caldavSyncService.syncEvents(fullDay);
log.info("Shift обработан: date={}, minutes={}, затронуто блоков={}", date, minutes, notStarted.size());
return fullDay;
}
/**
* Принудительная пересинхронизация с Baikal без пересчёта расписания — берёт текущее
* состояние occurrence'ов из БД как есть (полезно после ручных правок в БД,
* или чтобы повторить попытку, если Baikal был недоступен при /wake или /shift).
*/
@Transactional(readOnly = true)
public List<ScheduledEvent> resync(LocalDate date) {
List<ScheduledEvent> events = eventOccurrenceRepository.findByOccurrenceDate(date).stream()
.map(this::toScheduledEvent)
.toList();
if (events.isEmpty()) {
throw new IllegalStateException("Нет occurrence'ов на " + date + " — сначала вызовите /api/wake");
}
caldavSyncService.syncEvents(events);
log.info("Ручная пересинхронизация: date={}, событий={}", date, events.size());
return events;
}
/**
* Устраняет разрыв (или наложение) в расписании: последний блок непосредственно
* перед первым сдвинутым получает новый plannedEnd, равный новому plannedStart
* этого сдвинутого блока — работает симметрично для положительного и
* отрицательного сдвига.
*/
private void closeGap(List<EventOccurrence> allToday, List<EventOccurrence> shifted) {
if (shifted.isEmpty()) {
return;
}
EventOccurrence firstFuture = shifted.get(0);
EventOccurrence previous = null;
for (EventOccurrence o : allToday) {
if (o.getId().equals(firstFuture.getId())) {
break;
}
previous = o;
}
if (previous == null) {
return;
}
LocalDateTime newBoundary = firstFuture.getPlannedStart();
// Защита от отрицательной длительности при сильном отрицательном сдвиге
if (newBoundary.isAfter(previous.getPlannedStart())) {
previous.setPlannedEnd(newBoundary);
eventOccurrenceRepository.save(previous);
}
}
private void upsertDailyState(LocalDate date, LocalTime wakeTime) {
DailyState state = dailyStateRepository.findById(date).orElseGet(DailyState::new);
state.setDate(date);
state.setWakeTime(wakeTime);
if (state.getShiftMinutes() == null) {
state.setShiftMinutes(0);
}
state.setUpdatedAt(LocalDateTime.now());
dailyStateRepository.save(state);
}
private void persistOccurrences(LocalDate date, List<ScheduledEvent> events) {
for (ScheduledEvent event : events) {
Optional<EventOccurrence> existing = eventOccurrenceRepository
.findByOccurrenceDateAndBlockTitleSnapshot(date, event.title());
EventOccurrence occurrence = existing.orElseGet(EventOccurrence::new);
occurrence.setOccurrenceDate(date);
occurrence.setBlockTitleSnapshot(event.title());
occurrence.setCategory(event.category());
occurrence.setPlannedStart(event.start());
occurrence.setReminderMinutesBefore(event.reminderMinutesBefore());
occurrence.setPlannedEnd(event.end());
occurrence.setCaldavUid(event.uid());
// block (ManyToOne) намеренно не проставляем здесь: ScheduledEvent — эфемерный DTO
// без ссылки на исходную сущность; денормализованных title/category достаточно
// для истории и статистики, а связь с block нужна только для future-фич,
// где это можно будет добавить отдельно при необходимости.
eventOccurrenceRepository.save(occurrence);
}
}
private ScheduledEvent toScheduledEvent(EventOccurrence occurrence) {
return new ScheduledEvent(
occurrence.getCaldavUid(),
occurrence.getBlockTitleSnapshot(),
occurrence.getPlannedStart(),
occurrence.getPlannedEnd(),
occurrence.getCategory(),
occurrence.getReminderMinutesBefore()
);
}
}
@@ -0,0 +1,117 @@
package ru.kayashov.schedule.service;
import org.springframework.stereotype.Service;
import ru.kayashov.schedule.domain.ScheduleBlock;
import ru.kayashov.schedule.dto.ScheduledEvent;
import ru.kayashov.schedule.repository.ScheduleBlockRepository;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
import java.util.Set;
@Service
public class ScheduleCalculationService {
private static final String WEEKDAY = "WEEKDAY";
private static final Set<DayOfWeek> WEEKDAYS = Set.of(
DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY,
DayOfWeek.THURSDAY, DayOfWeek.FRIDAY
);
private final ScheduleBlockRepository scheduleBlockRepository;
public ScheduleCalculationService(ScheduleBlockRepository scheduleBlockRepository) {
this.scheduleBlockRepository = scheduleBlockRepository;
}
public List<ScheduledEvent> calculateForDay(LocalDate date, LocalTime wakeTime) {
List<ScheduleBlock> blocks = scheduleBlockRepository.findByActiveTrueOrderBySortOrderAsc();
return calculateForDay(blocks, date, wakeTime);
}
public List<ScheduledEvent> calculateForDay(List<ScheduleBlock> blocks, LocalDate date, LocalTime wakeTime) {
return blocks.stream()
.map(block -> toScheduledEvent(block, date, wakeTime))
.filter(java.util.Objects::nonNull)
.sorted(Comparator.comparing(ScheduledEvent::start))
.toList();
}
private ScheduledEvent toScheduledEvent(ScheduleBlock block, LocalDate date, LocalTime wakeTime) {
return switch (block.getScheduleType()) {
case RELATIVE -> toRelativeEvent(block, date, wakeTime);
case FIXED -> toFixedEvent(block, date);
};
}
private ScheduledEvent toRelativeEvent(ScheduleBlock block, LocalDate date, LocalTime wakeTime) {
if (block.getStartOffsetMin() == null) {
return null;
}
LocalDateTime wakeDateTime = LocalDateTime.of(date, wakeTime);
LocalDateTime start = wakeDateTime.plusMinutes(block.getStartOffsetMin());
LocalDateTime end = start.plusMinutes(durationOrZero(block));
return buildEvent(block, date, start, end);
}
private ScheduledEvent toFixedEvent(ScheduleBlock block, LocalDate date) {
if (block.getFixedTime() == null || !matchesDayOfWeek(block, date.getDayOfWeek())) {
return null;
}
LocalDateTime start = LocalDateTime.of(date, block.getFixedTime());
LocalDateTime end = start.plusMinutes(durationOrZero(block));
return buildEvent(block, date, start, end);
}
private boolean matchesDayOfWeek(ScheduleBlock block, DayOfWeek today) {
List<String> days = block.getDaysOfWeek();
if (days == null || days.isEmpty()) {
return false;
}
for (String day : days) {
if (WEEKDAY.equalsIgnoreCase(day) && WEEKDAYS.contains(today)) {
return true;
}
if (today.name().startsWith(day.toUpperCase())) {
return true;
}
}
return false;
}
private int durationOrZero(ScheduleBlock block) {
return block.getDurationMin() == null ? 0 : block.getDurationMin();
}
private ScheduledEvent buildEvent(ScheduleBlock block, LocalDate date, LocalDateTime start, LocalDateTime end) {
int reminder = block.getReminderMinutesBefore() == null ? 0 : block.getReminderMinutesBefore();
return new ScheduledEvent(
deterministicUid(date, block.getTitle()),
block.getTitle(),
start,
end,
block.getCategory(),
reminder
);
}
private String deterministicUid(LocalDate date, String title) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
String raw = date + "|" + title;
byte[] hash = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash) + "@schedule-service";
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,81 @@
package ru.kayashov.schedule.web;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.client.RestClientException;
import ru.kayashov.schedule.dto.ErrorResponse;
/**
* Централизованная обработка ошибок для всех REST-контроллеров: клиент (Shortcuts,
* фронт, навык Алисы) всегда получает понятный JSON со статусом и описанием причины,
* а не голый 500 без деталей.
*/
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* Ожидаемые бизнес-ошибки уровня "клиент сделал что-то не по порядку"
* (например, /shift без предварительного /wake) — 400, без стектрейса в теле ответа.
*/
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ErrorResponse> handleIllegalState(IllegalStateException e) {
log.warn("Некорректное состояние: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of(400, "Bad Request", e.getMessage()));
}
/**
* Ошибки валидации тела запроса (@Valid на WakeRequest/ShiftRequest) —
* например, отсутствует обязательное поле wakeTime.
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.findFirst()
.orElse("Некорректные данные запроса");
log.warn("Ошибка валидации: {}", message);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of(400, "Validation Error", message));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ErrorResponse> handleConstraintViolation(ConstraintViolationException e) {
log.warn("Нарушение ограничений: {}", e.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse.of(400, "Validation Error", e.getMessage()));
}
/**
* Baikal недоступен, таймаут, неверные креды и т.п. — отдаём 502 Bad Gateway,
* подчёркивая, что проблема во внешней зависимости, а не в самом сервисе.
*/
@ExceptionHandler(RestClientException.class)
public ResponseEntity<ErrorResponse> handleCaldavFailure(RestClientException e) {
log.error("Ошибка обращения к Baikal (CalDAV): {}", e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.BAD_GATEWAY)
.body(ErrorResponse.of(502, "Bad Gateway", "Не удалось связаться с календарём: " + e.getMessage()));
}
/**
* Всё остальное непредвиденное — не раскрываем внутренние детали (стектрейс, имена
* классов) клиенту, только логируем полностью на сервере.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("Непредвиденная ошибка: {}", e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse.of(500, "Internal Server Error", "Внутренняя ошибка сервиса"));
}
}
@@ -0,0 +1,49 @@
package ru.kayashov.schedule.web;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import ru.kayashov.schedule.dto.ScheduleResponse;
import ru.kayashov.schedule.dto.ShiftRequest;
import ru.kayashov.schedule.dto.WakeRequest;
import ru.kayashov.schedule.service.DailyScheduleService;
import java.time.LocalDate;
/**
* Точка входа для iOS Shortcuts: сигнал пробуждения и сдвиг оставшегося дня.
*/
@RestController
@RequestMapping("/api")
@Slf4j
public class WakeController {
private final DailyScheduleService dailyScheduleService;
public WakeController(DailyScheduleService dailyScheduleService) {
this.dailyScheduleService = dailyScheduleService;
}
@PostMapping("/wake")
public ScheduleResponse wake(@Valid @RequestBody WakeRequest request) {
LocalDate date = request.resolvedDate();
var events = dailyScheduleService.wake(date, request.wakeTime());
return ScheduleResponse.of(events);
}
@PostMapping("/shift")
public ScheduleResponse shift(@Valid @RequestBody ShiftRequest request) {
var events = dailyScheduleService.shift(LocalDate.now(), request.minutes());
return ScheduleResponse.of(events);
}
@PostMapping("/sync")
public ScheduleResponse resync(@RequestParam(required = false) java.time.LocalDate date) {
var events = dailyScheduleService.resync(date != null ? date : LocalDate.now());
return ScheduleResponse.of(events);
}
}
+30
View File
@@ -0,0 +1,30 @@
spring:
application:
name: schedule-service
datasource:
url: jdbc:postgresql://localhost:5433/baykal
username: baykal
password: kayash73
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: false
server:
port: 8080
caldav:
base-url: http://192.168.1.100:30019/dav.php/calendars/sergey/main
username: sergey
password: kayash73
# Для локального запуска:
# 1. kubectl port-forward -n media svc/postgres-postgresql 5432:5432
# 2. Создать БД и пользователя в Postgres, если ещё не созданы:
# CREATE DATABASE schedule;
# CREATE USER schedule_user WITH PASSWORD 'changeme';
# GRANT ALL PRIVILEGES ON DATABASE schedule TO schedule_user;
# 3. Заменить пароль caldav.password на реальный пароль пользователя Baikal
+10
View File
@@ -0,0 +1,10 @@
# Схема БД (актуально на текущий момент)
Таблицы создаются автоматически через `hibernate.ddl-auto=update`.
- `schedule_block` — шаблон дня (RELATIVE/FIXED блоки)
- `schedule_block_days`@ElementCollection для daysOfWeek
- `shopping_item` — список покупок
- `daily_state` — состояние текущего дня (wake_time, накопленный shift)
- `event_occurrence` — история конкретных экземпляров блоков по дням,
источник будущей статистики (plannedStart/End vs actualStart/End)