はじめに
決済サービスと通知サービスは、ECの注文→支払い→通知という流れを完結させます。 Payment Service は支払いゲートウェイの統合を処理し、Notification Service はイベント駆動型アーキテクチャ経由で電子メール/SMS を送信します (Kafka についてはパート 4 で詳しく説明します。この記事では基本的なイベントを使用します)。
決済サービス
支払い主体
@Entity
@Table(name = "payments")
public class Payment extends PanacheEntity {
@Column(name = "payment_number", unique = true, nullable = false)
public String paymentNumber;
@Column(name = "order_id", nullable = false)
public Long orderId;
@Column(name = "order_number")
public String orderNumber;
@Column(name = "customer_id", nullable = false)
public String customerId;
@Embedded
@AttributeOverrides({
@AttributeOverride(name = "amount",
column = @Column(name = "amount")),
@AttributeOverride(name = "currency",
column = @Column(name = "currency"))
})
public Money amount;
@Enumerated(EnumType.STRING)
@Column(length = 20)
public PaymentStatus status = PaymentStatus.PENDING;
@Enumerated(EnumType.STRING)
@Column(name = "payment_method", length = 20)
public PaymentMethod method;
@Column(name = "gateway_transaction_id")
public String gatewayTransactionId;
@Column(name = "gateway_response", columnDefinition = "TEXT")
public String gatewayResponse;
@Column(name = "failure_reason")
public String failureReason;
@Column(name = "created_at", updatable = false)
public LocalDateTime createdAt;
@Column(name = "completed_at")
public LocalDateTime completedAt;
@PrePersist
void onCreate() {
createdAt = LocalDateTime.now();
paymentNumber = "PAY-" + System.currentTimeMillis()
+ "-" + UUID.randomUUID().toString()
.substring(0, 6).toUpperCase();
}
// Domain Methods
public void complete(String transactionId) {
this.status = PaymentStatus.COMPLETED;
this.gatewayTransactionId = transactionId;
this.completedAt = LocalDateTime.now();
}
public void fail(String reason) {
this.status = PaymentStatus.FAILED;
this.failureReason = reason;
}
public void refund() {
if (this.status != PaymentStatus.COMPLETED) {
throw new BusinessException(400,
"Can only refund completed payments");
}
this.status = PaymentStatus.REFUNDED;
}
}
public enum PaymentStatus {
PENDING, PROCESSING, COMPLETED, FAILED, REFUNDED
}
public enum PaymentMethod {
VNPAY, MOMO, BANK_TRANSFER, COD
}
ペイメントゲートウェイ — 戦略パターン
public interface PaymentGateway {
PaymentResult process(PaymentRequest request);
PaymentResult checkStatus(String transactionId);
}
public record PaymentRequest(
String paymentNumber,
BigDecimal amount,
String currency,
PaymentMethod method,
String returnUrl,
String customerIp
) {}
public record PaymentResult(
boolean success,
String transactionId,
String redirectUrl, // cho online payment
String message,
String rawResponse
) {}
VNPay ゲートウェイ (モック実装)
@ApplicationScoped
@Named("vnpay")
public class VnPayGateway implements PaymentGateway {
@ConfigProperty(name = "payment.vnpay.tmn-code")
String tmnCode;
@ConfigProperty(name = "payment.vnpay.secret-key")
String secretKey;
@ConfigProperty(name = "payment.vnpay.url",
defaultValue = "https://sandbox.vnpayment.vn/paymentv2/vpcpay.html")
String vnpayUrl;
@Override
public PaymentResult process(PaymentRequest request) {
// Build VNPay payment URL
Map<String, String> params = new TreeMap<>();
params.put("vnp_Version", "2.1.0");
params.put("vnp_TmnCode", tmnCode);
params.put("vnp_Amount",
String.valueOf(request.amount()
.multiply(BigDecimal.valueOf(100)).longValue()));
params.put("vnp_Command", "pay");
params.put("vnp_OrderInfo", request.paymentNumber());
params.put("vnp_TxnRef", request.paymentNumber());
params.put("vnp_IpAddr", request.customerIp());
params.put("vnp_ReturnUrl", request.returnUrl());
params.put("vnp_CreateDate",
LocalDateTime.now().format(
DateTimeFormatter.ofPattern("yyyyMMddHHmmss")));
String queryString = buildQueryString(params);
String secureHash = hmacSHA512(secretKey, queryString);
String paymentUrl = vnpayUrl + "?" + queryString
+ "&vnp_SecureHash=" + secureHash;
return new PaymentResult(
true, null, paymentUrl,
"Redirect to VNPay", null);
}
@Override
public PaymentResult checkStatus(String transactionId) {
// Query VNPay API for transaction status
return new PaymentResult(true, transactionId,
null, "Transaction completed", null);
}
private String buildQueryString(Map<String, String> params) {
return params.entrySet().stream()
.map(e -> e.getKey() + "="
+ URLEncoder.encode(e.getValue(),
StandardCharsets.UTF_8))
.collect(Collectors.joining("&"));
}
private String hmacSHA512(String key, String data) {
try {
Mac hmac = Mac.getInstance("HmacSHA512");
hmac.init(new SecretKeySpec(
key.getBytes(StandardCharsets.UTF_8),
"HmacSHA512"));
byte[] hash = hmac.doFinal(
data.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (Exception e) {
throw new RuntimeException(
"Failed to generate HMAC", e);
}
}
}
決済サービス層
@ApplicationScoped
public class PaymentService {
@Inject
@Named("vnpay")
PaymentGateway vnpayGateway;
@Inject
@RestClient
OrderServiceClient orderClient;
@Inject
Event<PaymentCompletedEvent> paymentCompletedEvent;
@Transactional
public PaymentResponse initiatePayment(
String customerId,
InitiatePaymentRequest request) {
Payment payment = new Payment();
payment.orderId = request.orderId();
payment.orderNumber = request.orderNumber();
payment.customerId = customerId;
payment.amount = Money.vnd(request.amount());
payment.method = request.method();
payment.status = PaymentStatus.PROCESSING;
payment.persist();
PaymentResult result = vnpayGateway.process(
new PaymentRequest(
payment.paymentNumber,
request.amount(), "VND",
request.method(),
request.returnUrl(),
request.customerIp()));
return new PaymentResponse(
payment.paymentNumber,
result.redirectUrl(),
payment.status.name());
}
@Transactional
public void handleCallback(Map<String, String> params) {
String paymentNumber = params.get("vnp_TxnRef");
String responseCode = params.get("vnp_ResponseCode");
String transactionId = params.get("vnp_TransactionNo");
Payment payment = Payment.find(
"paymentNumber", paymentNumber)
.firstResult();
if ("00".equals(responseCode)) {
payment.complete(transactionId);
// Notify Order Service
orderClient.markPaid(payment.orderId,
payment.paymentNumber);
// Fire CDI Event
paymentCompletedEvent.fire(
new PaymentCompletedEvent(
payment.orderId,
payment.paymentNumber,
payment.amount));
} else {
payment.fail("VNPay response: " + responseCode);
}
}
}
通知サービス
通知エンティティ
@Entity
@Table(name = "notifications")
public class Notification extends PanacheEntity {
@Column(name = "recipient", nullable = false)
public String recipient; // email or phone
@Enumerated(EnumType.STRING)
@Column(name = "channel", length = 10)
public NotificationChannel channel;
@Column(nullable = false)
public String subject;
@Column(columnDefinition = "TEXT", nullable = false)
public String body;
@Enumerated(EnumType.STRING)
@Column(length = 20)
public NotificationStatus status = NotificationStatus.PENDING;
@Column(name = "reference_type", length = 50)
public String referenceType; // ORDER, PAYMENT
@Column(name = "reference_id")
public String referenceId;
@Column(name = "sent_at")
public LocalDateTime sentAt;
@Column(name = "error_message")
public String errorMessage;
@Column(name = "retry_count")
public int retryCount = 0;
@Column(name = "created_at", updatable = false)
public LocalDateTime createdAt;
@PrePersist
void onCreate() { createdAt = LocalDateTime.now(); }
public void markSent() {
this.status = NotificationStatus.SENT;
this.sentAt = LocalDateTime.now();
}
public void markFailed(String error) {
this.retryCount++;
if (retryCount >= 3) {
this.status = NotificationStatus.FAILED;
}
this.errorMessage = error;
}
}
public enum NotificationChannel { EMAIL, SMS }
public enum NotificationStatus { PENDING, SENT, FAILED }
Quarkus Mailer を使用した電子メール送信者
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-mailer</artifactId>
</dependency>
# Dev mode: Quarkus Mailpit (mock SMTP)
# Production:
%[email protected]
%prod.quarkus.mailer.host=${SMTP_HOST}
%prod.quarkus.mailer.port=587
%prod.quarkus.mailer.start-tls=REQUIRED
%prod.quarkus.mailer.username=${SMTP_USERNAME}
%prod.quarkus.mailer.password=${SMTP_PASSWORD}
@ApplicationScoped
public class EmailNotificationSender {
@Inject
Mailer mailer;
public void send(Notification notification) {
try {
mailer.send(Mail.withHtml(
notification.recipient,
notification.subject,
notification.body));
notification.markSent();
} catch (Exception e) {
notification.markFailed(e.getMessage());
}
}
}
通知テンプレート
@ApplicationScoped
public class NotificationTemplateService {
public Notification createOrderConfirmation(
String email, String orderNumber,
BigDecimal total) {
Notification n = new Notification();
n.recipient = email;
n.channel = NotificationChannel.EMAIL;
n.subject = "Xác nhận đơn hàng " + orderNumber;
n.body = """
<h2>Đơn hàng của bạn đã được xác nhận!</h2>
<p>Mã đơn hàng: <strong>%s</strong></p>
<p>Tổng tiền: <strong>%s VNĐ</strong></p>
<p>Cảm ơn bạn đã mua sắm tại xdev.asia!</p>
""".formatted(orderNumber,
NumberFormat.getInstance(
new Locale("vi", "VN")).format(total));
n.referenceType = "ORDER";
n.referenceId = orderNumber;
return n;
}
public Notification createPaymentSuccess(
String email, String paymentNumber,
String orderNumber, BigDecimal amount) {
Notification n = new Notification();
n.recipient = email;
n.channel = NotificationChannel.EMAIL;
n.subject = "Thanh toán thành công — " + orderNumber;
n.body = """
<h2>Thanh toán thành công!</h2>
<p>Mã giao dịch: <strong>%s</strong></p>
<p>Đơn hàng: <strong>%s</strong></p>
<p>Số tiền: <strong>%s VNĐ</strong></p>
""".formatted(paymentNumber, orderNumber,
NumberFormat.getInstance(
new Locale("vi", "VN")).format(amount));
n.referenceType = "PAYMENT";
n.referenceId = paymentNumber;
return n;
}
}
通知 REST API
@Path("/api/v1/notifications")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
@Tag(name = "Notifications")
public class NotificationResource {
@Inject
NotificationService notificationService;
@POST
@Path("/send")
public Response sendNotification(
@Valid SendNotificationRequest request) {
notificationService.send(request);
return Response.accepted().build();
}
@GET
@Path("/history")
public List<NotificationDTO> history(
@QueryParam("referenceType") String type,
@QueryParam("referenceId") String id) {
return notificationService.getHistory(type, id);
}
@POST
@Path("/retry-failed")
public Response retryFailed() {
int count = notificationService.retryFailed();
return Response.ok(Map.of("retried", count)).build();
}
}
Docker Compose — すべてのサービスを実行する
# docker-compose.yml
services:
product-db:
image: postgres:16
environment:
POSTGRES_DB: product_db
POSTGRES_USER: product
POSTGRES_PASSWORD: product_pass
ports: ["5433:5432"]
order-db:
image: postgres:16
environment:
POSTGRES_DB: order_db
POSTGRES_USER: order
POSTGRES_PASSWORD: order_pass
ports: ["5434:5432"]
payment-db:
image: postgres:16
environment:
POSTGRES_DB: payment_db
POSTGRES_USER: payment
POSTGRES_PASSWORD: payment_pass
ports: ["5435:5432"]
notification-db:
image: postgres:16
environment:
POSTGRES_DB: notification_db
POSTGRES_USER: notification
POSTGRES_PASSWORD: notification_pass
ports: ["5436:5432"]
mailpit:
image: axllent/mailpit
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP
演習
- 決済サービスを作成する
Paymentエンティティと VNPay モック ゲートウェイ - VNPay からの結果を処理するコールバック ハンドラーを実装します。
- 電子メール テンプレートを使用して通知サービスを作成する
- 書く
docker-compose.ymlすべてのデータベースと Mailpit を実行する - テスト フロー: 注文の作成 → 支払いの開始 → コールバック → 電子メール通知の送信
- Mailpit UI 経由で電子メールを確認します。
http://localhost:8025
概要
- 決済サービスは、決済ゲートウェイ (VNPay、MoMo...) に戦略パターンを使用します。
- コールバック パターン ゲートウェイからの非同期支払い結果を処理します
- 通知サービス は、開発サービス (Mailpit) を使用して Quarkus Mailer 経由で電子メールを送信します
- CDI イベント (
Event<T>) インプロセスイベント通信用 - Docker Compose はローカル開発用にすべてのデータベースを調整します
次の記事: Keycloak — OIDC 認証をインストールして統合する。