1. «Кто изменил» в аудите без security
Если createdAt и updatedAt отвечают на вопрос «когда», то createdBy и lastModifiedBy отвечают на вопрос «кем» (или, если честнее для нашей стадии проекта — «каким источником»). В реальном проде это часто становится одним из главных способов расследовать странные данные: кто поменял цену, кто закрыл заказ, кто обнулил остатки. И да, это полезно даже до появления настоящих пользователей и авторизации — просто «автором» у нас пока будет не человек, а условный источник изменения.
При этом базовый mini-shop уже нормально живёт на createdAt и updatedAt. Поля автора — дополнительный слой: полезный, но не обязательный для текущей рабочей конфигурации проекта.
Представьте ситуацию: вы видите в базе, что у товара вдруг стала цена 0. По updatedAt вы понимаете время, но не понимаете контекст. Поле lastModifiedBy = "system" тоже не превращает вас в Шерлока Холмса, но уже помогает отделить «это автоматическое изменение» от «это изменял какой-то админский сценарий» (если вы будете отдавать разные значения). И самое главное: вы перестаёте писать эти вещи вручную в каждом сервисе — иначе auditing превратится в «копипасту now() и currentUser()».
Важно не перепутать смысл: createdBy и lastModifiedBy — это технические поля, похожие по природе на createdAt/updatedAt. Это не бизнес-данные клиента и точно не замена customerEmail в заказе. customerEmail — это о ком заказ, а createdBy — кем он создан в системе. Как и в прошлых лекциях, мы держим границу: бизнес-поля отвечают за смысл домена, audit-поля — за происхождение и обслуживание данных.
Чтобы совсем не смешивать в голове, удобно один раз посмотреть на это в виде мини-таблицы:
| Вопрос | Пример поля | Что хранит | Что это не такое |
|---|---|---|---|
| Когда создали? | createdAt | момент вставки записи | «когда впервые загрузили в память» |
| Когда меняли? | updatedAt | момент последнего изменения | «когда прочитали» |
| Кто создал? | createdBy | источник создания | customerEmail, «владелец записи» |
| Кто менял? | lastModifiedBy | источник последней правки | «кто сейчас читает» |
2. Аннотации @CreatedBy и @LastModifiedBy
Сама идея author-полей в Spring Data JPA выглядит очень «по-спринговски»: вы помечаете поле аннотацией, а инфраструктура заполняет его в нужный момент жизненного цикла сущности. Но чтобы это не превратилось в магию «поставил аннотацию — и молюсь», нам нужно чётко понимать семантику. @CreatedBy заполняется при создании сущности (обычно перед INSERT) и логически должен быть неизменяемым, а @LastModifiedBy обновляется при изменениях сущности (обычно перед UPDATE). И всё это работает только в рамках подключенного auditing и entity listener’а.
Аннотации берутся из Spring Data, а не из JPA:
- org.springframework.data.annotation.CreatedBy
- org.springframework.data.annotation.LastModifiedBy
А обработка происходит через AuditingEntityListener, который мы уже подключали в предыдущих лекциях.
С практической точки зрения чаще всего поля делают строковыми. На нашей стадии это особенно удобно: у нас нет пользователей, ролей и логинов (и мы не хотим внезапно строить половину Spring Security только ради одной строчки). Поэтому для курса самый прагматичный тип — String: например "system", "manual", "migration", "catalog-service".
Ниже — минимальный фрагмент entity-полей, который показывает две роли и аккуратный mapping. Обратите внимание: createdBy логично сделать updatable = false, потому что «автор создания» не должен переписываться при каждом изменении цены.
import jakarta.persistence.Column;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;
// Заполняется один раз при создании сущности (обычно перед INSERT)
@CreatedBy
@Column(name = "created_by", updatable = false, length = 50) // updatable=false: "автор создания" не должен переписываться на UPDATE
private String createdBy;
// Обновляется при каждом изменении сущности (обычно перед UPDATE)
@LastModifiedBy
@Column(name = "last_modified_by", length = 50)
private String lastModifiedBy;
Важный нюанс, который спасает нервы: эти поля могут остаться null, и это нормально. Если AuditorAware вернёт Optional.empty(), Spring не станет «придумывать автора» за вас. Поэтому на уровне схемы БД такие колонки часто делают nullable, особенно при постепенном внедрении auditing в уже существующий проект (а мы как раз в проекте уже не на «пустой базе»).
3. Источник автора: контракт AuditorAware<T>
На этом месте у новичков обычно возникает вопрос: «Окей, аннотация говорит что заполнять, но откуда берётся значение?» Spring Data JPA не телепат, он не читает ваши мысли и не угадывает пользователя по выражению лица JVM. Для этого существует интерфейс AuditorAware<T>. Это простой контракт: в момент, когда нужно поставить автора, Spring вызывает ваш код и спрашивает «кто текущий автор?». Вы отвечаете Optional<T> — либо значение, либо «не знаю».
Интерфейс выглядит так (упрощённо по смыслу):
- есть метод getCurrentAuditor()
- он возвращает Optional<T>
- T — это ваш тип автора (у нас будет String)
Ключевой смысл в том, что AuditorAware должен быть максимально лёгким и предсказуемым. Это не место для походов в базу или сложных вычислений. В нормальном приложении AuditorAware берёт автора из контекста выполнения (например, из security-контекста). В нашем проекте пока нет настоящей модели пользователя, поэтому мы выбираем честный и понятный источник: «все изменения делает система» или «источник берём из простой настройки».
И ещё один момент: Optional здесь не «чтобы усложнить», а чтобы сделать поведение явным. Если автор неизвестен, лучше оставить createdBy = null, чем записать туда случайную строку и потом год удивляться, почему в базе половина записей создана пользователем "undefined" (да, это не шутка, это реальная классика).
4. Минимальный AuditorAware<String> для shop-data-jpa
На практике подключение author-auditing состоит из двух шагов. Первый — сказать @EnableJpaAuditing, что у нас есть bean, который умеет отдавать текущего автора. Второй — реализовать этот bean максимально просто и стабильно. И вот здесь важно не переусердствовать: мы сейчас в data-layer курсе, а не в «строим IAM-систему на 15 микросервисов».
Включаем auditing с auditorAwareRef
Если в прошлой лекции вы включали auditing так:
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing // включаем auditing-инфраструктуру (createdAt/updatedAt и т.п.)
public class JpaAuditConfig {
}
то для author-полей мы добавляем ссылку на bean через auditorAwareRef:
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
// auditorAwareRef указывает на бин, который отвечает на вопрос "кто текущий автор?"
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
public class JpaAuditConfig {
}
Здесь имя "auditorAware" — это не «магическое слово вселенной», а ссылка на конкретный Spring bean с таким именем.
Самый минимальный вариант: всегда возвращать "system"
Для учебного проекта это честный и рабочий старт. Он даёт вам корректные заполненные поля, не требует никаких контекстов, не ломается при многопоточности и не просит вас объяснить, кто такой «пользователь» в системе, где пользователей пока нет.
import java.util.Optional;
import org.springframework.data.domain.AuditorAware;
import org.springframework.stereotype.Component;
@Component("auditorAware") // важно: имя должно совпасть с auditorAwareRef
public class FixedAuditorAware implements AuditorAware<String> {
@Override
public Optional<String> getCurrentAuditor() {
// Самый простой сценарий для курса: все изменения делаем "от имени системы"
return Optional.of("system");
}
}
Да, это выглядит почти слишком просто. Но в этом и смысл: сначала мы учимся корректно подключать механизм, а не строим «идеальный авторский след» на песке.
Источник из настройки
Иногда удобно, чтобы в dev-окружении вы видели, что данные «подправлялись руками» или «создавались через учебный seed». Тогда можно прочитать значение из property. Это всё ещё не про пользователей, это про источник.
import java.util.Optional;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.AuditorAware;
import org.springframework.stereotype.Component;
@Component("auditorAware") // имя бина снова важно из-за auditorAwareRef
public class PropertyAuditorAware implements AuditorAware<String> {
private final String source;
public PropertyAuditorAware(@Value("${shop.audit.source:system}") String source) {
// Берём источник из application.yml, а если не задан — используем "system"
this.source = source;
}
@Override
public Optional<String> getCurrentAuditor() {
// Возвращаем стабильный, человекочитаемый "источник" изменений
return Optional.of(source);
}
}
Если в application-dev.yml вы поставите shop.audit.source: manual, то все записи будут иметь автора "manual". Это не супер-точно, но уже практично: вы начинаете различать происхождение данных, не трогая тему пользователей.
5. Где хранить author-поля
После появления createdBy и lastModifiedBy у вас очень быстро появится соблазн: «О! Давайте засунем их в наш BaseAuditEntity и забудем». Иногда это реально нормально. Но помните идею дня: не всем сущностям нужен одинаковый набор полей. Если вы сделаете большой базовый класс «на все случаи жизни», вы получите больше шума, чем пользы, и начнёте тащить auditing-поля туда, где их никто не читает. Для текущего baseline mini-shop этого делать не нужно: базовая конфигурация проекта спокойно обходится временем изменения, а author-поля остаются точечной дополнительной веткой.
Самый спокойный подход для нашего проекта выглядит так: BaseAuditEntity остаётся тонким и отвечает только за время (createdAt, updatedAt) и подключение listener’а, а author-поля вы добавляете только в те сущности, где это действительно помогает расследовать данные. Например, для CustomerOrder это может быть полезно (заказы часто создаются/меняются в разных сценариях), а для каких-нибудь редких справочников — уже спорно.
Ниже — пример, как добавить author-поля прямо в сущность, которая уже наследует базовый auditing-класс. За счёт того, что listener уже подключён (например, на базовом классе), поля будут заполняться автоматически.
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.LastModifiedBy;
@Entity
public class CustomerOrder extends BaseAuditEntity {
@Id
private Long id;
@CreatedBy
@Column(updatable = false, length = 50) // при создании запишется один раз, дальше не меняем
private String createdBy;
@LastModifiedBy
@Column(length = 50) // будет обновляться при каждом UPDATE
private String lastModifiedBy;
}
Если окажется, что author-поля нужны почти всем сущностям, можно перенести их в базовый класс. Но базовый audit-контракт mini-shop на этом не завязан: сначала достаточно увидеть механику на 1–2 сущностях и понять, есть ли от неё реальная польза.
6. Когда заполняются createdBy и lastModifiedBy
Снаружи auditing выглядит так, будто значения «появляются сами». Но мы уже достаточно взрослые (как минимум по меркам persistence context), чтобы не верить в спонтанное самозарождение данных. На самом деле всё происходит в понятные моменты: при persist/создании сущности и при обновлении сущности. Причём обновление не обязано выглядеть как явный save(): если сущность managed и вы изменили её поле в транзакции, сработает dirty checking, затем Hibernate сформирует UPDATE, и перед этим auditing проставит updatedAt/lastModifiedBy.
Удобно представить это в виде маленькой последовательности событий:
sequenceDiagram
participant S as "Service @Transactional"
participant PC as "Persistence Context"
participant A as "AuditingEntityListener"
participant DB as "PostgreSQL"
S->>PC: загружаем entity (managed)
S->>PC: меняем поле (dirty)
S->>PC: транзакция завершается (commit)
PC->>A: "before flush/update: заполни updatedAt/lastModifiedBy"
PC->>DB: "UPDATE ... updated_at=?, last_modified_by=? WHERE id=?"
С практической стороны это означает два важных вывода.
Первый вывод: если вы ожидали, что lastModifiedBy изменится «сразу после setter’а», вы будете разочарованы. Он обновится тогда, когда произойдёт реальная синхронизация с БД — чаще всего на flush перед коммитом. Это полностью соответствует нашей прошлой теме про flush и «SQL уходит в базу не всегда немедленно».
Второй вывод: author-auditing отлично сочетается с нашим стилем сервисов, где мы делаем «чистую бизнес-правку» без лишних save() после каждого изменения. Например, такой код будет корректно менять и бизнес-поле, и updatedAt, и lastModifiedBy:
import java.math.BigDecimal;
import org.springframework.transaction.annotation.Transactional;
@Transactional
public void changePrice(Long productId, BigDecimal newPrice) {
// 1) Загружаем managed-сущность
Product p = productRepository.findById(productId).orElseThrow();
// 2) Меняем бизнес-поле
p.setPrice(newPrice);
// 3) Явного save() не нужно: dirty checking + auditing сработают на flush/commit
}
А SQL (упрощённо по смыслу) будет выглядеть примерно так:
-- Hibernate обновит и бизнес-колонку, и audit-колонки в одном UPDATE
update product
set price = ?, updated_at = ?, last_modified_by = ?
where id = ?;
То есть auditing добавляет «служебные колонки» в те же INSERT/UPDATE, которые и так происходят.
7. Типичные ошибки в author-auditing
Author-auditing выглядит простым, но ошибки здесь довольно коварные: всё компилируется, приложение стартует, а в базе внезапно пустые поля или странные значения. Ниже — самые частые грабли, на которые наступают даже аккуратные разработчики (а уж мы, как будущие аккуратные разработчики, должны наступать на них только один раз и исключительно в учебных целях).
Ошибка №1: ожидать, что createdBy начнёт заполняться просто от аннотации на поле.
Часто ставят @CreatedBy и @LastModifiedBy, но забывают, что auditing — это инфраструктура. Если не включён @EnableJpaAuditing или не подключён AuditingEntityListener (на сущности или на @MappedSuperclass), то Spring Data просто не будет запускать аудит-механику. В результате поля останутся null, и вы будете смотреть на них с выражением лица «но я же поставил аннотацию!».
Ошибка №2: подключить auditorAwareRef, но не создать bean с таким именем.
Ссылка auditorAwareRef = "auditorAware" — это жёсткая привязка к имени бина. Если вы сделали @Component без имени и в итоге бин называется как-то иначе, auditing не найдёт ваш AuditorAware. В зависимости от настроек вы получите либо ошибку при старте, либо тихо неработающий author-auditing. Самый надёжный способ в учебном проекте — явно назвать бин: @Component("auditorAware").
Ошибка №3: путать createdBy с бизнес-данными, например с customerEmail.
Это очень «человеческая» ошибка: раз customerEmail уже есть, хочется написать его же в createdBy, потому что «заказ же для клиента». Но логика тут другая. customerEmail — это данные о заказчике. createdBy — это данные об источнике изменения в системе. В реальном приложении заказ может создать оператор, интеграция, автоматический процесс или сам клиент. Смешивать эти роли — значит ломать смысл данных, а потом ещё и пытаться чинить отчёты, которые внезапно считают «кто создавал заказы».
Ошибка №4: пытаться заменить @Version author-полями или временем.
Иногда хочется думать: «Ну раз я знаю updatedAt, значит я защищён от конфликтов». Нет. updatedAt и lastModifiedBy — это метаданные. Они не защищают от lost update. Защита — это @Version и optimistic locking, потому что там конфликт проверяется на уровне WHERE version = ? в UPDATE. Так что не стоит «сэкономить одно поле версии» ценой потерянных обновлений и очень грустных остатков на складе.
Ошибка №5: делать автора «случайным» или нестабильным значением.
Пока нет security-контекста, очень легко начать подставлять в AuditorAware какие-нибудь «временные» значения: текущий timestamp, random UUID, имя потока, фазу луны. В итоге createdBy превращается в набор случайных строк, которые никто не сможет интерпретировать. Если вы не можете дать стабильную семантику автора — лучше честно вернуть Optional.empty() и оставить поле пустым, чем заполнить его мусором, который будет мешать и вам, и базе данных.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ