bổ sung thêm chức năng

This commit is contained in:
2026-01-27 21:55:14 +07:00
parent 8086bcfe0c
commit 962cd5d873
48 changed files with 958 additions and 187 deletions

View File

@@ -9,4 +9,4 @@ RUN mvn clean package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /build/target/*.jar app.jar
ENTRYPOINT ["java","-jar","app.jar"]
ENTRYPOINT ["java", "-jar", "app.jar"]

View File

@@ -6,6 +6,7 @@ import org.springframework.data.mongodb.config.EnableMongoAuditing;
@SpringBootApplication
@EnableMongoAuditing
@org.springframework.scheduling.annotation.EnableScheduling
public class UserServiceApplication {
public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args);

View File

@@ -1,10 +1,10 @@
package com.dken.userservice.client;
import org.springframework.web.client.RestClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;
import com.dken.userservice.dto.response.UserApplyGiftCodeResponse;
import com.dken.userservice.dto.request.GiftCodeValidateRequest;
import org.springframework.web.client.RestClient;
import java.util.Map;
@Component
public class GiftCodeClient {
@@ -15,15 +15,27 @@ public class GiftCodeClient {
this.restClient = restClient;
}
public UserApplyGiftCodeResponse applyGiftCode(
String userId,
String giftCode) {
return restClient
.post()
.uri("/gift-codes/apply")
.body(new GiftCodeValidateRequest(userId, giftCode))
public Integer reserve(String transactionId, String userId, String code) {
return restClient.post()
.uri("/gift-codes/reserve")
.body(Map.of("transactionId", transactionId, "userId", userId, "code", code))
.retrieve()
.body(UserApplyGiftCodeResponse.class);
.body(Integer.class);
}
public void confirm(String transactionId) {
restClient.post()
.uri("/gift-codes/confirm")
.body(Map.of("transactionId", transactionId))
.retrieve()
.toBodilessEntity();
}
public void cancel(String transactionId) {
restClient.post()
.uri("/gift-codes/cancel")
.body(Map.of("transactionId", transactionId))
.retrieve()
.toBodilessEntity();
}
}

View File

@@ -10,9 +10,11 @@ public class GiftCodeClientConfig {
@Bean("giftCodeRestClient")
public RestClient giftCodeRestClient(
@Value("${giftcode.service.url}") String baseUrl) {
@Value("${giftcode.service.url}") String baseUrl,
@Value("${internal.api-key}") String apiKey) {
return RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("X-Internal-Key", apiKey)
.requestInterceptor((request, body, execution) -> {
return execution.execute(request, body);
})

View File

@@ -0,0 +1,34 @@
package com.dken.userservice.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.mongodb.config.AbstractMongoClientConfiguration;
import com.mongodb.ConnectionString;
import com.mongodb.MongoClientSettings;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;
@Configuration
@Profile("!dev") // Không active khi profile là "dev"
public class MongoConfig extends AbstractMongoClientConfiguration {
@Value("${spring.data.mongodb.uri:mongodb://mongodb:27017/userdb}")
private String mongoUri;
@Override
protected String getDatabaseName() {
ConnectionString connectionString = new ConnectionString(mongoUri);
return connectionString.getDatabase();
}
@Override
public MongoClient mongoClient() {
ConnectionString connectionString = new ConnectionString(mongoUri);
MongoClientSettings mongoClientSettings = MongoClientSettings.builder()
.applyConnectionString(connectionString)
.build();
return MongoClients.create(mongoClientSettings);
}
}

View File

@@ -1,6 +1,7 @@
package com.dken.userservice.dto.request;
public record GiftCodeValidateRequest(
String userId,
String giftCode) {
String userId,
String giftCode,
String requestId) {
}

View File

@@ -1,22 +1,19 @@
package com.dken.userservice.dto.response;
public class UserApplyGiftCodeResponse {
private String status;
private String message;
private String description;
private String status;
private Integer amount;
private String requestId;
public UserApplyGiftCodeResponse(String status, String message, String description) {
this.status = status;
public UserApplyGiftCodeResponse() {
}
public UserApplyGiftCodeResponse(String message, String status, Integer amount, String requestId) {
this.message = message;
this.description = description;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
this.amount = amount;
this.requestId = requestId;
}
public String getMessage() {
@@ -27,11 +24,27 @@ public class UserApplyGiftCodeResponse {
this.message = message;
}
public String getDescription() {
return description;
public String getStatus() {
return status;
}
public void setDescription(String description) {
this.description = description;
public void setStatus(String status) {
this.status = status;
}
public Integer getAmount() {
return amount;
}
public void setAmount(Integer amount) {
this.amount = amount;
}
public String getRequestId() {
return requestId;
}
public void setRequestId(String requestId) {
this.requestId = requestId;
}
}

View File

@@ -7,6 +7,7 @@ public class UserResponse {
private String name;
private String email;
private String phone;
private Long balance = 0L;
public String getPhone() {
return phone;
@@ -36,15 +37,17 @@ public class UserResponse {
user.getName(),
user.getEmail(),
user.getPhone(),
user.getSex() != null ? user.getSex().name() : null);
user.getSex() != null ? user.getSex().name() : null,
user.getBalance());
}
public UserResponse(String id, String name, String email, String phone, String sex) {
public UserResponse(String id, String name, String email, String phone, String sex, Long balance) {
this.id = id;
this.name = name;
this.email = email;
this.phone = phone;
this.sex = sex;
this.balance = balance;
}
// Getters and Setters
@@ -71,4 +74,12 @@ public class UserResponse {
public void setEmail(String email) {
this.email = email;
}
public Long getBalance() {
return balance;
}
public void setBalance(Long balance) {
this.balance = balance;
}
}

View File

@@ -0,0 +1,7 @@
package com.dken.userservice.exception;
public class GiftCodeReservationException extends RuntimeException {
public GiftCodeReservationException(String message) {
super("Failed to reserve gift code: " + message);
}
}

View File

@@ -27,4 +27,22 @@ public class GlobalExceptionHandler {
public Map<String, String> handleNotFound(UserNotFoundException ex) {
return Map.of("message", ex.getMessage());
}
@ExceptionHandler(GiftCodeReservationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleGiftCodeReservation(GiftCodeReservationException ex) {
return Map.of("message", ex.getMessage());
}
@ExceptionHandler(LocalProcessingException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, String> handleLocalProcessing(LocalProcessingException ex) {
return Map.of("message", ex.getMessage());
}
@ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, String> handleRuntimeException(RuntimeException ex) {
return Map.of("message", "An unexpected error occurred: " + ex.getMessage());
}
}

View File

@@ -0,0 +1,7 @@
package com.dken.userservice.exception;
public class LocalProcessingException extends RuntimeException {
public LocalProcessingException(String message) {
super("Local transaction failed: " + message);
}
}

View File

@@ -0,0 +1,100 @@
package com.dken.userservice.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.Indexed;
import org.springframework.data.mongodb.core.mapping.Document;
import java.time.Instant;
@Document(collection = "redeem_logs")
public class RedeemLog {
@Id
private String id;
@Indexed(unique = true)
private String transactionId;
@Indexed
private String userId;
private String code;
private Integer amount;
private RedeemLogStatus status;
private Instant createdAt;
private Instant lastRetryAt;
public RedeemLog() {
}
public RedeemLog(String transactionId, String userId, String code, Integer amount, RedeemLogStatus status) {
this.transactionId = transactionId;
this.userId = userId;
this.code = code;
this.amount = amount;
this.status = status;
this.createdAt = Instant.now();
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getTransactionId() {
return transactionId;
}
public void setTransactionId(String transactionId) {
this.transactionId = transactionId;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public String getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public Integer getAmount() {
return amount;
}
public void setAmount(Integer amount) {
this.amount = amount;
}
public RedeemLogStatus getStatus() {
return status;
}
public void setStatus(RedeemLogStatus status) {
this.status = status;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getLastRetryAt() {
return lastRetryAt;
}
public void setLastRetryAt(Instant lastRetryAt) {
this.lastRetryAt = lastRetryAt;
}
}

View File

@@ -0,0 +1,7 @@
package com.dken.userservice.model;
public enum RedeemLogStatus {
SUCCESS,
PENDING_CONFIRM,
FAILED
}

View File

@@ -19,6 +19,7 @@ public class User {
private String email;
private String phone;
private UserSex sex;
private Long balance = 0L;
private UserStatus status;
@@ -102,6 +103,14 @@ public class User {
this.sex = sex;
}
public Long getBalance() {
return balance;
}
public void setBalance(Long balance) {
this.balance = balance;
}
public String getPassword() {
return password;
}

View File

@@ -0,0 +1,12 @@
package com.dken.userservice.repository;
import com.dken.userservice.model.RedeemLog;
import com.dken.userservice.model.RedeemLogStatus;
import org.springframework.data.mongodb.repository.MongoRepository;
import java.util.List;
public interface RedeemLogRepository extends MongoRepository<RedeemLog, String> {
List<RedeemLog> findByStatus(RedeemLogStatus status);
RedeemLog findByTransactionId(String transactionId);
}

View File

@@ -0,0 +1,36 @@
package com.dken.userservice.scheduler;
import com.dken.userservice.client.GiftCodeClient;
import com.dken.userservice.model.RedeemLog;
import com.dken.userservice.model.RedeemLogStatus;
import com.dken.userservice.repository.RedeemLogRepository;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class RetryConfirmScheduler {
private final RedeemLogRepository redeemLogRepository;
private final GiftCodeClient giftCodeClient;
public RetryConfirmScheduler(RedeemLogRepository redeemLogRepository, GiftCodeClient giftCodeClient) {
this.redeemLogRepository = redeemLogRepository;
this.giftCodeClient = giftCodeClient;
}
@Scheduled(fixedDelay = 60000)
public void retryConfirms() {
List<RedeemLog> pendingLogs = redeemLogRepository.findByStatus(RedeemLogStatus.PENDING_CONFIRM);
for (RedeemLog log : pendingLogs) {
try {
giftCodeClient.confirm(log.getTransactionId());
log.setStatus(RedeemLogStatus.SUCCESS);
redeemLogRepository.save(log);
} catch (Exception e) {
System.err.println("Retry confirm failed for " + log.getTransactionId() + ": " + e.getMessage());
}
}
}
}

View File

@@ -5,17 +5,25 @@ import com.dken.userservice.dto.request.CreateUserRequest;
import com.dken.userservice.dto.request.UserApplyGiftCodeRequest;
import com.dken.userservice.dto.response.UserApplyGiftCodeResponse;
import com.dken.userservice.dto.response.UserResponse;
import com.dken.userservice.model.RedeemLog;
import com.dken.userservice.model.RedeemLogStatus;
import com.dken.userservice.model.User;
import com.dken.userservice.model.UserSex;
import com.dken.userservice.repository.RedeemLogRepository;
import com.dken.userservice.repository.UserRepository;
import com.dken.userservice.exception.InvalidUserIdException;
import com.dken.userservice.exception.GiftCodeReservationException;
import com.dken.userservice.exception.LocalProcessingException;
import com.dken.userservice.exception.UserNotFoundException;
import java.util.UUID;
import org.bson.types.ObjectId;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.Pageable;
@Service
@@ -23,18 +31,18 @@ public class UserService {
private final UserRepository userRepository;
private final GiftCodeClient giftCodeClient;
private final RedeemLogRepository redeemLogRepository;
public UserService(UserRepository userRepository, GiftCodeClient giftCodeClient) {
public UserService(UserRepository userRepository, GiftCodeClient giftCodeClient,
RedeemLogRepository redeemLogRepository) {
this.userRepository = userRepository;
this.giftCodeClient = giftCodeClient;
this.redeemLogRepository = redeemLogRepository;
}
public Page<UserResponse> getAllUsers(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return userRepository.findAll(pageable)
.map(UserResponse::from);
return userRepository.findAll(pageable).map(UserResponse::from);
}
public User create(String name, String email, String password, String phone, String sex) {
@@ -56,12 +64,64 @@ public class UserService {
if (!ObjectId.isValid(id)) {
throw new InvalidUserIdException(id);
}
User user = userRepository.findById(id).orElseThrow(() -> new RuntimeException("User not found"));
User user = userRepository.findById(id).orElseThrow(() -> new UserNotFoundException(id));
return UserResponse.from(user);
}
public UserApplyGiftCodeResponse applyGiftCode(UserApplyGiftCodeRequest req) {
User user = userRepository.findById(req.getUserId()).orElseThrow(() -> new RuntimeException("User not found"));
return giftCodeClient.applyGiftCode(req.getUserId(), req.getGiftCode());
String txId = UUID.randomUUID().toString();
// 1. Reserve Remotely (Saga Start)
Integer amount;
try {
amount = giftCodeClient.reserve(txId, req.getUserId(), req.getGiftCode());
} catch (Exception e) {
throw new GiftCodeReservationException(e.getMessage());
}
// 2. Local Transaction
try {
processLocalRedeem(txId, req.getUserId(), req.getGiftCode(), amount);
} catch (Exception e) {
// Compensate Remote
try {
giftCodeClient.cancel(txId);
} catch (Exception ignored) {
}
throw new LocalProcessingException(e.getMessage());
}
// 3. Confirm Remote
try {
giftCodeClient.confirm(txId);
updateLogStatus(txId, RedeemLogStatus.SUCCESS);
} catch (Exception e) {
// Confirm failed (Network mainly), Job will retry
// Log status is still PENDING_CONFIRM
System.err.println("Confirm failed for " + txId + ": " + e.getMessage());
}
return new UserApplyGiftCodeResponse("Gift code applied successfully", "success", amount, txId);
}
@Transactional
public void processLocalRedeem(String txId, String userId, String code, Integer amount) {
User user = userRepository.findById(userId).orElseThrow(() -> new UserNotFoundException(userId));
if (user.getBalance() == null)
user.setBalance(0L);
user.setBalance(user.getBalance() + amount);
userRepository.save(user);
RedeemLog log = new RedeemLog(txId, userId, code, amount, RedeemLogStatus.PENDING_CONFIRM);
redeemLogRepository.save(log);
}
@Transactional
public void updateLogStatus(String txId, RedeemLogStatus status) {
RedeemLog log = redeemLogRepository.findByTransactionId(txId);
if (log != null) {
log.setStatus(status);
redeemLogRepository.save(log);
}
}
}

View File

@@ -0,0 +1,19 @@
server:
port: 8601
giftcode:
service:
url: http://localhost:8602
internal:
api-key: "dken-secret-key-2024"
spring:
application:
name: user-service
data:
mongodb:
uri: mongodb://localhost:27017/userdb
redis:
host: localhost
port: 6379

View File

@@ -1,16 +1,19 @@
server:
port: 8601
giftcode:
service:
url: http://localhost:8602
url: http://giftcode-service:8602
internal:
api-key: "dken-secret-key-2024"
spring:
application:
name: user-service
data:
mongodb:
host: localhost
port: 27017
database: userdb
uri: mongodb://mongodb:27017/userdb
redis:
host: localhost
host: redis
port: 6379