添加埋点以及Yumi数据

This commit is contained in:
zhx 2026-07-03 14:43:43 +08:00
parent 0480304483
commit 0cd30e222d
21 changed files with 1809 additions and 3 deletions

1
.gitignore vendored
View File

@ -31,3 +31,4 @@ redis-data/
.tmp/
/tmp/
.claude/settings.local.json

View File

@ -0,0 +1,108 @@
# App 埋点 Flutter 对接
## 地址
```http
POST /api/v1/app/events
Content-Type: application/json
```
可登录也可未登录调用。已登录时建议带:
```http
Authorization: Bearer <access_token>
```
未登录时必须传 `device_id` 或 Header
```http
X-Device-ID: <device_id>
```
## 参数
Body
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `events` | array | 是 | 埋点列表,单次最多 50 条。 |
`events[]`
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `event_id` | string | 是 | 客户端生成的唯一事件 ID。同一事件重试必须复用同一个值。 |
| `event_name` | string | 是 | 事件名,例如 `banner_view``banner_click``page_open`。 |
| `event_type` | string | 否 | 事件类型,例如 `view``click``submit`。 |
| `screen` | string | 否 | 页面或模块,例如 `home``room``me`。 |
| `target_type` | string | 否 | 目标类型,例如 `banner``button``room`。 |
| `target_id` | string | 否 | 目标 ID例如 banner ID、房间 ID。 |
| `device_id` | string | 未登录必填 | 设备 ID。也可以放到 `X-Device-ID`。 |
| `session_id` | string | 否 | 客户端本地会话 ID。登录态下服务端优先使用 token 里的 session。 |
| `platform` | string | 否 | `android``ios`。不传时读取 `X-App-Platform` / `X-Platform`。 |
| `app_version` | string | 否 | App 版本。不传时读取 `X-App-Version` / `X-Client-Version`。 |
| `language` | string | 否 | 语言。不传时读取 `X-App-Language` / `X-Language` / `Accept-Language`。 |
| `timezone` | string | 否 | 客户端时区,只做属性记录,不参与业务切日。 |
| `duration_ms` | int64 | 否 | 客户端记录的耗时毫秒。 |
| `success` | bool | 否 | 动作是否成功。 |
| `error_code` | string | 否 | 失败时的客户端错误码。 |
| `properties` | object | 否 | 扩展属性,最大 4KB。 |
| `occurred_at_ms` | int64 | 否 | 事件发生时间UTC epoch milliseconds。不传时使用服务端接收时间。 |
服务端会自己识别 `app_code``user_id``country_id``region_id`,客户端不要传这些字段。
示例:
```json
{
"events": [
{
"event_id": "evt_20260703_100001_banner_17_view",
"event_name": "banner_view",
"event_type": "view",
"screen": "home",
"target_type": "banner",
"target_id": "17",
"device_id": "device_xxx",
"platform": "android",
"app_version": "1.2.3",
"language": "en-US",
"timezone": "Asia/Shanghai",
"success": true,
"properties": {
"position": "top"
},
"occurred_at_ms": 1778000005000
}
]
}
```
## 返回值
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"accepted": true,
"received": 1,
"stored": 1,
"duplicated": 0,
"server_time_ms": 1778000005000
}
}
```
| 字段 | 说明 |
| --- | --- |
| `data.accepted` | 是否接受本次上报。 |
| `data.received` | 本次收到的事件数。 |
| `data.stored` | 本次新写入的事件数。 |
| `data.duplicated` | 已存在的事件数。通常是重试导致的重复 `event_id`。 |
| `data.server_time_ms` | 服务端时间UTC epoch milliseconds。 |
## 相关 IM
无。

View File

@ -184,6 +184,11 @@ func main() {
fatalRuntime("connect_money_region_sources_failed", err)
}
defer closeMoneyRegionSources(context.Background())
financeBillSources, closeFinanceBillSources, err := connectFinanceBillSources(context.Background(), cfg.FinanceBillSources, moneyRegionSources)
if err != nil {
fatalRuntime("connect_finance_bill_sources_failed", err)
}
defer closeFinanceBillSources(context.Background())
dashboardExternalSources, closeDashboardExternalSources, err := connectDashboardExternalSources(context.Background(), cfg.DashboardExternalSources, dashboardRegionResolvers(moneyRegionSources)...)
if err != nil {
fatalRuntime("connect_dashboard_external_sources_failed", err)
@ -327,7 +332,7 @@ func main() {
LevelConfig: levelconfigmodule.New(activityclient.NewGRPC(activityConn), walletclient.NewGRPC(walletConn), auditHandler),
LuckyGift: luckygiftmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
Menu: menumodule.New(store, auditHandler),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler, paymentmodule.WithMoneyRegionSources(moneyRegionSources...)),
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), userDB, walletDB, store, auditHandler, paymentmodule.WithMoneyRegionSources(moneyRegionSources...), paymentmodule.WithRechargeBillSources(financeBillSources...)),
PrettyID: prettyidmodule.New(userclient.NewGRPC(userConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
@ -432,6 +437,44 @@ func connectMoneyRegionSources(ctx context.Context, configs []config.MoneyRegion
return sources, cleanup, nil
}
// connectFinanceBillSources 连接 legacy App 的账单 Mongo启动阶段 ping 失败直接退出,避免财务明细静默漏掉 Yumi 账单。
func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBillSourceConfig, regionSources []paymentmodule.MoneyRegionSource) ([]paymentmodule.RechargeBillSource, func(context.Context), error) {
sources := []paymentmodule.RechargeBillSource{}
clients := []*mongo.Client{}
cleanup := func(closeCtx context.Context) {
for _, client := range clients {
if client != nil {
_ = client.Disconnect(closeCtx)
}
}
}
for _, sourceConfig := range configs {
if !sourceConfig.Enabled {
continue
}
connectCtx, cancel := context.WithTimeout(ctx, sourceConfig.RequestTimeout)
client, err := mongo.Connect(options.Client().ApplyURI(sourceConfig.MongoURI).SetServerSelectionTimeout(sourceConfig.RequestTimeout))
if err != nil {
cancel()
cleanup(ctx)
return nil, nil, err
}
if err := client.Ping(connectCtx, readpref.Primary()); err != nil {
cancel()
_ = client.Disconnect(ctx)
cleanup(ctx)
return nil, nil, err
}
cancel()
clients = append(clients, client)
source := paymentmodule.NewMongoRechargeBillSource(client.Database(sourceConfig.MongoDatabase), sourceConfig, regionSources...)
if source != nil {
sources = append(sources, source)
}
}
return sources, cleanup, nil
}
func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashboardmodule.ExternalDashboardRegionResolver {
resolvers := []dashboardmodule.ExternalDashboardRegionResolver{}
for _, source := range sources {

View File

@ -15,11 +15,11 @@ robot_profile_source:
mysql_dsn: "likei_reader:REPLACE_ME@tcp(10.2.21.3:3306)/likei?parseTime=true&charset=utf8mb4&loc=UTC"
request_timeout: "5s"
money_region_sources:
- enabled: false
- enabled: true
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mongo_uri: "mongodb://REPLACE_ME"
mongo_uri: "mongodb://mongouser:REPLACE_ME@10.2.21.9:27017/?authSource=admin"
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
@ -31,6 +31,16 @@ money_region_sources:
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
# Yumi 的充值账单在利雅得内网 likei Mongo与 hyapp-server 同 VPC启用后 APP 充值详情直接读该库。
finance_bill_sources:
- enabled: true
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mongo_uri: "mongodb://mongouser:REPLACE_ME@10.2.21.9:27017/?authSource=admin"
mongo_database: "tarab_all"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
mysql_auto_migrate: false
migrations:
enabled: true

View File

@ -31,6 +31,16 @@ money_region_sources:
mongo_database: "tarab_all"
mongo_collection: "sys_region_config"
request_timeout: "5s"
# legacy App 的财务充值账单外部源:命中 app_code 后APP 充值详情直接读 likei Mongo 的内购明细。
finance_bill_sources:
- enabled: false
app_code: "yumi"
app_name: "Yumi"
sys_origin: "LIKEI"
mongo_uri: ""
mongo_database: "tarab_all"
mongo_collection: "in_app_purchase_details"
request_timeout: "5s"
mysql_auto_migrate: true
migrations:
enabled: true

View File

@ -23,6 +23,7 @@ type Config struct {
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
RobotProfileSource RobotProfileSourceConfig `yaml:"robot_profile_source"`
MoneyRegionSources []MoneyRegionSourceConfig `yaml:"money_region_sources"`
FinanceBillSources []FinanceBillSourceConfig `yaml:"finance_bill_sources"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Migrations MigrationConfig `yaml:"migrations"`
JWTSecret string `yaml:"jwt_secret"`
@ -132,6 +133,19 @@ type MoneyRegionSourceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
// FinanceBillSourceConfig 描述 legacy AppYumi/Aslan 等 likei 平台)的充值账单外部 Mongo 源;
// 财务系统 APP 充值详情按 app_code 命中后直接读 in_app_purchase_details不经过 wallet-service。
type FinanceBillSourceConfig struct {
Enabled bool `yaml:"enabled"`
AppCode string `yaml:"app_code"`
AppName string `yaml:"app_name"`
SysOrigin string `yaml:"sys_origin"`
MongoURI string `yaml:"mongo_uri"`
MongoDatabase string `yaml:"mongo_database"`
MongoCollection string `yaml:"mongo_collection"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type TencentCOSConfig struct {
Enabled bool `yaml:"enabled"`
SecretID string `yaml:"secret-id"`
@ -200,6 +214,17 @@ func Default() Config {
RequestTimeout: 5 * time.Second,
},
},
FinanceBillSources: []FinanceBillSourceConfig{
{
Enabled: false,
AppCode: "yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
MongoDatabase: "tarab_all",
MongoCollection: "in_app_purchase_details",
RequestTimeout: 5 * time.Second,
},
},
MySQLAutoMigrate: true,
Migrations: MigrationConfig{
Enabled: true,
@ -476,6 +501,27 @@ func (cfg *Config) Normalize() {
}
}
cfg.applyMoneyRegionSourceEnvOverrides()
for index := range cfg.FinanceBillSources {
source := &cfg.FinanceBillSources[index]
// legacy 账单源只服务财务充值明细配置层统一大小写和默认集合handler 只按 app_code 命中。
source.AppCode = strings.ToLower(strings.TrimSpace(source.AppCode))
source.AppName = strings.TrimSpace(source.AppName)
source.SysOrigin = strings.ToUpper(strings.TrimSpace(source.SysOrigin))
source.MongoURI = strings.TrimSpace(source.MongoURI)
source.MongoDatabase = strings.Trim(strings.TrimSpace(source.MongoDatabase), "/")
if source.MongoDatabase == "" {
// 线上 likei Mongo10.2.21.9)的账单和区域目录都在 tarab_all 库,与 money_region_sources 保持一致。
source.MongoDatabase = "tarab_all"
}
source.MongoCollection = strings.TrimSpace(source.MongoCollection)
if source.MongoCollection == "" {
source.MongoCollection = "in_app_purchase_details"
}
if source.RequestTimeout <= 0 {
source.RequestTimeout = 5 * time.Second
}
}
cfg.applyFinanceBillSourceEnvOverrides()
cfg.TencentCOS.SecretID = strings.TrimSpace(cfg.TencentCOS.SecretID)
cfg.TencentCOS.SecretKey = strings.TrimSpace(cfg.TencentCOS.SecretKey)
cfg.TencentCOS.BucketName = strings.TrimSpace(cfg.TencentCOS.BucketName)
@ -671,6 +717,30 @@ func (cfg Config) Validate() error {
return fmt.Errorf("money_region_sources[%d].request_timeout must be greater than 0", index)
}
}
for index, source := range cfg.FinanceBillSources {
if !source.Enabled {
continue
}
// legacy 账单源启用但配置不全时必须启动失败,避免财务充值明细静默漏掉 Yumi 账单。
if strings.TrimSpace(source.AppCode) == "" {
return fmt.Errorf("finance_bill_sources[%d].app_code is required when enabled", index)
}
if strings.TrimSpace(source.SysOrigin) == "" {
return fmt.Errorf("finance_bill_sources[%d].sys_origin is required when enabled", index)
}
if strings.TrimSpace(source.MongoURI) == "" {
return fmt.Errorf("finance_bill_sources[%d].mongo_uri is required when enabled", index)
}
if strings.TrimSpace(source.MongoDatabase) == "" {
return fmt.Errorf("finance_bill_sources[%d].mongo_database is required when enabled", index)
}
if strings.TrimSpace(source.MongoCollection) == "" {
return fmt.Errorf("finance_bill_sources[%d].mongo_collection is required when enabled", index)
}
if source.RequestTimeout <= 0 {
return fmt.Errorf("finance_bill_sources[%d].request_timeout must be greater than 0", index)
}
}
if cfg.TencentCOS.Enabled {
if cfg.TencentCOS.SecretID == "" || cfg.TencentCOS.SecretKey == "" || cfg.TencentCOS.BucketName == "" || cfg.TencentCOS.Region == "" || cfg.TencentCOS.AccessURL == "" {
return errors.New("tencent-cos config is incomplete")
@ -766,6 +836,29 @@ func (cfg *Config) applyMoneyRegionSourceEnvOverrides() {
}
}
func (cfg *Config) applyFinanceBillSourceEnvOverrides() {
for index := range cfg.FinanceBillSources {
source := &cfg.FinanceBillSources[index]
appKey := envAppKey(source.AppCode)
if appKey == "" {
continue
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_FINANCE_BILL_MONGO_URI",
"HYAPP_ADMIN_"+appKey+"_MONGO_URI",
)); value != "" {
// legacy 账单源与区域源/大屏共享同一个 likei Mongo 集群;真实密码只由运行环境注入。
source.MongoURI = value
}
if value := strings.TrimSpace(firstEnv(
"HYAPP_ADMIN_"+appKey+"_FINANCE_BILL_MONGO_DATABASE",
"HYAPP_ADMIN_"+appKey+"_MONGO_DATABASE",
)); value != "" {
source.MongoDatabase = strings.Trim(strings.TrimSpace(value), "/")
}
}
}
func envAppKey(appCode string) string {
appCode = strings.ToUpper(strings.TrimSpace(appCode))
if appCode == "" {

View File

@ -32,6 +32,7 @@ type Handler struct {
audit shared.OperationLogger
exchangeRates exchangeRateClient
moneyRegionSources []MoneyRegionSource
billSources map[string]RechargeBillSource
}
type HandlerOption func(*Handler)
@ -46,6 +47,21 @@ func WithMoneyRegionSources(sources ...MoneyRegionSource) HandlerOption {
}
}
// WithRechargeBillSources 注册 legacy AppYumi 等)的外部账单源;命中 app_code 的充值明细和汇总直接读外部源。
func WithRechargeBillSources(sources ...RechargeBillSource) HandlerOption {
return func(h *Handler) {
for _, source := range sources {
if source == nil {
continue
}
if h.billSources == nil {
h.billSources = map[string]RechargeBillSource{}
}
h.billSources[appctx.Normalize(source.AppCode())] = source
}
}
}
// New 组装支付后台 handlerwallet 负责账单和商品事实userDB 只补后台列表需要的用户展示资料。
func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *repository.Store, audit shared.OperationLogger, options ...HandlerOption) *Handler {
handler := &Handler{wallet: wallet, userDB: userDB, walletDB: walletDB, store: store, audit: audit, exchangeRates: newDefaultExchangeRateClient()}
@ -61,6 +77,10 @@ func New(wallet walletclient.Client, userDB *sql.DB, walletDB *sql.DB, store *re
func (h *Handler) ListRechargeBills(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
if source, ok := h.billSources[appCode]; ok {
h.listLegacyRechargeBills(c, source)
return
}
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {

View File

@ -0,0 +1,401 @@
package payment
import (
"context"
"fmt"
"math"
"strconv"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// legacyRechargeBillQuery 是 legacy 账单源支持的筛选子集;用户/币商筛选属于 hyapp 语义legacy 源不适用。
type legacyRechargeBillQuery struct {
Keyword string
RegionID int64
StartAtMS int64
EndAtMS int64
Page int
PageSize int
}
// RechargeBillSource 是 legacy AppYumi 等 likei 平台)充值账单的外部只读来源;
// 命中 app_code 后,财务充值明细和汇总直接走该来源,不再请求 wallet-service。
type RechargeBillSource interface {
AppCode() string
AppName() string
ListRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error)
SummarizeRechargeBills(ctx context.Context, query legacyRechargeBillQuery) (rechargeBillSummaryDTO, error)
}
// legacyRegionCountryResolver 复用财务范围的区域→国家映射MongoMoneyRegionSource 已实现)。
type legacyRegionCountryResolver interface {
CountryCodesForRegion(ctx context.Context, appCode string, regionID int64) ([]string, bool, error)
}
// MongoRechargeBillSource 读取 likei Mongo 的 in_app_purchase_details 集合作为充值账单事实。
type MongoRechargeBillSource struct {
collection *mongo.Collection
config config.FinanceBillSourceConfig
regionResolvers []legacyRegionCountryResolver
}
// NewMongoRechargeBillSource 创建 legacy 账单源regionSources 用于把后台区域 ID 解析成国家码过滤条件。
func NewMongoRechargeBillSource(database *mongo.Database, sourceConfig config.FinanceBillSourceConfig, regionSources ...MoneyRegionSource) *MongoRechargeBillSource {
if database == nil || !sourceConfig.Enabled {
return nil
}
collectionName := strings.TrimSpace(sourceConfig.MongoCollection)
if collectionName == "" {
collectionName = "in_app_purchase_details"
}
sourceConfig.AppCode = appctx.Normalize(sourceConfig.AppCode)
sourceConfig.AppName = strings.TrimSpace(sourceConfig.AppName)
sourceConfig.SysOrigin = strings.ToUpper(strings.TrimSpace(sourceConfig.SysOrigin))
source := &MongoRechargeBillSource{collection: database.Collection(collectionName), config: sourceConfig}
for _, regionSource := range regionSources {
if resolver, ok := regionSource.(legacyRegionCountryResolver); ok && resolver != nil {
source.regionResolvers = append(source.regionResolvers, resolver)
}
}
return source
}
func (s *MongoRechargeBillSource) AppCode() string {
return s.config.AppCode
}
func (s *MongoRechargeBillSource) AppName() string {
if s.config.AppName != "" {
return s.config.AppName
}
return s.config.AppCode
}
// legacyPurchaseDocument 对应 chatapp3-java 的 InAppPurchaseDetails Mongo 文档(只取展示需要的字段)。
type legacyPurchaseDocument struct {
ID any `bson:"_id"`
OrderID string `bson:"orderId"`
AcceptUserID int64 `bson:"acceptUserId"`
CountryCode string `bson:"countryCode"`
Currency string `bson:"currency"`
Amount any `bson:"amount"`
AmountUSD any `bson:"amountUsd"`
Status string `bson:"status"`
Factory legacyPurchaseFactory `bson:"factory"`
Products []legacyPurchaseProduct `bson:"products"`
CreateTime time.Time `bson:"createTime"`
PurchaseDateMS time.Time `bson:"purchaseDateMs"`
}
type legacyPurchaseFactory struct {
Platform string `bson:"platform"`
FactoryCode string `bson:"factoryCode"`
}
type legacyPurchaseProduct struct {
Name string `bson:"name"`
Content string `bson:"content"`
}
func (s *MongoRechargeBillSource) ListRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) {
if s == nil || s.collection == nil {
return nil, 0, fmt.Errorf("legacy bill source is not configured")
}
filter, matchable, err := s.billFilter(ctx, query)
if err != nil {
return nil, 0, err
}
if !matchable {
return []rechargeBillDTO{}, 0, nil
}
total, err := s.collection.CountDocuments(ctx, filter)
if err != nil {
return nil, 0, fmt.Errorf("count legacy recharge bills for %s: %w", s.config.AppCode, err)
}
page := query.Page
if page < 1 {
page = 1
}
pageSize := query.PageSize
if pageSize < 1 {
pageSize = 10
}
cursor, err := s.collection.Find(ctx, filter, options.Find().
SetSort(bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}).
SetSkip(int64(page-1)*int64(pageSize)).
SetLimit(int64(pageSize)))
if err != nil {
return nil, 0, fmt.Errorf("query legacy recharge bills for %s: %w", s.config.AppCode, err)
}
defer cursor.Close(ctx)
items := make([]rechargeBillDTO, 0, pageSize)
for cursor.Next(ctx) {
var document legacyPurchaseDocument
if err := cursor.Decode(&document); err != nil {
return nil, 0, fmt.Errorf("decode legacy recharge bill for %s: %w", s.config.AppCode, err)
}
items = append(items, legacyRechargeBillDTO(s.config, document))
}
if err := cursor.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate legacy recharge bills for %s: %w", s.config.AppCode, err)
}
return items, total, nil
}
func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, query legacyRechargeBillQuery) (rechargeBillSummaryDTO, error) {
if s == nil || s.collection == nil {
return rechargeBillSummaryDTO{}, fmt.Errorf("legacy bill source is not configured")
}
filter, matchable, err := s.billFilter(ctx, query)
if err != nil {
return rechargeBillSummaryDTO{}, err
}
if !matchable {
return rechargeBillSummaryDTO{}, nil
}
// 金币数只统计 GOLD/CANDY 商品内容,口径对齐 chatapp3 的 firstGoldProductcontent 非数值时按 0 计入。
coinExpr := bson.M{"$reduce": bson.M{
"input": bson.M{"$filter": bson.M{
"input": bson.M{"$ifNull": bson.A{"$products", bson.A{}}},
"cond": bson.M{"$in": bson.A{"$$this.name", bson.A{"GOLD", "CANDY"}}},
}},
"initialValue": 0,
"in": bson.M{"$add": bson.A{
"$$value",
bson.M{"$convert": bson.M{"input": "$$this.content", "to": "long", "onError": 0, "onNull": 0}},
}},
}}
cursor, err := s.collection.Aggregate(ctx, mongo.Pipeline{
{{Key: "$match", Value: filter}},
{{Key: "$group", Value: bson.M{
"_id": bson.M{"$cond": bson.A{
bson.M{"$eq": bson.A{"$factory.factoryCode", "GOOGLE"}},
"google",
"third_party",
}},
"billCount": bson.M{"$sum": 1},
"usd": bson.M{"$sum": bson.M{"$ifNull": bson.A{"$amountUsd", 0}}},
"coin": bson.M{"$sum": coinExpr},
}}},
})
if err != nil {
return rechargeBillSummaryDTO{}, fmt.Errorf("summarize legacy recharge bills for %s: %w", s.config.AppCode, err)
}
defer cursor.Close(ctx)
summary := rechargeBillSummaryDTO{}
for cursor.Next(ctx) {
var row struct {
ID string `bson:"_id"`
BillCount int64 `bson:"billCount"`
USD any `bson:"usd"`
Coin int64 `bson:"coin"`
}
if err := cursor.Decode(&row); err != nil {
return rechargeBillSummaryDTO{}, fmt.Errorf("decode legacy recharge summary for %s: %w", s.config.AppCode, err)
}
bucket := rechargeBillSummaryBucketDTO{
BillCount: row.BillCount,
CoinAmount: row.Coin,
USDMinorAmount: legacyDecimalToMinor(row.USD),
}
if row.ID == "google" {
summary.GooglePlay = bucket
} else {
summary.ThirdParty.BillCount += bucket.BillCount
summary.ThirdParty.CoinAmount += bucket.CoinAmount
summary.ThirdParty.USDMinorAmount += bucket.USDMinorAmount
}
}
if err := cursor.Err(); err != nil {
return rechargeBillSummaryDTO{}, fmt.Errorf("iterate legacy recharge summary for %s: %w", s.config.AppCode, err)
}
summary.Total = rechargeBillSummaryBucketDTO{
BillCount: summary.GooglePlay.BillCount + summary.ThirdParty.BillCount,
CoinAmount: summary.GooglePlay.CoinAmount + summary.ThirdParty.CoinAmount,
USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.ThirdParty.USDMinorAmount,
}
return summary, nil
}
// billFilter 构造与 hyapp 账单列表同口径的 Mongo 过滤条件;返回 matchable=false 表示筛选注定无结果。
func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRechargeBillQuery) (bson.M, bool, error) {
filter := bson.M{
"sysOrigin": s.config.SysOrigin,
"status": "SUCCESS",
// 订阅免费试用不产生真实付款,与 Aslan 大屏充值口径保持一致。
"trialPeriod": bson.M{"$ne": true},
}
timeRange := bson.M{}
if query.StartAtMS > 0 {
timeRange["$gte"] = time.UnixMilli(query.StartAtMS)
}
if query.EndAtMS > 0 {
timeRange["$lt"] = time.UnixMilli(query.EndAtMS)
}
if len(timeRange) > 0 {
filter["createTime"] = timeRange
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
// legacy 集合没有 hyapp 交易号;关键词只精确匹配内部订单 ID 和三方订单号,避免大集合正则扫描。
filter["$or"] = bson.A{
bson.M{"_id": keyword},
bson.M{"orderId": keyword},
}
}
if query.RegionID > 0 {
codes, handled, err := s.regionCountryCodes(ctx, query.RegionID)
if err != nil {
return nil, false, err
}
if !handled || len(codes) == 0 {
return nil, false, nil
}
filter["countryCode"] = bson.M{"$in": codes}
}
return filter, true, nil
}
func (s *MongoRechargeBillSource) regionCountryCodes(ctx context.Context, regionID int64) ([]string, bool, error) {
for _, resolver := range s.regionResolvers {
codes, handled, err := resolver.CountryCodesForRegion(ctx, s.config.AppCode, regionID)
if err != nil {
return nil, false, err
}
if handled {
normalized := make([]string, 0, len(codes))
for _, code := range codes {
if code = strings.ToUpper(strings.TrimSpace(code)); code != "" {
normalized = append(normalized, code)
}
}
return normalized, true, nil
}
}
return nil, false, nil
}
// legacyRechargeBillDTO 把 likei 内购明细收敛成财务充值账单 DTO金额统一按实付币种微单位、美金最小单位表达。
func legacyRechargeBillDTO(sourceConfig config.FinanceBillSourceConfig, document legacyPurchaseDocument) rechargeBillDTO {
createdAtMS := int64(0)
if !document.CreateTime.IsZero() {
createdAtMS = document.CreateTime.UnixMilli()
} else if !document.PurchaseDateMS.IsZero() {
createdAtMS = document.PurchaseDateMS.UnixMilli()
}
factoryCode := strings.ToUpper(strings.TrimSpace(document.Factory.FactoryCode))
dto := rechargeBillDTO{
AppCode: sourceConfig.AppCode,
TransactionID: legacyDocumentID(document.ID),
RechargeType: legacyRechargeType(factoryCode),
Status: "succeeded",
ExternalRef: strings.TrimSpace(document.OrderID),
UserID: document.AcceptUserID,
CurrencyCode: strings.ToUpper(strings.TrimSpace(document.Currency)),
CoinAmount: legacyGoldCoinAmount(document.Products),
USDMinorAmount: legacyDecimalToMinor(document.AmountUSD),
CreatedAtMS: createdAtMS,
ProviderCode: legacyProviderCode(factoryCode),
UserPaidCurrencyCode: strings.ToUpper(strings.TrimSpace(document.Currency)),
UserPaidAmountMicro: legacyDecimalToMicro(document.Amount),
// likei 平台没有可反推的三方扣款数据;标记已同步避免前端把 legacy 谷歌账单当成待查询。
PaidSyncedAtMS: createdAtMS,
}
return dto
}
func legacyDocumentID(value any) string {
if value == nil {
return ""
}
if text, ok := value.(string); ok {
return text
}
if objectID, ok := value.(bson.ObjectID); ok {
return objectID.Hex()
}
return strings.TrimSpace(fmt.Sprint(value))
}
func legacyRechargeType(factoryCode string) string {
switch factoryCode {
case "GOOGLE":
return "google_play_recharge"
case "APPLE":
return "apple_recharge"
case "HUAWEI":
return "huawei_recharge"
case "TELEGRAM":
return "telegram_recharge"
default:
return "web_recharge"
}
}
func legacyProviderCode(factoryCode string) string {
if factoryCode == "" {
return "legacy"
}
return strings.ToLower(factoryCode)
}
// legacyGoldCoinAmount 只统计 GOLD/CANDY 商品内容,口径对齐 chatapp3 的 firstGoldProduct。
func legacyGoldCoinAmount(products []legacyPurchaseProduct) int64 {
var total int64
for _, product := range products {
if product.Name != "GOLD" && product.Name != "CANDY" {
continue
}
if amount, err := strconv.ParseInt(strings.TrimSpace(product.Content), 10, 64); err == nil {
total += amount
}
}
return total
}
func legacyDecimalToMicro(value any) int64 {
return legacyDecimalScaled(value, 1_000_000)
}
func legacyDecimalToMinor(value any) int64 {
return legacyDecimalScaled(value, 100)
}
// legacyDecimalScaled 把 Mongo 里 Decimal128/数值/字符串形式的十进制金额换算成目标最小单位;非法值按 0 处理。
func legacyDecimalScaled(value any, scale float64) int64 {
var text string
switch typed := value.(type) {
case nil:
return 0
case bson.Decimal128:
text = typed.String()
case string:
text = typed
case int32:
return int64(typed) * int64(scale)
case int64:
return typed * int64(scale)
case int:
return int64(typed) * int64(scale)
case float64:
text = strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
text = strconv.FormatFloat(float64(typed), 'f', -1, 32)
default:
text = fmt.Sprint(value)
}
parsed, err := strconv.ParseFloat(strings.TrimSpace(text), 64)
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
return 0
}
return int64(math.Round(parsed * scale))
}

View File

@ -0,0 +1,165 @@
package payment
import (
"context"
"testing"
"time"
"hyapp-admin-server/internal/config"
"go.mongodb.org/mongo-driver/v2/bson"
)
func yumiBillSourceConfig() config.FinanceBillSourceConfig {
return config.FinanceBillSourceConfig{
Enabled: true,
AppCode: "yumi",
AppName: "Yumi",
SysOrigin: "LIKEI",
MongoDatabase: "test",
MongoCollection: "in_app_purchase_details",
RequestTimeout: time.Second,
}
}
func TestLegacyRechargeBillDTOMapsGooglePurchase(t *testing.T) {
amount, err := bson.ParseDecimal128("164.99")
if err != nil {
t.Fatalf("parse amount: %v", err)
}
amountUSD, err := bson.ParseDecimal128("2.99")
if err != nil {
t.Fatalf("parse amount usd: %v", err)
}
createTime := time.UnixMilli(1_751_000_000_000).UTC()
dto := legacyRechargeBillDTO(yumiBillSourceConfig(), legacyPurchaseDocument{
ID: "175100000000012345",
OrderID: "GPA.1234-5678-9012-34567",
AcceptUserID: 42,
CountryCode: "TR",
Currency: "try",
Amount: amount,
AmountUSD: amountUSD,
Status: "SUCCESS",
Factory: legacyPurchaseFactory{Platform: "Android", FactoryCode: "GOOGLE"},
Products: []legacyPurchaseProduct{
{Name: "GOLD", Content: "560000"},
{Name: "PROPS", Content: "AVATAR_FRAME"},
},
CreateTime: createTime,
})
if dto.AppCode != "yumi" || dto.TransactionID != "175100000000012345" {
t.Fatalf("unexpected identity: %+v", dto)
}
if dto.RechargeType != "google_play_recharge" || dto.ProviderCode != "google" {
t.Fatalf("unexpected channel mapping: %+v", dto)
}
if dto.Status != "succeeded" || dto.ExternalRef != "GPA.1234-5678-9012-34567" {
t.Fatalf("unexpected status/external ref: %+v", dto)
}
if dto.CoinAmount != 560000 {
t.Fatalf("coin amount = %d, want 560000", dto.CoinAmount)
}
if dto.USDMinorAmount != 299 {
t.Fatalf("usd minor = %d, want 299", dto.USDMinorAmount)
}
if dto.UserPaidCurrencyCode != "TRY" || dto.UserPaidAmountMicro != 164_990_000 {
t.Fatalf("unexpected user paid: %+v", dto)
}
if dto.CreatedAtMS != createTime.UnixMilli() || dto.PaidSyncedAtMS != dto.CreatedAtMS {
t.Fatalf("unexpected timestamps: %+v", dto)
}
}
func TestLegacyRechargeTypeMapping(t *testing.T) {
cases := map[string]string{
"GOOGLE": "google_play_recharge",
"APPLE": "apple_recharge",
"HUAWEI": "huawei_recharge",
"TELEGRAM": "telegram_recharge",
"PAYER_MAX": "web_recharge",
"": "web_recharge",
}
for factoryCode, want := range cases {
if got := legacyRechargeType(factoryCode); got != want {
t.Fatalf("legacyRechargeType(%q) = %q, want %q", factoryCode, got, want)
}
}
}
func TestLegacyDecimalScaledHandlesTypes(t *testing.T) {
decimal, err := bson.ParseDecimal128("6.99")
if err != nil {
t.Fatalf("parse decimal: %v", err)
}
if got := legacyDecimalToMinor(decimal); got != 699 {
t.Fatalf("decimal minor = %d, want 699", got)
}
if got := legacyDecimalToMicro("54.99"); got != 54_990_000 {
t.Fatalf("string micro = %d, want 54990000", got)
}
if got := legacyDecimalToMinor(int64(3)); got != 300 {
t.Fatalf("int64 minor = %d, want 300", got)
}
if got := legacyDecimalToMinor(nil); got != 0 {
t.Fatalf("nil minor = %d, want 0", got)
}
if got := legacyDecimalToMinor("not-a-number"); got != 0 {
t.Fatalf("invalid minor = %d, want 0", got)
}
}
type staticLegacyRegionResolver struct {
codes []string
handled bool
}
func (r staticLegacyRegionResolver) CountryCodesForRegion(context.Context, string, int64) ([]string, bool, error) {
return r.codes, r.handled, nil
}
func TestLegacyBillFilterBuildsQuery(t *testing.T) {
source := &MongoRechargeBillSource{
config: yumiBillSourceConfig(),
regionResolvers: []legacyRegionCountryResolver{staticLegacyRegionResolver{codes: []string{"tr", "SA"}, handled: true}},
}
filter, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{
Keyword: "GPA.1",
RegionID: 9_000_000_000_000_000_001,
StartAtMS: 1_000,
EndAtMS: 2_000,
})
if err != nil || !matchable {
t.Fatalf("billFilter err=%v matchable=%v", err, matchable)
}
if filter["sysOrigin"] != "LIKEI" || filter["status"] != "SUCCESS" {
t.Fatalf("unexpected base filter: %v", filter)
}
timeRange, ok := filter["createTime"].(bson.M)
if !ok || !timeRange["$gte"].(time.Time).Equal(time.UnixMilli(1_000)) || !timeRange["$lt"].(time.Time).Equal(time.UnixMilli(2_000)) {
t.Fatalf("unexpected time range: %v", filter["createTime"])
}
countries, ok := filter["countryCode"].(bson.M)
if !ok {
t.Fatalf("country filter missing: %v", filter)
}
codes, ok := countries["$in"].([]string)
if !ok || len(codes) != 2 || codes[0] != "TR" || codes[1] != "SA" {
t.Fatalf("unexpected country codes: %v", countries["$in"])
}
if _, ok := filter["$or"]; !ok {
t.Fatalf("keyword filter missing: %v", filter)
}
}
func TestLegacyBillFilterUnresolvedRegionMatchesNothing(t *testing.T) {
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
_, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{RegionID: 123})
if err != nil {
t.Fatalf("billFilter err=%v", err)
}
if matchable {
t.Fatalf("unresolved region should not be matchable")
}
}

View File

@ -2,6 +2,7 @@ package payment
import (
"context"
"sort"
"strings"
"time"
@ -47,10 +48,42 @@ type googleRechargePaidDTO struct {
Error string `json:"error,omitempty"`
}
// listLegacyRechargeBills 处理 legacy App 的充值明细;分页与展示口径对齐 wallet-service 账单列表。
func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSource) {
options := shared.ListOptions(c)
items, total, err := source.ListRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
Page: options.Page,
PageSize: options.PageSize,
})
if err != nil {
response.ServerError(c, "获取账单列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: total})
}
// GetRechargeBillSummary 返回与账单列表同一筛选口径的充值总和、Google 充值与三方充值聚合。
func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
if source, ok := h.billSources[appCode]; ok {
summary, err := source.SummarizeRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
})
if err != nil {
response.ServerError(c, "获取充值汇总失败")
return
}
response.OK(c, summary)
return
}
userFilter := shared.UserIdentityFilterFromQuery(c, "")
userID, userMatched, ok := h.resolveRechargeBillUserFilter(c, appCode, userFilter, "user_id 参数不正确", "user_id", "userId")
if !ok {
@ -111,6 +144,10 @@ func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
return
}
appCode := appctx.FromContext(c.Request.Context())
if _, ok := h.billSources[appCode]; ok {
response.BadRequest(c, "该 App 的账单来自外部系统,不支持谷歌实付同步")
return
}
resp, err := h.wallet.RefreshGooglePaymentPrices(c.Request.Context(), &walletv1.RefreshGooglePaymentPricesRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
@ -136,6 +173,54 @@ func (h *Handler) RefreshGoogleRechargePaidDetails(c *gin.Context) {
response.OK(c, gin.H{"items": items})
}
type rechargeBillAppDTO struct {
AppCode string `json:"appCode"`
AppName string `json:"appName"`
}
// ListRechargeBillApps 返回充值详情可选的 App 目录hyapp 在册应用 + 配置的 legacy 账单源Yumi 等)。
func (h *Handler) ListRechargeBillApps(c *gin.Context) {
items := []rechargeBillAppDTO{}
seen := map[string]struct{}{}
if h.userDB != nil {
rows, err := h.userDB.QueryContext(c.Request.Context(), `
SELECT app_code, app_name
FROM apps
WHERE status = 'active'
ORDER BY app_name ASC, app_code ASC`)
if err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
defer rows.Close()
for rows.Next() {
var item rechargeBillAppDTO
if err := rows.Scan(&item.AppCode, &item.AppName); err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
item.AppCode = appctx.Normalize(item.AppCode)
seen[item.AppCode] = struct{}{}
items = append(items, item)
}
if err := rows.Err(); err != nil {
response.ServerError(c, "获取 App 列表失败")
return
}
}
legacyApps := make([]rechargeBillAppDTO, 0, len(h.billSources))
for _, source := range h.billSources {
appCode := appctx.Normalize(source.AppCode())
if _, ok := seen[appCode]; ok {
continue
}
legacyApps = append(legacyApps, rechargeBillAppDTO{AppCode: appCode, AppName: source.AppName()})
}
sort.Slice(legacyApps, func(i, j int) bool { return legacyApps[i].AppName < legacyApps[j].AppName })
items = append(items, legacyApps...)
response.OK(c, gin.H{"items": items, "total": len(items)})
}
// ListRechargeBillRegions 返回当前 App 的区域目录,用于充值详情按区域筛选;不受财务范围授权限制。
func (h *Handler) ListRechargeBillRegions(c *gin.Context) {
appCode := appctx.FromContext(c.Request.Context())

View File

@ -15,6 +15,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/payment/recharge-bills/summary", middleware.RequirePermission("payment-bill:view"), h.GetRechargeBillSummary)
protected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:view"), h.RefreshGoogleRechargePaidDetails)
protected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions)
protected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps)
protected.GET("/admin/payment/third-party-channels", middleware.RequirePermission("payment-third-party:view"), h.ListThirdPartyPaymentChannels)
protected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope)
protected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope)

View File

@ -11,6 +11,38 @@ import (
"time"
)
type AppTrackingEvent struct {
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventName string `json:"event_name"`
EventType string `json:"event_type"`
Screen string `json:"screen"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
UserID int64 `json:"user_id"`
DeviceID string `json:"device_id"`
SessionID string `json:"session_id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Language string `json:"language"`
Timezone string `json:"timezone"`
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
DurationMS int64 `json:"duration_ms"`
Success bool `json:"success"`
ErrorCode string `json:"error_code"`
Properties json.RawMessage `json:"properties,omitempty"`
OccurredAtMS int64 `json:"occurred_at_ms"`
}
type AppTrackingReportResult struct {
Accepted bool `json:"accepted"`
Received int `json:"received"`
Stored int `json:"stored"`
Duplicated int `json:"duplicated"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type SelfGameH5Event struct {
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
@ -34,6 +66,7 @@ type SelfGameH5Event struct {
type StatisticsClient interface {
ReportSelfGameH5Event(ctx context.Context, event SelfGameH5Event) error
ReportAppTrackingEvents(ctx context.Context, events []AppTrackingEvent) (AppTrackingReportResult, error)
}
type HTTPStatisticsClient struct {
@ -79,3 +112,32 @@ func (c *HTTPStatisticsClient) ReportSelfGameH5Event(ctx context.Context, event
payload, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("statistics report failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(payload)))
}
func (c *HTTPStatisticsClient) ReportAppTrackingEvents(ctx context.Context, events []AppTrackingEvent) (AppTrackingReportResult, error) {
if c == nil || c.httpClient == nil || c.baseURL == "" {
return AppTrackingReportResult{}, fmt.Errorf("statistics client is not configured")
}
body, err := json.Marshal(map[string]any{"events": events})
if err != nil {
return AppTrackingReportResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/internal/v1/statistics/app/events", bytes.NewReader(body))
if err != nil {
return AppTrackingReportResult{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return AppTrackingReportResult{}, err
}
defer resp.Body.Close()
payload, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return AppTrackingReportResult{}, fmt.Errorf("statistics app event report failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(payload)))
}
var result AppTrackingReportResult
if err := json.Unmarshal(payload, &result); err != nil {
return AppTrackingReportResult{}, err
}
return result, nil
}

View File

@ -0,0 +1,215 @@
package appapi
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"time"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
const (
appEventMaxBatchSize = 50
appEventMaxPropertiesBytes = 4096
)
type appEventsRequest struct {
Events []appEventRequest `json:"events"`
}
type appEventRequest struct {
EventID string `json:"event_id"`
EventName string `json:"event_name"`
EventType string `json:"event_type"`
Screen string `json:"screen"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
DeviceID string `json:"device_id"`
SessionID string `json:"session_id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Language string `json:"language"`
Timezone string `json:"timezone"`
DurationMS int64 `json:"duration_ms"`
Success bool `json:"success"`
ErrorCode string `json:"error_code"`
Properties json.RawMessage `json:"properties"`
OccurredAtMS int64 `json:"occurred_at_ms"`
}
func (h *Handler) reportAppEvents(writer http.ResponseWriter, request *http.Request) {
if h.statisticsClient == nil {
// App 埋点事实由 statistics-service 持久化gateway 未注入写入边界时不能假成功,否则客户端会丢掉重试机会。
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body appEventsRequest
if !httpkit.Decode(writer, request, &body) {
return
}
if len(body.Events) == 0 || len(body.Events) > appEventMaxBatchSize {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
nowMS := time.Now().UTC().UnixMilli()
userID := auth.UserIDFromContext(request.Context())
countryID, regionID, ok := h.appEventUserDimensions(writer, request, userID)
if !ok {
return
}
headerDeviceID := httpkit.FirstHeader(request, "X-Device-ID", "X-HY-Device-ID")
headerPlatform := httpkit.FirstHeader(request, "X-App-Platform", "X-Platform")
headerAppVersion := httpkit.FirstHeader(request, "X-App-Version", "X-Client-Version")
headerLanguage := appEventRequestLanguage(request)
headerTimezone := httpkit.FirstHeader(request, "X-Timezone", "X-App-Timezone")
events := make([]client.AppTrackingEvent, 0, len(body.Events))
for _, item := range body.Events {
event, valid := appTrackingEventFromRequest(item, appTrackingEventDefaults{
appCode: appcode.FromContext(request.Context()),
userID: userID,
countryID: countryID,
regionID: regionID,
deviceID: headerDeviceID,
sessionID: auth.SessionIDFromContext(request.Context()),
platform: headerPlatform,
appVersion: headerAppVersion,
language: headerLanguage,
timezone: headerTimezone,
receivedAtMS: nowMS,
})
if !valid {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
events = append(events, event)
}
result, err := h.statisticsClient.ReportAppTrackingEvents(request.Context(), events)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
httpkit.WriteOK(writer, request, result)
}
func (h *Handler) appEventUserDimensions(writer http.ResponseWriter, request *http.Request, userID int64) (int64, int64, bool) {
if userID <= 0 {
return 0, 0, true
}
if h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return 0, 0, false
}
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, httpkit.FirstHeader(request, "X-Device-ID", "X-HY-Device-ID")),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return 0, 0, false
}
user := resp.GetUser()
if user == nil {
// 登录态缺用户主数据属于服务端依赖异常;不能退化成匿名埋点,否则会污染用户维度。
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return 0, 0, false
}
return user.GetCountryId(), user.GetRegionId(), true
}
type appTrackingEventDefaults struct {
appCode string
userID int64
countryID int64
regionID int64
deviceID string
sessionID string
platform string
appVersion string
language string
timezone string
receivedAtMS int64
}
func appTrackingEventFromRequest(item appEventRequest, defaults appTrackingEventDefaults) (client.AppTrackingEvent, bool) {
item.EventID = strings.TrimSpace(item.EventID)
item.EventName = strings.TrimSpace(item.EventName)
if item.EventID == "" || item.EventName == "" {
return client.AppTrackingEvent{}, false
}
deviceID := firstTrimmed(item.DeviceID, defaults.deviceID)
if defaults.userID <= 0 && deviceID == "" {
// 匿名埋点没有服务端 user_id只能依赖稳定 device_id 做排障和后续粗粒度去重。
return client.AppTrackingEvent{}, false
}
properties, ok := normalizeAppEventProperties(item.Properties)
if !ok {
return client.AppTrackingEvent{}, false
}
occurredAtMS := item.OccurredAtMS
if occurredAtMS <= 0 {
occurredAtMS = defaults.receivedAtMS
}
return client.AppTrackingEvent{
AppCode: defaults.appCode,
EventID: item.EventID,
EventName: item.EventName,
EventType: strings.TrimSpace(item.EventType),
Screen: strings.TrimSpace(item.Screen),
TargetType: strings.TrimSpace(item.TargetType),
TargetID: strings.TrimSpace(item.TargetID),
UserID: defaults.userID,
DeviceID: deviceID,
SessionID: firstTrimmed(defaults.sessionID, item.SessionID),
Platform: strings.ToLower(firstTrimmed(item.Platform, defaults.platform)),
AppVersion: firstTrimmed(item.AppVersion, defaults.appVersion),
Language: firstTrimmed(item.Language, defaults.language),
Timezone: firstTrimmed(item.Timezone, defaults.timezone),
CountryID: defaults.countryID,
RegionID: defaults.regionID,
DurationMS: item.DurationMS,
Success: item.Success,
ErrorCode: strings.TrimSpace(item.ErrorCode),
Properties: properties,
OccurredAtMS: occurredAtMS,
}, true
}
func normalizeAppEventProperties(raw json.RawMessage) (json.RawMessage, bool) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, true
}
if len(trimmed) > appEventMaxPropertiesBytes || !json.Valid(trimmed) {
return nil, false
}
return json.RawMessage(trimmed), true
}
func appEventRequestLanguage(request *http.Request) string {
value := httpkit.FirstHeader(request, "X-App-Language", "X-Language")
if value == "" {
value = request.Header.Get("Accept-Language")
}
if index := strings.Index(value, ","); index >= 0 {
value = value[:index]
}
return strings.TrimSpace(value)
}
func firstTrimmed(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}

View File

@ -32,6 +32,7 @@ type Handler struct {
userAuthClient client.UserAuthClient
userProfileClient client.UserProfileClient
userDeviceClient client.UserDeviceClient
statisticsClient client.StatisticsClient
objectUploader ObjectUploader
}
@ -40,6 +41,7 @@ type Config struct {
UserAuthClient client.UserAuthClient
UserProfileClient client.UserProfileClient
UserDeviceClient client.UserDeviceClient
StatisticsClient client.StatisticsClient
ObjectUploader ObjectUploader
}
@ -49,6 +51,7 @@ func New(config Config) *Handler {
userAuthClient: config.UserAuthClient,
userProfileClient: config.UserProfileClient,
userDeviceClient: config.UserDeviceClient,
statisticsClient: config.StatisticsClient,
objectUploader: config.ObjectUploader,
}
}
@ -104,3 +107,7 @@ func (h *Handler) HandlePushToken(writer http.ResponseWriter, request *http.Requ
func (h *Handler) AppHeartbeat(writer http.ResponseWriter, request *http.Request) {
h.appHeartbeat(writer, request)
}
func (h *Handler) ReportAppEvents(writer http.ResponseWriter, request *http.Request) {
h.reportAppEvents(writer, request)
}

View File

@ -65,6 +65,7 @@ type AppHandlers struct {
ProxyEffectMedia http.HandlerFunc
HandlePushToken http.HandlerFunc
AppHeartbeat http.HandlerFunc
ReportAppEvents http.HandlerFunc
}
type UserHandlers struct {
@ -413,6 +414,7 @@ func (r routes) registerAppRoutes() {
r.public("/files/registration/avatar/upload", http.MethodPost, h.UploadRegistrationAvatar)
r.public("/media/effects/proxy", http.MethodGet, h.ProxyEffectMedia)
r.auth("/app/heartbeat", http.MethodPost, h.AppHeartbeat)
r.public("/app/events", http.MethodPost, h.ReportAppEvents)
r.profile("/devices/push-token", "", h.HandlePushToken)
}

View File

@ -31,6 +31,7 @@ import (
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/appconfig"
"hyapp/services/gateway-service/internal/auth"
gatewayclient "hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/financewithdrawal"
)
@ -413,6 +414,30 @@ type fakeUserDeviceClient struct {
deleteErr error
}
type fakeStatisticsClient struct {
lastAppEvents []gatewayclient.AppTrackingEvent
appResult gatewayclient.AppTrackingReportResult
appErr error
selfEvents []gatewayclient.SelfGameH5Event
selfErr error
}
func (f *fakeStatisticsClient) ReportSelfGameH5Event(_ context.Context, event gatewayclient.SelfGameH5Event) error {
f.selfEvents = append(f.selfEvents, event)
return f.selfErr
}
func (f *fakeStatisticsClient) ReportAppTrackingEvents(_ context.Context, events []gatewayclient.AppTrackingEvent) (gatewayclient.AppTrackingReportResult, error) {
f.lastAppEvents = append([]gatewayclient.AppTrackingEvent(nil), events...)
if f.appErr != nil {
return gatewayclient.AppTrackingReportResult{}, f.appErr
}
if f.appResult.ServerTimeMS == 0 {
f.appResult = gatewayclient.AppTrackingReportResult{Accepted: true, Received: len(events), Stored: len(events), Duplicated: 0, ServerTimeMS: 1_778_000_005_000}
}
return f.appResult, nil
}
type fakeUserCountryQueryClient struct {
last *userv1.ListRegistrationCountriesRequest
resp *userv1.ListRegistrationCountriesResponse
@ -3776,6 +3801,115 @@ func TestAppHeartbeatUsesAuthenticatedSession(t *testing.T) {
}
}
func TestReportAppEventsUsesAuthenticatedUserDimensions(t *testing.T) {
profileClient := &fakeUserProfileClient{regionID: 210}
statisticsClient := &fakeStatisticsClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetStatisticsClient(statisticsClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"events":[{"event_id":"evt-login-1","event_name":"banner_click","event_type":"click","screen":"home","target_type":"banner","target_id":"17","device_id":"dev-body","user_id":999,"country_id":1,"region_id":2,"platform":"ios","app_version":"1.2.3","language":"en-US","timezone":"Asia/Shanghai","duration_ms":12,"success":true,"properties":{"slot":"home"}}]}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithAppCode(t, "secret", 42, "fami"))
request.Header.Set("X-Device-ID", "dev-header")
request.Header.Set("X-Request-ID", "req-app-events")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if len(statisticsClient.lastAppEvents) != 1 {
t.Fatalf("statistics app events not called: %+v", statisticsClient.lastAppEvents)
}
event := statisticsClient.lastAppEvents[0]
if event.AppCode != "fami" || event.UserID != 42 || event.CountryID != 840 || event.RegionID != 210 {
t.Fatalf("authenticated event must use server dimensions: %+v", event)
}
if event.DeviceID != "dev-body" || event.EventName != "banner_click" || event.Platform != "ios" || string(event.Properties) != `{"slot":"home"}` {
t.Fatalf("authenticated event fields mismatch: %+v properties=%s", event, string(event.Properties))
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode app events response failed: %v", err)
}
data := response.Data.(map[string]any)
if data["accepted"] != true || data["received"].(float64) != 1 || data["stored"].(float64) != 1 || data["duplicated"].(float64) != 0 {
t.Fatalf("app events response mismatch: %+v", data)
}
}
func TestReportAppEventsAllowsAnonymousWithDeviceID(t *testing.T) {
statisticsClient := &fakeStatisticsClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetStatisticsClient(statisticsClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"events":[{"event_id":"evt-anon-1","event_name":"splash_view"}]}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body))
request.Header.Set("X-Device-ID", "dev-anon")
request.Header.Set("X-App-Platform", "android")
request.Header.Set("X-App-Version", "2.0.1")
request.Header.Set("Accept-Language", "ar-EG,ar;q=0.9")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if len(statisticsClient.lastAppEvents) != 1 {
t.Fatalf("statistics app events not called: %+v", statisticsClient.lastAppEvents)
}
event := statisticsClient.lastAppEvents[0]
if event.UserID != 0 || event.DeviceID != "dev-anon" || event.Platform != "android" || event.AppVersion != "2.0.1" || event.Language != "ar-EG" {
t.Fatalf("anonymous event must use device/header fields: %+v", event)
}
}
func TestReportAppEventsRejectsInvalidRequests(t *testing.T) {
for _, test := range []struct {
name string
body []byte
}{
{name: "missing device anonymous", body: []byte(`{"events":[{"event_id":"evt-1","event_name":"open"}]}`)},
{name: "missing event id", body: []byte(`{"events":[{"event_name":"open","device_id":"dev-1"}]}`)},
{name: "missing event name", body: []byte(`{"events":[{"event_id":"evt-1","device_id":"dev-1"}]}`)},
{name: "too many events", body: mustAppEventsBody(t, 51)},
} {
t.Run(test.name, func(t *testing.T) {
statisticsClient := &fakeStatisticsClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetStatisticsClient(statisticsClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(test.body))
request.Header.Set("X-Request-ID", "req-invalid-app-events")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadRequest, httpkit.CodeInvalidArgument, "req-invalid-app-events")
if len(statisticsClient.lastAppEvents) != 0 {
t.Fatalf("invalid app events must not reach statistics-service: %+v", statisticsClient.lastAppEvents)
}
})
}
}
func TestReportAppEventsUpstreamFailure(t *testing.T) {
statisticsClient := &fakeStatisticsClient{appErr: errors.New("statistics unavailable")}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetStatisticsClient(statisticsClient)
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"events":[{"event_id":"evt-upstream","event_name":"open","device_id":"dev-1"}]}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/app/events", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-app-events-upstream")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-app-events-upstream")
}
func TestDeletePushTokenUsesAuthenticatedUser(t *testing.T) {
deviceClient := &fakeUserDeviceClient{}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
@ -10364,6 +10498,23 @@ func assertEnvelope(t *testing.T, recorder *httptest.ResponseRecorder, statusCod
}
}
func mustAppEventsBody(t *testing.T, count int) []byte {
t.Helper()
events := make([]map[string]any, 0, count)
for i := 0; i < count; i++ {
events = append(events, map[string]any{
"event_id": "evt-" + strconv.Itoa(i),
"event_name": "open",
"device_id": "dev-1",
})
}
payload, err := json.Marshal(map[string]any{"events": events})
if err != nil {
t.Fatalf("marshal app events body failed: %v", err)
}
return payload
}
func assertEnvelopeMessage(t *testing.T, recorder *httptest.ResponseRecorder, statusCode int, code string, message string, requestID string) {
t.Helper()

View File

@ -119,6 +119,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
UserAuthClient: h.userClient,
UserProfileClient: h.userProfileClient,
UserDeviceClient: h.userDeviceClient,
StatisticsClient: h.statisticsClient,
ObjectUploader: h.objectUploader,
})
callbackAPI := callbackapi.New(callbackapi.Config{
@ -204,6 +205,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
ProxyEffectMedia: appAPI.ProxyEffectMedia,
HandlePushToken: appAPI.HandlePushToken,
AppHeartbeat: appAPI.AppHeartbeat,
ReportAppEvents: appAPI.ReportAppEvents,
},
User: userHandlers,
Manager: managerAPI.ManagerHandlers(),

View File

@ -17,6 +17,37 @@ CREATE TABLE IF NOT EXISTS statistics_event_consumption (
KEY idx_event_consumption_type (app_code, source, event_type, consumed_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统计事件消费幂等表';
CREATE TABLE IF NOT EXISTS app_tracking_events (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
event_id VARCHAR(160) NOT NULL COMMENT '客户端生成的埋点幂等 ID',
event_name VARCHAR(96) NOT NULL COMMENT '事件名',
event_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '事件类型,如 click、view、submit',
screen VARCHAR(128) NOT NULL DEFAULT '' COMMENT '页面或模块',
target_type VARCHAR(64) NOT NULL DEFAULT '' COMMENT '目标类型,如 banner、button、room',
target_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '目标 ID',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '服务端识别的用户 ID匿名为 0',
device_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '设备 ID',
session_id VARCHAR(160) NOT NULL DEFAULT '' COMMENT '会话 ID',
platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '平台',
app_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'App 版本',
language VARCHAR(32) NOT NULL DEFAULT '' COMMENT '语言',
timezone VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端时区,仅作属性记录',
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '服务端用户国家维度',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '服务端用户区域维度',
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '客户端记录的耗时毫秒',
success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '客户端动作是否成功',
error_code VARCHAR(64) NOT NULL DEFAULT '' COMMENT '客户端错误码',
properties_json JSON NOT NULL COMMENT '事件扩展属性',
occurred_at_ms BIGINT NOT NULL COMMENT '客户端事件发生时间UTC epoch ms',
received_at_ms BIGINT NOT NULL COMMENT '服务端接收时间UTC epoch ms',
stat_day DATE NOT NULL COMMENT 'UTC 统计日',
PRIMARY KEY (app_code, event_id),
KEY idx_app_tracking_event_name (app_code, stat_day, event_name),
KEY idx_app_tracking_screen (app_code, stat_day, screen),
KEY idx_app_tracking_user (app_code, user_id, occurred_at_ms),
KEY idx_app_tracking_device (app_code, device_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App 埋点原始明细表';
CREATE TABLE IF NOT EXISTS stat_app_day_country (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区UTC 或 Asia/Shanghai',

View File

@ -34,6 +34,7 @@ func newQueryHTTPServer(addr string, repo *mysqlstorage.Repository) (*queryHTTPS
mux.HandleFunc("/internal/v1/statistics/overview", s.overview)
mux.HandleFunc("/internal/v1/statistics/platform-grants/users", s.platformGrantUsers)
mux.HandleFunc("/internal/v1/statistics/platform-grants/records", s.platformGrantRecords)
mux.HandleFunc("/internal/v1/statistics/app/events", s.appEvents)
mux.HandleFunc("/internal/v1/statistics/self-game/events", s.selfGameEvents)
mux.HandleFunc("/internal/v1/statistics/self-games/overview", s.selfGameOverview)
s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second}
@ -163,6 +164,84 @@ type selfGameH5EventRequest struct {
OccurredAtMS int64 `json:"occurred_at_ms"`
}
type appEventsRequest struct {
Events []appTrackingEventRequest `json:"events"`
}
type appTrackingEventRequest struct {
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventName string `json:"event_name"`
EventType string `json:"event_type"`
Screen string `json:"screen"`
TargetType string `json:"target_type"`
TargetID string `json:"target_id"`
UserID int64 `json:"user_id"`
DeviceID string `json:"device_id"`
SessionID string `json:"session_id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
Language string `json:"language"`
Timezone string `json:"timezone"`
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
DurationMS int64 `json:"duration_ms"`
Success bool `json:"success"`
ErrorCode string `json:"error_code"`
Properties json.RawMessage `json:"properties"`
OccurredAtMS int64 `json:"occurred_at_ms"`
}
func (s *queryHTTPServer) appEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})
return
}
defer r.Body.Close()
var body appEventsRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
return
}
if len(body.Events) == 0 || len(body.Events) > 50 {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "events batch is invalid"})
return
}
events := make([]mysqlstorage.AppTrackingEvent, 0, len(body.Events))
for _, item := range body.Events {
// Internal ingest 只接受 gateway 已解析过的 App、用户和维度statistics-service 只负责幂等落表,不回查 owner service。
events = append(events, mysqlstorage.AppTrackingEvent{
AppCode: appcode.Normalize(item.AppCode),
EventID: item.EventID,
EventName: item.EventName,
EventType: item.EventType,
Screen: item.Screen,
TargetType: item.TargetType,
TargetID: item.TargetID,
UserID: item.UserID,
DeviceID: item.DeviceID,
SessionID: item.SessionID,
Platform: item.Platform,
AppVersion: item.AppVersion,
Language: item.Language,
Timezone: item.Timezone,
CountryID: item.CountryID,
RegionID: item.RegionID,
DurationMS: item.DurationMS,
Success: item.Success,
ErrorCode: item.ErrorCode,
Properties: item.Properties,
OccurredAtMS: item.OccurredAtMS,
})
}
result, err := s.repo.ConsumeAppTrackingEvents(r.Context(), events)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *queryHTTPServer) selfGameEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})

View File

@ -2,11 +2,14 @@ package mysql
import (
"context"
"encoding/json"
"runtime"
"strings"
"testing"
"time"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
)
func TestQueryOverviewUsesActivityLuckyGiftDayStats(t *testing.T) {
@ -65,6 +68,120 @@ func TestQueryOverviewUsesActivityLuckyGiftDayStats(t *testing.T) {
}
}
func TestConsumeAppTrackingEventsStoresRawEventsAndDeduplicates(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_app_tracking_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, 7, 2, 23, 59, 59, 0, time.UTC).UnixMilli()
result, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), []AppTrackingEvent{
{
AppCode: "lalu",
EventID: "evt-banner-1",
EventName: "banner_view",
EventType: "view",
Screen: "home",
TargetType: "banner",
TargetID: "17",
UserID: 42,
DeviceID: "dev-1",
SessionID: "sess-1",
Platform: "ios",
AppVersion: "1.2.3",
Language: "en-US",
Timezone: "Asia/Shanghai",
CountryID: 86,
RegionID: 210,
DurationMS: 12,
Success: true,
Properties: json.RawMessage(`{"slot":"top"}`),
OccurredAtMS: occurredAt,
},
})
if err != nil {
t.Fatalf("consume app tracking event: %v", err)
}
if !result.Accepted || result.Received != 1 || result.Stored != 1 || result.Duplicated != 0 || result.ServerTimeMS <= 0 {
t.Fatalf("unexpected first consume result: %+v", result)
}
duplicate, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), []AppTrackingEvent{
{
AppCode: "lalu",
EventID: "evt-banner-1",
EventName: "banner_view",
Screen: "changed",
UserID: 42,
DeviceID: "dev-1",
OccurredAtMS: occurredAt,
},
})
if err != nil {
t.Fatalf("consume duplicate app tracking event: %v", err)
}
if duplicate.Received != 1 || duplicate.Stored != 0 || duplicate.Duplicated != 1 {
t.Fatalf("duplicate consume result mismatch: %+v", duplicate)
}
var count int64
var statDay, screen, propertiesJSON string
var userID, countryID, regionID, durationMS int64
var success bool
if err := repository.db.QueryRowContext(ctx, `
SELECT COUNT(*), MAX(CAST(stat_day AS CHAR)), MAX(screen), MAX(user_id), MAX(country_id), MAX(region_id),
MAX(duration_ms), MAX(success), COALESCE(MAX(CAST(properties_json AS CHAR)), '{}')
FROM app_tracking_events
WHERE app_code = 'lalu' AND event_id = 'evt-banner-1'
`).Scan(&count, &statDay, &screen, &userID, &countryID, &regionID, &durationMS, &success, &propertiesJSON); err != nil {
t.Fatalf("query app tracking event: %v", err)
}
if count != 1 || statDay != "2026-07-02" || screen != "home" || userID != 42 || countryID != 86 || regionID != 210 || durationMS != 12 || !success {
t.Fatalf("stored event mismatch: count=%d statDay=%s screen=%s userID=%d countryID=%d regionID=%d durationMS=%d success=%v", count, statDay, screen, userID, countryID, regionID, durationMS, success)
}
if DecodeJSON(propertiesJSON)["slot"] != "top" {
t.Fatalf("properties_json mismatch: %s", propertiesJSON)
}
}
func TestConsumeAppTrackingEventsRejectsInvalidProperties(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_app_tracking_invalid_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
for _, test := range []struct {
name string
properties json.RawMessage
}{
{name: "invalid json", properties: json.RawMessage(`{"bad"`)},
{name: "too large json", properties: json.RawMessage(`"` + strings.Repeat("a", appTrackingMaxPropertiesBytes+1) + `"`)},
} {
t.Run(test.name, func(t *testing.T) {
_, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), []AppTrackingEvent{
{EventID: "evt-" + test.name, EventName: "open", DeviceID: "dev-1", Properties: test.properties, OccurredAtMS: time.Date(2026, 7, 2, 0, 0, 0, 0, time.UTC).UnixMilli()},
})
if err == nil {
t.Fatalf("invalid properties must be rejected")
}
})
}
}
func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)

View File

@ -1,6 +1,7 @@
package mysql
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@ -21,6 +22,11 @@ const (
SourceH5 = "h5"
)
const (
appTrackingMaxBatchSize = 50
appTrackingMaxPropertiesBytes = 4096
)
// Repository owns the statistics read model. Online queries must use aggregate
// tables; the optional activity connection only reads lucky-gift day snapshots,
// never the high-cardinality draw fact table.
@ -103,6 +109,24 @@ func (r *Repository) Migrate(ctx context.Context) error {
PRIMARY KEY (app_code, source, event_id),
KEY idx_event_consumption_type (app_code, source, event_type, consumed_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS app_tracking_events (
app_code VARCHAR(32) NOT NULL, event_id VARCHAR(160) NOT NULL, event_name VARCHAR(96) NOT NULL,
event_type VARCHAR(64) NOT NULL DEFAULT '', screen VARCHAR(128) NOT NULL DEFAULT '',
target_type VARCHAR(64) NOT NULL DEFAULT '', target_id VARCHAR(128) NOT NULL DEFAULT '',
user_id BIGINT NOT NULL DEFAULT 0, device_id VARCHAR(128) NOT NULL DEFAULT '',
session_id VARCHAR(160) NOT NULL DEFAULT '', platform VARCHAR(32) NOT NULL DEFAULT '',
app_version VARCHAR(64) NOT NULL DEFAULT '', language VARCHAR(32) NOT NULL DEFAULT '',
timezone VARCHAR(64) NOT NULL DEFAULT '', country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0, duration_ms BIGINT NOT NULL DEFAULT 0,
success TINYINT(1) NOT NULL DEFAULT 0, error_code VARCHAR(64) NOT NULL DEFAULT '',
properties_json JSON NOT NULL, occurred_at_ms BIGINT NOT NULL, received_at_ms BIGINT NOT NULL,
stat_day DATE NOT NULL,
PRIMARY KEY (app_code, event_id),
KEY idx_app_tracking_event_name (app_code, stat_day, event_name),
KEY idx_app_tracking_screen (app_code, stat_day, screen),
KEY idx_app_tracking_user (app_code, user_id, occurred_at_ms),
KEY idx_app_tracking_device (app_code, device_id, occurred_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_app_day_country (
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,
@ -352,6 +376,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
}
}
for table, columns := range map[string][]string{
"app_tracking_events": {"app_code", "event_id"},
"stat_app_day_country": {"app_code", "stat_tz", "stat_day", "country_id", "region_id"},
"stat_user_day_activity": {"app_code", "stat_tz", "stat_day", "user_id"},
"stat_user_registration": {"app_code", "stat_tz", "user_id"},
@ -380,6 +405,10 @@ func (r *Repository) Migrate(ctx context.Context) error {
name string
columns []string
}{
{"app_tracking_events", "idx_app_tracking_event_name", []string{"app_code", "stat_day", "event_name"}},
{"app_tracking_events", "idx_app_tracking_screen", []string{"app_code", "stat_day", "screen"}},
{"app_tracking_events", "idx_app_tracking_user", []string{"app_code", "user_id", "occurred_at_ms"}},
{"app_tracking_events", "idx_app_tracking_device", []string{"app_code", "device_id", "occurred_at_ms"}},
{"stat_app_day_country", "idx_stat_app_day_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
{"stat_user_day_activity", "idx_stat_user_day_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
{"stat_user_day_activity", "idx_stat_user_day_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
@ -657,6 +686,38 @@ type SelfGameH5Event struct {
OccurredAtMS int64
}
type AppTrackingEvent struct {
AppCode string
EventID string
EventName string
EventType string
Screen string
TargetType string
TargetID string
UserID int64
DeviceID string
SessionID string
Platform string
AppVersion string
Language string
Timezone string
CountryID int64
RegionID int64
DurationMS int64
Success bool
ErrorCode string
Properties json.RawMessage
OccurredAtMS int64
}
type AppTrackingReportResult struct {
Accepted bool `json:"accepted"`
Received int `json:"received"`
Stored int `json:"stored"`
Duplicated int `json:"duplicated"`
ServerTimeMS int64 `json:"server_time_ms"`
}
type SelfGameMatchParticipantEvent struct {
UserID int64
ParticipantType string
@ -1286,6 +1347,52 @@ func (r *Repository) ConsumeSelfGameH5Event(ctx context.Context, event SelfGameH
})
}
func (r *Repository) ConsumeAppTrackingEvents(ctx context.Context, events []AppTrackingEvent) (AppTrackingReportResult, error) {
if r == nil || r.db == nil {
return AppTrackingReportResult{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
nowMS := time.Now().UTC().UnixMilli()
result := AppTrackingReportResult{Accepted: true, Received: len(events), ServerTimeMS: nowMS}
if len(events) == 0 || len(events) > appTrackingMaxBatchSize {
return AppTrackingReportResult{}, xerr.New(xerr.InvalidArgument, "app tracking events batch is invalid")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return AppTrackingReportResult{}, err
}
defer func() { _ = tx.Rollback() }()
for _, event := range events {
normalized, propertiesJSON, err := normalizeAppTrackingEvent(ctx, event, nowMS)
if err != nil {
return AppTrackingReportResult{}, err
}
affected, err := insertUnique(ctx, tx, `
INSERT IGNORE INTO app_tracking_events (
app_code, event_id, event_name, event_type, screen, target_type, target_id,
user_id, device_id, session_id, platform, app_version, language, timezone,
country_id, region_id, duration_ms, success, error_code, properties_json,
occurred_at_ms, received_at_ms, stat_day
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?)
`, normalized.AppCode, normalized.EventID, normalized.EventName, normalized.EventType, normalized.Screen,
normalized.TargetType, normalized.TargetID, normalized.UserID, normalized.DeviceID, normalized.SessionID,
normalized.Platform, normalized.AppVersion, normalized.Language, normalized.Timezone, normalized.CountryID,
normalized.RegionID, normalized.DurationMS, normalized.Success, normalized.ErrorCode, propertiesJSON,
normalized.OccurredAtMS, nowMS, statDay(normalized.OccurredAtMS))
if err != nil {
return AppTrackingReportResult{}, err
}
if affected > 0 {
result.Stored++
} else {
result.Duplicated++
}
}
if err := tx.Commit(); err != nil {
return AppTrackingReportResult{}, err
}
return result, nil
}
func (r *Repository) ConsumeSelfGameMatch(ctx context.Context, event SelfGameMatchEvent) error {
return r.withEvent(ctx, SourceGame, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
@ -1855,6 +1962,102 @@ func normalizeShortText(value string) string {
return strings.TrimSpace(value)
}
func normalizeAppTrackingEvent(ctx context.Context, event AppTrackingEvent, receivedAtMS int64) (AppTrackingEvent, string, error) {
var err error
if strings.TrimSpace(event.AppCode) == "" {
event.AppCode = appcode.FromContext(ctx)
}
event.AppCode = appcode.Normalize(event.AppCode)
event.EventID, err = normalizeAppTrackingText(event.EventID, 160)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.EventName, err = normalizeAppTrackingText(event.EventName, 96)
if err != nil {
return AppTrackingEvent{}, "", err
}
if event.EventID == "" || event.EventName == "" {
return AppTrackingEvent{}, "", xerr.New(xerr.InvalidArgument, "app tracking event_id and event_name are required")
}
event.EventType, err = normalizeAppTrackingText(event.EventType, 64)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.Screen, err = normalizeAppTrackingText(event.Screen, 128)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.TargetType, err = normalizeAppTrackingText(event.TargetType, 64)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.TargetID, err = normalizeAppTrackingText(event.TargetID, 128)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.DeviceID, err = normalizeAppTrackingText(event.DeviceID, 128)
if err != nil {
return AppTrackingEvent{}, "", err
}
if event.UserID <= 0 && event.DeviceID == "" {
return AppTrackingEvent{}, "", xerr.New(xerr.InvalidArgument, "anonymous app tracking event requires device_id")
}
event.SessionID, err = normalizeAppTrackingText(event.SessionID, 160)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.Platform, err = normalizeAppTrackingText(strings.ToLower(event.Platform), 32)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.AppVersion, err = normalizeAppTrackingText(event.AppVersion, 64)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.Language, err = normalizeAppTrackingText(event.Language, 32)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.Timezone, err = normalizeAppTrackingText(event.Timezone, 64)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.ErrorCode, err = normalizeAppTrackingText(event.ErrorCode, 64)
if err != nil {
return AppTrackingEvent{}, "", err
}
event.UserID = normalizeID(event.UserID)
event.CountryID, event.RegionID = normalizeDimension(event.CountryID, event.RegionID)
event.DurationMS = normalizeID(event.DurationMS)
if event.OccurredAtMS <= 0 {
event.OccurredAtMS = receivedAtMS
}
propertiesJSON, err := normalizeAppTrackingProperties(event.Properties)
if err != nil {
return AppTrackingEvent{}, "", err
}
return event, propertiesJSON, nil
}
func normalizeAppTrackingText(value string, maxBytes int) (string, error) {
value = strings.TrimSpace(value)
if maxBytes > 0 && len(value) > maxBytes {
return "", xerr.New(xerr.InvalidArgument, "app tracking text field is too long")
}
return value, nil
}
func normalizeAppTrackingProperties(raw json.RawMessage) (string, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return "{}", nil
}
if len(trimmed) > appTrackingMaxPropertiesBytes || !json.Valid(trimmed) {
return "", xerr.New(xerr.InvalidArgument, "app tracking properties_json is invalid")
}
return string(trimmed), nil
}
func normalizeDimension(countryID int64, regionID int64) (int64, int64) {
return normalizeID(countryID), normalizeID(regionID)
}