fix: restrict game king to game expenditures
This commit is contained in:
parent
e2bcc23308
commit
fa2ab4f3d6
@ -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):
|
-- 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
|
-- 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.
|
-- 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;
|
-- 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.
|
-- 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;
|
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_delay_minutes INT NOT NULL DEFAULT 30,
|
||||||
settlement_time DATETIME(3) NOT NULL,
|
settlement_time DATETIME(3) NOT NULL,
|
||||||
coin_per_draw BIGINT 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_type VARCHAR(32) NOT NULL DEFAULT 'TYCOON',
|
||||||
ranking_period VARCHAR(32) NOT NULL DEFAULT 'OVERALL',
|
ranking_period VARCHAR(32) NOT NULL DEFAULT 'OVERALL',
|
||||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
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,
|
sys_origin VARCHAR(32) NOT NULL,
|
||||||
event_type VARCHAR(64) NOT NULL,
|
event_type VARCHAR(64) NOT NULL,
|
||||||
event_id VARCHAR(128) 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,
|
amount DECIMAL(24,2) NOT NULL,
|
||||||
event_time DATETIME(3) NOT NULL,
|
event_time DATETIME(3) NOT NULL,
|
||||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
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),
|
UNIQUE KEY uq_aslan_gk_activity_asset (activity_id, asset_record_id),
|
||||||
KEY idx_aslan_gk_ledger_user (activity_id, user_id, event_time)
|
KEY idx_aslan_gk_ledger_user (activity_id, user_id, event_time)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) 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 (
|
CREATE TABLE IF NOT EXISTS aslan_game_king_user (
|
||||||
activity_id BIGINT NOT NULL,
|
activity_id BIGINT NOT NULL,
|
||||||
|
|||||||
@ -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):
|
-- 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
|
-- 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.
|
-- 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;
|
-- 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.
|
-- 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;
|
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_delay_minutes INT NOT NULL DEFAULT 30,
|
||||||
settlement_time DATETIME(3) NOT NULL,
|
settlement_time DATETIME(3) NOT NULL,
|
||||||
coin_per_draw BIGINT 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_type VARCHAR(32) NOT NULL DEFAULT 'TYCOON',
|
||||||
ranking_period VARCHAR(32) NOT NULL DEFAULT 'OVERALL',
|
ranking_period VARCHAR(32) NOT NULL DEFAULT 'OVERALL',
|
||||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
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,
|
sys_origin VARCHAR(32) NOT NULL,
|
||||||
event_type VARCHAR(64) NOT NULL,
|
event_type VARCHAR(64) NOT NULL,
|
||||||
event_id VARCHAR(128) 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,
|
amount DECIMAL(24,2) NOT NULL,
|
||||||
event_time DATETIME(3) NOT NULL,
|
event_time DATETIME(3) NOT NULL,
|
||||||
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
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),
|
UNIQUE KEY uq_aslan_gk_activity_asset (activity_id, asset_record_id),
|
||||||
KEY idx_aslan_gk_ledger_user (activity_id, user_id, event_time)
|
KEY idx_aslan_gk_ledger_user (activity_id, user_id, event_time)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
) 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 (
|
CREATE TABLE IF NOT EXISTS aslan_game_king_user (
|
||||||
activity_id BIGINT NOT NULL,
|
activity_id BIGINT NOT NULL,
|
||||||
|
|||||||
@ -75,8 +75,6 @@ public interface AslanGameKingManageClientApi {
|
|||||||
|
|
||||||
@PostMapping("/backfill/{id}")
|
@PostMapping("/backfill/{id}")
|
||||||
ResultResponse<BackfillResult> backfill(@PathVariable("id") Long 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 = "lastId", required = false) Long lastId,
|
||||||
@RequestParam(value = "limit", required = false) Integer limit);
|
@RequestParam(value = "limit", required = false) Integer limit);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,11 @@ public final class AslanGameKingModels {
|
|||||||
private Integer settlementDelayMinutes;
|
private Integer settlementDelayMinutes;
|
||||||
private Long settlementTime;
|
private Long settlementTime;
|
||||||
private Long coinPerDraw;
|
private Long coinPerDraw;
|
||||||
|
/**
|
||||||
|
* @deprecated 游戏王只统计服务端固定识别的第三方游戏金币消耗;该字段仅保留旧客户端兼容,
|
||||||
|
* 响应恒为 {@code [GAME_EXPENDITURE]},不能作为运营配置使用。
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
private List<String> eventTypes = new ArrayList<>();
|
private List<String> eventTypes = new ArrayList<>();
|
||||||
private String rankingType;
|
private String rankingType;
|
||||||
private String rankingPeriod;
|
private String rankingPeriod;
|
||||||
@ -311,9 +316,11 @@ public final class AslanGameKingModels {
|
|||||||
@Positive(message = "coinPerDraw must be greater than zero.")
|
@Positive(message = "coinPerDraw must be greater than zero.")
|
||||||
private Long coinPerDraw;
|
private Long coinPerDraw;
|
||||||
|
|
||||||
@NotNull(message = "eventTypes required.")
|
/**
|
||||||
@Size(min = 1, max = 30, message = "eventTypes size must be between 1 and 30.")
|
* @deprecated 兼容旧版 webconsole 请求体;服务端忽略传值并写入固定语义哨兵,避免旧客户端升级期间报错。
|
||||||
private List<@NotBlank String> eventTypes;
|
*/
|
||||||
|
@Deprecated
|
||||||
|
private List<String> eventTypes;
|
||||||
|
|
||||||
private String rankingType = "TYCOON";
|
private String rankingType = "TYCOON";
|
||||||
private String rankingPeriod = "OVERALL";
|
private String rankingPeriod = "OVERALL";
|
||||||
@ -374,7 +381,7 @@ public final class AslanGameKingModels {
|
|||||||
private static final long serialVersionUID = 1L;
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
private String eventType;
|
private String eventType;
|
||||||
/** 0=收入(VICTORIOUS),1=支出(TYCOON 与抽奖次数)。 */
|
/** 固定为 1(EXPENDITURE);历史补数不允许调用方切换收入/支出口径。 */
|
||||||
private Integer receiptType;
|
private Integer receiptType;
|
||||||
private Integer scanned;
|
private Integer scanned;
|
||||||
private Integer accepted;
|
private Integer accepted;
|
||||||
|
|||||||
@ -59,6 +59,12 @@ public class UserGoldRunningWaterBackQryCmd extends Command {
|
|||||||
*/
|
*/
|
||||||
private String origin;
|
private String origin;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅供服务端活动补数:强制查询金币支出,并把来源限制为四类第三方游戏前缀。
|
||||||
|
* 调用方不能通过 type/origin 放宽此口径,避免补数误扫礼物、转账等非游戏流水。
|
||||||
|
*/
|
||||||
|
private Boolean queryThirdPartyGameExpenditure;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建时间.
|
* 创建时间.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -118,13 +118,11 @@ public class AslanGameKingBackRestController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按精确 eventType + receiptType 分页补数;两种收支类型各自维护 lastId,避免合并游标跳流水。
|
* 服务端固定补算第三方游戏金币支出;页面只维护分页游标,不能手工扩大事件或切换收支口径。
|
||||||
*/
|
*/
|
||||||
@PostMapping("/backfill/{id}")
|
@PostMapping("/backfill/{id}")
|
||||||
public BackfillResult backfill(@PathVariable Long id, @RequestParam String eventType,
|
public BackfillResult backfill(@PathVariable Long id, @RequestParam(required = false) Long lastId,
|
||||||
@RequestParam Integer receiptType,
|
|
||||||
@RequestParam(required = false) Long lastId,
|
|
||||||
@RequestParam(required = false) Integer limit) {
|
@RequestParam(required = false) Integer limit) {
|
||||||
return client.backfill(id, eventType, receiptType, lastId, limit).getBody();
|
return client.backfill(id, lastId, limit).getBody();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,7 @@ Query:
|
|||||||
|
|
||||||
`body` 为 `Detail`:
|
`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`。
|
- `prizes`:7 个奖项;主要字段为 `id`、`prizeName`、`prizeImage`、`weight`、`stock`、`resourceGroupId`、`sortOrder`、`rewardItems`。
|
||||||
- `rankRewards`:固定档位 `1`、`2`、`3`、`4-7`、`8-10`、`11-30` 及其 `resourceGroupId`、`rewardName`、`rewardItems`。
|
- `rankRewards`:固定档位 `1`、`2`、`3`、`4-7`、`8-10`、`11-30` 及其 `resourceGroupId`、`rewardName`、`rewardItems`。
|
||||||
- `supportedRankingTypes`:固定为 `TYCOON`、`VICTORIOUS`。
|
- `supportedRankingTypes`:固定为 `TYCOON`、`VICTORIOUS`。
|
||||||
@ -89,7 +89,7 @@ Body:
|
|||||||
| Query 参数 | 必填 | 说明 |
|
| Query 参数 | 必填 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `activityId` | 否 | 活动 ID。 |
|
| `activityId` | 否 | 活动 ID。 |
|
||||||
| `rankingType` | 否 | `TYCOON`(游戏支出)或 `VICTORIOUS`(游戏收入);省略时使用活动配置。 |
|
| `rankingType` | 否 | `TYCOON`(游戏支出)或兼容维度 `VICTORIOUS`;省略时使用活动配置。本活动事件采集只接收支出,因此运营配置固定使用 `TYCOON`。 |
|
||||||
| `period` | 否 | `DAILY` 或 `OVERALL`;省略时使用活动配置。 |
|
| `period` | 否 | `DAILY` 或 `OVERALL`;省略时使用活动配置。 |
|
||||||
| `limit` | 否 | 默认 30,服务端限制到 1~100。 |
|
| `limit` | 否 | 默认 30,服务端限制到 1~100。 |
|
||||||
|
|
||||||
@ -128,7 +128,7 @@ Body:
|
|||||||
| `POST /settlement/{id}` | 活动 ID | 到达 `settlementTime` 后冻结 TYCOON/OVERALL Top 30 并发奖。 |
|
| `POST /settlement/{id}` | 活动 ID | 到达 `settlementTime` 后冻结 TYCOON/OVERALL Top 30 并发奖。 |
|
||||||
| `POST /settlement/retry/{recordId}` | 结算记录 ID | 仅重试已核实可重试的 `FAILED` 记录,不处理 `UNKNOWN`。 |
|
| `POST /settlement/retry/{recordId}` | 结算记录 ID | 仅重试已核实可重试的 `FAILED` 记录,不处理 `UNKNOWN`。 |
|
||||||
| `POST /delivery-item/resolve/{itemId}` | Query `delivered=true/false` | 只处理 `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` 关键字段与约束
|
### `SaveCommand` 关键字段与约束
|
||||||
|
|
||||||
@ -143,13 +143,13 @@ Body:
|
|||||||
| `startTime`、`endTime` | 必填毫秒时间戳,且 `startTime < endTime`。 |
|
| `startTime`、`endTime` | 必填毫秒时间戳,且 `startTime < endTime`。 |
|
||||||
| `settlementDelayMinutes` | 0~1440,默认 30;`settlementTime = endTime + settlementDelayMinutes`。 |
|
| `settlementDelayMinutes` | 0~1440,默认 30;`settlementTime = endTime + settlementDelayMinutes`。 |
|
||||||
| `coinPerDraw` | 必填,正整数金币数。 |
|
| `coinPerDraw` | 必填,正整数金币数。 |
|
||||||
| `eventTypes` | 必填,1~30 个非空字符串;按钱包 `eventType` 精确匹配,单值不能含逗号。 |
|
| `eventTypes` | 已废弃且可不传;旧客户端传值也会被忽略,服务端统一保存 `GAME_EXPENDITURE` 兼容哨兵。 |
|
||||||
| `rankingType` | `TYCOON` 或 `VICTORIOUS`,默认 `TYCOON`。 |
|
| `rankingType` | `TYCOON` 或 `VICTORIOUS`,默认 `TYCOON`。 |
|
||||||
| `rankingPeriod` | `DAILY` 或 `OVERALL`,默认 `OVERALL`。 |
|
| `rankingPeriod` | `DAILY` 或 `OVERALL`,默认 `OVERALL`。 |
|
||||||
| `enabled` | 默认 `false`。首次以 `true` 保存时,`startTime` 必须晚于当前时间。 |
|
| `enabled` | 默认 `false`。首次以 `true` 保存时,`startTime` 必须晚于当前时间。 |
|
||||||
| `prizes`、`rankRewards` | 可随活动一起保存,也可通过独立接口保存。启用前两者都必须完整。 |
|
| `prizes`、`rankRewards` | 可随活动一起保存,也可通过独立接口保存。启用前两者都必须完整。 |
|
||||||
|
|
||||||
同一 `sysOrigin` 的已启用活动时间窗不得重叠。活动开始后,统计时间、系统、时区、结算延时、`coinPerDraw`、启用状态和 `eventTypes` 等统计口径会锁定。
|
同一 `sysOrigin` 的已启用活动时间窗不得重叠。活动开始后,统计时间、系统、时区、结算延时、`coinPerDraw` 和启用状态等统计口径会锁定。事件资格由服务端固定分类器决定,不允许 webconsole 手工配置。
|
||||||
|
|
||||||
奖项必须恰好 7 个且全部 `enabled=true`,`sortOrder` 为不重复的 1~7,`prizeName`/`prizeImage` 非空,`weight > 0`,`stock >= -1`,并配置 `resourceGroupId`。`stock=-1` 表示无限库存,0 表示耗尽。
|
奖项必须恰好 7 个且全部 `enabled=true`,`sortOrder` 为不重复的 1~7,`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`。
|
- tag:`wallet_sync_gold_v2`。
|
||||||
- 独立 consumer group:`ASLAN_GAME_KING_WALLET_SYNC_V1`。不得和钱包落库消费者共用 group,否则会竞争消费。
|
- 独立 consumer group:`ASLAN_GAME_KING_WALLET_SYNC_V1`。不得和钱包落库消费者共用 group,否则会竞争消费。
|
||||||
- 事件模型:`WalletReceiptSyncEvent`。使用字段 `assetRecordId`、`userId`、`sysOrigin`、`eventType`、`eventId`、`receiptType`、`bizType`、`amount`、`createTime`。
|
- 事件模型:`WalletReceiptSyncEvent`。使用字段 `assetRecordId`、`userId`、`sysOrigin`、`eventType`、`eventId`、`receiptType`、`bizType`、`amount`、`createTime`。
|
||||||
- 仅接收 `bizType=1`、金额非 0、`sysOrigin` 与活动相同、`eventType` 在活动白名单中、`createTime` 位于 `[startTime,endTime)` 的事件。
|
- 唯一有效分类条件为:`bizType=WalletBizType.GOLD.getType()`、`receiptType=EXPENDITURE(1)`,并且 `eventType` 以 `HOT_GAME_`、`HKYS_GAME_`、`BAISHUN_GAME_`、`YOMI_GAME_` 之一开头;此外金额必须非 0、`sysOrigin` 必须与活动相同、`createTime` 必须位于 `[startTime,endTime)`。
|
||||||
- `receiptType=EXPENDITURE(1)` 累计 TYCOON 与抽奖机会;`receiptType=INCOME(0)` 累计 VICTORIOUS。
|
- 分类器使用严格 `startsWith`,且前缀后必须有具体游戏 ID/标识;裸 `HOT_GAME_` 等前缀不会计入。禁止仅判断“支出”、`contains("GAME")` 或 `GoldOriginMapping`,这些宽泛条件会误收礼物/奖励等流水或遗漏 YOMI。
|
||||||
|
- 有效流水只累计 TYCOON 与抽奖机会;`INCOME` 不进入该活动统计。
|
||||||
- 实时 MQ、MQ 重投和后台补数共用唯一幂等键 `(activity_id, asset_record_id)`。
|
- 实时 MQ、MQ 重投和后台补数共用唯一幂等键 `(activity_id, asset_record_id)`。
|
||||||
- DAILY 按活动 `timeZone` 落日表;OVERALL 使用全活动累计。活动结算奖励固定按 TYCOON/OVERALL,而不是当前 H5 正在查看的榜单维度。
|
- DAILY 按活动 `timeZone` 落日表;OVERALL 使用全活动累计。活动结算奖励固定按 TYCOON/OVERALL,而不是当前 H5 正在查看的榜单维度。
|
||||||
|
|
||||||
## 结算截点与发奖核账
|
## 结算截点与发奖核账
|
||||||
|
|
||||||
- 默认在 `endTime + 30 分钟` 到达结算截点;后台可通过 `settlementDelayMinutes` 配置 0~1440 分钟。定时任务每 5 分钟扫描一次到期活动。
|
- 默认在 `endTime + 30 分钟` 到达结算截点;后台可通过 `settlementDelayMinutes` 配置 0~1440 分钟。定时任务每 5 分钟扫描一次到期活动。
|
||||||
- 统计有效时间仍只到 `endTime`(不含);30 分钟只是给异步消息到达留出的缓冲,不代表 RocketMQ 已提供“无更早消息”的 watermark。要求严格完整时,应在截点前按全部配置的 `eventType × receiptType` 完成补数,再执行结算。
|
- 统计有效时间仍只到 `endTime`(不含);30 分钟只是给异步消息到达留出的缓冲,不代表 RocketMQ 已提供“无更早消息”的 watermark。要求严格完整时,应在截点前按 `nextLastId` 连续调用固定口径补数直至 `finished=true`,再执行结算。
|
||||||
- 首次结算会在活动行排他锁内冻结 TYCOON/OVERALL Top 30;已进入统计事务会先完成,冻结后到达的 MQ 或补数会被拒绝,排名快照不会再漂移。
|
- 首次结算会在活动行排他锁内冻结 TYCOON/OVERALL Top 30;已进入统计事务会先完成,冻结后到达的 MQ 或补数会被拒绝,排名快照不会再漂移。
|
||||||
- 每个奖励组拆成 `deliveryItems` 逐项发放。普通失败可标为 `FAILED` 并在运营核实后走 retry;外部适配器抛错,或 `PROCESSING` 超过 10 分钟租期时,结果不能判定是否已到账,因此标为 `UNKNOWN`。
|
- 每个奖励组拆成 `deliveryItems` 逐项发放。普通失败可标为 `FAILED` 并在运营核实后走 retry;外部适配器抛错,或 `PROCESSING` 超过 10 分钟租期时,结果不能判定是否已到账,因此标为 `UNKNOWN`。
|
||||||
- `UNKNOWN` 永远不会被定时任务或普通 retry 盲目补发。运营核对下游账目后调用 `/delivery-item/resolve/{itemId}`:`delivered=true` 表示确认已到账,仅把该项记为成功且不再次发送;`delivered=false` 表示确认未到账,原子解锁父记录与该项后再补发。
|
- `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/test-deploy/sql/20260717_aslan_game_king.sql`
|
||||||
- 生产环境:`.deploy/prod-deploy/sql/20260717_aslan_game_king.sql`
|
- 生产环境:`.deploy/prod-deploy/sql/20260717_aslan_game_king.sql`
|
||||||
- 两份 SQL 内容应保持字节一致。迁移创建 9 张独立活动表,并登记 webconsole 菜单/资源。
|
- 两份 SQL 内容应保持字节一致。迁移创建 9 张独立活动表,并登记 webconsole 菜单/资源。活动表 `event_types` 只为兼容旧服务保留,默认且只写 `GAME_EXPENDITURE`,真实流水的 `ledger.event_type` 仍保留用于审计。
|
||||||
- SQL 文件开头已记录性能评估:实时写路径使用唯一键/主键聚合,榜单直接读取维度索引,恢复与到期扫描均有状态时间索引;迁移不回填钱包流水,也不扫描更新既有业务大表。
|
- SQL 文件开头已记录性能评估:实时写路径使用唯一键/主键聚合,榜单直接读取维度索引,恢复与到期扫描均有状态时间索引;补数绑定 `sys_origin + EXPENDITURE` 并使用四个转义后的左锚定前缀,利用钱包 `(sys_origin,event_type,create_time)` 索引。迁移不回填钱包流水,也不扫描更新既有业务大表。
|
||||||
- 当前交付只新增了迁移文件,**尚未在测试或生产数据库执行**。执行前仍需按环境发布流程复核目标库、变更窗口和执行计划。
|
- 当前交付只新增了迁移文件,**尚未在测试或生产数据库执行**。执行前仍需按环境发布流程复核目标库、变更窗口和执行计划。
|
||||||
|
|||||||
@ -23,6 +23,13 @@
|
|||||||
<artifactId>component-game</artifactId>
|
<artifactId>component-game</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Game King 必须使用钱包领域枚举判定 GOLD,禁止复制魔法值后随钱包协议漂移。 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.red.circle</groupId>
|
||||||
|
<artifactId>wallet-domain</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.red.circle</groupId>
|
<groupId>com.red.circle</groupId>
|
||||||
<artifactId>live-inner-model</artifactId>
|
<artifactId>live-inner-model</artifactId>
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import com.red.circle.other.app.service.activity.aslan.AslanGameKingServiceImpl;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aslan 游戏王钱包收支监听器。
|
* Aslan 游戏王第三方游戏金币消费监听器。
|
||||||
*
|
*
|
||||||
* <p>使用独立 consumer group 订阅 wallet_sync_gold_v2,不能与钱包流水落库消费者共用
|
* <p>使用独立 consumer group 订阅 wallet_sync_gold_v2,不能与钱包流水落库消费者共用
|
||||||
* group;否则两者会竞争消息,导致活动漏统计。业务层仍以活动+资产流水唯一键兜底 MQ 重投。</p>
|
* group;否则两者会竞争消息,导致活动漏统计。业务层仍以活动+资产流水唯一键兜底 MQ 重投。</p>
|
||||||
@ -31,7 +31,7 @@ public class AslanGameKingWalletListener implements MessageListener {
|
|||||||
public Action consume(ConsumerMessage message) {
|
public Action consume(ConsumerMessage message) {
|
||||||
return messageEventProcess.consume(
|
return messageEventProcess.consume(
|
||||||
MessageEventProcessDescribe.builder()
|
MessageEventProcessDescribe.builder()
|
||||||
.logTag("Aslan游戏王收支统计")
|
.logTag("Aslan游戏王第三方游戏消费统计")
|
||||||
.consumeMsgTimeoutMinute(60)
|
.consumeMsgTimeoutMinute(60)
|
||||||
.repeatConsumeMinute(30)
|
.repeatConsumeMinute(30)
|
||||||
.repeatConsumeTag("AslanGameKingWalletSyncV1")
|
.repeatConsumeTag("AslanGameKingWalletSyncV1")
|
||||||
|
|||||||
@ -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.other.inner.model.dto.material.PropsActivityRewardConfigInfoDTO;
|
||||||
import com.red.circle.tool.core.sequence.IdWorkerUtils;
|
import com.red.circle.tool.core.sequence.IdWorkerUtils;
|
||||||
import com.red.circle.tool.core.text.StringUtils;
|
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.endpoint.wallet.WalletGoldClient;
|
||||||
import com.red.circle.wallet.inner.model.cmd.UserGoldRunningWaterBackQryCmd;
|
import com.red.circle.wallet.inner.model.cmd.UserGoldRunningWaterBackQryCmd;
|
||||||
import com.red.circle.wallet.inner.model.dto.UserGoldRunningWaterHistoryDTO;
|
import com.red.circle.wallet.inner.model.dto.UserGoldRunningWaterHistoryDTO;
|
||||||
@ -53,10 +54,8 @@ import java.time.LocalDate;
|
|||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.time.DateTimeException;
|
import java.time.DateTimeException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@ -85,6 +84,10 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
|
|||||||
private static final String RANKING_VICTORIOUS = "VICTORIOUS";
|
private static final String RANKING_VICTORIOUS = "VICTORIOUS";
|
||||||
private static final String PERIOD_DAILY = "DAILY";
|
private static final String PERIOD_DAILY = "DAILY";
|
||||||
private static final String PERIOD_OVERALL = "OVERALL";
|
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 String DEFAULT_TIME_ZONE = "Asia/Riyadh";
|
||||||
private static final int DEFAULT_SETTLEMENT_DELAY_MINUTES = 30;
|
private static final int DEFAULT_SETTLEMENT_DELAY_MINUTES = 30;
|
||||||
private static final int MAX_SETTLEMENT_DELAY_MINUTES = 1440;
|
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());
|
Timestamp eventTime = new Timestamp(event.getCreateTime().getTime());
|
||||||
BigDecimal amount = event.getAmount().getDollarAmount().abs();
|
BigDecimal amount = event.getAmount().getDollarAmount().abs();
|
||||||
for (ActivityRow activity : mapper.findActiveActivities(event.getSysOrigin(), eventTime)) {
|
for (ActivityRow activity : mapper.findActiveActivities(event.getSysOrigin(), eventTime)) {
|
||||||
if (!splitEventTypes(activity.getEventTypes()).contains(event.getEventType())) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
recordConsumption(activity, event.getAssetRecordId(), event.getUserId(), event.getSysOrigin(),
|
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() != null
|
||||||
&& event.getAmount().getDollarAmount().signum() != 0
|
&& event.getAmount().getDollarAmount().signum() != 0
|
||||||
&& event.getSysOrigin() != null
|
&& event.getSysOrigin() != null
|
||||||
&& event.getEventType() != null
|
&& isThirdPartyGameExpenditure(event.getBizType(), event.getReceiptType(),
|
||||||
// wallet_sync_gold_v2 当前由金币网关发布;bizType=1 再做一次防御,避免未来复用 topic 时误计。
|
event.getEventType());
|
||||||
&& Objects.equals(event.getBizType(), 1)
|
}
|
||||||
&& (ReceiptType.EXPENDITURE.eq(event.getReceiptType())
|
|
||||||
|| ReceiptType.INCOME.eq(event.getReceiptType()));
|
/**
|
||||||
|
* 唯一权威的游戏消费分类器。三个条件必须同时成立:金币业务、支出单据、四个第三方
|
||||||
|
* 游戏目录之一。禁止 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,
|
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) {
|
BigDecimal amount, Timestamp eventTime) {
|
||||||
if (assetRecordId == null || userId == null || eventTime == null || amount == null
|
if (assetRecordId == null || userId == null || eventTime == null || amount == null
|
||||||
|| amount.signum() <= 0 || !Objects.equals(activity.getSysOrigin(), sysOrigin)
|
|| amount.signum() <= 0 || !Objects.equals(activity.getSysOrigin(), sysOrigin)
|
||||||
|| !splitEventTypes(activity.getEventTypes()).contains(eventType)
|
|| !isThirdPartyGameExpenditure(bizType, receiptType, eventType)
|
||||||
|| eventTime.before(activity.getStartTime()) || !eventTime.before(activity.getEndTime())
|
|| eventTime.before(activity.getStartTime()) || !eventTime.before(activity.getEndTime())) {
|
||||||
|| !(ReceiptType.EXPENDITURE.eq(receiptType) || ReceiptType.INCOME.eq(receiptType))) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
Date statDate = Date.valueOf(eventTime.toInstant()
|
Date statDate = Date.valueOf(eventTime.toInstant()
|
||||||
@ -290,17 +302,10 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
|
|||||||
}
|
}
|
||||||
mapper.ensureUser(activity.getId(), userId);
|
mapper.ensureUser(activity.getId(), userId);
|
||||||
mapper.ensureDailyUser(activity.getId(), statDate, userId);
|
mapper.ensureDailyUser(activity.getId(), statDate, userId);
|
||||||
if (ReceiptType.EXPENDITURE.eq(receiptType)) {
|
ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE,
|
||||||
ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE,
|
mapper.incrementUserConsumption(activity.getId(), userId, amount, eventTime) == 1
|
||||||
mapper.incrementUserConsumption(activity.getId(), userId, amount, eventTime) == 1
|
&& mapper.incrementDailyConsumption(activity.getId(), statDate, userId, amount,
|
||||||
&& mapper.incrementDailyConsumption(activity.getId(), statDate, userId, amount,
|
eventTime) == 1);
|
||||||
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 true;
|
||||||
});
|
});
|
||||||
return Boolean.TRUE.equals(inserted);
|
return Boolean.TRUE.equals(inserted);
|
||||||
@ -582,22 +587,13 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public BackfillResult backfill(Long activityId, String eventType, Integer receiptType,
|
public BackfillResult backfill(Long activityId, Long lastId, Integer limit) {
|
||||||
Long lastId, Integer limit) {
|
|
||||||
ActivityRow activity = requireActivity(activityId);
|
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);
|
int safeLimit = clamp(limit, 200, MAX_BACKFILL_LIMIT);
|
||||||
UserGoldRunningWaterBackQryCmd query = new UserGoldRunningWaterBackQryCmd()
|
UserGoldRunningWaterBackQryCmd query = new UserGoldRunningWaterBackQryCmd()
|
||||||
.setSysOrigin(activity.getSysOrigin())
|
.setSysOrigin(activity.getSysOrigin())
|
||||||
.setType(requestedReceiptType.getType())
|
.setType(ReceiptType.EXPENDITURE.getType())
|
||||||
.setOrigin(eventType)
|
.setQueryThirdPartyGameExpenditure(true)
|
||||||
.setStartTime(activity.getStartTime().getTime())
|
.setStartTime(activity.getStartTime().getTime())
|
||||||
// 钱包历史接口使用 <= endTime;减 1ms 与实时监听的 [start,end) 边界保持一致。
|
// 钱包历史接口使用 <= endTime;减 1ms 与实时监听的 [start,end) 边界保持一致。
|
||||||
.setEndTime(Math.subtractExact(activity.getEndTime().getTime(), 1L))
|
.setEndTime(Math.subtractExact(activity.getEndTime().getTime(), 1L))
|
||||||
@ -608,17 +604,24 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
|
|||||||
.orElse(Collections.emptyList());
|
.orElse(Collections.emptyList());
|
||||||
int accepted = 0;
|
int accepted = 0;
|
||||||
for (UserGoldRunningWaterHistoryDTO record : records) {
|
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());
|
Timestamp eventTime = new Timestamp(record.getCreateTime());
|
||||||
if (recordConsumption(activity, record.getId(), record.getUserId(), record.getSysOrigin(),
|
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)) {
|
record.getQuantity().abs(), eventTime)) {
|
||||||
accepted++;
|
accepted++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Long nextLastId = records.isEmpty() ? null : records.get(records.size() - 1).getId();
|
Long nextLastId = records.isEmpty() ? null : records.get(records.size() - 1).getId();
|
||||||
return new BackfillResult()
|
return new BackfillResult()
|
||||||
.setEventType(eventType)
|
.setEventType(FIXED_EVENT_SCOPE)
|
||||||
.setReceiptType(requestedReceiptType.getType())
|
.setReceiptType(ReceiptType.EXPENDITURE.getType())
|
||||||
.setScanned(records.size())
|
.setScanned(records.size())
|
||||||
.setAccepted(accepted)
|
.setAccepted(accepted)
|
||||||
.setNextLastId(nextLastId)
|
.setNextLastId(nextLastId)
|
||||||
@ -813,7 +816,6 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
|
|||||||
|| RANKING_VICTORIOUS.equals(cmd.getRankingType()))
|
|| RANKING_VICTORIOUS.equals(cmd.getRankingType()))
|
||||||
&& (PERIOD_OVERALL.equals(Optional.ofNullable(cmd.getRankingPeriod())
|
&& (PERIOD_OVERALL.equals(Optional.ofNullable(cmd.getRankingPeriod())
|
||||||
.orElse(PERIOD_OVERALL)) || PERIOD_DAILY.equals(cmd.getRankingPeriod())));
|
.orElse(PERIOD_OVERALL)) || PERIOD_DAILY.equals(cmd.getRankingPeriod())));
|
||||||
normalizeEventTypes(cmd.getEventTypes());
|
|
||||||
if (cmd.getPrizes() != null) {
|
if (cmd.getPrizes() != null) {
|
||||||
validatePrizes(cmd.getPrizes(), true, cmd.getSysOrigin());
|
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()))) {
|
if (existing == null || existing.getStartTime().after(Timestamp.from(Instant.now()))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 活动一旦开始,资格分母、统计起点、系统与精确事件白名单均不可改变,否则历史流水无法重算。
|
// 活动一旦开始,资格分母、统计起点和系统不可改变,否则已累计的消费流水无法重算。
|
||||||
|
// 事件口径由服务端分类器固定,不再比较旧库 CSV;保存时会把兼容列收敛为统一哨兵。
|
||||||
boolean lockedFieldsSame = Objects.equals(existing.getStartTime().getTime(), cmd.getStartTime())
|
boolean lockedFieldsSame = Objects.equals(existing.getStartTime().getTime(), cmd.getStartTime())
|
||||||
&& Objects.equals(existing.getEndTime().getTime(), cmd.getEndTime())
|
&& Objects.equals(existing.getEndTime().getTime(), cmd.getEndTime())
|
||||||
&& Objects.equals(existing.getCoinPerDraw(), cmd.getCoinPerDraw())
|
&& 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.getTimeZone(), normalizeTimeZone(cmd.getTimeZone()))
|
||||||
&& Objects.equals(existing.getSettlementDelayMinutes(),
|
&& Objects.equals(existing.getSettlementDelayMinutes(),
|
||||||
normalizeSettlementDelay(cmd.getSettlementDelayMinutes()))
|
normalizeSettlementDelay(cmd.getSettlementDelayMinutes()))
|
||||||
&& Objects.equals(existing.getEnabled(), Boolean.TRUE.equals(cmd.getEnabled()))
|
&& Objects.equals(existing.getEnabled(), Boolean.TRUE.equals(cmd.getEnabled()));
|
||||||
&& Objects.equals(existing.getEventTypes(), String.join(",",
|
|
||||||
normalizeEventTypes(cmd.getEventTypes())));
|
|
||||||
ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, lockedFieldsSame);
|
ResponseAssert.isTrue(CommonErrorCode.STATE_ERROR, lockedFieldsSame);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1036,7 +1037,7 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
|
|||||||
.setSettlementDelayMinutes(row.getSettlementDelayMinutes())
|
.setSettlementDelayMinutes(row.getSettlementDelayMinutes())
|
||||||
.setSettlementTime(row.getSettlementTime().getTime())
|
.setSettlementTime(row.getSettlementTime().getTime())
|
||||||
.setCoinPerDraw(row.getCoinPerDraw())
|
.setCoinPerDraw(row.getCoinPerDraw())
|
||||||
.setEventTypes(splitEventTypes(row.getEventTypes()))
|
.setEventTypes(List.of(FIXED_EVENT_SCOPE))
|
||||||
.setRankingType(row.getRankingType())
|
.setRankingType(row.getRankingType())
|
||||||
.setRankingPeriod(row.getRankingPeriod())
|
.setRankingPeriod(row.getRankingPeriod())
|
||||||
.setEnabled(row.getEnabled())
|
.setEnabled(row.getEnabled())
|
||||||
@ -1059,7 +1060,7 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
|
|||||||
.setSettlementDelayMinutes(settlementDelayMinutes)
|
.setSettlementDelayMinutes(settlementDelayMinutes)
|
||||||
.setSettlementTime(buildSettlementTime(cmd.getEndTime(), settlementDelayMinutes))
|
.setSettlementTime(buildSettlementTime(cmd.getEndTime(), settlementDelayMinutes))
|
||||||
.setCoinPerDraw(cmd.getCoinPerDraw())
|
.setCoinPerDraw(cmd.getCoinPerDraw())
|
||||||
.setEventTypes(String.join(",", normalizeEventTypes(cmd.getEventTypes())))
|
.setEventTypes(FIXED_EVENT_SCOPE)
|
||||||
.setRankingType(Optional.ofNullable(cmd.getRankingType()).orElse(RANKING_TYCOON))
|
.setRankingType(Optional.ofNullable(cmd.getRankingType()).orElse(RANKING_TYCOON))
|
||||||
.setRankingPeriod(Optional.ofNullable(cmd.getRankingPeriod()).orElse(PERIOD_OVERALL))
|
.setRankingPeriod(Optional.ofNullable(cmd.getRankingPeriod()).orElse(PERIOD_OVERALL))
|
||||||
.setEnabled(Boolean.TRUE.equals(cmd.getEnabled()))
|
.setEnabled(Boolean.TRUE.equals(cmd.getEnabled()))
|
||||||
@ -1218,31 +1219,6 @@ public class AslanGameKingServiceImpl implements AslanGameKingService {
|
|||||||
.orElseThrow(() -> new IllegalStateException("Missing rank reward for rank " + rank));
|
.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) {
|
private String normalizeTimeZone(String timeZone) {
|
||||||
return StringUtils.isBlank(timeZone) ? DEFAULT_TIME_ZONE : timeZone.trim();
|
return StringUtils.isBlank(timeZone) ? DEFAULT_TIME_ZONE : timeZone.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,6 +63,5 @@ public interface AslanGameKingService {
|
|||||||
|
|
||||||
void resolveUnknownDeliveryItem(Long itemId, boolean delivered);
|
void resolveUnknownDeliveryItem(Long itemId, boolean delivered);
|
||||||
|
|
||||||
BackfillResult backfill(Long activityId, String eventType, Integer receiptType, Long lastId,
|
BackfillResult backfill(Long activityId, Long lastId, Integer limit);
|
||||||
Integer limit);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,8 @@ import lombok.experimental.Accessors;
|
|||||||
/**
|
/**
|
||||||
* Aslan 游戏王活动数据库行模型。
|
* Aslan 游戏王活动数据库行模型。
|
||||||
*
|
*
|
||||||
* <p>这些类型只在持久层使用,尤其把事件白名单保留为规范化 CSV;对外契约仍使用
|
* <p>这些类型只在持久层使用。eventTypes 列仅保存固定的 GAME_EXPENDITURE 兼容哨兵;
|
||||||
* List,防止存储格式泄漏到 H5 与 webconsole。</p>
|
* 真实事件资格由应用层按钱包业务类型、收支类型和第三方游戏前缀统一判定,不能由运营配置。</p>
|
||||||
*/
|
*/
|
||||||
public final class AslanGameKingRows {
|
public final class AslanGameKingRows {
|
||||||
|
|
||||||
|
|||||||
@ -119,9 +119,7 @@ public class AslanGameKingManageClientEndpoint implements AslanGameKingManageCli
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public ResultResponse<BackfillResult> backfill(Long id, String eventType, Integer receiptType,
|
public ResultResponse<BackfillResult> backfill(Long id, Long lastId, Integer limit) {
|
||||||
Long lastId, Integer limit) {
|
return ResultResponse.success(aslanGameKingService.backfill(id, lastId, limit));
|
||||||
return ResultResponse.success(
|
|
||||||
aslanGameKingService.backfill(id, eventType, receiptType, lastId, limit));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.red.circle.wallet.inner.service.wallet.impl;
|
|||||||
|
|
||||||
import com.google.common.collect.Lists;
|
import com.google.common.collect.Lists;
|
||||||
import com.red.circle.common.business.core.enums.OpUserType;
|
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.common.business.core.enums.SysOriginPlatformEnum;
|
||||||
import com.red.circle.component.tencent.cls.request.ClsLogSearchQry;
|
import com.red.circle.component.tencent.cls.request.ClsLogSearchQry;
|
||||||
import com.red.circle.component.tencent.cls.response.ClsLogSearchResult;
|
import com.red.circle.component.tencent.cls.response.ClsLogSearchResult;
|
||||||
@ -61,6 +62,14 @@ import org.springframework.stereotype.Service;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class WalletGoldClientServiceImpl implements WalletGoldClientService {
|
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 ClsService clsService;
|
||||||
private final EnvProperties envProperties;
|
private final EnvProperties envProperties;
|
||||||
private final WalletGoldGateway walletGoldGateway;
|
private final WalletGoldGateway walletGoldGateway;
|
||||||
@ -149,6 +158,11 @@ public class WalletGoldClientServiceImpl implements WalletGoldClientService {
|
|||||||
@Override
|
@Override
|
||||||
public List<UserGoldRunningWaterHistoryDTO> listWater(UserGoldRunningWaterBackQryCmd cmd) {
|
public List<UserGoldRunningWaterHistoryDTO> listWater(UserGoldRunningWaterBackQryCmd cmd) {
|
||||||
List<String> querySysOrigin = listWaterQuerySysOrigin(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();
|
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.getId()), WalletGoldAssetRecord::getId, cmd.getId())
|
||||||
.eq(Objects.nonNull(cmd.getQueryBackOperationUser()), WalletGoldAssetRecord::getOpUserType,
|
.eq(Objects.nonNull(cmd.getQueryBackOperationUser()), WalletGoldAssetRecord::getOpUserType,
|
||||||
cmd.getQueryBackOperationUser())
|
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(Objects.nonNull(cmd.getUserId()), WalletGoldAssetRecord::getUserId, cmd.getUserId())
|
||||||
.eq(StringUtils.isNotBlank(cmd.getTrackId()), WalletGoldAssetRecord::getEventId,
|
.eq(StringUtils.isNotBlank(cmd.getTrackId()), WalletGoldAssetRecord::getEventId,
|
||||||
cmd.getTrackId())
|
cmd.getTrackId())
|
||||||
.eq(StringUtils.isNotBlank(cmd.getOrigin()), WalletGoldAssetRecord::getEventType,
|
.eq(!queryThirdPartyGameExpenditure && StringUtils.isNotBlank(cmd.getOrigin()),
|
||||||
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())
|
.lt(Objects.nonNull(cmd.getLastId()), WalletGoldAssetRecord::getId, cmd.getLastId())
|
||||||
.orderByDesc(WalletGoldAssetRecord::getId)
|
.orderByDesc(WalletGoldAssetRecord::getId)
|
||||||
.last(Objects.nonNull(cmd.getLimit()) && cmd.getLimit() > 0 ? PageConstant.formatLimit(
|
.last(Objects.nonNull(cmd.getLimit()) && cmd.getLimit() > 0 ? PageConstant.formatLimit(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user