-
Notifications
You must be signed in to change notification settings - Fork 34
[volume-9] Product Ranking with Redis #228
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6ead99e
8e06929
5569bf6
ee02ce7
e1d3f05
b399fde
a143f91
0096dae
7f05b54
ca1fed1
d5280ea
05b7b2a
227d932
003b543
00498cd
1ad67da
4a13366
bc962b2
0605dc4
f1d94ed
a51021c
a946ac0
33c0ab8
b73c2c6
8d5e90a
d6711cf
7a56684
ded9e38
5b83c03
3158c2b
e287600
9c5e6ea
3774002
8e5643a
ef3eebd
d738654
b06e034
6c48755
d9ae7ad
5bb8d33
d505115
624b780
a97d77b
55f8c8b
592a4e5
4faa67e
86a8205
d06a0d0
5042c22
4fda674
25b423e
04ff345
7e0ac82
1087ea2
3e2e0f4
5db5c36
aa374c3
c74e6ad
c80ed47
c5754ff
deda1e2
617746d
be18c88
0074ea9
b84525a
52b62bd
34df8d5
4ca321e
339132b
d321b49
caec6fa
72517a8
1cfeaa3
e102c1d
cc0c139
a75c7ef
b397ce4
ccc015e
dcab5ea
d2586f9
82284a4
dc745c9
da195a3
6c87e71
4071e77
acc30d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| name: PR Agent | ||
| on: | ||
| pull_request: | ||
| types: [opened, synchronize] | ||
| jobs: | ||
| pr_agent_job: | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - name: PR Agent action step | ||
| uses: Codium-ai/pr-agent@main | ||
| env: | ||
| OPENAI_KEY: ${{ secrets.OPENAI_KEY }} | ||
| GITHUB_TOKEN: ${{ secrets.G_TOKEN }} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import com.loopers.domain.product.Product; | ||
|
|
||
| import java.math.BigDecimal; | ||
|
|
||
| public record ProductRankingInfo(Long id, Long brandId, String name, BigDecimal price, int stock, int likeCount, int rank, double score) { | ||
| public static ProductRankingInfo from(Product product, RankingInfo ranking) { | ||
| return new ProductRankingInfo( | ||
| product.getId(), | ||
| product.getBrandId(), | ||
| product.getName(), | ||
| product.getPrice(), | ||
| product.getStock(), | ||
| product.getLikeCount(), | ||
| ranking.rank(), | ||
| ranking.score() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| public record RankingInfo( | ||
| String date, | ||
| double score, | ||
| int rank, | ||
| Long total | ||
| ) { | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,40 @@ | ||||||
| package com.loopers.application.product; | ||||||
|
|
||||||
| import lombok.RequiredArgsConstructor; | ||||||
| import org.springframework.data.redis.core.RedisCallback; | ||||||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||||||
| import org.springframework.data.redis.serializer.RedisSerializer; | ||||||
| import org.springframework.stereotype.Component; | ||||||
|
|
||||||
| import java.util.List; | ||||||
|
|
||||||
| @Component | ||||||
| @RequiredArgsConstructor | ||||||
| public class RankingRedisReader { | ||||||
| private final StringRedisTemplate redisTemplate; | ||||||
|
|
||||||
| public RankingInfo getDailyRanking(String date, Long productId) { | ||||||
| String key = "ranking:all:" + date; | ||||||
| String member = String.valueOf(productId); | ||||||
|
|
||||||
| RedisSerializer<String> serializer = redisTemplate.getStringSerializer(); | ||||||
| byte[] keyBytes = serializer.serialize(key); | ||||||
| byte[] memberBytes = serializer.serialize(member); | ||||||
|
|
||||||
| @SuppressWarnings("unchecked") | ||||||
| List<Object> results = (List<Object>) redisTemplate.executePipelined((RedisCallback<Object>) connection -> { | ||||||
| connection.zScore(keyBytes, memberBytes); // -> Double (or null) | ||||||
| connection.zRevRank(keyBytes, memberBytes); // -> Long (or null) 0-base | ||||||
| connection.zCard(keyBytes); // -> Long | ||||||
| return null; | ||||||
| }); | ||||||
|
|
||||||
| Double score = (Double) results.get(0); | ||||||
| Long revRank0 = (Long) results.get(1); | ||||||
| Long total = (Long) results.get(2); | ||||||
|
|
||||||
| Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1); | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
π μμ ν λ³ν μ μ-Integer rank = (revRank0 == null) ? null : Math.toIntExact(revRank0 + 1);
+Integer rank = (revRank0 == null) ? null : (int) Math.min(revRank0 + 1, Integer.MAX_VALUE);π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||
|
|
||||||
| return new RankingInfo(date, score, rank, total); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,70 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| package com.loopers.application.ranking; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import com.loopers.interfaces.api.ranking.RankingV1Dto; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import lombok.RequiredArgsConstructor; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.data.redis.core.ZSetOperations; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import org.springframework.stereotype.Component; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.LocalDate; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.ZoneId; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import java.time.format.DateTimeFormatter; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.ArrayList; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.List; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import java.util.Set; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| @Component | ||||||||||||||||||||||||||||||||||||||||||||||||
| @RequiredArgsConstructor | ||||||||||||||||||||||||||||||||||||||||||||||||
| public class RankingFacade { | ||||||||||||||||||||||||||||||||||||||||||||||||
| private final StringRedisTemplate redisTemplate; | ||||||||||||||||||||||||||||||||||||||||||||||||
| private static final ZoneId KST = ZoneId.of("Asia/Seoul"); | ||||||||||||||||||||||||||||||||||||||||||||||||
| private static final DateTimeFormatter YYYYMMDD = DateTimeFormatter.BASIC_ISO_DATE; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| public RankingV1Dto.ProductRankingPageResponse getDailyProductRanking(int page, int size) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (page < 1) page = 1; | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (size < 1) size = 20; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| String date = LocalDate.now(KST).format(YYYYMMDD); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| String key = "ranking:all:" + date; | ||||||||||||||||||||||||||||||||||||||||||||||||
| ZSetOperations<String, String> zset = redisTemplate.opsForZSet(); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Long total = zset.size(key); | ||||||||||||||||||||||||||||||||||||||||||||||||
| long totalElements = (total == null) ? 0 : total; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (totalElements == 0) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return new RankingV1Dto.ProductRankingPageResponse(date, page, size, 0, 0, List.of()); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| long start = (long) (page - 1) * size; | ||||||||||||||||||||||||||||||||||||||||||||||||
| long end = start + size - 1; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (start >= totalElements) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| int totalPages = (int) Math.ceil((double) totalElements / size); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, List.of()); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Set<ZSetOperations.TypedTuple<String>> tuples = | ||||||||||||||||||||||||||||||||||||||||||||||||
| zset.reverseRangeWithScores(key, start, end); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| List<RankingV1Dto.ProductRankingResponse> items = new ArrayList<>(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (tuples != null) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| long rank = start + 1; | ||||||||||||||||||||||||||||||||||||||||||||||||
| for (var t : tuples) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| String member = t.getValue(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| Double score = t.getScore(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (member == null || score == null) continue; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| items.add(new RankingV1Dto.ProductRankingResponse( | ||||||||||||||||||||||||||||||||||||||||||||||||
| rank++, | ||||||||||||||||||||||||||||||||||||||||||||||||
| Long.parseLong(member), | ||||||||||||||||||||||||||||||||||||||||||||||||
| score | ||||||||||||||||||||||||||||||||||||||||||||||||
| )); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+58
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Redisμ μλͺ»λ νμμ member λ°μ΄ν°κ° μ‘΄μ¬ν κ²½μ° π μμΈ μ²λ¦¬ μΆκ° μμ for (var t : tuples) {
String member = t.getValue();
Double score = t.getScore();
if (member == null || score == null) continue;
+ Long productId;
+ try {
+ productId = Long.parseLong(member);
+ } catch (NumberFormatException e) {
+ continue; // μλͺ»λ λ°μ΄ν°λ μ€ν΅
+ }
+
items.add(new RankingV1Dto.ProductRankingResponse(
rank++,
- Long.parseLong(member),
+ productId,
score
));
}π Committable suggestion
Suggested change
π€ Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| int totalPages = (int) Math.ceil((double) totalElements / size); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return new RankingV1Dto.ProductRankingPageResponse(date, page, size, totalElements, totalPages, items); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.loopers.interfaces.api.ranking; | ||
|
|
||
| import com.loopers.interfaces.api.ApiResponse; | ||
|
|
||
| public interface RankingV1ApiSpec { | ||
|
|
||
| ApiResponse<RankingV1Dto.ProductRankingPageResponse> getDailyProductRanking(int size, int page); | ||
|
|
||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package com.loopers.interfaces.api.ranking; | ||
|
|
||
| import com.loopers.application.ranking.RankingFacade; | ||
| import com.loopers.interfaces.api.ApiResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RequestParam; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/v1/rankings") | ||
| @RequiredArgsConstructor | ||
| public class RankingV1Controller implements RankingV1ApiSpec { | ||
| private final RankingFacade rankingFacade; | ||
|
|
||
| @GetMapping | ||
| @Override | ||
| public ApiResponse<RankingV1Dto.ProductRankingPageResponse> getDailyProductRanking( | ||
| @RequestParam int size, | ||
| @RequestParam int page | ||
| ) { | ||
| RankingV1Dto.ProductRankingPageResponse response = rankingFacade.getDailyProductRanking(page, size); | ||
|
|
||
| return ApiResponse.success(response); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.loopers.interfaces.api.ranking; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public class RankingV1Dto { | ||
| public record ProductRankingResponse( | ||
| Long rank, | ||
| Long productId, | ||
| double score | ||
| ) {} | ||
|
|
||
| public record ProductRankingPageResponse( | ||
| String date, | ||
| int page, | ||
| int size, | ||
| long totalElements, | ||
| int totalPages, | ||
| List<ProductRankingResponse> items | ||
| ) {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| dependencies { | ||
| // add-ons | ||
| implementation(project(":modules:jpa")) | ||
| implementation(project(":modules:redis")) | ||
| implementation(project(":supports:jackson")) | ||
| implementation(project(":supports:logging")) | ||
| implementation(project(":supports:monitoring")) | ||
|
|
||
| // batch | ||
| implementation("org.springframework.boot:spring-boot-starter-batch") | ||
| testImplementation("org.springframework.batch:spring-batch-test") | ||
|
|
||
| // querydsl | ||
| annotationProcessor("com.querydsl:querydsl-apt::jakarta") | ||
| annotationProcessor("jakarta.persistence:jakarta.persistence-api") | ||
| annotationProcessor("jakarta.annotation:jakarta.annotation-api") | ||
|
|
||
| // test-fixtures | ||
| testImplementation(testFixtures(project(":modules:jpa"))) | ||
| testImplementation(testFixtures(project(":modules:redis"))) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| package com.loopers; | ||
|
|
||
| import jakarta.annotation.PostConstruct; | ||
| import org.springframework.boot.SpringApplication; | ||
| import org.springframework.boot.autoconfigure.SpringBootApplication; | ||
| import org.springframework.boot.context.properties.ConfigurationPropertiesScan; | ||
|
|
||
| import java.util.TimeZone; | ||
|
|
||
| @ConfigurationPropertiesScan | ||
| @SpringBootApplication | ||
| public class CommerceBatchApplication { | ||
|
|
||
| @PostConstruct | ||
| public void started() { | ||
| // set timezone | ||
| TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); | ||
| } | ||
|
|
||
| public static void main(String[] args) { | ||
| int exitCode = SpringApplication.exit(SpringApplication.run(CommerceBatchApplication.class, args)); | ||
| System.exit(exitCode); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
μμΈλ₯Ό 무μνλ
catch (Exception ignored)ν¨ν΄ κ°μ νμλͺ¨λ μμΈλ₯Ό μ‘°μ©ν μΌν€λ©΄ Redis μ°κ²° μ€ν¨, μ§λ ¬ν μ€λ₯ λ± μ€μν λ¬Έμ λ₯Ό λλ²κΉ νκΈ° μ΄λ ΅μ΅λλ€. μ΅μν λ‘κΉ μ μΆκ°νμΈμ.
π λ‘κΉ μΆκ° μμ
try { String date = LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); ranking = rankingRedisReader.getDailyRanking(date, product.getId()); -} catch (Exception ignored) {} +} catch (Exception e) { + log.warn("Failed to fetch ranking for product {}: {}", product.getId(), e.getMessage()); +}π€ Prompt for AI Agents