新BI系统
This commit is contained in:
parent
4060aee8af
commit
1409529f10
11
Makefile
11
Makefile
@ -7,7 +7,7 @@ ADMIN_CONFIG ?= configs/config.yaml
|
|||||||
DEV_RUN_SERVICES ?= all
|
DEV_RUN_SERVICES ?= all
|
||||||
DEV_RUN_SERVICE_LIST := $(strip $(if $(filter mysql redis,$(SERVICE_ID)),,$(if $(SERVICE_ID),$(SERVICE_ID),$(DEV_RUN_SERVICES))))
|
DEV_RUN_SERVICE_LIST := $(strip $(if $(filter mysql redis,$(SERVICE_ID)),,$(if $(SERVICE_ID),$(SERVICE_ID),$(DEV_RUN_SERVICES))))
|
||||||
|
|
||||||
.PHONY: proto vet test build run admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
|
.PHONY: proto vet test build run admin admin-up admin-deps admin-bootstrap admin-test admin-build up db-init databi-real-flow-check rebuild restart stop logs ps addr down up-service check-service resolve-compose-conflicts $(SERVICE_ALIASES)
|
||||||
|
|
||||||
# `proto` 负责把独立 api module 内的 .proto 重新生成成 Go 代码。
|
# `proto` 负责把独立 api module 内的 .proto 重新生成成 Go 代码。
|
||||||
proto:
|
proto:
|
||||||
@ -48,6 +48,7 @@ build:
|
|||||||
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
|
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
|
||||||
run:
|
run:
|
||||||
./scripts/resolve-compose-container-conflicts.sh
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
|
./scripts/prepare-local-mysql-initdb.sh
|
||||||
./scripts/prepare-local-rocketmq-config.sh
|
./scripts/prepare-local-rocketmq-config.sh
|
||||||
ROCKETMQ_BROKER_CONFIG=./tmp/rocketmq/broker.local.conf docker compose up -d mysql redis rocketmq-namesrv rocketmq-broker
|
ROCKETMQ_BROKER_CONFIG=./tmp/rocketmq/broker.local.conf docker compose up -d mysql redis rocketmq-namesrv rocketmq-broker
|
||||||
./scripts/apply-local-mysql-initdb.sh
|
./scripts/apply-local-mysql-initdb.sh
|
||||||
@ -69,6 +70,7 @@ admin-up: admin-deps admin
|
|||||||
# `admin-deps` 拉起后台本地运行所需的 MySQL、Redis 和 user-service。
|
# `admin-deps` 拉起后台本地运行所需的 MySQL、Redis 和 user-service。
|
||||||
admin-deps:
|
admin-deps:
|
||||||
./scripts/resolve-compose-container-conflicts.sh
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
|
./scripts/prepare-local-mysql-initdb.sh
|
||||||
docker compose up -d mysql redis
|
docker compose up -d mysql redis
|
||||||
./scripts/apply-local-mysql-initdb.sh
|
./scripts/apply-local-mysql-initdb.sh
|
||||||
./scripts/apply-local-rocketmq-topics.sh
|
./scripts/apply-local-rocketmq-topics.sh
|
||||||
@ -92,6 +94,7 @@ admin-build:
|
|||||||
# 完整 up 结束后自动启动 admin 前台进程。
|
# 完整 up 结束后自动启动 admin 前台进程。
|
||||||
up:
|
up:
|
||||||
./scripts/resolve-compose-container-conflicts.sh
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
|
./scripts/prepare-local-mysql-initdb.sh
|
||||||
@if [ -z "$(SERVICE_ID)" ]; then \
|
@if [ -z "$(SERVICE_ID)" ]; then \
|
||||||
docker compose up -d mysql redis; \
|
docker compose up -d mysql redis; \
|
||||||
./scripts/apply-local-mysql-initdb.sh; \
|
./scripts/apply-local-mysql-initdb.sh; \
|
||||||
@ -114,9 +117,14 @@ db-init:
|
|||||||
./scripts/resolve-compose-container-conflicts.sh mysql
|
./scripts/resolve-compose-container-conflicts.sh mysql
|
||||||
./scripts/apply-local-mysql-initdb.sh
|
./scripts/apply-local-mysql-initdb.sh
|
||||||
|
|
||||||
|
# `databi-real-flow-check` 用真实 owner outbox 重放并审计 Databi 口径;充值允许走本地模拟回调,其余必须先通过真实业务流程产生事实。
|
||||||
|
databi-real-flow-check:
|
||||||
|
./scripts/databi-real-flow-check.sh
|
||||||
|
|
||||||
# `rebuild` 重新构建并启动单个本地服务。
|
# `rebuild` 重新构建并启动单个本地服务。
|
||||||
rebuild: check-service
|
rebuild: check-service
|
||||||
./scripts/resolve-compose-container-conflicts.sh
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
|
./scripts/prepare-local-mysql-initdb.sh
|
||||||
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
||||||
./scripts/apply-local-mysql-initdb.sh; \
|
./scripts/apply-local-mysql-initdb.sh; \
|
||||||
./scripts/apply-local-rocketmq-topics.sh; \
|
./scripts/apply-local-rocketmq-topics.sh; \
|
||||||
@ -178,6 +186,7 @@ resolve-compose-conflicts:
|
|||||||
# `up-service` 是 `up SERVICE=...` 的内部实现。
|
# `up-service` 是 `up SERVICE=...` 的内部实现。
|
||||||
up-service: check-service
|
up-service: check-service
|
||||||
./scripts/resolve-compose-container-conflicts.sh
|
./scripts/resolve-compose-container-conflicts.sh
|
||||||
|
./scripts/prepare-local-mysql-initdb.sh
|
||||||
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
||||||
./scripts/apply-local-mysql-initdb.sh; \
|
./scripts/apply-local-mysql-initdb.sh; \
|
||||||
./scripts/apply-local-rocketmq-topics.sh; \
|
./scripts/apply-local-rocketmq-topics.sh; \
|
||||||
|
|||||||
@ -310,16 +310,16 @@ services:
|
|||||||
- ./services/user-service/deploy/mysql/initdb/001_user_service.sql:/docker-entrypoint-initdb.d/002_user_service.sql:ro
|
- ./services/user-service/deploy/mysql/initdb/001_user_service.sql:/docker-entrypoint-initdb.d/002_user_service.sql:ro
|
||||||
- ./services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql:/docker-entrypoint-initdb.d/003_wallet_service.sql:ro
|
- ./services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql:/docker-entrypoint-initdb.d/003_wallet_service.sql:ro
|
||||||
- ./services/activity-service/deploy/mysql/initdb/001_activity_service.sql:/docker-entrypoint-initdb.d/004_activity_service.sql:ro
|
- ./services/activity-service/deploy/mysql/initdb/001_activity_service.sql:/docker-entrypoint-initdb.d/004_activity_service.sql:ro
|
||||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
|
- ./tmp/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
|
||||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
|
- ./tmp/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.sql:ro
|
||||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
|
- ./tmp/mysql/initdb/007_resource_group_wallet_asset_items.sql:/docker-entrypoint-initdb.d/007_resource_group_wallet_asset_items.sql:ro
|
||||||
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
|
- ./services/cron-service/deploy/mysql/initdb/001_cron_service.sql:/docker-entrypoint-initdb.d/009_cron_service.sql:ro
|
||||||
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
|
- ./services/game-service/deploy/mysql/initdb/001_game_service.sql:/docker-entrypoint-initdb.d/010_game_service.sql:ro
|
||||||
- ./services/game-service/deploy/mysql/initdata/001_self_games.sql:/docker-entrypoint-initdb.d/010_game_service_data.sql:ro
|
- ./services/game-service/deploy/mysql/initdata/001_self_games.sql:/docker-entrypoint-initdb.d/010_game_service_data.sql:ro
|
||||||
- ./services/robot-service/deploy/mysql/initdb/001_robot_service.sql:/docker-entrypoint-initdb.d/011_robot_service.sql:ro
|
- ./services/robot-service/deploy/mysql/initdb/001_robot_service.sql:/docker-entrypoint-initdb.d/011_robot_service.sql:ro
|
||||||
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
|
- ./services/notice-service/deploy/mysql/initdb/001_notice_service.sql:/docker-entrypoint-initdb.d/011_notice_service.sql:ro
|
||||||
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
|
- ./services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql:/docker-entrypoint-initdb.d/012_statistics_service.sql:ro
|
||||||
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
- ./tmp/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
@ -29,6 +29,7 @@ SQL_FILES=(
|
|||||||
|
|
||||||
# Keep MySQL as the only required dependency for this script. Business services
|
# Keep MySQL as the only required dependency for this script. Business services
|
||||||
# are started after schema/grants are known to match the current repo.
|
# are started after schema/grants are known to match the current repo.
|
||||||
|
"${SCRIPT_DIR}/prepare-local-mysql-initdb.sh"
|
||||||
"${SCRIPT_DIR}/resolve-compose-container-conflicts.sh" mysql
|
"${SCRIPT_DIR}/resolve-compose-container-conflicts.sh" mysql
|
||||||
docker compose up -d mysql >/dev/null
|
docker compose up -d mysql >/dev/null
|
||||||
|
|
||||||
@ -49,8 +50,11 @@ fi
|
|||||||
|
|
||||||
for sql_file in "${SQL_FILES[@]}"; do
|
for sql_file in "${SQL_FILES[@]}"; do
|
||||||
if [[ ! -f "${sql_file}" ]]; then
|
if [[ ! -f "${sql_file}" ]]; then
|
||||||
printf 'missing mysql init file: %s\n' "${sql_file}" >&2
|
# Platform/admin seed files may live in a sibling deploy repository that
|
||||||
exit 1
|
# is absent on lightweight app-backend checkouts. Treat those inputs as
|
||||||
|
# optional so local service startup can still exercise the owned schemas.
|
||||||
|
printf 'skipping mysql init file because it is absent or not a regular file: %s\n' "${sql_file}"
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf 'applying mysql init file: %s\n' "${sql_file}"
|
printf 'applying mysql init file: %s\n' "${sql_file}"
|
||||||
|
|||||||
51
scripts/databi-real-flow-check.sh
Executable file
51
scripts/databi-real-flow-check.sh
Executable file
@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
APP_CODE="${APP_CODE:-lalu}"
|
||||||
|
STAT_TZ="${STAT_TZ:-Asia/Shanghai}"
|
||||||
|
START_DAY="${START_DAY:-$(TZ=Asia/Shanghai date +%F)}"
|
||||||
|
END_DAY="${END_DAY:-$START_DAY}"
|
||||||
|
|
||||||
|
STATISTICS_MYSQL_DSN="${STATISTICS_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_statistics?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
|
||||||
|
USER_MYSQL_DSN="${USER_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
|
||||||
|
WALLET_MYSQL_DSN="${WALLET_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
|
||||||
|
ROOM_MYSQL_DSN="${ROOM_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
|
||||||
|
GAME_MYSQL_DSN="${GAME_MYSQL_DSN:-hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_game?parseTime=true&charset=utf8mb4&loc=UTC&multiStatements=true}"
|
||||||
|
|
||||||
|
export APP_CODE STAT_TZ START_DAY END_DAY
|
||||||
|
export STATISTICS_MYSQL_DSN USER_MYSQL_DSN WALLET_MYSQL_DSN ROOM_MYSQL_DSN GAME_MYSQL_DSN
|
||||||
|
|
||||||
|
echo "Databi real-flow check"
|
||||||
|
echo " app=${APP_CODE}"
|
||||||
|
echo " stat_tz=${STAT_TZ}"
|
||||||
|
echo " days=${START_DAY}..${END_DAY}"
|
||||||
|
echo
|
||||||
|
echo "Before running this check, exercise the real local business flows for the selected days."
|
||||||
|
echo "Recharge is the only metric that may use a local simulated recharge/payment callback."
|
||||||
|
echo
|
||||||
|
|
||||||
|
go run ./services/statistics-service/cmd/replay-stat-tz \
|
||||||
|
-app "${APP_CODE}" \
|
||||||
|
-timezone "${STAT_TZ}" \
|
||||||
|
-start-day "${START_DAY}" \
|
||||||
|
-end-day "${END_DAY}" \
|
||||||
|
-apply \
|
||||||
|
-statistics-dsn "${STATISTICS_MYSQL_DSN}" \
|
||||||
|
-user-dsn "${USER_MYSQL_DSN}" \
|
||||||
|
-wallet-dsn "${WALLET_MYSQL_DSN}" \
|
||||||
|
-room-dsn "${ROOM_MYSQL_DSN}" \
|
||||||
|
-game-dsn "${GAME_MYSQL_DSN}"
|
||||||
|
|
||||||
|
go run ./services/statistics-service/cmd/databi-real-flow-audit \
|
||||||
|
-app "${APP_CODE}" \
|
||||||
|
-timezone "${STAT_TZ}" \
|
||||||
|
-start-day "${START_DAY}" \
|
||||||
|
-end-day "${END_DAY}" \
|
||||||
|
-statistics-dsn "${STATISTICS_MYSQL_DSN}" \
|
||||||
|
-user-dsn "${USER_MYSQL_DSN}" \
|
||||||
|
-wallet-dsn "${WALLET_MYSQL_DSN}" \
|
||||||
|
-room-dsn "${ROOM_MYSQL_DSN}" \
|
||||||
|
-game-dsn "${GAME_MYSQL_DSN}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Databi real-flow check passed."
|
||||||
35
scripts/prepare-local-mysql-initdb.sh
Executable file
35
scripts/prepare-local-mysql-initdb.sh
Executable file
@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Docker creates a missing bind-mounted file source as a directory. MySQL's
|
||||||
|
# entrypoint then treats that directory as a SQL input and exits before the
|
||||||
|
# local database is usable. Keep optional platform SQL behind generated files:
|
||||||
|
# present sources are copied and executed, absent sources become harmless no-op
|
||||||
|
# SQL files so lightweight backend checkouts can still start.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
DEPLOY_PLATFORM_ROOT="${DEPLOY_PLATFORM_ROOT:-${PROJECT_ROOT}/../deploy-platform}"
|
||||||
|
LOCAL_INITDB_DIR="${PROJECT_ROOT}/tmp/mysql/initdb"
|
||||||
|
|
||||||
|
OPTIONAL_SQL_FILES=(
|
||||||
|
"005_utf8mb4_chinese_support.sql"
|
||||||
|
"006_admin_database.sql"
|
||||||
|
"007_resource_group_wallet_asset_items.sql"
|
||||||
|
"999_local_grants.sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir -p "${LOCAL_INITDB_DIR}"
|
||||||
|
|
||||||
|
for sql_name in "${OPTIONAL_SQL_FILES[@]}"; do
|
||||||
|
source_file="${DEPLOY_PLATFORM_ROOT}/deploy/mysql/initdb/${sql_name}"
|
||||||
|
target_file="${LOCAL_INITDB_DIR}/${sql_name}"
|
||||||
|
|
||||||
|
if [[ -f "${source_file}" ]]; then
|
||||||
|
cp "${source_file}" "${target_file}"
|
||||||
|
printf 'prepared optional mysql init file: %s\n' "${source_file}"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf -- '-- optional mysql init file is absent locally: %s\n' "${source_file}" > "${target_file}"
|
||||||
|
printf 'prepared empty optional mysql init file: %s\n' "${source_file}"
|
||||||
|
done
|
||||||
26
server/admin/migrations/063_money_permission.sql
Normal file
26
server/admin/migrations/063_money_permission.sql
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- 财务系统是独立子系统入口,不挂后台左侧菜单;这里只登记访问权限,供 /money 独立入口校验当前账号是否可进入。
|
||||||
|
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||||
|
|
||||||
|
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||||
|
('财务系统查看', 'money:view', 'menu', '允许访问独立财务子系统 /money', @now_ms, @now_ms)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
kind = VALUES(kind),
|
||||||
|
description = VALUES(description),
|
||||||
|
updated_at_ms = @now_ms;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||||
|
SELECT admin_role.id, admin_permission.id
|
||||||
|
FROM admin_roles admin_role
|
||||||
|
JOIN admin_permissions admin_permission
|
||||||
|
WHERE admin_role.code = 'platform-admin'
|
||||||
|
AND admin_permission.code = 'money:view';
|
||||||
|
|
||||||
|
INSERT IGNORE INTO admin_user_roles (user_id, role_id)
|
||||||
|
SELECT admin_user.id, admin_role.id
|
||||||
|
FROM admin_users admin_user
|
||||||
|
JOIN admin_roles admin_role
|
||||||
|
WHERE admin_user.username IN ('admin', 'hyappadmin')
|
||||||
|
AND admin_role.code = 'platform-admin';
|
||||||
@ -33,9 +33,18 @@ type appDayAgg struct {
|
|||||||
rechargeUSDMinor int64
|
rechargeUSDMinor int64
|
||||||
newUserRechargeUSDMinor int64
|
newUserRechargeUSDMinor int64
|
||||||
coinSellerRechargeUSDMinor int64
|
coinSellerRechargeUSDMinor int64
|
||||||
|
coinSellerStockCoin int64
|
||||||
mifaPayRechargeUSDMinor int64
|
mifaPayRechargeUSDMinor int64
|
||||||
googleRechargeUSDMinor int64
|
googleRechargeUSDMinor int64
|
||||||
coinSellerTransferCoin int64
|
coinSellerTransferCoin int64
|
||||||
|
coinTotal int64
|
||||||
|
consumedCoin int64
|
||||||
|
platformGrantCoin int64
|
||||||
|
manualGrantCoin int64
|
||||||
|
salaryUSDMinor int64
|
||||||
|
salaryTransferCoin int64
|
||||||
|
micOnlineMS int64
|
||||||
|
micOnlineUsers int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type userRegistrationRow struct {
|
type userRegistrationRow struct {
|
||||||
@ -59,12 +68,20 @@ type activeRow struct {
|
|||||||
firstMS int64
|
firstMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type micRow struct {
|
||||||
|
day string
|
||||||
|
userID int64
|
||||||
|
dim countryRegion
|
||||||
|
micOnlineMS int64
|
||||||
|
}
|
||||||
|
|
||||||
type rebuildState struct {
|
type rebuildState struct {
|
||||||
appDays map[appDayKey]*appDayAgg
|
appDays map[appDayKey]*appDayAgg
|
||||||
registrations []userRegistrationRow
|
registrations []userRegistrationRow
|
||||||
registeredByUserDay map[string]struct{}
|
registeredByUserDay map[string]struct{}
|
||||||
payersByKey map[string]payerRow
|
payersByKey map[string]payerRow
|
||||||
activeByKey map[string]activeRow
|
activeByKey map[string]activeRow
|
||||||
|
micByKey map[string]micRow
|
||||||
userIDs map[int64]struct{}
|
userIDs map[int64]struct{}
|
||||||
userDims map[int64]countryRegion
|
userDims map[int64]countryRegion
|
||||||
knownUsers map[int64]struct{}
|
knownUsers map[int64]struct{}
|
||||||
@ -113,6 +130,7 @@ func main() {
|
|||||||
registeredByUserDay: map[string]struct{}{},
|
registeredByUserDay: map[string]struct{}{},
|
||||||
payersByKey: map[string]payerRow{},
|
payersByKey: map[string]payerRow{},
|
||||||
activeByKey: map[string]activeRow{},
|
activeByKey: map[string]activeRow{},
|
||||||
|
micByKey: map[string]micRow{},
|
||||||
userIDs: map[int64]struct{}{},
|
userIDs: map[int64]struct{}{},
|
||||||
userDims: map[int64]countryRegion{},
|
userDims: map[int64]countryRegion{},
|
||||||
knownUsers: map[int64]struct{}{},
|
knownUsers: map[int64]struct{}{},
|
||||||
@ -127,6 +145,9 @@ func main() {
|
|||||||
if err := collectRechargeUserIDs(ctx, walletDB, state, app, windowStartMS, windowEndMS); err != nil {
|
if err := collectRechargeUserIDs(ctx, walletDB, state, app, windowStartMS, windowEndMS); err != nil {
|
||||||
log.Fatalf("collect recharge ids: %v", err)
|
log.Fatalf("collect recharge ids: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := collectReportMetricUserIDs(ctx, walletDB, userDB, state, app, startDay, endDay, windowEndMS); err != nil {
|
||||||
|
log.Fatalf("collect report metric ids: %v", err)
|
||||||
|
}
|
||||||
if err := loadUserDims(ctx, userDB, state, app); err != nil {
|
if err := loadUserDims(ctx, userDB, state, app); err != nil {
|
||||||
log.Fatalf("load user dims: %v", err)
|
log.Fatalf("load user dims: %v", err)
|
||||||
}
|
}
|
||||||
@ -142,6 +163,15 @@ func main() {
|
|||||||
if err := rebuildCoinSellerStock(ctx, walletDB, state, app, windowStartMS, windowEndMS, loc); err != nil {
|
if err := rebuildCoinSellerStock(ctx, walletDB, state, app, windowStartMS, windowEndMS, loc); err != nil {
|
||||||
log.Fatalf("rebuild coin seller stock: %v", err)
|
log.Fatalf("rebuild coin seller stock: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := rebuildWalletReportMetrics(ctx, walletDB, state, app, startDay, endDay, windowStartMS, windowEndMS, loc); err != nil {
|
||||||
|
log.Fatalf("rebuild wallet report metrics: %v", err)
|
||||||
|
}
|
||||||
|
if err := rebuildSalary(ctx, walletDB, state, app, windowStartMS, windowEndMS, loc); err != nil {
|
||||||
|
log.Fatalf("rebuild salary: %v", err)
|
||||||
|
}
|
||||||
|
if err := rebuildMicStats(ctx, userDB, state, app, startDay, endDay); err != nil {
|
||||||
|
log.Fatalf("rebuild mic stats: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := stageAndMaybeApply(ctx, statsDB, state, app, statTZ, startDay, endDay, *applyFlag); err != nil {
|
if err := stageAndMaybeApply(ctx, statsDB, state, app, statTZ, startDay, endDay, *applyFlag); err != nil {
|
||||||
log.Fatalf("stage/apply: %v", err)
|
log.Fatalf("stage/apply: %v", err)
|
||||||
@ -310,6 +340,82 @@ func collectRechargeUserIDs(ctx context.Context, db *sql.DB, state *rebuildState
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectReportMetricUserIDs(ctx context.Context, walletDB *sql.DB, userDB *sql.DB, state *rebuildState, app string, startDay string, endDay string, windowEndMS int64) error {
|
||||||
|
queries := []struct {
|
||||||
|
db *sql.DB
|
||||||
|
query string
|
||||||
|
args []any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
db: walletDB,
|
||||||
|
query: `
|
||||||
|
SELECT DISTINCT e.user_id
|
||||||
|
FROM wallet_entries e
|
||||||
|
JOIN wallet_transactions wt ON wt.transaction_id = e.transaction_id
|
||||||
|
WHERE wt.app_code = ? AND e.asset_type IN ('COIN', 'COIN_SELLER_COIN', 'HOST_SALARY_USD', 'AGENCY_SALARY_USD') AND e.created_at_ms < ?
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT e.counterparty_user_id
|
||||||
|
FROM wallet_entries e
|
||||||
|
JOIN wallet_transactions wt ON wt.transaction_id = e.transaction_id
|
||||||
|
WHERE wt.app_code = ? AND wt.biz_type = 'salary_transfer_to_coin_seller'
|
||||||
|
AND e.asset_type = 'COIN_SELLER_COIN' AND e.counterparty_user_id > 0 AND e.created_at_ms < ?`,
|
||||||
|
args: []any{app, windowEndMS, app, windowEndMS},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: walletDB,
|
||||||
|
query: `
|
||||||
|
SELECT DISTINCT user_id
|
||||||
|
FROM wallet_accounts
|
||||||
|
WHERE app_code = ? AND asset_type = 'COIN' AND available_amount + frozen_amount <> 0`,
|
||||||
|
args: []any{app},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: walletDB,
|
||||||
|
query: `
|
||||||
|
SELECT DISTINCT user_id
|
||||||
|
FROM host_salary_settlement_records
|
||||||
|
WHERE app_code = ? AND status = 'succeeded' AND created_at_ms < ?`,
|
||||||
|
args: []any{app, windowEndMS},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: walletDB,
|
||||||
|
query: `
|
||||||
|
SELECT DISTINCT agency_owner_user_id
|
||||||
|
FROM host_salary_settlement_records
|
||||||
|
WHERE app_code = ? AND status = 'succeeded' AND agency_owner_user_id > 0 AND created_at_ms < ?`,
|
||||||
|
args: []any{app, windowEndMS},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
db: userDB,
|
||||||
|
query: `
|
||||||
|
SELECT DISTINCT user_id
|
||||||
|
FROM user_mic_daily_stats
|
||||||
|
WHERE app_code = ? AND stat_date BETWEEN ? AND ?`,
|
||||||
|
args: []any{app, startDay, endDay},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, item := range queries {
|
||||||
|
rows, err := item.db.QueryContext(ctx, item.query, item.args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var userID int64
|
||||||
|
if err := rows.Scan(&userID); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if userID > 0 {
|
||||||
|
state.userIDs[userID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func loadUserDims(ctx context.Context, db *sql.DB, state *rebuildState, app string) error {
|
func loadUserDims(ctx context.Context, db *sql.DB, state *rebuildState, app string) error {
|
||||||
userIDs := make([]int64, 0, len(state.userIDs))
|
userIDs := make([]int64, 0, len(state.userIDs))
|
||||||
for userID := range state.userIDs {
|
for userID := range state.userIDs {
|
||||||
@ -460,17 +566,17 @@ func rebuildWalletRecharge(ctx context.Context, db *sql.DB, state *rebuildState,
|
|||||||
func rebuildCoinSellerStock(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64, loc *time.Location) error {
|
func rebuildCoinSellerStock(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS, endMS int64, loc *time.Location) error {
|
||||||
// 币商进货是平台收到的 USDT 充值,按 seller 的国家/区域进入总充值,但不写入用户金币转账金额。
|
// 币商进货是平台收到的 USDT 充值,按 seller 的国家/区域进入总充值,但不写入用户金币转账金额。
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT seller_user_id, paid_amount_micro, created_at_ms
|
SELECT seller_user_id, paid_amount_micro, coin_amount, created_at_ms
|
||||||
FROM coin_seller_stock_records
|
FROM coin_seller_stock_records
|
||||||
WHERE app_code = ? AND counts_as_seller_recharge = 1 AND paid_amount_micro <> 0 AND created_at_ms >= ? AND created_at_ms < ?`,
|
WHERE app_code = ? AND counts_as_seller_recharge = 1 AND (paid_amount_micro <> 0 OR coin_amount <> 0) AND created_at_ms >= ? AND created_at_ms < ?`,
|
||||||
app, startMS, endMS)
|
app, startMS, endMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sellerUserID, amountMicro, createdAtMS int64
|
var sellerUserID, amountMicro, coinAmount, createdAtMS int64
|
||||||
if err := rows.Scan(&sellerUserID, &amountMicro, &createdAtMS); err != nil {
|
if err := rows.Scan(&sellerUserID, &amountMicro, &coinAmount, &createdAtMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
usdMinor := amountMicro / 10_000
|
usdMinor := amountMicro / 10_000
|
||||||
@ -479,10 +585,217 @@ func rebuildCoinSellerStock(ctx context.Context, db *sql.DB, state *rebuildState
|
|||||||
agg := state.ensureAgg(day, dim)
|
agg := state.ensureAgg(day, dim)
|
||||||
agg.rechargeUSDMinor += usdMinor
|
agg.rechargeUSDMinor += usdMinor
|
||||||
agg.coinSellerRechargeUSDMinor += usdMinor
|
agg.coinSellerRechargeUSDMinor += usdMinor
|
||||||
|
agg.coinSellerStockCoin += coinAmount
|
||||||
}
|
}
|
||||||
return rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rebuildWalletReportMetrics(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, startMS int64, endMS int64, loc *time.Location) error {
|
||||||
|
if err := rebuildCoinTotals(ctx, db, state, app, startDay, endDay, endMS, loc); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT e.user_id, e.counterparty_user_id, e.asset_type, e.available_delta + e.frozen_delta AS amount_delta, wt.biz_type, e.created_at_ms
|
||||||
|
FROM wallet_entries e
|
||||||
|
JOIN wallet_transactions wt ON wt.transaction_id = e.transaction_id
|
||||||
|
WHERE wt.app_code = ? AND e.asset_type IN ('COIN', 'COIN_SELLER_COIN') AND e.created_at_ms >= ? AND e.created_at_ms < ?`,
|
||||||
|
app, startMS, endMS)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var userID, counterpartyUserID, amountDelta, createdAtMS int64
|
||||||
|
var assetType, bizType string
|
||||||
|
if err := rows.Scan(&userID, &counterpartyUserID, &assetType, &amountDelta, &bizType, &createdAtMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
day := dayFromMS(createdAtMS, loc)
|
||||||
|
normalizedBiz := strings.ToLower(strings.TrimSpace(bizType))
|
||||||
|
normalizedAsset := strings.ToUpper(strings.TrimSpace(assetType))
|
||||||
|
if amountDelta > 0 && normalizedBiz == "salary_transfer_to_coin_seller" && normalizedAsset == "COIN_SELLER_COIN" {
|
||||||
|
dimensionUserID := userID
|
||||||
|
if counterpartyUserID > 0 {
|
||||||
|
dimensionUserID = counterpartyUserID
|
||||||
|
}
|
||||||
|
agg := state.ensureAgg(day, state.userDims[dimensionUserID])
|
||||||
|
agg.salaryTransferCoin += amountDelta
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if normalizedAsset != "COIN" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dim := state.userDims[userID]
|
||||||
|
agg := state.ensureAgg(day, dim)
|
||||||
|
switch {
|
||||||
|
case amountDelta < 0 && isConsumedCoinBizType(normalizedBiz):
|
||||||
|
agg.consumedCoin += -amountDelta
|
||||||
|
case amountDelta > 0 && normalizedBiz == "manual_credit":
|
||||||
|
agg.manualGrantCoin += amountDelta
|
||||||
|
case amountDelta > 0 && isPlatformGrantBizType(normalizedBiz):
|
||||||
|
agg.platformGrantCoin += amountDelta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type coinBalanceEntry struct {
|
||||||
|
userID int64
|
||||||
|
balance int64
|
||||||
|
createdAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func rebuildCoinTotals(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string, windowEndMS int64, loc *time.Location) error {
|
||||||
|
dayEnds, err := dayEndPoints(startDay, endDay, loc)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT e.user_id, e.available_after + e.frozen_after AS balance_after, e.created_at_ms
|
||||||
|
FROM wallet_entries e
|
||||||
|
JOIN wallet_transactions wt ON wt.transaction_id = e.transaction_id
|
||||||
|
WHERE wt.app_code = ? AND e.asset_type = 'COIN' AND e.created_at_ms < ?
|
||||||
|
ORDER BY e.user_id ASC, e.created_at_ms ASC, e.entry_id ASC`,
|
||||||
|
app, windowEndMS)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var currentUserID int64
|
||||||
|
entries := []coinBalanceEntry{}
|
||||||
|
flush := func() {
|
||||||
|
if currentUserID <= 0 || len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dim := state.userDims[currentUserID]
|
||||||
|
index := 0
|
||||||
|
balance := int64(0)
|
||||||
|
hasBalance := false
|
||||||
|
for _, point := range dayEnds {
|
||||||
|
for index < len(entries) && entries[index].createdAtMS < point.endMS {
|
||||||
|
balance = entries[index].balance
|
||||||
|
hasBalance = true
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
if hasBalance {
|
||||||
|
state.ensureAgg(point.day, dim).coinTotal += balance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var entry coinBalanceEntry
|
||||||
|
if err := rows.Scan(&entry.userID, &entry.balance, &entry.createdAtMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if currentUserID != 0 && entry.userID != currentUserID {
|
||||||
|
flush()
|
||||||
|
entries = entries[:0]
|
||||||
|
}
|
||||||
|
currentUserID = entry.userID
|
||||||
|
entries = append(entries, entry)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rebuildSalary(ctx context.Context, db *sql.DB, state *rebuildState, app string, startMS int64, endMS int64, loc *time.Location) error {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT user_id, agency_owner_user_id, host_salary_usd_minor_delta, agency_salary_usd_minor_delta, created_at_ms
|
||||||
|
FROM host_salary_settlement_records
|
||||||
|
WHERE app_code = ? AND status = 'succeeded' AND created_at_ms >= ? AND created_at_ms < ?`,
|
||||||
|
app, startMS, endMS)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var hostUserID, agencyUserID, hostSalary, agencySalary, createdAtMS int64
|
||||||
|
if err := rows.Scan(&hostUserID, &agencyUserID, &hostSalary, &agencySalary, &createdAtMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
day := dayFromMS(createdAtMS, loc)
|
||||||
|
if hostSalary > 0 {
|
||||||
|
state.ensureAgg(day, state.userDims[hostUserID]).salaryUSDMinor += hostSalary
|
||||||
|
}
|
||||||
|
if agencySalary > 0 && agencyUserID > 0 {
|
||||||
|
state.ensureAgg(day, state.userDims[agencyUserID]).salaryUSDMinor += agencySalary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func rebuildMicStats(ctx context.Context, db *sql.DB, state *rebuildState, app string, startDay string, endDay string) error {
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT DATE_FORMAT(stat_date, '%Y-%m-%d'), user_id, mic_online_ms
|
||||||
|
FROM user_mic_daily_stats
|
||||||
|
WHERE app_code = ? AND stat_date BETWEEN ? AND ?`,
|
||||||
|
app, startDay, endDay)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var day string
|
||||||
|
var userID, micOnlineMS int64
|
||||||
|
if err := rows.Scan(&day, &userID, &micOnlineMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if micOnlineMS <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dim := state.userDims[userID]
|
||||||
|
state.addMic(day, userID, dim, micOnlineMS)
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type dayEndPoint struct {
|
||||||
|
day string
|
||||||
|
endMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func dayEndPoints(startDay string, endDay string, loc *time.Location) ([]dayEndPoint, error) {
|
||||||
|
start, err := parseDay(startDay, loc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
end, err := parseDay(endDay, loc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
points := []dayEndPoint{}
|
||||||
|
for cursor := start; !cursor.After(end); cursor = cursor.AddDate(0, 0, 1) {
|
||||||
|
points = append(points, dayEndPoint{
|
||||||
|
day: formatDay(cursor),
|
||||||
|
endMS: cursor.AddDate(0, 0, 1).UTC().UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConsumedCoinBizType(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "gift_debit", "direct_gift_debit", "vip_purchase", "resource_shop_purchase",
|
||||||
|
"red_packet_create", "game_debit", "wheel_draw_debit", "cp_breakup_fee":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlatformGrantBizType(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "task_reward", "lucky_gift_reward", "wheel_reward", "room_turnover_reward",
|
||||||
|
"invite_activity_reward", "agency_opening_reward":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *rebuildState) ensureAgg(day string, dim countryRegion) *appDayAgg {
|
func (s *rebuildState) ensureAgg(day string, dim countryRegion) *appDayAgg {
|
||||||
key := appDayKey{day: day, countryID: dim.countryID, regionID: dim.regionID}
|
key := appDayKey{day: day, countryID: dim.countryID, regionID: dim.regionID}
|
||||||
if s.appDays[key] == nil {
|
if s.appDays[key] == nil {
|
||||||
@ -517,6 +830,21 @@ func (s *rebuildState) addActive(day string, userID int64, dim countryRegion, fi
|
|||||||
s.ensureAgg(day, dim).activeUsers++
|
s.ensureAgg(day, dim).activeUsers++
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *rebuildState) addMic(day string, userID int64, dim countryRegion, micOnlineMS int64) {
|
||||||
|
key := userDayKey(day, userID)
|
||||||
|
if existing, exists := s.micByKey[key]; exists {
|
||||||
|
agg := s.ensureAgg(day, existing.dim)
|
||||||
|
agg.micOnlineMS += micOnlineMS - existing.micOnlineMS
|
||||||
|
existing.micOnlineMS = micOnlineMS
|
||||||
|
s.micByKey[key] = existing
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.micByKey[key] = micRow{day: day, userID: userID, dim: dim, micOnlineMS: micOnlineMS}
|
||||||
|
agg := s.ensureAgg(day, dim)
|
||||||
|
agg.micOnlineMS += micOnlineMS
|
||||||
|
agg.micOnlineUsers++
|
||||||
|
}
|
||||||
|
|
||||||
func (s *rebuildState) userRegisteredOn(userID int64, day string) bool {
|
func (s *rebuildState) userRegisteredOn(userID int64, day string) bool {
|
||||||
_, ok := s.registeredByUserDay[userDayKey(day, userID)]
|
_, ok := s.registeredByUserDay[userDayKey(day, userID)]
|
||||||
return ok
|
return ok
|
||||||
@ -556,7 +884,10 @@ func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, ap
|
|||||||
SET new_users = 0, active_users = 0, paid_users = 0,
|
SET new_users = 0, active_users = 0, paid_users = 0,
|
||||||
recharge_usd_minor = 0, new_user_recharge_usd_minor = 0,
|
recharge_usd_minor = 0, new_user_recharge_usd_minor = 0,
|
||||||
coin_seller_recharge_usd_minor = 0,
|
coin_seller_recharge_usd_minor = 0,
|
||||||
|
coin_seller_stock_coin = 0,
|
||||||
mifapay_recharge_usd_minor = 0, google_recharge_usd_minor = 0, coin_seller_transfer_coin = 0,
|
mifapay_recharge_usd_minor = 0, google_recharge_usd_minor = 0, coin_seller_transfer_coin = 0,
|
||||||
|
coin_total = 0, consumed_coin = 0, platform_grant_coin = 0, manual_grant_coin = 0,
|
||||||
|
salary_usd_minor = 0, salary_transfer_coin = 0, mic_online_ms = 0, mic_online_users = 0,
|
||||||
updated_at_ms = ?
|
updated_at_ms = ?
|
||||||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
|
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
|
||||||
nowMS, app, statTZ, startDay, endDay); err != nil {
|
nowMS, app, statTZ, startDay, endDay); err != nil {
|
||||||
@ -566,29 +897,55 @@ func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, ap
|
|||||||
`DELETE FROM stat_user_registration WHERE app_code = ? AND stat_tz = ? AND registered_day BETWEEN ? AND ?`,
|
`DELETE FROM stat_user_registration WHERE app_code = ? AND stat_tz = ? AND registered_day BETWEEN ? AND ?`,
|
||||||
`DELETE FROM stat_user_day_activity WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
|
`DELETE FROM stat_user_day_activity WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
|
||||||
`DELETE FROM stat_recharge_day_payers WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
|
`DELETE FROM stat_recharge_day_payers WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
|
||||||
|
`DELETE FROM stat_user_day_mic WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
|
||||||
} {
|
} {
|
||||||
if _, err := tx.ExecContext(ctx, statement, app, statTZ, startDay, endDay); err != nil {
|
if _, err := tx.ExecContext(ctx, statement, app, statTZ, startDay, endDay); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for userID, dim := range state.userDims {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO stat_user_dimension (app_code, user_id, country_id, region_id, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
country_id = VALUES(country_id),
|
||||||
|
region_id = VALUES(region_id),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
|
`, app, userID, dim.countryID, dim.regionID, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO stat_app_day_country (
|
INSERT INTO stat_app_day_country (
|
||||||
app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users,
|
app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users,
|
||||||
recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor,
|
recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor,
|
||||||
mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin, updated_at_ms
|
coin_seller_stock_coin, mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin,
|
||||||
|
coin_total, consumed_coin, platform_grant_coin, manual_grant_coin,
|
||||||
|
salary_usd_minor, salary_transfer_coin, mic_online_ms, mic_online_users, updated_at_ms
|
||||||
)
|
)
|
||||||
SELECT app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users,
|
SELECT app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users,
|
||||||
recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor,
|
recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor,
|
||||||
mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin, ?
|
coin_seller_stock_coin, mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin,
|
||||||
|
coin_total, consumed_coin, platform_grant_coin, manual_grant_coin,
|
||||||
|
salary_usd_minor, salary_transfer_coin, mic_online_ms, mic_online_users, ?
|
||||||
FROM tmp_stat_backfill_app_day
|
FROM tmp_stat_backfill_app_day
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
new_users = VALUES(new_users), active_users = VALUES(active_users), paid_users = VALUES(paid_users),
|
new_users = VALUES(new_users), active_users = VALUES(active_users), paid_users = VALUES(paid_users),
|
||||||
recharge_usd_minor = VALUES(recharge_usd_minor),
|
recharge_usd_minor = VALUES(recharge_usd_minor),
|
||||||
new_user_recharge_usd_minor = VALUES(new_user_recharge_usd_minor),
|
new_user_recharge_usd_minor = VALUES(new_user_recharge_usd_minor),
|
||||||
coin_seller_recharge_usd_minor = VALUES(coin_seller_recharge_usd_minor),
|
coin_seller_recharge_usd_minor = VALUES(coin_seller_recharge_usd_minor),
|
||||||
|
coin_seller_stock_coin = VALUES(coin_seller_stock_coin),
|
||||||
mifapay_recharge_usd_minor = VALUES(mifapay_recharge_usd_minor),
|
mifapay_recharge_usd_minor = VALUES(mifapay_recharge_usd_minor),
|
||||||
google_recharge_usd_minor = VALUES(google_recharge_usd_minor),
|
google_recharge_usd_minor = VALUES(google_recharge_usd_minor),
|
||||||
coin_seller_transfer_coin = VALUES(coin_seller_transfer_coin),
|
coin_seller_transfer_coin = VALUES(coin_seller_transfer_coin),
|
||||||
|
coin_total = VALUES(coin_total),
|
||||||
|
consumed_coin = VALUES(consumed_coin),
|
||||||
|
platform_grant_coin = VALUES(platform_grant_coin),
|
||||||
|
manual_grant_coin = VALUES(manual_grant_coin),
|
||||||
|
salary_usd_minor = VALUES(salary_usd_minor),
|
||||||
|
salary_transfer_coin = VALUES(salary_transfer_coin),
|
||||||
|
mic_online_ms = VALUES(mic_online_ms),
|
||||||
|
mic_online_users = VALUES(mic_online_users),
|
||||||
updated_at_ms = VALUES(updated_at_ms)`, nowMS); err != nil {
|
updated_at_ms = VALUES(updated_at_ms)`, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -607,6 +964,11 @@ func stageAndMaybeApply(ctx context.Context, db *sql.DB, state *rebuildState, ap
|
|||||||
SELECT app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms FROM tmp_stat_backfill_payers`); err != nil {
|
SELECT app_code, stat_tz, stat_day, country_id, region_id, user_id, first_paid_at_ms FROM tmp_stat_backfill_payers`); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO stat_user_day_mic (app_code, stat_tz, stat_day, country_id, region_id, user_id, mic_online_ms, updated_at_ms)
|
||||||
|
SELECT app_code, stat_tz, stat_day, country_id, region_id, user_id, mic_online_ms, ? FROM tmp_stat_backfill_mic`, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -616,8 +978,12 @@ func createTempTables(ctx context.Context, tx *sql.Tx) error {
|
|||||||
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL, region_id BIGINT NOT NULL,
|
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL, region_id BIGINT NOT NULL,
|
||||||
new_users BIGINT NOT NULL, active_users BIGINT NOT NULL, paid_users BIGINT NOT NULL,
|
new_users BIGINT NOT NULL, active_users BIGINT NOT NULL, paid_users BIGINT NOT NULL,
|
||||||
recharge_usd_minor BIGINT NOT NULL, new_user_recharge_usd_minor BIGINT NOT NULL,
|
recharge_usd_minor BIGINT NOT NULL, new_user_recharge_usd_minor BIGINT NOT NULL,
|
||||||
coin_seller_recharge_usd_minor BIGINT NOT NULL, mifapay_recharge_usd_minor BIGINT NOT NULL, google_recharge_usd_minor BIGINT NOT NULL,
|
coin_seller_recharge_usd_minor BIGINT NOT NULL, coin_seller_stock_coin BIGINT NOT NULL,
|
||||||
|
mifapay_recharge_usd_minor BIGINT NOT NULL, google_recharge_usd_minor BIGINT NOT NULL,
|
||||||
coin_seller_transfer_coin BIGINT NOT NULL,
|
coin_seller_transfer_coin BIGINT NOT NULL,
|
||||||
|
coin_total BIGINT NOT NULL, consumed_coin BIGINT NOT NULL, platform_grant_coin BIGINT NOT NULL,
|
||||||
|
manual_grant_coin BIGINT NOT NULL, salary_usd_minor BIGINT NOT NULL, salary_transfer_coin BIGINT NOT NULL,
|
||||||
|
mic_online_ms BIGINT NOT NULL, mic_online_users BIGINT NOT NULL,
|
||||||
PRIMARY KEY(app_code, stat_tz, stat_day, country_id, region_id)
|
PRIMARY KEY(app_code, stat_tz, stat_day, country_id, region_id)
|
||||||
)`,
|
)`,
|
||||||
`CREATE TEMPORARY TABLE tmp_stat_backfill_registration (
|
`CREATE TEMPORARY TABLE tmp_stat_backfill_registration (
|
||||||
@ -635,6 +1001,11 @@ func createTempTables(ctx context.Context, tx *sql.Tx) error {
|
|||||||
region_id BIGINT NOT NULL, user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
|
region_id BIGINT NOT NULL, user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY(app_code, stat_tz, stat_day, user_id)
|
PRIMARY KEY(app_code, stat_tz, stat_day, user_id)
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TEMPORARY TABLE tmp_stat_backfill_mic (
|
||||||
|
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL,
|
||||||
|
region_id BIGINT NOT NULL, user_id BIGINT NOT NULL, mic_online_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY(app_code, stat_tz, stat_day, user_id)
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
for _, statement := range statements {
|
for _, statement := range statements {
|
||||||
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||||
@ -650,11 +1021,13 @@ func insertStageRows(ctx context.Context, tx *sql.Tx, state *rebuildState, app s
|
|||||||
INSERT INTO tmp_stat_backfill_app_day (
|
INSERT INTO tmp_stat_backfill_app_day (
|
||||||
app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users,
|
app_code, stat_tz, stat_day, country_id, region_id, new_users, active_users, paid_users,
|
||||||
recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor,
|
recharge_usd_minor, new_user_recharge_usd_minor, coin_seller_recharge_usd_minor,
|
||||||
mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin
|
coin_seller_stock_coin, mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
coin_total, consumed_coin, platform_grant_coin, manual_grant_coin, salary_usd_minor, salary_transfer_coin, mic_online_ms, mic_online_users
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
app, statTZ, key.day, key.countryID, key.regionID, agg.newUsers, agg.activeUsers, agg.paidUsers,
|
app, statTZ, key.day, key.countryID, key.regionID, agg.newUsers, agg.activeUsers, agg.paidUsers,
|
||||||
agg.rechargeUSDMinor, agg.newUserRechargeUSDMinor, agg.coinSellerRechargeUSDMinor,
|
agg.rechargeUSDMinor, agg.newUserRechargeUSDMinor, agg.coinSellerRechargeUSDMinor,
|
||||||
agg.mifaPayRechargeUSDMinor, agg.googleRechargeUSDMinor, agg.coinSellerTransferCoin); err != nil {
|
agg.coinSellerStockCoin, agg.mifaPayRechargeUSDMinor, agg.googleRechargeUSDMinor, agg.coinSellerTransferCoin,
|
||||||
|
agg.coinTotal, agg.consumedCoin, agg.platformGrantCoin, agg.manualGrantCoin, agg.salaryUSDMinor, agg.salaryTransferCoin, agg.micOnlineMS, agg.micOnlineUsers); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -679,6 +1052,13 @@ func insertStageRows(ctx context.Context, tx *sql.Tx, state *rebuildState, app s
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, row := range state.micByKey {
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO tmp_stat_backfill_mic (app_code, stat_tz, stat_day, country_id, region_id, user_id, mic_online_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`, app, statTZ, row.day, row.dim.countryID, row.dim.regionID, row.userID, row.micOnlineMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -687,7 +1067,9 @@ func validateStage(ctx context.Context, tx *sql.Tx) error {
|
|||||||
if err := tx.QueryRowContext(ctx, `
|
if err := tx.QueryRowContext(ctx, `
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM tmp_stat_backfill_app_day
|
FROM tmp_stat_backfill_app_day
|
||||||
WHERE new_users < 0 OR active_users < 0 OR paid_users < 0 OR recharge_usd_minor < 0 OR coin_seller_transfer_coin < 0`).Scan(&negativeCount); err != nil {
|
WHERE new_users < 0 OR active_users < 0 OR paid_users < 0 OR recharge_usd_minor < 0 OR coin_seller_transfer_coin < 0
|
||||||
|
OR coin_total < 0 OR consumed_coin < 0 OR platform_grant_coin < 0 OR manual_grant_coin < 0
|
||||||
|
OR salary_usd_minor < 0 OR salary_transfer_coin < 0 OR mic_online_ms < 0 OR mic_online_users < 0`).Scan(&negativeCount); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if negativeCount > 0 {
|
if negativeCount > 0 {
|
||||||
|
|||||||
294
services/statistics-service/cmd/databi-real-flow-audit/main.go
Normal file
294
services/statistics-service/cmd/databi-real-flow-audit/main.go
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"
|
||||||
|
|
||||||
|
_ "github.com/go-sql-driver/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type auditWindow struct {
|
||||||
|
startDay string
|
||||||
|
endDay string
|
||||||
|
startMS int64
|
||||||
|
endMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type auditCheck struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
source string
|
||||||
|
required bool
|
||||||
|
count int64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var (
|
||||||
|
appCode = flag.String("app", envDefault("APP_CODE", "lalu"), "app_code to audit")
|
||||||
|
statTZ = flag.String("timezone", envDefault("STAT_TZ", mysqlstorage.StatTZAsiaShanghai), "stat_tz to audit: UTC or Asia/Shanghai")
|
||||||
|
startDay = flag.String("start-day", envDefault("START_DAY", todayIn(mysqlstorage.StatTZAsiaShanghai)), "inclusive stat day in selected timezone, yyyy-mm-dd")
|
||||||
|
endDay = flag.String("end-day", envDefault("END_DAY", todayIn(mysqlstorage.StatTZAsiaShanghai)), "inclusive stat day in selected timezone, yyyy-mm-dd")
|
||||||
|
statisticsDSN = flag.String("statistics-dsn", firstNonEmptyEnv("STATISTICS_MYSQL_DSN", "MYSQL_DSN"), "statistics mysql dsn")
|
||||||
|
userDSN = flag.String("user-dsn", firstNonEmptyEnv("USER_MYSQL_DSN", "MYSQL_USER_DSN"), "user-service mysql dsn")
|
||||||
|
walletDSN = flag.String("wallet-dsn", firstNonEmptyEnv("WALLET_MYSQL_DSN", "MYSQL_WALLET_DSN"), "wallet-service mysql dsn")
|
||||||
|
roomDSN = flag.String("room-dsn", firstNonEmptyEnv("ROOM_MYSQL_DSN", "MYSQL_ROOM_DSN"), "room-service mysql dsn")
|
||||||
|
gameDSN = flag.String("game-dsn", firstNonEmptyEnv("GAME_MYSQL_DSN", "MYSQL_GAME_DSN"), "game-service mysql dsn")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
statTZValue, loc, err := normalizeStatTZ(*statTZ)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
window, err := resolveWindow(*startDay, *endDay, loc)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
dbs := map[string]*sql.DB{}
|
||||||
|
for name, dsn := range map[string]string{
|
||||||
|
"statistics": *statisticsDSN,
|
||||||
|
"user": *userDSN,
|
||||||
|
"wallet": *walletDSN,
|
||||||
|
"room": *roomDSN,
|
||||||
|
"game": *gameDSN,
|
||||||
|
} {
|
||||||
|
if strings.TrimSpace(dsn) == "" {
|
||||||
|
log.Fatalf("%s dsn is required", name)
|
||||||
|
}
|
||||||
|
db, err := openDB(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open %s db: %v", name, err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
dbs[name] = db
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceChecks := auditSources(ctx, dbs, *appCode, window)
|
||||||
|
statsChecks := auditStatistics(ctx, dbs["statistics"], *appCode, statTZValue, window)
|
||||||
|
failed := printChecks("owner outbox", sourceChecks)
|
||||||
|
failed += printChecks("statistics aggregate", statsChecks)
|
||||||
|
if failed > 0 {
|
||||||
|
log.Fatalf("databi real flow audit failed: %d required checks have no data", failed)
|
||||||
|
}
|
||||||
|
fmt.Printf("databi real flow audit passed app=%s stat_tz=%s days=%s..%s\n", *appCode, statTZValue, window.startDay, window.endDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditSources(ctx context.Context, dbs map[string]*sql.DB, app string, window auditWindow) []auditCheck {
|
||||||
|
return []auditCheck{
|
||||||
|
countCheck(ctx, dbs["user"], "new_users", "新增用户", "user_outbox.UserRegistered", true, `
|
||||||
|
SELECT COUNT(*) FROM user_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'UserRegistered' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["room"], "active_users", "活跃用户", "room_outbox.RoomUserJoined", true, `
|
||||||
|
SELECT COUNT(*) FROM room_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'RoomUserJoined' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["user"], "mic_online_ms", "麦上时长", "user_outbox.UserMicDailyStatsUpdated", true, `
|
||||||
|
SELECT COUNT(*) FROM user_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'UserMicDailyStatsUpdated' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "google_recharge_usd_minor", "谷歌充值", "wallet_outbox.WalletRechargeRecorded(recharge_type=google/google_play,可本地模拟)", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletRechargeRecorded' AND created_at_ms >= ? AND created_at_ms < ?
|
||||||
|
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')), JSON_UNQUOTE(JSON_EXTRACT(payload, '$.channel')), '')) IN ('google','google_play')`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "mifapay_recharge_usd_minor", "三方充值", "wallet_outbox.WalletRechargeRecorded(recharge_type=mifapay/v5pay,可本地模拟)", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletRechargeRecorded' AND created_at_ms >= ? AND created_at_ms < ?
|
||||||
|
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')), JSON_UNQUOTE(JSON_EXTRACT(payload, '$.channel')), '')) IN ('mifapay','v5pay')`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "coin_seller_stock_coin", "币商充值金币", "wallet_outbox.WalletCoinSellerStockPurchased", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletCoinSellerStockPurchased' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "coin_seller_transfer_coin", "币商出货金币", "wallet_outbox.WalletRechargeRecorded(recharge_type=coin_seller_transfer)", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletRechargeRecorded' AND created_at_ms >= ? AND created_at_ms < ?
|
||||||
|
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.recharge_type')), '')) IN ('coin_seller_transfer','coin_seller')`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "coin_total", "金币数量总和", "wallet_outbox.WalletBalanceChanged(asset=COIN)", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type = 'COIN' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "consumed_coin", "消耗金币", "wallet_outbox.WalletBalanceChanged(COIN消费biz_type)", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type = 'COIN' AND available_delta + frozen_delta < 0
|
||||||
|
AND created_at_ms >= ? AND created_at_ms < ?
|
||||||
|
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.biz_type')), '')) IN ('gift_debit','direct_gift_debit','vip_purchase','resource_shop_purchase','red_packet_create','game_debit','wheel_draw_debit','cp_breakup_fee')`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "platform_grant_coin", "平台发放金币", "wallet_outbox.Wallet*RewardCredited", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type IN ('WalletTaskRewardCredited','WalletLuckyGiftRewardCredited','WalletWheelRewardCredited','WalletRoomTurnoverRewardCredited','WalletInviteActivityRewardCredited','WalletAgencyOpeningRewardCredited')
|
||||||
|
AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "manual_grant_coin", "人工发放金币", "wallet_outbox.WalletBalanceChanged(biz_type=manual_credit)", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type = 'COIN' AND available_delta + frozen_delta > 0
|
||||||
|
AND created_at_ms >= ? AND created_at_ms < ?
|
||||||
|
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.biz_type')), '')) = 'manual_credit'`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "salary_usd_minor", "工资总和", "wallet_outbox.WalletBalanceChanged(biz_type=host_salary_settlement)", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type IN ('HOST_SALARY_USD','AGENCY_SALARY_USD')
|
||||||
|
AND available_delta + frozen_delta > 0 AND created_at_ms >= ? AND created_at_ms < ?
|
||||||
|
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.biz_type')), '')) = 'host_salary_settlement'`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "salary_transfer_coin", "工资兑换金币", "wallet_outbox.WalletBalanceChanged(biz_type=salary_transfer_to_coin_seller)", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletBalanceChanged' AND asset_type = 'COIN_SELLER_COIN'
|
||||||
|
AND available_delta + frozen_delta > 0 AND created_at_ms >= ? AND created_at_ms < ?
|
||||||
|
AND LOWER(COALESCE(JSON_UNQUOTE(JSON_EXTRACT(payload, '$.biz_type')), '')) = 'salary_transfer_to_coin_seller'`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["room"], "gift_coin_spent", "礼物/幸运礼物流水", "room_outbox.RoomGiftSent", true, `
|
||||||
|
SELECT COUNT(*) FROM room_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'RoomGiftSent' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["wallet"], "lucky_gift_payout", "幸运礼物返奖", "wallet_outbox.WalletLuckyGiftRewardCredited", true, `
|
||||||
|
SELECT COUNT(*) FROM wallet_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'WalletLuckyGiftRewardCredited' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
|
||||||
|
countCheck(ctx, dbs["game"], "game_turnover", "游戏流水/利润", "game_outbox.GameOrderSettled", true, `
|
||||||
|
SELECT COUNT(*) FROM game_outbox
|
||||||
|
WHERE app_code = ? AND event_type = 'GameOrderSettled' AND created_at_ms >= ? AND created_at_ms < ?`, app, window.startMS, window.endMS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditStatistics(ctx context.Context, db *sql.DB, app string, statTZ string, window auditWindow) []auditCheck {
|
||||||
|
checks := []auditCheck{}
|
||||||
|
for _, spec := range []struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
expr string
|
||||||
|
}{
|
||||||
|
{"new_users", "新增用户", "SUM(new_users)"},
|
||||||
|
{"active_users", "活跃用户", "SUM(active_users)"},
|
||||||
|
{"paid_users", "付费用户", "SUM(paid_users)"},
|
||||||
|
{"recharge_usd_minor", "总充值", "SUM(recharge_usd_minor)"},
|
||||||
|
{"coin_seller_recharge_usd_minor", "币商充值", "SUM(coin_seller_recharge_usd_minor)"},
|
||||||
|
{"coin_seller_stock_coin", "币商充值金币", "SUM(coin_seller_stock_coin)"},
|
||||||
|
{"coin_seller_transfer_coin", "币商出货金币", "SUM(coin_seller_transfer_coin)"},
|
||||||
|
{"google_recharge_usd_minor", "谷歌充值", "SUM(google_recharge_usd_minor)"},
|
||||||
|
{"mifapay_recharge_usd_minor", "三方充值", "SUM(mifapay_recharge_usd_minor)"},
|
||||||
|
{"coin_total", "金币数量总和", "SUM(coin_total)"},
|
||||||
|
{"consumed_coin", "消耗金币", "SUM(consumed_coin)"},
|
||||||
|
{"platform_grant_coin", "平台发放金币", "SUM(platform_grant_coin)"},
|
||||||
|
{"manual_grant_coin", "人工发放金币", "SUM(manual_grant_coin)"},
|
||||||
|
{"salary_usd_minor", "工资总和", "SUM(salary_usd_minor)"},
|
||||||
|
{"salary_transfer_coin", "工资兑换金币", "SUM(salary_transfer_coin)"},
|
||||||
|
{"mic_online_ms", "麦上总时长", "SUM(mic_online_ms)"},
|
||||||
|
{"mic_online_users", "平均麦上时间用户数", "SUM(mic_online_users)"},
|
||||||
|
{"gift_coin_spent", "礼物流水", "SUM(gift_coin_spent)"},
|
||||||
|
{"lucky_gift_turnover", "幸运礼物流水", "SUM(lucky_gift_turnover)"},
|
||||||
|
{"lucky_gift_payout", "幸运礼物返奖", "SUM(lucky_gift_payout)"},
|
||||||
|
{"game_turnover", "游戏流水", "SUM(game_turnover)"},
|
||||||
|
{"game_payout", "游戏返奖", "SUM(game_payout)"},
|
||||||
|
} {
|
||||||
|
checks = append(checks, countCheck(ctx, db, spec.key, spec.label, "stat_app_day_country."+spec.key, true,
|
||||||
|
"SELECT COALESCE("+spec.expr+",0) FROM stat_app_day_country WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?",
|
||||||
|
app, statTZ, window.startDay, window.endDay))
|
||||||
|
}
|
||||||
|
checks = append(checks,
|
||||||
|
countCheck(ctx, db, "super_lucky_gift_turnover", "超级幸运礼物流水", "stat_lucky_gift_pool_day_country(pool_id LIKE %super_lucky%)", true, `
|
||||||
|
SELECT COALESCE(SUM(turnover_coin),0) FROM stat_lucky_gift_pool_day_country
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND LOWER(pool_id) LIKE '%super_lucky%'`,
|
||||||
|
app, statTZ, window.startDay, window.endDay),
|
||||||
|
countCheck(ctx, db, "super_lucky_gift_payout", "超级幸运礼物返奖", "stat_lucky_gift_pool_day_country(pool_id LIKE %super_lucky%)", true, `
|
||||||
|
SELECT COALESCE(SUM(payout_coin),0) FROM stat_lucky_gift_pool_day_country
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND LOWER(pool_id) LIKE '%super_lucky%'`,
|
||||||
|
app, statTZ, window.startDay, window.endDay),
|
||||||
|
)
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
|
func countCheck(ctx context.Context, db *sql.DB, key string, label string, source string, required bool, query string, args ...any) auditCheck {
|
||||||
|
check := auditCheck{key: key, label: label, source: source, required: required}
|
||||||
|
if db == nil {
|
||||||
|
check.err = fmt.Errorf("db is nil")
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
err := db.QueryRowContext(ctx, query, args...).Scan(&check.count)
|
||||||
|
if err != nil {
|
||||||
|
check.err = err
|
||||||
|
}
|
||||||
|
return check
|
||||||
|
}
|
||||||
|
|
||||||
|
func printChecks(title string, checks []auditCheck) int {
|
||||||
|
fmt.Printf("\n%s\n", title)
|
||||||
|
failed := 0
|
||||||
|
for _, check := range checks {
|
||||||
|
status := "OK"
|
||||||
|
if check.err != nil || (check.required && check.count <= 0) {
|
||||||
|
status = "MISSING"
|
||||||
|
failed++
|
||||||
|
}
|
||||||
|
if check.err != nil {
|
||||||
|
fmt.Printf(" %-8s %-34s count=%d source=%s err=%v\n", status, check.label, check.count, check.source, check.err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %-8s %-34s count=%d source=%s\n", status, check.label, check.count, check.source)
|
||||||
|
}
|
||||||
|
return failed
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeStatTZ(value string) (string, *time.Location, error) {
|
||||||
|
switch strings.TrimSpace(value) {
|
||||||
|
case mysqlstorage.StatTZUTC:
|
||||||
|
return mysqlstorage.StatTZUTC, time.UTC, nil
|
||||||
|
case mysqlstorage.StatTZAsiaShanghai:
|
||||||
|
return mysqlstorage.StatTZAsiaShanghai, time.FixedZone(mysqlstorage.StatTZAsiaShanghai, 8*60*60), nil
|
||||||
|
default:
|
||||||
|
return "", nil, fmt.Errorf("unsupported stat_tz %q; only UTC and Asia/Shanghai are supported", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveWindow(startDay string, endDay string, loc *time.Location) (auditWindow, error) {
|
||||||
|
start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startDay), loc)
|
||||||
|
if err != nil {
|
||||||
|
return auditWindow{}, fmt.Errorf("parse start-day: %w", err)
|
||||||
|
}
|
||||||
|
end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endDay), loc)
|
||||||
|
if err != nil {
|
||||||
|
return auditWindow{}, fmt.Errorf("parse end-day: %w", err)
|
||||||
|
}
|
||||||
|
if end.Before(start) {
|
||||||
|
return auditWindow{}, fmt.Errorf("end-day %s is before start-day %s", endDay, startDay)
|
||||||
|
}
|
||||||
|
return auditWindow{
|
||||||
|
startDay: start.Format("2006-01-02"),
|
||||||
|
endDay: end.Format("2006-01-02"),
|
||||||
|
startMS: start.UnixMilli(),
|
||||||
|
endMS: end.AddDate(0, 0, 1).UnixMilli(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openDB(ctx context.Context, dsn string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("mysql", strings.TrimSpace(dsn))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func todayIn(statTZ string) string {
|
||||||
|
_, loc, err := normalizeStatTZ(statTZ)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.FixedZone(mysqlstorage.StatTZAsiaShanghai, 8*60*60)
|
||||||
|
}
|
||||||
|
return time.Now().In(loc).Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func envDefault(key string, fallback string) string {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmptyEnv(keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import (
|
|||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/gamemq"
|
"hyapp/pkg/gamemq"
|
||||||
"hyapp/pkg/roommq"
|
"hyapp/pkg/roommq"
|
||||||
"hyapp/pkg/usermq"
|
"hyapp/pkg/usermq"
|
||||||
@ -107,6 +108,10 @@ func main() {
|
|||||||
if err := resetReplayTarget(ctx, statsDB, *appCode, statTZValue, window); err != nil {
|
if err := resetReplayTarget(ctx, statsDB, *appCode, statTZValue, window); err != nil {
|
||||||
log.Fatalf("reset target: %v", err)
|
log.Fatalf("reset target: %v", err)
|
||||||
}
|
}
|
||||||
|
dimensionRows, err := primeUserDimensions(ctx, statsDB, dbs["user"], *appCode)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("prime user dimensions: %v", err)
|
||||||
|
}
|
||||||
scopeCtx := mysqlstorage.WithStatTZScope(ctx, statTZValue)
|
scopeCtx := mysqlstorage.WithStatTZScope(ctx, statTZValue)
|
||||||
if err := replayUser(ctx, scopeCtx, repo, dbs["user"], *appCode, statTZValue, window); err != nil {
|
if err := replayUser(ctx, scopeCtx, repo, dbs["user"], *appCode, statTZValue, window); err != nil {
|
||||||
log.Fatalf("replay user outbox: %v", err)
|
log.Fatalf("replay user outbox: %v", err)
|
||||||
@ -120,8 +125,8 @@ func main() {
|
|||||||
if err := replayGame(ctx, scopeCtx, repo, dbs["game"], *appCode, statTZValue, window); err != nil {
|
if err := replayGame(ctx, scopeCtx, repo, dbs["game"], *appCode, statTZValue, window); err != nil {
|
||||||
log.Fatalf("replay game outbox: %v", err)
|
log.Fatalf("replay game outbox: %v", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("applied app=%s stat_tz=%s days=%s..%s source_rows user=%d wallet=%d room=%d game=%d\n",
|
fmt.Printf("applied app=%s stat_tz=%s days=%s..%s source_rows user=%d wallet=%d room=%d game=%d user_dimension_rows=%d\n",
|
||||||
*appCode, statTZValue, window.startDay, window.endDay, summary.userRows, summary.walletRows, summary.roomRows, summary.gameRows)
|
*appCode, statTZValue, window.startDay, window.endDay, summary.userRows, summary.walletRows, summary.roomRows, summary.gameRows, dimensionRows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeReplayStatTZ(value string) (string, *time.Location, error) {
|
func normalizeReplayStatTZ(value string) (string, *time.Location, error) {
|
||||||
@ -179,13 +184,13 @@ func countSources(ctx context.Context, dbs map[string]*sql.DB, app string, windo
|
|||||||
var out replaySummary
|
var out replaySummary
|
||||||
var err error
|
var err error
|
||||||
if db := dbs["user"]; db != nil {
|
if db := dbs["user"]; db != nil {
|
||||||
out.userRows, err = countOutboxRows(ctx, db, "user_outbox", app, []string{"UserRegistered"}, window)
|
out.userRows, err = countOutboxRows(ctx, db, "user_outbox", app, userReplayEventTypes(), window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return replaySummary{}, err
|
return replaySummary{}, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if db := dbs["wallet"]; db != nil {
|
if db := dbs["wallet"]; db != nil {
|
||||||
out.walletRows, err = countOutboxRows(ctx, db, "wallet_outbox", app, []string{"WalletRechargeRecorded", "WalletCoinSellerStockPurchased", "WalletLuckyGiftRewardCredited"}, window)
|
out.walletRows, err = countOutboxRows(ctx, db, "wallet_outbox", app, walletReplayEventTypes(), window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return replaySummary{}, err
|
return replaySummary{}, err
|
||||||
}
|
}
|
||||||
@ -226,6 +231,7 @@ func resetReplayTarget(ctx context.Context, db *sql.DB, app string, statTZ strin
|
|||||||
{"stat_app_day_country", "stat_day"},
|
{"stat_app_day_country", "stat_day"},
|
||||||
{"stat_user_day_activity", "stat_day"},
|
{"stat_user_day_activity", "stat_day"},
|
||||||
{"stat_user_registration", "registered_day"},
|
{"stat_user_registration", "registered_day"},
|
||||||
|
{"stat_user_day_mic", "stat_day"},
|
||||||
{"stat_recharge_day_payers", "stat_day"},
|
{"stat_recharge_day_payers", "stat_day"},
|
||||||
{"stat_lucky_gift_day_payers", "stat_day"},
|
{"stat_lucky_gift_day_payers", "stat_day"},
|
||||||
{"stat_lucky_gift_pool_day_country", "stat_day"},
|
{"stat_lucky_gift_pool_day_country", "stat_day"},
|
||||||
@ -254,11 +260,10 @@ func resetReplayTarget(ctx context.Context, db *sql.DB, app string, statTZ strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func replayUser(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error {
|
func replayUser(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error {
|
||||||
rows, err := db.QueryContext(ctx, `
|
query, args := outboxWindowQuery(`
|
||||||
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
|
app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms`, "user_outbox", app,
|
||||||
FROM user_outbox
|
userReplayEventTypes(), window, "ORDER BY created_at_ms ASC, event_id ASC")
|
||||||
WHERE app_code = ? AND event_type = 'UserRegistered' AND created_at_ms >= ? AND created_at_ms < ?
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
ORDER BY created_at_ms ASC, event_id ASC`, app, window.startMS, window.endMS)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -280,11 +285,66 @@ func replayUser(ctx context.Context, scopeCtx context.Context, repo *mysqlstorag
|
|||||||
return rows.Err()
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func primeUserDimensions(ctx context.Context, statsDB *sql.DB, userDB *sql.DB, app string) (int64, error) {
|
||||||
|
rows, err := userDB.QueryContext(ctx, `
|
||||||
|
SELECT u.user_id, COALESCE(c.country_id, 0), COALESCE(u.region_id, 0)
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN countries c ON c.app_code = u.app_code AND c.country_code = u.country
|
||||||
|
WHERE u.app_code = ?
|
||||||
|
AND u.user_id > 0
|
||||||
|
AND (COALESCE(c.country_id, 0) > 0 OR COALESCE(u.region_id, 0) > 0)`,
|
||||||
|
appcode.Normalize(app))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
tx, err := statsDB.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
|
INSERT INTO stat_user_dimension (app_code, user_id, country_id, region_id, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
country_id = CASE WHEN VALUES(country_id) > 0 THEN VALUES(country_id) ELSE country_id END,
|
||||||
|
region_id = CASE WHEN VALUES(region_id) > 0 THEN VALUES(region_id) ELSE region_id END,
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
nowMS := time.Now().UnixMilli()
|
||||||
|
var count int64
|
||||||
|
for rows.Next() {
|
||||||
|
var userID int64
|
||||||
|
var countryID int64
|
||||||
|
var regionID int64
|
||||||
|
if err := rows.Scan(&userID, &countryID, ®ionID); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if _, err := stmt.ExecContext(ctx, appcode.Normalize(app), userID, countryID, regionID, nowMS); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
func replayWallet(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error {
|
func replayWallet(ctx context.Context, scopeCtx context.Context, repo *mysqlstorage.Repository, db *sql.DB, app string, statTZ string, window replayWindow) error {
|
||||||
query, args := outboxWindowQuery(`
|
query, args := outboxWindowQuery(`
|
||||||
app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
|
app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
|
||||||
available_delta, frozen_delta, CAST(payload AS CHAR), created_at_ms`, "wallet_outbox", app,
|
available_delta, frozen_delta, CAST(payload AS CHAR), created_at_ms`, "wallet_outbox", app,
|
||||||
[]string{"WalletRechargeRecorded", "WalletCoinSellerStockPurchased", "WalletLuckyGiftRewardCredited"}, window, "ORDER BY created_at_ms ASC, event_id ASC")
|
walletReplayEventTypes(), window, "ORDER BY created_at_ms ASC, event_id ASC")
|
||||||
rows, err := db.QueryContext(ctx, query, args...)
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -371,6 +431,28 @@ func outboxWindowQuery(selectExpr string, table string, app string, eventTypes [
|
|||||||
return fmt.Sprintf("SELECT %s FROM %s WHERE app_code = ? AND event_type IN (%s) AND created_at_ms >= ? AND created_at_ms < ? %s", selectExpr, table, placeholders, suffix), args
|
return fmt.Sprintf("SELECT %s FROM %s WHERE app_code = ? AND event_type IN (%s) AND created_at_ms >= ? AND created_at_ms < ? %s", selectExpr, table, placeholders, suffix), args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func userReplayEventTypes() []string {
|
||||||
|
return []string{
|
||||||
|
"UserRegistered",
|
||||||
|
"UserRegionChanged",
|
||||||
|
"UserMicDailyStatsUpdated",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletReplayEventTypes() []string {
|
||||||
|
return []string{
|
||||||
|
"WalletRechargeRecorded",
|
||||||
|
"WalletCoinSellerStockPurchased",
|
||||||
|
"WalletBalanceChanged",
|
||||||
|
"WalletTaskRewardCredited",
|
||||||
|
"WalletLuckyGiftRewardCredited",
|
||||||
|
"WalletWheelRewardCredited",
|
||||||
|
"WalletRoomTurnoverRewardCredited",
|
||||||
|
"WalletInviteActivityRewardCredited",
|
||||||
|
"WalletAgencyOpeningRewardCredited",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func gameReplayEventTypes() []string {
|
func gameReplayEventTypes() []string {
|
||||||
return []string{
|
return []string{
|
||||||
gamemq.EventTypeGameOrderSettled,
|
gamemq.EventTypeGameOrderSettled,
|
||||||
|
|||||||
@ -29,9 +29,18 @@ CREATE TABLE IF NOT EXISTS stat_app_day_country (
|
|||||||
recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '总充值美元最小单位',
|
recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '总充值美元最小单位',
|
||||||
new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '新增充值美元最小单位',
|
new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '新增充值美元最小单位',
|
||||||
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '币商充值美元最小单位',
|
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '币商充值美元最小单位',
|
||||||
|
coin_seller_stock_coin BIGINT NOT NULL DEFAULT 0 COMMENT '币商库存充值金币数量',
|
||||||
mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'MifaPay 充值美元最小单位',
|
mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'MifaPay 充值美元最小单位',
|
||||||
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 充值美元最小单位',
|
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 充值美元最小单位',
|
||||||
coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0 COMMENT '币商向用户转普通金币数量',
|
coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0 COMMENT '币商向用户转普通金币数量',
|
||||||
|
coin_total BIGINT NOT NULL DEFAULT 0 COMMENT '普通用户 COIN 钱包日终余额总和',
|
||||||
|
consumed_coin BIGINT NOT NULL DEFAULT 0 COMMENT '消费类普通金币扣减',
|
||||||
|
platform_grant_coin BIGINT NOT NULL DEFAULT 0 COMMENT '系统奖励金币发放',
|
||||||
|
manual_grant_coin BIGINT NOT NULL DEFAULT 0 COMMENT '后台人工发放金币',
|
||||||
|
salary_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '主播和代理工资 USD 最小单位',
|
||||||
|
salary_transfer_coin BIGINT NOT NULL DEFAULT 0 COMMENT '工资转币商入账金币数量',
|
||||||
|
mic_online_ms BIGINT NOT NULL DEFAULT 0 COMMENT '麦上总时长毫秒',
|
||||||
|
mic_online_users BIGINT NOT NULL DEFAULT 0 COMMENT '麦上用户数',
|
||||||
gift_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '礼物消耗金币',
|
gift_coin_spent BIGINT NOT NULL DEFAULT 0 COMMENT '礼物消耗金币',
|
||||||
lucky_gift_turnover BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物流水',
|
lucky_gift_turnover BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物流水',
|
||||||
lucky_gift_payout BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物返奖',
|
lucky_gift_payout BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物返奖',
|
||||||
@ -71,6 +80,31 @@ CREATE TABLE IF NOT EXISTS stat_user_registration (
|
|||||||
KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)
|
KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户注册日留存 cohort 表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户注册日留存 cohort 表';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stat_user_dimension (
|
||||||
|
app_code VARCHAR(32) NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
country_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, user_id),
|
||||||
|
KEY idx_stat_user_dimension_country (app_code, country_id),
|
||||||
|
KEY idx_stat_user_dimension_region (app_code, region_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统计服务用户国家区域维度投影';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stat_user_day_mic (
|
||||||
|
app_code VARCHAR(32) NOT NULL,
|
||||||
|
stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai',
|
||||||
|
stat_day DATE NOT NULL,
|
||||||
|
country_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
mic_online_ms BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, stat_tz, stat_day, user_id),
|
||||||
|
KEY idx_stat_user_day_mic_country (app_code, stat_tz, stat_day, country_id),
|
||||||
|
KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_day, region_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户每日麦上时长去重表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
|
CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai',
|
stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai',
|
||||||
|
|||||||
@ -168,34 +168,56 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq
|
|||||||
|
|
||||||
// ConsumeUserOutboxBody 是 user outbox 的唯一统计投影入口;实时 MQ 和离线回灌共用它,避免同一 payload 出现两套解析口径。
|
// ConsumeUserOutboxBody 是 user outbox 的唯一统计投影入口;实时 MQ 和离线回灌共用它,避免同一 payload 出现两套解析口径。
|
||||||
func ConsumeUserOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error {
|
func ConsumeUserOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error {
|
||||||
event, ok, err := userRegisteredEvent(body)
|
if event, ok, err := userRegisteredEvent(body); err != nil || ok {
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return repo.ConsumeUserRegistered(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
|
}
|
||||||
|
if event, ok, err := userRegionChangedEvent(body); err != nil || ok {
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return repo.ConsumeUserRegionChanged(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
|
}
|
||||||
|
event, ok, err := userMicDailyStatsUpdatedEvent(body)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return repo.ConsumeUserRegistered(appcode.WithContext(ctx, event.AppCode), event)
|
return repo.ConsumeUserMicDailyStatsUpdated(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsumeWalletOutboxBody 按钱包事实类型分派充值、币商补货和幸运礼物返奖;未知事件只提交消费位点,不写统计读模型。
|
// ConsumeWalletOutboxBody 按钱包事实类型分派充值、币商补货、奖励和余额变动;未知事件只提交消费位点,不写统计读模型。
|
||||||
func ConsumeWalletOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error {
|
func ConsumeWalletOutboxBody(ctx context.Context, repo *mysqlstorage.Repository, body []byte) error {
|
||||||
event, ok, err := rechargeEvent(body)
|
if event, ok, err := rechargeEvent(body); err != nil || ok {
|
||||||
if err != nil {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ok {
|
|
||||||
return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event)
|
return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
}
|
}
|
||||||
stock, ok, err := coinSellerStockEvent(body)
|
if stock, ok, err := coinSellerStockEvent(body); err != nil || ok {
|
||||||
if err != nil {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ok {
|
|
||||||
return repo.ConsumeCoinSellerStock(appcode.WithContext(ctx, stock.AppCode), stock)
|
return repo.ConsumeCoinSellerStock(appcode.WithContext(ctx, stock.AppCode), stock)
|
||||||
}
|
}
|
||||||
reward, ok, err := luckyGiftRewardEvent(body)
|
if reward, ok, err := luckyGiftRewardEvent(body); err != nil || ok {
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return repo.ConsumeLuckyGiftReward(appcode.WithContext(ctx, reward.AppCode), reward)
|
||||||
|
}
|
||||||
|
if grant, ok, err := platformGrantCoinEvent(body); err != nil || ok {
|
||||||
|
if err != nil || !ok {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return repo.ConsumePlatformGrantCoin(appcode.WithContext(ctx, grant.AppCode), grant)
|
||||||
|
}
|
||||||
|
event, ok, err := walletBalanceChangedEvent(body)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return repo.ConsumeLuckyGiftReward(appcode.WithContext(ctx, reward.AppCode), reward)
|
return repo.ConsumeWalletBalanceChanged(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConsumeRoomOutboxBody 只投影房间统计需要的礼物和真人进房事件;礼物事实额外产生 sender 当日活跃,保持实时和回灌一致。
|
// ConsumeRoomOutboxBody 只投影房间统计需要的礼物和真人进房事件;礼物事实额外产生 sender 当日活跃,保持实时和回灌一致。
|
||||||
@ -262,6 +284,44 @@ func userRegisteredEvent(body []byte) (mysqlstorage.UserRegisteredEvent, bool, e
|
|||||||
}, true, nil
|
}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func userRegionChangedEvent(body []byte) (mysqlstorage.UserRegionChangedEvent, bool, error) {
|
||||||
|
message, err := usermq.DecodeUserOutboxMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
return mysqlstorage.UserRegionChangedEvent{}, false, err
|
||||||
|
}
|
||||||
|
if message.EventType != "UserRegionChanged" {
|
||||||
|
return mysqlstorage.UserRegionChangedEvent{}, false, nil
|
||||||
|
}
|
||||||
|
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||||
|
return mysqlstorage.UserRegionChangedEvent{
|
||||||
|
AppCode: message.AppCode,
|
||||||
|
EventID: message.EventID,
|
||||||
|
UserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "user_id"), message.AggregateID),
|
||||||
|
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "country_id"), mysqlstorage.Int64(payload, "new_country_id")),
|
||||||
|
RegionID: firstNonZeroInt64(mysqlstorage.Int64(payload, "region_id"), mysqlstorage.Int64(payload, "new_region_id")),
|
||||||
|
OccurredAtMS: firstNonZeroInt64(mysqlstorage.Int64(payload, "changed_at_ms"), message.OccurredAtMS),
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func userMicDailyStatsUpdatedEvent(body []byte) (mysqlstorage.UserMicDailyStatsUpdatedEvent, bool, error) {
|
||||||
|
message, err := usermq.DecodeUserOutboxMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
return mysqlstorage.UserMicDailyStatsUpdatedEvent{}, false, err
|
||||||
|
}
|
||||||
|
if message.EventType != "UserMicDailyStatsUpdated" {
|
||||||
|
return mysqlstorage.UserMicDailyStatsUpdatedEvent{}, false, nil
|
||||||
|
}
|
||||||
|
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||||
|
return mysqlstorage.UserMicDailyStatsUpdatedEvent{
|
||||||
|
AppCode: message.AppCode,
|
||||||
|
EventID: message.EventID,
|
||||||
|
UserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "user_id"), message.AggregateID),
|
||||||
|
StatDate: mysqlstorage.String(payload, "stat_date"),
|
||||||
|
MicOnlineDeltaMS: mysqlstorage.Int64(payload, "mic_online_delta_ms"),
|
||||||
|
OccurredAtMS: firstNonZeroInt64(mysqlstorage.Int64(payload, "updated_at_ms"), message.OccurredAtMS),
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func rechargeEvent(body []byte) (mysqlstorage.RechargeEvent, bool, error) {
|
func rechargeEvent(body []byte) (mysqlstorage.RechargeEvent, bool, error) {
|
||||||
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -384,6 +444,73 @@ func luckyGiftRewardEvent(body []byte) (mysqlstorage.LuckyGiftRewardEvent, bool,
|
|||||||
}, true, nil
|
}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func platformGrantCoinEvent(body []byte) (mysqlstorage.PlatformGrantCoinEvent, bool, error) {
|
||||||
|
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
return mysqlstorage.PlatformGrantCoinEvent{}, false, err
|
||||||
|
}
|
||||||
|
if !isPlatformGrantCoinEventType(message.EventType) || message.EventType == "WalletLuckyGiftRewardCredited" {
|
||||||
|
return mysqlstorage.PlatformGrantCoinEvent{}, false, nil
|
||||||
|
}
|
||||||
|
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||||
|
return mysqlstorage.PlatformGrantCoinEvent{
|
||||||
|
AppCode: message.AppCode,
|
||||||
|
EventID: message.EventID,
|
||||||
|
EventType: message.EventType,
|
||||||
|
UserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "user_id"), message.UserID),
|
||||||
|
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "country_id"), mysqlstorage.Int64(payload, "target_country_id")),
|
||||||
|
RegionID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id"), mysqlstorage.Int64(payload, "target_region_id")),
|
||||||
|
Amount: firstNonZeroInt64(mysqlstorage.Int64(payload, "amount"), message.AvailableDelta+message.FrozenDelta),
|
||||||
|
OccurredAtMS: message.OccurredAtMS,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func walletBalanceChangedEvent(body []byte) (mysqlstorage.WalletBalanceChangedEvent, bool, error) {
|
||||||
|
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
return mysqlstorage.WalletBalanceChangedEvent{}, false, err
|
||||||
|
}
|
||||||
|
if message.EventType != "WalletBalanceChanged" {
|
||||||
|
return mysqlstorage.WalletBalanceChangedEvent{}, false, nil
|
||||||
|
}
|
||||||
|
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||||
|
metadata := objectValue(payload, "metadata")
|
||||||
|
assetType := firstNonEmpty(mysqlstorage.String(payload, "asset_type"), message.AssetType)
|
||||||
|
bizType := firstNonEmpty(mysqlstorage.String(payload, "biz_type"), mysqlstorage.String(payload, "business_type"))
|
||||||
|
dimensionUserID := int64(0)
|
||||||
|
if strings.EqualFold(strings.TrimSpace(bizType), "salary_transfer_to_coin_seller") && strings.EqualFold(strings.TrimSpace(assetType), "COIN_SELLER_COIN") {
|
||||||
|
dimensionUserID = firstNonZeroInt64(mysqlstorage.Int64(payload, "dimension_user_id"), mysqlstorage.Int64(metadata, "source_user_id"), mysqlstorage.Int64(metadata, "sourceUserID"))
|
||||||
|
}
|
||||||
|
return mysqlstorage.WalletBalanceChangedEvent{
|
||||||
|
AppCode: message.AppCode,
|
||||||
|
EventID: message.EventID,
|
||||||
|
EventType: message.EventType,
|
||||||
|
UserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "user_id"), message.UserID),
|
||||||
|
DimensionUserID: dimensionUserID,
|
||||||
|
AssetType: assetType,
|
||||||
|
BizType: bizType,
|
||||||
|
AvailableDelta: firstNonZeroInt64(mysqlstorage.Int64(payload, "available_delta"), message.AvailableDelta),
|
||||||
|
FrozenDelta: firstNonZeroInt64(mysqlstorage.Int64(payload, "frozen_delta"), message.FrozenDelta),
|
||||||
|
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "country_id"), mysqlstorage.Int64(payload, "target_country_id")),
|
||||||
|
RegionID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id"), mysqlstorage.Int64(payload, "target_region_id"), mysqlstorage.Int64(metadata, "region_id"), mysqlstorage.Int64(metadata, "regionId")),
|
||||||
|
OccurredAtMS: firstNonZeroInt64(mysqlstorage.Int64(payload, "created_at_ms"), message.OccurredAtMS),
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPlatformGrantCoinEventType(value string) bool {
|
||||||
|
switch strings.TrimSpace(value) {
|
||||||
|
case "WalletTaskRewardCredited",
|
||||||
|
"WalletLuckyGiftRewardCredited",
|
||||||
|
"WalletWheelRewardCredited",
|
||||||
|
"WalletRoomTurnoverRewardCredited",
|
||||||
|
"WalletInviteActivityRewardCredited",
|
||||||
|
"WalletAgencyOpeningRewardCredited":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func roomGiftEvent(body []byte) (mysqlstorage.RoomGiftEvent, bool, error) {
|
func roomGiftEvent(body []byte) (mysqlstorage.RoomGiftEvent, bool, error) {
|
||||||
envelope, _, err := roommq.DecodeRoomOutboxMessage(body)
|
envelope, _, err := roommq.DecodeRoomOutboxMessage(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -635,6 +762,13 @@ func firstNonZeroInt64(values ...int64) int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func objectValue(payload map[string]any, key string) map[string]any {
|
||||||
|
if value, ok := payload[key].(map[string]any); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
func boolValue(payload map[string]any, key string) bool {
|
func boolValue(payload map[string]any, key string) bool {
|
||||||
switch value := payload[key].(type) {
|
switch value := payload[key].(type) {
|
||||||
case bool:
|
case bool:
|
||||||
|
|||||||
@ -17,7 +17,9 @@ import (
|
|||||||
mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"
|
mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
// 这个用例只覆盖 statistics-service 对 owner outbox payload 的解析和聚合边界;
|
||||||
|
// 本地真实业务链路用 scripts/databi-real-flow-check.sh 从各 owner service 的 outbox 重放验证。
|
||||||
|
func TestConsumeOutboxMessagesProjectsDataScreenMetrics(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, file, _, _ := runtime.Caller(0)
|
_, file, _, _ := runtime.Caller(0)
|
||||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
@ -231,7 +233,7 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query overview failed: %v", err)
|
t.Fatalf("query overview failed: %v", err)
|
||||||
}
|
}
|
||||||
if overview.GoogleRechargeUSDMinor != 150 || overview.CoinSellerRechargeUSDMinor != 350 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 500 || overview.NewUserRechargeUSDMinor != 150 || overview.PaidUsers != 2 {
|
if overview.GoogleRechargeUSDMinor != 150 || overview.CoinSellerRechargeUSDMinor != 350 || overview.CoinSellerStockCoin != 350_000 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 500 || overview.NewUserRechargeUSDMinor != 150 || overview.PaidUsers != 2 {
|
||||||
t.Fatalf("google recharge metrics mismatch: %+v", overview)
|
t.Fatalf("google recharge metrics mismatch: %+v", overview)
|
||||||
}
|
}
|
||||||
if overview.LuckyGiftTurnover != 1_250 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 950 || overview.LuckyGiftPayers != 1 {
|
if overview.LuckyGiftTurnover != 1_250 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 950 || overview.LuckyGiftPayers != 1 {
|
||||||
@ -252,6 +254,103 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConsumeWalletOutboxBodyProjectsBalanceChangedReportMetrics(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
|
InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"),
|
||||||
|
DatabasePrefix: "hy_stats_wallet_balance_flow",
|
||||||
|
})
|
||||||
|
repo, err := mysqlstorage.Open(ctx, schema.DSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open statistics repository failed: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = repo.Close() })
|
||||||
|
|
||||||
|
occurredAt := time.Date(2026, 6, 22, 8, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
|
for _, message := range []walletmq.WalletOutboxMessage{
|
||||||
|
{
|
||||||
|
AppCode: "lalu",
|
||||||
|
EventID: "WalletBalanceChanged:coin:topup",
|
||||||
|
EventType: "WalletBalanceChanged",
|
||||||
|
TransactionID: "wallet_tx_topup",
|
||||||
|
CommandID: "topup",
|
||||||
|
UserID: 88001,
|
||||||
|
AssetType: "COIN",
|
||||||
|
PayloadJSON: mustJSON(t, map[string]any{
|
||||||
|
"user_id": int64(88001),
|
||||||
|
"asset_type": "COIN",
|
||||||
|
"biz_type": "google_play_recharge",
|
||||||
|
"available_delta": int64(100),
|
||||||
|
"country_id": int64(15),
|
||||||
|
"region_id": int64(10),
|
||||||
|
"created_at_ms": occurredAt,
|
||||||
|
}),
|
||||||
|
OccurredAtMS: occurredAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AppCode: "lalu",
|
||||||
|
EventID: "WalletBalanceChanged:coin:gift",
|
||||||
|
EventType: "WalletBalanceChanged",
|
||||||
|
TransactionID: "wallet_tx_gift",
|
||||||
|
CommandID: "gift",
|
||||||
|
UserID: 88001,
|
||||||
|
AssetType: "COIN",
|
||||||
|
PayloadJSON: mustJSON(t, map[string]any{
|
||||||
|
"user_id": int64(88001),
|
||||||
|
"asset_type": "COIN",
|
||||||
|
"biz_type": "gift_debit",
|
||||||
|
"available_delta": int64(-88),
|
||||||
|
"country_id": int64(15),
|
||||||
|
"region_id": int64(10),
|
||||||
|
"created_at_ms": occurredAt,
|
||||||
|
}),
|
||||||
|
OccurredAtMS: occurredAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AppCode: "lalu",
|
||||||
|
EventID: "WalletBalanceChanged:salary:coin-seller",
|
||||||
|
EventType: "WalletBalanceChanged",
|
||||||
|
TransactionID: "wallet_tx_salary_seller",
|
||||||
|
CommandID: "salary-seller",
|
||||||
|
UserID: 99001,
|
||||||
|
AssetType: "COIN_SELLER_COIN",
|
||||||
|
PayloadJSON: mustJSON(t, map[string]any{
|
||||||
|
"user_id": int64(99001),
|
||||||
|
"asset_type": "COIN_SELLER_COIN",
|
||||||
|
"biz_type": "salary_transfer_to_coin_seller",
|
||||||
|
"available_delta": int64(660),
|
||||||
|
"country_id": int64(15),
|
||||||
|
"region_id": int64(10),
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"source_user_id": int64(88001),
|
||||||
|
"region_id": int64(10),
|
||||||
|
},
|
||||||
|
"created_at_ms": occurredAt,
|
||||||
|
}),
|
||||||
|
OccurredAtMS: occurredAt,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
if err := ConsumeWalletOutboxBody(ctx, repo, walletMessage(t, message)); err != nil {
|
||||||
|
t.Fatalf("consume wallet balance message %s failed: %v", message.EventID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
|
overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{
|
||||||
|
AppCode: "lalu",
|
||||||
|
StartMS: start,
|
||||||
|
EndMS: start + int64(24*time.Hour/time.Millisecond),
|
||||||
|
CountryID: 15,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query overview failed: %v", err)
|
||||||
|
}
|
||||||
|
if overview.CoinTotal != 12 || overview.ConsumedCoin != 88 || overview.SalaryTransferCoin != 660 {
|
||||||
|
t.Fatalf("wallet balance report metrics mismatch: %+v", overview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRoomJoinActiveEventSkipsRobotJoin(t *testing.T) {
|
func TestRoomJoinActiveEventSkipsRobotJoin(t *testing.T) {
|
||||||
occurredAt := time.Date(2026, 6, 25, 8, 0, 0, 0, time.UTC).UnixMilli()
|
occurredAt := time.Date(2026, 6, 25, 8, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
@ -23,86 +24,116 @@ type OverviewQuery struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Overview struct {
|
type Overview struct {
|
||||||
AppCode string `json:"app_code"`
|
AppCode string `json:"app_code"`
|
||||||
StatTZ string `json:"stat_tz"`
|
StatTZ string `json:"stat_tz"`
|
||||||
StartMS int64 `json:"start_ms"`
|
StartMS int64 `json:"start_ms"`
|
||||||
EndMS int64 `json:"end_ms"`
|
EndMS int64 `json:"end_ms"`
|
||||||
CountryID int64 `json:"country_id"`
|
CountryID int64 `json:"country_id"`
|
||||||
NewUsers int64 `json:"new_users"`
|
NewUsers int64 `json:"new_users"`
|
||||||
ActiveUsers int64 `json:"active_users"`
|
ActiveUsers int64 `json:"active_users"`
|
||||||
PaidUsers int64 `json:"paid_users"`
|
PaidUsers int64 `json:"paid_users"`
|
||||||
NewPaidUsers int64 `json:"new_paid_users"`
|
NewPaidUsers int64 `json:"new_paid_users"`
|
||||||
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||||||
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
||||||
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
||||||
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
|
||||||
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
||||||
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
||||||
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
||||||
RealRoomRobotNormalGiftCoin int64 `json:"real_room_robot_normal_gift_coin"`
|
CoinTotal int64 `json:"coin_total"`
|
||||||
RealRoomRobotLuckyGiftCoin int64 `json:"real_room_robot_lucky_gift_coin"`
|
ConsumedCoin int64 `json:"consumed_coin"`
|
||||||
RealRoomRobotSuperGiftCoin int64 `json:"real_room_robot_super_gift_coin"`
|
PlatformGrantCoin int64 `json:"platform_grant_coin"`
|
||||||
RealRoomRobotTotalGiftCoin int64 `json:"real_room_robot_total_gift_coin"`
|
ManualGrantCoin int64 `json:"manual_grant_coin"`
|
||||||
RealRoomRobotGiftRoomCount int64 `json:"real_room_robot_gift_room_count"`
|
SalaryUSDMinor int64 `json:"salary_usd_minor"`
|
||||||
RealRoomRobotAvgRoomGiftCoin int64 `json:"real_room_robot_avg_room_gift_coin"`
|
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
|
||||||
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
MicOnlineMS int64 `json:"mic_online_ms"`
|
||||||
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
MicOnlineUsers int64 `json:"mic_online_users"`
|
||||||
LuckyGiftPayers int64 `json:"lucky_gift_payers"`
|
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
|
||||||
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
|
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
||||||
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
|
RealRoomRobotNormalGiftCoin int64 `json:"real_room_robot_normal_gift_coin"`
|
||||||
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
|
RealRoomRobotLuckyGiftCoin int64 `json:"real_room_robot_lucky_gift_coin"`
|
||||||
GameTurnover int64 `json:"game_turnover"`
|
RealRoomRobotSuperGiftCoin int64 `json:"real_room_robot_super_gift_coin"`
|
||||||
GamePayout int64 `json:"game_payout"`
|
RealRoomRobotTotalGiftCoin int64 `json:"real_room_robot_total_gift_coin"`
|
||||||
GameRefund int64 `json:"game_refund"`
|
RealRoomRobotGiftRoomCount int64 `json:"real_room_robot_gift_room_count"`
|
||||||
GamePlayers int64 `json:"game_players"`
|
RealRoomRobotAvgRoomGiftCoin int64 `json:"real_room_robot_avg_room_gift_coin"`
|
||||||
GameProfit int64 `json:"game_profit"`
|
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
||||||
GameProfitRate float64 `json:"game_profit_rate"`
|
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
||||||
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
LuckyGiftPayers int64 `json:"lucky_gift_payers"`
|
||||||
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
|
||||||
UPValueUSDMinor int64 `json:"up_value_usd_minor"`
|
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
|
||||||
RechargeDeltaRate float64 `json:"recharge_delta_rate"`
|
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
|
||||||
NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
|
GameTurnover int64 `json:"game_turnover"`
|
||||||
ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
|
GamePayout int64 `json:"game_payout"`
|
||||||
PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"`
|
GameRefund int64 `json:"game_refund"`
|
||||||
NewPaidUsersDeltaRate float64 `json:"new_paid_users_delta_rate"`
|
GamePlayers int64 `json:"game_players"`
|
||||||
ARPUDeltaRate float64 `json:"arpu_delta_rate"`
|
GameProfit int64 `json:"game_profit"`
|
||||||
ARPPUDeltaRate float64 `json:"arppu_delta_rate"`
|
GameProfitRate float64 `json:"game_profit_rate"`
|
||||||
GiftCoinSpentDeltaRate float64 `json:"gift_coin_spent_delta_rate"`
|
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
|
||||||
CoinSellerTransferDeltaRate float64 `json:"coin_seller_transfer_coin_delta_rate"`
|
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
|
||||||
LuckyGiftTurnoverDeltaRate float64 `json:"lucky_gift_turnover_delta_rate"`
|
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
|
||||||
LuckyGiftPayoutDeltaRate float64 `json:"lucky_gift_payout_delta_rate"`
|
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
|
||||||
LuckyGiftProfitDeltaRate float64 `json:"lucky_gift_profit_delta_rate"`
|
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
||||||
GameTurnoverDeltaRate float64 `json:"game_turnover_delta_rate"`
|
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
||||||
GameProfitDeltaRate float64 `json:"game_profit_delta_rate"`
|
UPValueUSDMinor int64 `json:"up_value_usd_minor"`
|
||||||
Retention Retention `json:"retention"`
|
RechargeDeltaRate float64 `json:"recharge_delta_rate"`
|
||||||
DailySeries []DailySeriesItem `json:"daily_series"`
|
NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
|
||||||
GameRanking []GameRank `json:"game_ranking"`
|
ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
|
||||||
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
|
PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"`
|
||||||
CountryBreakdown []CountryBreakdown `json:"country_breakdown"`
|
NewPaidUsersDeltaRate float64 `json:"new_paid_users_delta_rate"`
|
||||||
|
ARPUDeltaRate float64 `json:"arpu_delta_rate"`
|
||||||
|
ARPPUDeltaRate float64 `json:"arppu_delta_rate"`
|
||||||
|
GiftCoinSpentDeltaRate float64 `json:"gift_coin_spent_delta_rate"`
|
||||||
|
CoinSellerTransferDeltaRate float64 `json:"coin_seller_transfer_coin_delta_rate"`
|
||||||
|
LuckyGiftTurnoverDeltaRate float64 `json:"lucky_gift_turnover_delta_rate"`
|
||||||
|
LuckyGiftPayoutDeltaRate float64 `json:"lucky_gift_payout_delta_rate"`
|
||||||
|
LuckyGiftProfitDeltaRate float64 `json:"lucky_gift_profit_delta_rate"`
|
||||||
|
GameTurnoverDeltaRate float64 `json:"game_turnover_delta_rate"`
|
||||||
|
GameProfitDeltaRate float64 `json:"game_profit_delta_rate"`
|
||||||
|
Retention Retention `json:"retention"`
|
||||||
|
DailySeries []DailySeriesItem `json:"daily_series"`
|
||||||
|
DailyCountryBreakdown []DailyCountryStat `json:"daily_country_breakdown"`
|
||||||
|
GameRanking []GameRank `json:"game_ranking"`
|
||||||
|
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
|
||||||
|
CountryBreakdown []CountryBreakdown `json:"country_breakdown"`
|
||||||
|
ReportMetricSources []ReportMetricSource `json:"report_metric_sources"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DailySeriesItem struct {
|
type DailySeriesItem struct {
|
||||||
StatDay string `json:"stat_day"`
|
StatDay string `json:"stat_day"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
NewUsers int64 `json:"new_users"`
|
NewUsers int64 `json:"new_users"`
|
||||||
ActiveUsers int64 `json:"active_users"`
|
ActiveUsers int64 `json:"active_users"`
|
||||||
PaidUsers int64 `json:"paid_users"`
|
PaidUsers int64 `json:"paid_users"`
|
||||||
NewPaidUsers int64 `json:"new_paid_users"`
|
NewPaidUsers int64 `json:"new_paid_users"`
|
||||||
RechargeUsers int64 `json:"recharge_users"`
|
RechargeUsers int64 `json:"recharge_users"`
|
||||||
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||||||
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
||||||
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
||||||
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
|
||||||
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
||||||
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
||||||
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
||||||
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
CoinTotal int64 `json:"coin_total"`
|
||||||
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
ConsumedCoin int64 `json:"consumed_coin"`
|
||||||
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
|
PlatformGrantCoin int64 `json:"platform_grant_coin"`
|
||||||
GameTurnover int64 `json:"game_turnover"`
|
ManualGrantCoin int64 `json:"manual_grant_coin"`
|
||||||
GameProfit int64 `json:"game_profit"`
|
SalaryUSDMinor int64 `json:"salary_usd_minor"`
|
||||||
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
|
||||||
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
MicOnlineMS int64 `json:"mic_online_ms"`
|
||||||
|
MicOnlineUsers int64 `json:"mic_online_users"`
|
||||||
|
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
|
||||||
|
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
||||||
|
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
||||||
|
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
||||||
|
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
|
||||||
|
GameTurnover int64 `json:"game_turnover"`
|
||||||
|
GameProfit int64 `json:"game_profit"`
|
||||||
|
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
|
||||||
|
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
|
||||||
|
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
|
||||||
|
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
|
||||||
|
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
||||||
|
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Retention struct {
|
type Retention struct {
|
||||||
@ -135,6 +166,11 @@ type LuckyGiftPoolStat struct {
|
|||||||
ProfitRate float64 `json:"profit_rate"`
|
ProfitRate float64 `json:"profit_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type superLuckyAggregate struct {
|
||||||
|
Turnover int64
|
||||||
|
Payout int64
|
||||||
|
}
|
||||||
|
|
||||||
type CountryBreakdown struct {
|
type CountryBreakdown struct {
|
||||||
CountryID int64 `json:"country_id"`
|
CountryID int64 `json:"country_id"`
|
||||||
RegionID int64 `json:"region_id"`
|
RegionID int64 `json:"region_id"`
|
||||||
@ -144,9 +180,19 @@ type CountryBreakdown struct {
|
|||||||
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||||||
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
||||||
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
||||||
|
CoinSellerStockCoin int64 `json:"coin_seller_stock_coin"`
|
||||||
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
||||||
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
||||||
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
||||||
|
CoinTotal int64 `json:"coin_total"`
|
||||||
|
ConsumedCoin int64 `json:"consumed_coin"`
|
||||||
|
PlatformGrantCoin int64 `json:"platform_grant_coin"`
|
||||||
|
ManualGrantCoin int64 `json:"manual_grant_coin"`
|
||||||
|
SalaryUSDMinor int64 `json:"salary_usd_minor"`
|
||||||
|
SalaryTransferCoin int64 `json:"salary_transfer_coin"`
|
||||||
|
MicOnlineMS int64 `json:"mic_online_ms"`
|
||||||
|
MicOnlineUsers int64 `json:"mic_online_users"`
|
||||||
|
AvgMicOnlineMS int64 `json:"avg_mic_online_ms"`
|
||||||
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
||||||
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
||||||
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
||||||
@ -160,10 +206,29 @@ type CountryBreakdown struct {
|
|||||||
GamePlayers int64 `json:"game_players"`
|
GamePlayers int64 `json:"game_players"`
|
||||||
GameProfit int64 `json:"game_profit"`
|
GameProfit int64 `json:"game_profit"`
|
||||||
GameProfitRate float64 `json:"game_profit_rate"`
|
GameProfitRate float64 `json:"game_profit_rate"`
|
||||||
|
SuperLuckyGiftTurnover int64 `json:"super_lucky_gift_turnover"`
|
||||||
|
SuperLuckyGiftPayout int64 `json:"super_lucky_gift_payout"`
|
||||||
|
SuperLuckyGiftProfit int64 `json:"super_lucky_gift_profit"`
|
||||||
|
SuperLuckyGiftProfitRate float64 `json:"super_lucky_gift_profit_rate"`
|
||||||
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
||||||
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DailyCountryStat struct {
|
||||||
|
StatDay string `json:"stat_day"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
CountryBreakdown
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportMetricSource struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Definition string `json:"definition"`
|
||||||
|
MissingReason string `json:"missing_reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type SelfGameOverviewQuery struct {
|
type SelfGameOverviewQuery struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
StatTZ string
|
StatTZ string
|
||||||
@ -361,6 +426,7 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
|
|||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
overview.LuckyGiftPools = pools
|
overview.LuckyGiftPools = pools
|
||||||
|
overview.SuperLuckyGiftTurnover, overview.SuperLuckyGiftPayout = sumSuperLuckyGiftPools(pools)
|
||||||
if statTZ == StatTZUTC {
|
if statTZ == StatTZUTC {
|
||||||
// activity-service 的 lucky_draw_pool_day_stats 仍是 UTC 日表;只有 UTC 查询能拿它覆盖旧统计行,北京日必须使用本服务 stat_tz=Asia/Shanghai 的读模型。
|
// activity-service 的 lucky_draw_pool_day_stats 仍是 UTC 日表;只有 UTC 查询能拿它覆盖旧统计行,北京日必须使用本服务 stat_tz=Asia/Shanghai 的读模型。
|
||||||
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
|
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
|
||||||
@ -368,6 +434,7 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
|
|||||||
} else if ok {
|
} else if ok {
|
||||||
overview.LuckyGiftPools = activityPools
|
overview.LuckyGiftPools = activityPools
|
||||||
overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
|
overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
|
||||||
|
overview.SuperLuckyGiftTurnover, overview.SuperLuckyGiftPayout = sumSuperLuckyGiftPools(activityPools)
|
||||||
}
|
}
|
||||||
// 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。
|
// 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。
|
||||||
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
|
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
|
||||||
@ -395,6 +462,11 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
|
|||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
overview.CountryBreakdown = breakdown
|
overview.CountryBreakdown = breakdown
|
||||||
|
dailyCountryBreakdown, err := r.queryDailyCountryBreakdown(ctx, app, statTZ, startDay, endDay, query.RegionID)
|
||||||
|
if err != nil {
|
||||||
|
return Overview{}, err
|
||||||
|
}
|
||||||
|
overview.DailyCountryBreakdown = dailyCountryBreakdown
|
||||||
}
|
}
|
||||||
seriesStartDay, seriesEndDay := startDay, endDay
|
seriesStartDay, seriesEndDay := startDay, endDay
|
||||||
if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS {
|
if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS {
|
||||||
@ -407,6 +479,7 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
|
|||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
overview.DailySeries = dailySeries
|
overview.DailySeries = dailySeries
|
||||||
|
overview.ReportMetricSources = reportMetricSources()
|
||||||
return overview, nil
|
return overview, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -483,8 +556,11 @@ func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ,
|
|||||||
row := r.db.QueryRowContext(ctx, `
|
row := r.db.QueryRowContext(ctx, `
|
||||||
SELECT COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
SELECT COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||||||
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||||||
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||||||
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||||||
|
COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(platform_grant_coin),0),
|
||||||
|
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
|
||||||
|
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
|
||||||
COALESCE(SUM(gift_coin_spent),0),
|
COALESCE(SUM(gift_coin_spent),0),
|
||||||
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
|
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
|
||||||
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
||||||
@ -492,7 +568,7 @@ func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ,
|
|||||||
FROM stat_app_day_country
|
FROM stat_app_day_country
|
||||||
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...)
|
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...)
|
||||||
var overview Overview
|
var overview Overview
|
||||||
if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil {
|
if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.CoinSellerStockCoin, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &overview.CoinTotal, &overview.ConsumedCoin, &overview.PlatformGrantCoin, &overview.ManualGrantCoin, &overview.SalaryUSDMinor, &overview.SalaryTransferCoin, &overview.MicOnlineMS, &overview.MicOnlineUsers, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
return overview, nil
|
return overview, nil
|
||||||
@ -565,8 +641,11 @@ func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay
|
|||||||
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'),
|
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'),
|
||||||
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||||||
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||||||
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||||||
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||||||
|
COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(platform_grant_coin),0),
|
||||||
|
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
|
||||||
|
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
|
||||||
COALESCE(SUM(gift_coin_spent),0),
|
COALESCE(SUM(gift_coin_spent),0),
|
||||||
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0),
|
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0),
|
||||||
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0)
|
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0)
|
||||||
@ -582,7 +661,7 @@ func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item DailySeriesItem
|
var item DailySeriesItem
|
||||||
var gamePayout, gameRefund int64
|
var gamePayout, gameRefund int64
|
||||||
if err := rows.Scan(&item.StatDay, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.GameTurnover, &gamePayout, &gameRefund); err != nil {
|
if err := rows.Scan(&item.StatDay, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.GameTurnover, &gamePayout, &gameRefund); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
item.Label = item.StatDay
|
item.Label = item.StatDay
|
||||||
@ -600,6 +679,17 @@ func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay
|
|||||||
for index := range out {
|
for index := range out {
|
||||||
out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay]
|
out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay]
|
||||||
}
|
}
|
||||||
|
superByDay, err := r.querySuperLuckyGiftDaily(ctx, app, statTZ, startDay, endDay, countryID, regionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index := range out {
|
||||||
|
if super, ok := superByDay[out[index].StatDay]; ok {
|
||||||
|
out[index].SuperLuckyGiftTurnover = super.Turnover
|
||||||
|
out[index].SuperLuckyGiftPayout = super.Payout
|
||||||
|
applyDailySeriesDerivedMetrics(&out[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
if normalizeStatTZ(statTZ) == StatTZUTC {
|
if normalizeStatTZ(statTZ) == StatTZUTC {
|
||||||
if activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil {
|
if activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -654,8 +744,11 @@ func applyDailySeriesDerivedMetrics(item *DailySeriesItem) {
|
|||||||
}
|
}
|
||||||
item.RechargeUsers = item.PaidUsers
|
item.RechargeUsers = item.PaidUsers
|
||||||
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
|
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
|
||||||
|
item.SuperLuckyGiftProfit = item.SuperLuckyGiftTurnover - item.SuperLuckyGiftPayout
|
||||||
|
item.SuperLuckyGiftProfitRate = ratio(item.SuperLuckyGiftProfit, item.SuperLuckyGiftTurnover)
|
||||||
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
|
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
|
||||||
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
|
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
|
||||||
|
item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyOverviewDerivedMetrics(overview *Overview) {
|
func applyOverviewDerivedMetrics(overview *Overview) {
|
||||||
@ -672,9 +765,12 @@ func applyOverviewDerivedMetrics(overview *Overview) {
|
|||||||
overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers)
|
overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers)
|
||||||
overview.ARPPUUSDMinor = div(overview.RechargeUSDMinor, overview.PaidUsers)
|
overview.ARPPUUSDMinor = div(overview.RechargeUSDMinor, overview.PaidUsers)
|
||||||
overview.UPValueUSDMinor = overview.ARPPUUSDMinor
|
overview.UPValueUSDMinor = overview.ARPPUUSDMinor
|
||||||
|
overview.AvgMicOnlineMS = div(overview.MicOnlineMS, overview.MicOnlineUsers)
|
||||||
overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
|
overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
|
||||||
overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover)
|
overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, overview.LuckyGiftTurnover)
|
||||||
overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover)
|
overview.LuckyGiftProfitRate = ratio(overview.LuckyGiftProfit, overview.LuckyGiftTurnover)
|
||||||
|
overview.SuperLuckyGiftProfit = overview.SuperLuckyGiftTurnover - overview.SuperLuckyGiftPayout
|
||||||
|
overview.SuperLuckyGiftProfitRate = ratio(overview.SuperLuckyGiftProfit, overview.SuperLuckyGiftTurnover)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (overview *Overview) applyDeltaRates(previous Overview) {
|
func (overview *Overview) applyDeltaRates(previous Overview) {
|
||||||
@ -772,8 +868,11 @@ func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, sta
|
|||||||
SELECT country_id, COALESCE(MAX(region_id),0),
|
SELECT country_id, COALESCE(MAX(region_id),0),
|
||||||
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||||||
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||||||
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||||||
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||||||
|
COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(platform_grant_coin),0),
|
||||||
|
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
|
||||||
|
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
|
||||||
COALESCE(SUM(gift_coin_spent),0),
|
COALESCE(SUM(gift_coin_spent),0),
|
||||||
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
|
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
|
||||||
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
||||||
@ -790,13 +889,83 @@ func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, sta
|
|||||||
out := []CountryBreakdown{}
|
out := []CountryBreakdown{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item CountryBreakdown
|
var item CountryBreakdown
|
||||||
if err := rows.Scan(&item.CountryID, &item.RegionID, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil {
|
if err := rows.Scan(&item.CountryID, &item.RegionID, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
applyCountryBreakdownDerivedMetrics(&item)
|
applyCountryBreakdownDerivedMetrics(&item)
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
superByCountry, err := r.querySuperLuckyGiftCountry(ctx, app, statTZ, startDay, endDay, regionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index := range out {
|
||||||
|
if super, ok := superByCountry[out[index].CountryID]; ok {
|
||||||
|
out[index].SuperLuckyGiftTurnover = super.Turnover
|
||||||
|
out[index].SuperLuckyGiftPayout = super.Payout
|
||||||
|
applyCountryBreakdownDerivedMetrics(&out[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) queryDailyCountryBreakdown(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) ([]DailyCountryStat, error) {
|
||||||
|
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||||||
|
filter := ""
|
||||||
|
if regionID > 0 {
|
||||||
|
filter = " AND region_id = ?"
|
||||||
|
args = append(args, regionID)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), country_id, COALESCE(MAX(region_id),0),
|
||||||
|
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||||||
|
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||||||
|
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(coin_seller_stock_coin),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||||||
|
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||||||
|
COALESCE(SUM(coin_total),0), COALESCE(SUM(consumed_coin),0), COALESCE(SUM(platform_grant_coin),0),
|
||||||
|
COALESCE(SUM(manual_grant_coin),0), COALESCE(SUM(salary_usd_minor),0), COALESCE(SUM(salary_transfer_coin),0),
|
||||||
|
COALESCE(SUM(mic_online_ms),0), COALESCE(SUM(mic_online_users),0),
|
||||||
|
COALESCE(SUM(gift_coin_spent),0),
|
||||||
|
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
|
||||||
|
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
||||||
|
COALESCE(SUM(game_players),0)
|
||||||
|
FROM stat_app_day_country
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter+`
|
||||||
|
GROUP BY stat_day, country_id
|
||||||
|
ORDER BY stat_day ASC, COALESCE(SUM(recharge_usd_minor),0) DESC, COALESCE(SUM(active_users),0) DESC, country_id ASC
|
||||||
|
LIMIT 5000`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []DailyCountryStat{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item DailyCountryStat
|
||||||
|
if err := rows.Scan(&item.StatDay, &item.CountryID, &item.RegionID, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.CoinSellerStockCoin, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.CoinTotal, &item.ConsumedCoin, &item.PlatformGrantCoin, &item.ManualGrantCoin, &item.SalaryUSDMinor, &item.SalaryTransferCoin, &item.MicOnlineMS, &item.MicOnlineUsers, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Label = item.StatDay
|
||||||
|
applyCountryBreakdownDerivedMetrics(&item.CountryBreakdown)
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
superByDayCountry, err := r.querySuperLuckyGiftDailyCountry(ctx, app, statTZ, startDay, endDay, regionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index := range out {
|
||||||
|
if super, ok := superByDayCountry[dailyCountryKey(out[index].StatDay, out[index].CountryID)]; ok {
|
||||||
|
out[index].SuperLuckyGiftTurnover = super.Turnover
|
||||||
|
out[index].SuperLuckyGiftPayout = super.Payout
|
||||||
|
applyCountryBreakdownDerivedMetrics(&out[index].CountryBreakdown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
|
func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
|
||||||
@ -811,9 +980,12 @@ func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
|
|||||||
item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover)
|
item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover)
|
||||||
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
|
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
|
||||||
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
|
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
|
||||||
|
item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers)
|
||||||
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
|
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
|
||||||
item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, item.LuckyGiftTurnover)
|
item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, item.LuckyGiftTurnover)
|
||||||
item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover)
|
item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover)
|
||||||
|
item.SuperLuckyGiftProfit = item.SuperLuckyGiftTurnover - item.SuperLuckyGiftPayout
|
||||||
|
item.SuperLuckyGiftProfitRate = ratio(item.SuperLuckyGiftProfit, item.SuperLuckyGiftTurnover)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) {
|
func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) {
|
||||||
@ -966,6 +1138,141 @@ func sumLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
|
|||||||
return turnover, payout
|
return turnover, payout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sumSuperLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
|
||||||
|
var turnover, payout int64
|
||||||
|
for _, pool := range pools {
|
||||||
|
if !isSuperLuckyPool(pool.PoolID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
turnover += pool.TurnoverCoin
|
||||||
|
payout += pool.PayoutCoin
|
||||||
|
}
|
||||||
|
return turnover, payout
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) querySuperLuckyGiftDaily(ctx context.Context, app, statTZ, startDay, endDay string, countryID int64, regionID int64) (map[string]superLuckyAggregate, error) {
|
||||||
|
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||||||
|
filter := ""
|
||||||
|
if countryID > 0 {
|
||||||
|
filter += " AND country_id = ?"
|
||||||
|
args = append(args, countryID)
|
||||||
|
}
|
||||||
|
if regionID > 0 {
|
||||||
|
filter += " AND region_id = ?"
|
||||||
|
args = append(args, regionID)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||||||
|
FROM stat_lucky_gift_pool_day_country
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+`
|
||||||
|
GROUP BY stat_day`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]superLuckyAggregate{}
|
||||||
|
for rows.Next() {
|
||||||
|
var day string
|
||||||
|
var item superLuckyAggregate
|
||||||
|
if err := rows.Scan(&day, &item.Turnover, &item.Payout); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[day] = item
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) querySuperLuckyGiftCountry(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) (map[int64]superLuckyAggregate, error) {
|
||||||
|
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||||||
|
filter := ""
|
||||||
|
if regionID > 0 {
|
||||||
|
filter = " AND region_id = ?"
|
||||||
|
args = append(args, regionID)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT country_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||||||
|
FROM stat_lucky_gift_pool_day_country
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+`
|
||||||
|
GROUP BY country_id`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[int64]superLuckyAggregate{}
|
||||||
|
for rows.Next() {
|
||||||
|
var countryID int64
|
||||||
|
var item superLuckyAggregate
|
||||||
|
if err := rows.Scan(&countryID, &item.Turnover, &item.Payout); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[countryID] = item
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) querySuperLuckyGiftDailyCountry(ctx context.Context, app, statTZ, startDay, endDay string, regionID int64) (map[string]superLuckyAggregate, error) {
|
||||||
|
args := []any{app, normalizeStatTZ(statTZ), startDay, endDay}
|
||||||
|
filter := ""
|
||||||
|
if regionID > 0 {
|
||||||
|
filter = " AND region_id = ?"
|
||||||
|
args = append(args, regionID)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), country_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||||||
|
FROM stat_lucky_gift_pool_day_country
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ? AND `+superLuckyPoolSQL()+filter+`
|
||||||
|
GROUP BY stat_day, country_id`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]superLuckyAggregate{}
|
||||||
|
for rows.Next() {
|
||||||
|
var day string
|
||||||
|
var countryID int64
|
||||||
|
var item superLuckyAggregate
|
||||||
|
if err := rows.Scan(&day, &countryID, &item.Turnover, &item.Payout); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[dailyCountryKey(day, countryID)] = item
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dailyCountryKey(day string, countryID int64) string {
|
||||||
|
return day + ":" + strconv.FormatInt(countryID, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func superLuckyPoolSQL() string {
|
||||||
|
return "LOWER(pool_id) LIKE '%super_lucky%'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSuperLuckyPool(poolID string) bool {
|
||||||
|
return strings.Contains(strings.ToLower(strings.TrimSpace(poolID)), "super_lucky")
|
||||||
|
}
|
||||||
|
|
||||||
|
func reportMetricSources() []ReportMetricSource {
|
||||||
|
return []ReportMetricSource{
|
||||||
|
{Key: "coin_seller_recharge_usd_minor", Label: "币商充值", Status: "available", Source: "WalletCoinSellerStockPurchased / WalletRechargeRecorded", Definition: "币商进货 USDT 和未识别为 Google/MifaPay/USDT-TRC20/币商转账的充值美元最小单位。"},
|
||||||
|
{Key: "coin_seller_stock_coin", Label: "币商充值金币", Status: "available", Source: "WalletCoinSellerStockPurchased coin_amount", Definition: "币商库存进货金币数量,扣减类库存调整按负数冲减。"},
|
||||||
|
{Key: "coin_seller_transfer_coin", Label: "币商出货金币", Status: "available", Source: "WalletRechargeRecorded recharge_type=coin_seller_transfer|coin_seller", Definition: "币商向用户转入普通金币数量。"},
|
||||||
|
{Key: "google_recharge_usd_minor", Label: "谷歌充值", Status: "available", Source: "WalletRechargeRecorded recharge_type=google|google_play", Definition: "Google Play 充值美元最小单位。"},
|
||||||
|
{Key: "mifapay_recharge_usd_minor", Label: "三方充值", Status: "available", Source: "WalletRechargeRecorded recharge_type=mifapay|v5pay", Definition: "当前三方充值列使用 MifaPay/V5Pay 美元最小单位。"},
|
||||||
|
{Key: "game_turnover", Label: "游戏流水/利润率", Status: "available", Source: "GameOrderSettled", Definition: "debit/bet 为流水,credit/payout 为返奖,refund/reverse 为退款,利润率=(流水-返奖-退款)/流水。"},
|
||||||
|
{Key: "lucky_gift_turnover", Label: "幸运礼物流水/利润率", Status: "available", Source: "RoomGiftSent + WalletLuckyGiftRewardCredited", Definition: "带 pool_id 的送礼金币为流水,钱包返奖为返奖,利润率=(流水-返奖)/流水。"},
|
||||||
|
{Key: "super_lucky_gift_turnover", Label: "超级幸运礼物流水/利润率", Status: "available", Source: "stat_lucky_gift_pool_day_country", Definition: "pool_id 包含 super_lucky 的奖池流水和返奖派生,利润率=(流水-返奖)/流水。"},
|
||||||
|
{Key: "gift_coin_spent", Label: "礼物流水", Status: "available", Source: "RoomGiftSent", Definition: "非机器人真实送礼 coin_spent,历史事件无 coin_spent 时回退 gift_value。"},
|
||||||
|
{Key: "coin_total", Label: "金币数量(总和)", Status: "available", Source: "wallet_accounts / wallet_entries", Definition: "普通用户 COIN 钱包日终 available+frozen 余额总和;当天实时行按 WalletBalanceChanged 增量滚动,回填固化日终余额。"},
|
||||||
|
{Key: "consumed_coin", Label: "消耗金币", Status: "available", Source: "WalletBalanceChanged biz_type allowlist", Definition: "仅统计消费类普通 COIN 扣减:送礼、VIP、资源商城、红包创建、游戏下注、转盘抽奖和 CP 分手费。"},
|
||||||
|
{Key: "platform_grant_coin", Label: "平台发放金币", Status: "available", Source: "Wallet*RewardCredited", Definition: "系统奖励类 COIN 正向发放,包括任务、幸运礼物返奖、转盘、房间流水、邀请活动和代理开通奖励。"},
|
||||||
|
{Key: "manual_grant_coin", Label: "人工发放金币", Status: "available", Source: "WalletBalanceChanged biz_type=manual_credit", Definition: "后台人工调账向普通 COIN 钱包正向发放的金币。"},
|
||||||
|
{Key: "salary_usd_minor", Label: "工资总和(主播/代理)", Status: "available", Source: "host_salary_settlement_records / WalletBalanceChanged", Definition: "成功结算的主播工资和代理工资 USD minor 合计,不包含 residual、BD/Admin 工资。"},
|
||||||
|
{Key: "salary_transfer_coin", Label: "工资兑换金币", Status: "available", Source: "WalletBalanceChanged biz_type=salary_transfer_to_coin_seller / wallet_transactions", Definition: "用户工资钱包转给币商时,币商 COIN_SELLER_COIN 入账的金币数量;国家/区域按发起转账用户归属。"},
|
||||||
|
{Key: "avg_mic_online_ms", Label: "平均麦上时间", Status: "available", Source: "user_mic_daily_stats", Definition: "麦上总时长 / 当日麦上用户数,只统计 mic_online_ms > 0 的用户。"},
|
||||||
|
{Key: "mic_online_ms", Label: "麦上总时长", Status: "available", Source: "user_mic_daily_stats.mic_online_ms", Definition: "用户每日麦上时长按国家/区域聚合后的毫秒总和。"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) querySelfGameEvents(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) {
|
func (r *Repository) querySelfGameEvents(ctx context.Context, app, statTZ, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) {
|
||||||
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
|
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
|||||||
@ -128,6 +128,78 @@ func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConsumeReportMetricsAggregatesWalletSalaryGrantAndMic(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
statsSchema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
|
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
|
||||||
|
DatabasePrefix: "hy_stats_report_metrics_test",
|
||||||
|
})
|
||||||
|
repository, err := Open(ctx, statsSchema.DSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repository: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = repository.Close() })
|
||||||
|
|
||||||
|
occurredAt := time.Date(2026, 6, 22, 3, 4, 5, 0, time.UTC).UnixMilli()
|
||||||
|
for _, event := range []UserRegisteredEvent{
|
||||||
|
{AppCode: "lalu", EventID: "user_registered:report:1", UserID: 7001, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
|
||||||
|
{AppCode: "lalu", EventID: "user_registered:report:2", UserID: 7002, CountryID: 15, RegionID: 10, OccurredAtMS: occurredAt},
|
||||||
|
} {
|
||||||
|
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
|
||||||
|
t.Fatalf("consume user dimension: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, event := range []WalletBalanceChangedEvent{
|
||||||
|
{AppCode: "lalu", EventID: "balance:coin:recharge", UserID: 7001, AssetType: "COIN", BizType: "google_play_recharge", AvailableDelta: 1000, OccurredAtMS: occurredAt},
|
||||||
|
{AppCode: "lalu", EventID: "balance:coin:gift", UserID: 7001, AssetType: "COIN", BizType: "gift_debit", AvailableDelta: -200, OccurredAtMS: occurredAt},
|
||||||
|
{AppCode: "lalu", EventID: "balance:coin:refund", UserID: 7001, AssetType: "COIN", BizType: "game_refund", AvailableDelta: 50, OccurredAtMS: occurredAt},
|
||||||
|
{AppCode: "lalu", EventID: "balance:coin:manual", UserID: 7001, AssetType: "COIN", BizType: "manual_credit", AvailableDelta: 70, OccurredAtMS: occurredAt},
|
||||||
|
{AppCode: "lalu", EventID: "balance:salary:host", UserID: 7001, AssetType: "HOST_SALARY_USD", BizType: "host_salary_settlement", AvailableDelta: 123, OccurredAtMS: occurredAt},
|
||||||
|
{AppCode: "lalu", EventID: "balance:salary:coin-seller", UserID: 9001, DimensionUserID: 7001, AssetType: "COIN_SELLER_COIN", BizType: "salary_transfer_to_coin_seller", AvailableDelta: 444, OccurredAtMS: occurredAt},
|
||||||
|
} {
|
||||||
|
if err := repository.ConsumeWalletBalanceChanged(ctx, event); err != nil {
|
||||||
|
t.Fatalf("consume wallet balance changed %s: %v", event.EventID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := repository.ConsumePlatformGrantCoin(ctx, PlatformGrantCoinEvent{
|
||||||
|
AppCode: "lalu", EventID: "grant:task:1", EventType: "WalletTaskRewardCredited", UserID: 7001, Amount: 33, OccurredAtMS: occurredAt,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("consume platform grant: %v", err)
|
||||||
|
}
|
||||||
|
for _, event := range []UserMicDailyStatsUpdatedEvent{
|
||||||
|
{AppCode: "lalu", EventID: "mic:7001:first", UserID: 7001, StatDate: "2026-06-22", MicOnlineDeltaMS: 60_000, OccurredAtMS: occurredAt},
|
||||||
|
{AppCode: "lalu", EventID: "mic:7001:second", UserID: 7001, StatDate: "2026-06-22", MicOnlineDeltaMS: 30_000, OccurredAtMS: occurredAt},
|
||||||
|
{AppCode: "lalu", EventID: "mic:7002:first", UserID: 7002, StatDate: "2026-06-22", MicOnlineDeltaMS: 30_000, OccurredAtMS: occurredAt},
|
||||||
|
} {
|
||||||
|
if err := repository.ConsumeUserMicDailyStatsUpdated(ctx, event); err != nil {
|
||||||
|
t.Fatalf("consume mic stats %s: %v", event.EventID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Date(2026, 6, 22, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
|
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query overview: %v", err)
|
||||||
|
}
|
||||||
|
if overview.CoinTotal != 920 || overview.ConsumedCoin != 200 || overview.PlatformGrantCoin != 33 || overview.ManualGrantCoin != 70 || overview.SalaryUSDMinor != 123 || overview.SalaryTransferCoin != 444 {
|
||||||
|
t.Fatalf("report wallet metrics mismatch: %+v", overview)
|
||||||
|
}
|
||||||
|
if overview.MicOnlineMS != 120_000 || overview.MicOnlineUsers != 2 || overview.AvgMicOnlineMS != 60_000 {
|
||||||
|
t.Fatalf("mic metrics mismatch: %+v", overview)
|
||||||
|
}
|
||||||
|
if len(overview.CountryBreakdown) != 1 {
|
||||||
|
t.Fatalf("country breakdown missing: %+v", overview.CountryBreakdown)
|
||||||
|
}
|
||||||
|
country := overview.CountryBreakdown[0]
|
||||||
|
if country.CoinTotal != 920 || country.ConsumedCoin != 200 || country.SalaryTransferCoin != 444 || country.AvgMicOnlineMS != 60_000 {
|
||||||
|
t.Fatalf("country report metrics mismatch: %+v", country)
|
||||||
|
}
|
||||||
|
if len(overview.DailySeries) != 1 || overview.DailySeries[0].SalaryTransferCoin != 444 || overview.DailySeries[0].AvgMicOnlineMS != 60_000 {
|
||||||
|
t.Fatalf("daily report metrics mismatch: %+v", overview.DailySeries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestConsumeRechargeWritesUTCAndChinaStatDays(t *testing.T) {
|
func TestConsumeRechargeWritesUTCAndChinaStatDays(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, file, _, _ := runtime.Caller(0)
|
_, file, _, _ := runtime.Caller(0)
|
||||||
@ -327,11 +399,11 @@ func TestQueryOverviewReturnsCountryBreakdownAndIncludesCoinSellerRecharge(t *te
|
|||||||
if _, err := repository.db.ExecContext(ctx, `
|
if _, err := repository.db.ExecContext(ctx, `
|
||||||
INSERT INTO stat_app_day_country (
|
INSERT INTO stat_app_day_country (
|
||||||
app_code, stat_day, country_id, region_id, active_users, paid_users, recharge_usd_minor,
|
app_code, stat_day, country_id, region_id, active_users, paid_users, recharge_usd_minor,
|
||||||
coin_seller_recharge_usd_minor, google_recharge_usd_minor, updated_at_ms
|
coin_seller_recharge_usd_minor, coin_seller_stock_coin, google_recharge_usd_minor, updated_at_ms
|
||||||
) VALUES
|
) VALUES
|
||||||
('lalu', '2026-06-06', 86, 210, 10, 2, 150, 400, 150, 1),
|
('lalu', '2026-06-06', 86, 210, 10, 2, 150, 400, 880000, 150, 1),
|
||||||
('lalu', '2026-06-06', 66, 210, 5, 1, 100, 0, 100, 1),
|
('lalu', '2026-06-06', 66, 210, 5, 1, 100, 0, 0, 100, 1),
|
||||||
('lalu', '2026-06-06', 99, 220, 3, 1, 900, 0, 900, 1)`); err != nil {
|
('lalu', '2026-06-06', 99, 220, 3, 1, 900, 0, 0, 900, 1)`); err != nil {
|
||||||
t.Fatalf("seed country statistics: %v", err)
|
t.Fatalf("seed country statistics: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -340,17 +412,70 @@ func TestQueryOverviewReturnsCountryBreakdownAndIncludesCoinSellerRecharge(t *te
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query overview: %v", err)
|
t.Fatalf("query overview: %v", err)
|
||||||
}
|
}
|
||||||
if overview.RechargeUSDMinor != 650 || overview.CoinSellerRechargeUSDMinor != 400 {
|
if overview.RechargeUSDMinor != 650 || overview.CoinSellerRechargeUSDMinor != 400 || overview.CoinSellerStockCoin != 880000 {
|
||||||
t.Fatalf("overview recharge mismatch: %+v", overview)
|
t.Fatalf("overview recharge mismatch: %+v", overview)
|
||||||
}
|
}
|
||||||
if len(overview.CountryBreakdown) != 2 {
|
if len(overview.CountryBreakdown) != 2 {
|
||||||
t.Fatalf("country breakdown should come from aggregate query: %+v", overview.CountryBreakdown)
|
t.Fatalf("country breakdown should come from aggregate query: %+v", overview.CountryBreakdown)
|
||||||
}
|
}
|
||||||
if overview.CountryBreakdown[0].CountryID != 86 || overview.CountryBreakdown[0].RechargeUSDMinor != 550 || overview.CountryBreakdown[0].ARPPUUSDMinor != 275 {
|
if overview.CountryBreakdown[0].CountryID != 86 || overview.CountryBreakdown[0].RechargeUSDMinor != 550 || overview.CountryBreakdown[0].CoinSellerStockCoin != 880000 || overview.CountryBreakdown[0].ARPPUUSDMinor != 275 {
|
||||||
t.Fatalf("first country breakdown mismatch: %+v", overview.CountryBreakdown[0])
|
t.Fatalf("first country breakdown mismatch: %+v", overview.CountryBreakdown[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryOverviewReturnsDailyCountryBreakdownAndSuperLuckyGift(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
statsSchema := mysqlschema.New(t, mysqlschema.Config{
|
||||||
|
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
|
||||||
|
DatabasePrefix: "hy_stats_query_daily_country_test",
|
||||||
|
})
|
||||||
|
repository, err := Open(ctx, statsSchema.DSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repository: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = repository.Close() })
|
||||||
|
|
||||||
|
if _, err := repository.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO stat_app_day_country (
|
||||||
|
app_code, stat_day, country_id, region_id, active_users, paid_users, recharge_usd_minor,
|
||||||
|
coin_seller_stock_coin, lucky_gift_turnover, lucky_gift_payout, updated_at_ms
|
||||||
|
) VALUES
|
||||||
|
('lalu', '2026-06-05', 86, 210, 10, 2, 100, 7000, 900, 200, 1),
|
||||||
|
('lalu', '2026-06-05', 66, 210, 5, 1, 80, 1000, 100, 20, 1),
|
||||||
|
('lalu', '2026-06-06', 86, 210, 12, 3, 150, 9000, 1200, 300, 1)`); err != nil {
|
||||||
|
t.Fatalf("seed app country stats: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repository.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO stat_lucky_gift_pool_day_country (
|
||||||
|
app_code, stat_day, country_id, region_id, pool_id, turnover_coin, payout_coin, updated_at_ms
|
||||||
|
) VALUES
|
||||||
|
('lalu', '2026-06-05', 86, 210, 'super_lucky', 700, 120, 1),
|
||||||
|
('lalu', '2026-06-05', 66, 210, 'lucky', 100, 20, 1),
|
||||||
|
('lalu', '2026-06-06', 86, 210, 'super_lucky_v2', 900, 180, 1)`); err != nil {
|
||||||
|
t.Fatalf("seed lucky gift pools: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Date(2026, 6, 5, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
|
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(48*time.Hour/time.Millisecond), RegionID: 210})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query overview: %v", err)
|
||||||
|
}
|
||||||
|
if overview.SuperLuckyGiftTurnover != 1600 || overview.SuperLuckyGiftPayout != 300 || overview.SuperLuckyGiftProfit != 1300 {
|
||||||
|
t.Fatalf("super lucky overview mismatch: %+v", overview)
|
||||||
|
}
|
||||||
|
if len(overview.DailyCountryBreakdown) != 3 {
|
||||||
|
t.Fatalf("daily country breakdown length mismatch: %+v", overview.DailyCountryBreakdown)
|
||||||
|
}
|
||||||
|
first := overview.DailyCountryBreakdown[0]
|
||||||
|
if first.StatDay != "2026-06-05" || first.CountryID != 86 || first.CoinSellerStockCoin != 7000 || first.SuperLuckyGiftTurnover != 700 || first.SuperLuckyGiftProfitRate <= 0 {
|
||||||
|
t.Fatalf("first daily country row mismatch: %+v", first)
|
||||||
|
}
|
||||||
|
if len(overview.ReportMetricSources) == 0 {
|
||||||
|
t.Fatalf("report metric sources should document available and missing report fields")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestQuerySelfGameOverviewUsesRealUserStakeForPlatformProfitRate(t *testing.T) {
|
func TestQuerySelfGameOverviewUsesRealUserStakeForPlatformProfitRate(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
_, file, _, _ := runtime.Caller(0)
|
_, file, _, _ := runtime.Caller(0)
|
||||||
|
|||||||
@ -108,8 +108,13 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
region_id BIGINT NOT NULL DEFAULT 0,
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
new_users BIGINT NOT NULL DEFAULT 0, active_users BIGINT NOT NULL DEFAULT 0, paid_users BIGINT NOT NULL DEFAULT 0,
|
new_users BIGINT NOT NULL DEFAULT 0, active_users BIGINT NOT NULL DEFAULT 0, paid_users BIGINT NOT NULL DEFAULT 0,
|
||||||
recharge_usd_minor BIGINT NOT NULL DEFAULT 0, new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
recharge_usd_minor BIGINT NOT NULL DEFAULT 0, new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, coin_seller_stock_coin BIGINT NOT NULL DEFAULT 0,
|
||||||
|
mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0,
|
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0,
|
||||||
|
coin_total BIGINT NOT NULL DEFAULT 0, consumed_coin BIGINT NOT NULL DEFAULT 0,
|
||||||
|
platform_grant_coin BIGINT NOT NULL DEFAULT 0, manual_grant_coin BIGINT NOT NULL DEFAULT 0,
|
||||||
|
salary_usd_minor BIGINT NOT NULL DEFAULT 0, salary_transfer_coin BIGINT NOT NULL DEFAULT 0,
|
||||||
|
mic_online_ms BIGINT NOT NULL DEFAULT 0, mic_online_users BIGINT NOT NULL DEFAULT 0,
|
||||||
gift_coin_spent BIGINT NOT NULL DEFAULT 0,
|
gift_coin_spent BIGINT NOT NULL DEFAULT 0,
|
||||||
lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payout BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0,
|
lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payout BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0,
|
||||||
game_turnover BIGINT NOT NULL DEFAULT 0, game_players BIGINT NOT NULL DEFAULT 0,
|
game_turnover BIGINT NOT NULL DEFAULT 0, game_players BIGINT NOT NULL DEFAULT 0,
|
||||||
@ -131,6 +136,22 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
PRIMARY KEY (app_code, stat_tz, user_id), KEY idx_stat_user_registration_day (app_code, stat_tz, registered_day, country_id),
|
PRIMARY KEY (app_code, stat_tz, user_id), KEY idx_stat_user_registration_day (app_code, stat_tz, registered_day, country_id),
|
||||||
KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)
|
KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS stat_user_dimension (
|
||||||
|
app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL,
|
||||||
|
country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, user_id),
|
||||||
|
KEY idx_stat_user_dimension_country (app_code, country_id),
|
||||||
|
KEY idx_stat_user_dimension_region (app_code, region_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS stat_user_day_mic (
|
||||||
|
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL,
|
||||||
|
country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
user_id BIGINT NOT NULL, mic_online_ms BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
|
||||||
|
PRIMARY KEY (app_code, stat_tz, stat_day, user_id),
|
||||||
|
KEY idx_stat_user_day_mic_country (app_code, stat_tz, stat_day, country_id),
|
||||||
|
KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_day, region_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
|
`CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
|
||||||
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
||||||
region_id BIGINT NOT NULL DEFAULT 0,
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
@ -261,6 +282,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
"stat_app_day_country",
|
"stat_app_day_country",
|
||||||
"stat_user_day_activity",
|
"stat_user_day_activity",
|
||||||
"stat_user_registration",
|
"stat_user_registration",
|
||||||
|
"stat_user_day_mic",
|
||||||
"stat_recharge_day_payers",
|
"stat_recharge_day_payers",
|
||||||
"stat_lucky_gift_day_payers",
|
"stat_lucky_gift_day_payers",
|
||||||
"stat_lucky_gift_pool_day_country",
|
"stat_lucky_gift_pool_day_country",
|
||||||
@ -284,10 +306,20 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
`ALTER TABLE stat_app_day_country ADD COLUMN game_refund BIGINT NOT NULL DEFAULT 0 AFTER game_payout`,
|
`ALTER TABLE stat_app_day_country ADD COLUMN game_refund BIGINT NOT NULL DEFAULT 0 AFTER game_payout`,
|
||||||
`ALTER TABLE stat_app_day_country ADD COLUMN lucky_gift_payout BIGINT NOT NULL DEFAULT 0 AFTER lucky_gift_turnover`,
|
`ALTER TABLE stat_app_day_country ADD COLUMN lucky_gift_payout BIGINT NOT NULL DEFAULT 0 AFTER lucky_gift_turnover`,
|
||||||
`ALTER TABLE stat_app_day_country ADD COLUMN coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0 AFTER google_recharge_usd_minor`,
|
`ALTER TABLE stat_app_day_country ADD COLUMN coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0 AFTER google_recharge_usd_minor`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN coin_seller_stock_coin BIGINT NOT NULL DEFAULT 0 AFTER coin_seller_recharge_usd_minor`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN coin_total BIGINT NOT NULL DEFAULT 0 AFTER coin_seller_transfer_coin`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN consumed_coin BIGINT NOT NULL DEFAULT 0 AFTER coin_total`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN platform_grant_coin BIGINT NOT NULL DEFAULT 0 AFTER consumed_coin`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN manual_grant_coin BIGINT NOT NULL DEFAULT 0 AFTER platform_grant_coin`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN salary_usd_minor BIGINT NOT NULL DEFAULT 0 AFTER manual_grant_coin`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN salary_transfer_coin BIGINT NOT NULL DEFAULT 0 AFTER salary_usd_minor`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN mic_online_ms BIGINT NOT NULL DEFAULT 0 AFTER salary_transfer_coin`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN mic_online_users BIGINT NOT NULL DEFAULT 0 AFTER mic_online_ms`,
|
||||||
`ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`,
|
`ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`,
|
||||||
`ALTER TABLE stat_app_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
`ALTER TABLE stat_app_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
`ALTER TABLE stat_user_day_activity ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
`ALTER TABLE stat_user_day_activity ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
`ALTER TABLE stat_user_registration ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
`ALTER TABLE stat_user_registration ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
|
`ALTER TABLE stat_user_day_mic ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
`ALTER TABLE stat_recharge_day_payers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
`ALTER TABLE stat_recharge_day_payers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
`ALTER TABLE stat_lucky_gift_day_payers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
`ALTER TABLE stat_lucky_gift_day_payers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
`ALTER TABLE stat_lucky_gift_pool_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
`ALTER TABLE stat_lucky_gift_pool_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
@ -296,6 +328,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
`ALTER TABLE stat_app_day_country ADD KEY idx_stat_app_day_region (app_code, stat_tz, stat_day, region_id)`,
|
`ALTER TABLE stat_app_day_country ADD KEY idx_stat_app_day_region (app_code, stat_tz, stat_day, region_id)`,
|
||||||
`ALTER TABLE stat_user_day_activity ADD KEY idx_stat_user_day_region (app_code, stat_tz, stat_day, region_id)`,
|
`ALTER TABLE stat_user_day_activity ADD KEY idx_stat_user_day_region (app_code, stat_tz, stat_day, region_id)`,
|
||||||
`ALTER TABLE stat_user_registration ADD KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)`,
|
`ALTER TABLE stat_user_registration ADD KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)`,
|
||||||
|
`ALTER TABLE stat_user_day_mic ADD KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_day, region_id)`,
|
||||||
`ALTER TABLE stat_recharge_day_payers ADD KEY idx_stat_recharge_payer_region (app_code, stat_tz, stat_day, region_id)`,
|
`ALTER TABLE stat_recharge_day_payers ADD KEY idx_stat_recharge_payer_region (app_code, stat_tz, stat_day, region_id)`,
|
||||||
`ALTER TABLE stat_lucky_gift_day_payers ADD KEY idx_stat_lucky_payer_region (app_code, stat_tz, stat_day, region_id)`,
|
`ALTER TABLE stat_lucky_gift_day_payers ADD KEY idx_stat_lucky_payer_region (app_code, stat_tz, stat_day, region_id)`,
|
||||||
`ALTER TABLE stat_lucky_gift_pool_day_country ADD KEY idx_stat_lucky_pool_region (app_code, stat_tz, stat_day, region_id)`,
|
`ALTER TABLE stat_lucky_gift_pool_day_country ADD KEY idx_stat_lucky_pool_region (app_code, stat_tz, stat_day, region_id)`,
|
||||||
@ -311,6 +344,8 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
"stat_app_day_country": {"app_code", "stat_tz", "stat_day", "country_id", "region_id"},
|
"stat_app_day_country": {"app_code", "stat_tz", "stat_day", "country_id", "region_id"},
|
||||||
"stat_user_day_activity": {"app_code", "stat_tz", "stat_day", "user_id"},
|
"stat_user_day_activity": {"app_code", "stat_tz", "stat_day", "user_id"},
|
||||||
"stat_user_registration": {"app_code", "stat_tz", "user_id"},
|
"stat_user_registration": {"app_code", "stat_tz", "user_id"},
|
||||||
|
"stat_user_dimension": {"app_code", "user_id"},
|
||||||
|
"stat_user_day_mic": {"app_code", "stat_tz", "stat_day", "user_id"},
|
||||||
"stat_recharge_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"},
|
"stat_recharge_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"},
|
||||||
"stat_lucky_gift_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"},
|
"stat_lucky_gift_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"},
|
||||||
"stat_lucky_gift_pool_day_country": {"app_code", "stat_tz", "stat_day", "country_id", "region_id", "pool_id"},
|
"stat_lucky_gift_pool_day_country": {"app_code", "stat_tz", "stat_day", "country_id", "region_id", "pool_id"},
|
||||||
@ -338,6 +373,10 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
{"stat_user_day_activity", "idx_stat_user_day_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
|
{"stat_user_day_activity", "idx_stat_user_day_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
|
||||||
{"stat_user_registration", "idx_stat_user_registration_day", []string{"app_code", "stat_tz", "registered_day", "country_id"}},
|
{"stat_user_registration", "idx_stat_user_registration_day", []string{"app_code", "stat_tz", "registered_day", "country_id"}},
|
||||||
{"stat_user_registration", "idx_stat_user_registration_region", []string{"app_code", "stat_tz", "registered_day", "region_id"}},
|
{"stat_user_registration", "idx_stat_user_registration_region", []string{"app_code", "stat_tz", "registered_day", "region_id"}},
|
||||||
|
{"stat_user_dimension", "idx_stat_user_dimension_country", []string{"app_code", "country_id"}},
|
||||||
|
{"stat_user_dimension", "idx_stat_user_dimension_region", []string{"app_code", "region_id"}},
|
||||||
|
{"stat_user_day_mic", "idx_stat_user_day_mic_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
|
||||||
|
{"stat_user_day_mic", "idx_stat_user_day_mic_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
|
||||||
{"stat_recharge_day_payers", "idx_stat_recharge_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
|
{"stat_recharge_day_payers", "idx_stat_recharge_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
|
||||||
{"stat_recharge_day_payers", "idx_stat_recharge_payer_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
|
{"stat_recharge_day_payers", "idx_stat_recharge_payer_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
|
||||||
{"stat_lucky_gift_day_payers", "idx_stat_lucky_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
|
{"stat_lucky_gift_day_payers", "idx_stat_lucky_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
|
||||||
@ -451,6 +490,15 @@ type UserRegisteredEvent struct {
|
|||||||
OccurredAtMS int64
|
OccurredAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserRegionChangedEvent struct {
|
||||||
|
AppCode string
|
||||||
|
EventID string
|
||||||
|
UserID int64
|
||||||
|
CountryID int64
|
||||||
|
RegionID int64
|
||||||
|
OccurredAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
type RechargeEvent struct {
|
type RechargeEvent struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
EventID string
|
EventID string
|
||||||
@ -503,6 +551,41 @@ type LuckyGiftRewardEvent struct {
|
|||||||
OccurredAtMS int64
|
OccurredAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PlatformGrantCoinEvent struct {
|
||||||
|
AppCode string
|
||||||
|
EventID string
|
||||||
|
EventType string
|
||||||
|
UserID int64
|
||||||
|
CountryID int64
|
||||||
|
RegionID int64
|
||||||
|
Amount int64
|
||||||
|
OccurredAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type WalletBalanceChangedEvent struct {
|
||||||
|
AppCode string
|
||||||
|
EventID string
|
||||||
|
EventType string
|
||||||
|
UserID int64
|
||||||
|
DimensionUserID int64
|
||||||
|
AssetType string
|
||||||
|
BizType string
|
||||||
|
AvailableDelta int64
|
||||||
|
FrozenDelta int64
|
||||||
|
CountryID int64
|
||||||
|
RegionID int64
|
||||||
|
OccurredAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserMicDailyStatsUpdatedEvent struct {
|
||||||
|
AppCode string
|
||||||
|
EventID string
|
||||||
|
UserID int64
|
||||||
|
StatDate string
|
||||||
|
MicOnlineDeltaMS int64
|
||||||
|
OccurredAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
type UserActiveEvent struct {
|
type UserActiveEvent struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
EventID string
|
EventID string
|
||||||
@ -604,6 +687,9 @@ type SelfGamePoolAdjustmentEvent struct {
|
|||||||
func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegisteredEvent) error {
|
func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegisteredEvent) error {
|
||||||
return r.withEvent(ctx, SourceUser, event.EventID, "UserRegistered", func(tx *sql.Tx, nowMS int64) error {
|
return r.withEvent(ctx, SourceUser, event.EventID, "UserRegistered", func(tx *sql.Tx, nowMS int64) error {
|
||||||
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||||||
|
if err := upsertUserDimension(ctx, tx, event.AppCode, event.UserID, countryID, regionID, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) {
|
for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) {
|
||||||
if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil {
|
if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
@ -637,6 +723,13 @@ func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegist
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ConsumeUserRegionChanged(ctx context.Context, event UserRegionChangedEvent) error {
|
||||||
|
return r.withEvent(ctx, SourceUser, event.EventID, "UserRegionChanged", func(tx *sql.Tx, nowMS int64) error {
|
||||||
|
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||||||
|
return upsertUserDimension(ctx, tx, event.AppCode, event.UserID, countryID, regionID, nowMS)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) error {
|
func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) error {
|
||||||
return r.withEvent(ctx, SourceWallet, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
|
return r.withEvent(ctx, SourceWallet, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
|
||||||
countryID, regionID := normalizeDimension(event.TargetCountryID, event.TargetRegionID)
|
countryID, regionID := normalizeDimension(event.TargetCountryID, event.TargetRegionID)
|
||||||
@ -700,9 +793,10 @@ func (r *Repository) ConsumeCoinSellerStock(ctx context.Context, event CoinSelle
|
|||||||
UPDATE stat_app_day_country
|
UPDATE stat_app_day_country
|
||||||
SET recharge_usd_minor = recharge_usd_minor + ?,
|
SET recharge_usd_minor = recharge_usd_minor + ?,
|
||||||
coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?,
|
coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?,
|
||||||
|
coin_seller_stock_coin = coin_seller_stock_coin + ?,
|
||||||
updated_at_ms = ?
|
updated_at_ms = ?
|
||||||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||||||
`, event.USDMinor, event.USDMinor, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
`, event.USDMinor, event.USDMinor, event.CoinAmount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -823,9 +917,146 @@ func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGift
|
|||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE stat_app_day_country
|
UPDATE stat_app_day_country
|
||||||
SET lucky_gift_payout = lucky_gift_payout + ?, updated_at_ms = ?
|
SET lucky_gift_payout = lucky_gift_payout + ?,
|
||||||
|
platform_grant_coin = platform_grant_coin + ?,
|
||||||
|
updated_at_ms = ?
|
||||||
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||||||
`, event.Amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
`, event.Amount, event.Amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ConsumePlatformGrantCoin(ctx context.Context, event PlatformGrantCoinEvent) error {
|
||||||
|
eventType := strings.TrimSpace(event.EventType)
|
||||||
|
if eventType == "" {
|
||||||
|
eventType = "WalletPlatformGrantCoinCredited"
|
||||||
|
}
|
||||||
|
return r.withEvent(ctx, SourceWallet, event.EventID, eventType, func(tx *sql.Tx, nowMS int64) error {
|
||||||
|
countryID, regionID, err := r.dimensionForUser(ctx, tx, event.AppCode, event.UserID, event.CountryID, event.RegionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
amount := normalizeID(event.Amount)
|
||||||
|
if amount == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) {
|
||||||
|
if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE stat_app_day_country
|
||||||
|
SET platform_grant_coin = platform_grant_coin + ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||||||
|
`, amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ConsumeWalletBalanceChanged(ctx context.Context, event WalletBalanceChangedEvent) error {
|
||||||
|
return r.withEvent(ctx, SourceWallet, event.EventID, "WalletBalanceChanged", func(tx *sql.Tx, nowMS int64) error {
|
||||||
|
dimensionUserID := event.UserID
|
||||||
|
if event.DimensionUserID > 0 {
|
||||||
|
dimensionUserID = event.DimensionUserID
|
||||||
|
}
|
||||||
|
countryID, regionID, err := r.dimensionForUser(ctx, tx, event.AppCode, dimensionUserID, event.CountryID, event.RegionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
totalDelta := event.AvailableDelta + event.FrozenDelta
|
||||||
|
coinTotal, consumed, manualGrant, salaryUSD, salaryTransferCoin := int64(0), int64(0), int64(0), int64(0), int64(0)
|
||||||
|
assetType := strings.ToUpper(strings.TrimSpace(event.AssetType))
|
||||||
|
bizType := strings.ToLower(strings.TrimSpace(event.BizType))
|
||||||
|
if assetType == "COIN" {
|
||||||
|
coinTotal = totalDelta
|
||||||
|
if totalDelta < 0 && isConsumedCoinBizType(bizType) {
|
||||||
|
consumed = -totalDelta
|
||||||
|
}
|
||||||
|
if totalDelta > 0 && bizType == "manual_credit" {
|
||||||
|
manualGrant = totalDelta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if totalDelta > 0 && bizType == "host_salary_settlement" && (assetType == "HOST_SALARY_USD" || assetType == "AGENCY_SALARY_USD") {
|
||||||
|
salaryUSD = totalDelta
|
||||||
|
}
|
||||||
|
if totalDelta > 0 && bizType == "salary_transfer_to_coin_seller" && assetType == "COIN_SELLER_COIN" {
|
||||||
|
salaryTransferCoin = totalDelta
|
||||||
|
}
|
||||||
|
if coinTotal == 0 && consumed == 0 && manualGrant == 0 && salaryUSD == 0 && salaryTransferCoin == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, scope := range statDayScopesFromContext(ctx, event.OccurredAtMS) {
|
||||||
|
if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE stat_app_day_country
|
||||||
|
SET coin_total = coin_total + ?,
|
||||||
|
consumed_coin = consumed_coin + ?,
|
||||||
|
manual_grant_coin = manual_grant_coin + ?,
|
||||||
|
salary_usd_minor = salary_usd_minor + ?,
|
||||||
|
salary_transfer_coin = salary_transfer_coin + ?,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||||||
|
`, coinTotal, consumed, manualGrant, salaryUSD, salaryTransferCoin, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ConsumeUserMicDailyStatsUpdated(ctx context.Context, event UserMicDailyStatsUpdatedEvent) error {
|
||||||
|
return r.withEvent(ctx, SourceUser, event.EventID, "UserMicDailyStatsUpdated", func(tx *sql.Tx, nowMS int64) error {
|
||||||
|
if event.MicOnlineDeltaMS == 0 || strings.TrimSpace(event.StatDate) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
countryID, regionID, err := r.dimensionForUser(ctx, tx, event.AppCode, event.UserID, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, scope := range statDateScopesFromContext(ctx, event.StatDate) {
|
||||||
|
if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
before, err := micOnlineBefore(ctx, tx, event.AppCode, scope.tz, scope.day, event.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
after := before + event.MicOnlineDeltaMS
|
||||||
|
if after < 0 {
|
||||||
|
after = 0
|
||||||
|
}
|
||||||
|
userDelta := int64(0)
|
||||||
|
if before <= 0 && after > 0 {
|
||||||
|
userDelta = 1
|
||||||
|
} else if before > 0 && after <= 0 {
|
||||||
|
userDelta = -1
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO stat_user_day_mic (app_code, stat_tz, stat_day, country_id, region_id, user_id, mic_online_ms, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
country_id = VALUES(country_id),
|
||||||
|
region_id = VALUES(region_id),
|
||||||
|
mic_online_ms = VALUES(mic_online_ms),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
|
`, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID, event.UserID, after, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE stat_app_day_country
|
||||||
|
SET mic_online_ms = mic_online_ms + ?,
|
||||||
|
mic_online_users = GREATEST(mic_online_users + ?, 0),
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
|
||||||
|
`, after-before, userDelta, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1276,6 +1507,79 @@ func applyActive(ctx context.Context, tx *sql.Tx, app string, statTZ string, day
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func upsertUserDimension(ctx context.Context, tx *sql.Tx, app string, userID int64, countryID int64, regionID int64, nowMS int64) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO stat_user_dimension (app_code, user_id, country_id, region_id, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
country_id = CASE WHEN VALUES(country_id) > 0 THEN VALUES(country_id) ELSE country_id END,
|
||||||
|
region_id = CASE WHEN VALUES(region_id) > 0 THEN VALUES(region_id) ELSE region_id END,
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
|
`, appcode.Normalize(app), userID, normalizeID(countryID), normalizeID(regionID), nowMS)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) dimensionForUser(ctx context.Context, tx *sql.Tx, app string, userID int64, countryID int64, regionID int64) (int64, int64, error) {
|
||||||
|
countryID, regionID = normalizeDimension(countryID, regionID)
|
||||||
|
if (countryID > 0 && regionID > 0) || userID <= 0 {
|
||||||
|
return countryID, regionID, nil
|
||||||
|
}
|
||||||
|
var storedCountryID, storedRegionID int64
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT country_id, region_id
|
||||||
|
FROM stat_user_dimension
|
||||||
|
WHERE app_code = ? AND user_id = ?
|
||||||
|
`, appcode.Normalize(app), userID).Scan(&storedCountryID, &storedRegionID)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
if countryID == 0 {
|
||||||
|
countryID = storedCountryID
|
||||||
|
}
|
||||||
|
if regionID == 0 {
|
||||||
|
regionID = storedRegionID
|
||||||
|
}
|
||||||
|
countryID, regionID = normalizeDimension(countryID, regionID)
|
||||||
|
return countryID, regionID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConsumedCoinBizType(value string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||||
|
case "gift_debit", "direct_gift_debit", "vip_purchase", "resource_shop_purchase",
|
||||||
|
"red_packet_create", "game_debit", "wheel_draw_debit", "cp_breakup_fee":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statDateScopesFromContext(ctx context.Context, statDate string) []statDayScope {
|
||||||
|
day := strings.TrimSpace(statDate)
|
||||||
|
if ctx != nil {
|
||||||
|
if statTZ, ok := ctx.Value(statTZScopeContextKey{}).(string); ok {
|
||||||
|
return []statDayScope{{tz: normalizeStatTZ(statTZ), day: day}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []statDayScope{{tz: StatTZUTC, day: day}, {tz: StatTZAsiaShanghai, day: day}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func micOnlineBefore(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, userID int64) (int64, error) {
|
||||||
|
var before int64
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT mic_online_ms
|
||||||
|
FROM stat_user_day_mic
|
||||||
|
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND user_id = ?
|
||||||
|
FOR UPDATE
|
||||||
|
`, appcode.Normalize(app), normalizeStatTZ(statTZ), day, userID).Scan(&before)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return before, err
|
||||||
|
}
|
||||||
|
|
||||||
func ensureAppDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, nowMS int64) error {
|
func ensureAppDay(ctx context.Context, tx *sql.Tx, app string, statTZ string, day string, countryID int64, regionID int64, nowMS int64) error {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT INTO stat_app_day_country (app_code, stat_tz, stat_day, country_id, region_id, updated_at_ms)
|
INSERT INTO stat_app_day_country (app_code, stat_tz, stat_day, country_id, region_id, updated_at_ms)
|
||||||
|
|||||||
@ -19,6 +19,7 @@ display_user_id:
|
|||||||
third_party:
|
third_party:
|
||||||
allowed_providers:
|
allowed_providers:
|
||||||
- "firebase"
|
- "firebase"
|
||||||
|
- "wechat"
|
||||||
firebase:
|
firebase:
|
||||||
project_id: "lalu-party"
|
project_id: "lalu-party"
|
||||||
allowed_sign_in_providers:
|
allowed_sign_in_providers:
|
||||||
|
|||||||
@ -2250,6 +2250,9 @@ func TestTransferSalaryToCoinSellerUsesRegionRateTier(t *testing.T) {
|
|||||||
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
|
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
|
||||||
t.Fatalf("salary transfer must not write player recharge record, got %d", got)
|
t.Fatalf("salary transfer must not write player recharge record, got %d", got)
|
||||||
}
|
}
|
||||||
|
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.biz_type')) = ?", first.TransactionID, "WalletBalanceChanged", "salary_transfer_to_coin_seller"); got != 2 {
|
||||||
|
t.Fatalf("salary transfer balance events must expose biz_type for statistics, got %d", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly 验证别人主播把工资转给币商时,
|
// TestTransferHostSalaryToCoinSellerCreditsTargetSellerOnly 验证别人主播把工资转给币商时,
|
||||||
|
|||||||
@ -81,7 +81,7 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
|
|||||||
return ledger.AssetBalance{}, "", err
|
return ledger.AssetBalance{}, "", err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, command.AssetType, command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, command.AssetType, command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs, bizTypeManualCredit),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.AssetBalance{}, "", err
|
return ledger.AssetBalance{}, "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -481,8 +481,8 @@ func (r *Repository) TransferSalaryToCoinSeller(ctx context.Context, command led
|
|||||||
return ledger.SalaryTransferToCoinSellerReceipt{}, err
|
return ledger.SalaryTransferToCoinSellerReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.SourceUserID, command.SalaryAssetType, -command.SalaryUSDMinor, 0, sourceAfter, source.FrozenAmount, source.Version+1, metadata, nowMs, bizTypeSalaryTransferToCoinSeller),
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, coinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, coinAmount, 0, sellerAfter, seller.FrozenAmount, seller.Version+1, metadata, nowMs, bizTypeSalaryTransferToCoinSeller),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.SalaryTransferToCoinSellerReceipt{}, err
|
return ledger.SalaryTransferToCoinSellerReceipt{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,7 +84,7 @@ func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.Gam
|
|||||||
return ledger.GameCoinChangeReceipt{}, err
|
return ledger.GameCoinChangeReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, delta, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs, bizType),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.GameCoinChangeReceipt{}, err
|
return ledger.GameCoinChangeReceipt{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -320,13 +320,13 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
|
|||||||
|
|
||||||
if chargeSource != giftChargeSourceBag {
|
if chargeSource != giftChargeSourceBag {
|
||||||
if target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin {
|
if target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin {
|
||||||
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs))
|
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs, bizTypeGiftDebit))
|
||||||
} else {
|
} else {
|
||||||
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs))
|
events = append(events, balanceChangedEvent(transactionID, target.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs, bizTypeGiftDebit))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if giftIncomeCoinAmount > 0 && !(target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) {
|
if giftIncomeCoinAmount > 0 && !(target.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) {
|
||||||
events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs))
|
events = append(events, balanceChangedEvent(transactionID, target.CommandID, target.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs, bizTypeGiftDebit))
|
||||||
}
|
}
|
||||||
events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
|
events = append(events, giftDebitedEvent(transactionID, target.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
|
||||||
if giftIncomeCoinAmount > 0 {
|
if giftIncomeCoinAmount > 0 {
|
||||||
|
|||||||
@ -314,13 +314,13 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
|||||||
|
|
||||||
if chargeSource != giftChargeSourceBag {
|
if chargeSource != giftChargeSourceBag {
|
||||||
if command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin {
|
if command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin {
|
||||||
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs))
|
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, giftIncomeCoinAmount-chargeAmount, 0, metadata.BalanceAfter, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs, bizType))
|
||||||
} else {
|
} else {
|
||||||
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs))
|
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderState.account.AvailableAmount, senderState.account.FrozenAmount, senderState.account.Version, metadata, nowMs, bizType))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if giftIncomeCoinAmount > 0 && !(command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) {
|
if giftIncomeCoinAmount > 0 && !(command.TargetUserID == command.SenderUserID && chargeAssetType == ledger.AssetCoin && chargeSource != giftChargeSourceBag) {
|
||||||
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs))
|
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.TargetUserID, ledger.AssetCoin, giftIncomeCoinAmount, 0, metadata.GiftIncomeBalanceAfter, targetCoinState.account.FrozenAmount, targetCoinState.account.Version, metadata, nowMs, bizType))
|
||||||
}
|
}
|
||||||
events = append(events, giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
|
events = append(events, giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
|
||||||
if giftIncomeCoinAmount > 0 {
|
if giftIncomeCoinAmount > 0 {
|
||||||
|
|||||||
@ -511,7 +511,7 @@ func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, tra
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
metadata.HostUSDBalanceAfter = balanceAfter
|
metadata.HostUSDBalanceAfter = balanceAfter
|
||||||
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetHostSalaryUSD, hostUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs))
|
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetHostSalaryUSD, hostUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs, bizTypeHostSalarySettlement))
|
||||||
}
|
}
|
||||||
if hostCoinDelta > 0 {
|
if hostCoinDelta > 0 {
|
||||||
balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, nowMs)
|
balanceAfter, version, err := r.creditSettlementAsset(ctx, tx, transactionID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, nowMs)
|
||||||
@ -519,7 +519,7 @@ func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, tra
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
metadata.HostCoinBalanceAfter = balanceAfter
|
metadata.HostCoinBalanceAfter = balanceAfter
|
||||||
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, balanceAfter, 0, version, metadata, nowMs))
|
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.HostUserID, ledger.AssetCoin, hostCoinDelta, 0, balanceAfter, 0, version, metadata, nowMs, bizTypeHostSalarySettlement))
|
||||||
}
|
}
|
||||||
if agencyUSDDelta > 0 {
|
if agencyUSDDelta > 0 {
|
||||||
// 代理工资进入代理工资钱包,同一用户若同时是主播也不会和主播工资钱包合并。
|
// 代理工资进入代理工资钱包,同一用户若同时是主播也不会和主播工资钱包合并。
|
||||||
@ -528,7 +528,7 @@ func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, tra
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
metadata.AgencyUSDBalanceAfter = balanceAfter
|
metadata.AgencyUSDBalanceAfter = balanceAfter
|
||||||
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.AgencyOwnerUserID, ledger.AssetAgencySalaryUSD, agencyUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs))
|
events = append(events, balanceChangedEvent(transactionID, commandID, metadata.AgencyOwnerUserID, ledger.AssetAgencySalaryUSD, agencyUSDDelta, 0, balanceAfter, 0, version, metadata, nowMs, bizTypeHostSalarySettlement))
|
||||||
}
|
}
|
||||||
return events, nil
|
return events, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -345,7 +345,11 @@ func trimWalletOutboxError(value string) string {
|
|||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, balanceVersion int64, payload any, nowMs int64) walletOutboxEvent {
|
func balanceChangedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, availableAfter int64, frozenAfter int64, balanceVersion int64, payload any, nowMs int64, bizTypes ...string) walletOutboxEvent {
|
||||||
|
bizType := ""
|
||||||
|
if len(bizTypes) > 0 {
|
||||||
|
bizType = strings.TrimSpace(bizTypes[0])
|
||||||
|
}
|
||||||
return walletOutboxEvent{
|
return walletOutboxEvent{
|
||||||
EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType),
|
EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType),
|
||||||
EventType: "WalletBalanceChanged",
|
EventType: "WalletBalanceChanged",
|
||||||
@ -360,6 +364,7 @@ func balanceChangedEvent(transactionID string, commandID string, userID int64, a
|
|||||||
"command_id": commandID,
|
"command_id": commandID,
|
||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"asset_type": assetType,
|
"asset_type": assetType,
|
||||||
|
"biz_type": bizType,
|
||||||
"available_delta": availableDelta,
|
"available_delta": availableDelta,
|
||||||
"frozen_delta": frozenDelta,
|
"frozen_delta": frozenDelta,
|
||||||
"available_after": availableAfter,
|
"available_after": availableAfter,
|
||||||
|
|||||||
@ -127,7 +127,7 @@ func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPack
|
|||||||
return ledger.RedPacketCreateReceipt{}, err
|
return ledger.RedPacketCreateReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, -command.TotalAmount, 0, balanceAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMS),
|
balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, ledger.AssetCoin, -command.TotalAmount, 0, balanceAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMS, bizTypeRedPacketCreate),
|
||||||
redPacketCreatedEvent(transactionID, command.CommandID, metadata, nowMS),
|
redPacketCreatedEvent(transactionID, command.CommandID, metadata, nowMS),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.RedPacketCreateReceipt{}, err
|
return ledger.RedPacketCreateReceipt{}, err
|
||||||
|
|||||||
@ -300,7 +300,7 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou
|
|||||||
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
return resourcedomain.ResourceShopPurchaseReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -item.CoinPrice, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -item.CoinPrice, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs, bizTypeResourceShopPurchase),
|
||||||
resourceOutboxEvent("ResourceGranted", command.CommandID, command.UserID, item.ResourceID, map[string]any{"grant_id": grantID, "resource": item.Resource, "source": resourcedomain.GrantSourceResourceShop}, nowMs),
|
resourceOutboxEvent("ResourceGranted", command.CommandID, command.UserID, item.ResourceID, map[string]any{"grant_id": grantID, "resource": item.Resource, "source": resourcedomain.GrantSourceResourceShop}, nowMs),
|
||||||
{
|
{
|
||||||
EventID: eventID(transactionID, "ResourceShopItemPurchased", command.UserID, resourceOutboxAsset),
|
EventID: eventID(transactionID, "ResourceShopItemPurchased", command.UserID, resourceOutboxAsset),
|
||||||
|
|||||||
@ -88,7 +88,7 @@ func (r *Repository) DebitCPBreakupFee(ctx context.Context, command ledger.CPBre
|
|||||||
return ledger.CPBreakupFeeReceipt{}, err
|
return ledger.CPBreakupFeeReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs, bizTypeCPBreakupFee),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.CPBreakupFeeReceipt{}, err
|
return ledger.CPBreakupFeeReceipt{}, err
|
||||||
}
|
}
|
||||||
@ -180,7 +180,7 @@ func (r *Repository) DebitWheelDraw(ctx context.Context, command ledger.WheelDra
|
|||||||
return ledger.WheelDrawDebitReceipt{}, err
|
return ledger.WheelDrawDebitReceipt{}, err
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -command.Amount, 0, balance.AvailableAmount, balance.FrozenAmount, balance.Version, metadata, nowMs, bizTypeWheelDrawDebit),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return ledger.WheelDrawDebitReceipt{}, err
|
return ledger.WheelDrawDebitReceipt{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -142,7 +142,7 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
|
|||||||
vipEventType = "VipUpgraded"
|
vipEventType = "VipUpgraded"
|
||||||
}
|
}
|
||||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -level.PriceCoin, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
|
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -level.PriceCoin, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs, bizTypeVIPPurchase),
|
||||||
{
|
{
|
||||||
EventID: eventID(transactionID, vipEventType, command.UserID, "VIP"),
|
EventID: eventID(transactionID, vipEventType, command.UserID, "VIP"),
|
||||||
EventType: vipEventType,
|
EventType: vipEventType,
|
||||||
|
|||||||
1059
tools/databi-real-flow/main.go
Normal file
1059
tools/databi-real-flow/main.go
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user