Procházet zdrojové kódy

会员管理代码

wwh před 1 týdnem
rodič
revize
db8610b134

+ 120 - 66
baqing-shop/src/main/java/com/ruoyi/web/modules/openstats/service/impl/MallStatsOpenServiceImpl.java

@@ -10,6 +10,7 @@ import java.util.Comparator;
10 10
 import java.util.HashMap;
11 11
 import java.util.List;
12 12
 import java.util.Map;
13
+import java.util.concurrent.ConcurrentHashMap;
13 14
 import java.util.concurrent.TimeUnit;
14 15
 import org.springframework.beans.factory.annotation.Autowired;
15 16
 import org.springframework.stereotype.Service;
@@ -42,6 +43,8 @@ public class MallStatsOpenServiceImpl implements IMallStatsOpenService
42 43
 {
43 44
     private static final ZoneId ZONE_SHANGHAI = ZoneId.of("Asia/Shanghai");
44 45
 
46
+    private final ConcurrentHashMap<Integer, Object> yearlyRefreshLocks = new ConcurrentHashMap<>();
47
+
45 48
     @Autowired
46 49
     private MallStatsOpenMapper statsMapper;
47 50
 
@@ -55,110 +58,161 @@ public class MallStatsOpenServiceImpl implements IMallStatsOpenService
55 58
     public CategorySalesVO getCategorySales(String statYear)
56 59
     {
57 60
         int year = resolveStatYear(statYear);
58
-        String cacheKey = StatsCacheKeys.categorySales(year);
59
-        CategorySalesVO cached = redisCache.getCacheObject(cacheKey);
60
-        if (cached != null)
61
-        {
62
-            return cached;
63
-        }
64
-        List<CategoryQtyRow> rows = safeList(statsMapper.selectCategoryQtyByFinishYear(year));
65
-        CategorySalesVO vo = buildCategorySales(year, rows);
66
-        redisCache.setCacheObject(cacheKey, vo, OpenStatsConstants.CACHE_TTL_YEARLY_HOUR, TimeUnit.HOURS);
67
-        return vo;
61
+        ensureYearlyStatsCached(year);
62
+        return redisCache.getCacheObject(StatsCacheKeys.categorySales(year));
68 63
     }
69 64
 
70 65
     @Override
71 66
     public HotCategoryRankVO getHotCategoryRank(String statYear)
72 67
     {
73 68
         int year = resolveStatYear(statYear);
74
-        String cacheKey = StatsCacheKeys.hotCategory(year);
75
-        HotCategoryRankVO cached = redisCache.getCacheObject(cacheKey);
76
-        if (cached != null)
77
-        {
78
-            return cached;
79
-        }
80
-        List<CategoryQtyRow> rows = safeList(statsMapper.selectCategoryQtyByFinishYear(year));
81
-        HotCategoryRankVO vo = buildHotCategoryRank(year, rows);
82
-        redisCache.setCacheObject(cacheKey, vo, OpenStatsConstants.CACHE_TTL_YEARLY_HOUR, TimeUnit.HOURS);
83
-        return vo;
69
+        ensureYearlyStatsCached(year);
70
+        return redisCache.getCacheObject(StatsCacheKeys.hotCategory(year));
84 71
     }
85 72
 
86 73
     @Override
87 74
     public OrderTrendVO getOrderTrend(String statYear)
88 75
     {
89 76
         int year = resolveStatYear(statYear);
90
-        String cacheKey = StatsCacheKeys.orderTrend(year);
91
-        OrderTrendVO cached = redisCache.getCacheObject(cacheKey);
92
-        if (cached != null)
93
-        {
94
-            return cached;
95
-        }
96
-        List<MonthCountRow> rows = safeList(statsMapper.selectOrderCountByYear(year));
97
-        OrderTrendVO vo = buildOrderTrend(year, rows);
98
-        redisCache.setCacheObject(cacheKey, vo, OpenStatsConstants.CACHE_TTL_YEARLY_HOUR, TimeUnit.HOURS);
99
-        return vo;
77
+        ensureYearlyStatsCached(year);
78
+        return redisCache.getCacheObject(StatsCacheKeys.orderTrend(year));
100 79
     }
101 80
 
102 81
     @Override
103 82
     public ShopEntryVO getShopEntry(String statYear)
104 83
     {
105 84
         int year = resolveStatYear(statYear);
106
-        String cacheKey = StatsCacheKeys.shopEntry(year);
107
-        ShopEntryVO cached = redisCache.getCacheObject(cacheKey);
108
-        if (cached != null)
109
-        {
110
-            return cached;
111
-        }
112
-        List<MonthCountRow> rows = safeList(statsMapper.selectShopCountByYear(year));
113
-        ShopEntryVO vo = buildShopEntry(year, rows);
114
-        redisCache.setCacheObject(cacheKey, vo, OpenStatsConstants.CACHE_TTL_YEARLY_HOUR, TimeUnit.HOURS);
115
-        return vo;
85
+        ensureYearlyStatsCached(year);
86
+        return redisCache.getCacheObject(StatsCacheKeys.shopEntry(year));
116 87
     }
117 88
 
118 89
     @Override
119 90
     public RegionRankVO getRegionRank(String statYear)
120 91
     {
121 92
         int year = resolveStatYear(statYear);
122
-        String cacheKey = StatsCacheKeys.regionRank(year);
123
-        RegionRankVO cached = redisCache.getCacheObject(cacheKey);
124
-        if (cached != null)
125
-        {
126
-            return cached;
127
-        }
128
-        List<RegionAmountRow> rows = safeList(statsMapper.selectRegionAmountByYear(year));
129
-        RegionRankVO vo = buildRegionRank(year, rows);
130
-        redisCache.setCacheObject(cacheKey, vo, OpenStatsConstants.CACHE_TTL_YEARLY_HOUR, TimeUnit.HOURS);
131
-        return vo;
93
+        ensureYearlyStatsCached(year);
94
+        return redisCache.getCacheObject(StatsCacheKeys.regionRank(year));
132 95
     }
133 96
 
134 97
     @Override
135 98
     public ReviewWordCloudVO getReviewWordCloud()
136 99
     {
137
-        String cacheKey = StatsCacheKeys.wordCloud();
138
-        ReviewWordCloudVO cached = redisCache.getCacheObject(cacheKey);
139
-        if (cached != null)
140
-        {
141
-            return cached;
142
-        }
143
-        List<String> contents = safeList(statsMapper.selectReviewContentsForWordCloud());
144
-        ReviewWordCloudVO vo = wordCloudSupport.buildTopWords(contents);
145
-        redisCache.setCacheObject(cacheKey, vo, OpenStatsConstants.CACHE_TTL_WORDCLOUD_HOUR, TimeUnit.HOURS);
146
-        return vo;
100
+        ensureWordCloudCached();
101
+        return redisCache.getCacheObject(StatsCacheKeys.wordCloud());
147 102
     }
148 103
 
149 104
     @Override
150 105
     public StatsOverviewVO getOverview(String statYear)
151 106
     {
107
+        int year = resolveStatYear(statYear);
108
+        ensureOverviewStatsCached(year);
152 109
         StatsOverviewVO overview = new StatsOverviewVO();
153
-        overview.setCategorySales(getCategorySales(statYear));
154
-        overview.setHotCategoryRank(getHotCategoryRank(statYear));
155
-        overview.setOrderTrend(getOrderTrend(statYear));
156
-        overview.setShopEntry(getShopEntry(statYear));
157
-        overview.setRegionRank(getRegionRank(statYear));
158
-        overview.setReviewWordCloud(getReviewWordCloud());
110
+        overview.setCategorySales(redisCache.getCacheObject(StatsCacheKeys.categorySales(year)));
111
+        overview.setHotCategoryRank(redisCache.getCacheObject(StatsCacheKeys.hotCategory(year)));
112
+        overview.setOrderTrend(redisCache.getCacheObject(StatsCacheKeys.orderTrend(year)));
113
+        overview.setShopEntry(redisCache.getCacheObject(StatsCacheKeys.shopEntry(year)));
114
+        overview.setRegionRank(redisCache.getCacheObject(StatsCacheKeys.regionRank(year)));
115
+        overview.setReviewWordCloud(redisCache.getCacheObject(StatsCacheKeys.wordCloud()));
159 116
         return overview;
160 117
     }
161 118
 
119
+    private void ensureOverviewStatsCached(int year)
120
+    {
121
+        if (isYearlyStatsCached(year) && isWordCloudCached())
122
+        {
123
+            return;
124
+        }
125
+        Object lock = yearlyRefreshLocks.computeIfAbsent(year, key -> new Object());
126
+        synchronized (lock)
127
+        {
128
+            if (!isYearlyStatsCached(year))
129
+            {
130
+                refreshYearlyStatsCache(year);
131
+            }
132
+            if (!isWordCloudCached())
133
+            {
134
+                refreshWordCloudCache();
135
+            }
136
+        }
137
+    }
138
+
139
+    private void ensureYearlyStatsCached(int year)
140
+    {
141
+        if (isYearlyStatsCached(year))
142
+        {
143
+            return;
144
+        }
145
+        Object lock = yearlyRefreshLocks.computeIfAbsent(year, key -> new Object());
146
+        synchronized (lock)
147
+        {
148
+            if (!isYearlyStatsCached(year))
149
+            {
150
+                refreshYearlyStatsCache(year);
151
+            }
152
+        }
153
+    }
154
+
155
+    private void ensureWordCloudCached()
156
+    {
157
+        if (isWordCloudCached())
158
+        {
159
+            return;
160
+        }
161
+        synchronized (MallStatsOpenServiceImpl.class)
162
+        {
163
+            if (!isWordCloudCached())
164
+            {
165
+                refreshWordCloudCache();
166
+            }
167
+        }
168
+    }
169
+
170
+    private boolean isYearlyStatsCached(int year)
171
+    {
172
+        for (String cacheKey : StatsCacheKeys.yearlyStatKeys(year))
173
+        {
174
+            if (redisCache.getCacheObject(cacheKey) == null)
175
+            {
176
+                return false;
177
+            }
178
+        }
179
+        return true;
180
+    }
181
+
182
+    private boolean isWordCloudCached()
183
+    {
184
+        return redisCache.getCacheObject(StatsCacheKeys.wordCloud()) != null;
185
+    }
186
+
187
+    private void refreshYearlyStatsCache(int year)
188
+    {
189
+        List<CategoryQtyRow> categoryRows = safeList(statsMapper.selectCategoryQtyByFinishYear(year));
190
+        CategorySalesVO categorySales = buildCategorySales(year, categoryRows);
191
+        HotCategoryRankVO hotCategory = buildHotCategoryRank(year, new ArrayList<>(categoryRows));
192
+        OrderTrendVO orderTrend = buildOrderTrend(year, safeList(statsMapper.selectOrderCountByYear(year)));
193
+        ShopEntryVO shopEntry = buildShopEntry(year, safeList(statsMapper.selectShopCountByYear(year)));
194
+        RegionRankVO regionRank = buildRegionRank(year, safeList(statsMapper.selectRegionAmountByYear(year)));
195
+
196
+        cacheYearlyStat(StatsCacheKeys.categorySales(year), categorySales);
197
+        cacheYearlyStat(StatsCacheKeys.hotCategory(year), hotCategory);
198
+        cacheYearlyStat(StatsCacheKeys.orderTrend(year), orderTrend);
199
+        cacheYearlyStat(StatsCacheKeys.shopEntry(year), shopEntry);
200
+        cacheYearlyStat(StatsCacheKeys.regionRank(year), regionRank);
201
+    }
202
+
203
+    private void refreshWordCloudCache()
204
+    {
205
+        List<String> contents = safeList(statsMapper.selectReviewContentsForWordCloud());
206
+        ReviewWordCloudVO vo = wordCloudSupport.buildTopWords(contents);
207
+        redisCache.setCacheObject(StatsCacheKeys.wordCloud(), vo,
208
+                OpenStatsConstants.CACHE_TTL_WORDCLOUD_HOUR, TimeUnit.HOURS);
209
+    }
210
+
211
+    private void cacheYearlyStat(String cacheKey, Object value)
212
+    {
213
+        redisCache.setCacheObject(cacheKey, value, OpenStatsConstants.CACHE_TTL_YEARLY_HOUR, TimeUnit.HOURS);
214
+    }
215
+
162 216
     private CategorySalesVO buildCategorySales(int statYear, List<CategoryQtyRow> rows)
163 217
     {
164 218
         CategorySalesVO vo = new CategorySalesVO();

+ 11 - 0
baqing-shop/src/main/java/com/ruoyi/web/modules/openstats/support/StatsCacheKeys.java

@@ -40,4 +40,15 @@ public final class StatsCacheKeys
40 40
     {
41 41
         return PREFIX + "wordcloud";
42 42
     }
43
+
44
+    public static String[] yearlyStatKeys(int statYear)
45
+    {
46
+        return new String[] {
47
+            categorySales(statYear),
48
+            hotCategory(statYear),
49
+            orderTrend(statYear),
50
+            shopEntry(statYear),
51
+            regionRank(statYear)
52
+        };
53
+    }
43 54
 }

+ 51 - 10
baqing-shop/src/test/java/com/ruoyi/web/modules/openstats/service/MallStatsOpenServiceImplTest.java

@@ -8,12 +8,15 @@ import static org.mockito.ArgumentMatchers.anyInt;
8 8
 import static org.mockito.ArgumentMatchers.anyString;
9 9
 import static org.mockito.ArgumentMatchers.eq;
10 10
 import static org.mockito.Mockito.atLeastOnce;
11
+import static org.mockito.Mockito.doAnswer;
11 12
 import static org.mockito.Mockito.times;
12 13
 import static org.mockito.Mockito.verify;
13 14
 import static org.mockito.Mockito.when;
14 15
 import java.math.BigDecimal;
15 16
 import java.util.Arrays;
16 17
 import java.util.Collections;
18
+import java.util.HashMap;
19
+import java.util.Map;
17 20
 import java.util.concurrent.TimeUnit;
18 21
 import org.junit.jupiter.api.BeforeEach;
19 22
 import org.junit.jupiter.api.Test;
@@ -21,6 +24,9 @@ import org.junit.jupiter.api.extension.ExtendWith;
21 24
 import org.mockito.InjectMocks;
22 25
 import org.mockito.Mock;
23 26
 import org.mockito.junit.jupiter.MockitoExtension;
27
+import org.mockito.junit.jupiter.MockitoSettings;
28
+import org.mockito.quality.Strictness;
29
+import org.mockito.stubbing.Answer;
24 30
 import com.ruoyi.common.core.redis.RedisCache;
25 31
 import com.ruoyi.web.modules.openstats.constant.OpenStatsConstants;
26 32
 import com.ruoyi.web.modules.openstats.dto.CategoryQtyRow;
@@ -40,6 +46,7 @@ import com.ruoyi.web.modules.openstats.vo.StatsOverviewVO;
40 46
 import com.ruoyi.web.modules.openstats.vo.WordCloudItemVO;
41 47
 
42 48
 @ExtendWith(MockitoExtension.class)
49
+@MockitoSettings(strictness = Strictness.LENIENT)
43 50
 class MallStatsOpenServiceImplTest
44 51
 {
45 52
     private static final int STAT_YEAR = 2026;
@@ -56,10 +63,20 @@ class MallStatsOpenServiceImplTest
56 63
     @InjectMocks
57 64
     private MallStatsOpenServiceImpl service;
58 65
 
66
+    private final Map<String, Object> cacheStore = new HashMap<>();
67
+
59 68
     @BeforeEach
60
-    void stubCacheMiss()
69
+    void setUpCache()
61 70
     {
62
-        when(redisCache.getCacheObject(anyString())).thenReturn(null);
71
+        cacheStore.clear();
72
+        when(redisCache.getCacheObject(anyString())).thenAnswer(
73
+                invocation -> cacheStore.get(invocation.getArgument(0)));
74
+        Answer<Object> saveCache = invocation -> {
75
+            cacheStore.put(invocation.getArgument(0), invocation.getArgument(1));
76
+            return null;
77
+        };
78
+        doAnswer(saveCache).when(redisCache).setCacheObject(anyString(), any(), anyInt(), any(TimeUnit.class));
79
+        stubYearlyMapperDefaults();
63 80
     }
64 81
 
65 82
     @Test
@@ -77,8 +94,8 @@ class MallStatsOpenServiceImplTest
77 94
         assertEquals(3, vo.getItems().size());
78 95
         double ratioSum = vo.getItems().stream().mapToDouble(i -> i.getRatio()).sum();
79 96
         assertEquals(100.0, ratioSum, 0.05);
80
-        verify(redisCache).setCacheObject(eq(StatsCacheKeys.categorySales(STAT_YEAR)), eq(vo),
81
-                eq(OpenStatsConstants.CACHE_TTL_YEARLY_HOUR), eq(TimeUnit.HOURS));
97
+        assertNotNull(cacheStore.get(StatsCacheKeys.orderTrend(STAT_YEAR)));
98
+        assertNotNull(cacheStore.get(StatsCacheKeys.hotCategory(STAT_YEAR)));
82 99
     }
83 100
 
84 101
     @Test
@@ -99,6 +116,22 @@ class MallStatsOpenServiceImplTest
99 116
         assertEquals(1, vo.getItems().get(0).getRank());
100 117
         assertEquals("A", vo.getItems().get(0).getCategoryName());
101 118
         assertTrue(vo.getItems().stream().noneMatch(i -> "F".equals(i.getCategoryName())));
119
+        assertNotNull(cacheStore.get(StatsCacheKeys.categorySales(STAT_YEAR)));
120
+    }
121
+
122
+    @Test
123
+    void yearlyStatsCache_refreshesAllMetricsTogether()
124
+    {
125
+        service.getOrderTrend("2026");
126
+
127
+        for (String cacheKey : StatsCacheKeys.yearlyStatKeys(STAT_YEAR))
128
+        {
129
+            assertNotNull(cacheStore.get(cacheKey), cacheKey);
130
+        }
131
+        verify(statsMapper, times(1)).selectCategoryQtyByFinishYear(STAT_YEAR);
132
+        verify(statsMapper, times(1)).selectOrderCountByYear(STAT_YEAR);
133
+        verify(statsMapper, times(1)).selectShopCountByYear(STAT_YEAR);
134
+        verify(statsMapper, times(1)).selectRegionAmountByYear(STAT_YEAR);
102 135
     }
103 136
 
104 137
     @Test
@@ -150,7 +183,6 @@ class MallStatsOpenServiceImplTest
150 183
         item.setWord("质量好");
151 184
         item.setCount(10L);
152 185
         cached.getItems().add(item);
153
-        when(redisCache.getCacheObject(StatsCacheKeys.wordCloud())).thenReturn(null, cached);
154 186
         when(statsMapper.selectReviewContentsForWordCloud()).thenReturn(Collections.singletonList("质量很好"));
155 187
         when(wordCloudSupport.buildTopWords(any())).thenReturn(cached);
156 188
 
@@ -165,11 +197,6 @@ class MallStatsOpenServiceImplTest
165 197
     @Test
166 198
     void overview_containsAllSections()
167 199
     {
168
-        when(statsMapper.selectCategoryQtyByFinishYear(anyInt())).thenReturn(Collections.emptyList());
169
-        when(statsMapper.selectOrderCountByYear(anyInt())).thenReturn(Collections.emptyList());
170
-        when(statsMapper.selectShopCountByYear(anyInt())).thenReturn(Collections.emptyList());
171
-        when(statsMapper.selectRegionAmountByYear(anyInt())).thenReturn(Collections.emptyList());
172
-        when(statsMapper.selectReviewContentsForWordCloud()).thenReturn(Collections.emptyList());
173 200
         when(wordCloudSupport.buildTopWords(any())).thenReturn(new ReviewWordCloudVO());
174 201
 
175 202
         StatsOverviewVO overview = service.getOverview("2026");
@@ -181,6 +208,20 @@ class MallStatsOpenServiceImplTest
181 208
         assertNotNull(overview.getRegionRank());
182 209
         assertNotNull(overview.getReviewWordCloud());
183 210
         verify(statsMapper, atLeastOnce()).selectCategoryQtyByFinishYear(STAT_YEAR);
211
+        verify(statsMapper, times(1)).selectReviewContentsForWordCloud();
212
+        for (String cacheKey : StatsCacheKeys.yearlyStatKeys(STAT_YEAR))
213
+        {
214
+            assertNotNull(cacheStore.get(cacheKey));
215
+        }
216
+        assertNotNull(cacheStore.get(StatsCacheKeys.wordCloud()));
217
+    }
218
+
219
+    private void stubYearlyMapperDefaults()
220
+    {
221
+        when(statsMapper.selectCategoryQtyByFinishYear(anyInt())).thenReturn(Collections.emptyList());
222
+        when(statsMapper.selectOrderCountByYear(anyInt())).thenReturn(Collections.emptyList());
223
+        when(statsMapper.selectShopCountByYear(anyInt())).thenReturn(Collections.emptyList());
224
+        when(statsMapper.selectRegionAmountByYear(anyInt())).thenReturn(Collections.emptyList());
184 225
     }
185 226
 
186 227
     private CategoryQtyRow row(Long id, String name, Long qty)

+ 5 - 2
doc/平台后台/外部接口/商城数据统计技术方案.md

@@ -356,8 +356,10 @@ Header: X-Open-Token: {token}
356 356
 | 规则 |
357 357
 |------|
358 358
 | **Cache-Aside**:先读 Redis,miss 再查 DB 并写入 |
359
+| **年度指标捆绑刷新**:同一 `statYear` 下 **5 项年度指标**(品类销售、热销、订单趋势、入驻、区域)**任一缓存 miss 或不全** 时,**一次性重建并写入全部 5 个 key**,避免各接口数据时间点不一致 |
360
+| **`overview`**:在年度捆绑就绪且词云缓存存在时组装;**任一缺失** 时 **同步刷新年度捆绑 + 词云** 后再返回 |
359 361
 | 订单 **确认收货/自动确认** 后 **不主动删缓存**;靠 TTL **准实时**(对齐功能需求) |
360
-| **`overview`** 内部并行调 6 段逻辑,**共用** 各 key |
362
+| **`overview` / 单接口** 均 **复用** 上述子 key,不再单独刷新部分指标 |
361 363
 
362 364
 ### 5.3 性能约束
363 365
 
@@ -420,7 +422,8 @@ Header: X-Open-Token: {token}
420 422
 | 版本 | 说明 |
421 423
 |------|------|
422 424
 | **v1.0** | 首版:RuoYi3.9.2 + MySQL5.7;复用业务表聚合;六项 Open API + overview;Redis 缓存 |
425
+| **v1.3** | 年度 5 项指标 **捆绑刷新**;`overview` miss 时 **同步更新** 年度指标 + 词云,避免部分缓存过期 |
423 426
 | **v1.2** | §5.1/§5.2 改为 **统计年**(`YEAR(finish_time)`);接口参数统一 `statYear`;缓存 key/TTL 对齐年度指标 |
424 427
 | **v1.1** | Token 改为 **AES-128-CBC 加密 UUID** 校验;**移除** `biz_open_api_token` 库表方案 |
425 428
 
426
-*文档版本:v1.2 · 关联《商城数据统计功能需求.md》v1.0.4 · 统计索引:`sql/biz_order.sql` → `idx_stats_finish`*
429
+*文档版本:v1.3 · 关联《商城数据统计功能需求.md》v1.0.4 · 统计索引:`sql/biz_order.sql` → `idx_stats_finish`*