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

@@ -10,8 +10,6 @@ services:
build: ./user-service
ports:
- "8601:8601"
environment:
- SPRING_PROFILES_ACTIVE=docker
depends_on:
- redis
- mongodb
@@ -20,8 +18,6 @@ services:
build: ./giftcode-service
ports:
- "8602:8602"
environment:
- SPRING_PROFILES_ACTIVE=docker
depends_on:
- redis
- mongodb

View File

@@ -9,9 +9,9 @@
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.dken</groupId>
<artifactId>gift-code-service</artifactId>
<artifactId>giftcode-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gift-code-service</name>
<name>giftcode-service</name>
<description>Gift Code Service</description>
<url/>
<licenses>

View File

@@ -0,0 +1,41 @@
package com.dken.giftcodeservice.config;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
@Order(1)
public class InternalAuthFilter implements Filter {
@Value("${internal.api-key}")
private String internalApiKey;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String path = httpRequest.getRequestURI();
if (path.startsWith("/v3/api-docs") || path.startsWith("/swagger-ui")) {
chain.doFilter(request, response);
return;
}
String apiKey = httpRequest.getHeader("X-Internal-Key");
if (internalApiKey.equals(apiKey)) {
chain.doFilter(request, response);
} else {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("Unauthorized: Invalid Internal API Key");
}
}
}

View File

@@ -0,0 +1,34 @@
package com.dken.giftcodeservice.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/giftcodedb}")
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,10 +1,9 @@
package com.dken.giftCodeService.controller;
package com.dken.giftcodeservice.controller;
import com.dken.giftCodeService.dto.request.GiftCodeCreateRequest;
import com.dken.giftCodeService.dto.request.UserApplyGiftCodeRequest;
import com.dken.giftCodeService.dto.response.GiftCodeResponse;
import com.dken.giftCodeService.dto.response.UserApplyGiftCodeResponse;
import com.dken.giftCodeService.service.GiftCodeService;
import com.dken.giftcodeservice.dto.request.GiftCodeCreateRequest;
import com.dken.giftcodeservice.dto.request.GiftCodeTransactionRequest;
import com.dken.giftcodeservice.dto.response.GiftCodeResponse;
import com.dken.giftcodeservice.service.GiftCodeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
@@ -13,6 +12,8 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import java.util.Map;
@RestController
@RequestMapping("/gift-codes")
public class GiftCodeController {
@@ -36,8 +37,18 @@ public class GiftCodeController {
return giftCodeService.getGiftCodeByCode(code);
}
@PostMapping("/apply")
public UserApplyGiftCodeResponse applyGiftCode(@Valid @RequestBody UserApplyGiftCodeRequest req) {
return giftCodeService.applyGiftCode(req.getGiftCode(), req.getUserId());
@PostMapping("/reserve")
public Integer reserve(@Valid @RequestBody GiftCodeTransactionRequest req) {
return giftCodeService.reserve(req);
}
@PostMapping("/confirm")
public void confirm(@RequestBody Map<String, String> body) {
giftCodeService.confirm(body.get("transactionId"));
}
@PostMapping("/cancel")
public void cancel(@RequestBody Map<String, String> body) {
giftCodeService.cancel(body.get("transactionId"));
}
}

View File

@@ -1,4 +1,4 @@
package com.dken.giftCodeService.dto.request;
package com.dken.giftcodeservice.dto.request;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
@@ -23,12 +23,13 @@ public class GiftCodeCreateRequest {
private boolean active;
private Instant startAt;
private Instant endAt;
private int amount;
public GiftCodeCreateRequest() {
}
public GiftCodeCreateRequest(String code, String type, int usage, int limit, boolean multiUse, boolean onePerType,
Instant startAt, Instant endAt) {
Instant startAt, Instant endAt, int amount) {
this.code = code;
this.type = type;
this.usage = usage;
@@ -37,6 +38,7 @@ public class GiftCodeCreateRequest {
this.onePerType = onePerType;
this.startAt = startAt;
this.endAt = endAt;
this.amount = amount;
}
// getter only
@@ -75,4 +77,8 @@ public class GiftCodeCreateRequest {
public Instant getEndAt() {
return endAt;
}
public int getAmount() {
return amount;
}
}

View File

@@ -0,0 +1,43 @@
package com.dken.giftcodeservice.dto.request;
import jakarta.validation.constraints.NotBlank;
public class GiftCodeTransactionRequest {
@NotBlank
private String transactionId;
private String userId;
private String code;
public GiftCodeTransactionRequest() {
}
public GiftCodeTransactionRequest(String transactionId, String userId, String code) {
this.transactionId = transactionId;
this.userId = userId;
this.code = code;
}
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;
}
}

View File

@@ -1,12 +1,14 @@
package com.dken.giftCodeService.dto.request;
package com.dken.giftcodeservice.dto.request;
public class UserApplyGiftCodeRequest {
private String userId;
private String giftCode;
private String requestId;
public UserApplyGiftCodeRequest(String userId, String giftCode) {
public UserApplyGiftCodeRequest(String userId, String giftCode, String requestId) {
this.userId = userId;
this.giftCode = giftCode;
this.requestId = requestId;
}
public String getUserId() {
@@ -24,4 +26,12 @@ public class UserApplyGiftCodeRequest {
public void setGiftCode(String giftCode) {
this.giftCode = giftCode;
}
public String getRequestId() {
return requestId;
}
public void setRequestId(String requestId) {
this.requestId = requestId;
}
}

View File

@@ -1,8 +1,8 @@
package com.dken.giftCodeService.dto.response;
package com.dken.giftcodeservice.dto.response;
import java.time.Instant;
import com.dken.giftCodeService.model.GiftCode;
import com.dken.giftcodeservice.model.GiftCode;
public class GiftCodeResponse {
@@ -16,15 +16,17 @@ public class GiftCodeResponse {
private Instant endAt;
private Instant createdAt;
private boolean active;
private int amount;
public static GiftCodeResponse from(GiftCode giftCode) {
return new GiftCodeResponse(giftCode.getCode(), giftCode.isMultiUse(), giftCode.isOnePerType(),
giftCode.getType(), giftCode.getUsageLimit(), giftCode.getUsageCount(), giftCode.isActive(),
giftCode.getStartAt(), giftCode.getEndAt(), giftCode.getCreatedAt());
giftCode.getStartAt(), giftCode.getEndAt(), giftCode.getCreatedAt(), giftCode.getAmount());
}
public GiftCodeResponse(String code, boolean multiUse, boolean onePerType, String type,
Integer usageLimit, Integer usageCount, boolean active, Instant startAt, Instant endAt, Instant createdAt) {
Integer usageLimit, Integer usageCount, boolean active, Instant startAt, Instant endAt, Instant createdAt,
int amount) {
this.code = code;
this.multiUse = multiUse;
this.onePerType = onePerType;
@@ -35,6 +37,7 @@ public class GiftCodeResponse {
this.startAt = startAt;
this.endAt = endAt;
this.createdAt = createdAt;
this.amount = amount;
}
// getter only
@@ -77,4 +80,8 @@ public class GiftCodeResponse {
public Instant getCreatedAt() {
return createdAt;
}
public int getAmount() {
return amount;
}
}

View File

@@ -1,17 +1,19 @@
package com.dken.giftCodeService.dto.response;
package com.dken.giftcodeservice.dto.response;
public class UserApplyGiftCodeResponse {
private String message;
private String status;
private String description;
private Integer amount;
private String requestId;
public UserApplyGiftCodeResponse() {
}
public UserApplyGiftCodeResponse(String message, String status, String description) {
public UserApplyGiftCodeResponse(String message, String status, Integer amount, String requestId) {
this.message = message;
this.status = status;
this.description = description;
this.amount = amount;
this.requestId = requestId;
}
// getters and setters
@@ -31,11 +33,19 @@ public class UserApplyGiftCodeResponse {
this.status = status;
}
public String getDescription() {
return description;
public String getRequestId() {
return requestId;
}
public void setDescription(String description) {
this.description = description;
public void setRequestId(String requestId) {
this.requestId = requestId;
}
public Integer getAmount() {
return amount;
}
public void setAmount(Integer amount) {
this.amount = amount;
}
}

View File

@@ -1,4 +1,4 @@
package com.dken.giftCodeService.dto.response;
package com.dken.giftcodeservice.dto.response;
public class UserConfirmGiftCodeResponse {
private String userId;

View File

@@ -1,4 +1,4 @@
package com.dken.giftCodeService.exception;
package com.dken.giftcodeservice.exception;
public class DefaultException extends RuntimeException {
public DefaultException(String message) {

View File

@@ -1,4 +1,4 @@
package com.dken.giftCodeService.exception;
package com.dken.giftcodeservice.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;

View File

@@ -1,15 +1,19 @@
package com.dken.giftCodeService;
package com.dken.giftcodeservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.mongodb.config.EnableMongoAuditing;
import org.springframework.data.web.config.EnableSpringDataWebSupport;
import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode;
@SpringBootApplication
@EnableMongoAuditing
public class giftCodeServiceApplication {
@org.springframework.scheduling.annotation.EnableScheduling
@EnableSpringDataWebSupport(pageSerializationMode = PageSerializationMode.VIA_DTO)
public class GiftCodeServiceApplication {
public static void main(String[] args) {
SpringApplication.run(giftCodeServiceApplication.class, args);
SpringApplication.run(GiftCodeServiceApplication.class, args);
}
}

View File

@@ -1,4 +1,4 @@
package com.dken.giftCodeService.model;
package com.dken.giftcodeservice.model;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document;
@@ -26,6 +26,7 @@ public class GiftCode {
private Instant startAt;
private Instant endAt;
private boolean active;
private Integer amount;
@CreatedDate
private Instant createdAt;
@@ -39,7 +40,7 @@ public class GiftCode {
}
public GiftCode(String code, boolean multiUse, boolean onePerType, String type, Integer usageLimit,
Instant startAt, Instant endAt) {
Instant startAt, Instant endAt, Integer amount) {
this.code = code;
this.multiUse = multiUse;
this.onePerType = onePerType;
@@ -49,6 +50,7 @@ public class GiftCode {
this.startAt = startAt;
this.endAt = endAt;
this.active = true;
this.amount = amount;
}
// ===== business methods =====
@@ -174,4 +176,12 @@ public class GiftCode {
public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = updatedAt;
}
public Integer getAmount() {
return amount == null ? 0 : amount;
}
public void setAmount(Integer amount) {
this.amount = amount;
}
}

View File

@@ -1,4 +1,4 @@
package com.dken.giftCodeService.model;
package com.dken.giftcodeservice.model;
import java.time.Instant;

View File

@@ -0,0 +1,7 @@
package com.dken.giftcodeservice.model;
public enum RedeemStatus {
RESERVED,
USED,
CANCELLED
}

View File

@@ -0,0 +1,105 @@
package com.dken.giftcodeservice.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_transactions")
public class RedeemTransaction {
@Id
private String id;
@Indexed(unique = true)
private String transactionId;
@Indexed
private String code;
@Indexed
private String userId;
private RedeemStatus status;
private Instant reservedAt;
private Instant usedAt;
private Instant cancelledAt;
public RedeemTransaction() {
}
public RedeemTransaction(String transactionId, String code, String userId, RedeemStatus status,
Instant reservedAt) {
this.transactionId = transactionId;
this.code = code;
this.userId = userId;
this.status = status;
this.reservedAt = reservedAt;
}
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 getCode() {
return code;
}
public void setCode(String code) {
this.code = code;
}
public String getUserId() {
return userId;
}
public void setUserId(String userId) {
this.userId = userId;
}
public RedeemStatus getStatus() {
return status;
}
public void setStatus(RedeemStatus status) {
this.status = status;
}
public Instant getReservedAt() {
return reservedAt;
}
public void setReservedAt(Instant reservedAt) {
this.reservedAt = reservedAt;
}
public Instant getUsedAt() {
return usedAt;
}
public void setUsedAt(Instant usedAt) {
this.usedAt = usedAt;
}
public Instant getCancelledAt() {
return cancelledAt;
}
public void setCancelledAt(Instant cancelledAt) {
this.cancelledAt = cancelledAt;
}
}

View File

@@ -1,7 +1,7 @@
package com.dken.giftCodeService.repository;
package com.dken.giftcodeservice.repository;
import com.dken.giftCodeService.model.GiftCode;
import com.dken.giftcodeservice.model.GiftCode;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;

View File

@@ -1,5 +1,7 @@
package com.dken.giftCodeService.repository;
package com.dken.giftcodeservice.repository;
public interface GiftCodeRepositoryCustom {
boolean increaseUsageIfAvailable(String code);
void decreaseUsage(String code);
}

View File

@@ -1,6 +1,6 @@
package com.dken.giftCodeService.repository;
package com.dken.giftcodeservice.repository;
import com.dken.giftCodeService.model.GiftCode;
import com.dken.giftcodeservice.model.GiftCode;
import org.springframework.data.mongodb.core.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.ComparisonOperators;
@@ -12,32 +12,40 @@ import org.springframework.stereotype.Repository;
@Repository
public class GiftCodeRepositoryImpl implements GiftCodeRepositoryCustom {
private final MongoTemplate mongoTemplate;
private final MongoTemplate mongoTemplate;
public GiftCodeRepositoryImpl(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
public GiftCodeRepositoryImpl(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate;
}
@Override
public boolean increaseUsageIfAvailable(String code) {
Query query = new Query(
new Criteria().andOperator(
Criteria.where("code").is(code),
Criteria.where("active").is(true),
new Criteria().orOperator(
Criteria.where("usageLimit").is(null),
Criteria.expr(
ComparisonOperators.Lt.valueOf("usageCount")
.lessThan("usageLimit")))));
@Override
public boolean increaseUsageIfAvailable(String code) {
Query query = new Query(
new Criteria().andOperator(
Criteria.where("code").is(code),
Criteria.where("active").is(true),
new Criteria().orOperator(
Criteria.where("usageLimit").is(null),
Criteria.expr(
ComparisonOperators.Lt
.valueOf("usageCount")
.lessThan("usageLimit")))));
Update update = new Update().inc("usageCount", 1);
Update update = new Update().inc("usageCount", 1);
GiftCode updated = mongoTemplate.findAndModify(
query,
update,
FindAndModifyOptions.options().returnNew(true),
GiftCode.class);
GiftCode updated = mongoTemplate.findAndModify(
query,
update,
FindAndModifyOptions.options().returnNew(true),
GiftCode.class);
return updated != null;
}
return updated != null;
}
@Override
public void decreaseUsage(String code) {
Query query = new Query(Criteria.where("code").is(code).and("usageCount").gt(0));
Update update = new Update().inc("usageCount", -1);
mongoTemplate.updateFirst(query, update, GiftCode.class);
}
}

View File

@@ -1,6 +1,6 @@
package com.dken.giftCodeService.repository;
package com.dken.giftcodeservice.repository;
import com.dken.giftCodeService.model.GiftCodeUsage;
import com.dken.giftcodeservice.model.GiftCodeUsage;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository;

View File

@@ -0,0 +1,17 @@
package com.dken.giftcodeservice.repository;
import com.dken.giftcodeservice.model.RedeemTransaction;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.mongodb.repository.Query;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
public interface RedeemTransactionRepository extends MongoRepository<RedeemTransaction, String> {
Optional<RedeemTransaction> findByTransactionId(String transactionId);
// Find reserved transactions older than a certain time
@Query("{ 'status': 'RESERVED', 'reservedAt': { $lt: ?0 } }")
List<RedeemTransaction> findStuckReservedTransactions(Instant timeLimit);
}

View File

@@ -0,0 +1,40 @@
package com.dken.giftcodeservice.scheduler;
import com.dken.giftcodeservice.model.RedeemTransaction;
import com.dken.giftcodeservice.repository.RedeemTransactionRepository;
import com.dken.giftcodeservice.service.GiftCodeService;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
@Component
public class ReclaimScheduler {
private final RedeemTransactionRepository redeemTransactionRepository;
private final GiftCodeService giftCodeService;
public ReclaimScheduler(RedeemTransactionRepository redeemTransactionRepository, GiftCodeService giftCodeService) {
this.redeemTransactionRepository = redeemTransactionRepository;
this.giftCodeService = giftCodeService;
}
@Scheduled(fixedDelay = 60000) // 1 minute
public void reclaim() {
Instant fiveMinutesAgo = Instant.now().minus(5, ChronoUnit.MINUTES);
List<RedeemTransaction> stuckTransactions = redeemTransactionRepository
.findStuckReservedTransactions(fiveMinutesAgo);
for (RedeemTransaction tx : stuckTransactions) {
try {
giftCodeService.cancel(tx.getTransactionId());
System.out.println("Reclaimed stuck transaction: " + tx.getTransactionId());
} catch (Exception e) {
System.err.println("Failed to reclaim transaction: " + tx.getTransactionId());
e.printStackTrace();
}
}
}
}

View File

@@ -1,15 +1,17 @@
package com.dken.giftCodeService.service;
import com.dken.giftCodeService.model.GiftCode;
import com.dken.giftCodeService.model.GiftCodeUsage;
import com.dken.giftCodeService.exception.DefaultException;
import com.dken.giftCodeService.repository.GiftCodeRepository;
import com.dken.giftCodeService.repository.GiftCodeRepositoryImpl;
import com.dken.giftCodeService.repository.GiftCodeUsageRepository;
import com.dken.giftCodeService.dto.request.GiftCodeCreateRequest;
import com.dken.giftCodeService.dto.response.GiftCodeResponse;
import com.dken.giftCodeService.dto.response.UserApplyGiftCodeResponse;
package com.dken.giftcodeservice.service;
import com.dken.giftcodeservice.model.GiftCode;
import com.dken.giftcodeservice.model.GiftCodeUsage;
import com.dken.giftcodeservice.model.RedeemStatus;
import com.dken.giftcodeservice.model.RedeemTransaction;
import com.dken.giftcodeservice.dto.request.GiftCodeCreateRequest;
import com.dken.giftcodeservice.dto.request.GiftCodeTransactionRequest;
import com.dken.giftcodeservice.dto.response.GiftCodeResponse;
import com.dken.giftcodeservice.exception.DefaultException;
import com.dken.giftcodeservice.repository.GiftCodeRepository;
import com.dken.giftcodeservice.repository.GiftCodeRepositoryImpl;
import com.dken.giftcodeservice.repository.GiftCodeUsageRepository;
import com.dken.giftcodeservice.repository.RedeemTransactionRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
@@ -20,6 +22,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
@Service
@@ -28,37 +33,35 @@ public class GiftCodeService {
private final GiftCodeRepository giftCodeRepository;
private final GiftCodeRepositoryImpl giftCodeRepositoryImpl;
private final GiftCodeUsageRepository usageRepo;
private final RedeemTransactionRepository redeemTransactionRepository;
private final RedisLockService lockService;
public GiftCodeService(GiftCodeRepository giftCodeRepository,
GiftCodeRepositoryImpl giftCodeRepositoryImpl,
GiftCodeUsageRepository usageRepo,
RedeemTransactionRepository redeemTransactionRepository,
RedisLockService lockService) {
this.giftCodeRepository = giftCodeRepository;
this.giftCodeRepositoryImpl = giftCodeRepositoryImpl;
this.usageRepo = usageRepo;
this.redeemTransactionRepository = redeemTransactionRepository;
this.lockService = lockService;
}
public Page<GiftCodeResponse> getAllGiftCodes(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
return giftCodeRepository.findAll(pageable)
.map(GiftCodeResponse::from);
return giftCodeRepository.findAll(pageable).map(GiftCodeResponse::from);
}
private static String hashType(String type) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(type.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : hash) {
hex.append(String.format("%02x", b));
}
return hex.toString();
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
@@ -66,21 +69,14 @@ public class GiftCodeService {
String generateCode(String type) {
String typeHash = hashType(type).substring(0, 6);
String randomPart = UUID.randomUUID()
.toString()
.replace("-", "")
.substring(0, 16)
.toUpperCase();
String randomPart = UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
return (typeHash + randomPart).toUpperCase();
}
public GiftCode create(GiftCodeCreateRequest req) {
if (req.getCode() == null || req.getCode().isEmpty()) {
String code = generateCode(req.getType());
req.setCode(code);
req.setCode(generateCode(req.getType()));
}
GiftCode giftCode = new GiftCode();
giftCode.setCode(req.getCode());
giftCode.setMultiUse(req.isMultiUse());
@@ -91,12 +87,12 @@ public class GiftCodeService {
giftCode.setActive(req.isActive());
giftCode.setStartAt(req.getStartAt());
giftCode.setEndAt(req.getEndAt());
giftCode.setAmount(req.getAmount());
return giftCodeRepository.save(giftCode);
}
public GiftCodeResponse createGiftCode(GiftCodeCreateRequest req) {
GiftCode giftCode = create(req);
return GiftCodeResponse.from(giftCode);
return GiftCodeResponse.from(create(req));
}
public GiftCodeResponse getGiftCodeByCode(String code) {
@@ -105,59 +101,98 @@ public class GiftCodeService {
return GiftCodeResponse.from(giftCode);
}
public UserApplyGiftCodeResponse applyGiftCode(String code, String userId) {
String lockKey = "lock:giftcode:" + code + ":user:" + userId;
try {
if (!lockService.lock(lockKey, java.time.Duration.ofSeconds(5))) {
throw new DefaultException("Please wait and try again");
@Transactional
public Integer reserve(GiftCodeTransactionRequest req) {
Optional<RedeemTransaction> txOpt = redeemTransactionRepository.findByTransactionId(req.getTransactionId());
if (txOpt.isPresent()) {
RedeemTransaction tx = txOpt.get();
if (tx.getStatus() == RedeemStatus.CANCELLED) {
throw new DefaultException("Transaction already cancelled");
}
GiftCode code = giftCodeRepository.findByCode(req.getCode()).orElseThrow();
return code.getAmount();
}
GiftCode giftCode = giftCodeRepository.findByCode(code)
String lockKey = "lock:reserve:" + req.getUserId();
if (!lockService.lock(lockKey, Duration.ofSeconds(5))) {
throw new DefaultException("System busy");
}
try {
GiftCode giftCode = giftCodeRepository.findByCode(req.getCode())
.orElseThrow(() -> new RuntimeException("Gift code not found"));
if (!giftCode.canUse()) {
throw new DefaultException("Gift code is not available");
}
if (!giftCode.isMultiUse()) {
boolean used = usageRepo.existsByGiftCodeAndUserId(
giftCode.getCode(), userId);
if (used) {
throw new DefaultException("Gift code has already been used");
}
checkUsageHistory(giftCode, req.getUserId());
boolean increased = giftCodeRepositoryImpl.increaseUsageIfAvailable(giftCode.getCode());
if (!increased) {
throw new DefaultException("Gift code is out of limit");
}
if (giftCode.isOnePerType()) {
boolean used = usageRepo.existsByUserIdAndTypeAndGiftCodeNotAndOnePerTypeTrue(userId,
giftCode.getType(),
giftCode.getCode());
if (used) {
throw new DefaultException("Gift code has already been used");
}
}
effectGiftCode(giftCode, userId);
return new UserApplyGiftCodeResponse("Gift code applied successfully", "success", "");
} catch (Exception e) {
return new UserApplyGiftCodeResponse(
e.getMessage(),
"failed",
"ROLLBACK");
RedeemTransaction tx = new RedeemTransaction(req.getTransactionId(), req.getCode(), req.getUserId(),
RedeemStatus.RESERVED, Instant.now());
redeemTransactionRepository.save(tx);
return giftCode.getAmount();
} finally {
lockService.unlock(lockKey);
}
}
@Transactional
void effectGiftCode(GiftCode giftCode, String userId) {
boolean increased = giftCodeRepositoryImpl
.increaseUsageIfAvailable(giftCode.getCode());
public void confirm(String transactionId) {
RedeemTransaction tx = redeemTransactionRepository.findByTransactionId(transactionId)
.orElseThrow(() -> new DefaultException("Transaction not found"));
if (!increased) {
throw new DefaultException("Gift code is out of limit");
}
if (tx.getStatus() == RedeemStatus.USED)
return;
if (tx.getStatus() == RedeemStatus.CANCELLED)
throw new DefaultException("Transaction cancelled");
GiftCodeUsage usage = new GiftCodeUsage(giftCode.getId(), giftCode.getCode(), giftCode.isOnePerType(),
userId);
tx.setStatus(RedeemStatus.USED);
tx.setUsedAt(Instant.now());
redeemTransactionRepository.save(tx);
GiftCode giftCode = giftCodeRepository.findByCode(tx.getCode()).orElseThrow();
GiftCodeUsage usage = new GiftCodeUsage(giftCode.getCode(), giftCode.getType(), giftCode.isOnePerType(),
tx.getUserId());
usageRepo.save(usage);
}
@Transactional
public void cancel(String transactionId) {
RedeemTransaction tx = redeemTransactionRepository.findByTransactionId(transactionId)
.orElseThrow(() -> new DefaultException("Transaction not found"));
if (tx.getStatus() == RedeemStatus.CANCELLED)
return;
if (tx.getStatus() == RedeemStatus.USED)
throw new DefaultException("Cannot cancel used transaction");
tx.setStatus(RedeemStatus.CANCELLED);
tx.setCancelledAt(Instant.now());
redeemTransactionRepository.save(tx);
giftCodeRepositoryImpl.decreaseUsage(tx.getCode());
}
private void checkUsageHistory(GiftCode giftCode, String userId) {
if (!giftCode.isMultiUse()) {
boolean used = usageRepo.existsByGiftCodeAndUserId(giftCode.getCode(), userId);
if (used) {
throw new DefaultException("Gift code has already been used");
}
}
if (giftCode.isOnePerType()) {
boolean used = usageRepo.existsByUserIdAndTypeAndGiftCodeNotAndOnePerTypeTrue(userId, giftCode.getType(),
giftCode.getCode());
if (used) {
throw new DefaultException("Gift code type has already been used");
}
}
}
}

View File

@@ -1,4 +1,4 @@
package com.dken.giftCodeService.service;
package com.dken.giftcodeservice.service;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

View File

@@ -0,0 +1,17 @@
server:
port: 8602
internal:
api-key: "dken-secret-key-2024"
spring:
application:
name: giftcode-service
data:
mongodb:
host: localhost
port: 27017
database: giftcodedb
redis:
host: localhost
port: 6379

View File

@@ -1,14 +1,17 @@
server:
port: 8602
internal:
api-key: "dken-secret-key-2024"
spring:
application:
name: gift-code-service
name: giftcode-service
data:
mongodb:
host: localhost
host: mongodb
port: 27017
database: gift-codedb
database: giftcodedb
redis:
host: localhost
host: redis
port: 6379

View File

@@ -0,0 +1,13 @@
package com.dken.giftcodeservice;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class GiftCodeServiceApplicationTests {
@Test
void contextLoads() {
}
}

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