fix: synchronize training module state
parent
c106ee56f0
commit
b7000539ec
@ -0,0 +1,105 @@
|
||||
package com.yau.digitalrmb.payment.domain.model;
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Separates the prototype's fixed wallet identifiers from learner-owned
|
||||
* persistence identifiers. The latter are deterministic per training subject
|
||||
* so retries reuse the same isolated runtime without exposing it to the UI.
|
||||
*/
|
||||
public final class TeachingPaymentWalletIdentity {
|
||||
public static final String PAYER_DISPLAY_WALLET_ID =
|
||||
"WALLET_A_3A4B5C6D7E8F9A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4";
|
||||
public static final String PAYEE_DISPLAY_WALLET_ID =
|
||||
"WALLET_B_7D8E9F0A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0C1D2E3F4A5B6";
|
||||
|
||||
private static final String PAYER_PREFIX = "PAY_TEACH_A_";
|
||||
private static final String PAYEE_PREFIX = "PAY_TEACH_B_";
|
||||
|
||||
private TeachingPaymentWalletIdentity() {
|
||||
}
|
||||
|
||||
public static SubjectWallets forSubject(String userId, long schoolId, long classId) {
|
||||
if (userId == null || userId.trim().isEmpty()) throw new IllegalArgumentException("userId is required");
|
||||
String subjectKey = userId.trim() + "|" + schoolId + "|" + classId;
|
||||
String token = hex("PAYMENT_SUBJECT|" + subjectKey).substring(0, 32);
|
||||
return new SubjectWallets(subjectKey, token);
|
||||
}
|
||||
|
||||
public static String displayWalletId(String storageWalletId) {
|
||||
if (storageWalletId == null) return null;
|
||||
if (storageWalletId.startsWith(PAYER_PREFIX)) return PAYER_DISPLAY_WALLET_ID;
|
||||
if (storageWalletId.startsWith(PAYEE_PREFIX)) return PAYEE_DISPLAY_WALLET_ID;
|
||||
return storageWalletId;
|
||||
}
|
||||
|
||||
private static String hex(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder result = new StringBuilder(digest.length * 2);
|
||||
for (byte item : digest) result.append(String.format("%02x", item & 0xff));
|
||||
return result.toString();
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static String uuid(String value) {
|
||||
return UUID.nameUUIDFromBytes(value.getBytes(StandardCharsets.UTF_8)).toString();
|
||||
}
|
||||
|
||||
private static long positiveLong(String value) {
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
long result = ByteBuffer.wrap(digest).getLong() & Long.MAX_VALUE;
|
||||
return result == 0 ? 1 : result;
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class SubjectWallets {
|
||||
private final String subjectKey;
|
||||
private final String token;
|
||||
|
||||
private SubjectWallets(String subjectKey, String token) {
|
||||
this.subjectKey = subjectKey;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
public String payerWalletId() { return PAYER_PREFIX + token; }
|
||||
public String payeeWalletId() { return PAYEE_PREFIX + token; }
|
||||
public String payerUserId() { return uuid("PAYMENT_PAYER_USER|" + subjectKey); }
|
||||
public String payeeUserId() { return uuid("PAYMENT_PAYEE_USER|" + subjectKey); }
|
||||
public String payerCertificateSerial() { return "CERT_PAY_A_" + token; }
|
||||
public String payeeCertificateSerial() { return "CERT_PAY_B_" + token; }
|
||||
public String payerAccountId() { return "ACCOUNT_PAY_A_" + token; }
|
||||
public String payeeAccountId() { return "ACCOUNT_PAY_B_" + token; }
|
||||
public String payerContractId() { return "CONTRACT_PAY_A_" + token; }
|
||||
public long payerSourceBatchId() { return positiveLong("PAYMENT_BATCH|" + subjectKey); }
|
||||
public long payerSourceId(int index) { return positiveLong("PAYMENT_COIN|" + subjectKey + "|" + index); }
|
||||
public String payerCurrencyId(int index) {
|
||||
return PAYER_PREFIX + token + "_" + String.format("%02d", index);
|
||||
}
|
||||
public List<String> walletIds() {
|
||||
List<String> values = new ArrayList<String>();
|
||||
values.add(payerWalletId());
|
||||
values.add(payeeWalletId());
|
||||
return Collections.unmodifiableList(values);
|
||||
}
|
||||
public List<String> payerCurrencyIds() {
|
||||
List<String> values = new ArrayList<String>();
|
||||
for (int index = 1; index <= 10; index++) values.add(payerCurrencyId(index));
|
||||
return Collections.unmodifiableList(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package com.yau.digitalrmb.training.attempt.application;
|
||||
|
||||
import com.yau.digitalrmb.shared.exception.BusinessException;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
final class ExperimentActionFailedEvent {
|
||||
private static final int MAX_MESSAGE_LENGTH = 512;
|
||||
|
||||
private final UUID attemptId;
|
||||
private final String stepCode;
|
||||
private final String actionCode;
|
||||
private final String requestFingerprint;
|
||||
private final String errorCode;
|
||||
private final String errorMessage;
|
||||
|
||||
private ExperimentActionFailedEvent(UUID attemptId, String stepCode, String actionCode,
|
||||
String requestFingerprint, String errorCode, String errorMessage) {
|
||||
this.attemptId = attemptId;
|
||||
this.stepCode = stepCode;
|
||||
this.actionCode = actionCode;
|
||||
this.requestFingerprint = requestFingerprint;
|
||||
this.errorCode = errorCode;
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
static ExperimentActionFailedEvent from(UUID attemptId, String stepCode, String actionCode,
|
||||
String requestFingerprint, RuntimeException exception) {
|
||||
String code = exception instanceof BusinessException
|
||||
? ((BusinessException) exception).getErrorCode().name()
|
||||
: "INTERNAL_ERROR";
|
||||
String message = exception.getMessage() == null ? exception.getClass().getSimpleName() : exception.getMessage();
|
||||
if (message.length() > MAX_MESSAGE_LENGTH) {
|
||||
message = message.substring(0, MAX_MESSAGE_LENGTH);
|
||||
}
|
||||
return new ExperimentActionFailedEvent(
|
||||
attemptId, stepCode, actionCode, requestFingerprint, code, message);
|
||||
}
|
||||
|
||||
UUID getAttemptId() { return attemptId; }
|
||||
String getStepCode() { return stepCode; }
|
||||
String getActionCode() { return actionCode; }
|
||||
String getRequestFingerprint() { return requestFingerprint; }
|
||||
String getErrorCode() { return errorCode; }
|
||||
String getErrorMessage() { return errorMessage; }
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.yau.digitalrmb.training.attempt.application;
|
||||
|
||||
import com.yau.digitalrmb.training.attempt.domain.ExperimentAction;
|
||||
import com.yau.digitalrmb.training.attempt.domain.ExperimentAttemptRepository;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class ExperimentActionFailureListener {
|
||||
private final ExperimentAttemptRepository repository;
|
||||
|
||||
public ExperimentActionFailureListener(ExperimentAttemptRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void recordFailure(ExperimentActionFailedEvent event) {
|
||||
ExperimentAction action = repository.findActionForUpdate(
|
||||
event.getAttemptId(), event.getStepCode(), event.getActionCode())
|
||||
.orElse(null);
|
||||
boolean insert = action == null;
|
||||
if (insert) {
|
||||
action = ExperimentAction.running(UUID.randomUUID(), event.getAttemptId(),
|
||||
event.getStepCode(), event.getActionCode(), event.getRequestFingerprint());
|
||||
} else if (!event.getRequestFingerprint().equals(action.getRequestFingerprint())) {
|
||||
action.restart(event.getRequestFingerprint(), Instant.now());
|
||||
}
|
||||
action.fail(event.getErrorCode(), event.getErrorMessage(), Instant.now());
|
||||
if (insert) {
|
||||
repository.insertAction(action);
|
||||
} else {
|
||||
repository.saveAction(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
package com.yau.digitalrmb.exchange.application.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yau.digitalrmb.exchange.domain.model.ExchangeActor;
|
||||
import com.yau.digitalrmb.exchange.interfaces.dto.ExchangeActionRequest;
|
||||
import com.yau.digitalrmb.institutionidentity.domain.InstitutionIdentityCryptography;
|
||||
import com.yau.digitalrmb.training.attempt.application.ExperimentAttemptService;
|
||||
import com.yau.digitalrmb.training.attempt.domain.ActionStatus;
|
||||
import com.yau.digitalrmb.training.attempt.domain.AttemptStatus;
|
||||
import com.yau.digitalrmb.training.attempt.domain.ExperimentModule;
|
||||
import com.yau.digitalrmb.training.attempt.domain.ExperimentSubject;
|
||||
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentActionView;
|
||||
import com.yau.digitalrmb.training.attempt.interfaces.dto.ExperimentAttemptView;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class ExchangeTrainingActionServiceTest {
|
||||
|
||||
@Test
|
||||
void scoresOnlyAfterTheLastActionInAnExchangeStep() {
|
||||
ExperimentAttemptService attempts = mock(ExperimentAttemptService.class);
|
||||
ExchangeApplicationService exchanges = mock(ExchangeApplicationService.class);
|
||||
ExchangeScoreService scoreService = mock(ExchangeScoreService.class);
|
||||
ExchangeTrainingActionService service = new ExchangeTrainingActionService(attempts, exchanges,
|
||||
mock(InstitutionIdentityCryptography.class), new ObjectMapper(), scoreService);
|
||||
UUID attemptId = UUID.randomUUID();
|
||||
ExperimentSubject subject = new ExperimentSubject("user-1", 1L, 2L, "");
|
||||
ExchangeActor actor = new ExchangeActor("user-1", "tester", 1L, 2L);
|
||||
when(attempts.execute(any(), any(), any(), any(), any(), any(), any(), any(), any()))
|
||||
.thenReturn(action(attemptId, "01", "refresh-wallet"));
|
||||
when(attempts.detail(attemptId, subject)).thenReturn(attempt(attemptId));
|
||||
|
||||
service.execute(attemptId, "01", "refresh-wallet", new ExchangeActionRequest(), subject, actor);
|
||||
verify(scoreService, never()).markStepCompleted(subject.getUserId(), 1);
|
||||
|
||||
when(attempts.detail(attemptId, subject)).thenReturn(attempt(attemptId,
|
||||
action(attemptId, "01", "refresh-wallet"),
|
||||
action(attemptId, "01", "confirm-binding")));
|
||||
service.execute(attemptId, "01", "refresh-institution", new ExchangeActionRequest(), subject, actor);
|
||||
|
||||
verify(scoreService, times(1)).markStepCompleted(subject.getUserId(), 1);
|
||||
}
|
||||
|
||||
private ExperimentAttemptView attempt(UUID attemptId, ExperimentActionView<?>... completedActions) {
|
||||
return new ExperimentAttemptView(attemptId, ExperimentModule.EXCHANGE, 1,
|
||||
AttemptStatus.IN_PROGRESS, "01", "refresh-wallet", null,
|
||||
Instant.now(), Instant.now(), null,
|
||||
completedActions.length == 0 ? Collections.emptyList() : Arrays.asList(completedActions));
|
||||
}
|
||||
|
||||
private ExperimentActionView<Object> action(UUID attemptId, String step, String action) {
|
||||
return new ExperimentActionView<Object>(attemptId, ExperimentModule.EXCHANGE, step, action,
|
||||
ActionStatus.COMPLETED, AttemptStatus.IN_PROGRESS, step, action,
|
||||
Instant.now(), null);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue