انتقل إلى المحتوى الرئيسي

TP2 - المعاملات والتراجع

أهداف التمرين​

في هذا التمرين، سوف تقوم بـ:

  1. ✅ إنشاء نظام لتحويل الأموال مع المعاملات
  2. ✅ ملاحظة سلوك التراجع
  3. ✅ اختبار مستويات الانتشار المختلفة
  4. ✅ فهم فخ الاستدعاء الذاتي
  5. ✅ استخدام المعاملات للقراءة فقط

المدة المقدرة: 1 ساعة و30 دقيقة


السياق​

أنت تطور تطبيقًا مصرفيًا مبسطًا. يجب أن يتضمن تحويل الأموال:

  • خصم الحساب المصدر
  • إضافة إلى الحساب الوجهة
  • تسجيل العملية في تدقيق

القاعدة الذهبية: إذا فشلت أي خطوة، يجب إلغاء كل شيء!


الجزء 1: إعداد المشروع​

1.1 استئناف مشروع TP7.1​

تابع مع مشروع datasource-demo من TP7.1، أو أنشئ مشروعًا جديدًا بنفس التبعيات.

1.2 الهيكل​

src/main/java/com/example/bank/
├── model/
│ ├── Account.java
│ └── TransactionLog.java
├── repository/
│ ├── AccountRepository.java
│ └── TransactionLogRepository.java
├── service/
│ ├── AccountService.java
│ ├── TransferService.java
│ └── AuditService.java
├── controller/
│ └── TransferController.java
└── exception/
└── InsufficientFundsException.java

الجزء 2: الكيانات​

2.1 Account (الحساب البنكي)​

// src/main/java/com/example/bank/model/Account.java
package com.example.bank.model;

import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;

import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(unique = true, nullable = false)
private String accountNumber;

@Column(nullable = false)
private String ownerName;

@Column(nullable = false, precision = 15, scale = 2)
private BigDecimal balance;

public Account(String accountNumber, String ownerName, BigDecimal balance) {
this.accountNumber = accountNumber;
this.ownerName = ownerName;
this.balance = balance;
}

public void debit(BigDecimal amount) {
if (this.balance.compareTo(amount) < 0) {
throw new IllegalStateException("رصيد غير كافٍ");
}
this.balance = this.balance.subtract(amount);
}

public void credit(BigDecimal amount) {
this.balance = this.balance.add(amount);
}
}

2.2 TransactionLog (سجل التدقيق)​

// src/main/java/com/example/bank/model/TransactionLog.java
package com.example.bank.model;

import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "transaction_logs")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TransactionLog {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String fromAccount;

@Column(nullable = false)
private String toAccount;

@Column(nullable = false, precision = 15, scale = 2)
private BigDecimal amount;

@Column(nullable = false)
private String status; // SUCCESS, FAILED

private String errorMessage;

@Column(nullable = false)
private LocalDateTime timestamp = LocalDateTime.now();

public TransactionLog(String from, String to, BigDecimal amount, String status) {
this.fromAccount = from;
this.toAccount = to;
this.amount = amount;
this.status = status;
this.timestamp = LocalDateTime.now();
}
}

2.3 استثناء مخصص​

// src/main/java/com/example/bank/exception/InsufficientFundsException.java
package com.example.bank.exception;

public class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(String message) {
super(message);
}
}

الجزء 3: المستودعات​

// AccountRepository.java
package com.example.bank.repository;

import com.example.bank.model.Account;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.Optional;

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
Optional<Account> findByAccountNumber(String accountNumber);
}
// TransactionLogRepository.java
package com.example.bank.repository;

import com.example.bank.model.TransactionLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface TransactionLogRepository extends JpaRepository<TransactionLog, Long> {
List<TransactionLog> findByFromAccountOrToAccount(String from, String to);
}

الجزء 4: الخدمات​

4.1 AccountService​

// src/main/java/com/example/bank/service/AccountService.java
package com.example.bank.service;

import com.example.bank.exception.InsufficientFundsException;
import com.example.bank.model.Account;
import com.example.bank.repository.AccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class AccountService {

private final AccountRepository accountRepository;

@Transactional(readOnly = true)
public List<Account> getAllAccounts() {
return accountRepository.findAll();
}

@Transactional(readOnly = true)
public Account getAccount(String accountNumber) {
return accountRepository.findByAccountNumber(accountNumber)
.orElseThrow(() -> new RuntimeException("الحساب غير موجود: " + accountNumber));
}

@Transactional
public void debit(String accountNumber, BigDecimal amount) {
log.info("خصم {} من الحساب {}", amount, accountNumber);
Account account = getAccount(accountNumber);

if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientFundsException(
"رصيد غير كافٍ. المتاح: " + account.getBalance() + ", المطلوب: " + amount
);
}

account.debit(amount);
accountRepository.save(account);
log.info("تم الخصم. الرصيد الجديد: {}", account.getBalance());
}

@Transactional
public void credit(String accountNumber, BigDecimal amount) {
log.info("إضافة {} إلى الحساب {}", amount, accountNumber);
Account account = getAccount(accountNumber);
account.credit(amount);
accountRepository.save(account);
log.info("تمت الإضافة. الرصيد الجديد: {}", account.getBalance());
}

@Transactional
public Account createAccount(String accountNumber, String ownerName, BigDecimal initialBalance) {
Account account = new Account(accountNumber, ownerName, initialBalance);
return accountRepository.save(account);
}
}

4.2 AuditService (مع REQUIRES_NEW)​

// src/main/java/com/example/bank/service/AuditService.java
package com.example.bank.service;

import com.example.bank.model.TransactionLog;
import com.example.bank.repository.TransactionLogRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class AuditService {

private final TransactionLogRepository logRepository;

/**
* تسجيل في معاملة جديدة.
* حتى إذا فشلت المعاملة الرئيسية، يتم الاحتفاظ بالسجل!
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logTransaction(String from, String to, BigDecimal amount,
String status, String errorMessage) {
log.info("تسجيل المعاملة: {} -> {} ({}€) - الحالة: {}",
from, to, amount, status);

TransactionLog txLog = new TransactionLog(from, to, amount, status);
txLog.setErrorMessage(errorMessage);
logRepository.save(txLog);

log.info("تم حفظ سجل المعاملة بالمعرف: {}", txLog.getId());
}

@Transactional(readOnly = true)
public List<TransactionLog> getLogsForAccount(String accountNumber) {
return logRepository.findByFromAccountOrToAccount(accountNumber, accountNumber);
}

@Transactional(readOnly = true)
public List<TransactionLog> getAllLogs() {
return logRepository.findAll();
}
}

4.3 TransferService (الخدمة الرئيسية)​

// src/main/java/com/example/bank/service/TransferService.java
package com.example.bank.service;

import com.example.bank.exception.InsufficientFundsException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
@RequiredArgsConstructor
@Slf4j
public class TransferService {

private final AccountService accountService;
private final AuditService auditService;

/**
* تحويل الأموال مع المعاملة.
* إذا فشلت أي خطوة، يتم إلغاء كل شيء (باستثناء سجل التدقيق).
*/
@Transactional
public void transfer(String fromAccount, String toAccount, BigDecimal amount) {
log.info("=== بدء التحويل: {} -> {} ({}€) ===", fromAccount, toAccount, amount);

try {
// الخطوة 1: خصم الحساب المصدر
accountService.debit(fromAccount, amount);

// محاكاة خطأ (قم بإلغاء التعليق لاختبار التراجع)
// if (amount.compareTo(new BigDecimal("100")) > 0) {
// throw new RuntimeException("خطأ محاكى!");
// }

// الخطوة 2: إضافة إلى الحساب الوجهة
accountService.credit(toAccount, amount);

// الخطوة 3: تسجيل النجاح (REQUIRES_NEW = معاملة منفصلة)
auditService.logTransaction(fromAccount, toAccount, amount, "SUCCESS", null);

log.info("=== التحويل ناجح! ===");

} catch (InsufficientFundsException e) {
// تسجيل الفشل (حتى إذا كانت المعاملة الرئيسية في تراجع)
auditService.logTransaction(fromAccount, toAccount, amount, "FAILED", e.getMessage());
throw e;

} catch (Exception e) {
// تسجيل الفشل
auditService.logTransaction(fromAccount, toAccount, amount, "FAILED", e.getMessage());
throw new RuntimeException("خطأ أثناء التحويل: " + e.getMessage(), e);
}
}

/**
* نسخة بدون معاملة (للتوضيح).
* لا تستخدم في الإنتاج!
*/
public void transferNoTransaction(String fromAccount, String toAccount, BigDecimal amount) {
log.warn("⚠️ تحويل بدون معاملة - خطير!");

// خصم
accountService.debit(fromAccount, amount);

// محاكاة خطأ بعد الخصم
throw new RuntimeException("خطأ بعد الخصم!");

// لن يتم تنفيذ الإضافة أبدًا
// accountService.credit(toAccount, amount);
}
}

الجزء 5: وحدة التحكم والبيانات التجريبية​

5.1 TransferController​

// src/main/java/com/example/bank/controller/TransferController.java
package com.example.bank.controller;

import com.example.bank.model.Account;
import com.example.bank.model.TransactionLog;
import com.example.bank.service.AccountService;
import com.example.bank.service.AuditService;
import com.example.bank.service.TransferService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/bank")
@RequiredArgsConstructor
public class TransferController {

private final AccountService accountService;
private final TransferService transferService;
private final AuditService auditService;

// === الحسابات ===

@GetMapping("/accounts")
public List<Account> getAllAccounts() {
return accountService.getAllAccounts();
}

@GetMapping("/accounts/{accountNumber}")
public Account getAccount(@PathVariable String accountNumber) {
return accountService.getAccount(accountNumber);
}

// === التحويلات ===

@PostMapping("/transfer")
public ResponseEntity<String> transfer(@RequestBody Map<String, Object> request) {
String from = (String) request.get("from");
String to = (String) request.get("to");
BigDecimal amount = new BigDecimal(request.get("amount").toString());

transferService.transfer(from, to, amount);
return ResponseEntity.ok("تم التحويل بنجاح!");
}

@PostMapping("/transfer-no-tx")
public ResponseEntity<String> transferNoTransaction(@RequestBody Map<String, Object> request) {
String from = (String) request.get("from");
String to = (String) request.get("to");
BigDecimal amount = new BigDecimal(request.get("amount").toString());

transferService.transferNoTransaction(from, to, amount);
return ResponseEntity.ok("تم التحويل بنجاح!");
}

// === التدقيق ===

@GetMapping("/logs")
public List<TransactionLog> getAllLogs() {
return auditService.getAllLogs();
}

@GetMapping("/logs/{accountNumber}")
public List<TransactionLog> getAccountLogs(@PathVariable String accountNumber) {
return auditService.getLogsForAccount(accountNumber);
}
}

5.2 البيانات الأولية​

-- src/main/resources/data.sql
INSERT INTO accounts (account_number, owner_name, balance) VALUES
('ACC001', 'Alice Martin', 1000.00),
('ACC002', 'Bob Dupont', 500.00),
('ACC003', 'Charlie Bernard', 2500.00);

5.3 التكوين​

# application-dev.properties (إضافة)
spring.jpa.defer-datasource-initialization=true
spring.sql.init.mode=always

الجزء 6: الاختبارات العملية​

6.1 الاختبار 1: تحويل ناجح​

# تحقق من الأرصدة الأولية
curl http://localhost:8080/api/bank/accounts

# تحويل 200€ من ACC001 إلى ACC002
curl -X POST http://localhost:8080/api/bank/transfer \
-H "Content-Type: application/json" \
-d '{"from":"ACC001", "to":"ACC002", "amount":200}'

# تحقق من الأرصدة الجديدة
curl http://localhost:8080/api/bank/accounts

# تحقق من السجلات
curl http://localhost:8080/api/bank/logs

النتيجة المتوقعة:

  • ACC001: 1000 → 800€
  • ACC002: 500 → 700€
  • سجل بحالة "SUCCESS"

6.2 الاختبار 2: رصيد غير كافٍ (تراجع)​

# محاولة تحويل 2000€ (ACC001 لديه فقط 800€)
curl -X POST http://localhost:8080/api/bank/transfer \
-H "Content-Type: application/json" \
-d '{"from":"ACC001", "to":"ACC002", "amount":2000}'

# تحقق من أن الأرصدة لم تتغير
curl http://localhost:8080/api/bank/accounts

# تحقق من وجود سجل الفشل!
curl http://localhost:8080/api/bank/logs

النتيجة المتوقعة:

  • ACC001: يبقى 800€ (لا تغيير)
  • ACC002: يبقى 700€ (لا تغيير)
  • سجل بحالة "FAILED" ورسالة خطأ

6.3 الاختبار 3: بدون معاملة (خطير)​

# إعادة تشغيل التطبيق لإعادة تعيين البيانات

# محاولة التحويل بدون معاملة
curl -X POST http://localhost:8080/api/bank/transfer-no-tx \
-H "Content-Type: application/json" \
-d '{"from":"ACC001", "to":"ACC002", "amount":200}'

# تحقق من الأرصدة
curl http://localhost:8080/api/bank/accounts

النتيجة المتوقعة (خطأ!):

  • ACC001: 1000 → 800€ (تم الخصم!)
  • ACC002: يبقى 500€ (لم يتم الإضافة!)
  • 200€ اختفت! 💀

الجزء 7: فهم REQUIRES_NEW​

7.1 لماذا يتم الاحتفاظ بسجل التدقيق؟​

انظر إلى هذا المخطط:

7.2 تمرين: تعديل السلوك​

  1. في AuditService، قم بتغيير REQUIRES_NEW إلى REQUIRED
  2. أعد تشغيل الاختبار 2 (رصيد غير كافٍ)
  3. لاحظ: لم يعد يتم إنشاء السجل في حالة الفشل!
// جرب هذا مؤقتًا:
@Transactional(propagation = Propagation.REQUIRED) // بدلاً من REQUIRES_NEW
public void logTransaction(...) { }

الجزء 8: فخ الاستدعاء الذاتي​

8.1 المشكلة​

أضف هذه الطريقة في TransferService:

// طريقة مع فخ الاستدعاء الذاتي
public void transferWithLogging(String from, String to, BigDecimal amount) {
log.info("استدعاء خارجي...");

// ⚠️ فخ: استدعاء مباشر = لا يوجد وكيل = لا توجد معاملة!
this.transfer(from, to, amount); // @Transactional تم تجاهله!
}

8.2 الحل​

@Service
@RequiredArgsConstructor
@Slf4j
public class TransferService {

private final AccountService accountService;
private final AuditService auditService;

// حقن الذات للمرور عبر الوكيل
@Autowired
private TransferService self;

public void transferWithLogging(String from, String to, BigDecimal amount) {
log.info("استدعاء خارجي...");

// ✅ يمر عبر الوكيل = معاملة نشطة!
self.transfer(from, to, amount);
}

@Transactional
public void transfer(String from, String to, BigDecimal amount) {
// ... الكود الحالي
}
}

الجزء 9: معاملة للقراءة فقط​

9.1 التحقق من التحسين​

أضف تسجيلًا في AccountService:

@Transactional(readOnly = true)
public List<Account> getAllAccounts() {
log.info("Transaction readOnly = true");
List<Account> accounts = accountRepository.findAll();

// لن يتم حفظ هذا لأن readOnly = true
accounts.forEach(a -> a.setBalance(BigDecimal.ZERO));

return accounts;
}

اختبار:

curl http://localhost:8080/api/bank/accounts
# الأرصدة المعروضة هي 0...

curl http://localhost:8080/api/bank/accounts
# لكن الأرصدة الحقيقية لم تتغير!

ملخص​

ما تعلمته​

المفهومالمثال
@Transactionalيضمن الذرية
Rollbackيلغي كل شيء في حالة الخطأ
REQUIRES_NEWمعاملة مستقلة
readOnlyتحسين للقراءات
Self-invocationفخ وكيل Spring

القواعد الذهبية​

1. استخدم دائمًا @Transactional على عمليات الكتابة
2. استخدم REQUIRES_NEW لسجلات التدقيق
3. تجنب الاستدعاء الذاتي
4. استخدم readOnly = true للقراءات
5. لا تتجاهل الاستثناءات أبدًا

🎯 الخطوة التالية​

➡️ TP7.3: الترحيل باستخدام Flyway


⬅️ العودة إلى الوحدة 7