package jnpf.elnService.template.impl; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import jnpf.audit.model.AuditEventDTO; import jnpf.elnEntity.template.ElnTemplateBindingEntity; import jnpf.elnEntity.template.ElnTemplateCurrentVersionEntity; import jnpf.elnEntity.template.ElnTemplateForms; import jnpf.elnEntity.template.ElnTemplateVersionEntity; import jnpf.elnEntity.template.ElnTemplateVos; import jnpf.elnService.template.ElnTemplateHealthService; import jnpf.elnService.template.ElnTemplatePublishValidator; import jnpf.exception.DataException; import org.junit.Test; import org.springframework.dao.DuplicateKeyException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class ElnTemplatePublishServiceTest { @Test public void publishAggregatesEveryErrorBeforeRejectingWithoutAudit() { Fixture fixture = new Fixture(Arrays.asList( validator(diagnostic("ERROR", "FIRST_ERROR", "第一个错误")), validator(diagnostic("ERROR", "SECOND_ERROR", "第二个错误")))); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); DataException error = expectDataException(() -> fixture.service.publish("draft-1", "发布原因")); assertTrue(error.getMessage().contains("第一个错误")); assertTrue(error.getMessage().contains("第二个错误")); assertFalse(error.getMessage().contains("FIRST_ERROR")); assertFalse(error.getMessage().contains("SECOND_ERROR")); assertEquals("DRAFT", fixture.store.templates.get("draft-1").getBizStatus()); assertTrue(fixture.audits.isEmpty()); } @Test public void healthValidatorDownloadsAgainAndPromotesPendingAndPreReleaseToErrors() { AtomicInteger checks = new AtomicInteger(); ElnTemplateHealthService health = templateId -> { checks.incrementAndGet(); ElnTemplateHealthService.HealthReport report = new ElnTemplateHealthService.HealthReport(); report.setDiagnostics(Arrays.asList( healthDiagnostic("ERROR", "DUPLICATE_TAG", "标签重复"), healthDiagnostic("WARNING", "PENDING_BINDING", "绑定待确认"), healthDiagnostic("WARNING", "FIELD_VERSION_NOT_PUBLISHED", "字段版本预发布"))); return report; }; Fixture fixture = new Fixture(Arrays.asList( ElnTemplateCatalogServiceImpl.healthValidator(health))); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); DataException error = expectDataException(() -> fixture.service.publish("draft-1", "发布原因")); assertEquals(1, checks.get()); assertTrue(error.getMessage().contains("标签重复")); assertTrue(error.getMessage().contains("绑定待确认")); assertTrue(error.getMessage().contains("字段版本预发布")); assertFalse(error.getMessage().contains("DUPLICATE_TAG")); assertFalse(error.getMessage().contains("PENDING_BINDING")); assertFalse(error.getMessage().contains("FIELD_VERSION_NOT_PUBLISHED")); assertTrue(fixture.audits.isEmpty()); } @Test public void healthValidatorPromotesSingleCheckboxGroupMemberToPublishError() { ElnTemplateHealthService health = templateId -> { ElnTemplateHealthService.HealthReport report = new ElnTemplateHealthService.HealthReport(); report.setDiagnostics(Collections.singletonList(healthDiagnostic( "WARNING", "CHECKBOX_GROUP_SINGLE_MEMBER", "选择组“test_result”只有一个成员,发布前需要补充成员"))); return report; }; List diagnostics = ElnTemplateCatalogServiceImpl.healthValidator(health) .validate("tenant-1", "draft-1"); assertEquals(1, diagnostics.size()); assertEquals("CHECKBOX_GROUP_SINGLE_MEMBER", diagnostics.get(0).getCode()); assertEquals("ERROR", diagnostics.get(0).getSeverity()); assertEquals("选择组“test_result”只有一个成员,发布前需要补充成员", diagnostics.get(0).getMessage()); } @Test public void publishFirstVersionAlsoMakesItCurrentAndWritesFixedAudit() { Fixture fixture = new Fixture(Arrays.asList(validator())); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); fixture.service.publish("draft-1", "评审通过"); assertEquals("PUBLISHED", fixture.store.templates.get("draft-1").getBizStatus()); ElnTemplateCurrentVersionEntity current = fixture.store.currents.get("TMP-001"); assertEquals("tenant-1", current.getFTenantId()); assertEquals("draft-1", current.getDangqianMubanId()); assertEquals("HD_jyxm_0001", current.getJianyanXiangmuBianma()); assertEquals(Integer.valueOf(0), current.getFVersion()); assertEquals(1, fixture.audits.size()); AuditEventDTO audit = fixture.audits.get(0); assertEquals("ELN_TEMPLATE_PUBLISH", audit.getActionCode()); assertEquals("发布 ELN 模板版本", audit.getActionLabel()); assertEquals("eln", audit.getBizModule()); assertEquals("eln_template", audit.getBizType()); assertEquals("eln_template", audit.getTargetTable()); assertEquals("draft-1", audit.getTargetId()); assertEquals("评审通过", audit.getReason()); } @Test public void publishAdditionalVersionDoesNotChangeCurrentPointer() { Fixture fixture = new Fixture(Arrays.asList(validator())); fixture.addTemplate("published-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); fixture.addTemplate("draft-2", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); fixture.addCurrent("TMP-001", "published-1", 3); fixture.service.publish("draft-2", "发布新版本"); assertEquals("PUBLISHED", fixture.store.templates.get("draft-2").getBizStatus()); ElnTemplateCurrentVersionEntity current = fixture.store.currents.get("TMP-001"); assertEquals("published-1", current.getDangqianMubanId()); assertEquals(Integer.valueOf(3), current.getFVersion()); } @Test public void publishFirstRunnableVersionMakesItCurrentWhenOtherDraftsExist() { Fixture fixture = new Fixture(Arrays.asList(validator())); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); fixture.addTemplate("draft-2", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); fixture.service.publish("draft-2", "发布首个可运行版本"); assertEquals("draft-2", fixture.store.currents.get("TMP-001").getDangqianMubanId()); } @Test public void publishNonRunnableVersionDoesNotMakeItCurrent() { Fixture futureFixture = new Fixture(Arrays.asList(validator())); futureFixture.addTemplate( "future-1", "TMP-001", "DRAFT", "enabled", future(), oneDocx()); futureFixture.service.publish("future-1", "发布未来生效版本"); assertEquals("PUBLISHED", futureFixture.store.templates.get("future-1").getBizStatus()); assertTrue(futureFixture.store.currents.isEmpty()); Fixture disabledFixture = new Fixture(Arrays.asList(validator())); disabledFixture.addTemplate( "disabled-1", "TMP-001", "DRAFT", "disabled", past(), oneDocx()); disabledFixture.service.publish("disabled-1", "发布停用版本"); assertEquals("PUBLISHED", disabledFixture.store.templates.get("disabled-1").getBizStatus()); assertTrue(disabledFixture.store.currents.isEmpty()); } @Test public void publishFreezesDocxToDeterministicSnapshotBeforePublishing() { RecordingFileCopier copier = new RecordingFileCopier(); Fixture fixture = new Fixture(Arrays.asList(validator()), copier); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); fixture.service.publish("draft-1", "评审通过"); assertEquals(Collections.singletonList("template.docx->draft-1-published"), copier.copies); assertEquals("PUBLISHED", fixture.store.templates.get("draft-1").getBizStatus()); assertEquals(snapshotDocx("draft-1-published"), fixture.store.templates.get("draft-1").getMubanFile()); } @Test public void publishCopyFailureLeavesDraftAndOriginalAttachmentUntouched() { Fixture fixture = new Fixture(Arrays.asList(validator()), (attachments, targetId) -> { throw new DataException("模拟复制失败"); }); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); expectDataException(() -> fixture.service.publish("draft-1", "评审通过")); assertEquals("DRAFT", fixture.store.templates.get("draft-1").getBizStatus()); assertEquals(oneDocx(), fixture.store.templates.get("draft-1").getMubanFile()); assertTrue(fixture.audits.isEmpty()); } @Test public void publishValidationFailureLeavesDraftAndOriginalAttachmentUntouchedAfterCopy() { RecordingFileCopier copier = new RecordingFileCopier(); Fixture fixture = new Fixture(Arrays.asList( validator(diagnostic("ERROR", "HEALTH_FAILED", "快照体检失败"))), copier); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); expectDataException(() -> fixture.service.publish("draft-1", "评审通过")); assertEquals(Collections.singletonList("template.docx->draft-1-published"), copier.copies); assertEquals("DRAFT", fixture.store.templates.get("draft-1").getBizStatus()); assertEquals(oneDocx(), fixture.store.templates.get("draft-1").getMubanFile()); assertTrue(fixture.audits.isEmpty()); } @Test public void publishRetryUsesTheSameSnapshotTarget() { RecordingFileCopier copier = new RecordingFileCopier(); AtomicInteger validations = new AtomicInteger(); Fixture fixture = new Fixture(Arrays.asList((tenantId, templateId) -> validations.incrementAndGet() == 1 ? Arrays.asList(diagnostic("ERROR", "HEALTH_FAILED", "快照体检失败")) : new ArrayList<>()), copier); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); expectDataException(() -> fixture.service.publish("draft-1", "第一次发布")); fixture.service.publish("draft-1", "第二次发布"); assertEquals(Arrays.asList("template.docx->draft-1-published", "template.docx->draft-1-published"), copier.copies); assertEquals(snapshotDocx("draft-1-published"), fixture.store.templates.get("draft-1").getMubanFile()); } @Test public void publishHealthValidationReadsThePublishedSnapshotInsteadOfTheDraftFile() { List inspectedAttachments = new ArrayList<>(); ElnTemplateHealthService health = new ElnTemplateHealthService() { @Override public HealthReport inspect(String templateId) { inspectedAttachments.add(oneDocx()); return new HealthReport(); } @Override public HealthReport inspect(String templateId, String attachments) { inspectedAttachments.add(attachments); return new HealthReport(); } }; Fixture fixture = new Fixture(Arrays.asList( ElnTemplateCatalogServiceImpl.healthValidator(health)), new RecordingFileCopier()); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); fixture.service.publish("draft-1", "评审通过"); assertEquals(Collections.singletonList(snapshotDocx("draft-1-published")), inspectedAttachments); } @Test public void publishHoldsOnlyOfficeFenceUntilTheDatabaseTransactionCompletes() { List events = new ArrayList<>(); RecordingPublishFence fence = new RecordingPublishFence(events); ElnTemplateCatalogServiceImpl.TransactionRunner transactionRunner = callback -> { events.add("transaction-begin"); ElnTemplateVos.Detail result = callback.get(); events.add("transaction-commit"); return result; }; Fixture fixture = new Fixture(Arrays.asList(validator()), (attachments, targetId) -> { events.add("copy"); return snapshotDocx(targetId); }, transactionRunner, fence); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); fixture.service.publish("draft-1", "评审通过"); assertEquals(Arrays.asList("fence-acquire:tenant-1:draft-1:template.docx:annex", "transaction-begin", "copy", "transaction-commit", "fence-release:fence-1"), events); } @Test public void reportsPublishedStateWhenFenceReleaseFailsAfterCommit() { ElnTemplateCatalogServiceImpl.TemplatePublishFence fence = new ElnTemplateCatalogServiceImpl.TemplatePublishFence() { @Override public String acquire(String tenantId, String templateId, String fileId, String fileType) { return "fence-1"; } @Override public void release(String tenantId, String templateId, String fileId, String fileType, String token) { throw new DataException("模拟发布锁释放失败"); } }; Fixture fixture = new Fixture(Arrays.asList(validator()), new RecordingFileCopier(), callback -> callback.get(), fence); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); DataException error = expectDataException( () -> fixture.service.publish("draft-1", "评审通过")); assertEquals("模板已发布成功,但 OnlyOffice 发布锁释放失败,需人工恢复并刷新页面", error.getMessage()); assertEquals("PUBLISHED", fixture.store.templates.get("draft-1").getBizStatus()); assertEquals(1, fixture.audits.size()); } @Test public void publishReleasesOnlyOfficeFenceAfterTransactionFailure() { List events = new ArrayList<>(); RecordingPublishFence fence = new RecordingPublishFence(events); ElnTemplateCatalogServiceImpl.TransactionRunner transactionRunner = callback -> { events.add("transaction-begin"); try { return callback.get(); } catch (RuntimeException e) { events.add("transaction-rollback"); throw e; } }; Fixture fixture = new Fixture(Arrays.asList(validator()), (attachments, targetId) -> { throw new DataException("模拟复制失败"); }, transactionRunner, fence); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); expectDataException(() -> fixture.service.publish("draft-1", "评审通过")); assertEquals(Arrays.asList("fence-acquire:tenant-1:draft-1:template.docx:annex", "transaction-begin", "transaction-rollback", "fence-release:fence-1"), events); assertEquals("DRAFT", fixture.store.templates.get("draft-1").getBizStatus()); } @Test public void publishRejectsAttachmentChangedAfterFenceAcquireAndThenReleasesFence() { List events = new ArrayList<>(); Runnable[] afterAcquire = {() -> { }}; ElnTemplateCatalogServiceImpl.TemplatePublishFence fence = new ElnTemplateCatalogServiceImpl.TemplatePublishFence() { @Override public String acquire(String tenantId, String templateId, String fileId, String fileType) { events.add("fence-acquire:" + fileId); afterAcquire[0].run(); return "fence-1"; } @Override public void release(String tenantId, String templateId, String fileId, String fileType, String token) { events.add("fence-release:" + token); } }; ElnTemplateCatalogServiceImpl.TransactionRunner transactionRunner = callback -> { events.add("transaction-begin"); try { return callback.get(); } catch (RuntimeException e) { events.add("transaction-rollback"); throw e; } }; AtomicInteger copies = new AtomicInteger(); AtomicInteger validations = new AtomicInteger(); Fixture fixture = new Fixture(Arrays.asList((tenantId, templateId) -> { validations.incrementAndGet(); return new ArrayList<>(); }), (attachments, targetId) -> { copies.incrementAndGet(); return snapshotDocx(targetId); }, transactionRunner, fence); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); afterAcquire[0] = () -> fixture.store.templates.get("draft-1") .setMubanFile("[{\"fileId\":\"changed.docx\",\"fileExtension\":\"docx\"}]"); DataException error = expectDataException( () -> fixture.service.publish("draft-1", "评审通过")); assertEquals("模板附件已变化,请刷新后重试", error.getMessage()); assertEquals(0, copies.get()); assertEquals(0, validations.get()); assertEquals("DRAFT", fixture.store.templates.get("draft-1").getBizStatus()); assertTrue(fixture.audits.isEmpty()); assertEquals(Arrays.asList("fence-acquire:template.docx", "transaction-begin", "transaction-rollback", "fence-release:fence-1"), events); } @Test public void publishFenceAcquireFailurePreventsSnapshotAndValidation() { AtomicInteger copies = new AtomicInteger(); AtomicInteger validations = new AtomicInteger(); ElnTemplateCatalogServiceImpl.TemplatePublishFence rejectingFence = new ElnTemplateCatalogServiceImpl.TemplatePublishFence() { @Override public String acquire(String tenantId, String templateId, String fileId, String fileType) { throw new DataException("文档仍在编辑或保存中"); } @Override public void release(String tenantId, String templateId, String fileId, String fileType, String token) { fail("未取得 fence 时不得释放"); } }; Fixture fixture = new Fixture(Arrays.asList((tenantId, templateId) -> { validations.incrementAndGet(); return new ArrayList<>(); }), (attachments, targetId) -> { copies.incrementAndGet(); return snapshotDocx(targetId); }, callback -> callback.get(), rejectingFence); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); expectDataException(() -> fixture.service.publish("draft-1", "评审通过")); assertEquals(0, copies.get()); assertEquals(0, validations.get()); assertEquals("DRAFT", fixture.store.templates.get("draft-1").getBizStatus()); } @Test public void publishRejectsNonDraftBeforeRunningValidators() { AtomicInteger validations = new AtomicInteger(); Fixture fixture = new Fixture(Arrays.asList((tenantId, templateId) -> { validations.incrementAndGet(); return new ArrayList<>(); })); fixture.addTemplate("published-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); expectDataException(() -> fixture.service.publish("published-1", "重复发布")); assertEquals(0, validations.get()); assertTrue(fixture.audits.isEmpty()); } @Test public void enableAndDisableRequireReasonAndDoNotChangeCurrentPointer() { Fixture fixture = new Fixture(Arrays.asList(validator())); fixture.addTemplate("published-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); fixture.addCurrent("TMP-001", "published-1", 2); expectDataException(() -> fixture.service.disable("published-1", " ")); fixture.service.disable("published-1", "临时停止新建记录"); assertEquals("disabled", fixture.store.templates.get("published-1").getBizEnabled()); assertEquals("published-1", fixture.store.currents.get("TMP-001").getDangqianMubanId()); assertEquals("ELN_TEMPLATE_DISABLE", fixture.audits.get(0).getActionCode()); assertEquals("临时停止新建记录", fixture.audits.get(0).getReason()); fixture.service.enable("published-1", "恢复新建记录"); assertEquals("enabled", fixture.store.templates.get("published-1").getBizEnabled()); assertEquals("published-1", fixture.store.currents.get("TMP-001").getDangqianMubanId()); assertEquals("ELN_TEMPLATE_ENABLE", fixture.audits.get(1).getActionCode()); } @Test public void switchCurrentRevalidatesAndUsesPointerVersionCas() { AtomicInteger validations = new AtomicInteger(); Fixture fixture = new Fixture(Arrays.asList((tenantId, templateId) -> { validations.incrementAndGet(); return new ArrayList<>(); })); fixture.addTemplate("old-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); fixture.addTemplate("new-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); fixture.addCurrent("TMP-001", "old-1", 4); fixture.service.switchCurrent("TMP-001", "new-1", "切换新版本"); assertEquals(1, validations.get()); ElnTemplateCurrentVersionEntity current = fixture.store.currents.get("TMP-001"); assertEquals("new-1", current.getDangqianMubanId()); assertEquals(Integer.valueOf(5), current.getFVersion()); assertEquals("PUBLISHED", fixture.store.templates.get("old-1").getBizStatus()); assertEquals("PUBLISHED", fixture.store.templates.get("new-1").getBizStatus()); AuditEventDTO audit = fixture.audits.get(0); assertEquals("ELN_TEMPLATE_SWITCH_CURRENT", audit.getActionCode()); assertEquals("切换新版本", audit.getReason()); assertTrue(audit.getDataSnapshot().contains("old-1")); assertTrue(audit.getDataSnapshot().contains("new-1")); } @Test public void switchCurrentSafelyInsertsMissingPointer() { Fixture fixture = new Fixture(Arrays.asList(validator())); fixture.addTemplate("new-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); fixture.service.switchCurrent("TMP-001", "new-1", "首次设置"); ElnTemplateCurrentVersionEntity current = fixture.store.currents.get("TMP-001"); assertEquals("tenant-1", current.getFTenantId()); assertEquals("new-1", current.getDangqianMubanId()); assertEquals(Integer.valueOf(0), current.getFVersion()); assertNull(fixture.audits.get(0).getOperationId()); } @Test public void switchCurrentRejectsIneligibleTargetsAndTenantOrCodeMismatch() { Fixture fixture = new Fixture(Arrays.asList(validator())); fixture.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); fixture.addTemplate("disabled-1", "TMP-001", "PUBLISHED", "disabled", past(), oneDocx()); fixture.addTemplate("future-1", "TMP-001", "PUBLISHED", "enabled", future(), oneDocx()); fixture.addTemplate("missing-file", "TMP-001", "PUBLISHED", "enabled", past(), "[]"); fixture.addTemplate("other-code", "TMP-002", "PUBLISHED", "enabled", past(), oneDocx()); fixture.addTemplate("other-tenant", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx(), "tenant-2"); expectDataException(() -> fixture.service.switchCurrent("TMP-001", "draft-1", "原因")); expectDataException(() -> fixture.service.switchCurrent("TMP-001", "disabled-1", "原因")); expectDataException(() -> fixture.service.switchCurrent("TMP-001", "future-1", "原因")); expectDataException(() -> fixture.service.switchCurrent("TMP-001", "missing-file", "原因")); expectDataException(() -> fixture.service.switchCurrent("TMP-001", "other-code", "原因")); expectDataException(() -> fixture.service.switchCurrent("TMP-001", "other-tenant", "原因")); expectDataException(() -> fixture.service.switchCurrent("TMP-001", "other-code", " ")); assertTrue(fixture.store.currents.isEmpty()); assertTrue(fixture.audits.isEmpty()); } @Test public void casOrUniqueInsertConflictDoesNotWriteSuccessAudit() { Fixture casFixture = new Fixture(Arrays.asList(validator())); casFixture.addTemplate("new-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); casFixture.addCurrent("TMP-001", "old-1", 1); casFixture.store.failCurrentCas = true; expectDataException(() -> casFixture.service.switchCurrent("TMP-001", "new-1", "并发切换")); assertTrue(casFixture.audits.isEmpty()); Fixture insertFixture = new Fixture(Arrays.asList(validator())); insertFixture.addTemplate("new-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); insertFixture.store.failCurrentInsert = true; expectDataException(() -> insertFixture.service.switchCurrent("TMP-001", "new-1", "并发首次设置")); assertTrue(insertFixture.audits.isEmpty()); } @Test public void currentInsertReturningZeroDoesNotWriteSuccessAudit() { Fixture fixture = new Fixture(Arrays.asList(validator())); fixture.addTemplate("new-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); fixture.store.skipCurrentInsert = true; expectDataException(() -> fixture.service.switchCurrent( "TMP-001", "new-1", "首次设置未落库")); assertTrue(fixture.store.currents.isEmpty()); assertTrue(fixture.audits.isEmpty()); } @Test public void lifecycleMutationsAcquireTheTemplateWriteLockBeforeValidation() { Fixture publish = new Fixture(Arrays.asList(validator())); publish.addTemplate("draft-1", "TMP-001", "DRAFT", "enabled", past(), oneDocx()); publish.service.publish("draft-1", "发布"); assertEquals(Arrays.asList("draft-1"), publish.store.lockedTemplateIds); Fixture switching = new Fixture(Arrays.asList(validator())); switching.addTemplate("published-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); switching.service.switchCurrent("TMP-001", "published-1", "切换"); assertEquals(Arrays.asList("published-1"), switching.store.lockedTemplateIds); Fixture disabling = new Fixture(Arrays.asList(validator())); disabling.addTemplate("published-1", "TMP-001", "PUBLISHED", "enabled", past(), oneDocx()); disabling.service.disable("published-1", "停用"); assertEquals(Arrays.asList("published-1"), disabling.store.lockedTemplateIds); } private static ElnTemplatePublishValidator validator(ElnTemplateVos.Diagnostic... diagnostics) { return (tenantId, templateId) -> Arrays.asList(diagnostics); } private static ElnTemplateVos.Diagnostic diagnostic(String severity, String code, String message) { ElnTemplateVos.Diagnostic result = new ElnTemplateVos.Diagnostic(); result.setSeverity(severity); result.setCode(code); result.setMessage(message); return result; } private static ElnTemplateHealthService.HealthDiagnostic healthDiagnostic( String severity, String code, String message) { ElnTemplateHealthService.HealthDiagnostic result = new ElnTemplateHealthService.HealthDiagnostic(); result.setSeverity(severity); result.setCode(code); result.setMessage(message); return result; } private static DataException expectDataException(Runnable action) { try { action.run(); fail("预期抛出 DataException"); return null; } catch (DataException expected) { assertFalse(expected.getMessage().trim().isEmpty()); return expected; } } private static Date past() { return new Date(System.currentTimeMillis() - 60000L); } private static Date future() { return new Date(System.currentTimeMillis() + 60000L); } private static String oneDocx() { return "[{\"fileId\":\"template.docx\",\"fileExtension\":\"docx\"}]"; } private static String snapshotDocx(String targetId) { return "[{\"fileId\":\"" + targetId + ".docx\",\"fileExtension\":\"docx\"}]"; } private static final class Fixture { private final MemoryStore store = new MemoryStore(); private final List audits = new ArrayList<>(); private final ElnTemplateCatalogServiceImpl service; private Fixture(List validators) { this(validators, (attachments, targetId) -> attachments); } private Fixture(List validators, ElnTemplateCatalogServiceImpl.TemplateFileCopier fileCopier) { this(validators, fileCopier, callback -> callback.get(), ElnTemplateCatalogServiceImpl.TemplatePublishFence.noop()); } private Fixture(List validators, ElnTemplateCatalogServiceImpl.TemplateFileCopier fileCopier, ElnTemplateCatalogServiceImpl.TransactionRunner transactionRunner, ElnTemplateCatalogServiceImpl.TemplatePublishFence publishFence) { service = new ElnTemplateCatalogServiceImpl( store, () -> new ElnTemplateCatalogServiceImpl.UserContext("tenant-1", "user-1"), ElnTemplateCatalogServiceImpl::deterministicTargetId, () -> "binding-new", fileCopier, audits::add, transactionRunner, validators, publishFence); } private void addTemplate(String id, String code, String status, String enabled, Date effectiveDate, String attachments) { addTemplate(id, code, status, enabled, effectiveDate, attachments, "tenant-1"); } private void addTemplate(String id, String code, String status, String enabled, Date effectiveDate, String attachments, String tenantId) { ElnTemplateVersionEntity template = new ElnTemplateVersionEntity(); template.setId(id); template.setFTenantId(tenantId); template.setMubanBianhao(code); template.setMubanBanben("V1"); template.setJianyanXiangmu("test-definition-1"); template.setJianyanXiangmuBianma("HD_jyxm_0001"); template.setJianyanXiangmuMingcheng("pH值"); template.setBizStatus(status); template.setBizEnabled(enabled); template.setShengxiaoRiqi(effectiveDate); template.setMubanFile(attachments); template.setFVersion(0); template.setFDeleteMark(0); store.templates.put(id, template); } private void addCurrent(String code, String templateId, int version) { ElnTemplateCurrentVersionEntity current = new ElnTemplateCurrentVersionEntity(); current.setId("current-" + code); current.setFTenantId("tenant-1"); current.setMubanBianhao(code); current.setDangqianMubanId(templateId); current.setFVersion(version); current.setFDeleteMark(0); store.currents.put(code, current); } } private static final class RecordingPublishFence implements ElnTemplateCatalogServiceImpl.TemplatePublishFence { private final List events; private RecordingPublishFence(List events) { this.events = events; } @Override public String acquire(String tenantId, String templateId, String fileId, String fileType) { events.add("fence-acquire:" + tenantId + ":" + templateId + ":" + fileId + ":" + fileType); return "fence-1"; } @Override public void release(String tenantId, String templateId, String fileId, String fileType, String token) { events.add("fence-release:" + token); } } private static final class RecordingFileCopier implements ElnTemplateCatalogServiceImpl.TemplateFileCopier { private final List copies = new ArrayList<>(); @Override public String copy(String attachments, String targetId) { copies.add("template.docx->" + targetId); return snapshotDocx(targetId); } } private static final class MemoryStore implements ElnTemplateCatalogServiceImpl.TemplateCatalogStore { private final Map templates = new LinkedHashMap<>(); private final Map currents = new LinkedHashMap<>(); private final List lockedTemplateIds = new ArrayList<>(); private boolean failCurrentCas; private boolean failCurrentInsert; private boolean skipCurrentInsert; @Override public ElnTemplateVersionEntity findTemplate(String tenantId, String templateId) { ElnTemplateVersionEntity template = templates.get(templateId); return template != null && tenantId.equals(template.getFTenantId()) ? template : null; } @Override public ElnTemplateVersionEntity findTemplateForUpdate(String tenantId, String templateId) { lockedTemplateIds.add(templateId); return findTemplate(tenantId, templateId); } @Override public ElnTemplateVersionEntity findByOperation(String tenantId, String operationCode) { return null; } @Override public ElnTemplateVersionEntity findByCodeAndVersion( String tenantId, String templateCode, String businessVersion) { return null; } @Override public void insertTemplate(ElnTemplateVersionEntity template) { templates.put(template.getId(), template); } @Override public void updateTemplateFile(ElnTemplateVersionEntity template) { templates.put(template.getId(), template); } @Override public void updateTemplateLifecycle(ElnTemplateVersionEntity template) { ElnTemplateVersionEntity stored = templates.get(template.getId()); if (stored == null || !stored.getFVersion().equals(template.getFVersion())) { throw new DataException("模板状态已变化,请刷新后重试"); } template.setFVersion(template.getFVersion() + 1); templates.put(template.getId(), template); } @Override public ElnTemplateCurrentVersionEntity findCurrent(String tenantId, String templateCode) { ElnTemplateCurrentVersionEntity current = currents.get(templateCode); return current != null && tenantId.equals(current.getFTenantId()) ? current : null; } @Override public List listCurrentVersions( String tenantId, List templateCodes) { List result = new ArrayList<>(); for (String code : templateCodes) { ElnTemplateCurrentVersionEntity current = findCurrent(tenantId, code); if (current != null) { result.add(current); } } return result; } @Override public boolean insertCurrent(ElnTemplateCurrentVersionEntity current) { if (failCurrentInsert) { throw new DuplicateKeyException("模拟当前指针唯一冲突"); } if (skipCurrentInsert) { return false; } currents.put(current.getMubanBianhao(), current); return true; } @Override public void updateCurrent(ElnTemplateCurrentVersionEntity current) { if (failCurrentCas) { throw new DataException("当前模板已变化,请刷新后重试"); } ElnTemplateCurrentVersionEntity stored = currents.get(current.getMubanBianhao()); if (stored == null || !stored.getFVersion().equals(current.getFVersion())) { throw new DataException("当前模板已变化,请刷新后重试"); } current.setFVersion(current.getFVersion() + 1); currents.put(current.getMubanBianhao(), current); } @Override public List listActiveBindings(String tenantId, String templateId) { return new ArrayList<>(); } @Override public List listActiveBindingsByTemplateIds( String tenantId, List templateIds) { return new ArrayList<>(); } @Override public void insertBinding(ElnTemplateBindingEntity binding) { } @Override public IPage page(String tenantId, ElnTemplateForms.Query query) { return new Page<>(); } @Override public IPage pageDirectory( String tenantId, ElnTemplateForms.Query query) { return new Page<>(); } } }