新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_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:
@ -48,6 +48,7 @@ build:
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
run:
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.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
./scripts/apply-local-mysql-initdb.sh
@ -69,6 +70,7 @@ admin-up: admin-deps admin
# `admin-deps` 拉起后台本地运行所需的 MySQL、Redis 和 user-service。
admin-deps:
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.sh
docker compose up -d mysql redis
./scripts/apply-local-mysql-initdb.sh
./scripts/apply-local-rocketmq-topics.sh
@ -92,6 +94,7 @@ admin-build:
# 完整 up 结束后自动启动 admin 前台进程。
up:
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.sh
@if [ -z "$(SERVICE_ID)" ]; then \
docker compose up -d mysql redis; \
./scripts/apply-local-mysql-initdb.sh; \
@ -114,9 +117,14 @@ db-init:
./scripts/resolve-compose-container-conflicts.sh mysql
./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: check-service
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.sh
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
./scripts/apply-local-mysql-initdb.sh; \
./scripts/apply-local-rocketmq-topics.sh; \
@ -178,6 +186,7 @@ resolve-compose-conflicts:
# `up-service` 是 `up SERVICE=...` 的内部实现。
up-service: check-service
./scripts/resolve-compose-container-conflicts.sh
./scripts/prepare-local-mysql-initdb.sh
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
./scripts/apply-local-mysql-initdb.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/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
- ${DEPLOY_PLATFORM_ROOT:-../deploy-platform}/deploy/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
- ${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/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
- ./tmp/mysql/initdb/006_admin_database.sql:/docker-entrypoint-initdb.d/006_admin_database.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/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/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/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:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
interval: 5s

View File

@ -29,6 +29,7 @@ SQL_FILES=(
# Keep MySQL as the only required dependency for this script. Business services
# 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
docker compose up -d mysql >/dev/null
@ -49,8 +50,11 @@ fi
for sql_file in "${SQL_FILES[@]}"; do
if [[ ! -f "${sql_file}" ]]; then
printf 'missing mysql init file: %s\n' "${sql_file}" >&2
exit 1
# Platform/admin seed files may live in a sibling deploy repository that
# 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
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
newUserRechargeUSDMinor int64
coinSellerRechargeUSDMinor int64
coinSellerStockCoin int64
mifaPayRechargeUSDMinor int64
googleRechargeUSDMinor int64
coinSellerTransferCoin int64
coinTotal int64
consumedCoin int64
platformGrantCoin int64
manualGrantCoin int64
salaryUSDMinor int64
salaryTransferCoin int64
micOnlineMS int64
micOnlineUsers int64
}
type userRegistrationRow struct {
@ -59,12 +68,20 @@ type activeRow struct {
firstMS int64
}
type micRow struct {
day string
userID int64
dim countryRegion
micOnlineMS int64
}
type rebuildState struct {
appDays map[appDayKey]*appDayAgg
registrations []userRegistrationRow
registeredByUserDay map[string]struct{}
payersByKey map[string]payerRow
activeByKey map[string]activeRow
micByKey map[string]micRow
userIDs map[int64]struct{}
userDims map[int64]countryRegion
knownUsers map[int64]struct{}
@ -113,6 +130,7 @@ func main() {
registeredByUserDay: map[string]struct{}{},
payersByKey: map[string]payerRow{},
activeByKey: map[string]activeRow{},
micByKey: map[string]micRow{},
userIDs: map[int64]struct{}{},
userDims: map[int64]countryRegion{},
knownUsers: map[int64]struct{}{},
@ -127,6 +145,9 @@ func main() {
if err := collectRechargeUserIDs(ctx, walletDB, state, app, windowStartMS, windowEndMS); err != nil {
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 {
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 {
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 {
log.Fatalf("stage/apply: %v", err)
@ -310,6 +340,82 @@ func collectRechargeUserIDs(ctx context.Context, db *sql.DB, state *rebuildState
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 {
userIDs := make([]int64, 0, len(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 {
// 币商进货是平台收到的 USDT 充值,按 seller 的国家/区域进入总充值,但不写入用户金币转账金额。
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
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)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var sellerUserID, amountMicro, createdAtMS int64
if err := rows.Scan(&sellerUserID, &amountMicro, &createdAtMS); err != nil {
var sellerUserID, amountMicro, coinAmount, createdAtMS int64
if err := rows.Scan(&sellerUserID, &amountMicro, &coinAmount, &createdAtMS); err != nil {
return err
}
usdMinor := amountMicro / 10_000
@ -479,10 +585,217 @@ func rebuildCoinSellerStock(ctx context.Context, db *sql.DB, state *rebuildState
agg := state.ensureAgg(day, dim)
agg.rechargeUSDMinor += usdMinor
agg.coinSellerRechargeUSDMinor += usdMinor
agg.coinSellerStockCoin += coinAmount
}
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 {
key := appDayKey{day: day, countryID: dim.countryID, regionID: dim.regionID}
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++
}
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 {
_, ok := s.registeredByUserDay[userDayKey(day, userID)]
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,
recharge_usd_minor = 0, new_user_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,
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 = ?
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`,
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_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_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 {
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, `
INSERT INTO stat_app_day_country (
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,
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,
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
ON DUPLICATE KEY UPDATE
new_users = VALUES(new_users), active_users = VALUES(active_users), paid_users = VALUES(paid_users),
recharge_usd_minor = VALUES(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_stock_coin = VALUES(coin_seller_stock_coin),
mifapay_recharge_usd_minor = VALUES(mifapay_recharge_usd_minor),
google_recharge_usd_minor = VALUES(google_recharge_usd_minor),
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 {
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 {
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()
}
@ -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,
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,
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_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)
)`,
`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,
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 {
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 (
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,
mifapay_recharge_usd_minor, google_recharge_usd_minor, coin_seller_transfer_coin
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
app, statTZ, key.day, key.countryID, key.regionID, agg.newUsers, agg.activeUsers, agg.paidUsers,
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
}
}
@ -679,6 +1052,13 @@ func insertStageRows(ctx context.Context, tx *sql.Tx, state *rebuildState, app s
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
}
@ -687,7 +1067,9 @@ func validateStage(ctx context.Context, tx *sql.Tx) error {
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
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
}
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"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/gamemq"
"hyapp/pkg/roommq"
"hyapp/pkg/usermq"
@ -107,6 +108,10 @@ func main() {
if err := resetReplayTarget(ctx, statsDB, *appCode, statTZValue, window); err != nil {
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)
if err := replayUser(ctx, scopeCtx, repo, dbs["user"], *appCode, statTZValue, window); err != nil {
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 {
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",
*appCode, statTZValue, window.startDay, window.endDay, summary.userRows, summary.walletRows, summary.roomRows, summary.gameRows)
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, dimensionRows)
}
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 err error
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 {
return replaySummary{}, err
}
}
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 {
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_user_day_activity", "stat_day"},
{"stat_user_registration", "registered_day"},
{"stat_user_day_mic", "stat_day"},
{"stat_recharge_day_payers", "stat_day"},
{"stat_lucky_gift_day_payers", "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 {
rows, err := db.QueryContext(ctx, `
SELECT app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms
FROM user_outbox
WHERE app_code = ? AND event_type = 'UserRegistered' AND created_at_ms >= ? AND created_at_ms < ?
ORDER BY created_at_ms ASC, event_id ASC`, app, window.startMS, window.endMS)
query, args := outboxWindowQuery(`
app_code, event_id, event_type, aggregate_type, aggregate_id, CAST(payload_json AS CHAR), created_at_ms`, "user_outbox", app,
userReplayEventTypes(), window, "ORDER BY created_at_ms ASC, event_id ASC")
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return err
}
@ -280,11 +285,66 @@ func replayUser(ctx context.Context, scopeCtx context.Context, repo *mysqlstorag
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 {
query, args := outboxWindowQuery(`
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,
[]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...)
if err != nil {
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
}
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 {
return []string{
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 '总充值美元最小单位',
new_user_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 充值美元最小单位',
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 充值美元最小单位',
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 '礼物消耗金币',
lucky_gift_turnover 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)
) 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 (
app_code VARCHAR(32) NOT NULL,
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 出现两套解析口径。
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 {
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 {
event, ok, err := rechargeEvent(body)
if err != nil {
return err
}
if ok {
if event, ok, err := rechargeEvent(body); err != nil || ok {
if err != nil || !ok {
return err
}
return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event)
}
stock, ok, err := coinSellerStockEvent(body)
if err != nil {
return err
}
if ok {
if stock, ok, err := coinSellerStockEvent(body); err != nil || ok {
if err != nil || !ok {
return err
}
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 {
return err
}
return repo.ConsumeLuckyGiftReward(appcode.WithContext(ctx, reward.AppCode), reward)
return repo.ConsumeWalletBalanceChanged(appcode.WithContext(ctx, event.AppCode), event)
}
// ConsumeRoomOutboxBody 只投影房间统计需要的礼物和真人进房事件;礼物事实额外产生 sender 当日活跃,保持实时和回灌一致。
@ -262,6 +284,44 @@ func userRegisteredEvent(body []byte) (mysqlstorage.UserRegisteredEvent, bool, e
}, 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) {
message, err := walletmq.DecodeWalletOutboxMessage(body)
if err != nil {
@ -384,6 +444,73 @@ func luckyGiftRewardEvent(body []byte) (mysqlstorage.LuckyGiftRewardEvent, bool,
}, 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) {
envelope, _, err := roommq.DecodeRoomOutboxMessage(body)
if err != nil {
@ -635,6 +762,13 @@ func firstNonZeroInt64(values ...int64) int64 {
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 {
switch value := payload[key].(type) {
case bool:

View File

@ -17,7 +17,9 @@ import (
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()
_, file, _, _ := runtime.Caller(0)
schema := mysqlschema.New(t, mysqlschema.Config{
@ -231,7 +233,7 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
if err != nil {
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)
}
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) {
occurredAt := time.Date(2026, 6, 25, 8, 0, 0, 0, time.UTC).UnixMilli()

View File

@ -6,6 +6,7 @@ import (
"math"
"sort"
"strconv"
"strings"
"time"
"hyapp/pkg/appcode"
@ -23,86 +24,116 @@ type OverviewQuery struct {
}
type Overview struct {
AppCode string `json:"app_code"`
StatTZ string `json:"stat_tz"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
CountryID int64 `json:"country_id"`
NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
GiftCoinSpent int64 `json:"gift_coin_spent"`
RealRoomRobotNormalGiftCoin int64 `json:"real_room_robot_normal_gift_coin"`
RealRoomRobotLuckyGiftCoin int64 `json:"real_room_robot_lucky_gift_coin"`
RealRoomRobotSuperGiftCoin int64 `json:"real_room_robot_super_gift_coin"`
RealRoomRobotTotalGiftCoin int64 `json:"real_room_robot_total_gift_coin"`
RealRoomRobotGiftRoomCount int64 `json:"real_room_robot_gift_room_count"`
RealRoomRobotAvgRoomGiftCoin int64 `json:"real_room_robot_avg_room_gift_coin"`
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
LuckyGiftPayers int64 `json:"lucky_gift_payers"`
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
GameTurnover int64 `json:"game_turnover"`
GamePayout int64 `json:"game_payout"`
GameRefund int64 `json:"game_refund"`
GamePlayers int64 `json:"game_players"`
GameProfit int64 `json:"game_profit"`
GameProfitRate float64 `json:"game_profit_rate"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
UPValueUSDMinor int64 `json:"up_value_usd_minor"`
RechargeDeltaRate float64 `json:"recharge_delta_rate"`
NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"`
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"`
GameRanking []GameRank `json:"game_ranking"`
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
CountryBreakdown []CountryBreakdown `json:"country_breakdown"`
AppCode string `json:"app_code"`
StatTZ string `json:"stat_tz"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
CountryID int64 `json:"country_id"`
NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_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"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
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"`
RealRoomRobotNormalGiftCoin int64 `json:"real_room_robot_normal_gift_coin"`
RealRoomRobotLuckyGiftCoin int64 `json:"real_room_robot_lucky_gift_coin"`
RealRoomRobotSuperGiftCoin int64 `json:"real_room_robot_super_gift_coin"`
RealRoomRobotTotalGiftCoin int64 `json:"real_room_robot_total_gift_coin"`
RealRoomRobotGiftRoomCount int64 `json:"real_room_robot_gift_room_count"`
RealRoomRobotAvgRoomGiftCoin int64 `json:"real_room_robot_avg_room_gift_coin"`
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
LuckyGiftPayers int64 `json:"lucky_gift_payers"`
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
GameTurnover int64 `json:"game_turnover"`
GamePayout int64 `json:"game_payout"`
GameRefund int64 `json:"game_refund"`
GamePlayers int64 `json:"game_players"`
GameProfit int64 `json:"game_profit"`
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"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
UPValueUSDMinor int64 `json:"up_value_usd_minor"`
RechargeDeltaRate float64 `json:"recharge_delta_rate"`
NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"`
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 {
StatDay string `json:"stat_day"`
Label string `json:"label"`
NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUsers int64 `json:"recharge_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
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"`
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
StatDay string `json:"stat_day"`
Label string `json:"label"`
NewUsers int64 `json:"new_users"`
ActiveUsers int64 `json:"active_users"`
PaidUsers int64 `json:"paid_users"`
NewPaidUsers int64 `json:"new_paid_users"`
RechargeUsers int64 `json:"recharge_users"`
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_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"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
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"`
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 {
@ -135,6 +166,11 @@ type LuckyGiftPoolStat struct {
ProfitRate float64 `json:"profit_rate"`
}
type superLuckyAggregate struct {
Turnover int64
Payout int64
}
type CountryBreakdown struct {
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
@ -144,9 +180,19 @@ type CountryBreakdown struct {
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
NewUserRechargeUSDMinor int64 `json:"new_user_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"`
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
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"`
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
@ -160,10 +206,29 @@ type CountryBreakdown struct {
GamePlayers int64 `json:"game_players"`
GameProfit int64 `json:"game_profit"`
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"`
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 {
AppCode string
StatTZ string
@ -361,6 +426,7 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
return Overview{}, err
}
overview.LuckyGiftPools = pools
overview.SuperLuckyGiftTurnover, overview.SuperLuckyGiftPayout = sumSuperLuckyGiftPools(pools)
if statTZ == StatTZUTC {
// 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 {
@ -368,6 +434,7 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
} else if ok {
overview.LuckyGiftPools = 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 {
@ -395,6 +462,11 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
return Overview{}, err
}
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
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
}
overview.DailySeries = dailySeries
overview.ReportMetricSources = reportMetricSources()
return overview, nil
}
@ -483,8 +556,11 @@ func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ,
row := r.db.QueryRowContext(ctx, `
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(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(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),
@ -492,7 +568,7 @@ func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, statTZ,
FROM stat_app_day_country
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?`+filter, args...)
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, nil
@ -565,8 +641,11 @@ func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay
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(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(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(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() {
var item DailySeriesItem
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
}
item.Label = item.StatDay
@ -600,6 +679,17 @@ func (r *Repository) queryDailySeries(ctx context.Context, app, statTZ, startDay
for index := range out {
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 activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil {
return nil, err
@ -654,8 +744,11 @@ func applyDailySeriesDerivedMetrics(item *DailySeriesItem) {
}
item.RechargeUsers = item.PaidUsers
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.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers)
}
func applyOverviewDerivedMetrics(overview *Overview) {
@ -672,9 +765,12 @@ func applyOverviewDerivedMetrics(overview *Overview) {
overview.ARPUUSDMinor = div(overview.RechargeUSDMinor, overview.ActiveUsers)
overview.ARPPUUSDMinor = div(overview.RechargeUSDMinor, overview.PaidUsers)
overview.UPValueUSDMinor = overview.ARPPUUSDMinor
overview.AvgMicOnlineMS = div(overview.MicOnlineMS, overview.MicOnlineUsers)
overview.LuckyGiftProfit = overview.LuckyGiftTurnover - overview.LuckyGiftPayout
overview.LuckyGiftPayoutRate = ratio(overview.LuckyGiftPayout, 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) {
@ -772,8 +868,11 @@ func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, sta
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(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(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),
@ -790,13 +889,83 @@ func (r *Repository) queryCountryBreakdown(ctx context.Context, app, statTZ, sta
out := []CountryBreakdown{}
for rows.Next() {
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
}
applyCountryBreakdownDerivedMetrics(&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) {
@ -811,9 +980,12 @@ func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover)
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
item.AvgMicOnlineMS = div(item.MicOnlineMS, item.MicOnlineUsers)
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, 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) {
@ -966,6 +1138,141 @@ func sumLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
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) {
filter, args := selfGameFilter(app, statTZ, startDay, endDay, gameID, countryID, regionID)
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) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
@ -327,11 +399,11 @@ func TestQueryOverviewReturnsCountryBreakdownAndIncludesCoinSellerRecharge(t *te
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_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
('lalu', '2026-06-06', 86, 210, 10, 2, 150, 400, 150, 1),
('lalu', '2026-06-06', 66, 210, 5, 1, 100, 0, 100, 1),
('lalu', '2026-06-06', 99, 220, 3, 1, 900, 0, 900, 1)`); err != nil {
('lalu', '2026-06-06', 86, 210, 10, 2, 150, 400, 880000, 150, 1),
('lalu', '2026-06-06', 66, 210, 5, 1, 100, 0, 0, 100, 1),
('lalu', '2026-06-06', 99, 220, 3, 1, 900, 0, 0, 900, 1)`); err != nil {
t.Fatalf("seed country statistics: %v", err)
}
@ -340,17 +412,70 @@ func TestQueryOverviewReturnsCountryBreakdownAndIncludesCoinSellerRecharge(t *te
if err != nil {
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)
}
if len(overview.CountryBreakdown) != 2 {
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])
}
}
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) {
ctx := context.Background()
_, 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,
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,
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,
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,
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,
@ -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),
KEY idx_stat_user_registration_region (app_code, stat_tz, registered_day, region_id)
) 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 (
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,
@ -261,6 +282,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
"stat_app_day_country",
"stat_user_day_activity",
"stat_user_registration",
"stat_user_day_mic",
"stat_recharge_day_payers",
"stat_lucky_gift_day_payers",
"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 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_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_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_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_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`,
@ -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_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_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_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)`,
@ -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_user_day_activity": {"app_code", "stat_tz", "stat_day", "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_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"},
@ -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_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_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_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"}},
@ -451,6 +490,15 @@ type UserRegisteredEvent struct {
OccurredAtMS int64
}
type UserRegionChangedEvent struct {
AppCode string
EventID string
UserID int64
CountryID int64
RegionID int64
OccurredAtMS int64
}
type RechargeEvent struct {
AppCode string
EventID string
@ -503,6 +551,41 @@ type LuckyGiftRewardEvent struct {
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 {
AppCode string
EventID string
@ -604,6 +687,9 @@ type SelfGamePoolAdjustmentEvent struct {
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 {
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) {
if err := ensureAppDay(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, nowMS); err != nil {
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 {
return r.withEvent(ctx, SourceWallet, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
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
SET recharge_usd_minor = recharge_usd_minor + ?,
coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?,
coin_seller_stock_coin = coin_seller_stock_coin + ?,
updated_at_ms = ?
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
}
}
@ -823,9 +917,146 @@ func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGift
}
if _, err := tx.ExecContext(ctx, `
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 = ?
`, 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
}
}
@ -1276,6 +1507,79 @@ func applyActive(ctx context.Context, tx *sql.Tx, app string, statTZ string, day
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 {
_, err := tx.ExecContext(ctx, `
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:
allowed_providers:
- "firebase"
- "wechat"
firebase:
project_id: "lalu-party"
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 {
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 验证别人主播把工资转给币商时,

View File

@ -81,7 +81,7 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
return ledger.AssetBalance{}, "", err
}
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 {
return ledger.AssetBalance{}, "", err
}

View File

@ -481,8 +481,8 @@ func (r *Repository) TransferSalaryToCoinSeller(ctx context.Context, command led
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}
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.SellerUserID, ledger.AssetCoinSellerCoin, coinAmount, 0, sellerAfter, seller.FrozenAmount, seller.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, bizTypeSalaryTransferToCoinSeller),
}); err != nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, err
}

View File

@ -84,7 +84,7 @@ func (r *Repository) ApplyGameCoinChange(ctx context.Context, command ledger.Gam
return ledger.GameCoinChangeReceipt{}, err
}
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 {
return ledger.GameCoinChangeReceipt{}, err
}

View File

@ -320,13 +320,13 @@ func (r *Repository) BatchDebitGift(ctx context.Context, command ledger.BatchDeb
if chargeSource != giftChargeSourceBag {
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 {
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) {
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))
if giftIncomeCoinAmount > 0 {

View File

@ -314,13 +314,13 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if chargeSource != giftChargeSourceBag {
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 {
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) {
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))
if giftIncomeCoinAmount > 0 {

View File

@ -511,7 +511,7 @@ func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, tra
return nil, err
}
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 {
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
}
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 {
// 代理工资进入代理工资钱包,同一用户若同时是主播也不会和主播工资钱包合并。
@ -528,7 +528,7 @@ func (r *Repository) applyHostSalaryCredits(ctx context.Context, tx *sql.Tx, tra
return nil, err
}
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
}

View File

@ -345,7 +345,11 @@ func trimWalletOutboxError(value string) string {
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{
EventID: eventID(transactionID, "WalletBalanceChanged", userID, assetType),
EventType: "WalletBalanceChanged",
@ -360,6 +364,7 @@ func balanceChangedEvent(transactionID string, commandID string, userID int64, a
"command_id": commandID,
"user_id": userID,
"asset_type": assetType,
"biz_type": bizType,
"available_delta": availableDelta,
"frozen_delta": frozenDelta,
"available_after": availableAfter,

View File

@ -127,7 +127,7 @@ func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPack
return ledger.RedPacketCreateReceipt{}, err
}
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),
}); err != nil {
return ledger.RedPacketCreateReceipt{}, err

View File

@ -300,7 +300,7 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou
return resourcedomain.ResourceShopPurchaseReceipt{}, err
}
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),
{
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
}
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 {
return ledger.CPBreakupFeeReceipt{}, err
}
@ -180,7 +180,7 @@ func (r *Repository) DebitWheelDraw(ctx context.Context, command ledger.WheelDra
return ledger.WheelDrawDebitReceipt{}, err
}
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 {
return ledger.WheelDrawDebitReceipt{}, err
}

View File

@ -142,7 +142,7 @@ func (r *Repository) PurchaseVip(ctx context.Context, command ledger.PurchaseVip
vipEventType = "VipUpgraded"
}
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"),
EventType: vipEventType,

File diff suppressed because it is too large Load Diff