feat: update HotGame callbacks and wallet snapshots

This commit is contained in:
zhx 2026-07-16 13:57:42 +08:00
parent 0f4e440f2b
commit cc3f3af264
14 changed files with 394 additions and 22 deletions

View File

@ -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;

View File

@ -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;

6
.gitignore vendored
View File

@ -97,4 +97,8 @@ 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
View File

@ -0,0 +1,8 @@
# AGENTS.md
1. 这个是 Aslan 的服务端
2. aslan的 后台 在 ../webconsole
3. aslan的 h5 在 ../aslan-h5
4. aslan的 app代码 在 ../Aslan
5. 如果没有明确的要求, 不要去更改和去查看其他项目的代码, 默认是在aslan这个项目中做逻辑流转

View File

@ -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

View File

@ -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;
}
} }

View File

@ -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();
}
}

View File

@ -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);
} }
} }
} }

View File

@ -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);
} }

View File

@ -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);
} }

View File

@ -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);
} }

View File

@ -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);
}
} }

View File

@ -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));
}
}

View File

@ -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>