Compare commits

...

6 Commits

28 changed files with 2262 additions and 65 deletions

View File

@ -37,4 +37,7 @@ public interface UserHistoryIdentityClientApi {
@GetMapping("/saveManager")
ResultResponse<Void> saveManager(@RequestParam("userId") Long userId);
@GetMapping("/saveYumiManager")
ResultResponse<Void> saveYumiManager(@RequestParam("userId") Long userId);
}

View File

@ -40,8 +40,13 @@ public class UserHistoryIdentityDTO implements Serializable {
private Boolean host;
/**
* 是否做过团队经理.
* 是否做过团队经理.
*/
private Boolean manager;
/**
* 是否做过 Yumi 团队经理.
*/
private Boolean yumiManager;
}

View File

@ -1,10 +1,13 @@
package com.red.circle.console.adapter.app.user;
import com.red.circle.console.app.dto.clientobject.datav.CountryDashboardCO;
import com.red.circle.console.app.dto.clientobject.datav.CountryDashboardGameMetricCO;
import com.red.circle.console.app.dto.clientobject.datav.CountryDashboardRechargeDetailCO;
import com.red.circle.console.app.dto.cmd.datav.CountryDashboardGameMetricQryCmd;
import com.red.circle.console.app.dto.cmd.datav.CountryDashboardQryCmd;
import com.red.circle.console.app.dto.cmd.datav.CountryDashboardRechargeDetailQryCmd;
import com.red.circle.console.app.service.app.datav.CountryDashboardDailyMetricService;
import com.red.circle.console.app.service.app.datav.CountryDashboardGameMetricService;
import com.red.circle.console.app.service.app.datav.CountryDashboardService;
import com.red.circle.console.app.service.app.user.DatavService;
import com.red.circle.framework.dto.PageResult;
@ -36,6 +39,7 @@ public class DatavRestController extends BaseController {
private final DatavService userExpandService;
private final CountryDashboardService countryDashboardService;
private final CountryDashboardDailyMetricService countryDashboardDailyMetricService;
private final CountryDashboardGameMetricService countryDashboardGameMetricService;
@GetMapping("/active/user-country-code")
public List<ActiveUserCountryCodeDTO> latestActiveUserCountryCode() {
@ -63,6 +67,24 @@ public class DatavRestController extends BaseController {
return countryDashboardService.pageRechargeDetails(cmd);
}
@GetMapping("/country-dashboard/game-metrics")
public PageResult<CountryDashboardGameMetricCO> countryDashboardGameMetrics(
CountryDashboardGameMetricQryCmd cmd) {
return countryDashboardGameMetricService.pageGameMetrics(cmd);
}
@GetMapping("/country-dashboard/game-metrics/games")
public PageResult<CountryDashboardGameMetricCO> countryDashboardGameMetricGames(
CountryDashboardGameMetricQryCmd cmd) {
return countryDashboardGameMetricService.pageGameSummaries(cmd);
}
@GetMapping("/country-dashboard/game-metrics/country-rank")
public List<CountryDashboardGameMetricCO> countryDashboardGameMetricCountryRanks(
CountryDashboardGameMetricQryCmd cmd) {
return countryDashboardGameMetricService.listGameCountryRanks(cmd);
}
@PostMapping("/country-dashboard/backfill")
public Map<String, Object> backfillCountryDashboard(
@RequestParam String startDate,
@ -81,4 +103,22 @@ public class DatavRestController extends BaseController {
"statTimezones", refreshedTimezones);
}
@PostMapping("/country-dashboard/game-metrics/backfill")
public Map<String, Object> backfillCountryDashboardGameMetrics(
@RequestParam String startDate,
@RequestParam String endDate,
@RequestParam(value = "sysOrigin", defaultValue = "LIKEI") String sysOrigin,
@RequestParam(value = "statTimezones", required = false) String statTimezones) {
LocalDate parsedStartDate = LocalDate.parse(startDate);
LocalDate parsedEndDate = LocalDate.parse(endDate);
List<String> refreshedTimezones = countryDashboardGameMetricService.refreshRange(
parsedStartDate, parsedEndDate, sysOrigin, statTimezones);
return Map.of(
"status", "success",
"startDate", parsedStartDate.toString(),
"endDate", parsedEndDate.toString(),
"sysOrigin", sysOrigin,
"statTimezones", refreshedTimezones);
}
}

View File

@ -2,6 +2,7 @@ package com.red.circle.console.app.scheduler;
import com.red.circle.component.redis.annotation.TaskCacheLock;
import com.red.circle.console.app.service.app.datav.CountryDashboardDailyMetricService;
import com.red.circle.console.app.service.app.datav.CountryDashboardGameMetricService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@ -17,6 +18,7 @@ import org.springframework.stereotype.Component;
public class CountryDashboardDailyMetricTask {
private final CountryDashboardDailyMetricService countryDashboardDailyMetricService;
private final CountryDashboardGameMetricService countryDashboardGameMetricService;
@Value("${red-circle.country-dashboard.refresh-today-days:1}")
private int refreshTodayDays;
@ -30,6 +32,7 @@ public class CountryDashboardDailyMetricTask {
public void refreshToday() {
long start = System.currentTimeMillis();
countryDashboardDailyMetricService.refreshRecentDays(refreshTodayDays);
countryDashboardGameMetricService.refreshRecentDays(refreshTodayDays);
log.info("country dashboard refresh today task finished, days={}, costMs={}",
refreshTodayDays, System.currentTimeMillis() - start);
}
@ -40,6 +43,7 @@ public class CountryDashboardDailyMetricTask {
public void refreshRecent() {
long start = System.currentTimeMillis();
countryDashboardDailyMetricService.refreshRecentDays(refreshRecentDays);
countryDashboardGameMetricService.refreshRecentDays(refreshRecentDays);
log.info("country dashboard refresh recent task finished, days={}, costMs={}",
refreshRecentDays, System.currentTimeMillis() - start);
}

View File

@ -0,0 +1,745 @@
package com.red.circle.console.app.service.app.datav;
import com.red.circle.common.business.core.enums.SysOriginPlatformEnum;
import com.red.circle.console.app.dto.clientobject.datav.CountryDashboardGameMetricCO;
import com.red.circle.console.app.dto.cmd.datav.CountryDashboardGameMetricQryCmd;
import com.red.circle.console.infra.database.rds.dao.datav.CountryDashboardDAO;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardGameMetricDTO;
import com.red.circle.framework.dto.PageResult;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.time.temporal.WeekFields;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
/**
* 国家数据大屏游戏维度预聚合.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CountryDashboardGameMetricServiceImpl implements CountryDashboardGameMetricService {
private static final String PERIOD_DAY = "DAY";
private static final String PERIOD_WEEK = "WEEK";
private static final String PERIOD_MONTH = "MONTH";
private static final String PERIOD_ALL = "ALL";
private static final ZoneId STORAGE_ZONE_ID = ZoneId.of("Asia/Riyadh");
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private static final DateTimeFormatter MONTH_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM");
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final WeekFields WEEK_FIELDS = WeekFields.ISO;
private final CountryDashboardDAO countryDashboardDAO;
private final TransactionTemplate transactionTemplate;
@Value("${red-circle.country-dashboard.refresh-sys-origins:LIKEI}")
private String refreshSysOrigins;
@Value("${red-circle.country-dashboard.refresh-timezones:Asia/Riyadh,UTC,Asia/Shanghai}")
private String refreshTimezones;
private volatile boolean metricSchemaEnsured;
@Override
public PageResult<CountryDashboardGameMetricCO> pageGameMetrics(
CountryDashboardGameMetricQryCmd cmd) {
ensureMetricSchema();
QueryCondition condition = QueryCondition.of(cmd);
int cursor = Math.max(1, cmd == null || cmd.getCursor() == null ? 1 : cmd.getCursor());
int limit = Math.max(1, cmd == null || cmd.getLimit() == null ? 50 : cmd.getLimit());
limit = Math.min(200, limit);
int offset = (cursor - 1) * limit;
PageResult<CountryDashboardGameMetricCO> result = new PageResult<>();
result.setCurrent(cursor);
result.setSize(limit);
if (!hasText(condition.sysOrigin)) {
result.setTotal(0L);
result.setRecords(List.of());
return result;
}
SortSpec sortSpec = normalizeSort(cmd == null ? null : cmd.getSortField(),
cmd == null ? null : cmd.getSortOrder());
String countryCodes = normalizeCsv(cmd == null ? null : cmd.getCountryCodes());
String gameProvider = normalizeGameProvider(cmd == null ? null : cmd.getGameProvider());
String gameKeyword = trimToNull(cmd == null ? null : cmd.getGameKeyword());
Long total = countryDashboardDAO.countGamePeriodMetrics(
condition.periodType, condition.startTime, condition.endTime,
PERIOD_ALL.equals(condition.periodType) ? PERIOD_ALL : null,
condition.startPeriodKey, condition.endPeriodKey, condition.statTimezone,
condition.countryKeyword, countryCodes, gameProvider, gameKeyword, condition.sysOrigin);
List<CountryDashboardGameMetricCO> records = countryDashboardDAO.listGamePeriodMetrics(
condition.periodType, condition.startTime, condition.endTime,
PERIOD_ALL.equals(condition.periodType) ? PERIOD_ALL : null,
condition.startPeriodKey, condition.endPeriodKey, condition.statTimezone,
condition.countryKeyword, countryCodes, gameProvider, gameKeyword, condition.sysOrigin,
sortSpec.column(), sortSpec.direction(), offset, limit)
.stream()
.map(this::toCO)
.toList();
result.setTotal(total == null ? 0L : total);
result.setRecords(records);
return result;
}
@Override
public PageResult<CountryDashboardGameMetricCO> pageGameSummaries(
CountryDashboardGameMetricQryCmd cmd) {
ensureMetricSchema();
QueryCondition condition = QueryCondition.of(cmd);
int cursor = Math.max(1, cmd == null || cmd.getCursor() == null ? 1 : cmd.getCursor());
int limit = Math.max(1, cmd == null || cmd.getLimit() == null ? 50 : cmd.getLimit());
limit = Math.min(200, limit);
int offset = (cursor - 1) * limit;
PageResult<CountryDashboardGameMetricCO> result = new PageResult<>();
result.setCurrent(cursor);
result.setSize(limit);
if (!hasText(condition.sysOrigin)) {
result.setTotal(0L);
result.setRecords(List.of());
return result;
}
SortSpec sortSpec = normalizeSummarySort(cmd == null ? null : cmd.getSortField(),
cmd == null ? null : cmd.getSortOrder());
String countryCodes = normalizeCsv(cmd == null ? null : cmd.getCountryCodes());
String gameProvider = normalizeGameProvider(cmd == null ? null : cmd.getGameProvider());
String gameKeyword = trimToNull(cmd == null ? null : cmd.getGameKeyword());
Long total = countryDashboardDAO.countGameSummaryMetrics(
condition.periodType, condition.startTime, condition.endTime,
PERIOD_ALL.equals(condition.periodType) ? PERIOD_ALL : null,
condition.startPeriodKey, condition.endPeriodKey, condition.statTimezone,
condition.countryKeyword, countryCodes, gameProvider, gameKeyword, condition.sysOrigin);
List<CountryDashboardGameMetricCO> records = countryDashboardDAO.listGameSummaryMetrics(
condition.periodType, condition.startTime, condition.endTime,
PERIOD_ALL.equals(condition.periodType) ? PERIOD_ALL : null,
condition.startPeriodKey, condition.endPeriodKey, condition.statTimezone,
condition.countryKeyword, countryCodes, gameProvider, gameKeyword, condition.sysOrigin,
sortSpec.column(), sortSpec.direction(), offset, limit)
.stream()
.map(this::toCO)
.toList();
result.setTotal(total == null ? 0L : total);
result.setRecords(records);
return result;
}
@Override
public List<CountryDashboardGameMetricCO> listGameCountryRanks(
CountryDashboardGameMetricQryCmd cmd) {
ensureMetricSchema();
QueryCondition condition = QueryCondition.of(cmd);
if (!hasText(condition.sysOrigin)) {
return List.of();
}
String gameProvider = normalizeGameProvider(cmd == null ? null : cmd.getGameProvider());
String gameId = trimToNull(cmd == null ? null : cmd.getGameId());
if (gameProvider == null || gameId == null) {
return List.of();
}
int limit = Math.max(1, cmd.getRankLimit() == null ? 100 : cmd.getRankLimit());
limit = Math.min(500, limit);
String countryCodes = normalizeCsv(cmd.getCountryCodes());
return countryDashboardDAO.listGameCountryRankMetrics(
condition.periodType, condition.startTime, condition.endTime,
PERIOD_ALL.equals(condition.periodType) ? PERIOD_ALL : null,
condition.startPeriodKey, condition.endPeriodKey, condition.statTimezone,
condition.countryKeyword, countryCodes, gameProvider, null, gameId, condition.sysOrigin,
limit)
.stream()
.map(this::toCO)
.toList();
}
@Override
public void refreshRecentDays(int days) {
ensureMetricSchema();
int safeDays = Math.max(days, 1);
List<String> sysOrigins = resolveSysOrigins();
List<StatTimezone> statTimezones = resolveStatTimezones(null);
for (String sysOrigin : sysOrigins) {
for (StatTimezone statTimezone : statTimezones) {
List<LocalDate> statDates = recentDates(safeDays, statTimezone.zoneId());
for (LocalDate statDate : statDates) {
try {
refreshPeriod(toDayWindow(statDate), sysOrigin, statTimezone);
} catch (Exception ex) {
log.error("refresh country dashboard game day metric failed, statDate={}, sysOrigin={}, statTimezone={}",
statDate, sysOrigin, statTimezone.id(), ex);
}
}
refreshAffectedPeriods(statDates, sysOrigin, statTimezone, false);
try {
refreshAll(sysOrigin, statTimezone);
} catch (Exception ex) {
log.error("refresh country dashboard game all metric failed, sysOrigin={}, statTimezone={}",
sysOrigin, statTimezone.id(), ex);
}
}
}
}
@Override
public List<String> refreshRange(LocalDate startDate, LocalDate endDate, String sysOrigin,
String statTimezonesValue) {
ensureMetricSchema();
if (startDate == null || endDate == null) {
throw new IllegalArgumentException("startDate and endDate are required");
}
if (endDate.isBefore(startDate)) {
throw new IllegalArgumentException("endDate must be greater than or equal to startDate");
}
long dayCount = ChronoUnit.DAYS.between(startDate, endDate) + 1;
if (dayCount > 366) {
throw new IllegalArgumentException("backfill range can not exceed 366 days");
}
String safeSysOrigin = trimToNull(sysOrigin);
if (safeSysOrigin == null) {
safeSysOrigin = SysOriginPlatformEnum.LIKEI.name();
}
List<LocalDate> statDates = new ArrayList<>();
for (long i = 0; i < dayCount; i++) {
statDates.add(startDate.plusDays(i));
}
List<StatTimezone> statTimezones = resolveStatTimezones(statTimezonesValue);
long start = System.currentTimeMillis();
log.info("refresh country dashboard game range start, startDate={}, endDate={}, days={}, sysOrigin={}, statTimezones={}",
startDate, endDate, dayCount, safeSysOrigin,
statTimezones.stream().map(StatTimezone::id).toList());
for (StatTimezone statTimezone : statTimezones) {
for (LocalDate statDate : statDates) {
refreshPeriod(toDayWindow(statDate), safeSysOrigin, statTimezone);
}
refreshAffectedPeriods(statDates, safeSysOrigin, statTimezone, true);
refreshAll(safeSysOrigin, statTimezone);
}
log.info("refresh country dashboard game range success, startDate={}, endDate={}, days={}, sysOrigin={}, statTimezones={}, costMs={}",
startDate, endDate, dayCount, safeSysOrigin,
statTimezones.stream().map(StatTimezone::id).toList(), System.currentTimeMillis() - start);
return statTimezones.stream().map(StatTimezone::id).toList();
}
private void ensureMetricSchema() {
if (metricSchemaEnsured) {
return;
}
synchronized (this) {
if (metricSchemaEnsured) {
return;
}
countryDashboardDAO.createGamePeriodMetricTable();
countryDashboardDAO.createGamePeriodUserMetricTable();
metricSchemaEnsured = true;
log.info("country dashboard game metric tables ensured");
}
}
private void refreshAffectedPeriods(List<LocalDate> statDates, String sysOrigin,
StatTimezone statTimezone, boolean failFast) {
Set<PeriodWindow> windows = new LinkedHashSet<>();
for (LocalDate statDate : statDates) {
windows.add(toWeekWindow(statDate, statTimezone.zoneId()));
windows.add(toMonthWindow(statDate, statTimezone.zoneId()));
}
for (PeriodWindow window : windows) {
try {
refreshPeriod(window, sysOrigin, statTimezone);
} catch (Exception ex) {
if (failFast) {
throw ex;
}
log.error("refresh country dashboard game period metric failed, periodType={}, periodKey={}, sysOrigin={}, statTimezone={}",
window.periodType(), window.periodKey(), sysOrigin, statTimezone.id(), ex);
}
}
}
private void refreshPeriod(PeriodWindow window, String sysOrigin, StatTimezone statTimezone) {
long start = System.currentTimeMillis();
TimeWindow timeWindow = toStorageTimeWindow(window, statTimezone);
Map<String, CountryDashboardGameMetricDTO> rows = new LinkedHashMap<>();
mergeAmounts(rows, countryDashboardDAO.listWalletGameMetricAmounts(
window.periodType(), timeWindow.startTime(), timeWindow.endTime(),
timeWindow.startTimeMillis(), timeWindow.endTimeMillis(), null, sysOrigin,
zoneOffset(STORAGE_ZONE_ID), statTimezone.offset()), window, sysOrigin, statTimezone);
mergeAmounts(rows, countryDashboardDAO.listLuckyBoxGameMetricAmounts(
window.periodType(), timeWindow.startTime(), timeWindow.endTime(), null, sysOrigin,
zoneOffset(STORAGE_ZONE_ID), statTimezone.offset()), window, sysOrigin, statTimezone);
RefreshWriteResult writeResult = transactionTemplate.execute(status -> {
countryDashboardDAO.deleteGamePeriodUserMetrics(window.periodType(), window.periodKey(),
statTimezone.id(), sysOrigin);
int walletUsers = countryDashboardDAO.insertWalletGamePeriodUserMetrics(
window.periodType(), window.periodKey(), window.periodName(),
window.periodStartDate(), window.periodEndDate(), statTimezone.id(),
timeWindow.startTime(), timeWindow.endTime(), timeWindow.startTimeMillis(),
timeWindow.endTimeMillis(), sysOrigin);
int luckyBoxUsers = countryDashboardDAO.insertLuckyBoxGamePeriodUserMetrics(
window.periodType(), window.periodKey(), window.periodName(),
window.periodStartDate(), window.periodEndDate(), statTimezone.id(),
timeWindow.startTime(), timeWindow.endTime(), sysOrigin);
mergeUserCounts(rows, countryDashboardDAO.listGamePeriodUserMetricCounts(
window.periodType(), window.periodKey(), statTimezone.id(), sysOrigin),
window, sysOrigin, statTimezone);
List<CountryDashboardGameMetricDTO> metrics = new ArrayList<>(rows.values());
int deleted = countryDashboardDAO.deleteGamePeriodMetrics(window.periodType(),
window.periodKey(), statTimezone.id(), sysOrigin);
int inserted = metrics.isEmpty() ? 0 : countryDashboardDAO.upsertGamePeriodMetrics(metrics);
return new RefreshWriteResult(deleted, inserted, walletUsers + luckyBoxUsers);
});
log.info("refresh country dashboard game period metric success, periodType={}, periodKey={}, sysOrigin={}, statTimezone={}, rows={}, deleted={}, inserted={}, userRows={}, costMs={}",
window.periodType(), window.periodKey(), sysOrigin, statTimezone.id(), rows.size(),
writeResult.deleted(), writeResult.inserted(), writeResult.userRows(),
System.currentTimeMillis() - start);
}
private void refreshAll(String sysOrigin, StatTimezone statTimezone) {
long start = System.currentTimeMillis();
PeriodWindow window = new PeriodWindow(PERIOD_ALL, PERIOD_ALL, "全部", null, null);
Map<String, CountryDashboardGameMetricDTO> rows = new LinkedHashMap<>();
mergeAmounts(rows, countryDashboardDAO.listAllGamePeriodAmountsFromPeriodDays(
statTimezone.id(), sysOrigin), window, sysOrigin, statTimezone);
RefreshWriteResult writeResult = transactionTemplate.execute(status -> {
countryDashboardDAO.deleteGamePeriodUserMetrics(PERIOD_ALL, PERIOD_ALL,
statTimezone.id(), sysOrigin);
int userRows = countryDashboardDAO.insertAllGamePeriodUserMetricsFromDaily(
statTimezone.id(), sysOrigin);
mergeUserCounts(rows, countryDashboardDAO.listGamePeriodUserMetricCounts(
PERIOD_ALL, PERIOD_ALL, statTimezone.id(), sysOrigin), window, sysOrigin, statTimezone);
List<CountryDashboardGameMetricDTO> metrics = new ArrayList<>(rows.values());
int deleted = countryDashboardDAO.deleteGamePeriodMetrics(PERIOD_ALL, PERIOD_ALL,
statTimezone.id(), sysOrigin);
int inserted = metrics.isEmpty() ? 0 : countryDashboardDAO.upsertGamePeriodMetrics(metrics);
return new RefreshWriteResult(deleted, inserted, userRows);
});
log.info("refresh country dashboard game all metric success, sysOrigin={}, statTimezone={}, rows={}, deleted={}, inserted={}, userRows={}, costMs={}",
sysOrigin, statTimezone.id(), rows.size(), writeResult.deleted(), writeResult.inserted(),
writeResult.userRows(), System.currentTimeMillis() - start);
}
private void mergeAmounts(Map<String, CountryDashboardGameMetricDTO> rows,
List<CountryDashboardGameMetricDTO> metrics, PeriodWindow window, String sysOrigin,
StatTimezone statTimezone) {
if (metrics == null || metrics.isEmpty()) {
return;
}
for (CountryDashboardGameMetricDTO metric : metrics) {
CountryDashboardGameMetricDTO row = getRow(rows, metric, window, sysOrigin, statTimezone);
row.setConsumeAmount(add(row.getConsumeAmount(), metric.getConsumeAmount()));
row.setPayoutAmount(add(row.getPayoutAmount(), metric.getPayoutAmount()));
row.setProfitAmount(add(row.getProfitAmount(), metric.getProfitAmount()));
row.setOrderCount(safeLong(row.getOrderCount()) + safeLong(metric.getOrderCount()));
}
}
private void mergeUserCounts(Map<String, CountryDashboardGameMetricDTO> rows,
List<CountryDashboardGameMetricDTO> metrics, PeriodWindow window, String sysOrigin,
StatTimezone statTimezone) {
if (metrics == null || metrics.isEmpty()) {
return;
}
for (CountryDashboardGameMetricDTO metric : metrics) {
CountryDashboardGameMetricDTO row = getRow(rows, metric, window, sysOrigin, statTimezone);
row.setUserCount(safeLong(metric.getUserCount()));
}
}
private CountryDashboardGameMetricDTO getRow(
Map<String, CountryDashboardGameMetricDTO> rows, CountryDashboardGameMetricDTO metric,
PeriodWindow window, String sysOrigin, StatTimezone statTimezone) {
String countryCode = hasText(metric.getCountryCode()) ? metric.getCountryCode() : "UNKNOWN";
String countryName = hasText(metric.getCountryName()) ? metric.getCountryName() : countryCode;
String gameProvider = hasText(metric.getGameProvider()) ? metric.getGameProvider() : "UNKNOWN";
String gameId = hasText(metric.getGameId()) ? metric.getGameId() : "UNKNOWN";
String gameName = hasText(metric.getGameName()) ? metric.getGameName() : gameId;
return rows.computeIfAbsent(countryCode + "|" + gameProvider + "|" + gameId,
key -> new CountryDashboardGameMetricDTO()
.setPeriodType(window.periodType())
.setPeriodKey(window.periodKey())
.setStatTimezone(statTimezone.id())
.setPeriodName(window.periodName())
.setPeriodStartDate(window.periodStartDate())
.setPeriodEndDate(window.periodEndDate())
.setSysOrigin(sysOrigin)
.setCountryCode(countryCode)
.setCountryName(countryName)
.setGameProvider(gameProvider)
.setGameId(gameId)
.setGameName(gameName)
.setConsumeAmount(BigDecimal.ZERO)
.setPayoutAmount(BigDecimal.ZERO)
.setProfitAmount(BigDecimal.ZERO)
.setUserCount(0L)
.setOrderCount(0L));
}
private CountryDashboardGameMetricCO toCO(CountryDashboardGameMetricDTO dto) {
BigDecimal consume = safe(dto.getConsumeAmount());
BigDecimal payout = safe(dto.getPayoutAmount());
BigDecimal profit = safe(dto.getProfitAmount());
return new CountryDashboardGameMetricCO()
.setPeriodKey(dto.getPeriodKey())
.setPeriodName(dto.getPeriodName())
.setStatTimezone(dto.getStatTimezone())
.setCountryCode(dto.getCountryCode())
.setCountryName(dto.getCountryName())
.setGameProvider(dto.getGameProvider())
.setGameId(dto.getGameId())
.setGameName(dto.getGameName())
.setConsumeAmount(consume)
.setPayoutAmount(payout)
.setProfitAmount(profit)
.setPayoutRate(percent(payout, consume))
.setProfitRate(percent(profit, consume))
.setUserCount(safeLong(dto.getUserCount()))
.setOrderCount(safeLong(dto.getOrderCount()))
.setRefreshedAt(dto.getRefreshedAt() == null ? null : dto.getRefreshedAt().format(DATETIME_FORMATTER));
}
private PeriodWindow toDayWindow(LocalDate statDate) {
String key = statDate.format(DATE_FORMATTER);
return new PeriodWindow(PERIOD_DAY, key, key, statDate, statDate.plusDays(1));
}
private PeriodWindow toWeekWindow(LocalDate statDate, ZoneId statZoneId) {
LocalDate start = statDate.with(DayOfWeek.MONDAY);
LocalDate end = capCurrentPeriodEnd(start.plusDays(7), statZoneId);
int weekYear = statDate.get(WEEK_FIELDS.weekBasedYear());
int week = statDate.get(WEEK_FIELDS.weekOfWeekBasedYear());
String key = String.format(Locale.ROOT, "%d-W%02d", weekYear, week);
return new PeriodWindow(PERIOD_WEEK, key, key, start, end);
}
private PeriodWindow toMonthWindow(LocalDate statDate, ZoneId statZoneId) {
LocalDate start = statDate.withDayOfMonth(1);
LocalDate end = capCurrentPeriodEnd(start.plusMonths(1), statZoneId);
String key = statDate.format(MONTH_FORMATTER);
return new PeriodWindow(PERIOD_MONTH, key, key, start, end);
}
private LocalDate capCurrentPeriodEnd(LocalDate naturalEndExclusive, ZoneId statZoneId) {
LocalDate tomorrow = LocalDate.now(statZoneId).plusDays(1);
return naturalEndExclusive.isAfter(tomorrow) ? tomorrow : naturalEndExclusive;
}
private TimeWindow toStorageTimeWindow(PeriodWindow window, StatTimezone statTimezone) {
Instant startInstant = window.periodStartDate().atStartOfDay(statTimezone.zoneId()).toInstant();
Instant endInstant = window.periodEndDate().atStartOfDay(statTimezone.zoneId()).toInstant();
return new TimeWindow(
LocalDateTime.ofInstant(startInstant, STORAGE_ZONE_ID),
LocalDateTime.ofInstant(endInstant, STORAGE_ZONE_ID),
startInstant.toEpochMilli(),
endInstant.toEpochMilli());
}
private List<LocalDate> recentDates(int days, ZoneId zoneId) {
LocalDate today = LocalDate.now(zoneId);
List<LocalDate> statDates = new ArrayList<>();
for (int i = days - 1; i >= 0; i--) {
statDates.add(today.minusDays(i));
}
return statDates;
}
private List<StatTimezone> resolveStatTimezones(String statTimezonesValue) {
String config = trimToNull(statTimezonesValue);
if (config == null) {
config = trimToNull(refreshTimezones);
}
if (config == null) {
config = "Asia/Riyadh,UTC,Asia/Shanghai";
}
Map<String, StatTimezone> zones = new LinkedHashMap<>();
Arrays.stream(config.split(","))
.map(String::trim)
.map(CountryDashboardGameMetricServiceImpl::normalizeStatTimezone)
.filter(Objects::nonNull)
.forEach(zone -> zones.putIfAbsent(zone.id(), zone));
if (zones.isEmpty()) {
zones.put(STORAGE_ZONE_ID.getId(), new StatTimezone(STORAGE_ZONE_ID.getId(), STORAGE_ZONE_ID,
zoneOffset(STORAGE_ZONE_ID)));
}
return new ArrayList<>(zones.values());
}
private List<String> resolveSysOrigins() {
String config = trimToNull(refreshSysOrigins);
if (config == null) {
return List.of(SysOriginPlatformEnum.LIKEI.name());
}
if ("ALL".equalsIgnoreCase(config)) {
return SysOriginPlatformEnum.getVoiceSystems().stream()
.map(SysOriginPlatformEnum::name)
.filter(Objects::nonNull)
.distinct()
.toList();
}
List<String> sysOrigins = Arrays.stream(config.split(","))
.map(String::trim)
.filter(CountryDashboardGameMetricServiceImpl::hasText)
.distinct()
.toList();
return sysOrigins.isEmpty() ? List.of(SysOriginPlatformEnum.LIKEI.name()) : sysOrigins;
}
private static SortSpec normalizeSort(String sortField, String sortOrder) {
String column = switch (Objects.toString(sortField, "")) {
case "payoutAmount" -> "t.payout_amount";
case "profitAmount" -> "t.profit_amount";
case "userCount" -> "t.user_count";
case "orderCount" -> "t.order_count";
case "consumeAmount" -> "t.consume_amount";
default -> null;
};
String direction = "ascend".equalsIgnoreCase(sortOrder) ? "ASC" : "DESC";
return new SortSpec(column, direction);
}
private static SortSpec normalizeSummarySort(String sortField, String sortOrder) {
String column = switch (Objects.toString(sortField, "")) {
case "payoutAmount" -> "payoutAmount";
case "profitAmount" -> "profitAmount";
case "userCount" -> "userCount";
case "orderCount" -> "orderCount";
case "consumeAmount" -> "consumeAmount";
default -> null;
};
String direction = "ascend".equalsIgnoreCase(sortOrder) ? "ASC" : "DESC";
return new SortSpec(column, direction);
}
private static String normalizeGameProvider(String gameProvider) {
String value = trimToNull(gameProvider);
return value == null ? null : value.toUpperCase(Locale.ROOT);
}
private static StatTimezone normalizeStatTimezone(String statTimezone) {
String value = trimToNull(statTimezone);
if (value == null) {
return null;
}
String upperValue = value.toUpperCase(Locale.ROOT);
ZoneId zoneId = switch (upperValue) {
case "UTC", "Z", "+00:00" -> ZoneId.of("UTC");
case "ASIA/SHANGHAI", "BEIJING", "CHINA", "+08:00" -> ZoneId.of("Asia/Shanghai");
case "ASIA/RIYADH", "RIYADH", "SERVER", "+03:00" -> STORAGE_ZONE_ID;
default -> null;
};
if (zoneId == null) {
return null;
}
return new StatTimezone(zoneId.getId(), zoneId, zoneOffset(zoneId));
}
private static String normalizeCsv(String value) {
if (!hasText(value)) {
return null;
}
String normalized = Arrays.stream(value.split(","))
.map(String::trim)
.filter(CountryDashboardGameMetricServiceImpl::hasText)
.distinct()
.reduce((left, right) -> left + "," + right)
.orElse(null);
return hasText(normalized) ? normalized : null;
}
private static BigDecimal add(BigDecimal left, BigDecimal right) {
return safe(left).add(safe(right));
}
private static BigDecimal safe(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
private static BigDecimal percent(BigDecimal numerator, BigDecimal denominator) {
BigDecimal safeDenominator = safe(denominator);
if (BigDecimal.ZERO.compareTo(safeDenominator) == 0) {
return BigDecimal.ZERO;
}
return safe(numerator)
.multiply(BigDecimal.valueOf(100))
.divide(safeDenominator, 4, RoundingMode.HALF_UP);
}
private static long safeLong(Long value) {
return value == null ? 0L : value;
}
private static boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static String zoneOffset(ZoneId zoneId) {
ZoneOffset offset = zoneId.getRules().getOffset(Instant.now());
String id = offset.getId();
return "Z".equals(id) ? "+00:00" : id;
}
private record PeriodWindow(String periodType, String periodKey, String periodName,
LocalDate periodStartDate, LocalDate periodEndDate) {
}
private record StatTimezone(String id, ZoneId zoneId, String offset) {
}
private record TimeWindow(LocalDateTime startTime, LocalDateTime endTime,
Long startTimeMillis, Long endTimeMillis) {
}
private record RefreshWriteResult(int deleted, int inserted, int userRows) {
}
private record SortSpec(String column, String direction) {
}
private static final class QueryCondition {
private final String periodType;
private final String statTimezone;
private final ZoneId statZoneId;
private final LocalDate startDateValue;
private final LocalDate endDateValue;
private final LocalDateTime startTime;
private final LocalDateTime endTime;
private final String startPeriodKey;
private final String endPeriodKey;
private final String countryKeyword;
private final String sysOrigin;
private QueryCondition(String periodType, String statTimezone, ZoneId statZoneId,
LocalDate startDateValue, LocalDate endDateValue, LocalDateTime startTime,
LocalDateTime endTime, String startPeriodKey, String endPeriodKey,
String countryKeyword, String sysOrigin) {
this.periodType = periodType;
this.statTimezone = statTimezone;
this.statZoneId = statZoneId;
this.startDateValue = startDateValue;
this.endDateValue = endDateValue;
this.startTime = startTime;
this.endTime = endTime;
this.startPeriodKey = startPeriodKey;
this.endPeriodKey = endPeriodKey;
this.countryKeyword = countryKeyword;
this.sysOrigin = sysOrigin;
}
private static QueryCondition of(CountryDashboardGameMetricQryCmd cmd) {
String periodType = normalizePeriodType(cmd == null ? null : cmd.getPeriodType());
ZoneId statZoneId = normalizeStatZoneId(cmd == null ? null : cmd.getStatTimezone());
LocalDate start = parseDate(cmd == null ? null : cmd.getStartDate());
LocalDate end = parseDate(cmd == null ? null : cmd.getEndDate());
if (!PERIOD_ALL.equals(periodType) && start == null && end == null) {
LocalDate today = LocalDate.now(statZoneId);
switch (periodType) {
case PERIOD_WEEK -> {
start = today.with(DayOfWeek.MONDAY);
end = today.with(DayOfWeek.SUNDAY);
}
case PERIOD_MONTH -> {
start = today.withDayOfMonth(1);
end = today.withDayOfMonth(today.lengthOfMonth());
}
default -> {
start = today;
end = today;
}
}
}
if (start != null && end == null) {
end = start;
}
if (start == null && end != null) {
start = end;
}
if (start != null && end != null && end.isBefore(start)) {
throw new IllegalArgumentException("endDate must not be before startDate.");
}
Instant startInstant = start == null ? null : start.atStartOfDay(statZoneId).toInstant();
Instant endInstant = end == null ? null : end.plusDays(1).atStartOfDay(statZoneId).toInstant();
LocalDateTime storageStartTime = startInstant == null ? null
: LocalDateTime.ofInstant(startInstant, STORAGE_ZONE_ID);
LocalDateTime storageEndTime = endInstant == null ? null
: LocalDateTime.ofInstant(endInstant, STORAGE_ZONE_ID);
String startPeriodKey = PERIOD_DAY.equals(periodType) && start != null
? start.format(DATE_FORMATTER) : null;
String endPeriodKey = PERIOD_DAY.equals(periodType) && end != null
? end.format(DATE_FORMATTER) : null;
return new QueryCondition(
periodType,
statZoneId.getId(),
statZoneId,
start,
end,
storageStartTime,
storageEndTime,
startPeriodKey,
endPeriodKey,
trimToNull(cmd == null ? null : cmd.getCountryKeyword()),
trimToNull(cmd == null ? null : cmd.getSysOrigin())
);
}
private static String normalizePeriodType(String periodType) {
String value = trimToNull(periodType);
if (value == null) {
return PERIOD_DAY;
}
value = value.toUpperCase(Locale.ROOT);
return switch (value) {
case PERIOD_DAY, PERIOD_WEEK, PERIOD_MONTH, PERIOD_ALL -> value;
default -> PERIOD_DAY;
};
}
private static ZoneId normalizeStatZoneId(String statTimezone) {
StatTimezone normalized = normalizeStatTimezone(statTimezone);
return normalized == null ? STORAGE_ZONE_ID : normalized.zoneId();
}
private static LocalDate parseDate(String value) {
String fixed = trimToNull(value);
return fixed == null ? null : LocalDate.parse(fixed, DATE_FORMATTER);
}
}
}

View File

@ -15,8 +15,6 @@ import com.red.circle.other.inner.endpoint.team.target.UserHistoryIdentityClient
import com.red.circle.other.inner.endpoint.user.region.RegionConfigClient;
import com.red.circle.other.inner.endpoint.user.user.UserProfileClient;
import com.red.circle.other.inner.enums.sys.appmanager.RoomRolesEnum;
import com.red.circle.other.inner.model.cmd.sys.AddSysAdministratorCmd;
import com.red.circle.other.inner.model.cmd.sys.SysAdministratorChangeStatusCmd;
import com.red.circle.other.inner.model.cmd.team.manager.TeamManagerOpsPageQryCmd;
import com.red.circle.other.inner.model.dto.sys.AdministratorDTO;
import com.red.circle.other.inner.model.dto.agency.manager.TeamManagerDTO;
@ -117,8 +115,7 @@ public class TeamManagerServiceImpl implements TeamManagerService {
.setCreateUser(cmd.getCreateUser());
ResponseAssert.requiredSuccess(teamManagerClient.add(addCmd));
ResponseAssert.requiredSuccess(userHistoryIdentityClient.saveManager(cmd.getUserId()));
ensureAppManager(cmd.getUserId(), cmd.getCreateUser());
ResponseAssert.requiredSuccess(userHistoryIdentityClient.saveYumiManager(cmd.getUserId()));
}
@Override
@ -138,36 +135,6 @@ public class TeamManagerServiceImpl implements TeamManagerService {
removeAppManagerIfOnlyManagerRole(db.getUserId());
}
private void ensureAppManager(Long userId, Long operatorUserId) {
AdministratorDTO administrator = ResponseAssert.requiredSuccess(
administratorClient.getByUserId(userId));
if (Objects.isNull(administrator)) {
AddSysAdministratorCmd addCmd = new AddSysAdministratorCmd();
addCmd.setUserId(userId);
addCmd.setRoles(RoomRolesEnum.MANAGER.name());
addCmd.setReqUserId(operatorUserId);
ResponseAssert.requiredSuccess(administratorClient.addSysAdministrator(addCmd));
administrator = ResponseAssert.requiredSuccess(administratorClient.getByUserId(userId));
}
ResponseAssert.notNull(CommonErrorCode.DATA_ERROR, administrator);
if (StringUtils.isBlank(administrator.getRoles())
|| !Boolean.TRUE.equals(administrator.getStatus())) {
ResponseAssert.requiredSuccess(
administratorClient.changeRoles(administrator.getId(), RoomRolesEnum.MANAGER.name()));
}
if (!Boolean.TRUE.equals(administrator.getStatus())) {
SysAdministratorChangeStatusCmd statusCmd = new SysAdministratorChangeStatusCmd()
.setId(administrator.getId())
.setStatus(1);
statusCmd.setReqUserId(operatorUserId);
ResponseAssert.requiredSuccess(administratorClient.changeStatusAdministrator(statusCmd));
}
}
private void removeAppManagerIfOnlyManagerRole(Long userId) {
AdministratorDTO administrator = ResponseAssert.requiredSuccess(
administratorClient.getByUserId(userId));

View File

@ -287,9 +287,10 @@ public class UserBaseInfoServiceImpl implements
.setAnchor(Objects.nonNull(teamMember))
.setBd(ResponseAssert.requiredSuccess(bdTeamInfoClient.check(userId)))
.setBdLeader(ResponseAssert.requiredSuccess(bdTeamLeaderClient.check(userId)))
.setManager(ResponseAssert.requiredSuccess(teamManagerClient.check(userId)))
.setManager(Boolean.FALSE)
.setYumiManager(ResponseAssert.requiredSuccess(teamManagerClient.check(userId)))
.setFreightAgent(
ResponseAssert.requiredSuccess(freightGoldClient.checkFreightAgent(userId)))
ResponseAssert.requiredSuccess(freightGoldClient.checkFreightAgent(userId)))
.setHistoryIdentity(
ResponseAssert.requiredSuccess(historyIdentityClient.getLabels(userId)));
}

View File

@ -37,13 +37,18 @@ public class UserIdentityCO implements Serializable {
private Boolean bdLeader;
/**
* true团队经理false:不是.
* true团队经理false:不是.
*/
private Boolean manager;
/**
* trueYumi 团队经理false:不是.
*/
private Boolean yumiManager;
/**
* 货运代理: true: false:不是.
*/
*/
private Boolean freightAgent;
/**

View File

@ -0,0 +1,50 @@
package com.red.circle.console.app.dto.clientobject.datav;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 国家数据大屏游戏维度指标.
*/
@Data
@Accessors(chain = true)
public class CountryDashboardGameMetricCO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private String periodKey;
private String periodName;
private String statTimezone;
private String countryCode;
private String countryName;
private String gameProvider;
private String gameId;
private String gameName;
private BigDecimal consumeAmount = BigDecimal.ZERO;
private BigDecimal payoutAmount = BigDecimal.ZERO;
private BigDecimal profitAmount = BigDecimal.ZERO;
private BigDecimal payoutRate = BigDecimal.ZERO;
private BigDecimal profitRate = BigDecimal.ZERO;
private Long userCount = 0L;
private Long orderCount = 0L;
private String refreshedAt;
}

View File

@ -0,0 +1,63 @@
package com.red.circle.console.app.dto.cmd.datav;
import java.io.Serial;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
/**
* 国家数据大屏游戏数据查询.
*/
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = true)
public class CountryDashboardGameMetricQryCmd extends CountryDashboardQryCmd {
@Serial
private static final long serialVersionUID = 1L;
/**
* 页码 1 开始.
*/
private Integer cursor;
/**
* 每页条数.
*/
private Integer limit;
/**
* 多个国家编码逗号分隔.
*/
private String countryCodes;
/**
* 游戏厂商.
*/
private String gameProvider;
/**
* 游戏 ID.
*/
private String gameId;
/**
* 游戏 ID 或名称关键字.
*/
private String gameKeyword;
/**
* 国家排名返回条数.
*/
private Integer rankLimit;
/**
* 排序字段.
*/
private String sortField;
/**
* ascend / descend.
*/
private String sortOrder;
}

View File

@ -0,0 +1,24 @@
package com.red.circle.console.app.service.app.datav;
import com.red.circle.console.app.dto.clientobject.datav.CountryDashboardGameMetricCO;
import com.red.circle.console.app.dto.cmd.datav.CountryDashboardGameMetricQryCmd;
import com.red.circle.framework.dto.PageResult;
import java.time.LocalDate;
import java.util.List;
/**
* 国家数据大屏游戏维度指标.
*/
public interface CountryDashboardGameMetricService {
PageResult<CountryDashboardGameMetricCO> pageGameMetrics(CountryDashboardGameMetricQryCmd cmd);
PageResult<CountryDashboardGameMetricCO> pageGameSummaries(CountryDashboardGameMetricQryCmd cmd);
List<CountryDashboardGameMetricCO> listGameCountryRanks(CountryDashboardGameMetricQryCmd cmd);
void refreshRecentDays(int days);
List<String> refreshRange(LocalDate startDate, LocalDate endDate, String sysOrigin,
String statTimezones);
}

View File

@ -1,6 +1,7 @@
package com.red.circle.console.infra.database.rds.dao.datav;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardDailyMetricDTO;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardGameMetricDTO;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardMetricDTO;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardPeriodMetricDTO;
import com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardRechargeDetailDTO;
@ -25,6 +26,10 @@ public interface CountryDashboardDAO extends BaseDAO<IUserBaseInfo> {
int createPeriodUserMetricTable();
int createGamePeriodMetricTable();
int createGamePeriodUserMetricTable();
int ensurePeriodMetricTimezoneSchema();
List<CountryDashboardMetricDTO> listNewUsers(
@ -259,4 +264,156 @@ public interface CountryDashboardDAO extends BaseDAO<IUserBaseInfo> {
@Param("statTimezoneOffset") String statTimezoneOffset,
@Param("offset") int offset,
@Param("limit") int limit);
List<CountryDashboardGameMetricDTO> listWalletGameMetricAmounts(
@Param("periodType") String periodType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("startTimeMillis") Long startTimeMillis,
@Param("endTimeMillis") Long endTimeMillis,
@Param("countryKeyword") String countryKeyword,
@Param("sysOrigin") String sysOrigin,
@Param("storageTimezoneOffset") String storageTimezoneOffset,
@Param("statTimezoneOffset") String statTimezoneOffset);
List<CountryDashboardGameMetricDTO> listLuckyBoxGameMetricAmounts(
@Param("periodType") String periodType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("countryKeyword") String countryKeyword,
@Param("sysOrigin") String sysOrigin,
@Param("storageTimezoneOffset") String storageTimezoneOffset,
@Param("statTimezoneOffset") String statTimezoneOffset);
int deleteGamePeriodUserMetrics(
@Param("periodType") String periodType,
@Param("periodKey") String periodKey,
@Param("statTimezone") String statTimezone,
@Param("sysOrigin") String sysOrigin);
int insertWalletGamePeriodUserMetrics(
@Param("periodType") String periodType,
@Param("periodKey") String periodKey,
@Param("periodName") String periodName,
@Param("periodStartDate") LocalDate periodStartDate,
@Param("periodEndDate") LocalDate periodEndDate,
@Param("statTimezone") String statTimezone,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("startTimeMillis") Long startTimeMillis,
@Param("endTimeMillis") Long endTimeMillis,
@Param("sysOrigin") String sysOrigin);
int insertLuckyBoxGamePeriodUserMetrics(
@Param("periodType") String periodType,
@Param("periodKey") String periodKey,
@Param("periodName") String periodName,
@Param("periodStartDate") LocalDate periodStartDate,
@Param("periodEndDate") LocalDate periodEndDate,
@Param("statTimezone") String statTimezone,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("sysOrigin") String sysOrigin);
int insertAllGamePeriodUserMetricsFromDaily(
@Param("statTimezone") String statTimezone,
@Param("sysOrigin") String sysOrigin);
List<CountryDashboardGameMetricDTO> listGamePeriodUserMetricCounts(
@Param("periodType") String periodType,
@Param("periodKey") String periodKey,
@Param("statTimezone") String statTimezone,
@Param("sysOrigin") String sysOrigin);
List<CountryDashboardGameMetricDTO> listAllGamePeriodAmountsFromPeriodDays(
@Param("statTimezone") String statTimezone,
@Param("sysOrigin") String sysOrigin);
int deleteGamePeriodMetrics(
@Param("periodType") String periodType,
@Param("periodKey") String periodKey,
@Param("statTimezone") String statTimezone,
@Param("sysOrigin") String sysOrigin);
int upsertGamePeriodMetrics(@Param("metrics") List<CountryDashboardGameMetricDTO> metrics);
Long countGamePeriodMetrics(
@Param("periodType") String periodType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("periodKey") String periodKey,
@Param("startPeriodKey") String startPeriodKey,
@Param("endPeriodKey") String endPeriodKey,
@Param("statTimezone") String statTimezone,
@Param("countryKeyword") String countryKeyword,
@Param("countryCodes") String countryCodes,
@Param("gameProvider") String gameProvider,
@Param("gameKeyword") String gameKeyword,
@Param("sysOrigin") String sysOrigin);
List<CountryDashboardGameMetricDTO> listGamePeriodMetrics(
@Param("periodType") String periodType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("periodKey") String periodKey,
@Param("startPeriodKey") String startPeriodKey,
@Param("endPeriodKey") String endPeriodKey,
@Param("statTimezone") String statTimezone,
@Param("countryKeyword") String countryKeyword,
@Param("countryCodes") String countryCodes,
@Param("gameProvider") String gameProvider,
@Param("gameKeyword") String gameKeyword,
@Param("sysOrigin") String sysOrigin,
@Param("sortColumn") String sortColumn,
@Param("sortDirection") String sortDirection,
@Param("offset") int offset,
@Param("limit") int limit);
Long countGameSummaryMetrics(
@Param("periodType") String periodType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("periodKey") String periodKey,
@Param("startPeriodKey") String startPeriodKey,
@Param("endPeriodKey") String endPeriodKey,
@Param("statTimezone") String statTimezone,
@Param("countryKeyword") String countryKeyword,
@Param("countryCodes") String countryCodes,
@Param("gameProvider") String gameProvider,
@Param("gameKeyword") String gameKeyword,
@Param("sysOrigin") String sysOrigin);
List<CountryDashboardGameMetricDTO> listGameSummaryMetrics(
@Param("periodType") String periodType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("periodKey") String periodKey,
@Param("startPeriodKey") String startPeriodKey,
@Param("endPeriodKey") String endPeriodKey,
@Param("statTimezone") String statTimezone,
@Param("countryKeyword") String countryKeyword,
@Param("countryCodes") String countryCodes,
@Param("gameProvider") String gameProvider,
@Param("gameKeyword") String gameKeyword,
@Param("sysOrigin") String sysOrigin,
@Param("sortColumn") String sortColumn,
@Param("sortDirection") String sortDirection,
@Param("offset") int offset,
@Param("limit") int limit);
List<CountryDashboardGameMetricDTO> listGameCountryRankMetrics(
@Param("periodType") String periodType,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("periodKey") String periodKey,
@Param("startPeriodKey") String startPeriodKey,
@Param("endPeriodKey") String endPeriodKey,
@Param("statTimezone") String statTimezone,
@Param("countryKeyword") String countryKeyword,
@Param("countryCodes") String countryCodes,
@Param("gameProvider") String gameProvider,
@Param("gameKeyword") String gameKeyword,
@Param("gameId") String gameId,
@Param("sysOrigin") String sysOrigin,
@Param("limit") int limit);
}

View File

@ -0,0 +1,51 @@
package com.red.circle.console.infra.database.rds.dto.datav;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Data;
import lombok.experimental.Accessors;
/**
* 国家数据大屏游戏维度聚合结果.
*/
@Data
@Accessors(chain = true)
public class CountryDashboardGameMetricDTO {
private String periodType;
private String periodKey;
private String statTimezone;
private String periodName;
private LocalDate periodStartDate;
private LocalDate periodEndDate;
private String sysOrigin;
private String countryCode;
private String countryName;
private String gameProvider;
private String gameId;
private String gameName;
private BigDecimal consumeAmount;
private BigDecimal payoutAmount;
private BigDecimal profitAmount;
private Long userCount;
private Long orderCount;
private LocalDateTime refreshedAt;
}

View File

@ -91,6 +91,96 @@
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='国家数据大屏周期用户去重明细'
</update>
<update id="createGamePeriodMetricTable">
CREATE TABLE IF NOT EXISTS `country_dashboard_game_period_metric` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '周期类型:DAY/WEEK/MONTH/ALL',
`period_key` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '周期键',
`stat_timezone` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '统计时区',
`period_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '周期名称',
`period_start_date` date DEFAULT NULL COMMENT '周期开始日期',
`period_end_date` date DEFAULT NULL COMMENT '周期结束日期,开区间',
`sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '系统来源',
`country_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家码',
`country_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家名称',
`game_provider` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏厂商',
`game_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏ID',
`game_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏名称',
`consume_amount` decimal(20,2) NOT NULL DEFAULT 0.00 COMMENT '用户消耗',
`payout_amount` decimal(20,2) NOT NULL DEFAULT 0.00 COMMENT '用户返奖',
`profit_amount` decimal(20,2) NOT NULL DEFAULT 0.00 COMMENT '平台收益',
`user_count` bigint NOT NULL DEFAULT 0 COMMENT '去重游戏用户数',
`order_count` bigint NOT NULL DEFAULT 0 COMMENT '流水笔数',
`refreshed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近刷新时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_game_period` (`sys_origin`, `stat_timezone`, `period_type`, `period_key`, `country_code`, `game_provider`, `game_id`),
KEY `idx_game_period_query` (`sys_origin`, `stat_timezone`, `period_type`, `period_key`, `country_code`),
KEY `idx_game_period_date` (`sys_origin`, `stat_timezone`, `period_type`, `period_start_date`, `period_end_date`),
KEY `idx_game_lookup` (`sys_origin`, `stat_timezone`, `game_provider`, `game_id`, `period_type`, `period_key`),
KEY `idx_game_period_sort` (`sys_origin`, `stat_timezone`, `period_type`, `period_key`, `consume_amount`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='国家数据大屏游戏维度周期预聚合'
;
SET @current_schema = DATABASE();
SET @index_columns = (
SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema
AND TABLE_NAME = 'country_dashboard_game_period_metric'
AND INDEX_NAME = 'idx_game_period_game'
);
SET @ddl = IF(
@index_columns IS NULL,
'ALTER TABLE `country_dashboard_game_period_metric` ADD KEY `idx_game_period_game` (`sys_origin`, `stat_timezone`, `period_type`, `period_key`, `game_provider`, `game_id`, `country_code`)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @index_columns = (
SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX)
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = @current_schema
AND TABLE_NAME = 'country_dashboard_game_period_metric'
AND INDEX_NAME = 'idx_game_rank_period'
);
SET @ddl = IF(
@index_columns IS NULL,
'ALTER TABLE `country_dashboard_game_period_metric` ADD KEY `idx_game_rank_period` (`sys_origin`, `stat_timezone`, `game_provider`, `game_id`, `period_type`, `period_key`, `country_code`)',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
</update>
<update id="createGamePeriodUserMetricTable">
CREATE TABLE IF NOT EXISTS `country_dashboard_game_period_user_metric` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '周期类型:DAY/WEEK/MONTH/ALL',
`period_key` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '周期键',
`stat_timezone` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '统计时区',
`period_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '周期名称',
`period_start_date` date DEFAULT NULL COMMENT '周期开始日期',
`period_end_date` date DEFAULT NULL COMMENT '周期结束日期,开区间',
`sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '系统来源',
`country_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家码',
`country_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家名称',
`game_provider` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏厂商',
`game_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_game_period_user` (`period_type`, `period_key`, `stat_timezone`, `sys_origin`, `country_code`, `game_provider`, `game_id`, `user_id`),
KEY `idx_game_user_count` (`period_type`, `period_key`, `stat_timezone`, `sys_origin`, `game_provider`, `game_id`, `country_code`),
KEY `idx_game_user_lookup` (`sys_origin`, `stat_timezone`, `user_id`, `game_provider`, `game_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='国家数据大屏游戏维度周期用户去重明细'
</update>
<update id="ensurePeriodMetricTimezoneSchema">
SET @country_dashboard_schema_lock = GET_LOCK('country_dashboard_period_timezone_schema', 60);
SET @current_schema = DATABASE();
@ -519,51 +609,51 @@
</sql>
<sql id="WalletGoldAssetRecordSource">
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_1
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_2
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_3
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_4
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_5
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_6
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_7
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_8
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_9
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_10
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_11
<include refid="WalletGoldAssetRecordShardWhere"/>
UNION ALL
SELECT user_id, sys_origin, type, penny_amount, event_type, FROM_UNIXTIME(create_time / 1000) AS create_time
SELECT user_id, sys_origin, type, penny_amount, event_type, event_id, FROM_UNIXTIME(create_time / 1000) AS create_time
FROM likei_wallet.wallet_gold_asset_record_12
<include refid="WalletGoldAssetRecordShardWhere"/>
</sql>
@ -596,6 +686,7 @@
'LOTTERY_REWARD'
)
OR t.event_type LIKE 'BAISHUN_GAME%'
OR t.event_type LIKE 'GAME_OPEN_%'
OR t.event_type LIKE 'YOMI_GAME%'
OR t.event_type LIKE 'HKYS_GAME%'
OR t.event_type LIKE 'HOT_GAME%'
@ -605,6 +696,135 @@
AND t.event_type NOT LIKE 'ACCEPT_GIFT%'
</sql>
<sql id="WalletGameProviderExpr">
CASE
WHEN bo.vendor_game_id IS NOT NULL THEN 'BAISHUN'
WHEN go.game_id IS NOT NULL THEN 'GAME_OPEN'
WHEN t.event_type LIKE 'BAISHUN_GAME%' THEN 'BAISHUN'
WHEN t.event_type LIKE 'GAME_OPEN_%' THEN 'GAME_OPEN'
WHEN t.event_type LIKE 'YOMI_GAME%' THEN 'YOMI'
WHEN t.event_type LIKE 'HKYS_GAME%' THEN 'HKYS'
WHEN t.event_type LIKE 'HOT_GAME%' THEN 'HOT'
ELSE 'INTERNAL'
END
</sql>
<sql id="WalletGameIdExpr">
CASE
WHEN bo.vendor_game_id IS NOT NULL THEN CAST(bo.vendor_game_id AS CHAR)
WHEN go.game_id IS NOT NULL THEN go.game_id
WHEN t.event_type LIKE 'BAISHUN_GAME_%' THEN COALESCE(NULLIF(SUBSTRING(t.event_type, 14), ''), t.event_type)
WHEN t.event_type LIKE 'GAME_OPEN_%' THEN COALESCE(NULLIF(SUBSTRING(t.event_type, 11), ''), t.event_type)
WHEN t.event_type LIKE 'YOMI_GAME_%' THEN COALESCE(NULLIF(SUBSTRING(t.event_type, 11), ''), t.event_type)
WHEN t.event_type LIKE 'HKYS_GAME_%' THEN COALESCE(NULLIF(SUBSTRING(t.event_type, 11), ''), t.event_type)
WHEN t.event_type LIKE 'HOT_GAME_%' THEN COALESCE(NULLIF(SUBSTRING(t.event_type, 10), ''), t.event_type)
WHEN t.event_type IN ('FRUIT_GAME', 'DOUBLE_LAYER_FRUIT', 'GAME_FRUIT_BET', 'GAME_FRUIT_AWARD') THEN 'FRUIT_GAME'
WHEN t.event_type IN ('LUDO_GAME', 'LUDO_VICTORY', 'LUDO_REFUND', 'GAME_LUDO_REFUND') THEN 'LUDO_GAME'
WHEN t.event_type IN ('GAME_BARBECUE', 'DOUBLE_LAYER_BARBECUE') THEN 'BARBECUE_GAME'
WHEN t.event_type IN ('GAME_SLOT_MACHINE') THEN 'SLOT_MACHINE'
WHEN t.event_type IN ('TURNTABLE_GAME') THEN 'TURNTABLE_GAME'
WHEN t.event_type IN ('GAME_BURST_CRYSTAL') THEN 'BURST_CRYSTAL'
WHEN t.event_type IN ('TEEN_PATTI', 'BET_TEEN_PATTI') THEN 'TEEN_PATTI'
WHEN t.event_type IN ('LOTTERY_NUMBER_BET', 'LOTTERY_REWARD') THEN 'LOTTERY_NUMBER'
ELSE t.event_type
END
</sql>
<sql id="WalletGameNameFallbackExpr">
CASE
WHEN t.event_type IN ('FRUIT_GAME', 'DOUBLE_LAYER_FRUIT', 'GAME_FRUIT_BET', 'GAME_FRUIT_AWARD') THEN 'Fruit Game'
WHEN t.event_type IN ('LUDO_GAME', 'LUDO_VICTORY', 'LUDO_REFUND', 'GAME_LUDO_REFUND') THEN 'Ludo Game'
WHEN t.event_type IN ('GAME_BARBECUE', 'DOUBLE_LAYER_BARBECUE') THEN 'Barbecue Game'
WHEN t.event_type = 'GAME_SLOT_MACHINE' THEN 'Slot Machine'
WHEN t.event_type = 'TURNTABLE_GAME' THEN 'Turntable Game'
WHEN t.event_type = 'GAME_BURST_CRYSTAL' THEN 'Burst Crystal'
WHEN t.event_type IN ('TEEN_PATTI', 'BET_TEEN_PATTI') THEN 'Teen Patti'
WHEN t.event_type IN ('LOTTERY_NUMBER_BET', 'LOTTERY_REWARD') THEN 'Lottery Number'
ELSE t.event_type
END
</sql>
<sql id="WalletGameJoinCatalogs">
LEFT JOIN baishun_order_idempotency bo
ON bo.sys_origin = t.sys_origin COLLATE utf8mb4_0900_ai_ci
AND bo.wallet_event_id = t.event_id COLLATE utf8mb4_0900_ai_ci
LEFT JOIN game_open_callback_log go
ON go.sys_origin = t.sys_origin COLLATE utf8mb4_0900_ai_ci
AND t.event_id COLLATE utf8mb4_0900_ai_ci = CONCAT('GAME_OPEN:', go.order_id)
LEFT JOIN baishun_game_catalog bc
ON (bo.vendor_game_id IS NOT NULL OR t.event_type LIKE 'BAISHUN_GAME_%')
AND bc.sys_origin = t.sys_origin COLLATE utf8mb4_0900_ai_ci
AND bc.profile = 'PROD'
AND bc.vendor_game_id = CAST(<include refid="WalletGameIdExpr"/> AS UNSIGNED)
LEFT JOIN lingxian_game_catalog lc
ON (
go.game_id IS NOT NULL
OR
t.event_type LIKE 'GAME_OPEN_%'
OR t.event_type LIKE 'YOMI_GAME_%'
OR t.event_type LIKE 'HKYS_GAME_%'
OR t.event_type LIKE 'HOT_GAME_%'
)
AND lc.sys_origin = t.sys_origin COLLATE utf8mb4_0900_ai_ci
AND lc.profile = 'PROD'
AND lc.vendor_game_id = <include refid="WalletGameIdExpr"/> COLLATE utf8mb4_0900_ai_ci
LEFT JOIN sys_game_list_config sg
ON sg.sys_origin = t.sys_origin COLLATE utf8mb4_0900_ai_ci
AND sg.game_id = <include refid="WalletGameIdExpr"/> COLLATE utf8mb4_0900_ai_ci
</sql>
<sql id="WalletGameNameExpr">
COALESCE(
NULLIF(CONVERT(bc.name USING utf8mb4) COLLATE utf8mb4_unicode_ci, CONVERT('' USING utf8mb4) COLLATE utf8mb4_unicode_ci),
NULLIF(CONVERT(lc.name USING utf8mb4) COLLATE utf8mb4_unicode_ci, CONVERT('' USING utf8mb4) COLLATE utf8mb4_unicode_ci),
NULLIF(CONVERT(sg.name USING utf8mb4) COLLATE utf8mb4_unicode_ci, CONVERT('' USING utf8mb4) COLLATE utf8mb4_unicode_ci),
CONVERT(<include refid="WalletGameNameFallbackExpr"/> USING utf8mb4) COLLATE utf8mb4_unicode_ci
)
</sql>
<sql id="GameMetricFilterWhere">
WHERE t.period_type = #{periodType}
AND t.stat_timezone = #{statTimezone}
<choose>
<when test="periodType == 'ALL'">
AND t.period_key = 'ALL'
</when>
<when test="periodType == 'DAY' and startPeriodKey != null and startPeriodKey != '' and endPeriodKey != null and endPeriodKey != ''">
AND t.period_key &gt;= #{startPeriodKey}
AND t.period_key &lt;= #{endPeriodKey}
</when>
<otherwise>
<if test="startTime != null">
AND t.period_start_date &gt;= DATE(#{startTime})
</if>
<if test="endTime != null">
AND t.period_start_date &lt; DATE(#{endTime})
</if>
</otherwise>
</choose>
<if test="countryKeyword != null and countryKeyword != ''">
AND (
t.country_code = #{countryKeyword}
OR t.country_name LIKE CONCAT('%', #{countryKeyword}, '%')
)
</if>
<if test="countryCodes != null and countryCodes != ''">
AND FIND_IN_SET(t.country_code, #{countryCodes})
</if>
<if test="gameProvider != null and gameProvider != ''">
AND t.game_provider = #{gameProvider}
</if>
<if test="gameKeyword != null and gameKeyword != ''">
AND (
t.game_id = #{gameKeyword}
OR t.game_name LIKE CONCAT('%', #{gameKeyword}, '%')
)
</if>
<if test="sysOrigin != null and sysOrigin != ''">
AND t.sys_origin = #{sysOrigin}
</if>
</sql>
<select id="listPrecomputedMetrics"
resultType="com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardMetricDTO">
SELECT
@ -1345,6 +1565,391 @@
GROUP BY countryCode, countryName, periodKey, periodName
</select>
<select id="listWalletGameMetricAmounts"
resultType="com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardGameMetricDTO">
SELECT
<include refid="CountryColumns"/>,
<include refid="PeriodColumnsT"/>,
<include refid="WalletGameProviderExpr"/> AS gameProvider,
<include refid="WalletGameIdExpr"/> AS gameId,
MAX(<include refid="WalletGameNameExpr"/>) AS gameName,
IFNULL(SUM(CASE WHEN t.type = 1 THEN t.penny_amount ELSE 0 END) / 100.00, 0) AS consumeAmount,
IFNULL(SUM(CASE WHEN t.type = 0 THEN t.penny_amount ELSE 0 END) / 100.00, 0) AS payoutAmount,
IFNULL((SUM(CASE WHEN t.type = 1 THEN t.penny_amount ELSE 0 END)
- SUM(CASE WHEN t.type = 0 THEN t.penny_amount ELSE 0 END)) / 100.00, 0) AS profitAmount,
COUNT(1) AS orderCount
FROM (
<include refid="WalletGoldAssetRecordSource"/>
) t
INNER JOIN user_base_info u ON u.id = t.user_id
<include refid="WalletGameJoinCatalogs"/>
WHERE <include refid="WalletGameEventWhere"/>
<include refid="UserWhere"/>
<include refid="SourceTimeWhere"/>
<include refid="SourceSysOriginWhere"/>
GROUP BY countryCode, countryName, periodKey, periodName, gameProvider, gameId
</select>
<select id="listLuckyBoxGameMetricAmounts"
resultType="com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardGameMetricDTO">
SELECT
<include refid="CountryColumns"/>,
<include refid="PeriodColumnsT"/>,
'INTERNAL' AS gameProvider,
'LUCKY_BOX' AS gameId,
'Lucky Box' AS gameName,
IFNULL(SUM(t.pay_amount), 0) AS consumeAmount,
IFNULL(SUM(t.gift_amount), 0) AS payoutAmount,
IFNULL(SUM(t.pay_amount), 0) - IFNULL(SUM(t.gift_amount), 0) AS profitAmount,
COUNT(1) AS orderCount
FROM game_lucky_box_count t
INNER JOIN user_base_info u ON u.id = t.user_id
WHERE 1 = 1
<include refid="UserWhere"/>
<include refid="SourceTimeWhere"/>
<include refid="SourceSysOriginWhere"/>
GROUP BY countryCode, countryName, periodKey, periodName, gameProvider, gameId, gameName
</select>
<delete id="deleteGamePeriodUserMetrics">
DELETE FROM country_dashboard_game_period_user_metric
WHERE period_type = #{periodType}
AND period_key = #{periodKey}
AND stat_timezone = #{statTimezone}
<if test="sysOrigin != null and sysOrigin != ''">
AND sys_origin = #{sysOrigin}
</if>
</delete>
<insert id="insertWalletGamePeriodUserMetrics">
INSERT IGNORE INTO country_dashboard_game_period_user_metric (
period_type,
period_key,
stat_timezone,
period_name,
period_start_date,
period_end_date,
sys_origin,
country_code,
country_name,
game_provider,
game_id,
user_id
)
SELECT DISTINCT
#{periodType},
#{periodKey},
#{statTimezone},
#{periodName},
#{periodStartDate},
#{periodEndDate},
#{sysOrigin},
IFNULL(NULLIF(u.country_code, ''), 'UNKNOWN') AS countryCode,
IFNULL(NULLIF(u.country_name, ''), IFNULL(NULLIF(u.country_code, ''), 'UNKNOWN')) AS countryName,
<include refid="WalletGameProviderExpr"/> AS gameProvider,
<include refid="WalletGameIdExpr"/> AS gameId,
t.user_id AS userId
FROM (
<include refid="WalletGoldAssetRecordSource"/>
) t
INNER JOIN user_base_info u ON u.id = t.user_id
<include refid="WalletGameJoinCatalogs"/>
WHERE <include refid="WalletGameEventWhere"/>
AND (u.is_del = 0 OR u.is_del IS NULL)
<include refid="SourceTimeWhere"/>
<include refid="SourceSysOriginWhere"/>
<if test="sysOrigin != null and sysOrigin != ''">
AND u.origin_sys = #{sysOrigin}
</if>
</insert>
<insert id="insertLuckyBoxGamePeriodUserMetrics">
INSERT IGNORE INTO country_dashboard_game_period_user_metric (
period_type,
period_key,
stat_timezone,
period_name,
period_start_date,
period_end_date,
sys_origin,
country_code,
country_name,
game_provider,
game_id,
user_id
)
SELECT DISTINCT
#{periodType},
#{periodKey},
#{statTimezone},
#{periodName},
#{periodStartDate},
#{periodEndDate},
#{sysOrigin},
IFNULL(NULLIF(u.country_code, ''), 'UNKNOWN') AS countryCode,
IFNULL(NULLIF(u.country_name, ''), IFNULL(NULLIF(u.country_code, ''), 'UNKNOWN')) AS countryName,
'INTERNAL' AS gameProvider,
'LUCKY_BOX' AS gameId,
t.user_id AS userId
FROM game_lucky_box_count t
INNER JOIN user_base_info u ON u.id = t.user_id
WHERE 1 = 1
AND (u.is_del = 0 OR u.is_del IS NULL)
<include refid="SourceTimeWhere"/>
<include refid="SourceSysOriginWhere"/>
<if test="sysOrigin != null and sysOrigin != ''">
AND u.origin_sys = #{sysOrigin}
</if>
</insert>
<insert id="insertAllGamePeriodUserMetricsFromDaily">
INSERT IGNORE INTO country_dashboard_game_period_user_metric (
period_type,
period_key,
stat_timezone,
period_name,
period_start_date,
period_end_date,
sys_origin,
country_code,
country_name,
game_provider,
game_id,
user_id
)
SELECT DISTINCT
'ALL',
'ALL',
#{statTimezone},
'全部',
NULL,
NULL,
t.sys_origin,
t.country_code,
t.country_name,
t.game_provider,
t.game_id,
t.user_id
FROM country_dashboard_game_period_user_metric t
WHERE t.period_type = 'DAY'
AND t.stat_timezone = #{statTimezone}
<if test="sysOrigin != null and sysOrigin != ''">
AND t.sys_origin = #{sysOrigin}
</if>
</insert>
<select id="listGamePeriodUserMetricCounts"
resultType="com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardGameMetricDTO">
SELECT
t.country_code AS countryCode,
MAX(t.country_name) AS countryName,
t.period_key AS periodKey,
MAX(t.period_name) AS periodName,
t.game_provider AS gameProvider,
t.game_id AS gameId,
COUNT(1) AS userCount
FROM country_dashboard_game_period_user_metric t
WHERE t.period_type = #{periodType}
AND t.period_key = #{periodKey}
AND t.stat_timezone = #{statTimezone}
<if test="sysOrigin != null and sysOrigin != ''">
AND t.sys_origin = #{sysOrigin}
</if>
GROUP BY countryCode, periodKey, gameProvider, gameId
</select>
<select id="listAllGamePeriodAmountsFromPeriodDays"
resultType="com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardGameMetricDTO">
SELECT
t.country_code AS countryCode,
MAX(t.country_name) AS countryName,
'ALL' AS periodKey,
'全部' AS periodName,
t.game_provider AS gameProvider,
t.game_id AS gameId,
MAX(t.game_name) AS gameName,
IFNULL(SUM(t.consume_amount), 0) AS consumeAmount,
IFNULL(SUM(t.payout_amount), 0) AS payoutAmount,
IFNULL(SUM(t.profit_amount), 0) AS profitAmount,
IFNULL(SUM(t.order_count), 0) AS orderCount
FROM country_dashboard_game_period_metric t
WHERE t.period_type = 'DAY'
AND t.stat_timezone = #{statTimezone}
<if test="sysOrigin != null and sysOrigin != ''">
AND t.sys_origin = #{sysOrigin}
</if>
GROUP BY countryCode, gameProvider, gameId
</select>
<delete id="deleteGamePeriodMetrics">
DELETE FROM country_dashboard_game_period_metric
WHERE period_type = #{periodType}
AND period_key = #{periodKey}
AND stat_timezone = #{statTimezone}
<if test="sysOrigin != null and sysOrigin != ''">
AND sys_origin = #{sysOrigin}
</if>
</delete>
<insert id="upsertGamePeriodMetrics">
INSERT INTO country_dashboard_game_period_metric (
period_type,
period_key,
stat_timezone,
period_name,
period_start_date,
period_end_date,
sys_origin,
country_code,
country_name,
game_provider,
game_id,
game_name,
consume_amount,
payout_amount,
profit_amount,
user_count,
order_count,
refreshed_at
)
VALUES
<foreach collection="metrics" item="metric" separator=",">
(
#{metric.periodType},
#{metric.periodKey},
#{metric.statTimezone},
#{metric.periodName},
#{metric.periodStartDate},
#{metric.periodEndDate},
#{metric.sysOrigin},
#{metric.countryCode},
#{metric.countryName},
#{metric.gameProvider},
#{metric.gameId},
#{metric.gameName},
#{metric.consumeAmount},
#{metric.payoutAmount},
#{metric.profitAmount},
#{metric.userCount},
#{metric.orderCount},
NOW()
)
</foreach>
ON DUPLICATE KEY UPDATE
period_name = VALUES(period_name),
period_start_date = VALUES(period_start_date),
period_end_date = VALUES(period_end_date),
country_name = VALUES(country_name),
game_name = VALUES(game_name),
consume_amount = VALUES(consume_amount),
payout_amount = VALUES(payout_amount),
profit_amount = VALUES(profit_amount),
user_count = VALUES(user_count),
order_count = VALUES(order_count),
refreshed_at = VALUES(refreshed_at)
</insert>
<select id="countGamePeriodMetrics" resultType="java.lang.Long">
SELECT COUNT(1)
FROM country_dashboard_game_period_metric t
<include refid="GameMetricFilterWhere"/>
</select>
<select id="listGamePeriodMetrics"
resultType="com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardGameMetricDTO">
SELECT
t.period_key AS periodKey,
t.period_name AS periodName,
t.stat_timezone AS statTimezone,
t.country_code AS countryCode,
t.country_name AS countryName,
t.game_provider AS gameProvider,
t.game_id AS gameId,
t.game_name AS gameName,
t.consume_amount AS consumeAmount,
t.payout_amount AS payoutAmount,
t.profit_amount AS profitAmount,
t.user_count AS userCount,
t.order_count AS orderCount,
t.refreshed_at AS refreshedAt
FROM country_dashboard_game_period_metric t
<include refid="GameMetricFilterWhere"/>
<choose>
<when test="sortColumn != null and sortColumn != ''">
ORDER BY ${sortColumn} ${sortDirection}, t.period_key DESC, t.country_code ASC, t.game_provider ASC, t.game_id ASC
</when>
<otherwise>
ORDER BY t.period_key DESC, t.consume_amount DESC, t.country_code ASC, t.game_provider ASC, t.game_id ASC
</otherwise>
</choose>
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countGameSummaryMetrics" resultType="java.lang.Long">
SELECT COUNT(1)
FROM (
SELECT 1
FROM country_dashboard_game_period_metric t
<include refid="GameMetricFilterWhere"/>
GROUP BY t.game_provider, t.game_id
) summary
</select>
<select id="listGameSummaryMetrics"
resultType="com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardGameMetricDTO">
SELECT
MAX(t.period_key) AS periodKey,
MAX(t.period_name) AS periodName,
MAX(t.stat_timezone) AS statTimezone,
MAX(t.game_provider) AS gameProvider,
t.game_id AS gameId,
MAX(t.game_name) AS gameName,
COALESCE(SUM(t.consume_amount), 0) AS consumeAmount,
COALESCE(SUM(t.payout_amount), 0) AS payoutAmount,
COALESCE(SUM(t.profit_amount), 0) AS profitAmount,
COALESCE(SUM(t.user_count), 0) AS userCount,
COALESCE(SUM(t.order_count), 0) AS orderCount,
MAX(t.refreshed_at) AS refreshedAt
FROM country_dashboard_game_period_metric t
<include refid="GameMetricFilterWhere"/>
GROUP BY t.game_provider, t.game_id
<choose>
<when test="sortColumn != null and sortColumn != ''">
ORDER BY ${sortColumn} ${sortDirection}, gameProvider ASC, gameId ASC
</when>
<otherwise>
ORDER BY consumeAmount DESC, gameProvider ASC, gameId ASC
</otherwise>
</choose>
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="listGameCountryRankMetrics"
resultType="com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardGameMetricDTO">
SELECT
MAX(t.period_key) AS periodKey,
MAX(t.period_name) AS periodName,
MAX(t.stat_timezone) AS statTimezone,
t.country_code AS countryCode,
MAX(t.country_name) AS countryName,
MAX(t.game_provider) AS gameProvider,
MAX(t.game_id) AS gameId,
MAX(t.game_name) AS gameName,
COALESCE(SUM(t.consume_amount), 0) AS consumeAmount,
COALESCE(SUM(t.payout_amount), 0) AS payoutAmount,
COALESCE(SUM(t.profit_amount), 0) AS profitAmount,
COALESCE(SUM(t.user_count), 0) AS userCount,
COALESCE(SUM(t.order_count), 0) AS orderCount,
MAX(t.refreshed_at) AS refreshedAt
FROM country_dashboard_game_period_metric t
<include refid="GameMetricFilterWhere"/>
AND t.game_provider = #{gameProvider}
AND t.game_id = #{gameId}
GROUP BY t.country_code
ORDER BY consumeAmount DESC, countryCode ASC
LIMIT #{limit}
</select>
<select id="listNewUserCohorts"
resultType="com.red.circle.console.infra.database.rds.dto.datav.CountryDashboardRetentionUserDTO">
SELECT

View File

@ -74,4 +74,7 @@ red-circle:
- /console/datav/online/room/count
- /console/datav/country-dashboard
- /console/datav/country-dashboard/recharge-details
- /console/datav/country-dashboard/game-metrics
- /console/datav/country-dashboard/game-metrics/games
- /console/datav/country-dashboard/game-metrics/country-rank
- /console/user/base/info/im/sig

View File

@ -0,0 +1,314 @@
# Admin 数据大屏游戏数据表技术方案
## 目标
在新 admin 的 `统计管理 / 数据大屏` 中增加独立的“游戏数据”表,用于按日期、国家、游戏维度查看消耗和收益数据。
页面查询不能直接扫描 `likei_wallet.wallet_gold_asset_record_1..12`、第三方回调日志或游戏明细表。所有 admin 查询默认读取游戏维度预聚合表;只有后台刷新任务和手动回填任务允许按时间窗口扫描原始数据。
## 现状
当前国家数据大屏入口:
- 前端路由:`chatapp3-admin/apps/src/router/routes/modules/statistics.ts`
- 前端页面:`chatapp3-admin/apps/src/views/statistics/datav.vue`
- 前端接口:`chatapp3-admin/apps/src/api/legacy/datav.ts`
- 后端 Controller`DatavRestController`
- 后端 Service`CountryDashboardServiceImpl`
- 后端 Mapper`CountryDashboardDAO.xml`
- 现有查询文档:`chatapp3-java/rc-service/rc-service-console/docs/country-dashboard-data-query.md`
现有大屏已经聚合了国家维度的 `game_total_flow``game_payout``game_user`,但没有保留游戏维度。新增功能不改动现有总览口径,新增一套游戏维度查询和预聚合表。
## 页面设计
`datav.vue` 的表格区域增加第三个 tab
- `指标总览`
- `充值相关数据`
- `游戏数据`
“游戏数据”表复用页面已有筛选项:
- 系统:`sysOrigin`
- 统计时区:`statTimezone`
- 周期:`periodType`
- 日期范围:`startDate/endDate`
- 国家多选:`countryCodes`
新增游戏筛选项:
- `gameProvider`:游戏厂商,支持 `INTERNAL``BAISHUN``GAME_OPEN``LINGXIAN`,可为空。
- `gameKeyword`:游戏 ID 或游戏名称模糊搜索。
表格字段:
| 字段 | 说明 |
| --- | --- |
| 日期/周期 | `periodKey`DAY 显示日期WEEK/MONTH/ALL 显示周期 |
| 国家 | `countryCode / countryName` |
| 游戏厂商 | `gameProvider` |
| 游戏 ID | `gameId` |
| 游戏名称 | `gameName` |
| 消耗 | 用户玩游戏支出,现有钱包口径为 `type = 1` |
| 返奖 | 游戏返还给用户,现有钱包口径为 `type = 0` |
| 收益 | `consumeAmount - payoutAmount` |
| 返奖率 | `payoutAmount / consumeAmount` |
| 收益率 | `profitAmount / consumeAmount` |
| 游戏用户数 | 周期内去重用户数 |
| 流水笔数 | 周期内消耗和返奖记录数 |
| 更新时间 | 预聚合刷新时间 |
表格必须服务端分页,默认 `limit=50`,最大 `limit=200`
## 后端接口
新增接口:
```http
GET /console/datav/country-dashboard/game-metrics
```
请求参数:
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `sysOrigin` | 是 | 系统来源 |
| `periodType` | 是 | `DAY` / `WEEK` / `MONTH` / `ALL` |
| `startDate` | DAY/WEEK/MONTH 必填 | 查询开始日期 |
| `endDate` | DAY/WEEK/MONTH 必填 | 查询结束日期 |
| `statTimezone` | 否 | 默认 `Asia/Riyadh` |
| `countryCodes` | 否 | 多国家码,逗号分隔 |
| `countryKeyword` | 否 | 国家码或国家名搜索 |
| `gameProvider` | 否 | 游戏厂商 |
| `gameKeyword` | 否 | 游戏 ID 或名称搜索 |
| `cursor` | 否 | 页码,默认 1 |
| `limit` | 否 | 页大小,默认 50最大 200 |
| `sortField` | 否 | 允许 `consumeAmount/payoutAmount/profitAmount/userCount/orderCount` |
| `sortOrder` | 否 | `ascend/descend` |
响应结构:
```json
{
"current": 1,
"size": 50,
"total": 120,
"records": [
{
"periodKey": "2026-05-12",
"periodName": "2026-05-12",
"statTimezone": "Asia/Riyadh",
"countryCode": "SA",
"countryName": "Saudi Arabia",
"gameProvider": "BAISHUN",
"gameId": "1067",
"gameName": "example game",
"consumeAmount": "1000.00",
"payoutAmount": "720.00",
"profitAmount": "280.00",
"payoutRate": "72.00%",
"profitRate": "28.00%",
"userCount": 86,
"orderCount": 320,
"refreshedAt": "2026-05-12 10:20:00"
}
]
}
```
后端新增对象建议:
- `CountryDashboardGameMetricQryCmd`
- `CountryDashboardGameMetricCO`
- `CountryDashboardGameMetricDTO`
- `CountryDashboardGameMetricService`
- `CountryDashboardGameMetricDAO`
- `CountryDashboardGameMetricDAO.xml`
## 预聚合表
### `country_dashboard_game_period_metric`
页面直接查询这张表。
```sql
CREATE TABLE IF NOT EXISTS `country_dashboard_game_period_metric` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_type` varchar(16) NOT NULL COMMENT '周期类型:DAY/WEEK/MONTH/ALL',
`period_key` varchar(32) NOT NULL COMMENT '周期键',
`stat_timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '统计时区',
`period_name` varchar(64) NOT NULL DEFAULT '' COMMENT '周期名称',
`period_start_date` date DEFAULT NULL COMMENT '周期开始日期',
`period_end_date` date DEFAULT NULL COMMENT '周期结束日期,开区间',
`sys_origin` varchar(32) NOT NULL DEFAULT '' COMMENT '系统来源',
`country_code` varchar(64) NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家码',
`country_name` varchar(128) NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家名称',
`game_provider` varchar(32) NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏厂商',
`game_id` varchar(64) NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏ID',
`game_name` varchar(128) NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏名称',
`consume_amount` decimal(20,2) NOT NULL DEFAULT 0.00 COMMENT '用户消耗',
`payout_amount` decimal(20,2) NOT NULL DEFAULT 0.00 COMMENT '用户返奖',
`profit_amount` decimal(20,2) NOT NULL DEFAULT 0.00 COMMENT '平台收益',
`user_count` bigint NOT NULL DEFAULT 0 COMMENT '去重游戏用户数',
`order_count` bigint NOT NULL DEFAULT 0 COMMENT '流水笔数',
`refreshed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近刷新时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_game_period` (`period_type`, `period_key`, `stat_timezone`, `sys_origin`, `country_code`, `game_provider`, `game_id`),
KEY `idx_game_period_query` (`sys_origin`, `stat_timezone`, `period_type`, `period_key`, `country_code`),
KEY `idx_game_lookup` (`sys_origin`, `stat_timezone`, `game_provider`, `game_id`, `period_type`, `period_key`),
KEY `idx_game_period_sort` (`sys_origin`, `stat_timezone`, `period_type`, `period_key`, `consume_amount`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='国家数据大屏游戏维度周期预聚合';
```
### `country_dashboard_game_period_user_metric`
用于 `WEEK/MONTH/ALL` 去重用户数,避免直接把日用户数相加导致重复。
```sql
CREATE TABLE IF NOT EXISTS `country_dashboard_game_period_user_metric` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_type` varchar(16) NOT NULL COMMENT '周期类型:DAY/WEEK/MONTH/ALL',
`period_key` varchar(32) NOT NULL COMMENT '周期键',
`stat_timezone` varchar(64) NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '统计时区',
`period_name` varchar(64) NOT NULL DEFAULT '' COMMENT '周期名称',
`period_start_date` date DEFAULT NULL COMMENT '周期开始日期',
`period_end_date` date DEFAULT NULL COMMENT '周期结束日期,开区间',
`sys_origin` varchar(32) NOT NULL DEFAULT '' COMMENT '系统来源',
`country_code` varchar(64) NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家码',
`country_name` varchar(128) NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家名称',
`game_provider` varchar(32) NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏厂商',
`game_id` varchar(64) NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_game_period_user` (`period_type`, `period_key`, `stat_timezone`, `sys_origin`, `country_code`, `game_provider`, `game_id`, `user_id`),
KEY `idx_game_user_count` (`period_type`, `period_key`, `stat_timezone`, `sys_origin`, `game_provider`, `game_id`, `country_code`),
KEY `idx_game_user_lookup` (`sys_origin`, `stat_timezone`, `user_id`, `game_provider`, `game_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='国家数据大屏游戏维度周期用户去重明细';
```
## 数据来源和游戏识别
以钱包流水作为财务主口径:
- `wallet_gold_asset_record_1..12.type = 1`:消耗
- `wallet_gold_asset_record_1..12.type = 0`:返奖
- 金额沿用现有大屏口径:`penny_amount / 100.00`
- `LUCKY_GIFT``LUCKY_GIFT_GOLD_REWARD` 继续归属幸运礼物模块,不并入游戏数据表。
游戏维度来源:
| 来源 | 识别方式 | 名称补全 |
| --- | --- | --- |
| 百顺 | 钱包 `event_id = baishun_order_idempotency.wallet_event_id`,取 `vendor_game_id`;兜底解析 `event_type = BAISHUN_GAME_{gameId}` | `baishun_game_catalog.name` |
| 统一三方游戏 | 钱包 `event_id = CONCAT('GAME_OPEN:', game_open_callback_log.order_id)`,取 `game_open_callback_log.game_id`;兜底解析 `event_type = GAME_OPEN_{gameId}` | `lingxian_game_catalog.name``sys_game_list_config.name` |
| Lucky Box | `game_lucky_box_count` 固定为 `gameProvider=INTERNAL, gameId=LUCKY_BOX` | 固定名称 `Lucky Box` |
| 其他站内游戏 | 按 `event_type` 映射稳定 `gameId`,例如 `FRUIT_GAME/GAME_FRUIT_* -> FRUIT_GAME``LUDO_* -> LUDO_GAME` | 代码内映射或后续配置表 |
如果某条流水无法识别具体游戏,落到:
- `gameProvider = UNKNOWN`
- `gameId = event_type`
- `gameName = event_type`
这样可以保证金额不丢失,同时便于后续补映射。
## 刷新和回填
新增刷新服务建议为 `CountryDashboardGameMetricService`
默认任务:
- 每 10 分钟刷新最近 1 天。
- 每天 00:30 刷新最近 8 天。
- 支持手动回填,单次最大 366 天,和现有国家大屏保持一致。
刷新逻辑:
1. 按 `sysOrigin``statTimezone` 计算周期窗口,转成存储时区时间窗口。
2. 扫描窗口内原始游戏流水,按 `period + country + gameProvider + gameId` 聚合金额和流水笔数。
3. 删除对应周期的 `country_dashboard_game_period_user_metric`,写入去重用户明细。
4. 从用户明细聚合 `user_count`
5. 删除并写入对应周期的 `country_dashboard_game_period_metric`
6. `ALL` 周期的金额从 `DAY` 周期表汇总,用户数从日级用户明细去重汇总。
刷新任务失败只影响游戏数据表,不影响现有国家大屏总览。
## 查询性能要求
页面查询只能访问 `country_dashboard_game_period_metric`,禁止在请求链路访问以下原始表:
- `likei_wallet.wallet_gold_asset_record_1..12`
- `game_open_callback_log`
- `baishun_order_idempotency`
- `game_lucky_box_count`
- 第三方游戏 catalog 表
分页查询必须满足:
- `sysOrigin` 必传。
- `periodType``statTimezone` 必须落到联合索引前缀。
- `DAY/WEEK/MONTH` 查询必须带日期范围,并转成 `period_key``period_start_date` 范围。
- `ALL` 只查 `period_type='ALL' AND period_key='ALL'`,不回退全历史实时扫描。
- `countryKeyword``gameKeyword` 只允许在已经命中周期范围之后过滤;大范围模糊搜索需要限制日期范围。
- 只允许白名单排序字段,避免动态 SQL 注入和无法使用索引的任意排序。
- `limit` 上限 200。
刷新任务扫描原始钱包分表时必须带:
- `sys_origin`
- `create_time` 毫秒闭开区间
- 游戏 `event_type` 白名单或前缀条件
现有 wallet 分表有 `idx_sorigin_etype_ctime(sys_origin,event_type,create_time)``idx_event(event_id)`,刷新 SQL 应优先利用这两个索引。不能对 `create_time` 列包函数做过滤,只能在 SELECT 或 GROUP BY 阶段转换时间。
## 实施步骤
1. 新增 SQL 迁移:创建 `country_dashboard_game_period_metric``country_dashboard_game_period_user_metric`
2. 新增后端 DTO/Cmd/CO/DAO/Service并在 `DatavRestController` 暴露 `/country-dashboard/game-metrics`
3. 新增刷新任务和手动回填入口,复用现有 `red-circle.country-dashboard.refresh-sys-origins``refresh-timezones` 配置。
4. 新 admin 新增接口方法 `countryDashboardGameMetrics`
5. `datav.vue` 增加“游戏数据”tab、游戏筛选项和分页表格。
6. 首次上线后先回填最近 8 天,再回填历史需要展示的范围。
## 验证方案
SQL 验证:
```sql
EXPLAIN
SELECT *
FROM country_dashboard_game_period_metric
WHERE sys_origin = 'LIKEI'
AND stat_timezone = 'Asia/Riyadh'
AND period_type = 'DAY'
AND period_key BETWEEN '2026-05-01' AND '2026-05-12'
ORDER BY consume_amount DESC
LIMIT 50;
```
预期使用 `idx_game_period_query``idx_game_period_sort`,不能全表扫描。
数据口径验证:
```sql
SELECT SUM(consume_amount), SUM(payout_amount)
FROM country_dashboard_game_period_metric
WHERE sys_origin = 'LIKEI'
AND stat_timezone = 'Asia/Riyadh'
AND period_type = 'DAY'
AND period_key = '2026-05-12';
```
对比现有 `country_dashboard_period_metric` 同日同国家汇总的 `game_total_flow/game_payout`,差异应只来自“无法识别但仍落 UNKNOWN”的映射归类不应出现金额丢失。
功能验证:
- 查询 DAY 日期范围,表格按日期、国家、游戏拆分。
- 查询 WEEK/MONTH用户数为周期内去重不是日用户数累加。
- 查询 ALL不触发实时原始流水扫描。
- 国家筛选、游戏厂商筛选、游戏关键字筛选、分页、排序都能返回稳定结果。
- 刷新任务失败时,现有数据大屏总览接口不受影响。

View File

@ -164,6 +164,7 @@ public class TeamBdMemberBillListQryExe {
TeamBdMemberCO result = new TeamBdMemberCO();
result.setMemberBillList(memberBillList);
result.setMemberCount(memberBillList.size());
result.setAgencyNumber(memberBillList.size());
result.setTotalSalary(totalSalary);
result.setTotalRecharge(totalRecharge);
result.setBillTitle(TeamBillCycleUtils.parseBillBelongToDateRangeStr(currentBillBelong));
@ -222,6 +223,8 @@ public class TeamBdMemberBillListQryExe {
TeamBdMemberCO result = new TeamBdMemberCO();
result.setMemberBillList(memberBillList);
result.setMemberCount(memberBillList.size());
result.setBdNumber(memberBillList.size());
result.setAgencyNumber(totalAgentCount);
result.setTotalSalary(totalSalary);
result.setTotalRecharge(totalRecharge);
result.setBillTitle(TeamBillCycleUtils.parseBillBelongToDateRangeStr(currentBillBelong));
@ -321,6 +324,8 @@ public class TeamBdMemberBillListQryExe {
TeamBdMemberCO result = new TeamBdMemberCO();
result.setMemberBillList(Collections.emptyList());
result.setMemberCount(0);
result.setAgencyNumber(0);
result.setBdNumber(0);
result.setTotalSalary(BigDecimal.ZERO);
result.setTotalRecharge(BigDecimal.ZERO);
result.setBillTitle(TeamBillCycleUtils.parseBillBelongToDateRangeStr(currentBillBelong));
@ -335,24 +340,45 @@ public class TeamBdMemberBillListQryExe {
@NotNull
private Map<Long, BigDecimal> getRechargeMap(Integer currentBillBelong, Set<Long> teamIds) {
if (CollectionUtils.isEmpty(teamIds)) {
return Collections.emptyMap();
}
Map<Long, List<TeamMember>> mapByTeamIds = teamMemberService.mapByTeamIds(teamIds);
if (Objects.isNull(mapByTeamIds) || mapByTeamIds.isEmpty()) {
return Collections.emptyMap();
}
List<Long> memberIds = mapByTeamIds.values().stream()
.flatMap(List::stream)
.map(TeamMember::getMemberId)
.toList();
if (CollectionUtils.isEmpty(memberIds)) {
return Collections.emptyMap();
}
LocalDateTime startTime = TeamBillCycleUtils.getBillBelongStartTime(currentBillBelong);
LocalDateTime endTime = TeamBillCycleUtils.getBillBelongEndTime(currentBillBelong);
ResultResponse<List<BatchUserRechargeStatisticsDTO>> response =
inAppPurchaseDetailsClient.batchUserRechargeStatistics(
memberIds,
SysOriginPlatformEnum.LIKEI.name(),
startTime,
endTime
);
List<BatchUserRechargeStatisticsDTO> body = response.getBody();
Map<Long, BigDecimal> rechargeMap = body.stream().collect(Collectors.toMap(BatchUserRechargeStatisticsDTO::getUserId, BatchUserRechargeStatisticsDTO::getTotalAmount, BigDecimal::add));
return rechargeMap;
try {
ResultResponse<List<BatchUserRechargeStatisticsDTO>> response =
inAppPurchaseDetailsClient.batchUserRechargeStatistics(
memberIds,
SysOriginPlatformEnum.LIKEI.name(),
startTime,
endTime
);
List<BatchUserRechargeStatisticsDTO> body = Optional.ofNullable(response)
.map(ResultResponse::getBody)
.orElse(Collections.emptyList());
if (CollectionUtils.isEmpty(body)) {
return Collections.emptyMap();
}
return body.stream().collect(Collectors.toMap(
BatchUserRechargeStatisticsDTO::getUserId,
BatchUserRechargeStatisticsDTO::getTotalAmount,
BigDecimal::add));
} catch (Exception ex) {
log.warn("Failed to query BD team recharge statistics, teamIds={}", teamIds, ex);
return Collections.emptyMap();
}
}
private Collection<? extends BusinessDevelopmentTeam> buildBdTeamVO(List<TeamMember> teamMembers) {

View File

@ -62,10 +62,11 @@ public class UserIdentityServiceImpl implements UserIdentityService {
.setSuperAdmin(isSuperAdmin)
.setAdmin(isValidAdmin)
.setBdLeader(ResponseAssert.requiredSuccess(bdTeamLeaderClient.check(userId)))
.setManager(isTeamManager)
.setManager(Boolean.FALSE)
.setYumiManager(isTeamManager)
.setAppManager(isManager)
;
}
}
@Override
public Boolean hasPermission(PropsStoreTypeQryCmd cmd) {

View File

@ -17,6 +17,10 @@ public class TeamBdMemberCO {
private Integer memberCount;
private Integer agencyNumber;
private Integer bdNumber;
private List<TeamBdMemberBillCO> memberBillList;
private BdHistoryCO bdHistoryCO;

View File

@ -55,10 +55,16 @@ public class UserIdentityVO implements Serializable {
private Boolean superAdmin;
/**
* 是否是经理
* 旧经理字段Yumi 经理身份不再使用.
*/
private Boolean manager;
/**
* 是否是 Yumi 经理.
*/
@JsonProperty("yumi_manager")
private Boolean yumiManager;
/**
* 是否是App管理员角色里的经理.
*/

View File

@ -51,9 +51,15 @@ public class UserHistoryIdentity extends TimestampBaseEntity {
private Boolean host;
/**
* 是否做过团队经理.
* 是否做过团队经理.
*/
@TableField("is_manager")
private Boolean manager;
/**
* 是否做过 Yumi 团队经理.
*/
@TableField("is_yumi_manager")
private Boolean yumiManager;
}

View File

@ -22,6 +22,11 @@ public interface UserHistoryIdentityService extends BaseService<UserHistoryIdent
* 保存身份=manager.
*/
void saveManager(Long userId);
/**
* 保存身份=yumi manager.
*/
void saveYumiManager(Long userId);
/**
* 保存身份=host.

View File

@ -43,6 +43,7 @@ public class UserHistoryIdentityServiceImpl extends
.setAgent(Boolean.FALSE)
.setHost(Boolean.FALSE)
.setManager(Boolean.FALSE)
.setYumiManager(Boolean.FALSE)
);
} catch (DuplicateKeyException ex) {
// ignore
@ -66,6 +67,31 @@ public class UserHistoryIdentityServiceImpl extends
.setAgent(Boolean.FALSE)
.setHost(Boolean.FALSE)
.setManager(Boolean.TRUE)
.setYumiManager(Boolean.FALSE)
);
} catch (DuplicateKeyException ex) {
// ignore
}
}
@Override
public void saveYumiManager(Long userId) {
if (exists(userId)) {
update()
.set(UserHistoryIdentity::getYumiManager, Boolean.TRUE)
.eq(UserHistoryIdentity::getId, userId)
.execute();
return;
}
try {
save(new UserHistoryIdentity()
.setId(userId)
.setBd(Boolean.FALSE)
.setAgent(Boolean.FALSE)
.setHost(Boolean.FALSE)
.setManager(Boolean.FALSE)
.setYumiManager(Boolean.TRUE)
);
} catch (DuplicateKeyException ex) {
// ignore
@ -88,6 +114,7 @@ public class UserHistoryIdentityServiceImpl extends
.setAgent(Boolean.FALSE)
.setHost(Boolean.TRUE)
.setManager(Boolean.FALSE)
.setYumiManager(Boolean.FALSE)
);
} catch (DuplicateKeyException ex) {
// ignore
@ -112,6 +139,7 @@ public class UserHistoryIdentityServiceImpl extends
.setAgent(Boolean.TRUE)
.setHost(Boolean.TRUE)
.setManager(Boolean.FALSE)
.setYumiManager(Boolean.FALSE)
);
} catch (DuplicateKeyException ex) {
// ignore
@ -174,6 +202,9 @@ public class UserHistoryIdentityServiceImpl extends
if (Objects.equals(identity.getManager(), Boolean.TRUE)) {
res.add("Manager");
}
if (Objects.equals(identity.getYumiManager(), Boolean.TRUE)) {
res.add("YumiManager");
}
return res;
}

View File

@ -62,4 +62,10 @@ public class UserHistoryIdentityEndpoint implements UserHistoryIdentityClientApi
historyIdentityClientService.saveManager(userId);
return ResultResponse.success();
}
@Override
public ResultResponse<Void> saveYumiManager(Long userId) {
historyIdentityClientService.saveYumiManager(userId);
return ResultResponse.success();
}
}

View File

@ -23,4 +23,6 @@ public interface UserHistoryIdentityClientService {
void saveBd(Long userId);
void saveManager(Long userId);
void saveYumiManager(Long userId);
}

View File

@ -55,4 +55,9 @@ public class UserHistoryIdentityClientServiceImpl implements UserHistoryIdentity
public void saveManager(Long userId) {
historyIdentityService.saveManager(userId);
}
@Override
public void saveYumiManager(Long userId) {
historyIdentityService.saveYumiManager(userId);
}
}

View File

@ -0,0 +1,16 @@
SET @table_schema = DATABASE();
SET @ddl = IF(
EXISTS(
SELECT 1
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @table_schema
AND TABLE_NAME = 'user_history_identity'
AND COLUMN_NAME = 'is_yumi_manager'
),
'SELECT 1',
"ALTER TABLE `user_history_identity` ADD COLUMN `is_yumi_manager` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否做过Yumi团队经理' AFTER `is_manager`"
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,52 @@
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `country_dashboard_game_period_metric` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '周期类型:DAY/WEEK/MONTH/ALL',
`period_key` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '周期键',
`stat_timezone` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '统计时区',
`period_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '周期名称',
`period_start_date` date DEFAULT NULL COMMENT '周期开始日期',
`period_end_date` date DEFAULT NULL COMMENT '周期结束日期,开区间',
`sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '系统来源',
`country_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家码',
`country_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家名称',
`game_provider` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏厂商',
`game_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏ID',
`game_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏名称',
`consume_amount` decimal(20,2) NOT NULL DEFAULT 0.00 COMMENT '用户消耗',
`payout_amount` decimal(20,2) NOT NULL DEFAULT 0.00 COMMENT '用户返奖',
`profit_amount` decimal(20,2) NOT NULL DEFAULT 0.00 COMMENT '平台收益',
`user_count` bigint NOT NULL DEFAULT 0 COMMENT '去重游戏用户数',
`order_count` bigint NOT NULL DEFAULT 0 COMMENT '流水笔数',
`refreshed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近刷新时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_game_period` (`sys_origin`, `stat_timezone`, `period_type`, `period_key`, `country_code`, `game_provider`, `game_id`),
KEY `idx_game_period_query` (`sys_origin`, `stat_timezone`, `period_type`, `period_key`, `country_code`),
KEY `idx_game_period_date` (`sys_origin`, `stat_timezone`, `period_type`, `period_start_date`, `period_end_date`),
KEY `idx_game_lookup` (`sys_origin`, `stat_timezone`, `game_provider`, `game_id`, `period_type`, `period_key`),
KEY `idx_game_period_sort` (`sys_origin`, `stat_timezone`, `period_type`, `period_key`, `consume_amount`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='国家数据大屏游戏维度周期预聚合';
CREATE TABLE IF NOT EXISTS `country_dashboard_game_period_user_metric` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`period_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '周期类型:DAY/WEEK/MONTH/ALL',
`period_key` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '周期键',
`stat_timezone` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'Asia/Riyadh' COMMENT '统计时区',
`period_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '周期名称',
`period_start_date` date DEFAULT NULL COMMENT '周期开始日期',
`period_end_date` date DEFAULT NULL COMMENT '周期结束日期,开区间',
`sys_origin` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '系统来源',
`country_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家码',
`country_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '国家名称',
`game_provider` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏厂商',
`game_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNKNOWN' COMMENT '游戏ID',
`user_id` bigint NOT NULL COMMENT '用户ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_game_period_user` (`period_type`, `period_key`, `stat_timezone`, `sys_origin`, `country_code`, `game_provider`, `game_id`, `user_id`),
KEY `idx_game_user_count` (`period_type`, `period_key`, `stat_timezone`, `sys_origin`, `game_provider`, `game_id`, `country_code`),
KEY `idx_game_user_lookup` (`sys_origin`, `stat_timezone`, `user_id`, `game_provider`, `game_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='国家数据大屏游戏维度周期用户去重明细';