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 build: ./user-service
ports: ports:
- "8601:8601" - "8601:8601"
environment:
- SPRING_PROFILES_ACTIVE=docker
depends_on: depends_on:
- redis - redis
- mongodb - mongodb
@@ -20,8 +18,6 @@ services:
build: ./giftcode-service build: ./giftcode-service
ports: ports:
- "8602:8602" - "8602:8602"
environment:
- SPRING_PROFILES_ACTIVE=docker
depends_on: depends_on:
- redis - redis
- mongodb - mongodb

View File

@@ -9,9 +9,9 @@
<relativePath/> <!-- lookup parent from repository --> <relativePath/> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>com.dken</groupId> <groupId>com.dken</groupId>
<artifactId>gift-code-service</artifactId> <artifactId>giftcode-service</artifactId>
<version>0.0.1-SNAPSHOT</version> <version>0.0.1-SNAPSHOT</version>
<name>gift-code-service</name> <name>giftcode-service</name>
<description>Gift Code Service</description> <description>Gift Code Service</description>
<url/> <url/>
<licenses> <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.GiftCodeCreateRequest;
import com.dken.giftCodeService.dto.request.UserApplyGiftCodeRequest; import com.dken.giftcodeservice.dto.request.GiftCodeTransactionRequest;
import com.dken.giftCodeService.dto.response.GiftCodeResponse; import com.dken.giftcodeservice.dto.response.GiftCodeResponse;
import com.dken.giftCodeService.dto.response.UserApplyGiftCodeResponse; import com.dken.giftcodeservice.service.GiftCodeService;
import com.dken.giftCodeService.service.GiftCodeService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@@ -13,6 +12,8 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Min;
import java.util.Map;
@RestController @RestController
@RequestMapping("/gift-codes") @RequestMapping("/gift-codes")
public class GiftCodeController { public class GiftCodeController {
@@ -36,8 +37,18 @@ public class GiftCodeController {
return giftCodeService.getGiftCodeByCode(code); return giftCodeService.getGiftCodeByCode(code);
} }
@PostMapping("/apply") @PostMapping("/reserve")
public UserApplyGiftCodeResponse applyGiftCode(@Valid @RequestBody UserApplyGiftCodeRequest req) { public Integer reserve(@Valid @RequestBody GiftCodeTransactionRequest req) {
return giftCodeService.applyGiftCode(req.getGiftCode(), req.getUserId()); 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.NotBlank;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@@ -23,12 +23,13 @@ public class GiftCodeCreateRequest {
private boolean active; private boolean active;
private Instant startAt; private Instant startAt;
private Instant endAt; private Instant endAt;
private int amount;
public GiftCodeCreateRequest() { public GiftCodeCreateRequest() {
} }
public GiftCodeCreateRequest(String code, String type, int usage, int limit, boolean multiUse, boolean onePerType, 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.code = code;
this.type = type; this.type = type;
this.usage = usage; this.usage = usage;
@@ -37,6 +38,7 @@ public class GiftCodeCreateRequest {
this.onePerType = onePerType; this.onePerType = onePerType;
this.startAt = startAt; this.startAt = startAt;
this.endAt = endAt; this.endAt = endAt;
this.amount = amount;
} }
// getter only // getter only
@@ -75,4 +77,8 @@ public class GiftCodeCreateRequest {
public Instant getEndAt() { public Instant getEndAt() {
return endAt; 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 { public class UserApplyGiftCodeRequest {
private String userId; private String userId;
private String giftCode; private String giftCode;
private String requestId;
public UserApplyGiftCodeRequest(String userId, String giftCode) { public UserApplyGiftCodeRequest(String userId, String giftCode, String requestId) {
this.userId = userId; this.userId = userId;
this.giftCode = giftCode; this.giftCode = giftCode;
this.requestId = requestId;
} }
public String getUserId() { public String getUserId() {
@@ -24,4 +26,12 @@ public class UserApplyGiftCodeRequest {
public void setGiftCode(String giftCode) { public void setGiftCode(String giftCode) {
this.giftCode = 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 java.time.Instant;
import com.dken.giftCodeService.model.GiftCode; import com.dken.giftcodeservice.model.GiftCode;
public class GiftCodeResponse { public class GiftCodeResponse {
@@ -16,15 +16,17 @@ public class GiftCodeResponse {
private Instant endAt; private Instant endAt;
private Instant createdAt; private Instant createdAt;
private boolean active; private boolean active;
private int amount;
public static GiftCodeResponse from(GiftCode giftCode) { public static GiftCodeResponse from(GiftCode giftCode) {
return new GiftCodeResponse(giftCode.getCode(), giftCode.isMultiUse(), giftCode.isOnePerType(), return new GiftCodeResponse(giftCode.getCode(), giftCode.isMultiUse(), giftCode.isOnePerType(),
giftCode.getType(), giftCode.getUsageLimit(), giftCode.getUsageCount(), giftCode.isActive(), 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, 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.code = code;
this.multiUse = multiUse; this.multiUse = multiUse;
this.onePerType = onePerType; this.onePerType = onePerType;
@@ -35,6 +37,7 @@ public class GiftCodeResponse {
this.startAt = startAt; this.startAt = startAt;
this.endAt = endAt; this.endAt = endAt;
this.createdAt = createdAt; this.createdAt = createdAt;
this.amount = amount;
} }
// getter only // getter only
@@ -77,4 +80,8 @@ public class GiftCodeResponse {
public Instant getCreatedAt() { public Instant getCreatedAt() {
return createdAt; 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 { public class UserApplyGiftCodeResponse {
private String message; private String message;
private String status; private String status;
private String description; private Integer amount;
private String requestId;
public UserApplyGiftCodeResponse() { public UserApplyGiftCodeResponse() {
} }
public UserApplyGiftCodeResponse(String message, String status, String description) { public UserApplyGiftCodeResponse(String message, String status, Integer amount, String requestId) {
this.message = message; this.message = message;
this.status = status; this.status = status;
this.description = description; this.amount = amount;
this.requestId = requestId;
} }
// getters and setters // getters and setters
@@ -31,11 +33,19 @@ public class UserApplyGiftCodeResponse {
this.status = status; this.status = status;
} }
public String getDescription() { public String getRequestId() {
return description; return requestId;
} }
public void setDescription(String description) { public void setRequestId(String requestId) {
this.description = description; 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 { public class UserConfirmGiftCodeResponse {
private String userId; 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 class DefaultException extends RuntimeException {
public DefaultException(String message) { 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.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler; 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.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.mongodb.config.EnableMongoAuditing; import org.springframework.data.mongodb.config.EnableMongoAuditing;
import org.springframework.data.web.config.EnableSpringDataWebSupport;
import org.springframework.data.web.config.EnableSpringDataWebSupport.PageSerializationMode;
@SpringBootApplication @SpringBootApplication
@EnableMongoAuditing @EnableMongoAuditing
public class giftCodeServiceApplication { @org.springframework.scheduling.annotation.EnableScheduling
@EnableSpringDataWebSupport(pageSerializationMode = PageSerializationMode.VIA_DTO)
public class GiftCodeServiceApplication {
public static void main(String[] args) { 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.annotation.Id;
import org.springframework.data.mongodb.core.mapping.Document; import org.springframework.data.mongodb.core.mapping.Document;
@@ -26,6 +26,7 @@ public class GiftCode {
private Instant startAt; private Instant startAt;
private Instant endAt; private Instant endAt;
private boolean active; private boolean active;
private Integer amount;
@CreatedDate @CreatedDate
private Instant createdAt; private Instant createdAt;
@@ -39,7 +40,7 @@ public class GiftCode {
} }
public GiftCode(String code, boolean multiUse, boolean onePerType, String type, Integer usageLimit, 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.code = code;
this.multiUse = multiUse; this.multiUse = multiUse;
this.onePerType = onePerType; this.onePerType = onePerType;
@@ -49,6 +50,7 @@ public class GiftCode {
this.startAt = startAt; this.startAt = startAt;
this.endAt = endAt; this.endAt = endAt;
this.active = true; this.active = true;
this.amount = amount;
} }
// ===== business methods ===== // ===== business methods =====
@@ -174,4 +176,12 @@ public class GiftCode {
public void setUpdatedAt(Instant updatedAt) { public void setUpdatedAt(Instant updatedAt) {
this.updatedAt = 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; 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.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -11,4 +11,4 @@ import java.util.Optional;
public interface GiftCodeRepository extends MongoRepository<GiftCode, String> { public interface GiftCodeRepository extends MongoRepository<GiftCode, String> {
Optional<GiftCode> findByCode(String code); Optional<GiftCode> findByCode(String code);
} }

View File

@@ -1,5 +1,7 @@
package com.dken.giftCodeService.repository; package com.dken.giftcodeservice.repository;
public interface GiftCodeRepositoryCustom { public interface GiftCodeRepositoryCustom {
boolean increaseUsageIfAvailable(String code); 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.FindAndModifyOptions;
import org.springframework.data.mongodb.core.MongoTemplate; import org.springframework.data.mongodb.core.MongoTemplate;
import org.springframework.data.mongodb.core.aggregation.ComparisonOperators; import org.springframework.data.mongodb.core.aggregation.ComparisonOperators;
@@ -12,32 +12,40 @@ import org.springframework.stereotype.Repository;
@Repository @Repository
public class GiftCodeRepositoryImpl implements GiftCodeRepositoryCustom { public class GiftCodeRepositoryImpl implements GiftCodeRepositoryCustom {
private final MongoTemplate mongoTemplate; private final MongoTemplate mongoTemplate;
public GiftCodeRepositoryImpl(MongoTemplate mongoTemplate) { public GiftCodeRepositoryImpl(MongoTemplate mongoTemplate) {
this.mongoTemplate = mongoTemplate; this.mongoTemplate = mongoTemplate;
} }
@Override @Override
public boolean increaseUsageIfAvailable(String code) { public boolean increaseUsageIfAvailable(String code) {
Query query = new Query( Query query = new Query(
new Criteria().andOperator( new Criteria().andOperator(
Criteria.where("code").is(code), Criteria.where("code").is(code),
Criteria.where("active").is(true), Criteria.where("active").is(true),
new Criteria().orOperator( new Criteria().orOperator(
Criteria.where("usageLimit").is(null), Criteria.where("usageLimit").is(null),
Criteria.expr( Criteria.expr(
ComparisonOperators.Lt.valueOf("usageCount") ComparisonOperators.Lt
.lessThan("usageLimit"))))); .valueOf("usageCount")
.lessThan("usageLimit")))));
Update update = new Update().inc("usageCount", 1); Update update = new Update().inc("usageCount", 1);
GiftCode updated = mongoTemplate.findAndModify( GiftCode updated = mongoTemplate.findAndModify(
query, query,
update, update,
FindAndModifyOptions.options().returnNew(true), FindAndModifyOptions.options().returnNew(true),
GiftCode.class); 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.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Repository; 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; 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;
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.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@@ -20,6 +22,9 @@ import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Service @Service
@@ -28,37 +33,35 @@ public class GiftCodeService {
private final GiftCodeRepository giftCodeRepository; private final GiftCodeRepository giftCodeRepository;
private final GiftCodeRepositoryImpl giftCodeRepositoryImpl; private final GiftCodeRepositoryImpl giftCodeRepositoryImpl;
private final GiftCodeUsageRepository usageRepo; private final GiftCodeUsageRepository usageRepo;
private final RedeemTransactionRepository redeemTransactionRepository;
private final RedisLockService lockService; private final RedisLockService lockService;
public GiftCodeService(GiftCodeRepository giftCodeRepository, public GiftCodeService(GiftCodeRepository giftCodeRepository,
GiftCodeRepositoryImpl giftCodeRepositoryImpl, GiftCodeRepositoryImpl giftCodeRepositoryImpl,
GiftCodeUsageRepository usageRepo, GiftCodeUsageRepository usageRepo,
RedeemTransactionRepository redeemTransactionRepository,
RedisLockService lockService) { RedisLockService lockService) {
this.giftCodeRepository = giftCodeRepository; this.giftCodeRepository = giftCodeRepository;
this.giftCodeRepositoryImpl = giftCodeRepositoryImpl; this.giftCodeRepositoryImpl = giftCodeRepositoryImpl;
this.usageRepo = usageRepo; this.usageRepo = usageRepo;
this.redeemTransactionRepository = redeemTransactionRepository;
this.lockService = lockService; this.lockService = lockService;
} }
public Page<GiftCodeResponse> getAllGiftCodes(int page, int size) { public Page<GiftCodeResponse> getAllGiftCodes(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); 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) { private static String hashType(String type) {
try { try {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(type.getBytes(StandardCharsets.UTF_8)); byte[] hash = digest.digest(type.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(); StringBuilder hex = new StringBuilder();
for (byte b : hash) { for (byte b : hash) {
hex.append(String.format("%02x", b)); hex.append(String.format("%02x", b));
} }
return hex.toString(); return hex.toString();
} catch (NoSuchAlgorithmException e) { } catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }
@@ -66,21 +69,14 @@ public class GiftCodeService {
String generateCode(String type) { String generateCode(String type) {
String typeHash = hashType(type).substring(0, 6); String typeHash = hashType(type).substring(0, 6);
String randomPart = UUID.randomUUID() String randomPart = UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase();
.toString()
.replace("-", "")
.substring(0, 16)
.toUpperCase();
return (typeHash + randomPart).toUpperCase(); return (typeHash + randomPart).toUpperCase();
} }
public GiftCode create(GiftCodeCreateRequest req) { public GiftCode create(GiftCodeCreateRequest req) {
if (req.getCode() == null || req.getCode().isEmpty()) { if (req.getCode() == null || req.getCode().isEmpty()) {
String code = generateCode(req.getType()); req.setCode(generateCode(req.getType()));
req.setCode(code);
} }
GiftCode giftCode = new GiftCode(); GiftCode giftCode = new GiftCode();
giftCode.setCode(req.getCode()); giftCode.setCode(req.getCode());
giftCode.setMultiUse(req.isMultiUse()); giftCode.setMultiUse(req.isMultiUse());
@@ -91,12 +87,12 @@ public class GiftCodeService {
giftCode.setActive(req.isActive()); giftCode.setActive(req.isActive());
giftCode.setStartAt(req.getStartAt()); giftCode.setStartAt(req.getStartAt());
giftCode.setEndAt(req.getEndAt()); giftCode.setEndAt(req.getEndAt());
giftCode.setAmount(req.getAmount());
return giftCodeRepository.save(giftCode); return giftCodeRepository.save(giftCode);
} }
public GiftCodeResponse createGiftCode(GiftCodeCreateRequest req) { public GiftCodeResponse createGiftCode(GiftCodeCreateRequest req) {
GiftCode giftCode = create(req); return GiftCodeResponse.from(create(req));
return GiftCodeResponse.from(giftCode);
} }
public GiftCodeResponse getGiftCodeByCode(String code) { public GiftCodeResponse getGiftCodeByCode(String code) {
@@ -105,59 +101,98 @@ public class GiftCodeService {
return GiftCodeResponse.from(giftCode); return GiftCodeResponse.from(giftCode);
} }
public UserApplyGiftCodeResponse applyGiftCode(String code, String userId) { @Transactional
String lockKey = "lock:giftcode:" + code + ":user:" + userId; public Integer reserve(GiftCodeTransactionRequest req) {
try { Optional<RedeemTransaction> txOpt = redeemTransactionRepository.findByTransactionId(req.getTransactionId());
if (!lockService.lock(lockKey, java.time.Duration.ofSeconds(5))) { if (txOpt.isPresent()) {
throw new DefaultException("Please wait and try again"); 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")); .orElseThrow(() -> new RuntimeException("Gift code not found"));
if (!giftCode.canUse()) { if (!giftCode.canUse()) {
throw new DefaultException("Gift code is not available"); throw new DefaultException("Gift code is not available");
} }
if (!giftCode.isMultiUse()) {
boolean used = usageRepo.existsByGiftCodeAndUserId(
giftCode.getCode(), userId);
if (used) { checkUsageHistory(giftCode, req.getUserId());
throw new DefaultException("Gift code has already been used");
} 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, RedeemTransaction tx = new RedeemTransaction(req.getTransactionId(), req.getCode(), req.getUserId(),
giftCode.getType(), RedeemStatus.RESERVED, Instant.now());
giftCode.getCode()); redeemTransactionRepository.save(tx);
if (used) {
throw new DefaultException("Gift code has already been used"); return giftCode.getAmount();
}
}
effectGiftCode(giftCode, userId);
return new UserApplyGiftCodeResponse("Gift code applied successfully", "success", "");
} catch (Exception e) {
return new UserApplyGiftCodeResponse(
e.getMessage(),
"failed",
"ROLLBACK");
} finally { } finally {
lockService.unlock(lockKey); lockService.unlock(lockKey);
} }
} }
@Transactional @Transactional
void effectGiftCode(GiftCode giftCode, String userId) { public void confirm(String transactionId) {
boolean increased = giftCodeRepositoryImpl RedeemTransaction tx = redeemTransactionRepository.findByTransactionId(transactionId)
.increaseUsageIfAvailable(giftCode.getCode()); .orElseThrow(() -> new DefaultException("Transaction not found"));
if (!increased) { if (tx.getStatus() == RedeemStatus.USED)
throw new DefaultException("Gift code is out of limit"); return;
} if (tx.getStatus() == RedeemStatus.CANCELLED)
throw new DefaultException("Transaction cancelled");
GiftCodeUsage usage = new GiftCodeUsage(giftCode.getId(), giftCode.getCode(), giftCode.isOnePerType(), tx.setStatus(RedeemStatus.USED);
userId); 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); 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.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component; 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: server:
port: 8602 port: 8602
internal:
api-key: "dken-secret-key-2024"
spring: spring:
application: application:
name: gift-code-service name: giftcode-service
data: data:
mongodb: mongodb:
host: localhost host: mongodb
port: 27017 port: 27017
database: gift-codedb database: giftcodedb
redis: redis:
host: localhost host: redis
port: 6379 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 FROM eclipse-temurin:21-jre
WORKDIR /app WORKDIR /app
COPY --from=build /build/target/*.jar app.jar 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 @SpringBootApplication
@EnableMongoAuditing @EnableMongoAuditing
@org.springframework.scheduling.annotation.EnableScheduling
public class UserServiceApplication { public class UserServiceApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(UserServiceApplication.class, args); SpringApplication.run(UserServiceApplication.class, args);

View File

@@ -1,10 +1,10 @@
package com.dken.userservice.client; package com.dken.userservice.client;
import org.springframework.web.client.RestClient;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.dken.userservice.dto.response.UserApplyGiftCodeResponse; import org.springframework.web.client.RestClient;
import com.dken.userservice.dto.request.GiftCodeValidateRequest;
import java.util.Map;
@Component @Component
public class GiftCodeClient { public class GiftCodeClient {
@@ -15,15 +15,27 @@ public class GiftCodeClient {
this.restClient = restClient; this.restClient = restClient;
} }
public UserApplyGiftCodeResponse applyGiftCode( public Integer reserve(String transactionId, String userId, String code) {
String userId, return restClient.post()
String giftCode) { .uri("/gift-codes/reserve")
.body(Map.of("transactionId", transactionId, "userId", userId, "code", code))
return restClient
.post()
.uri("/gift-codes/apply")
.body(new GiftCodeValidateRequest(userId, giftCode))
.retrieve() .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") @Bean("giftCodeRestClient")
public RestClient 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() return RestClient.builder()
.baseUrl(baseUrl) .baseUrl(baseUrl)
.defaultHeader("X-Internal-Key", apiKey)
.requestInterceptor((request, body, execution) -> { .requestInterceptor((request, body, execution) -> {
return execution.execute(request, body); 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; package com.dken.userservice.dto.request;
public record GiftCodeValidateRequest( public record GiftCodeValidateRequest(
String userId, String userId,
String giftCode) { String giftCode,
String requestId) {
} }

View File

@@ -1,22 +1,19 @@
package com.dken.userservice.dto.response; package com.dken.userservice.dto.response;
public class UserApplyGiftCodeResponse { public class UserApplyGiftCodeResponse {
private String status;
private String message; private String message;
private String description; private String status;
private Integer amount;
private String requestId;
public UserApplyGiftCodeResponse(String status, String message, String description) { public UserApplyGiftCodeResponse() {
this.status = status; }
public UserApplyGiftCodeResponse(String message, String status, Integer amount, String requestId) {
this.message = message; this.message = message;
this.description = description;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status; this.status = status;
this.amount = amount;
this.requestId = requestId;
} }
public String getMessage() { public String getMessage() {
@@ -27,11 +24,27 @@ public class UserApplyGiftCodeResponse {
this.message = message; this.message = message;
} }
public String getDescription() { public String getStatus() {
return description; return status;
} }
public void setDescription(String description) { public void setStatus(String status) {
this.description = description; 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 name;
private String email; private String email;
private String phone; private String phone;
private Long balance = 0L;
public String getPhone() { public String getPhone() {
return phone; return phone;
@@ -36,15 +37,17 @@ public class UserResponse {
user.getName(), user.getName(),
user.getEmail(), user.getEmail(),
user.getPhone(), 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.id = id;
this.name = name; this.name = name;
this.email = email; this.email = email;
this.phone = phone; this.phone = phone;
this.sex = sex; this.sex = sex;
this.balance = balance;
} }
// Getters and Setters // Getters and Setters
@@ -71,4 +74,12 @@ public class UserResponse {
public void setEmail(String email) { public void setEmail(String email) {
this.email = 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) { public Map<String, String> handleNotFound(UserNotFoundException ex) {
return Map.of("message", ex.getMessage()); 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 email;
private String phone; private String phone;
private UserSex sex; private UserSex sex;
private Long balance = 0L;
private UserStatus status; private UserStatus status;
@@ -102,6 +103,14 @@ public class User {
this.sex = sex; this.sex = sex;
} }
public Long getBalance() {
return balance;
}
public void setBalance(Long balance) {
this.balance = balance;
}
public String getPassword() { public String getPassword() {
return password; 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.request.UserApplyGiftCodeRequest;
import com.dken.userservice.dto.response.UserApplyGiftCodeResponse; import com.dken.userservice.dto.response.UserApplyGiftCodeResponse;
import com.dken.userservice.dto.response.UserResponse; 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.User;
import com.dken.userservice.model.UserSex; import com.dken.userservice.model.UserSex;
import com.dken.userservice.repository.RedeemLogRepository;
import com.dken.userservice.repository.UserRepository; import com.dken.userservice.repository.UserRepository;
import com.dken.userservice.exception.InvalidUserIdException; 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.bson.types.ObjectId;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
@Service @Service
@@ -23,18 +31,18 @@ public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final GiftCodeClient giftCodeClient; 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.userRepository = userRepository;
this.giftCodeClient = giftCodeClient; this.giftCodeClient = giftCodeClient;
this.redeemLogRepository = redeemLogRepository;
} }
public Page<UserResponse> getAllUsers(int page, int size) { public Page<UserResponse> getAllUsers(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); 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) { public User create(String name, String email, String password, String phone, String sex) {
@@ -56,12 +64,64 @@ public class UserService {
if (!ObjectId.isValid(id)) { if (!ObjectId.isValid(id)) {
throw new InvalidUserIdException(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); return UserResponse.from(user);
} }
public UserApplyGiftCodeResponse applyGiftCode(UserApplyGiftCodeRequest req) { public UserApplyGiftCodeResponse applyGiftCode(UserApplyGiftCodeRequest req) {
User user = userRepository.findById(req.getUserId()).orElseThrow(() -> new RuntimeException("User not found")); String txId = UUID.randomUUID().toString();
return giftCodeClient.applyGiftCode(req.getUserId(), req.getGiftCode());
// 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: server:
port: 8601 port: 8601
giftcode: giftcode:
service: service:
url: http://localhost:8602 url: http://giftcode-service:8602
internal:
api-key: "dken-secret-key-2024"
spring: spring:
application: application:
name: user-service name: user-service
data: data:
mongodb: mongodb:
host: localhost uri: mongodb://mongodb:27017/userdb
port: 27017
database: userdb
redis: redis:
host: localhost host: redis
port: 6379 port: 6379