fix: restrict game king to game expenditures

This commit is contained in:
zhx 2026-07-18 13:12:15 +08:00
parent e2bcc23308
commit fa2ab4f3d6
14 changed files with 138 additions and 117 deletions

View File

@ -1,4 +1,4 @@
-- Aslan Game King: dedicated consume-to-draw campaign, ranking and settlement.
-- Aslan Game King: fixed third-party game expenditure-to-draw campaign, ranking and settlement.
--
-- Performance review (DDL only; this migration must not run ad-hoc against production):
-- 1. Wallet hot path inserts one ledger row by uq_activity_asset, then updates one overall PK row
@ -18,6 +18,9 @@
-- idempotent menu rows; it does not backfill wallet history or lock existing business tables.
-- 7. Unlimited prize stock (-1) is never updated at draw time, avoiding a global hot-row lock;
-- only finite stock uses the activity/prize primary-key update.
-- 8. Historical wallet backfill is not executed by this migration. Application queries bind
-- sys_origin + EXPENDITURE and four escaped left-anchored game prefixes, so the existing
-- wallet index (sys_origin,event_type,create_time) remains usable instead of scanning all spend.
SET NAMES utf8mb4;
@ -33,7 +36,8 @@ CREATE TABLE IF NOT EXISTS aslan_game_king_activity (
settlement_delay_minutes INT NOT NULL DEFAULT 30,
settlement_time DATETIME(3) NOT NULL,
coin_per_draw BIGINT NOT NULL,
event_types VARCHAR(2000) NOT NULL,
event_types VARCHAR(2000) NOT NULL DEFAULT 'GAME_EXPENDITURE'
COMMENT 'compatibility sentinel; event scope is fixed in service code',
ranking_type VARCHAR(32) NOT NULL DEFAULT 'TYCOON',
ranking_period VARCHAR(32) NOT NULL DEFAULT 'OVERALL',
enabled TINYINT(1) NOT NULL DEFAULT 0,
@ -88,7 +92,7 @@ CREATE TABLE IF NOT EXISTS aslan_game_king_wallet_ledger (
sys_origin VARCHAR(32) NOT NULL,
event_type VARCHAR(64) NOT NULL,
event_id VARCHAR(128) NULL,
receipt_type TINYINT NOT NULL COMMENT '0 income/winning, 1 expenditure/consumption',
receipt_type TINYINT NOT NULL COMMENT 'fixed 1: third-party game expenditure',
amount DECIMAL(24,2) NOT NULL,
event_time DATETIME(3) NOT NULL,
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@ -96,7 +100,7 @@ CREATE TABLE IF NOT EXISTS aslan_game_king_wallet_ledger (
UNIQUE KEY uq_aslan_gk_activity_asset (activity_id, asset_record_id),
KEY idx_aslan_gk_ledger_user (activity_id, user_id, event_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Idempotent eligible game expenditure and winning ledger';
COMMENT='Idempotent eligible third-party game expenditure ledger';
CREATE TABLE IF NOT EXISTS aslan_game_king_user (
activity_id BIGINT NOT NULL,

View File

@ -1,4 +1,4 @@
-- Aslan Game King: dedicated consume-to-draw campaign, ranking and settlement.
-- Aslan Game King: fixed third-party game expenditure-to-draw campaign, ranking and settlement.
--
-- Performance review (DDL only; this migration must not run ad-hoc against production):
-- 1. Wallet hot path inserts one ledger row by uq_activity_asset, then updates one overall PK row
@ -18,6 +18,9 @@
-- idempotent menu rows; it does not backfill wallet history or lock existing business tables.
-- 7. Unlimited prize stock (-1) is never updated at draw time, avoiding a global hot-row lock;
-- only finite stock uses the activity/prize primary-key update.
-- 8. Historical wallet backfill is not executed by this migration. Application queries bind
-- sys_origin + EXPENDITURE and four escaped left-anchored game prefixes, so the existing
-- wallet index (sys_origin,event_type,create_time) remains usable instead of scanning all spend.
SET NAMES utf8mb4;
@ -33,7 +36,8 @@ CREATE TABLE IF NOT EXISTS aslan_game_king_activity (
settlement_delay_minutes INT NOT NULL DEFAULT 30,
settlement_time DATETIME(3) NOT NULL,
coin_per_draw BIGINT NOT NULL,
event_types VARCHAR(2000) NOT NULL,
event_types VARCHAR(2000) NOT NULL DEFAULT 'GAME_EXPENDITURE'
COMMENT 'compatibility sentinel; event scope is fixed in service code',
ranking_type VARCHAR(32) NOT NULL DEFAULT 'TYCOON',
ranking_period VARCHAR(32) NOT NULL DEFAULT 'OVERALL',
enabled TINYINT(1) NOT NULL DEFAULT 0,
@ -88,7 +92,7 @@ CREATE TABLE IF NOT EXISTS aslan_game_king_wallet_ledger (
sys_origin VARCHAR(32) NOT NULL,
event_type VARCHAR(64) NOT NULL,
event_id VARCHAR(128) NULL,
receipt_type TINYINT NOT NULL COMMENT '0 income/winning, 1 expenditure/consumption',
receipt_type TINYINT NOT NULL COMMENT 'fixed 1: third-party game expenditure',
amount DECIMAL(24,2) NOT NULL,
event_time DATETIME(3) NOT NULL,
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
@ -96,7 +100,7 @@ CREATE TABLE IF NOT EXISTS aslan_game_king_wallet_ledger (
UNIQUE KEY uq_aslan_gk_activity_asset (activity_id, asset_record_id),
KEY idx_aslan_gk_ledger_user (activity_id, user_id, event_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Idempotent eligible game expenditure and winning ledger';
COMMENT='Idempotent eligible third-party game expenditure ledger';
CREATE TABLE IF NOT EXISTS aslan_game_king_user (
activity_id BIGINT NOT NULL,

View File

@ -75,8 +75,6 @@ public interface AslanGameKingManageClientApi {
@PostMapping("/backfill/{id}")
ResultResponse<BackfillResult> backfill(@PathVariable("id") Long id,
@RequestParam("eventType") String eventType,
@RequestParam("receiptType") Integer receiptType,
@RequestParam(value = "lastId", required = false) Long lastId,
@RequestParam(value = "limit", required = false) Integer limit);
}

View File

@ -47,6 +47,11 @@ public final class AslanGameKingModels {
private Integer settlementDelayMinutes;
private Long settlementTime;
private Long coinPerDraw;
/**
* @deprecated 游戏王只统计服务端固定识别的第三方游戏金币消耗该字段仅保留旧客户端兼容
* 响应恒为 {@code [GAME_EXPENDITURE]}不能作为运营配置使用
*/
@Deprecated
private List<String> eventTypes = new ArrayList<>();
private String rankingType;
private String rankingPeriod;
@ -311,9 +316,11 @@ public final class AslanGameKingModels {
@Positive(message = "coinPerDraw must be greater than zero.")
private Long coinPerDraw;
@NotNull(message = "eventTypes required.")
@Size(min = 1, max = 30, message = "eventTypes size must be between 1 and 30.")
private List<@NotBlank String> eventTypes;
/**
* @deprecated 兼容旧版 webconsole 请求体服务端忽略传值并写入固定语义哨兵避免旧客户端升级期间报错
*/
@Deprecated
private List<String> eventTypes;
private String rankingType = "TYCOON";
private String rankingPeriod = "OVERALL";
@ -374,7 +381,7 @@ public final class AslanGameKingModels {
private static final long serialVersionUID = 1L;
private String eventType;
/** 0=收入VICTORIOUS1=支出TYCOON 与抽奖次数)。 */
/** 固定为 1EXPENDITURE历史补数不允许调用方切换收入/支出口径。 */
private Integer receiptType;
private Integer scanned;
private Integer accepted;

View File

@ -59,6 +59,12 @@ public class UserGoldRunningWaterBackQryCmd extends Command {
*/
private String origin;
/**
* 仅供服务端活动补数强制查询金币支出并把来源限制为四类第三方游戏前缀
* 调用方不能通过 type/origin 放宽此口径避免补数误扫礼物转账等非游戏流水
*/
private Boolean queryThirdPartyGameExpenditure;
/**
* 创建时间.
*/

View File

@ -118,13 +118,11 @@ public class AslanGameKingBackRestController extends BaseController {
}
/**
* 按精确 eventType + receiptType 分页补数两种收支类型各自维护 lastId避免合并游标跳流水
* 服务端固定补算第三方游戏金币支出页面只维护分页游标不能手工扩大事件或切换收支口径
*/
@PostMapping("/backfill/{id}")
public BackfillResult backfill(@PathVariable Long id, @RequestParam String eventType,
@RequestParam Integer receiptType,
@RequestParam(required = false) Long lastId,
public BackfillResult backfill(@PathVariable Long id, @RequestParam(required = false) Long lastId,
@RequestParam(required = false) Integer limit) {
return client.backfill(id, eventType, receiptType, lastId, limit).getBody();
return client.backfill(id, lastId, limit).getBody();
}
}

View File

@ -42,7 +42,7 @@ Query
`body``Detail`
- `activity`:活动配置,主要字段为 `id``activityCode``activityName``activityDesc``sysOrigin``timeZone``startTime``endTime``settlementDelayMinutes``settlementTime``coinPerDraw``eventTypes`、`rankingType`、`rankingPeriod``enabled``settlementStatus`
- `activity`:活动配置,主要字段为 `id``activityCode``activityName``activityDesc``sysOrigin``timeZone``startTime``endTime``settlementDelayMinutes``settlementTime``coinPerDraw``rankingType`、`rankingPeriod``enabled``settlementStatus`兼容字段 `eventTypes` 恒为 `["GAME_EXPENDITURE"]`,不代表可配置白名单。
- `prizes`7 个奖项;主要字段为 `id``prizeName``prizeImage``weight``stock``resourceGroupId``sortOrder``rewardItems`
- `rankRewards`:固定档位 `1``2``3``4-7``8-10``11-30` 及其 `resourceGroupId``rewardName``rewardItems`
- `supportedRankingTypes`:固定为 `TYCOON``VICTORIOUS`
@ -89,7 +89,7 @@ Body
| Query 参数 | 必填 | 说明 |
| --- | --- | --- |
| `activityId` | 否 | 活动 ID。 |
| `rankingType` | 否 | `TYCOON`(游戏支出)或 `VICTORIOUS`(游戏收入);省略时使用活动配置。 |
| `rankingType` | 否 | `TYCOON`(游戏支出)或兼容维度 `VICTORIOUS`;省略时使用活动配置。本活动事件采集只接收支出,因此运营配置固定使用 `TYCOON`。 |
| `period` | 否 | `DAILY``OVERALL`;省略时使用活动配置。 |
| `limit` | 否 | 默认 30服务端限制到 1100。 |
@ -128,7 +128,7 @@ Body
| `POST /settlement/{id}` | 活动 ID | 到达 `settlementTime` 后冻结 TYCOON/OVERALL Top 30 并发奖。 |
| `POST /settlement/retry/{recordId}` | 结算记录 ID | 仅重试已核实可重试的 `FAILED` 记录,不处理 `UNKNOWN`。 |
| `POST /delivery-item/resolve/{itemId}` | Query `delivered=true/false` | 只处理 `UNKNOWN` 单项;语义见下文。 |
| `POST /backfill/{id}` | `eventType`、`receiptType` 必填;`lastId`、`limit` 可选 | 分页补算钱包流水,返回 `BackfillResult`。 |
| `POST /backfill/{id}` | `lastId`、`limit` 可选 | 服务端按固定第三方游戏支出口径分页补算钱包流水,返回 `BackfillResult`。 |
### `SaveCommand` 关键字段与约束
@ -143,13 +143,13 @@ Body
| `startTime``endTime` | 必填毫秒时间戳,且 `startTime < endTime`。 |
| `settlementDelayMinutes` | 01440默认 30`settlementTime = endTime + settlementDelayMinutes`。 |
| `coinPerDraw` | 必填,正整数金币数。 |
| `eventTypes` | 必填130 个非空字符串;按钱包 `eventType` 精确匹配,单值不能含逗号。 |
| `eventTypes` | 已废弃且可不传;旧客户端传值也会被忽略,服务端统一保存 `GAME_EXPENDITURE` 兼容哨兵。 |
| `rankingType` | `TYCOON``VICTORIOUS`,默认 `TYCOON`。 |
| `rankingPeriod` | `DAILY``OVERALL`,默认 `OVERALL`。 |
| `enabled` | 默认 `false`。首次以 `true` 保存时,`startTime` 必须晚于当前时间。 |
| `prizes``rankRewards` | 可随活动一起保存,也可通过独立接口保存。启用前两者都必须完整。 |
同一 `sysOrigin` 的已启用活动时间窗不得重叠。活动开始后,统计时间、系统、时区、结算延时、`coinPerDraw`、启用状态和 `eventTypes` 等统计口径会锁定
同一 `sysOrigin` 的已启用活动时间窗不得重叠。活动开始后,统计时间、系统、时区、结算延时、`coinPerDraw` 和启用状态等统计口径会锁定。事件资格由服务端固定分类器决定,不允许 webconsole 手工配置
奖项必须恰好 7 个且全部 `enabled=true``sortOrder` 为不重复的 17`prizeName`/`prizeImage` 非空,`weight > 0``stock >= -1`,并配置 `resourceGroupId``stock=-1` 表示无限库存0 表示耗尽。
@ -157,9 +157,9 @@ Body
### 补数返回
`POST /backfill/{id}` `receiptType``0=INCOME/VICTORIOUS``1=EXPENDITURE/TYCOON 与抽奖机会``limit` 默认 200、最大 500每个 `eventType + receiptType` 必须分别维护自己的 `lastId`,直到返回 `finished=true`
`POST /backfill/{id}` 不接收 `eventType``receiptType``limit` 默认 200、最大 500调用方只需把本次 `nextLastId` 作为下次 `lastId` 继续请求,直到返回 `finished=true`。钱包侧会强制 `EXPENDITURE`,并以四个转义后的左锚定游戏前缀查询,避免扫描活动时间窗内的全部非游戏支出
`BackfillResult` 字段:`eventType``receiptType``scanned``accepted``nextLastId``finished`。补数查询边界同实时统计,为 `[startTime,endTime)`;调用钱包历史接口时实现为 `endTime - 1ms`
`BackfillResult` 字段:`eventType`(恒为 `GAME_EXPENDITURE``receiptType`(恒为 `1/EXPENDITURE``scanned``accepted``nextLastId``finished`。补数查询边界同实时统计,为 `[startTime,endTime)`;调用钱包历史接口时实现为 `endTime - 1ms`
## 钱包事件统计契约
@ -167,15 +167,16 @@ Body
- tag`wallet_sync_gold_v2`
- 独立 consumer group`ASLAN_GAME_KING_WALLET_SYNC_V1`。不得和钱包落库消费者共用 group否则会竞争消费。
- 事件模型:`WalletReceiptSyncEvent`。使用字段 `assetRecordId``userId``sysOrigin``eventType``eventId``receiptType``bizType``amount``createTime`
- 仅接收 `bizType=1`、金额非 0、`sysOrigin` 与活动相同、`eventType` 在活动白名单中、`createTime` 位于 `[startTime,endTime)` 的事件。
- `receiptType=EXPENDITURE(1)` 累计 TYCOON 与抽奖机会;`receiptType=INCOME(0)` 累计 VICTORIOUS。
- 唯一有效分类条件为:`bizType=WalletBizType.GOLD.getType()``receiptType=EXPENDITURE(1)`,并且 `eventType``HOT_GAME_``HKYS_GAME_``BAISHUN_GAME_``YOMI_GAME_` 之一开头;此外金额必须非 0、`sysOrigin` 必须与活动相同、`createTime` 必须位于 `[startTime,endTime)`
- 分类器使用严格 `startsWith`,且前缀后必须有具体游戏 ID/标识;裸 `HOT_GAME_` 等前缀不会计入。禁止仅判断“支出”、`contains("GAME")``GoldOriginMapping`,这些宽泛条件会误收礼物/奖励等流水或遗漏 YOMI。
- 有效流水只累计 TYCOON 与抽奖机会;`INCOME` 不进入该活动统计。
- 实时 MQ、MQ 重投和后台补数共用唯一幂等键 `(activity_id, asset_record_id)`
- DAILY 按活动 `timeZone` 落日表OVERALL 使用全活动累计。活动结算奖励固定按 TYCOON/OVERALL而不是当前 H5 正在查看的榜单维度。
## 结算截点与发奖核账
- 默认在 `endTime + 30 分钟` 到达结算截点;后台可通过 `settlementDelayMinutes` 配置 01440 分钟。定时任务每 5 分钟扫描一次到期活动。
- 统计有效时间仍只到 `endTime`不含30 分钟只是给异步消息到达留出的缓冲,不代表 RocketMQ 已提供“无更早消息”的 watermark。要求严格完整时应在截点前按全部配置的 `eventType × receiptType` 完成补数,再执行结算。
- 统计有效时间仍只到 `endTime`不含30 分钟只是给异步消息到达留出的缓冲,不代表 RocketMQ 已提供“无更早消息”的 watermark。要求严格完整时应在截点前按 `nextLastId` 连续调用固定口径补数直至 `finished=true`,再执行结算。
- 首次结算会在活动行排他锁内冻结 TYCOON/OVERALL Top 30已进入统计事务会先完成冻结后到达的 MQ 或补数会被拒绝,排名快照不会再漂移。
- 每个奖励组拆成 `deliveryItems` 逐项发放。普通失败可标为 `FAILED` 并在运营核实后走 retry外部适配器抛错`PROCESSING` 超过 10 分钟租期时,结果不能判定是否已到账,因此标为 `UNKNOWN`
- `UNKNOWN` 永远不会被定时任务或普通 retry 盲目补发。运营核对下游账目后调用 `/delivery-item/resolve/{itemId}``delivered=true` 表示确认已到账,仅把该项记为成功且不再次发送;`delivered=false` 表示确认未到账,原子解锁父记录与该项后再补发。
@ -185,6 +186,6 @@ Body
- 测试环境:`.deploy/test-deploy/sql/20260717_aslan_game_king.sql`
- 生产环境:`.deploy/prod-deploy/sql/20260717_aslan_game_king.sql`
- 两份 SQL 内容应保持字节一致。迁移创建 9 张独立活动表,并登记 webconsole 菜单/资源。
- SQL 文件开头已记录性能评估:实时写路径使用唯一键/主键聚合,榜单直接读取维度索引,恢复与到期扫描均有状态时间索引;迁移不回填钱包流水,也不扫描更新既有业务大表。
- 两份 SQL 内容应保持字节一致。迁移创建 9 张独立活动表,并登记 webconsole 菜单/资源。活动表 `event_types` 只为兼容旧服务保留,默认且只写 `GAME_EXPENDITURE`,真实流水的 `ledger.event_type` 仍保留用于审计。
- SQL 文件开头已记录性能评估:实时写路径使用唯一键/主键聚合,榜单直接读取维度索引,恢复与到期扫描均有状态时间索引;补数绑定 `sys_origin + EXPENDITURE` 并使用四个转义后的左锚定前缀,利用钱包 `(sys_origin,event_type,create_time)` 索引。迁移不回填钱包流水,也不扫描更新既有业务大表。
- 当前交付只新增了迁移文件,**尚未在测试或生产数据库执行**。执行前仍需按环境发布流程复核目标库、变更窗口和执行计划。

View File

@ -23,6 +23,13 @@
<artifactId>component-game</artifactId>
</dependency>
<!-- Game King 必须使用钱包领域枚举判定 GOLD禁止复制魔法值后随钱包协议漂移。 -->
<dependency>
<groupId>com.red.circle</groupId>
<artifactId>wallet-domain</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.red.circle</groupId>
<artifactId>live-inner-model</artifactId>

View File

@ -12,7 +12,7 @@ import com.red.circle.other.app.service.activity.aslan.AslanGameKingServiceImpl;
import lombok.RequiredArgsConstructor;
/**
* Aslan 游戏王钱包收支监听器
* Aslan 游戏王第三方游戏金币消费监听器
*
* <p>使用独立 consumer group 订阅 wallet_sync_gold_v2不能与钱包流水落库消费者共用
* group否则两者会竞争消息导致活动漏统计业务层仍以活动+资产流水唯一键兜底 MQ 重投</p>
@ -31,7 +31,7 @@ public class AslanGameKingWalletListener implements MessageListener {
public Action consume(ConsumerMessage message) {
return messageEventProcess.consume(
MessageEventProcessDescribe.builder()
.logTag("Aslan游戏王收支统计")
.logTag("Aslan游戏王第三方游戏消费统计")
.consumeMsgTimeoutMinute(60)
.repeatConsumeMinute(30)
.repeatConsumeTag("AslanGameKingWalletSyncV1")

View File

@ -42,6 +42,7 @@ import com.red.circle.other.inner.model.dto.user.UserProfileDTO;
import com.red.circle.other.inner.model.dto.material.PropsActivityRewardConfigInfoDTO;
import com.red.circle.tool.core.sequence.IdWorkerUtils;
import com.red.circle.tool.core.text.StringUtils;
import com.red.circle.wallet.domain.wallet.WalletBizType;
import com.red.circle.wallet.inner.endpoint.wallet.WalletGoldClient;
import com.red.circle.wallet.inner.model.cmd.UserGoldRunningWaterBackQryCmd;
import com.red.circle.wallet.inner.model.dto.UserGoldRunningWaterHistoryDTO;
@ -53,10 +54,8 @@ import java.time.LocalDate;
import java.time.ZoneId;
import java.time.DateTimeException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -85,6 +84,10 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
private static final String RANKING_VICTORIOUS = "VICTORIOUS";
private static final String PERIOD_DAILY = "DAILY";
private static final String PERIOD_OVERALL = "OVERALL";
/** 对外兼容字段与数据库列只保存此哨兵;运营不能自行扩大钱包事件口径。 */
private static final String FIXED_EVENT_SCOPE = "GAME_EXPENDITURE";
private static final List<String> THIRD_PARTY_GAME_EVENT_PREFIXES = List.of(
"HOT_GAME_", "HKYS_GAME_", "BAISHUN_GAME_", "YOMI_GAME_");
private static final String DEFAULT_TIME_ZONE = "Asia/Riyadh";
private static final int DEFAULT_SETTLEMENT_DELAY_MINUTES = 30;
private static final int MAX_SETTLEMENT_DELAY_MINUTES = 1440;
@ -238,11 +241,9 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
Timestamp eventTime = new Timestamp(event.getCreateTime().getTime());
BigDecimal amount = event.getAmount().getDollarAmount().abs();
for (ActivityRow activity : mapper.findActiveActivities(event.getSysOrigin(), eventTime)) {
if (!splitEventTypes(activity.getEventTypes()).contains(event.getEventType())) {
continue;
}
recordConsumption(activity, event.getAssetRecordId(), event.getUserId(), event.getSysOrigin(),
event.getEventType(), event.getEventId(), event.getReceiptType(), amount, eventTime);
event.getBizType(), event.getEventType(), event.getEventId(), event.getReceiptType(),
amount, eventTime);
}
}
@ -255,21 +256,32 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
&& event.getAmount().getDollarAmount() != null
&& event.getAmount().getDollarAmount().signum() != 0
&& event.getSysOrigin() != null
&& event.getEventType() != null
// wallet_sync_gold_v2 当前由金币网关发布bizType=1 再做一次防御避免未来复用 topic 时误计
&& Objects.equals(event.getBizType(), 1)
&& (ReceiptType.EXPENDITURE.eq(event.getReceiptType())
|| ReceiptType.INCOME.eq(event.getReceiptType()));
&& isThirdPartyGameExpenditure(event.getBizType(), event.getReceiptType(),
event.getEventType());
}
/**
* 唯一权威的游戏消费分类器三个条件必须同时成立金币业务支出单据四个第三方
* 游戏目录之一禁止 contains("GAME") GoldOriginMapping因为二者会把奖励/礼物等
* 非下注流水混入活动且旧映射并不完整包含 YOMI
*/
private boolean isThirdPartyGameExpenditure(Integer bizType, ReceiptType receiptType,
String eventType) {
return Objects.equals(bizType, WalletBizType.GOLD.getType())
&& ReceiptType.EXPENDITURE.eq(receiptType)
&& StringUtils.isNotBlank(eventType)
// 裸前缀不是具体游戏事件必须在前缀后继续包含游戏 ID/标识
&& THIRD_PARTY_GAME_EVENT_PREFIXES.stream()
.anyMatch(prefix -> eventType.length() > prefix.length() && eventType.startsWith(prefix));
}
private boolean recordConsumption(ActivityRow activity, Long assetRecordId, Long userId,
String sysOrigin, String eventType, String eventId, ReceiptType receiptType,
String sysOrigin, Integer bizType, String eventType, String eventId, ReceiptType receiptType,
BigDecimal amount, Timestamp eventTime) {
if (assetRecordId == null || userId == null || eventTime == null || amount == null
|| amount.signum() <= 0 || !Objects.equals(activity.getSysOrigin(), sysOrigin)
|| !splitEventTypes(activity.getEventTypes()).contains(eventType)
|| eventTime.before(activity.getStartTime()) || !eventTime.before(activity.getEndTime())
|| !(ReceiptType.EXPENDITURE.eq(receiptType) || ReceiptType.INCOME.eq(receiptType))) {
|| !isThirdPartyGameExpenditure(bizType, receiptType, eventType)
|| eventTime.before(activity.getStartTime()) || !eventTime.before(activity.getEndTime())) {
return false;
}
Date statDate = Date.valueOf(eventTime.toInstant()
@ -290,17 +302,10 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
}
mapper.ensureUser(activity.getId(), userId);
mapper.ensureDailyUser(activity.getId(), statDate, userId);
if (ReceiptType.EXPENDITURE.eq(receiptType)) {
ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE,
mapper.incrementUserConsumption(activity.getId(), userId, amount, eventTime) == 1
&& mapper.incrementDailyConsumption(activity.getId(), statDate, userId, amount,
eventTime) == 1);
} else {
ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE,
mapper.incrementUserWinning(activity.getId(), userId, amount, eventTime) == 1
&& mapper.incrementDailyWinning(activity.getId(), statDate, userId, amount,
eventTime) == 1);
}
return true;
});
return Boolean.TRUE.equals(inserted);
@ -582,22 +587,13 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
}
@Override
public BackfillResult backfill(Long activityId, String eventType, Integer receiptType,
Long lastId, Integer limit) {
public BackfillResult backfill(Long activityId, Long lastId, Integer limit) {
ActivityRow activity = requireActivity(activityId);
ResponseAssert.isTrue(CommonErrorCode.REQUIRED_FIELDS_CANNOT_BE_IGNORED,
StringUtils.isNotBlank(eventType));
ResponseAssert.isTrue(CommonErrorCode.TYPE_IS_NOT_IN_SCOPE,
splitEventTypes(activity.getEventTypes()).contains(eventType));
ResponseAssert.isTrue(CommonErrorCode.TYPE_IS_NOT_IN_SCOPE,
Objects.equals(receiptType, ReceiptType.EXPENDITURE.getType())
|| Objects.equals(receiptType, ReceiptType.INCOME.getType()));
ReceiptType requestedReceiptType = ReceiptType.toEnum(receiptType);
int safeLimit = clamp(limit, 200, MAX_BACKFILL_LIMIT);
UserGoldRunningWaterBackQryCmd query = new UserGoldRunningWaterBackQryCmd()
.setSysOrigin(activity.getSysOrigin())
.setType(requestedReceiptType.getType())
.setOrigin(eventType)
.setType(ReceiptType.EXPENDITURE.getType())
.setQueryThirdPartyGameExpenditure(true)
.setStartTime(activity.getStartTime().getTime())
// 钱包历史接口使用 <= endTime 1ms 与实时监听的 [start,end) 边界保持一致
.setEndTime(Math.subtractExact(activity.getEndTime().getTime(), 1L))
@ -608,17 +604,24 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
.orElse(Collections.emptyList());
int accepted = 0;
for (UserGoldRunningWaterHistoryDTO record : records) {
ReceiptType recordReceiptType = Objects.equals(record.getType(),
ReceiptType.EXPENDITURE.getType()) ? ReceiptType.EXPENDITURE : null;
// 钱包查询已用索引前缀收窄这里仍复用实时 MQ 的分类器做第二道防线防止查询契约漂移
if (!isThirdPartyGameExpenditure(WalletBizType.GOLD.getType(), recordReceiptType,
record.getOrigin())) {
continue;
}
Timestamp eventTime = new Timestamp(record.getCreateTime());
if (recordConsumption(activity, record.getId(), record.getUserId(), record.getSysOrigin(),
record.getOrigin(), record.getTrackId(), requestedReceiptType,
WalletBizType.GOLD.getType(), record.getOrigin(), record.getTrackId(), recordReceiptType,
record.getQuantity().abs(), eventTime)) {
accepted++;
}
}
Long nextLastId = records.isEmpty() ? null : records.get(records.size() - 1).getId();
return new BackfillResult()
.setEventType(eventType)
.setReceiptType(requestedReceiptType.getType())
.setEventType(FIXED_EVENT_SCOPE)
.setReceiptType(ReceiptType.EXPENDITURE.getType())
.setScanned(records.size())
.setAccepted(accepted)
.setNextLastId(nextLastId)
@ -813,7 +816,6 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
|| RANKING_VICTORIOUS.equals(cmd.getRankingType()))
&& (PERIOD_OVERALL.equals(Optional.ofNullable(cmd.getRankingPeriod())
.orElse(PERIOD_OVERALL)) || PERIOD_DAILY.equals(cmd.getRankingPeriod())));
normalizeEventTypes(cmd.getEventTypes());
if (cmd.getPrizes() != null) {
validatePrizes(cmd.getPrizes(), true, cmd.getSysOrigin());
}
@ -826,7 +828,8 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
if (existing == null || existing.getStartTime().after(Timestamp.from(Instant.now()))) {
return;
}
// 活动一旦开始资格分母统计起点系统与精确事件白名单均不可改变否则历史流水无法重算
// 活动一旦开始资格分母统计起点和系统不可改变否则已累计的消费流水无法重算
// 事件口径由服务端分类器固定不再比较旧库 CSV保存时会把兼容列收敛为统一哨兵
boolean lockedFieldsSame = Objects.equals(existing.getStartTime().getTime(), cmd.getStartTime())
&& Objects.equals(existing.getEndTime().getTime(), cmd.getEndTime())
&& Objects.equals(existing.getCoinPerDraw(), cmd.getCoinPerDraw())
@ -834,9 +837,7 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
&& Objects.equals(existing.getTimeZone(), normalizeTimeZone(cmd.getTimeZone()))
&& Objects.equals(existing.getSettlementDelayMinutes(),
normalizeSettlementDelay(cmd.getSettlementDelayMinutes()))
&& Objects.equals(existing.getEnabled(), Boolean.TRUE.equals(cmd.getEnabled()))
&& Objects.equals(existing.getEventTypes(), String.join(",",
normalizeEventTypes(cmd.getEventTypes())));
&& Objects.equals(existing.getEnabled(), Boolean.TRUE.equals(cmd.getEnabled()));
ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, lockedFieldsSame);
}
@ -1036,7 +1037,7 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
.setSettlementDelayMinutes(row.getSettlementDelayMinutes())
.setSettlementTime(row.getSettlementTime().getTime())
.setCoinPerDraw(row.getCoinPerDraw())
.setEventTypes(splitEventTypes(row.getEventTypes()))
.setEventTypes(List.of(FIXED_EVENT_SCOPE))
.setRankingType(row.getRankingType())
.setRankingPeriod(row.getRankingPeriod())
.setEnabled(row.getEnabled())
@ -1059,7 +1060,7 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
.setSettlementDelayMinutes(settlementDelayMinutes)
.setSettlementTime(buildSettlementTime(cmd.getEndTime(), settlementDelayMinutes))
.setCoinPerDraw(cmd.getCoinPerDraw())
.setEventTypes(String.join(",", normalizeEventTypes(cmd.getEventTypes())))
.setEventTypes(FIXED_EVENT_SCOPE)
.setRankingType(Optional.ofNullable(cmd.getRankingType()).orElse(RANKING_TYCOON))
.setRankingPeriod(Optional.ofNullable(cmd.getRankingPeriod()).orElse(PERIOD_OVERALL))
.setEnabled(Boolean.TRUE.equals(cmd.getEnabled()))
@ -1218,31 +1219,6 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
.orElseThrow(() -> new IllegalStateException("Missing rank reward for rank " + rank));
}
private List<String> normalizeEventTypes(List<String> eventTypes) {
ResponseAssert.isTrue(CommonErrorCode.CONFIGURATION_ERROR,
eventTypes != null && !eventTypes.isEmpty());
LinkedHashSet<String> normalized = new LinkedHashSet<>();
for (String eventType : eventTypes) {
String value = eventType == null ? "" : eventType.trim();
// CSV 是内部存储格式因此拒绝逗号其余字符保持原样以做钱包 eventType 精确匹配
ResponseAssert.isTrue(CommonErrorCode.CONFIGURATION_ERROR,
StringUtils.isNotBlank(value) && value.length() <= 64 && !value.contains(","));
normalized.add(value);
}
ResponseAssert.isTrue(CommonErrorCode.CONFIGURATION_ERROR, normalized.size() == eventTypes.size());
return new ArrayList<>(normalized);
}
private List<String> splitEventTypes(String eventTypes) {
if (StringUtils.isBlank(eventTypes)) {
return Collections.emptyList();
}
return Arrays.stream(eventTypes.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.toList();
}
private String normalizeTimeZone(String timeZone) {
return StringUtils.isBlank(timeZone) ? DEFAULT_TIME_ZONE : timeZone.trim();
}

View File

@ -63,6 +63,5 @@ public interface AslanGameKingService {
void resolveUnknownDeliveryItem(Long itemId, boolean delivered);
BackfillResult backfill(Long activityId, String eventType, Integer receiptType, Long lastId,
Integer limit);
BackfillResult backfill(Long activityId, Long lastId, Integer limit);
}

View File

@ -8,8 +8,8 @@ import lombok.experimental.Accessors;
/**
* Aslan 游戏王活动数据库行模型
*
* <p>这些类型只在持久层使用尤其把事件白名单保留为规范化 CSV对外契约仍使用
* List防止存储格式泄漏到 H5 webconsole</p>
* <p>这些类型只在持久层使用eventTypes 列仅保存固定的 GAME_EXPENDITURE 兼容哨兵
* 真实事件资格由应用层按钱包业务类型收支类型和第三方游戏前缀统一判定不能由运营配置</p>
*/
public final class AslanGameKingRows {

View File

@ -119,9 +119,7 @@ public class AslanGameKingManageClientEndpoint implements AslanGameKingManageCli
}
@Override
public ResultResponse<BackfillResult> backfill(Long id, String eventType, Integer receiptType,
Long lastId, Integer limit) {
return ResultResponse.success(
aslanGameKingService.backfill(id, eventType, receiptType, lastId, limit));
public ResultResponse<BackfillResult> backfill(Long id, Long lastId, Integer limit) {
return ResultResponse.success(aslanGameKingService.backfill(id, lastId, limit));
}
}

View File

@ -2,6 +2,7 @@ package com.red.circle.wallet.inner.service.wallet.impl;
import com.google.common.collect.Lists;
import com.red.circle.common.business.core.enums.OpUserType;
import com.red.circle.common.business.core.enums.ReceiptType;
import com.red.circle.common.business.core.enums.SysOriginPlatformEnum;
import com.red.circle.component.tencent.cls.request.ClsLogSearchQry;
import com.red.circle.component.tencent.cls.response.ClsLogSearchResult;
@ -61,6 +62,14 @@ import org.springframework.stereotype.Service;
@RequiredArgsConstructor
public class WalletGoldClientServiceImpl implements WalletGoldClientService {
/**
* MySQL LIKE 中下划线本身是单字符通配符所以这里必须先用反斜线转义MyBatis-Plus
* 再追加右侧百分号后得到形如 HOT\_GAME\_% 的左锚定范围该条件能继续利用
* (sys_origin,event_type,create_time) 复合索引避免活动补数退化成全平台支出扫描
*/
private static final List<String> THIRD_PARTY_GAME_ORIGIN_LIKE_PREFIXES = List.of(
"HOT\\_GAME\\_", "HKYS\\_GAME\\_", "BAISHUN\\_GAME\\_", "YOMI\\_GAME\\_");
private final ClsService clsService;
private final EnvProperties envProperties;
private final WalletGoldGateway walletGoldGateway;
@ -149,6 +158,11 @@ public class WalletGoldClientServiceImpl implements WalletGoldClientService {
@Override
public List<UserGoldRunningWaterHistoryDTO> listWater(UserGoldRunningWaterBackQryCmd cmd) {
List<String> querySysOrigin = listWaterQuerySysOrigin(cmd);
boolean queryThirdPartyGameExpenditure =
Boolean.TRUE.equals(cmd.getQueryThirdPartyGameExpenditure());
// 固定模式忽略调用方传入的 type/origin服务端强制收窄为游戏金币支出不能被请求放宽
Integer queryType = queryThirdPartyGameExpenditure
? ReceiptType.EXPENDITURE.getType() : cmd.getType();
LambdaQueryWrapperChain<WalletGoldAssetRecord> queryChain = walletGoldAssetRecordService.query();
@ -165,12 +179,21 @@ public class WalletGoldClientServiceImpl implements WalletGoldClientService {
.eq(Objects.nonNull(cmd.getId()), WalletGoldAssetRecord::getId, cmd.getId())
.eq(Objects.nonNull(cmd.getQueryBackOperationUser()), WalletGoldAssetRecord::getOpUserType,
cmd.getQueryBackOperationUser())
.eq(Objects.nonNull(cmd.getType()), WalletGoldAssetRecord::getType, cmd.getType())
.eq(Objects.nonNull(queryType), WalletGoldAssetRecord::getType, queryType)
.eq(Objects.nonNull(cmd.getUserId()), WalletGoldAssetRecord::getUserId, cmd.getUserId())
.eq(StringUtils.isNotBlank(cmd.getTrackId()), WalletGoldAssetRecord::getEventId,
cmd.getTrackId())
.eq(StringUtils.isNotBlank(cmd.getOrigin()), WalletGoldAssetRecord::getEventType,
cmd.getOrigin())
.eq(!queryThirdPartyGameExpenditure && StringUtils.isNotBlank(cmd.getOrigin()),
WalletGoldAssetRecord::getEventType, cmd.getOrigin())
.and(queryThirdPartyGameExpenditure, origins -> origins
.likeRight(WalletGoldAssetRecord::getEventType,
THIRD_PARTY_GAME_ORIGIN_LIKE_PREFIXES.get(0))
.or().likeRight(WalletGoldAssetRecord::getEventType,
THIRD_PARTY_GAME_ORIGIN_LIKE_PREFIXES.get(1))
.or().likeRight(WalletGoldAssetRecord::getEventType,
THIRD_PARTY_GAME_ORIGIN_LIKE_PREFIXES.get(2))
.or().likeRight(WalletGoldAssetRecord::getEventType,
THIRD_PARTY_GAME_ORIGIN_LIKE_PREFIXES.get(3)))
.lt(Objects.nonNull(cmd.getLastId()), WalletGoldAssetRecord::getId, cmd.getLastId())
.orderByDesc(WalletGoldAssetRecord::getId)
.last(Objects.nonNull(cmd.getLimit()) && cmd.getLimit() > 0 ? PageConstant.formatLimit(