Дата: 2026-04-18
Статус: Accepted, ready for implementation
Scope: crcm-protocol-service — ядро pipeline генерации протокола + task-layer для async-оркестрации.
Документ собирает в единую картину все архитектурные решения Phase 5 (ядро генерации) и точную раскладку будущего кода. Является результатом разведки референса OBESRABUO_protocol и серии ADR-0008..0013. Может быть взят в wiki.js как autonomous artifact.
ProtocolService.protocolByAgendaExtracts в исходном OBESRABUO_protocol — God-метод на 800+ строк, смешивающий:
StructuredTaskScope,Переписывание 2.0 преследует цели:
Все принятые архитектурные решения зафиксированы в docs/decisions/:
| ADR | Суть |
|---|---|
| 0008 | PRKK-выписки сохраняют оригинальное форматирование. Phase 3 branching, Phase 4 selective-normalize merge. Feature flag PRKK_EXTRACT_SKIP_FORMATTING как safety-valve в common (default true). |
| 0009 | Fail-fast. Никаких "красных текстов", partial-generation, silent failures. StructuredTaskScope.Joiner.allSuccessfulOrThrow(). Protocol entity создаётся только после успеха всех фаз. |
| 0010 | Task-based primary pattern. Sync v1 — transition bridge для agenda-state-machine. 3-фазная миграция (M1 ship 2.0 / M2 agenda event-driven / M3 cleanup sync v1). |
| 0011 | Row-level access check для secret questions. accessForbidden=true → DomainException.Forbidden (HTTP 403) в ACL-адаптере. Ядро не видит forbidden-вопросы. |
| 0012 | Context propagation через Micrometer ContextSnapshot.captureAll() / setThreadLocals(). Автоматически SecurityContext + MDC + Observation. Никаких ручных ScopedValue-мостов. |
| 0013 | Task entity детали. UUID taskId, createdBy, soft ownership, cleanup через scheduled job. Без legacy-полей progress/modifiedQuestionsJson. |
protocol/port/; реализации — integration/, extract/.extract зависит от protocol (реализует его порты).DomainException.Integration), security — на границе.List.copyOf.StructuredTaskScope.Joiner.allSuccessfulOrThrow() для parallel I/O.POST /api/v2/protocol/merge/byAgenda/{agendaId}/task
→ TaskService.submit → 202 Accepted (taskId, PENDING)
→ executor.execute(() -> runGeneration(taskId, agendaId))
runGeneration:
updateStatus(taskId, IN_PROGRESS)
protocolId = ProtocolGenerator.generate(agendaId)
┌─ Phase 1 — FETCH_VALIDATE ─────────────────────────────┐
│ AgendaProvider.loadSnapshot(agendaId) │
│ internally: 3 parallel Feign (agenda + questions │
│ + votes) через StructuredTaskScope │
│ Validation: not empty, split PRKK vs regular │
│ → AfterPhase1 │
└────────────────────────┬───────────────────────────────┘
│
┌────────────────────────▼───────────────────────────────┐
│ Phase 2 — SIMPLE_EXTRACTS │
│ a) Regular: ExtractProvider.findByAgenda(id) │
│ → cache-hit existing extracts │
│ diff → missing → ExtractSynthesizer.ensureFor(...) │
│ → parallel gen + batch save + idempotency │
│ b) PRKK: create SHORT-placeholder entities │
│ (pointer to prkkExtractFileId, не saved) │
│ → AfterPhase2 (simpleExtracts: regular + PRKK-ph) │
└────────────────────────┬───────────────────────────────┘
│
┌────────────────────────▼───────────────────────────────┐
│ Phase 3 — SHORT_EXTRACTS + PRKK strip │
│ a) Regular SIMPLE → SHORT: ExtractSynthesizer │
│ .synthesizeShortExtracts(regulars, ctx) │
│ → parallel via StructuredTaskScope │
│ b) PRKK: PrkkExtractStripper.stripAll(prkks, ...) │
│ → Map<UUID, byte[]> │
│ → AfterPhase3 │
└────────────────────────┬───────────────────────────────┘
│
┌────────────────────────▼───────────────────────────────┐
│ Phase 4 — BUILD_PROTOCOL │
│ ProtocolAssembler: │
│ 1. Sort shortExtracts by questionNumber │
│ 2. Resolve strategy (Bank/Group/Smb/GoSmb) │
│ по CommitteeType │
│ 3. SelectiveMerger.merge(regularShort, prkkBytes) │
│ → per-extract normalization: regular yes, │
│ PRKK as-is │
│ 4. Strategy.buildReportData(agenda, questions, ...) │
│ 5. ReportGenerator.stampTemplate(templatePath, data) │
│ 6. EcmStorageAdapter.saveProtocolFile(bytes, name) │
│ 7. ProtocolCrudService.create(...) │
│ 8. ProtocolEventsProvider.notifyGenerationCompleted │
│ → UUID protocolId │
└────────────────────────┬───────────────────────────────┘
│
markCompleted(taskId, protocolId)
↘ on any failure: markFailed(taskId, e.getMessage())
Позже клиент: GET /api/v2/protocol/task/{taskId}/status
→ TaskStatusResponse { status, protocolId, errorMessage, ... }
crcm-protocol-service/modules/
│
├── core/src/main/java/ru/vtb/protocol/core/
│ ├── domain/ [существует]
│ │ ├── Agenda.java + nested Chair, Secretary, Participant
│ │ ├── Question.java + nested WorkflowFlags, Member, Counterparty
│ │ ├── Vote.java, Extract.java, AgendaSnapshot.java, QuestionFile.java
│ │ └── error/DomainException.java (с Forbidden)
│ ├── port/FeatureFlagProvider.java
│ └── persistence/ (AbstractEntity, ProtocolEntity, ExtractEntity)
│
├── document/src/main/java/ru/vtb/protocol/document/
│ ├── generation/ReportGenerator.java [существует]
│ ├── template/DocumentTemplateType.java [существует]
│ ├── prkk/PrkkExtractStripper.java [NEW]
│ └── merge/SelectiveMerger.java [NEW]
│
├── protocol/src/main/java/ru/vtb/protocol/protocol/
│ ├── port/ [существует]
│ │ ├── AgendaProvider.java, QuestionFileProvider.java
│ │ ├── ExtractProvider.java, ExtractSynthesizer.java
│ │ ├── ProtocolEventsProvider.java [NEW]
│ │ └── ProtocolStreamingProvider.java [NEW]
│ ├── service/ [частично существует]
│ │ ├── ProtocolCrudService.java, ProtocolHistoryService.java
│ │ ├── ProtocolSpecifications.java
│ │ ├── ProtocolGenerator.java [NEW] — 4-phase orchestrator
│ │ ├── ProtocolAssembler.java [NEW] — Phase 4
│ │ ├── ProtocolNameFormatter.java [NEW] package-private
│ │ ├── ProtocolGenerationTaskService.java [NEW]
│ │ ├── context/ProtocolGenerationContext.java [NEW] sealed hierarchy
│ │ └── report/
│ │ ├── ProtocolReportStrategy.java [NEW]
│ │ ├── ReportData.java [NEW] nested record
│ │ └── impl/
│ │ ├── BankProtocolReportStrategy.java
│ │ ├── GroupProtocolReportStrategy.java
│ │ ├── SmbProtocolReportStrategy.java
│ │ └── GoSmbProtocolReportStrategy.java
│ ├── persistence/
│ │ ├── ProtocolGenerationTaskEntity.java [NEW]
│ │ └── ProtocolGenerationTaskRepository.java [NEW]
│ └── api/
│ └── task/
│ ├── ProtocolTaskController.java [NEW]
│ ├── TaskResponse.java [NEW] record
│ └── TaskStatusResponse.java [NEW] record
│
├── extract/src/main/java/ru/vtb/protocol/extract/
│ ├── service/ (CrudService, Generator, Specifications)
│ └── adapter/
│ ├── ExtractProviderAdapter.java [NEW, Phase 6]
│ └── ExtractSynthesizerAdapter.java [NEW, Phase 6]
│
├── integration/src/main/java/ru/vtb/protocol/integration/
│ ├── agenda/
│ │ ├── AgendaFeignClient.java [NEW, Phase 6]
│ │ ├── AgendaFeignAdapter.java [NEW, Phase 6]
│ │ ├── QuestionFileAdapter.java [NEW, Phase 6]
│ │ ├── AgendaMapper.java [NEW, Phase 6] (RLS-check)
│ │ └── dto/ ...
│ └── common/
│ ├── events/ProtocolEventsAdapter.java
│ ├── streaming/ProtocolStreamingAdapter.java
│ └── featureflags/FeatureFlagAdapter.java
│
└── app/src/main/java/ru/vtb/protocol/app/
├── config/
│ ├── AsyncConfig.java [NEW]
│ └── ContextPropagationConfig.java [NEW]
└── scheduler/
└── TaskCleanupScheduler.java [NEW]
ProtocolGenerationContext — sealed phase-typed hierarchypublic sealed interface ProtocolGenerationContext {
UUID agendaId();
Agenda agenda();
record AfterPhase1(
UUID agendaId,
Agenda agenda, // ACL verified, no accessForbidden
List<Question> questions,
Map<UUID, Question> questionsById,
List<Question> prkkQuestions,
List<Question> regularQuestions,
Map<UUID, List<Vote>> votesByQuestion
) implements ProtocolGenerationContext {
public AfterPhase1 {
Objects.requireNonNull(agenda);
questions = List.copyOf(questions);
questionsById = Map.copyOf(questionsById);
prkkQuestions = List.copyOf(prkkQuestions);
regularQuestions = List.copyOf(regularQuestions);
votesByQuestion = Map.copyOf(votesByQuestion);
}
public boolean isPrkk(UUID questionId) { ... }
}
record AfterPhase2(
AfterPhase1 phase1,
List<Extract> simpleExtracts // regular + PRKK placeholders
) implements ProtocolGenerationContext {
@Override public UUID agendaId() { return phase1.agendaId(); }
@Override public Agenda agenda() { return phase1.agenda(); }
}
record AfterPhase3(
AfterPhase2 phase2,
List<Extract> shortExtracts,
Map<UUID, byte[]> prkkStrippedBytes
) implements ProtocolGenerationContext {
@Override public UUID agendaId() { return phase2.agendaId(); }
@Override public Agenda agenda() { return phase2.agenda(); }
}
}
Зачем sealed phase-typed: компилятор гарантирует что Phase 3 не вызовется до Phase 2 — сигнатура принимает только AfterPhase2. Против nullable-полей в одном record'е как в референсе.
ProtocolGenerator — оркестратор@Service @RequiredArgsConstructor @Slf4j
public class ProtocolGenerator {
private final AgendaProvider agendaProvider;
private final ExtractProvider extractProvider;
private final ExtractSynthesizer extractSynthesizer;
private final PrkkExtractStripper prkkStripper;
private final ProtocolAssembler assembler;
private final ProtocolCrudService crud;
private final ProtocolEventsProvider events;
public UUID generate(UUID agendaId) {
var phase1 = fetchAndValidate(agendaId);
var phase2 = synthesizeSimpleExtracts(phase1);
var phase3 = produceShortExtractsAndPrkk(phase2);
var protocolFile = assembler.assemble(phase3);
var protocol = crud.create(new ProtocolCreateRequest(agendaId, Status.CREATED));
events.notifyGenerationCompleted(protocol.id(), agendaId);
return protocol.id();
}
private AfterPhase1 fetchAndValidate(UUID agendaId) { ... }
private AfterPhase2 synthesizeSimpleExtracts(AfterPhase1 ctx) { ... }
private AfterPhase3 produceShortExtractsAndPrkk(AfterPhase2 ctx) { ... }
}
Parallelism + batch-DB — инкапсулированы за портами AgendaProvider / ExtractSynthesizer. Generator остаётся тонким оркестратором.
ProtocolAssembler — Phase 4@Service @RequiredArgsConstructor @Slf4j
public class ProtocolAssembler {
private final List<ProtocolReportStrategy> strategies;
private final SelectiveMerger selectiveMerger;
private final ReportGenerator reportGenerator;
private final EcmStorageAdapter ecmStorage;
public ProtocolFile assemble(AfterPhase3 ctx) {
var strategy = resolveStrategy(ctx.agenda().type());
var sortedShort = ctx.shortExtracts().stream()
.sorted(Comparator.comparingInt(e -> questionNumber(e, ctx)))
.toList();
var merged = selectiveMerger.merge(sortedShort, ctx.prkkStrippedBytes());
var reportData = strategy.buildReportData(ctx.agenda(), ctx.questions(), sortedShort)
.withDocumentPath(merged.path());
var bytes = reportGenerator.stampTemplate(strategy.templatePath(), reportData);
var fileName = ProtocolNameFormatter.format(ctx.agenda());
return ecmStorage.saveProtocolFile(bytes, fileName);
}
private ProtocolReportStrategy resolveStrategy(CommitteeType type) {
return strategies.stream()
.filter(s -> s.supports(type))
.findFirst()
.orElseThrow(() -> new DomainException.ContractMismatch(
ExternalSystem.AGENDA, "reportStrategy",
"No strategy for CommitteeType " + type));
}
}
ProtocolReportStrategy + ReportDatapublic interface ProtocolReportStrategy {
boolean supports(CommitteeType type);
String templatePath();
ReportData buildReportData(Agenda agenda, List<Question> questions, List<Extract> shortExtracts);
}
public record ReportData(
Header header,
Participants participants,
List<QuestionSection> questions,
String documentPath // set later via withDocumentPath
) {
public ReportData withDocumentPath(String path) { ... }
public record Header(
String committeeName,
Integer documentNumber,
LocalDate dateCommittee,
MeetingType formType,
boolean confidential
) {}
public record Participants(
Agenda.Chair chair,
Agenda.Secretary secretary,
List<String> attendedNames,
List<String> absentNames
) {}
public record QuestionSection(
int number,
String name,
String resolution,
String approvedAgency,
List<VoteEntry> votes
) {
public record VoteEntry(
String voter,
VoteDecision decision,
String resolution // мотивировка при DECLINE
) {}
}
}
Разбиение ReportData по секциям шаблона, не по типу комитета. Все 4 стратегии возвращают один record, отличаются внутренней логикой сборки.
ProtocolNameFormatter — дословная копия реф-форматаФормат:
ПРОТОКОЛ № {zero-pad-if-<10} от {dd.MM.yyyy Europe/Moscow} ({ОЧНЫЙ|ЗАОЧНЫЙ}{_ЗАЩИЩЕННЫЙ}?).docx
Edge-cases:
agenda == null → "[Ошибка при формировании имени протокола].docx"documentNumber == null → "... № [Неизвестно] ..."dateCommittee == null → "... от [Неизвестно] ..."formType неизвестен → "[" + formType + "]"MeetingType-форма "ОЧНЫЙ"/"ЗАОЧНЫЙ" — hardcode switch внутри formatter'а (не добавляем третье поле в enum — это презентационная деталь filename format).
ProtocolGenerationTaskEntity@Entity @Table(name = "protocol_generation_task")
public class ProtocolGenerationTaskEntity extends AbstractEntity {
@Id
private UUID taskId; // NOT String
@Column(nullable = false)
private UUID agendaId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private TaskStatus status; // PENDING | IN_PROGRESS | COMPLETED | FAILED
@Column(name = "protocol_id")
private UUID protocolId; // set on COMPLETED
@Column(columnDefinition = "TEXT")
private String errorMessage; // set on FAILED
@Column(nullable = false, length = 255)
private String createdBy; // login, soft ownership
@Column(nullable = false) private Instant createdAt;
@Column(nullable = false) private Instant updatedAt;
@Column private Instant completedAt;
// Убрано: progress, modifiedQuestionsJson (legacy Aspose)
}
ProtocolGenerationTaskService@Service @RequiredArgsConstructor
public class ProtocolGenerationTaskService {
private final ProtocolGenerationTaskRepository repository;
private final ProtocolGenerator generator;
@Qualifier("protocolTaskExecutor") private final Executor executor;
@Transactional
public UUID submit(UUID agendaId) {
var taskId = UUID.randomUUID();
var createdBy = SecurityContext.currentUserLogin();
repository.save(new ProtocolGenerationTaskEntity(
taskId, agendaId, TaskStatus.PENDING, createdBy, Instant.now()));
executor.execute(() -> runGeneration(taskId, agendaId));
return taskId;
}
private void runGeneration(UUID taskId, UUID agendaId) {
MDC.put("taskId", taskId.toString());
try {
markInProgress(taskId);
UUID protocolId = generator.generate(agendaId); // 4 фазы
markCompleted(taskId, protocolId);
} catch (Exception e) {
markFailed(taskId, e.getMessage());
// НЕ throw — fire-and-forget, клиент узнает через polling
}
}
public ProtocolGenerationTaskEntity getTask(UUID taskId) {
var task = repository.findById(taskId)
.orElseThrow(() -> new DomainException.NotFound("task", taskId));
logOwnershipMismatch(task); // soft check, WARN only
return task;
}
@Transactional private void markInProgress(UUID taskId) { ... }
@Transactional private void markCompleted(UUID taskId, UUID protocolId) { ... }
@Transactional private void markFailed(UUID taskId, String errorMessage) { ... }
private void logOwnershipMismatch(ProtocolGenerationTaskEntity task) {
var current = SecurityContext.currentUserLogin();
if (!Objects.equals(current, task.getCreatedBy())) {
log.warn("Task {} accessed by {} (owner: {})",
task.getTaskId(), current, task.getCreatedBy());
}
}
}
AsyncConfig@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("protocolTaskExecutor")
public Executor protocolTaskExecutor() {
var vt = Executors.newVirtualThreadPerTaskExecutor();
return new ContextPropagatingTaskDecorator(vt);
// Автоматически snapshot'ит SecurityContext + MDC + Observation
// См. ADR-0012
}
}
TaskCleanupScheduler@Component @RequiredArgsConstructor @Slf4j
public class TaskCleanupScheduler {
private final ProtocolGenerationTaskRepository repository;
@Value("${protocol.task.retention-days:7}")
private int retentionDays;
@Scheduled(cron = "0 0 3 * * *") // 03:00 ежедневно
@Transactional
public void cleanupOldTasks() {
var cutoff = Instant.now().minus(retentionDays, ChronoUnit.DAYS);
var deleted = repository.deleteByStatusInAndCompletedAtBefore(
Set.of(TaskStatus.COMPLETED, TaskStatus.FAILED), cutoff);
log.info("Cleaned up {} old protocol tasks (older than {} days)", deleted, retentionDays);
}
}
POST /api/v2/protocol/merge/byAgenda/{agendaId}/task
Body: пусто
202 Accepted → { taskId: UUID, status: PENDING }
GET /api/v2/protocol/task/{taskId}/status
200 OK → {
taskId: UUID, status: TaskStatus,
protocolId?: UUID, errorMessage?: String,
createdAt, updatedAt, completedAt?
}
404 если task не найден (DomainException.NotFound → ProblemDetail)
Убрано vs реф:
POST /api/v2/.../byAgenda/{id} (sync v2) — dead endpoint.POST /api/v2/.../byAgenda/{id}/async (async без task-tracking) — deprecated, удалим после миграции фронта.Сохранено legacy:
POST /api/v1/protocol/merge/byAgenda/{id} (sync v1) — transition bridge для agenda-state-machine. Внутренне: submit task + poll status (timeout 12 min) → возвращает готовый protocol или 504 с taskId.@RestController @RequestMapping("/api/v1/protocol")
public class ProtocolLegacyController {
@PostMapping("/merge/byAgenda/{agendaId}")
@Deprecated
public ResponseEntity<ProtocolResponse> generateSync(@PathVariable UUID agendaId) {
log.warn("Legacy sync v1 endpoint called for agendaId={}, caller should migrate to task-based", agendaId);
// submit task + blocking poll with 12-min timeout → вернуть ProtocolResponse или 504
...
}
}
Каждый шаг — один commit, mvn compile зелёный. Адаптеры отложены на Phase 6.
| # | Шаг | Что создаём | Dependencies |
|---|---|---|---|
| 1 | Context | ProtocolGenerationContext sealed + AfterPhase1/2/3 |
core domain |
| 2 | Report | ReportData + ProtocolReportStrategy interface |
core domain |
| 3 | Strategies | 4 report strategies (Bank/Group/Smb/GoSmb) | #2 |
| 4 | Formatter | ProtocolNameFormatter (package-private) |
core domain |
| 5 | Document ops | SelectiveMerger + PrkkExtractStripper |
core + common-lib docx4j |
| 6 | Assembler | ProtocolAssembler |
#2-5 |
| 7 | Generator | ProtocolGenerator + ProtocolEventsProvider/ProtocolStreamingProvider interfaces |
все ports + #1 + #6 |
| 8 | Task entity | ProtocolGenerationTaskEntity + Repository + ProtocolGenerationTaskService + AsyncConfig |
#7 |
| 9 | Task API | ProtocolTaskController + TaskResponse/TaskStatusResponse records |
#8 |
| 10 | Legacy bridge | ProtocolLegacyController (sync v1 poll-loop) |
#8 |
| 11 | Cleanup | TaskCleanupScheduler + property protocol.task.retention-days |
Repository |
После Phase 5 ядро компилируется против портов и покрывается unit-тестами с моками. Адаптеры — Phase 6.
AgendaFeignAdapter, ExtractSynthesizerAdapter, ProtocolEventsAdapter etc.) — Phase 6.ProtocolController create/update/delete) — Phase 7.crcm-protocol-servicereference/OBESRABUO_protocol/ — переписываемый сервисreference/OBESRABUO_agenda/ — источник повесток и вопросовreference/OBESRABUO_common/ — events / feature flags / streamingreference/CRED_cred-question-api/ — источник вопросов (через agenda)