新BI系统

This commit is contained in:
local 2026-06-28 15:11:56 +08:00
parent 4060aee8af
commit 1409529f10
29 changed files with 3112 additions and 158 deletions

View File

@ -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; \

View File

@ -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

View File

@ -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}"

View 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."

View 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

View 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';

View File

@ -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 {

View 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 ""
}

View File

@ -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, &regionID); 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,

View File

@ -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',

View File

@ -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:

View File

@ -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()

View File

@ -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, `

View File

@ -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)

View File

@ -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)

View File

@ -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:

View File

@ -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 验证别人主播把工资转给币商时,

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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 {

View File

@ -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 {

View File

@ -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
} }

View File

@ -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,

View File

@ -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

View File

@ -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),

View File

@ -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
} }

View File

@ -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,

File diff suppressed because it is too large Load Diff