From cc3f3af2641d6b217cb0025c1369751fb78257dd Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 13:57:42 +0800 Subject: [PATCH] feat: update HotGame callbacks and wallet snapshots --- ...0716_wallet_gold_count_hourly_snapshot.sql | 10 ++ ...0716_wallet_gold_count_hourly_snapshot.sql | 10 ++ .gitignore | 6 +- AGENTS.md | 8 + rc-gateway/src/main/resources/application.yml | 6 + .../game/party3rd/HotGameRestController.java | 50 +++++- .../party3rd/HotGameRestControllerTest.java | 156 ++++++++++++++++++ .../app/scheduler/WalletGoldCacheTask.java | 46 ++++-- .../impl/WalletGoldCacheServiceImpl.java | 5 +- .../database/rds/dao/WalletGoldCountDAO.java | 6 + .../rds/service/WalletGoldCountService.java | 7 + .../impl/WalletGoldCountServiceImpl.java | 26 +++ .../tool/WalletGoldStatisticsDateUtils.java | 41 +++++ .../main/resources/dao/WalletGoldCountDAO.xml | 39 +++++ 14 files changed, 394 insertions(+), 22 deletions(-) create mode 100644 .deploy/prod-deploy/sql/20260716_wallet_gold_count_hourly_snapshot.sql create mode 100644 .deploy/test-deploy/sql/20260716_wallet_gold_count_hourly_snapshot.sql create mode 100644 AGENTS.md create mode 100644 rc-service/rc-service-other/other-adapter/src/test/java/com/red/circle/other/adapter/app/game/party3rd/HotGameRestControllerTest.java create mode 100644 rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/tool/WalletGoldStatisticsDateUtils.java diff --git a/.deploy/prod-deploy/sql/20260716_wallet_gold_count_hourly_snapshot.sql b/.deploy/prod-deploy/sql/20260716_wallet_gold_count_hourly_snapshot.sql new file mode 100644 index 00000000..3a99d5c8 --- /dev/null +++ b/.deploy/prod-deploy/sql/20260716_wallet_gold_count_hourly_snapshot.sql @@ -0,0 +1,10 @@ +-- 小时级累计快照会重复写入同一平台、利雅得自然日、收支方向和事件类型。 +-- 唯一键必须包含 sys_origin,否则不同 App 的同名游戏事件会互相覆盖;group_num 紧随 +-- sys_origin,保证现有按平台和日期读取单日/区间快照时可以使用唯一索引的连续前缀。 +-- 本迁移必须先于新版 wallet-service 发布,旧唯一键不满足小时 upsert 的逻辑主键约束。 +ALTER TABLE `wallet_gold_count` + DROP INDEX `unique_key1`, + ADD UNIQUE KEY `uk_wallet_gold_count_snapshot` + (`sys_origin`, `group_num`, `type`, `event_type`), + ALGORITHM=INPLACE, + LOCK=NONE; diff --git a/.deploy/test-deploy/sql/20260716_wallet_gold_count_hourly_snapshot.sql b/.deploy/test-deploy/sql/20260716_wallet_gold_count_hourly_snapshot.sql new file mode 100644 index 00000000..3a99d5c8 --- /dev/null +++ b/.deploy/test-deploy/sql/20260716_wallet_gold_count_hourly_snapshot.sql @@ -0,0 +1,10 @@ +-- 小时级累计快照会重复写入同一平台、利雅得自然日、收支方向和事件类型。 +-- 唯一键必须包含 sys_origin,否则不同 App 的同名游戏事件会互相覆盖;group_num 紧随 +-- sys_origin,保证现有按平台和日期读取单日/区间快照时可以使用唯一索引的连续前缀。 +-- 本迁移必须先于新版 wallet-service 发布,旧唯一键不满足小时 upsert 的逻辑主键约束。 +ALTER TABLE `wallet_gold_count` + DROP INDEX `unique_key1`, + ADD UNIQUE KEY `uk_wallet_gold_count_snapshot` + (`sys_origin`, `group_num`, `type`, `event_type`), + ALGORITHM=INPLACE, + LOCK=NONE; diff --git a/.gitignore b/.gitignore index 6db24d87..8ff60e20 100644 --- a/.gitignore +++ b/.gitignore @@ -97,4 +97,8 @@ devops-monitor-id_rsa.key build.md -reports/ \ No newline at end of file +reports/ + +# Local operational credentials and generated report artifacts. +/aslan-test-app-token.txt +/outputs/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a58ef606 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,8 @@ +# AGENTS.md + +1. 这个是 Aslan 的服务端 +2. aslan的 后台 在 ../webconsole +3. aslan的 h5 在 ../aslan-h5 +4. aslan的 app代码 在 ../Aslan + +5. 如果没有明确的要求, 不要去更改和去查看其他项目的代码, 默认是在aslan这个项目中做逻辑流转 diff --git a/rc-gateway/src/main/resources/application.yml b/rc-gateway/src/main/resources/application.yml index 5c2ef17a..7d6e8c04 100644 --- a/rc-gateway/src/main/resources/application.yml +++ b/rc-gateway/src/main/resources/application.yml @@ -46,3 +46,9 @@ gateway: - /console/datav/online/room/count - /console/datav/aslan/region-country/statistics - /console/user/base/info/im/sig + - /game/hot/** + - /getUserInfo + - /updateBalance + # V5Pay 服务端回调不携带用户 token;同时兼容网关转发前和已剥离 /order 的内部路径。 + - /order/play-server-notice/v5pay/receive_payment + - /play-server-notice/v5pay/receive_payment diff --git a/rc-service/rc-service-other/other-adapter/src/main/java/com/red/circle/other/adapter/app/game/party3rd/HotGameRestController.java b/rc-service/rc-service-other/other-adapter/src/main/java/com/red/circle/other/adapter/app/game/party3rd/HotGameRestController.java index f950da81..8e93ec37 100644 --- a/rc-service/rc-service-other/other-adapter/src/main/java/com/red/circle/other/adapter/app/game/party3rd/HotGameRestController.java +++ b/rc-service/rc-service-other/other-adapter/src/main/java/com/red/circle/other/adapter/app/game/party3rd/HotGameRestController.java @@ -34,12 +34,18 @@ import org.springframework.web.bind.annotation.RestController; @Slf4j @RequiredArgsConstructor @RestController -@RequestMapping("/game/hot") +// 热游文档要求回调路径是基地址 + /getUserInfo|/updateBalance;保留 /game/hot 兼容历史配置。 +@RequestMapping({"", "/game/hot"}) public class HotGameRestController { private final HotGameService hostGameService; private final HotGameProperties hotGameProperties; + private static final int HOT_GAME_TOKEN_INVALID = 1001; + private static final int HOT_GAME_INSUFFICIENT_BALANCE = 2001; + private static final int HOT_GAME_DUPLICATE_ORDER = 3001; + private static final int EXISTING_DUPLICATE_ORDER_CODE = 406; + @IgnoreResultResponse @PostMapping("/getUserInfo") public HotGameResponse getUserProfile( @@ -59,7 +65,8 @@ public class HotGameRestController { return new HotGameResponse() .setErrorCode(HotGameErrorEnum.SIGN_VERIFY_FAIL.getErrorCode()); } - return hostGameService.getUserProfile(query); + HotGameResponse response = hostGameService.getUserProfile(query); + return response.setErrorCode(toHotGameCallbackErrorCode(response.getErrorCode())); } @IgnoreResultResponse @@ -98,13 +105,48 @@ public class HotGameRestController { HotGameUserCoinUpdate coinUpdate = new HotGameUserCoinUpdate(); BeanUtils.copyProperties(update, coinUpdate); - HotGameCoin data = hostGameService.updateUserCoin(coinUpdate).getData(); + HotGameResponse updateResponse = hostGameService.updateUserCoin(coinUpdate); + if (!Objects.equals(updateResponse.getErrorCode(), HotGameErrorEnum.SUCCESS.getErrorCode())) { + // 扣款/加币失败必须原样返回失败语义,避免第三方把失败订单当成成功结算。 + return new HotGameResponse() + .setData(toHotGameCoinVO(updateResponse.getData())) + .setErrorCode(toHotGameCallbackErrorCode(updateResponse.getErrorCode())); + } HotGameResponse res = new HotGameResponse<>(); - res.setData(new HotGameCoinVO().setCoin(data.getBalanceCoin()).setResponseId(IdWorkerUtils.getIdStr())); + res.setData(toHotGameCoinVO(updateResponse.getData())); res.setErrorCode(HotGameErrorEnum.SUCCESS.getErrorCode()); return res; } + private HotGameCoinVO toHotGameCoinVO(HotGameCoin data) { + if (Objects.isNull(data)) { + return new HotGameCoinVO() + .setCoin(0L) + .setResponseId(IdWorkerUtils.getIdStr()); + } + return new HotGameCoinVO() + .setCoin(data.getBalanceCoin()) + .setResponseId(StringUtils.isBlank(data.getResponseId()) + ? IdWorkerUtils.getIdStr() + : data.getResponseId()); + } + + private int toHotGameCallbackErrorCode(Integer errorCode) { + if (Objects.isNull(errorCode)) { + return HotGameErrorEnum.SERVER_ERROR.getErrorCode(); + } + if (Objects.equals(errorCode, HotGameErrorEnum.AUTHENTICATION_FAILED.getErrorCode())) { + return HOT_GAME_TOKEN_INVALID; + } + if (Objects.equals(errorCode, HotGameErrorEnum.INSUFFICIENT_BALANCE.getErrorCode())) { + return HOT_GAME_INSUFFICIENT_BALANCE; + } + if (Objects.equals(errorCode, EXISTING_DUPLICATE_ORDER_CODE)) { + return HOT_GAME_DUPLICATE_ORDER; + } + return errorCode; + } + } diff --git a/rc-service/rc-service-other/other-adapter/src/test/java/com/red/circle/other/adapter/app/game/party3rd/HotGameRestControllerTest.java b/rc-service/rc-service-other/other-adapter/src/test/java/com/red/circle/other/adapter/app/game/party3rd/HotGameRestControllerTest.java new file mode 100644 index 00000000..149b448e --- /dev/null +++ b/rc-service/rc-service-other/other-adapter/src/test/java/com/red/circle/other/adapter/app/game/party3rd/HotGameRestControllerTest.java @@ -0,0 +1,156 @@ +package com.red.circle.other.adapter.app.game.party3rd; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.red.circle.component.game.hotgame.props.HotGameProperties; +import com.red.circle.component.game.hotgame.request.HotGameUserCoinUpdate; +import com.red.circle.component.game.hotgame.request.HotGameUserProfileQuery; +import com.red.circle.component.game.hotgame.response.HotGameCoin; +import com.red.circle.component.game.hotgame.response.HotGameErrorEnum; +import com.red.circle.component.game.hotgame.response.HotGameResponse; +import com.red.circle.other.app.service.game.override.domain.HotGameUserProfile; +import com.red.circle.other.app.service.game.override.service.HotGameService; +import com.red.circle.tool.crypto.SecurityUtils; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class HotGameRestControllerTest { + + private static final String SIGN_KEY = "hot-game-key"; + + private HotGameService hotGameService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + hotGameService = Mockito.mock(HotGameService.class); + HotGameProperties hotGameProperties = new HotGameProperties(); + hotGameProperties.setSignKey(SIGN_KEY); + mockMvc = MockMvcBuilders + .standaloneSetup(new HotGameRestController(hotGameService, hotGameProperties)) + .build(); + } + + @Test + void getUserInfoSupportsRootCallbackPath() throws Exception { + when(hotGameService.getUserProfile(any())).thenReturn(new HotGameResponse() + .setErrorCode(HotGameErrorEnum.SUCCESS.getErrorCode()) + .setData(HotGameUserProfile.builder() + .uid("10001") + .nickname("lucky") + .avatar("https://example.com/avatar.png") + .coin(10000L) + .vipLevel(1) + .build())); + + mockMvc.perform(post("/getUserInfo") + .contentType(MediaType.APPLICATION_JSON) + .content(userInfoBody("101", "10001", "ABCD123"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errorCode").value(0)) + .andExpect(jsonPath("$.data.uid").value("10001")) + .andExpect(jsonPath("$.data.vipLevel").value(1)); + + ArgumentCaptor captor = ArgumentCaptor.forClass( + HotGameUserProfileQuery.class); + verify(hotGameService).getUserProfile(captor.capture()); + // Controller 解码 token 后再进入业务鉴权,避免第三方 URL 编码 token 导致鉴权失败。 + org.assertj.core.api.Assertions.assertThat(captor.getValue().getToken()).isEqualTo("ABCD123"); + } + + @Test + void getUserInfoKeepsLegacyPrefixedPath() throws Exception { + when(hotGameService.getUserProfile(any())).thenReturn(new HotGameResponse() + .setErrorCode(HotGameErrorEnum.SUCCESS.getErrorCode()) + .setData(HotGameUserProfile.builder() + .uid("10001") + .nickname("lucky") + .avatar("https://example.com/avatar.png") + .coin(10000L) + .vipLevel(1) + .build())); + + mockMvc.perform(post("/game/hot/getUserInfo") + .contentType(MediaType.APPLICATION_JSON) + .content(userInfoBody("101", "10001", "ABCD123"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errorCode").value(0)) + .andExpect(jsonPath("$.data.uid").value("10001")); + } + + @Test + void updateBalanceMapsInsufficientBalanceToHotGameCallbackCode() throws Exception { + when(hotGameService.updateUserCoin(any())).thenReturn(new HotGameResponse() + .setErrorCode(HotGameErrorEnum.INSUFFICIENT_BALANCE.getErrorCode())); + + mockMvc.perform(post("/updateBalance") + .contentType(MediaType.APPLICATION_JSON) + .content(updateBalanceBody("order-1", "101", "3199", "10001", 100L, 1, "ABCD123"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errorCode").value(2001)) + .andExpect(jsonPath("$.data.coin").value(0)); + } + + @Test + void updateBalanceMapsDuplicateOrderToHotGameCallbackCode() throws Exception { + when(hotGameService.updateUserCoin(any())).thenReturn(new HotGameResponse() + .setErrorCode(406) + .setData(new HotGameCoin().setBalanceCoin(0L).setResponseId("0"))); + + mockMvc.perform(post("/updateBalance") + .contentType(MediaType.APPLICATION_JSON) + .content(updateBalanceBody("order-1", "101", "3199", "10001", 100L, 1, "ABCD123"))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.errorCode").value(3001)) + .andExpect(jsonPath("$.data.responseId").value("0")); + } + + private String userInfoBody(String gameId, String uid, String token) { + String sign = sign(gameId, uid, token, SIGN_KEY); + return """ + { + "gameId": "%s", + "uid": "%s", + "token": "%s", + "sign": "%s" + } + """.formatted(gameId, uid, token, sign); + } + + private String updateBalanceBody(String orderId, String gameId, String roundId, String uid, + Long coin, Integer type, String token) { + String sign = sign(orderId, gameId, roundId, uid, coin, type, token, SIGN_KEY); + return """ + { + "orderId": "%s", + "gameId": "%s", + "roundId": "%s", + "uid": "%s", + "coin": %d, + "type": %d, + "token": "%s", + "sign": "%s" + } + """.formatted(orderId, gameId, roundId, uid, coin, type, token, sign); + } + + private String sign(Object... parts) { + return SecurityUtils.md5(Stream.of(parts) + .filter(Objects::nonNull) + .map(String::valueOf) + .collect(Collectors.joining(""))).toUpperCase(); + } +} diff --git a/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/scheduler/WalletGoldCacheTask.java b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/scheduler/WalletGoldCacheTask.java index 149b477f..0718de17 100644 --- a/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/scheduler/WalletGoldCacheTask.java +++ b/rc-service/rc-service-wallet/wallet-application/src/main/java/com/red/circle/wallet/app/scheduler/WalletGoldCacheTask.java @@ -1,6 +1,5 @@ package com.red.circle.wallet.app.scheduler; -import com.red.circle.common.business.core.date.AsiaShanghaiDateUtils; import com.red.circle.common.business.core.enums.SysOriginPlatformEnum; import com.red.circle.component.redis.annotation.TaskCacheLock; import com.red.circle.tool.core.collection.CollectionUtils; @@ -8,7 +7,7 @@ import com.red.circle.tool.core.date.DateUtils; import com.red.circle.wallet.infra.database.cache.service.WalletGoldCacheService; import com.red.circle.wallet.infra.database.rds.entity.WalletGoldCount; import com.red.circle.wallet.infra.database.rds.service.WalletGoldCountService; -import com.red.circle.wallet.infra.tool.WalletGoldCountTool; +import com.red.circle.wallet.infra.tool.WalletGoldStatisticsDateUtils; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -25,7 +24,6 @@ import org.springframework.stereotype.Component; @RequiredArgsConstructor public class WalletGoldCacheTask { - private final WalletGoldCountTool walletGoldCountTool; private final WalletGoldCacheService walletGoldCacheService; private final WalletGoldCountService walletGoldCountService; @@ -44,28 +42,46 @@ public class WalletGoldCacheTask { } /** - * 每天8点执行,同步缓存统计入库. - *

同步钱包金币消费统计到库

+ * 每个整点同步钱包金币日累计快照. + * + *

统计日严格按 {@code Asia/Riyadh} 切分,与金币事件写入 Redis 时使用的分组日期保持一致; + * 每轮同时覆盖当前日和昨日,是为了在利雅得零点后继续收口跨进程、跨消息队列边界延迟到达的昨日事件。 + * Redis 中的金额是从当日零点开始累计的绝对值,因此数据库必须做覆盖式 upsert,绝不能把本轮金额再次累加。

*/ - @Scheduled(cron = "0 0 8 * * ?", zone = "Asia/Shanghai") - @TaskCacheLock(key = "WALLET_GOLD_CACHE_COUNT_CONSUME", expireSecond = 60 * 60) + @Scheduled(cron = "0 0 * * * ?", zone = WalletGoldStatisticsDateUtils.ZONE_ID) + @TaskCacheLock(key = "WALLET_GOLD_CACHE_COUNT_CONSUME", expireSecond = 55 * 60) public void syncCountConsume() { - Integer dateNumber = AsiaShanghaiDateUtils.nowMinusDateToInt(1); - log.warn("开始执行: {}", dateNumber); + Integer currentDateNumber = WalletGoldStatisticsDateUtils.nowDateToInt(); + Integer previousDateNumber = WalletGoldStatisticsDateUtils.nowMinusDateToInt(1); + log.warn("开始执行钱包金币累计快照同步: current={}, previous={}, zone={}", + currentDateNumber, previousDateNumber, WalletGoldStatisticsDateUtils.ZONE_ID); - SysOriginPlatformEnum[] values = SysOriginPlatformEnum.values(); - for (SysOriginPlatformEnum sysOrigin : values) { + // 先收口昨日,再刷新当前日;单个平台/日期失败不会阻断其他平台,下一小时仍会用绝对快照自动重试。 + for (SysOriginPlatformEnum sysOrigin : SysOriginPlatformEnum.values()) { + syncAbsoluteSnapshot(sysOrigin, previousDateNumber); + syncAbsoluteSnapshot(sysOrigin, currentDateNumber); + } + } + + private void syncAbsoluteSnapshot(SysOriginPlatformEnum sysOrigin, Integer dateNumber) { + try { List walletGoldCounts = walletGoldCacheService.getCacheCountConsumeAll( sysOrigin.name(), dateNumber); if (CollectionUtils.isEmpty(walletGoldCounts)) { - log.warn("结束执行: 无内容 {}", sysOrigin); - continue; + log.info("钱包金币累计快照无内容: sysOrigin={}, groupNum={}", sysOrigin, dateNumber); + return; } - walletGoldCountService.saveBatch(walletGoldCountTool.peekPercentage(walletGoldCounts)); + // getCacheCountConsumeAll 已按收入/支出分别计算占比;此处按四字段逻辑唯一键整体覆盖绝对值。 + walletGoldCountService.upsertAbsoluteSnapshot(walletGoldCounts); + log.info("钱包金币累计快照同步完成: sysOrigin={}, groupNum={}, records={}", + sysOrigin, dateNumber, walletGoldCounts.size()); + } catch (Exception exception) { + // Redis key 会保留三天,本轮失败后下一小时仍可同时重试今日与昨日,不应牵连其他平台。 + log.error("钱包金币累计快照同步失败: sysOrigin={}, groupNum={}", + sysOrigin, dateNumber, exception); } - } } diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/cache/service/impl/WalletGoldCacheServiceImpl.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/cache/service/impl/WalletGoldCacheServiceImpl.java index 793d5a4d..acf7c618 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/cache/service/impl/WalletGoldCacheServiceImpl.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/cache/service/impl/WalletGoldCacheServiceImpl.java @@ -1,7 +1,6 @@ package com.red.circle.wallet.infra.database.cache.service.impl; import com.fasterxml.jackson.core.type.TypeReference; -import com.red.circle.common.business.core.date.AsiaShanghaiDateUtils; import com.red.circle.common.business.core.enums.ReceiptType; import com.red.circle.component.redis.service.RedisService; import com.red.circle.component.redis.service.ZsetTypedTuple; @@ -21,6 +20,7 @@ import com.red.circle.wallet.infra.database.rds.service.WalletGoldAssetRecordSer import com.red.circle.wallet.infra.database.rds.service.WalletGoldBalanceService; import com.red.circle.wallet.infra.database.rds.service.WalletGoldCountService; import com.red.circle.wallet.infra.tool.WalletGoldCountTool; +import com.red.circle.wallet.infra.tool.WalletGoldStatisticsDateUtils; import com.red.circle.wallet.inner.model.enums.GoldOrigin; import java.util.Comparator; import java.util.List; @@ -200,8 +200,9 @@ public class WalletGoldCacheServiceImpl implements WalletGoldCacheService { @Override public void countConsumeDaily(String sysOrigin, String eventType, Integer type, double quantity) { + // 金币日累计的 Redis 分桶固定按利雅得自然日生成,必须与小时持久化任务使用完全相同的日期工具。 String key = WalletGoldKey.COUNT_CONSUME.getKey(sysOrigin, - AsiaShanghaiDateUtils.nowDateToInt()); + WalletGoldStatisticsDateUtils.nowDateToInt()); redisService.zsetIncrementScore(key, type + "#" + eventType, quantity); redisService.expire(key, 3, TimeUnit.DAYS); } diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/dao/WalletGoldCountDAO.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/dao/WalletGoldCountDAO.java index 256f35f4..27ad6f55 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/dao/WalletGoldCountDAO.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/dao/WalletGoldCountDAO.java @@ -2,6 +2,8 @@ package com.red.circle.wallet.infra.database.rds.dao; import com.red.circle.framework.mybatis.dao.BaseDAO; import com.red.circle.wallet.infra.database.rds.entity.WalletGoldCount; +import java.util.List; +import org.apache.ibatis.annotations.Param; /** * 金币统计 DAO. @@ -10,4 +12,8 @@ import com.red.circle.wallet.infra.database.rds.entity.WalletGoldCount; */ public interface WalletGoldCountDAO extends BaseDAO { + /** + * 批量覆盖 Redis 日累计快照,金额字段不得做加法. + */ + int upsertAbsoluteSnapshot(@Param("list") List walletGoldCounts); } diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/service/WalletGoldCountService.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/service/WalletGoldCountService.java index ad868c37..8e19a3ec 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/service/WalletGoldCountService.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/service/WalletGoldCountService.java @@ -20,4 +20,11 @@ public interface WalletGoldCountService extends BaseService { * 获取一组范围内统计. */ List listRange(String sysOrigin, Integer startGroupNum, Integer endGroupNum); + + /** + * 按平台、统计日、收支类型和事件类型批量覆盖累计快照. + * + *

传入金额是 Redis 当日累计绝对值,不是相对上一次调度的增量。

+ */ + int upsertAbsoluteSnapshot(List walletGoldCounts); } diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/service/impl/WalletGoldCountServiceImpl.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/service/impl/WalletGoldCountServiceImpl.java index 4afdf3ac..816cd7ba 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/service/impl/WalletGoldCountServiceImpl.java +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/database/rds/service/impl/WalletGoldCountServiceImpl.java @@ -1,9 +1,13 @@ package com.red.circle.wallet.infra.database.rds.service.impl; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.red.circle.framework.mybatis.service.impl.BaseServiceImpl; +import com.red.circle.tool.core.collection.CollectionUtils; +import com.red.circle.tool.core.date.DateUtils; import com.red.circle.wallet.infra.database.rds.dao.WalletGoldCountDAO; import com.red.circle.wallet.infra.database.rds.entity.WalletGoldCount; import com.red.circle.wallet.infra.database.rds.service.WalletGoldCountService; +import java.util.Date; import java.util.List; import org.springframework.stereotype.Service; @@ -35,4 +39,26 @@ public class WalletGoldCountServiceImpl extends .list(); } + @Override + public int upsertAbsoluteSnapshot(List walletGoldCounts) { + if (CollectionUtils.isEmpty(walletGoldCounts)) { + return 0; + } + + Date now = DateUtils.now(); + for (WalletGoldCount walletGoldCount : walletGoldCounts) { + // 自定义批量 SQL 不经过 MyBatis-Plus 的单行 insert,因此在应用层显式生成雪花 ID。 + if (walletGoldCount.getId() == null) { + walletGoldCount.setId(IdWorker.getId()); + } + if (walletGoldCount.getCreateTime() == null) { + walletGoldCount.setCreateTime(now); + } + walletGoldCount.setUpdateTime(now); + } + + // 单条多值 INSERT 让同一平台/日期的快照原子落库,并依赖四字段唯一键完成幂等覆盖。 + return baseDAO.upsertAbsoluteSnapshot(walletGoldCounts); + } + } diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/tool/WalletGoldStatisticsDateUtils.java b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/tool/WalletGoldStatisticsDateUtils.java new file mode 100644 index 00000000..20900de2 --- /dev/null +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/java/com/red/circle/wallet/infra/tool/WalletGoldStatisticsDateUtils.java @@ -0,0 +1,41 @@ +package com.red.circle.wallet.infra.tool; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +/** + * 钱包金币统计的利雅得自然日工具. + * + *

钱包累计 Redis key 与持久化表的 {@code group_num} 必须使用同一时区切日;这里独立声明 + * {@code Asia/Riyadh},避免继续依赖名称与实际时区不一致的历史通用日期工具。

+ */ +public final class WalletGoldStatisticsDateUtils { + + public static final String ZONE_ID = "Asia/Riyadh"; + + private static final ZoneId RIYADH_ZONE_ID = ZoneId.of(ZONE_ID); + private static final DateTimeFormatter GROUP_NUMBER_FORMATTER = DateTimeFormatter.BASIC_ISO_DATE; + + private WalletGoldStatisticsDateUtils() { + throw new UnsupportedOperationException("Utility class cannot be instantiated"); + } + + /** + * 当前利雅得自然日,格式为 yyyyMMdd. + */ + public static Integer nowDateToInt() { + return dateToInt(LocalDate.now(RIYADH_ZONE_ID)); + } + + /** + * 当前利雅得自然日向前偏移指定天数,格式为 yyyyMMdd. + */ + public static Integer nowMinusDateToInt(long days) { + return dateToInt(LocalDate.now(RIYADH_ZONE_ID).minusDays(days)); + } + + private static Integer dateToInt(LocalDate date) { + return Integer.valueOf(date.format(GROUP_NUMBER_FORMATTER)); + } +} diff --git a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/resources/dao/WalletGoldCountDAO.xml b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/resources/dao/WalletGoldCountDAO.xml index 456c19ee..f9536967 100644 --- a/rc-service/rc-service-wallet/wallet-infrastructure/src/main/resources/dao/WalletGoldCountDAO.xml +++ b/rc-service/rc-service-wallet/wallet-infrastructure/src/main/resources/dao/WalletGoldCountDAO.xml @@ -3,4 +3,43 @@ "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> + + + INSERT INTO wallet_gold_count ( + id, + sys_origin, + `type`, + group_num, + event_type, + event_desc, + amount, + percentage, + create_time, + update_time + ) + VALUES + + ( + #{item.id}, + #{item.sysOrigin}, + #{item.type}, + #{item.groupNum}, + #{item.eventType}, + #{item.eventDesc}, + #{item.amount}, + #{item.percentage}, + #{item.createTime}, + #{item.updateTime} + ) + + ON DUPLICATE KEY UPDATE + event_desc = VALUES(event_desc), + amount = VALUES(amount), + percentage = VALUES(percentage), + update_time = VALUES(update_time) + +