From ae8ce2819c530f79b7b543686fe37df6879c454a Mon Sep 17 00:00:00 2001 From: chenyuan Date: Tue, 18 Aug 2026 13:39:31 +0800 Subject: [PATCH] feat: persist module training attempts and actions --- .../training/attempt/domain/ActionStatus.java | 8 + .../attempt/domain/AttemptStatus.java | 8 + .../attempt/domain/ExperimentAction.java | 94 ++++++++++ .../attempt/domain/ExperimentAttempt.java | 92 ++++++++++ .../domain/ExperimentAttemptRepository.java | 17 ++ .../attempt/domain/ExperimentModule.java | 7 + .../attempt/domain/ExperimentSubject.java | 21 +++ .../JdbcExperimentAttemptRepository.java | 168 ++++++++++++++++++ .../module_2_4_5_training_attempt_upgrade.sql | 41 +++++ src/main/resources/schema.sql | 42 +++++ .../JdbcExperimentAttemptRepositoryTest.java | 72 ++++++++ src/test/resources/schema.sql | 41 +++++ 12 files changed, 611 insertions(+) create mode 100644 src/main/java/com/yau/digitalrmb/training/attempt/domain/ActionStatus.java create mode 100644 src/main/java/com/yau/digitalrmb/training/attempt/domain/AttemptStatus.java create mode 100644 src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAction.java create mode 100644 src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAttempt.java create mode 100644 src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAttemptRepository.java create mode 100644 src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentModule.java create mode 100644 src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentSubject.java create mode 100644 src/main/java/com/yau/digitalrmb/training/attempt/infrastructure/JdbcExperimentAttemptRepository.java create mode 100644 src/main/resources/db/manual/module_2_4_5_training_attempt_upgrade.sql create mode 100644 src/test/java/com/yau/digitalrmb/training/attempt/infrastructure/JdbcExperimentAttemptRepositoryTest.java diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/domain/ActionStatus.java b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ActionStatus.java new file mode 100644 index 0000000..784fa5c --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ActionStatus.java @@ -0,0 +1,8 @@ +package com.yau.digitalrmb.training.attempt.domain; + +public enum ActionStatus { + RUNNING, + COMPLETED, + FAILED, + ROLLED_BACK +} diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/domain/AttemptStatus.java b/src/main/java/com/yau/digitalrmb/training/attempt/domain/AttemptStatus.java new file mode 100644 index 0000000..b885513 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/attempt/domain/AttemptStatus.java @@ -0,0 +1,8 @@ +package com.yau.digitalrmb.training.attempt.domain; + +public enum AttemptStatus { + IN_PROGRESS, + SUBMITTED, + COMPLETED, + CANCELLED +} diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAction.java b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAction.java new file mode 100644 index 0000000..756e07e --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAction.java @@ -0,0 +1,94 @@ +package com.yau.digitalrmb.training.attempt.domain; + +import java.time.Instant; +import java.util.UUID; + +public final class ExperimentAction { + private final UUID id; + private final UUID attemptId; + private final String stepCode; + private final String actionCode; + private ActionStatus status; + private String requestFingerprint; + private String outputJson; + private String errorCode; + private String errorMessage; + private final Instant startedAt; + private Instant completedAt; + private Instant updatedAt; + private long version; + + public ExperimentAction(UUID id, UUID attemptId, String stepCode, String actionCode, ActionStatus status, + String requestFingerprint, String outputJson, String errorCode, String errorMessage, + Instant startedAt, Instant completedAt, Instant updatedAt, long version) { + this.id = id; + this.attemptId = attemptId; + this.stepCode = stepCode; + this.actionCode = actionCode; + this.status = status; + this.requestFingerprint = requestFingerprint; + this.outputJson = outputJson; + this.errorCode = errorCode; + this.errorMessage = errorMessage; + this.startedAt = startedAt; + this.completedAt = completedAt; + this.updatedAt = updatedAt; + this.version = version; + } + + public static ExperimentAction running(UUID id, UUID attemptId, String stepCode, + String actionCode, String requestFingerprint) { + Instant now = Instant.now(); + return new ExperimentAction(id, attemptId, stepCode, actionCode, ActionStatus.RUNNING, + requestFingerprint, null, null, null, now, null, now, 0L); + } + + public void complete(ActionStatus terminalStatus, String json, Instant now) { + if (terminalStatus != ActionStatus.COMPLETED && terminalStatus != ActionStatus.ROLLED_BACK) { + throw new IllegalArgumentException("动作终态不合法"); + } + status = terminalStatus; + outputJson = json; + errorCode = null; + errorMessage = null; + completedAt = now; + updatedAt = now; + } + + public void fail(String code, String message, Instant now) { + status = ActionStatus.FAILED; + errorCode = code; + errorMessage = message; + completedAt = now; + updatedAt = now; + } + + public void restart(String fingerprint, Instant now) { + if (status != ActionStatus.FAILED && status != ActionStatus.ROLLED_BACK) { + throw new IllegalStateException("只有失败或已补偿动作可以重试"); + } + status = ActionStatus.RUNNING; + requestFingerprint = fingerprint; + outputJson = null; + errorCode = null; + errorMessage = null; + completedAt = null; + updatedAt = now; + } + + public void markPersisted() { version++; } + + public UUID getId() { return id; } + public UUID getAttemptId() { return attemptId; } + public String getStepCode() { return stepCode; } + public String getActionCode() { return actionCode; } + public ActionStatus getStatus() { return status; } + public String getRequestFingerprint() { return requestFingerprint; } + public String getOutputJson() { return outputJson; } + public String getErrorCode() { return errorCode; } + public String getErrorMessage() { return errorMessage; } + public Instant getStartedAt() { return startedAt; } + public Instant getCompletedAt() { return completedAt; } + public Instant getUpdatedAt() { return updatedAt; } + public long getVersion() { return version; } +} diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAttempt.java b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAttempt.java new file mode 100644 index 0000000..a53a945 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAttempt.java @@ -0,0 +1,92 @@ +package com.yau.digitalrmb.training.attempt.domain; + +import java.time.Instant; +import java.util.UUID; + +public final class ExperimentAttempt { + private final UUID id; + private final ExperimentModule module; + private final ExperimentSubject subject; + private final int attemptNo; + private AttemptStatus status; + private String currentStepCode; + private String currentActionCode; + private String businessId; + private long version; + private final Instant createdAt; + private Instant updatedAt; + private Instant completedAt; + + public ExperimentAttempt(UUID id, ExperimentModule module, ExperimentSubject subject, int attemptNo, + AttemptStatus status, String currentStepCode, String currentActionCode, + String businessId, long version, Instant createdAt, Instant updatedAt, + Instant completedAt) { + this.id = id; + this.module = module; + this.subject = subject; + this.attemptNo = attemptNo; + this.status = status; + this.currentStepCode = currentStepCode; + this.currentActionCode = currentActionCode; + this.businessId = businessId; + this.version = version; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.completedAt = completedAt; + } + + public static ExperimentAttempt start(ExperimentModule module, ExperimentSubject subject, int attemptNo, Instant now) { + return new ExperimentAttempt(UUID.randomUUID(), module, subject, attemptNo, AttemptStatus.IN_PROGRESS, + "01", null, null, 0L, now, now, null); + } + + public void moveTo(String stepCode, String actionCode, Instant now) { + requireActive(); + currentStepCode = stepCode; + currentActionCode = actionCode; + updatedAt = now; + } + + public void bindBusinessId(String value, Instant now) { + requireActive(); + if (businessId != null && value != null && !businessId.equals(value)) { + throw new IllegalStateException("实验尝试已经绑定其他业务记录"); + } + businessId = value; + updatedAt = now; + } + + public void complete(Instant now) { + requireActive(); + status = AttemptStatus.COMPLETED; + completedAt = now; + updatedAt = now; + } + + public void cancel() { + requireActive(); + status = AttemptStatus.CANCELLED; + updatedAt = Instant.now(); + } + + public void markPersisted() { version++; } + + private void requireActive() { + if (status != AttemptStatus.IN_PROGRESS && status != AttemptStatus.SUBMITTED) { + throw new IllegalStateException("实验尝试已经结束"); + } + } + + public UUID getId() { return id; } + public ExperimentModule getModule() { return module; } + public ExperimentSubject getSubject() { return subject; } + public int getAttemptNo() { return attemptNo; } + public AttemptStatus getStatus() { return status; } + public String getCurrentStepCode() { return currentStepCode; } + public String getCurrentActionCode() { return currentActionCode; } + public String getBusinessId() { return businessId; } + public long getVersion() { return version; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } + public Instant getCompletedAt() { return completedAt; } +} diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAttemptRepository.java b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAttemptRepository.java new file mode 100644 index 0000000..6df78ae --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentAttemptRepository.java @@ -0,0 +1,17 @@ +package com.yau.digitalrmb.training.attempt.domain; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ExperimentAttemptRepository { + ExperimentAttempt create(ExperimentModule module, ExperimentSubject subject); + Optional findCurrent(ExperimentModule module, ExperimentSubject subject); + Optional findById(UUID id, ExperimentSubject subject); + Optional findByIdForUpdate(UUID id, ExperimentSubject subject); + void save(ExperimentAttempt attempt); + void insertAction(ExperimentAction action); + void saveAction(ExperimentAction action); + Optional findActionForUpdate(UUID attemptId, String stepCode, String actionCode); + List findActions(UUID attemptId); +} diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentModule.java b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentModule.java new file mode 100644 index 0000000..4cbe6a2 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentModule.java @@ -0,0 +1,7 @@ +package com.yau.digitalrmb.training.attempt.domain; + +public enum ExperimentModule { + ISSUANCE, + EXCHANGE, + PAYMENT +} diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentSubject.java b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentSubject.java new file mode 100644 index 0000000..8d626c3 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/attempt/domain/ExperimentSubject.java @@ -0,0 +1,21 @@ +package com.yau.digitalrmb.training.attempt.domain; + +public final class ExperimentSubject { + private final String userId; + private final long schoolId; + private final long classId; + private final String organizationId; + + public ExperimentSubject(String userId, long schoolId, long classId, String organizationId) { + if (userId == null || userId.trim().isEmpty()) throw new IllegalArgumentException("用户ID不能为空"); + this.userId = userId.trim(); + this.schoolId = schoolId; + this.classId = classId; + this.organizationId = organizationId == null ? "" : organizationId.trim(); + } + + public String getUserId() { return userId; } + public long getSchoolId() { return schoolId; } + public long getClassId() { return classId; } + public String getOrganizationId() { return organizationId; } +} diff --git a/src/main/java/com/yau/digitalrmb/training/attempt/infrastructure/JdbcExperimentAttemptRepository.java b/src/main/java/com/yau/digitalrmb/training/attempt/infrastructure/JdbcExperimentAttemptRepository.java new file mode 100644 index 0000000..9759580 --- /dev/null +++ b/src/main/java/com/yau/digitalrmb/training/attempt/infrastructure/JdbcExperimentAttemptRepository.java @@ -0,0 +1,168 @@ +package com.yau.digitalrmb.training.attempt.infrastructure; + +import com.yau.digitalrmb.training.attempt.domain.ActionStatus; +import com.yau.digitalrmb.training.attempt.domain.AttemptStatus; +import com.yau.digitalrmb.training.attempt.domain.ExperimentAction; +import com.yau.digitalrmb.training.attempt.domain.ExperimentAttempt; +import com.yau.digitalrmb.training.attempt.domain.ExperimentAttemptRepository; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public class JdbcExperimentAttemptRepository implements ExperimentAttemptRepository { + private final JdbcTemplate jdbc; + + public JdbcExperimentAttemptRepository(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @Override + @Transactional + public ExperimentAttempt create(ExperimentModule module, ExperimentSubject subject) { + Integer latest = jdbc.queryForObject("SELECT COALESCE(MAX(attempt_no),0) FROM training_experiment_attempt " + + "WHERE module_code=? AND user_id=? AND school_id=? AND class_id=? " + + "AND organization_id=?", + Integer.class, module.name(), subject.getUserId(), subject.getSchoolId(), subject.getClassId(), + subject.getOrganizationId()); + ExperimentAttempt attempt = ExperimentAttempt.start(module, subject, (latest == null ? 0 : latest) + 1, + Instant.now()); + jdbc.update("INSERT INTO training_experiment_attempt (id,module_code,user_id,school_id,class_id," + + "organization_id,attempt_no,status,current_step_code,current_action_code,business_id,version," + + "created_at,updated_at,completed_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + attempt.getId().toString(), module.name(), subject.getUserId(), subject.getSchoolId(), + subject.getClassId(), subject.getOrganizationId(), attempt.getAttemptNo(), attempt.getStatus().name(), + attempt.getCurrentStepCode(), attempt.getCurrentActionCode(), attempt.getBusinessId(), + attempt.getVersion(), timestamp(attempt.getCreatedAt()), timestamp(attempt.getUpdatedAt()), null); + return attempt; + } + + @Override + public Optional findCurrent(ExperimentModule module, ExperimentSubject subject) { + List values = jdbc.query("SELECT * FROM training_experiment_attempt WHERE module_code=? " + + "AND user_id=? AND school_id=? AND class_id=? " + + "AND organization_id=? " + + "AND status IN ('IN_PROGRESS','SUBMITTED') ORDER BY attempt_no DESC LIMIT 1", + attemptMapper(), module.name(), subject.getUserId(), subject.getSchoolId(), subject.getClassId(), + subject.getOrganizationId()); + return optional(values); + } + + @Override + public Optional findById(UUID id, ExperimentSubject subject) { + return findById(id, subject, false); + } + + @Override + public Optional findByIdForUpdate(UUID id, ExperimentSubject subject) { + return findById(id, subject, true); + } + + private Optional findById(UUID id, ExperimentSubject subject, boolean forUpdate) { + List values = jdbc.query("SELECT * FROM training_experiment_attempt WHERE id=? " + + "AND user_id=? AND school_id=? AND class_id=? " + + "AND organization_id=?" + + (forUpdate ? " FOR UPDATE" : ""), + attemptMapper(), id.toString(), subject.getUserId(), subject.getSchoolId(), subject.getClassId(), + subject.getOrganizationId()); + return optional(values); + } + + @Override + public void save(ExperimentAttempt attempt) { + int updated = jdbc.update("UPDATE training_experiment_attempt SET status=?,current_step_code=?," + + "current_action_code=?,business_id=?,version=version+1,updated_at=?,completed_at=? " + + "WHERE id=? AND version=?", + attempt.getStatus().name(), attempt.getCurrentStepCode(), attempt.getCurrentActionCode(), + attempt.getBusinessId(), timestamp(attempt.getUpdatedAt()), timestamp(attempt.getCompletedAt()), + attempt.getId().toString(), attempt.getVersion()); + if (updated != 1) throw new IllegalStateException("实验尝试状态已被其他请求修改"); + attempt.markPersisted(); + } + + @Override + public void insertAction(ExperimentAction action) { + jdbc.update("INSERT INTO training_experiment_action (id,attempt_id,step_code,action_code,status," + + "request_fingerprint,output_json,error_code,error_message,started_at,completed_at,updated_at,version) " + + "VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + action.getId().toString(), action.getAttemptId().toString(), action.getStepCode(), action.getActionCode(), + action.getStatus().name(), action.getRequestFingerprint(), action.getOutputJson(), action.getErrorCode(), + action.getErrorMessage(), timestamp(action.getStartedAt()), timestamp(action.getCompletedAt()), + timestamp(action.getUpdatedAt()), action.getVersion()); + } + + @Override + public void saveAction(ExperimentAction action) { + int updated = jdbc.update("UPDATE training_experiment_action SET status=?,request_fingerprint=?,output_json=?," + + "error_code=?,error_message=?,completed_at=?,updated_at=?,version=version+1 " + + "WHERE id=? AND version=?", + action.getStatus().name(), action.getRequestFingerprint(), action.getOutputJson(), action.getErrorCode(), + action.getErrorMessage(), timestamp(action.getCompletedAt()), timestamp(action.getUpdatedAt()), + action.getId().toString(), action.getVersion()); + if (updated != 1) throw new IllegalStateException("实验动作状态已被其他请求修改"); + action.markPersisted(); + } + + @Override + public Optional findActionForUpdate(UUID attemptId, String stepCode, String actionCode) { + List values = jdbc.query("SELECT * FROM training_experiment_action WHERE attempt_id=? " + + "AND step_code=? AND action_code=? FOR UPDATE", actionMapper(), + attemptId.toString(), stepCode, actionCode); + return optional(values); + } + + @Override + public List findActions(UUID attemptId) { + return jdbc.query("SELECT * FROM training_experiment_action WHERE attempt_id=? " + + "ORDER BY started_at,id", actionMapper(), attemptId.toString()); + } + + private RowMapper attemptMapper() { + return new RowMapper() { + @Override + public ExperimentAttempt mapRow(ResultSet rs, int rowNum) throws SQLException { + ExperimentSubject subject = new ExperimentSubject(rs.getString("user_id"), rs.getLong("school_id"), + rs.getLong("class_id"), rs.getString("organization_id")); + return new ExperimentAttempt(UUID.fromString(rs.getString("id")), + ExperimentModule.valueOf(rs.getString("module_code")), subject, rs.getInt("attempt_no"), + AttemptStatus.valueOf(rs.getString("status")), rs.getString("current_step_code"), + rs.getString("current_action_code"), rs.getString("business_id"), rs.getLong("version"), + instant(rs.getTimestamp("created_at")), instant(rs.getTimestamp("updated_at")), + instant(rs.getTimestamp("completed_at"))); + } + }; + } + + private RowMapper actionMapper() { + return new RowMapper() { + @Override + public ExperimentAction mapRow(ResultSet rs, int rowNum) throws SQLException { + return new ExperimentAction(UUID.fromString(rs.getString("id")), + UUID.fromString(rs.getString("attempt_id")), rs.getString("step_code"), + rs.getString("action_code"), ActionStatus.valueOf(rs.getString("status")), + rs.getString("request_fingerprint"), rs.getString("output_json"), + rs.getString("error_code"), rs.getString("error_message"), + instant(rs.getTimestamp("started_at")), instant(rs.getTimestamp("completed_at")), + instant(rs.getTimestamp("updated_at")), rs.getLong("version")); + } + }; + } + + private static Optional optional(List values) { + return values.isEmpty() ? Optional.empty() : Optional.of(values.get(0)); + } + + private static Timestamp timestamp(Instant value) { return value == null ? null : Timestamp.from(value); } + private static Instant instant(Timestamp value) { return value == null ? null : value.toInstant(); } +} diff --git a/src/main/resources/db/manual/module_2_4_5_training_attempt_upgrade.sql b/src/main/resources/db/manual/module_2_4_5_training_attempt_upgrade.sql new file mode 100644 index 0000000..fb43466 --- /dev/null +++ b/src/main/resources/db/manual/module_2_4_5_training_attempt_upgrade.sql @@ -0,0 +1,41 @@ +CREATE TABLE IF NOT EXISTS training_experiment_attempt ( + id CHAR(36) PRIMARY KEY, + module_code VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + organization_id VARCHAR(64) NOT NULL DEFAULT '', + attempt_no INT NOT NULL, + status VARCHAR(32) NOT NULL, + current_step_code VARCHAR(16) NULL, + current_action_code VARCHAR(64) NULL, + business_id VARCHAR(96) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + CONSTRAINT uk_experiment_attempt_no + UNIQUE (module_code, user_id, school_id, class_id, organization_id, attempt_no), + INDEX idx_experiment_attempt_current + (module_code, user_id, school_id, class_id, status, attempt_no) +); + +CREATE TABLE IF NOT EXISTS training_experiment_action ( + id CHAR(36) PRIMARY KEY, + attempt_id CHAR(36) NOT NULL, + step_code VARCHAR(16) NOT NULL, + action_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + request_fingerprint CHAR(64) NOT NULL, + output_json TEXT NULL, + error_code VARCHAR(32) NULL, + error_message VARCHAR(512) NULL, + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + updated_at TIMESTAMP NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_experiment_action UNIQUE (attempt_id, step_code, action_code), + INDEX idx_experiment_action_attempt (attempt_id, started_at), + CONSTRAINT fk_experiment_action_attempt + FOREIGN KEY (attempt_id) REFERENCES training_experiment_attempt(id) +); diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 34b0d21..2be7220 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -822,6 +822,48 @@ CREATE TABLE IF NOT EXISTS payment_step_log ( operator_name VARCHAR(64) NOT NULL, FOREIGN KEY (payment_id) REFERENCES payment_order(id) ); +CREATE TABLE IF NOT EXISTS training_experiment_attempt ( + id CHAR(36) PRIMARY KEY, + module_code VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + organization_id VARCHAR(64) NOT NULL DEFAULT '', + attempt_no INT NOT NULL, + status VARCHAR(32) NOT NULL, + current_step_code VARCHAR(16) NULL, + current_action_code VARCHAR(64) NULL, + business_id VARCHAR(96) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + CONSTRAINT uk_experiment_attempt_no + UNIQUE (module_code, user_id, school_id, class_id, organization_id, attempt_no), + INDEX idx_experiment_attempt_current + (module_code, user_id, school_id, class_id, status, attempt_no) +); + +CREATE TABLE IF NOT EXISTS training_experiment_action ( + id CHAR(36) PRIMARY KEY, + attempt_id CHAR(36) NOT NULL, + step_code VARCHAR(16) NOT NULL, + action_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + request_fingerprint CHAR(64) NOT NULL, + output_json TEXT NULL, + error_code VARCHAR(32) NULL, + error_message VARCHAR(512) NULL, + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + updated_at TIMESTAMP NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_experiment_action UNIQUE (attempt_id, step_code, action_code), + INDEX idx_experiment_action_attempt (attempt_id, started_at), + CONSTRAINT fk_experiment_action_attempt + FOREIGN KEY (attempt_id) REFERENCES training_experiment_attempt(id) +); + -- 开通数字货币钱包实验模块 - 步骤一 CREATE TABLE IF NOT EXISTS wallet_application ( diff --git a/src/test/java/com/yau/digitalrmb/training/attempt/infrastructure/JdbcExperimentAttemptRepositoryTest.java b/src/test/java/com/yau/digitalrmb/training/attempt/infrastructure/JdbcExperimentAttemptRepositoryTest.java new file mode 100644 index 0000000..364b42c --- /dev/null +++ b/src/test/java/com/yau/digitalrmb/training/attempt/infrastructure/JdbcExperimentAttemptRepositoryTest.java @@ -0,0 +1,72 @@ +package com.yau.digitalrmb.training.attempt.infrastructure; + +import com.yau.digitalrmb.training.attempt.domain.ActionStatus; +import com.yau.digitalrmb.training.attempt.domain.ExperimentAction; +import com.yau.digitalrmb.training.attempt.domain.ExperimentAttempt; +import com.yau.digitalrmb.training.attempt.domain.ExperimentAttemptRepository; +import com.yau.digitalrmb.training.attempt.domain.ExperimentModule; +import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class JdbcExperimentAttemptRepositoryTest { + @Autowired + private ExperimentAttemptRepository repository; + + @Test + void assignsIncreasingAttemptNumbersWithinTheAuthenticatedScope() { + ExperimentSubject subject = new ExperimentSubject("user-1", 10L, 20L, "ORG-1"); + + ExperimentAttempt first = repository.create(ExperimentModule.EXCHANGE, subject); + first.cancel(); + repository.save(first); + ExperimentAttempt second = repository.create(ExperimentModule.EXCHANGE, subject); + + assertEquals(1, first.getAttemptNo()); + assertEquals(2, second.getAttemptNo()); + assertEquals(second.getId(), repository.findCurrent(ExperimentModule.EXCHANGE, subject).get().getId()); + } + + @Test + void scopesAttemptReadsToUserSchoolClassAndOrganization() { + ExperimentSubject owner = new ExperimentSubject("user-1", 10L, 20L, "ORG-1"); + ExperimentAttempt attempt = repository.create(ExperimentModule.ISSUANCE, owner); + + assertTrue(repository.findById(attempt.getId(), owner).isPresent()); + assertFalse(repository.findById(attempt.getId(), + new ExperimentSubject("user-2", 10L, 20L, "ORG-1")).isPresent()); + assertFalse(repository.findById(attempt.getId(), + new ExperimentSubject("user-1", 10L, 21L, "ORG-1")).isPresent()); + } + + @Test + void enforcesOneActionRowForAnAttemptStepAndActionCode() { + ExperimentSubject subject = new ExperimentSubject("user-1", 10L, 20L, "ORG-1"); + ExperimentAttempt attempt = repository.create(ExperimentModule.PAYMENT, subject); + UUID actionId = UUID.randomUUID(); + repository.insertAction(ExperimentAction.running(actionId, attempt.getId(), "02", + "generate-digest", "fingerprint-1")); + + ExperimentAction locked = repository.findActionForUpdate(attempt.getId(), "02", "generate-digest") + .orElseThrow(AssertionError::new); + + assertEquals(ActionStatus.RUNNING, locked.getStatus()); + assertThrows(DuplicateKeyException.class, () -> repository.insertAction( + ExperimentAction.running(UUID.randomUUID(), attempt.getId(), "02", + "generate-digest", "fingerprint-2"))); + } +} diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 937445e..3fc0ee1 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -795,3 +795,44 @@ CREATE TABLE IF NOT EXISTS payment_step_log ( operator_name VARCHAR(64) NOT NULL, FOREIGN KEY (payment_id) REFERENCES payment_order(id) ); +CREATE TABLE IF NOT EXISTS training_experiment_attempt ( + id CHAR(36) PRIMARY KEY, + module_code VARCHAR(32) NOT NULL, + user_id VARCHAR(36) NOT NULL, + school_id BIGINT NOT NULL, + class_id BIGINT NOT NULL, + organization_id VARCHAR(64) NOT NULL DEFAULT '', + attempt_no INT NOT NULL, + status VARCHAR(32) NOT NULL, + current_step_code VARCHAR(16) NULL, + current_action_code VARCHAR(64) NULL, + business_id VARCHAR(96) NULL, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + CONSTRAINT uk_experiment_attempt_no + UNIQUE (module_code, user_id, school_id, class_id, organization_id, attempt_no), + INDEX idx_experiment_attempt_current + (module_code, user_id, school_id, class_id, status, attempt_no) +); + +CREATE TABLE IF NOT EXISTS training_experiment_action ( + id CHAR(36) PRIMARY KEY, + attempt_id CHAR(36) NOT NULL, + step_code VARCHAR(16) NOT NULL, + action_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL, + request_fingerprint CHAR(64) NOT NULL, + output_json TEXT NULL, + error_code VARCHAR(32) NULL, + error_message VARCHAR(512) NULL, + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP NULL, + updated_at TIMESTAMP NOT NULL, + version BIGINT NOT NULL DEFAULT 0, + CONSTRAINT uk_experiment_action UNIQUE (attempt_id, step_code, action_code), + INDEX idx_experiment_action_attempt (attempt_id, started_at), + CONSTRAINT fk_experiment_action_attempt + FOREIGN KEY (attempt_id) REFERENCES training_experiment_attempt(id) +);