Fix dashboard lucky gift and active retention CDC

This commit is contained in:
zhx 2026-05-26 16:51:13 +08:00
parent 4877da1d74
commit 1fa43185c1
10 changed files with 754 additions and 18 deletions

View File

@ -12,6 +12,8 @@ import (
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"dashboard-cdc-worker/internal/cdc" "dashboard-cdc-worker/internal/cdc"
"dashboard-cdc-worker/internal/config" "dashboard-cdc-worker/internal/config"
@ -83,6 +85,24 @@ func main() {
repo.StartRechargeReconciler(ctx, logger, projector) repo.StartRechargeReconciler(ctx, logger, projector)
repo.StartGiftReconciler(ctx, logger, projector) repo.StartGiftReconciler(ctx, logger, projector)
if cfg.Mongo.Enabled {
mongoClient, err := mongo.Connect(options.Client().ApplyURI(cfg.Mongo.URI))
if err != nil {
logger.Error("connect mongo failed", "error", err)
os.Exit(1)
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = mongoClient.Disconnect(shutdownCtx)
}()
if err := mongoClient.Ping(ctx, nil); err != nil {
logger.Error("ping mongo failed", "error", err)
os.Exit(1)
}
repo.StartActiveReconciler(ctx, mongoClient.Database(cfg.Mongo.Database), logger)
}
worker, err := cdc.NewWorker(cfg, repo, projector, logger, metrics) worker, err := cdc.NewWorker(cfg, repo, projector, logger, metrics)
if err != nil { if err != nil {
logger.Error("create cdc worker failed", "error", err) logger.Error("create cdc worker failed", "error", err)

View File

@ -9,6 +9,9 @@ CDC_BATCH_SIZE=1000
CDC_FLUSH_INTERVAL=3s CDC_FLUSH_INTERVAL=3s
CDC_BACKFILL_LOCK_WAIT=1s CDC_BACKFILL_LOCK_WAIT=1s
CDC_MASTER_POSITION_POLL_INTERVAL=1m CDC_MASTER_POSITION_POLL_INTERVAL=1m
MONGO_ENABLED=false
MONGO_URI=
MONGO_DATABASE=
DASHBOARD_SYS_ORIGINS=LIKEI DASHBOARD_SYS_ORIGINS=LIKEI
DASHBOARD_STAT_TIMEZONES=Asia/Riyadh DASHBOARD_STAT_TIMEZONES=Asia/Riyadh
DASHBOARD_STORAGE_TIMEZONE=Asia/Riyadh DASHBOARD_STORAGE_TIMEZONE=Asia/Riyadh
@ -16,6 +19,10 @@ DASHBOARD_CLEANUP_INTERVAL=30m
DASHBOARD_DEDUP_RETENTION=72h DASHBOARD_DEDUP_RETENTION=72h
DASHBOARD_USER_METRIC_RETENTION_DAYS=120 DASHBOARD_USER_METRIC_RETENTION_DAYS=120
DASHBOARD_CLEANUP_BATCH_SIZE=5000 DASHBOARD_CLEANUP_BATCH_SIZE=5000
DASHBOARD_ACTIVE_RECONCILE_ENABLED=false
DASHBOARD_ACTIVE_RECONCILE_INTERVAL=10m
DASHBOARD_ACTIVE_RECONCILE_LOOKBACK_DAYS=35
DASHBOARD_ACTIVE_RECONCILE_BATCH_LIMIT=200000
REDIS_ENABLED=false REDIS_ENABLED=false
REDIS_ADDR=127.0.0.1:6379 REDIS_ADDR=127.0.0.1:6379
HEALTH_ADDR=:2910 HEALTH_ADDR=:2910

8
go.mod
View File

@ -7,6 +7,7 @@ require (
github.com/go-sql-driver/mysql v1.9.3 github.com/go-sql-driver/mysql v1.9.3
github.com/redis/go-redis/v9 v9.12.1 github.com/redis/go-redis/v9 v9.12.1
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
go.mongodb.org/mongo-driver/v2 v2.3.1
) )
require ( require (
@ -15,15 +16,22 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/uuid v1.3.0 // indirect github.com/google/uuid v1.3.0 // indirect
github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/compress v1.17.8 // indirect
github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec // indirect github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec // indirect
github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a // indirect github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a // indirect
github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d // indirect github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect go.uber.org/zap v1.27.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/text v0.24.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
) )

36
go.sum
View File

@ -20,6 +20,10 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
@ -49,6 +53,17 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.3.1 h1:WrCgSzO7dh1/FrePud9dK5fKNZOE97q5EQimGkos7Wo=
go.mongodb.org/mongo-driver/v2 v2.3.1/go.mod h1:jHeEDJHJq7tm6ZF45Issun9dbogjfnPySb1vXA7EeAI=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
@ -65,17 +80,38 @@ go.uber.org/zap v1.19.0/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191108193012-7d206e10da11/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@ -12,11 +12,13 @@ import (
type Config struct { type Config struct {
MySQL MySQLConfig MySQL MySQLConfig
Mongo MongoConfig
Redis RedisConfig Redis RedisConfig
CDC CDCConfig CDC CDCConfig
Dashboard DashboardConfig Dashboard DashboardConfig
Recharge RechargeReconcileConfig Recharge RechargeReconcileConfig
Gift GiftReconcileConfig Gift GiftReconcileConfig
Active ActiveReconcileConfig
Health HealthConfig Health HealthConfig
} }
@ -31,6 +33,12 @@ type MySQLConfig struct {
MaxIdleConns int MaxIdleConns int
} }
type MongoConfig struct {
Enabled bool
URI string
Database string
}
type RedisConfig struct { type RedisConfig struct {
Enabled bool Enabled bool
Addr string Addr string
@ -78,6 +86,13 @@ type GiftReconcileConfig struct {
BatchLimit int BatchLimit int
} }
type ActiveReconcileConfig struct {
Enabled bool
Interval time.Duration
LookbackDays int
BatchLimit int
}
type HealthConfig struct { type HealthConfig struct {
Addr string Addr string
} }
@ -94,6 +109,11 @@ func Load() (Config, error) {
MaxOpenConns: envInt("MYSQL_MAX_OPEN_CONNS", 8), MaxOpenConns: envInt("MYSQL_MAX_OPEN_CONNS", 8),
MaxIdleConns: envInt("MYSQL_MAX_IDLE_CONNS", 4), MaxIdleConns: envInt("MYSQL_MAX_IDLE_CONNS", 4),
}, },
Mongo: MongoConfig{
Enabled: envBool("MONGO_ENABLED", false),
URI: env("MONGO_URI", env("LIKEI_MONGO_URI", "")),
Database: env("MONGO_DATABASE", env("LIKEI_MONGO_DB", "")),
},
Redis: RedisConfig{ Redis: RedisConfig{
Enabled: envBool("REDIS_ENABLED", false), Enabled: envBool("REDIS_ENABLED", false),
Addr: env("REDIS_ADDR", ""), Addr: env("REDIS_ADDR", ""),
@ -136,6 +156,12 @@ func Load() (Config, error) {
CurrentDayLag: envDuration("DASHBOARD_GIFT_RECONCILE_CURRENT_DAY_LAG", 5*time.Minute), CurrentDayLag: envDuration("DASHBOARD_GIFT_RECONCILE_CURRENT_DAY_LAG", 5*time.Minute),
BatchLimit: envInt("DASHBOARD_GIFT_RECONCILE_BATCH_LIMIT", 50000), BatchLimit: envInt("DASHBOARD_GIFT_RECONCILE_BATCH_LIMIT", 50000),
}, },
Active: ActiveReconcileConfig{
Enabled: envBool("DASHBOARD_ACTIVE_RECONCILE_ENABLED", false),
Interval: envDuration("DASHBOARD_ACTIVE_RECONCILE_INTERVAL", 10*time.Minute),
LookbackDays: envInt("DASHBOARD_ACTIVE_RECONCILE_LOOKBACK_DAYS", 35),
BatchLimit: envInt("DASHBOARD_ACTIVE_RECONCILE_BATCH_LIMIT", 200000),
},
Health: HealthConfig{ Health: HealthConfig{
Addr: env("HEALTH_ADDR", ":2910"), Addr: env("HEALTH_ADDR", ":2910"),
}, },
@ -153,6 +179,12 @@ func Load() (Config, error) {
if cfg.Redis.Enabled && cfg.Redis.Addr == "" { if cfg.Redis.Enabled && cfg.Redis.Addr == "" {
return Config{}, errors.New("REDIS_ADDR is required when REDIS_ENABLED=true") return Config{}, errors.New("REDIS_ADDR is required when REDIS_ENABLED=true")
} }
if cfg.Mongo.Enabled && cfg.Mongo.URI == "" {
return Config{}, errors.New("MONGO_URI or LIKEI_MONGO_URI is required when MONGO_ENABLED=true")
}
if cfg.Mongo.Enabled && cfg.Mongo.Database == "" {
return Config{}, errors.New("MONGO_DATABASE or LIKEI_MONGO_DB is required when MONGO_ENABLED=true")
}
if cfg.CDC.ServerID == 0 { if cfg.CDC.ServerID == 0 {
return Config{}, errors.New("CDC_SERVER_ID must be greater than 0") return Config{}, errors.New("CDC_SERVER_ID must be greater than 0")
} }
@ -168,6 +200,12 @@ func Load() (Config, error) {
if cfg.Gift.BatchLimit <= 0 { if cfg.Gift.BatchLimit <= 0 {
cfg.Gift.BatchLimit = 50000 cfg.Gift.BatchLimit = 50000
} }
if cfg.Active.LookbackDays <= 0 {
cfg.Active.LookbackDays = 35
}
if cfg.Active.BatchLimit <= 0 {
cfg.Active.BatchLimit = 200000
}
if cfg.CDC.StartMode != "checkpoint" && cfg.CDC.StartMode != "current" { if cfg.CDC.StartMode != "checkpoint" && cfg.CDC.StartMode != "current" {
return Config{}, fmt.Errorf("unsupported CDC_START_MODE=%s", cfg.CDC.StartMode) return Config{}, fmt.Errorf("unsupported CDC_START_MODE=%s", cfg.CDC.StartMode)
} }

View File

@ -18,7 +18,7 @@ var walletGameEvents = map[string]struct{}{
func (m *Mapper) mapWalletGame(ctx context.Context, schema string, table string, action string, row Row) ([]Contribution, error) { func (m *Mapper) mapWalletGame(ctx context.Context, schema string, table string, action string, row Row) ([]Contribution, error) {
eventType := row.String("event_type") eventType := row.String("event_type")
if eventType == "LUCKY_GIFT" || isWalletGiftConsumeEvent(eventType) { if isWalletGiftConsumeEvent(eventType) {
return m.mapWalletGift(ctx, schema, table, action, row) return m.mapWalletGift(ctx, schema, table, action, row)
} }
if !isWalletGameEvent(eventType) { if !isWalletGameEvent(eventType) {
@ -87,11 +87,6 @@ func (m *Mapper) mapWalletGift(ctx context.Context, schema string, table string,
c.EventKey = WalletMetricEventKey(schema, table, rowPK(row)) c.EventKey = WalletMetricEventKey(schema, table, rowPK(row))
} }
switch { switch {
case eventType == "LUCKY_GIFT" && recordType == 1:
c.LuckyGiftTotalFlow = amount
c.LuckyGiftUser = true
case eventType == "LUCKY_GIFT" && recordType == 0:
c.LuckyGiftPayout = amount
case isWalletGiftConsumeEvent(eventType) && recordType == 1: case isWalletGiftConsumeEvent(eventType) && recordType == 1:
c.GiftConsume = amount c.GiftConsume = amount
default: default:

View File

@ -0,0 +1,101 @@
package dashboard
import (
"context"
"database/sql"
"testing"
"time"
"github.com/go-mysql-org/go-mysql/mysql"
"github.com/shopspring/decimal"
)
type walletMapperTestStore struct {
users map[int64]UserCountry
}
func (s walletMapperTestStore) WithTx(context.Context, func(*sql.Tx) error) error { return nil }
func (s walletMapperTestStore) WithDashboardLock(context.Context, time.Duration, func() error) error {
return nil
}
func (s walletMapperTestStore) MarkEventApplied(context.Context, *sql.Tx, string, string) (bool, error) {
return true, nil
}
func (s walletMapperTestStore) SaveCheckpoint(context.Context, mysql.Position) error { return nil }
func (s walletMapperTestStore) LoadCheckpoint(context.Context) (mysql.Position, bool, error) {
return mysql.Position{}, false, nil
}
func (s walletMapperTestStore) Cleanup(context.Context) (int64, error) { return 0, nil }
func (s walletMapperTestStore) UpsertUserCountry(context.Context, *sql.Tx, UserCountry) error {
return nil
}
func (s walletMapperTestStore) UserCountry(_ context.Context, userID int64) (UserCountry, bool, error) {
user, ok := s.users[userID]
return user, ok, nil
}
func (s walletMapperTestStore) ResolveWalletGame(context.Context, string, string, string) (string, string, string, error) {
return "TEST", "1", "Test", nil
}
func (s walletMapperTestStore) ApplyDailyContribution(context.Context, *sql.Tx, Contribution, string) error {
return nil
}
func (s walletMapperTestStore) ApplyPeriodContribution(context.Context, *sql.Tx, Contribution, string) error {
return nil
}
func (s walletMapperTestStore) ApplyGamePeriodContribution(context.Context, *sql.Tx, Contribution, string) error {
return nil
}
func (s walletMapperTestStore) ApplyContributionBatch(context.Context, *sql.Tx, []Contribution, string, []string) error {
return nil
}
func TestWalletLuckyGiftIsNotMappedAsGiftConsume(t *testing.T) {
mapper := NewMapper(walletMapperTestStore{users: map[int64]UserCountry{
1001: {UserID: 1001, SysOrigin: "LIKEI", Country: Country{Code: "BD", Name: "Bangladesh"}},
}}, []string{"LIKEI"}, "UNKNOWN")
row := Row{
"id": int64(11),
"user_id": int64(1001),
"sys_origin": "LIKEI",
"event_type": "LUCKY_GIFT",
"type": int64(1),
"penny_amount": decimal.NewFromInt(10000),
"create_time": time.Now().UnixMilli(),
}
contributions, err := mapper.Map(context.Background(), "likei_wallet", "wallet_gold_asset_record_1", "insert", row)
if err != nil {
t.Fatal(err)
}
if len(contributions) != 0 {
t.Fatalf("expected wallet LUCKY_GIFT to be ignored, got %#v", contributions)
}
}
func TestWalletGiveGiftIsMappedAsGiftConsume(t *testing.T) {
mapper := NewMapper(walletMapperTestStore{users: map[int64]UserCountry{
1001: {UserID: 1001, SysOrigin: "LIKEI", Country: Country{Code: "BD", Name: "Bangladesh"}},
}}, []string{"LIKEI"}, "UNKNOWN")
row := Row{
"id": int64(12),
"user_id": int64(1001),
"sys_origin": "LIKEI",
"event_type": "GIVE_GIFT_V3",
"type": int64(1),
"penny_amount": decimal.NewFromInt(10000),
"create_time": time.Now().UnixMilli(),
}
contributions, err := mapper.Map(context.Background(), "likei_wallet", "wallet_gold_asset_record_1", "insert", row)
if err != nil {
t.Fatal(err)
}
if len(contributions) != 1 {
t.Fatalf("expected one gift contribution, got %d", len(contributions))
}
if !contributions[0].GiftConsume.Equal(decimal.NewFromInt(100)) {
t.Fatalf("expected gift consume 100, got %s", contributions[0].GiftConsume.String())
}
}

View File

@ -0,0 +1,500 @@
package storage
import (
"context"
"database/sql"
"log/slog"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"dashboard-cdc-worker/internal/dashboard"
)
const activeLogCollection = "user_daily_active_log"
type activeLogRow struct {
UserID int64 `bson:"userId"`
ActiveDay string `bson:"activeDate"`
SysOrigin string `bson:"sysOrigin"`
}
type activeDayStats struct {
active map[string]map[int64]struct{}
}
type activeRetentionValues struct {
countryName string
activeUsers int64
d1User int64
d1Base int64
d7User int64
d7Base int64
d30User int64
d30Base int64
}
type countryCount struct {
name string
count int64
}
func (r *MySQLRepository) StartActiveReconciler(ctx context.Context, db *mongo.Database, logger *slog.Logger) {
if !r.cfg.Active.Enabled || r.cfg.Active.Interval <= 0 {
return
}
run := func() {
if err := r.RebuildRecentActiveRetention(ctx, db); err != nil {
if r.metrics != nil {
r.metrics.RecordError(err)
}
logger.Error("dashboard active reconcile failed", "error", err)
return
}
logger.Info("dashboard active reconcile applied", "lookbackDays", r.cfg.Active.LookbackDays)
}
go func() {
run()
ticker := time.NewTicker(r.cfg.Active.Interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
run()
}
}
}()
}
func (r *MySQLRepository) RebuildRecentActiveRetention(ctx context.Context, db *mongo.Database) error {
location, err := time.LoadLocation(r.cfg.Dashboard.StorageTimezone)
if err != nil {
location = time.UTC
}
today := time.Now().In(location)
end := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1)
start := end.AddDate(0, 0, -r.cfg.Active.LookbackDays)
return r.RebuildActiveRetentionRange(ctx, db, start, end)
}
func (r *MySQLRepository) RebuildActiveRetentionRange(ctx context.Context, db *mongo.Database, start, end time.Time) error {
events, err := r.loadActiveLogs(ctx, db, start, end)
if err != nil {
return err
}
users, err := r.loadUserCountriesByID(ctx, collectActiveUserIDs(events))
if err != nil {
return err
}
return r.WithDashboardLock(ctx, r.cfg.CDC.BackfillLockWait, func() error {
return r.WithTx(ctx, func(tx *sql.Tx) error {
for day := start; day.Before(end); day = day.AddDate(0, 0, 1) {
if err := r.replaceActiveRetentionDay(ctx, tx, day, events, users); err != nil {
return err
}
}
for _, statTimezone := range r.cfg.Dashboard.StatTimezones {
if err := r.rebuildAllPeriodFromDays(ctx, tx, statTimezone); err != nil {
return err
}
}
return nil
})
})
}
func (r *MySQLRepository) loadActiveLogs(ctx context.Context, db *mongo.Database, start, end time.Time) (map[string]map[int64]struct{}, error) {
filter := bson.D{
{Key: "activeDate", Value: bson.D{{Key: "$gte", Value: start.Format("2006-01-02")}, {Key: "$lt", Value: end.Format("2006-01-02")}}},
{Key: "sysOrigin", Value: bson.D{{Key: "$in", Value: r.cfg.Dashboard.SysOrigins}}},
}
findOptions := options.Find().SetProjection(bson.D{
{Key: "userId", Value: 1},
{Key: "activeDate", Value: 1},
{Key: "sysOrigin", Value: 1},
})
if r.cfg.Active.BatchLimit > 0 {
findOptions.SetLimit(int64(r.cfg.Active.BatchLimit))
}
cursor, err := db.Collection(activeLogCollection).Find(ctx, filter, findOptions)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)
events := make(map[string]map[int64]struct{})
for cursor.Next(ctx) {
var row activeLogRow
if err := cursor.Decode(&row); err != nil {
return nil, err
}
if row.UserID == 0 || row.ActiveDay == "" || !r.allowedOrigin(row.SysOrigin) {
continue
}
users := events[row.ActiveDay]
if users == nil {
users = make(map[int64]struct{})
events[row.ActiveDay] = users
}
users[row.UserID] = struct{}{}
}
return events, cursor.Err()
}
func collectActiveUserIDs(events map[string]map[int64]struct{}) []int64 {
seen := make(map[int64]struct{})
for _, users := range events {
for userID := range users {
seen[userID] = struct{}{}
}
}
out := make([]int64, 0, len(seen))
for userID := range seen {
out = append(out, userID)
}
return out
}
func (r *MySQLRepository) loadUserCountriesByID(ctx context.Context, ids []int64) (map[int64]dashboard.UserCountry, error) {
result := make(map[int64]dashboard.UserCountry)
for start := 0; start < len(ids); start += bulkInsertChunkSize {
end := min(start+bulkInsertChunkSize, len(ids))
args := make([]interface{}, 0, end-start)
for _, id := range ids[start:end] {
args = append(args, id)
}
rows, err := r.db.QueryContext(ctx, `
SELECT user_id, sys_origin, country_code, country_name, is_deleted, user_created_at, user_updated_at
FROM dashboard_cdc_user_country
WHERE user_id IN `+inPlaceholders(end-start), args...)
if err != nil {
return nil, err
}
for rows.Next() {
var user dashboard.UserCountry
var createdAt, updatedAt sql.NullTime
if err := rows.Scan(&user.UserID, &user.SysOrigin, &user.Country.Code, &user.Country.Name,
&user.IsDeleted, &createdAt, &updatedAt); err != nil {
_ = rows.Close()
return nil, err
}
if createdAt.Valid {
user.CreatedAt = createdAt.Time
}
if updatedAt.Valid {
user.UpdatedAt = updatedAt.Time
}
result[user.UserID] = user
}
if err := rows.Close(); err != nil {
return nil, err
}
}
return result, nil
}
func (r *MySQLRepository) replaceActiveRetentionDay(ctx context.Context, tx *sql.Tx, day time.Time,
events map[string]map[int64]struct{}, users map[int64]dashboard.UserCountry) error {
dayKey := day.Format("2006-01-02")
statsByOrigin := make(map[string]map[string]*activeDayStats)
for userID := range events[dayKey] {
user, ok := users[userID]
if !ok || user.IsDeleted || !r.allowedOrigin(user.SysOrigin) {
continue
}
stats := statsByOrigin[user.SysOrigin]
if stats == nil {
stats = make(map[string]*activeDayStats)
statsByOrigin[user.SysOrigin] = stats
}
stat := stats[user.Country.Code]
if stat == nil {
stat = &activeDayStats{active: make(map[string]map[int64]struct{})}
stats[user.Country.Code] = stat
}
active := stat.active[user.Country.Name]
if active == nil {
active = make(map[int64]struct{})
stat.active[user.Country.Name] = active
}
active[userID] = struct{}{}
}
retentionByOrigin, err := r.retentionStatsForDay(ctx, day, events[dayKey], users)
if err != nil {
return err
}
for _, statTimezone := range r.cfg.Dashboard.StatTimezones {
for _, sysOrigin := range r.cfg.Dashboard.SysOrigins {
if _, err := tx.ExecContext(ctx, `
UPDATE country_dashboard_period_metric
SET daily_active_user = 0,
d1_retention_user = 0, d1_retention_base_user = 0,
d7_retention_user = 0, d7_retention_base_user = 0,
d30_retention_user = 0, d30_retention_base_user = 0,
refreshed_at = NOW()
WHERE period_type = 'DAY' AND period_key = ? AND stat_timezone = ? AND sys_origin = ?`, dayKey, statTimezone, sysOrigin); err != nil {
return err
}
if err := r.upsertActiveRetentionRows(ctx, tx, day, statTimezone, sysOrigin,
statsByOrigin[sysOrigin], retentionByOrigin[sysOrigin]); err != nil {
return err
}
}
}
return nil
}
func (r *MySQLRepository) retentionStatsForDay(ctx context.Context, day time.Time,
activeUsers map[int64]struct{}, users map[int64]dashboard.UserCountry) (map[string]map[string]activeRetentionValues, error) {
out := make(map[string]map[string]activeRetentionValues)
for _, days := range []int{1, 7, 30} {
bases, err := r.loadRetentionBase(ctx, day.AddDate(0, 0, -days))
if err != nil {
return nil, err
}
for origin, countries := range bases {
originOut := out[origin]
if originOut == nil {
originOut = make(map[string]activeRetentionValues)
out[origin] = originOut
}
for country, item := range countries {
stats := originOut[country]
stats.countryName = item.name
switch days {
case 1:
stats.d1Base = item.count
case 7:
stats.d7Base = item.count
case 30:
stats.d30Base = item.count
}
originOut[country] = stats
}
}
}
for userID := range activeUsers {
user, ok := users[userID]
if !ok || user.CreatedAt.IsZero() || user.IsDeleted || !r.allowedOrigin(user.SysOrigin) {
continue
}
for _, days := range []int{1, 7, 30} {
if user.CreatedAt.Format("2006-01-02") != day.AddDate(0, 0, -days).Format("2006-01-02") {
continue
}
originOut := out[user.SysOrigin]
if originOut == nil {
originOut = make(map[string]activeRetentionValues)
out[user.SysOrigin] = originOut
}
country := user.Country.Code
stats := originOut[country]
if stats.countryName == "" {
stats.countryName = user.Country.Name
}
switch days {
case 1:
stats.d1User++
case 7:
stats.d7User++
case 30:
stats.d30User++
}
originOut[country] = stats
}
}
return out, nil
}
func (r *MySQLRepository) loadRetentionBase(ctx context.Context, cohortDay time.Time) (map[string]map[string]countryCount, error) {
args := stringArgs(r.cfg.Dashboard.SysOrigins)
args = append(args, cohortDay, cohortDay.AddDate(0, 0, 1))
rows, err := r.db.QueryContext(ctx, `
SELECT sys_origin, country_code, MAX(country_name), COUNT(1)
FROM dashboard_cdc_user_country
WHERE is_deleted = 0
AND sys_origin IN `+inStringPlaceholders(len(r.cfg.Dashboard.SysOrigins))+`
AND user_created_at >= ?
AND user_created_at < ?
GROUP BY sys_origin, country_code`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]map[string]countryCount)
for rows.Next() {
var origin, country, name string
var count int64
if err := rows.Scan(&origin, &country, &name, &count); err != nil {
return nil, err
}
countries := result[origin]
if countries == nil {
countries = make(map[string]countryCount)
result[origin] = countries
}
countries[country] = countryCount{name: name, count: count}
}
return result, rows.Err()
}
func (r *MySQLRepository) upsertActiveRetentionRows(ctx context.Context, tx *sql.Tx, day time.Time,
statTimezone string, sysOrigin string, active map[string]*activeDayStats, retention map[string]activeRetentionValues) error {
values := make(map[string]activeRetentionValues)
for country, stat := range active {
item := values[country]
for name, users := range stat.active {
item.countryName = name
item.activeUsers += int64(len(users))
}
values[country] = item
}
for country, item := range retention {
existing := values[country]
if existing.countryName == "" {
existing.countryName = item.countryName
}
existing.d1User = item.d1User
existing.d1Base = item.d1Base
existing.d7User = item.d7User
existing.d7Base = item.d7Base
existing.d30User = item.d30User
existing.d30Base = item.d30Base
values[country] = existing
}
if len(values) == 0 {
return nil
}
dayKey := day.Format("2006-01-02")
args := make([]interface{}, 0, len(values)*16)
for country, item := range values {
name := item.countryName
if name == "" {
name = country
}
args = append(args, dashboard.PeriodDay, dayKey, statTimezone, dayKey, day,
day.AddDate(0, 0, 1), sysOrigin, country, name, item.activeUsers,
item.d1User, item.d1Base, item.d7User, item.d7Base, item.d30User, item.d30Base)
}
_, err := tx.ExecContext(ctx, `
INSERT INTO country_dashboard_period_metric (
period_type, period_key, stat_timezone, period_name, period_start_date, period_end_date,
sys_origin, country_code, country_name, daily_active_user,
d1_retention_user, d1_retention_base_user,
d7_retention_user, d7_retention_base_user,
d30_retention_user, d30_retention_base_user,
refreshed_at
) VALUES `+placeholders(len(values), 16)+`
ON DUPLICATE KEY UPDATE
period_name = VALUES(period_name),
period_start_date = VALUES(period_start_date),
period_end_date = VALUES(period_end_date),
country_name = VALUES(country_name),
daily_active_user = VALUES(daily_active_user),
d1_retention_user = VALUES(d1_retention_user),
d1_retention_base_user = VALUES(d1_retention_base_user),
d7_retention_user = VALUES(d7_retention_user),
d7_retention_base_user = VALUES(d7_retention_base_user),
d30_retention_user = VALUES(d30_retention_user),
d30_retention_base_user = VALUES(d30_retention_base_user),
refreshed_at = NOW()`, args...)
return err
}
func (r *MySQLRepository) rebuildAllPeriodFromDays(ctx context.Context, tx *sql.Tx, statTimezone string) error {
for _, sysOrigin := range r.cfg.Dashboard.SysOrigins {
if _, err := tx.ExecContext(ctx, `
DELETE FROM country_dashboard_period_user_metric
WHERE period_type = 'ALL' AND period_key = 'ALL' AND stat_timezone = ? AND sys_origin = ?`, statTimezone, sysOrigin); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO country_dashboard_period_user_metric (
period_type, period_key, stat_timezone, period_name, period_start_date, period_end_date,
sys_origin, country_code, country_name, user_id, metric_type
)
SELECT DISTINCT 'ALL', 'ALL', ?, '全部', NULL, NULL,
sys_origin, country_code, country_name, user_id, metric_type
FROM country_dashboard_period_user_metric
WHERE period_type = 'DAY' AND stat_timezone = ? AND sys_origin = ?`, statTimezone, statTimezone, sysOrigin); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
DELETE FROM country_dashboard_period_metric
WHERE period_type = 'ALL' AND period_key = 'ALL' AND stat_timezone = ? AND sys_origin = ?`, statTimezone, sysOrigin); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO country_dashboard_period_metric (
period_type, period_key, stat_timezone, period_name, period_start_date, period_end_date,
sys_origin, country_code, country_name,
country_new_user, daily_active_user, new_user_recharge, official_recharge, mifapay_recharge,
google_recharge, dealer_recharge, salary_exchange, salary_transfer, gift_consume,
lucky_gift_total_flow, lucky_gift_user, lucky_gift_payout, lucky_gift_anchor_share,
game_total_flow, game_user, game_payout,
d1_retention_user, d1_retention_base_user, d7_retention_user, d7_retention_base_user,
d30_retention_user, d30_retention_base_user, refreshed_at
)
SELECT 'ALL', 'ALL', ?, '全部', NULL, NULL,
d.sys_origin, d.country_code, MAX(d.country_name),
IFNULL(MAX(u.country_new_user), 0),
IFNULL(SUM(d.daily_active_user), 0),
IFNULL(SUM(d.new_user_recharge), 0),
IFNULL(SUM(d.official_recharge), 0),
IFNULL(SUM(d.mifapay_recharge), 0),
IFNULL(SUM(d.google_recharge), 0),
IFNULL(SUM(d.dealer_recharge), 0),
IFNULL(SUM(d.salary_exchange), 0),
IFNULL(SUM(d.salary_transfer), 0),
IFNULL(SUM(d.gift_consume), 0),
IFNULL(SUM(d.lucky_gift_total_flow), 0),
IFNULL(MAX(u.lucky_gift_user), 0),
IFNULL(SUM(d.lucky_gift_payout), 0),
IFNULL(SUM(d.lucky_gift_anchor_share), 0),
IFNULL(SUM(d.game_total_flow), 0),
IFNULL(MAX(u.game_user), 0),
IFNULL(SUM(d.game_payout), 0),
IFNULL(SUM(d.d1_retention_user), 0),
IFNULL(SUM(d.d1_retention_base_user), 0),
IFNULL(SUM(d.d7_retention_user), 0),
IFNULL(SUM(d.d7_retention_base_user), 0),
IFNULL(SUM(d.d30_retention_user), 0),
IFNULL(SUM(d.d30_retention_base_user), 0),
NOW()
FROM country_dashboard_period_metric d
LEFT JOIN (
SELECT country_code,
SUM(metric_type = 'COUNTRY_NEW_USER') AS country_new_user,
SUM(metric_type = 'LUCKY_GIFT_USER') AS lucky_gift_user,
SUM(metric_type = 'GAME_USER') AS game_user
FROM country_dashboard_period_user_metric
WHERE period_type = 'ALL' AND period_key = 'ALL' AND stat_timezone = ? AND sys_origin = ?
GROUP BY country_code
) u ON u.country_code = d.country_code
WHERE d.period_type = 'DAY' AND d.stat_timezone = ? AND d.sys_origin = ?
GROUP BY d.sys_origin, d.country_code`, statTimezone, statTimezone, sysOrigin, statTimezone, sysOrigin); err != nil {
return err
}
}
return nil
}
func inPlaceholders(count int) string {
return "(" + placeholders(count, 1) + ")"
}
func inStringPlaceholders(count int) string {
return "(" + placeholders(count, 1) + ")"
}
func stringArgs(values []string) []interface{} {
args := make([]interface{}, 0, len(values))
for _, value := range values {
args = append(args, value)
}
return args
}

View File

@ -54,11 +54,6 @@ func (r *MySQLRepository) BuildGiftReconcileEvents(ctx context.Context, since ti
UserCreatedAt: row.user.CreatedAt, UserCreatedAt: row.user.CreatedAt,
} }
switch { switch {
case row.eventType == "LUCKY_GIFT" && row.recordType == 1:
contribution.LuckyGiftTotalFlow = row.amount
contribution.LuckyGiftUser = true
case row.eventType == "LUCKY_GIFT" && row.recordType == 0:
contribution.LuckyGiftPayout = row.amount
case isGiftConsumeEvent(row.eventType) && row.recordType == 1: case isGiftConsumeEvent(row.eventType) && row.recordType == 1:
contribution.GiftConsume = row.amount contribution.GiftConsume = row.amount
default: default:
@ -186,10 +181,9 @@ FROM %s.%s w
JOIN %s.user_base_info u ON u.id = w.user_id JOIN %s.user_base_info u ON u.id = w.user_id
WHERE w.id > ? WHERE w.id > ?
AND w.create_time >= ? AND w.create_time >= ?
AND ( AND w.event_type LIKE 'GIVE_GIFT%%'
w.event_type = 'LUCKY_GIFT' AND w.event_type NOT LIKE '%%LUCKY_GIFT%%'
OR (w.event_type LIKE 'GIVE_GIFT%%' AND w.event_type NOT LIKE '%%LUCKY_GIFT%%' AND w.type = 1) AND w.type = 1
)
ORDER BY w.id ASC ORDER BY w.id ASC
LIMIT ?`, quoteIdent(r.cfg.MySQL.WalletSchema), quoteIdent(table), quoteIdent(r.cfg.MySQL.Schema)) LIMIT ?`, quoteIdent(r.cfg.MySQL.WalletSchema), quoteIdent(table), quoteIdent(r.cfg.MySQL.Schema))
rows, err := r.db.QueryContext(ctx, query, r.cfg.Dashboard.UnknownCountryCode, rows, err := r.db.QueryContext(ctx, query, r.cfg.Dashboard.UnknownCountryCode,

View File

@ -34,7 +34,8 @@ func (r *MySQLRepository) EnsureSchema(ctx context.Context) error {
user_updated_at datetime NULL, user_updated_at datetime NULL,
updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id), PRIMARY KEY (user_id),
KEY idx_dashboard_cdc_user_country_origin_country (sys_origin, country_code) KEY idx_dashboard_cdc_user_country_origin_country (sys_origin, country_code),
KEY idx_dashboard_cdc_user_country_origin_created (sys_origin, user_created_at, country_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
createDailyMetricTable, createDailyMetricTable,
createDailyUserMetricTable, createDailyUserMetricTable,
@ -128,8 +129,12 @@ func (r *MySQLRepository) ensureMetricColumns(ctx context.Context) error {
"ALTER TABLE country_dashboard_daily_metric ADD COLUMN salary_transfer decimal(20,2) NOT NULL DEFAULT 0 AFTER salary_exchange"); err != nil { "ALTER TABLE country_dashboard_daily_metric ADD COLUMN salary_transfer decimal(20,2) NOT NULL DEFAULT 0 AFTER salary_exchange"); err != nil {
return err return err
} }
return r.addColumnIfMissing(ctx, "country_dashboard_period_metric", "salary_transfer", if err := r.addColumnIfMissing(ctx, "country_dashboard_period_metric", "salary_transfer",
"ALTER TABLE country_dashboard_period_metric ADD COLUMN salary_transfer decimal(20,2) NOT NULL DEFAULT 0 AFTER salary_exchange") "ALTER TABLE country_dashboard_period_metric ADD COLUMN salary_transfer decimal(20,2) NOT NULL DEFAULT 0 AFTER salary_exchange"); err != nil {
return err
}
return r.addIndexIfMissing(ctx, "dashboard_cdc_user_country", "idx_dashboard_cdc_user_country_origin_created",
"ALTER TABLE dashboard_cdc_user_country ADD INDEX idx_dashboard_cdc_user_country_origin_created (sys_origin, user_created_at, country_code)")
} }
func (r *MySQLRepository) addColumnIfMissing(ctx context.Context, table string, column string, ddl string) error { func (r *MySQLRepository) addColumnIfMissing(ctx context.Context, table string, column string, ddl string) error {
@ -153,6 +158,27 @@ WHERE table_schema = DATABASE()
return nil return nil
} }
func (r *MySQLRepository) addIndexIfMissing(ctx context.Context, table string, indexName string, ddl string) error {
var exists int
err := r.db.QueryRowContext(ctx, `
SELECT COUNT(1)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = ?
AND index_name = ?
`, table, indexName).Scan(&exists)
if err != nil {
return err
}
if exists > 0 {
return nil
}
if _, err := r.db.ExecContext(ctx, ddl); err != nil && !isDuplicateKeyError(err) {
return err
}
return nil
}
func isDuplicateColumnError(err error) bool { func isDuplicateColumnError(err error) bool {
var mysqlErr *driverMysql.MySQLError var mysqlErr *driverMysql.MySQLError
if err == nil { if err == nil {
@ -164,6 +190,17 @@ func isDuplicateColumnError(err error) bool {
return fmt.Sprint(err) == "Error 1060 (42S21): Duplicate column name 'salary_transfer'" return fmt.Sprint(err) == "Error 1060 (42S21): Duplicate column name 'salary_transfer'"
} }
func isDuplicateKeyError(err error) bool {
var mysqlErr *driverMysql.MySQLError
if err == nil {
return false
}
if errors.As(err, &mysqlErr) {
return mysqlErr.Number == 1061
}
return false
}
const createPeriodUserMetricTable = `CREATE TABLE IF NOT EXISTS country_dashboard_period_user_metric ( const createPeriodUserMetricTable = `CREATE TABLE IF NOT EXISTS country_dashboard_period_user_metric (
id bigint NOT NULL AUTO_INCREMENT, id bigint NOT NULL AUTO_INCREMENT,
period_type varchar(16) NOT NULL, period_type varchar(16) NOT NULL,