initial commit
This commit is contained in:
@@ -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 | вторник |
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user