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