feat: update HotGame callbacks and wallet snapshots
This commit is contained in:
parent
0f4e440f2b
commit
cc3f3af264
@ -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;
|
||||||
@ -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;
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -98,3 +98,7 @@ devops-monitor-id_rsa.key
|
|||||||
build.md
|
build.md
|
||||||
|
|
||||||
reports/
|
reports/
|
||||||
|
|
||||||
|
# Local operational credentials and generated report artifacts.
|
||||||
|
/aslan-test-app-token.txt
|
||||||
|
/outputs/
|
||||||
|
|||||||
8
AGENTS.md
Normal file
8
AGENTS.md
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
1. 这个是 Aslan 的服务端
|
||||||
|
2. aslan的 后台 在 ../webconsole
|
||||||
|
3. aslan的 h5 在 ../aslan-h5
|
||||||
|
4. aslan的 app代码 在 ../Aslan
|
||||||
|
|
||||||
|
5. 如果没有明确的要求, 不要去更改和去查看其他项目的代码, 默认是在aslan这个项目中做逻辑流转
|
||||||
@ -46,3 +46,9 @@ gateway:
|
|||||||
- /console/datav/online/room/count
|
- /console/datav/online/room/count
|
||||||
- /console/datav/aslan/region-country/statistics
|
- /console/datav/aslan/region-country/statistics
|
||||||
- /console/user/base/info/im/sig
|
- /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
|
||||||
|
|||||||
@ -34,12 +34,18 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@Slf4j
|
@Slf4j
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/game/hot")
|
// 热游文档要求回调路径是基地址 + /getUserInfo|/updateBalance;保留 /game/hot 兼容历史配置。
|
||||||
|
@RequestMapping({"", "/game/hot"})
|
||||||
public class HotGameRestController {
|
public class HotGameRestController {
|
||||||
|
|
||||||
private final HotGameService hostGameService;
|
private final HotGameService hostGameService;
|
||||||
private final HotGameProperties hotGameProperties;
|
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
|
@IgnoreResultResponse
|
||||||
@PostMapping("/getUserInfo")
|
@PostMapping("/getUserInfo")
|
||||||
public HotGameResponse<HotGameUserProfile> getUserProfile(
|
public HotGameResponse<HotGameUserProfile> getUserProfile(
|
||||||
@ -59,7 +65,8 @@ public class HotGameRestController {
|
|||||||
return new HotGameResponse<HotGameUserProfile>()
|
return new HotGameResponse<HotGameUserProfile>()
|
||||||
.setErrorCode(HotGameErrorEnum.SIGN_VERIFY_FAIL.getErrorCode());
|
.setErrorCode(HotGameErrorEnum.SIGN_VERIFY_FAIL.getErrorCode());
|
||||||
}
|
}
|
||||||
return hostGameService.getUserProfile(query);
|
HotGameResponse<HotGameUserProfile> response = hostGameService.getUserProfile(query);
|
||||||
|
return response.setErrorCode(toHotGameCallbackErrorCode(response.getErrorCode()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@IgnoreResultResponse
|
@IgnoreResultResponse
|
||||||
@ -98,13 +105,48 @@ public class HotGameRestController {
|
|||||||
|
|
||||||
HotGameUserCoinUpdate coinUpdate = new HotGameUserCoinUpdate();
|
HotGameUserCoinUpdate coinUpdate = new HotGameUserCoinUpdate();
|
||||||
BeanUtils.copyProperties(update, coinUpdate);
|
BeanUtils.copyProperties(update, coinUpdate);
|
||||||
HotGameCoin data = hostGameService.updateUserCoin(coinUpdate).getData();
|
HotGameResponse<HotGameCoin> updateResponse = hostGameService.updateUserCoin(coinUpdate);
|
||||||
|
if (!Objects.equals(updateResponse.getErrorCode(), HotGameErrorEnum.SUCCESS.getErrorCode())) {
|
||||||
|
// 扣款/加币失败必须原样返回失败语义,避免第三方把失败订单当成成功结算。
|
||||||
|
return new HotGameResponse<HotGameCoinVO>()
|
||||||
|
.setData(toHotGameCoinVO(updateResponse.getData()))
|
||||||
|
.setErrorCode(toHotGameCallbackErrorCode(updateResponse.getErrorCode()));
|
||||||
|
}
|
||||||
|
|
||||||
HotGameResponse<HotGameCoinVO> res = new HotGameResponse<>();
|
HotGameResponse<HotGameCoinVO> res = new HotGameResponse<>();
|
||||||
res.setData(new HotGameCoinVO().setCoin(data.getBalanceCoin()).setResponseId(IdWorkerUtils.getIdStr()));
|
res.setData(toHotGameCoinVO(updateResponse.getData()));
|
||||||
res.setErrorCode(HotGameErrorEnum.SUCCESS.getErrorCode());
|
res.setErrorCode(HotGameErrorEnum.SUCCESS.getErrorCode());
|
||||||
return res;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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<HotGameUserProfile>()
|
||||||
|
.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<HotGameUserProfileQuery> 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<HotGameUserProfile>()
|
||||||
|
.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<HotGameCoin>()
|
||||||
|
.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<HotGameCoin>()
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
package com.red.circle.wallet.app.scheduler;
|
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.common.business.core.enums.SysOriginPlatformEnum;
|
||||||
import com.red.circle.component.redis.annotation.TaskCacheLock;
|
import com.red.circle.component.redis.annotation.TaskCacheLock;
|
||||||
import com.red.circle.tool.core.collection.CollectionUtils;
|
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.cache.service.WalletGoldCacheService;
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.WalletGoldCount;
|
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.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 java.util.List;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -25,7 +24,6 @@ import org.springframework.stereotype.Component;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class WalletGoldCacheTask {
|
public class WalletGoldCacheTask {
|
||||||
|
|
||||||
private final WalletGoldCountTool walletGoldCountTool;
|
|
||||||
private final WalletGoldCacheService walletGoldCacheService;
|
private final WalletGoldCacheService walletGoldCacheService;
|
||||||
private final WalletGoldCountService walletGoldCountService;
|
private final WalletGoldCountService walletGoldCountService;
|
||||||
|
|
||||||
@ -44,28 +42,46 @@ public class WalletGoldCacheTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 每天8点执行,同步缓存统计入库.
|
* 每个整点同步钱包金币日累计快照.
|
||||||
* <p>同步钱包金币消费统计到库</p>
|
*
|
||||||
|
* <p>统计日严格按 {@code Asia/Riyadh} 切分,与金币事件写入 Redis 时使用的分组日期保持一致;
|
||||||
|
* 每轮同时覆盖当前日和昨日,是为了在利雅得零点后继续收口跨进程、跨消息队列边界延迟到达的昨日事件。
|
||||||
|
* Redis 中的金额是从当日零点开始累计的绝对值,因此数据库必须做覆盖式 upsert,绝不能把本轮金额再次累加。</p>
|
||||||
*/
|
*/
|
||||||
@Scheduled(cron = "0 0 8 * * ?", zone = "Asia/Shanghai")
|
@Scheduled(cron = "0 0 * * * ?", zone = WalletGoldStatisticsDateUtils.ZONE_ID)
|
||||||
@TaskCacheLock(key = "WALLET_GOLD_CACHE_COUNT_CONSUME", expireSecond = 60 * 60)
|
@TaskCacheLock(key = "WALLET_GOLD_CACHE_COUNT_CONSUME", expireSecond = 55 * 60)
|
||||||
public void syncCountConsume() {
|
public void syncCountConsume() {
|
||||||
Integer dateNumber = AsiaShanghaiDateUtils.nowMinusDateToInt(1);
|
Integer currentDateNumber = WalletGoldStatisticsDateUtils.nowDateToInt();
|
||||||
log.warn("开始执行: {}", dateNumber);
|
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<WalletGoldCount> walletGoldCounts = walletGoldCacheService.getCacheCountConsumeAll(
|
List<WalletGoldCount> walletGoldCounts = walletGoldCacheService.getCacheCountConsumeAll(
|
||||||
sysOrigin.name(), dateNumber);
|
sysOrigin.name(), dateNumber);
|
||||||
|
|
||||||
if (CollectionUtils.isEmpty(walletGoldCounts)) {
|
if (CollectionUtils.isEmpty(walletGoldCounts)) {
|
||||||
log.warn("结束执行: 无内容 {}", sysOrigin);
|
log.info("钱包金币累计快照无内容: sysOrigin={}, groupNum={}", sysOrigin, dateNumber);
|
||||||
continue;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package com.red.circle.wallet.infra.database.cache.service.impl;
|
package com.red.circle.wallet.infra.database.cache.service.impl;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
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.common.business.core.enums.ReceiptType;
|
||||||
import com.red.circle.component.redis.service.RedisService;
|
import com.red.circle.component.redis.service.RedisService;
|
||||||
import com.red.circle.component.redis.service.ZsetTypedTuple;
|
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.WalletGoldBalanceService;
|
||||||
import com.red.circle.wallet.infra.database.rds.service.WalletGoldCountService;
|
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.WalletGoldCountTool;
|
||||||
|
import com.red.circle.wallet.infra.tool.WalletGoldStatisticsDateUtils;
|
||||||
import com.red.circle.wallet.inner.model.enums.GoldOrigin;
|
import com.red.circle.wallet.inner.model.enums.GoldOrigin;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -200,8 +200,9 @@ public class WalletGoldCacheServiceImpl implements WalletGoldCacheService {
|
|||||||
@Override
|
@Override
|
||||||
public void countConsumeDaily(String sysOrigin, String eventType, Integer type,
|
public void countConsumeDaily(String sysOrigin, String eventType, Integer type,
|
||||||
double quantity) {
|
double quantity) {
|
||||||
|
// 金币日累计的 Redis 分桶固定按利雅得自然日生成,必须与小时持久化任务使用完全相同的日期工具。
|
||||||
String key = WalletGoldKey.COUNT_CONSUME.getKey(sysOrigin,
|
String key = WalletGoldKey.COUNT_CONSUME.getKey(sysOrigin,
|
||||||
AsiaShanghaiDateUtils.nowDateToInt());
|
WalletGoldStatisticsDateUtils.nowDateToInt());
|
||||||
redisService.zsetIncrementScore(key, type + "#" + eventType, quantity);
|
redisService.zsetIncrementScore(key, type + "#" + eventType, quantity);
|
||||||
redisService.expire(key, 3, TimeUnit.DAYS);
|
redisService.expire(key, 3, TimeUnit.DAYS);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.framework.mybatis.dao.BaseDAO;
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.WalletGoldCount;
|
import com.red.circle.wallet.infra.database.rds.entity.WalletGoldCount;
|
||||||
|
import java.util.List;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 金币统计 DAO.
|
* 金币统计 DAO.
|
||||||
@ -10,4 +12,8 @@ import com.red.circle.wallet.infra.database.rds.entity.WalletGoldCount;
|
|||||||
*/
|
*/
|
||||||
public interface WalletGoldCountDAO extends BaseDAO<WalletGoldCount> {
|
public interface WalletGoldCountDAO extends BaseDAO<WalletGoldCount> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量覆盖 Redis 日累计快照,金额字段不得做加法.
|
||||||
|
*/
|
||||||
|
int upsertAbsoluteSnapshot(@Param("list") List<WalletGoldCount> walletGoldCounts);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,4 +20,11 @@ public interface WalletGoldCountService extends BaseService<WalletGoldCount> {
|
|||||||
* 获取一组范围内统计.
|
* 获取一组范围内统计.
|
||||||
*/
|
*/
|
||||||
List<WalletGoldCount> listRange(String sysOrigin, Integer startGroupNum, Integer endGroupNum);
|
List<WalletGoldCount> listRange(String sysOrigin, Integer startGroupNum, Integer endGroupNum);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按平台、统计日、收支类型和事件类型批量覆盖累计快照.
|
||||||
|
*
|
||||||
|
* <p>传入金额是 Redis 当日累计绝对值,不是相对上一次调度的增量。</p>
|
||||||
|
*/
|
||||||
|
int upsertAbsoluteSnapshot(List<WalletGoldCount> walletGoldCounts);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
package com.red.circle.wallet.infra.database.rds.service.impl;
|
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.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.dao.WalletGoldCountDAO;
|
||||||
import com.red.circle.wallet.infra.database.rds.entity.WalletGoldCount;
|
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.database.rds.service.WalletGoldCountService;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@ -35,4 +39,26 @@ public class WalletGoldCountServiceImpl extends
|
|||||||
.list();
|
.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int upsertAbsoluteSnapshot(List<WalletGoldCount> 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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,41 @@
|
|||||||
|
package com.red.circle.wallet.infra.tool;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 钱包金币统计的利雅得自然日工具.
|
||||||
|
*
|
||||||
|
* <p>钱包累计 Redis key 与持久化表的 {@code group_num} 必须使用同一时区切日;这里独立声明
|
||||||
|
* {@code Asia/Riyadh},避免继续依赖名称与实际时区不一致的历史通用日期工具。</p>
|
||||||
|
*/
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,4 +3,43 @@
|
|||||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
<mapper namespace="com.red.circle.wallet.infra.database.rds.dao.WalletGoldCountDAO">
|
<mapper namespace="com.red.circle.wallet.infra.database.rds.dao.WalletGoldCountDAO">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Redis 保存的是日累计绝对快照;重复调度必须覆盖 amount/percentage,使用 amount + VALUES(amount)
|
||||||
|
会把同一批流水反复累计。幂等性依赖 (sys_origin, group_num, type, event_type) 唯一键。
|
||||||
|
-->
|
||||||
|
<insert id="upsertAbsoluteSnapshot">
|
||||||
|
INSERT INTO wallet_gold_count (
|
||||||
|
id,
|
||||||
|
sys_origin,
|
||||||
|
`type`,
|
||||||
|
group_num,
|
||||||
|
event_type,
|
||||||
|
event_desc,
|
||||||
|
amount,
|
||||||
|
percentage,
|
||||||
|
create_time,
|
||||||
|
update_time
|
||||||
|
)
|
||||||
|
VALUES
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(
|
||||||
|
#{item.id},
|
||||||
|
#{item.sysOrigin},
|
||||||
|
#{item.type},
|
||||||
|
#{item.groupNum},
|
||||||
|
#{item.eventType},
|
||||||
|
#{item.eventDesc},
|
||||||
|
#{item.amount},
|
||||||
|
#{item.percentage},
|
||||||
|
#{item.createTime},
|
||||||
|
#{item.updateTime}
|
||||||
|
)
|
||||||
|
</foreach>
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
event_desc = VALUES(event_desc),
|
||||||
|
amount = VALUES(amount),
|
||||||
|
percentage = VALUES(percentage),
|
||||||
|
update_time = VALUES(update_time)
|
||||||
|
</insert>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user