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):
|
||||
-- 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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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=收入(VICTORIOUS),1=支出(TYCOON 与抽奖次数)。 */
|
||||
/** 固定为 1(EXPENDITURE);历史补数不允许调用方切换收入/支出口径。 */
|
||||
private Integer receiptType;
|
||||
private Integer scanned;
|
||||
private Integer accepted;
|
||||
|
||||
@ -59,6 +59,12 @@ public class UserGoldRunningWaterBackQryCmd extends Command {
|
||||
*/
|
||||
private String origin;
|
||||
|
||||
/**
|
||||
* 仅供服务端活动补数:强制查询金币支出,并把来源限制为四类第三方游戏前缀。
|
||||
* 调用方不能通过 type/origin 放宽此口径,避免补数误扫礼物、转账等非游戏流水。
|
||||
*/
|
||||
private Boolean queryThirdPartyGameExpenditure;
|
||||
|
||||
/**
|
||||
* 创建时间.
|
||||
*/
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,服务端限制到 1~100。 |
|
||||
|
||||
@ -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` | 0~1440,默认 30;`settlementTime = endTime + settlementDelayMinutes`。 |
|
||||
| `coinPerDraw` | 必填,正整数金币数。 |
|
||||
| `eventTypes` | 必填,1~30 个非空字符串;按钱包 `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` 为不重复的 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`。
|
||||
- 独立 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` 配置 0~1440 分钟。定时任务每 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)` 索引。迁移不回填钱包流水,也不扫描更新既有业务大表。
|
||||
- 当前交付只新增了迁移文件,**尚未在测试或生产数据库执行**。执行前仍需按环境发布流程复核目标库、变更窗口和执行计划。
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
}
|
||||
ResponseAssert.isTrue(CommonErrorCode.UPDATE_FAILURE,
|
||||
mapper.incrementUserConsumption(activity.getId(), userId, amount, eventTime) == 1
|
||||
&& mapper.incrementDailyConsumption(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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -8,8 +8,8 @@ import lombok.experimental.Accessors;
|
||||
/**
|
||||
* Aslan 游戏王活动数据库行模型。
|
||||
*
|
||||
* <p>这些类型只在持久层使用,尤其把事件白名单保留为规范化 CSV;对外契约仍使用
|
||||
* List,防止存储格式泄漏到 H5 与 webconsole。</p>
|
||||
* <p>这些类型只在持久层使用。eventTypes 列仅保存固定的 GAME_EXPENDITURE 兼容哨兵;
|
||||
* 真实事件资格由应用层按钱包业务类型、收支类型和第三方游戏前缀统一判定,不能由运营配置。</p>
|
||||
*/
|
||||
public final class AslanGameKingRows {
|
||||
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user