feat(rest-api): backend enforcement для x-unique и x-id-source schema extensions

x-id-source (auto-derive businessKey):
- DictionaryRecordService.resolveBusinessKey() читает schemaJson['x-id-source'].
- Если задан — businessKey берётся из data[idSource]. Если клиент явно прислал
  и значения не совпадают → 400 business_key_mismatch (защита от рассинхрона).
- Если поле в data пустое → 400 id_source_missing.
- Если x-id-source не задан и businessKey пуст → 400 business_key_required.

x-unique (constraint на дубли значений):
- DictionaryRecordRepository.findActiveByJsonField() — native SQL c data->>field.
- DictionaryRecordService.enforceUniqueFields() walks schema.properties, для
  каждого property с x-unique=true ищет активные записи с тем же значением.
  Исключает current record на update.
- Конфликт → 409 unique_field_violation с указанием business_key конфликтующей.

Делается на каждом create/update в той же SERIALIZABLE транзакции что и save —
без отдельного DB constraint (для MVP). Partial unique index per-field —
оптимизация на потом, когда будет много справочников с x-unique.
This commit is contained in:
Zimin A.N.
2026-05-04 03:52:51 +03:00
parent b0746de128
commit c96b6706fc
2 changed files with 99 additions and 3 deletions
@@ -76,17 +76,21 @@ public class DictionaryRecordService {
OffsetDateTime validFrom = req.validFrom() == null ? OffsetDateTime.now() : req.validFrom();
OffsetDateTime validTo = req.validTo() == null ? FAR_FUTURE : req.validTo();
if (repository.findActiveAt(d.getId(), req.businessKey(), validFrom).isPresent()) {
String businessKey = resolveBusinessKey(d, req);
if (repository.findActiveAt(d.getId(), businessKey, validFrom).isPresent()) {
throw OrdinisException.conflict(
"record_already_active",
"Запись с business_key=" + req.businessKey() + " уже активна на " + validFrom +
"Запись с business_key=" + businessKey + " уже активна на " + validFrom +
". Используйте PUT (update) для новой версии.");
}
enforceUniqueFields(d, req.data(), validFrom, null);
var record = new DictionaryRecord(
UUID.randomUUID(),
d.getId(),
req.businessKey(),
businessKey,
req.data(),
d.getScope(),
validFrom);
@@ -114,6 +118,8 @@ public class DictionaryRecordService {
var prevData = current.getData();
UUID previousRecordId = current.getId();
enforceUniqueFields(d, req.data(), newValidFrom, current.getId());
current.setValidTo(newValidFrom);
// saveAndFlush обязателен: иначе Hibernate отложит UPDATE до commit, INSERT новой
// записи пойдёт первым → exclusion constraint dictionary_records_no_overlap бьёт,
@@ -158,6 +164,78 @@ public class DictionaryRecordService {
if (!vr.valid()) throw new ValidationException(vr.errors());
}
/**
* Если в schema задано {@code x-id-source: <field>}, бизнес-ключ автоматически
* берётся из {@code data[field]}. Если клиент явно прислал businessKey — он
* должен совпадать, иначе 422 (защита от рассинхронизации).
*/
private String resolveBusinessKey(DictionaryDefinition d, CreateRecordRequest req) {
var schema = d.getSchemaJson();
var idSourceNode = schema == null ? null : schema.get("x-id-source");
if (idSourceNode == null || !idSourceNode.isTextual() || idSourceNode.asText().isBlank()) {
if (req.businessKey() == null || req.businessKey().isBlank()) {
throw OrdinisException.badRequest(
"business_key_required",
"businessKey обязателен (в schema нет x-id-source).");
}
return req.businessKey();
}
String fieldName = idSourceNode.asText();
var valueNode = req.data() == null ? null : req.data().get(fieldName);
if (valueNode == null || valueNode.isNull()) {
throw OrdinisException.badRequest(
"id_source_missing",
"Поле x-id-source='" + fieldName + "' пустое в data — бизнес-ключ невозможно вывести.");
}
String derived = valueNode.isTextual() ? valueNode.asText() : valueNode.toString();
if (req.businessKey() != null && !req.businessKey().isBlank()
&& !req.businessKey().equals(derived)) {
throw OrdinisException.badRequest(
"business_key_mismatch",
"businessKey='" + req.businessKey() + "' не совпадает с x-id-source='"
+ fieldName + "' value='" + derived + "'.");
}
return derived;
}
/**
* Для каждого property с {@code x-unique: true} ищет другие активные записи
* (на момент {@code at}) с тем же значением. Исключает текущую версию (для
* update). Бьёт 409 при дубликате.
*/
private void enforceUniqueFields(
DictionaryDefinition d,
com.fasterxml.jackson.databind.JsonNode data,
OffsetDateTime at,
UUID excludeRecordId) {
if (data == null) return;
var schema = d.getSchemaJson();
if (schema == null) return;
var properties = schema.get("properties");
if (properties == null || !properties.isObject()) return;
var fieldNames = properties.fieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
var fieldSchema = properties.get(fieldName);
var unique = fieldSchema == null ? null : fieldSchema.get("x-unique");
if (unique == null || !unique.asBoolean(false)) continue;
var valueNode = data.get(fieldName);
if (valueNode == null || valueNode.isNull()) continue;
String value = valueNode.isTextual() ? valueNode.asText() : valueNode.toString();
var conflicts = repository.findActiveByJsonField(d.getId(), at, fieldName, value);
for (var c : conflicts) {
if (excludeRecordId != null && c.getId().equals(excludeRecordId)) continue;
throw OrdinisException.conflict(
"unique_field_violation",
"Поле '" + fieldName + "' помечено x-unique. Значение '" + value
+ "' уже используется в записи business_key='" + c.getBusinessKey() + "'.");
}
}
}
private Geometry parseWkt(String wkt) {
if (wkt == null || wkt.isBlank()) return null;
try {