diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 4c9f7e87..f31d5d72 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -404,39 +404,6 @@ func (UserMoneyScope) TableName() string { return "admin_user_money_scopes" } -// DatabiKpiTarget 是社交 BI 里运营人员按 App×区域×月份 的充值 KPI 目标; -// 实际达成从统计链路实时聚合,目标只在 admin 库维护,不回写任何业务库。 -type DatabiKpiTarget struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"index:uk_admin_databi_kpi_target,unique;not null" json:"userId"` - AppCode string `gorm:"size:32;index:uk_admin_databi_kpi_target,unique;not null" json:"appCode"` - RegionID int64 `gorm:"index:uk_admin_databi_kpi_target,unique;not null;default:0" json:"regionId"` - PeriodMonth string `gorm:"size:7;column:period_month;index:uk_admin_databi_kpi_target,unique;index:idx_admin_databi_kpi_targets_period;not null" json:"periodMonth"` - TargetUSDMinor int64 `gorm:"column:target_usd_minor;not null;default:0" json:"targetUsdMinor"` - DailyTargetUSDMinor int64 `gorm:"column:daily_target_usd_minor;not null;default:0" json:"dailyTargetUsdMinor"` - CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` - UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` -} - -func (DatabiKpiTarget) TableName() string { - return "admin_databi_kpi_targets" -} - -// DatabiAppKpiTarget 是 App 级充值 KPI 月度目标:不分运营的全员共同目标。 -type DatabiAppKpiTarget struct { - ID uint `gorm:"primaryKey" json:"id"` - AppCode string `gorm:"size:32;index:uk_admin_databi_app_kpi_target,unique;not null" json:"appCode"` - PeriodMonth string `gorm:"size:7;column:period_month;index:uk_admin_databi_app_kpi_target,unique;not null" json:"periodMonth"` - TargetUSDMinor int64 `gorm:"column:target_usd_minor;not null;default:0" json:"targetUsdMinor"` - DailyTargetUSDMinor int64 `gorm:"column:daily_target_usd_minor;not null;default:0" json:"dailyTargetUsdMinor"` - CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` - UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` -} - -func (DatabiAppKpiTarget) TableName() string { - return "admin_databi_app_kpi_targets" -} - type TemporaryPaymentLinkOwner struct { AppCode string `gorm:"size:32;primaryKey" json:"appCode"` OrderID string `gorm:"size:96;primaryKey" json:"orderId"` diff --git a/server/admin/internal/modules/dashboard/aslan_coin_seller_bills.go b/server/admin/internal/modules/dashboard/aslan_coin_seller_bills.go new file mode 100644 index 00000000..abce263a --- /dev/null +++ b/server/admin/internal/modules/dashboard/aslan_coin_seller_bills.go @@ -0,0 +1,182 @@ +package dashboard + +import ( + "context" + "fmt" + "strings" + "time" +) + +// CoinSellerRechargeBillQuery 是 finance legacy 列表需要的币商进货明细筛选。 +// 它只服务只读展示;金额口径必须与 StatisticsOverview 的 coin_seller_recharge_usd_minor 保持一致。 +type CoinSellerRechargeBillQuery struct { + Keyword string + RegionID int64 + StartMS int64 + EndMS int64 + Page int + PageSize int +} + +type CoinSellerRechargeBill struct { + TransactionID string + Source string + UserID int64 + CountryCode string + USDMinorAmount int64 + CreatedAtMS int64 +} + +type CoinSellerRechargeBillSource interface { + ListCoinSellerRechargeBills(ctx context.Context, query CoinSellerRechargeBillQuery) ([]CoinSellerRechargeBill, int64, error) +} + +func (s *AslanMongoDashboardSource) ListCoinSellerRechargeBills(ctx context.Context, query CoinSellerRechargeBillQuery) ([]CoinSellerRechargeBill, int64, error) { + if s == nil || s.legacyDB == nil { + return nil, 0, fmt.Errorf("aslan legacy mysql is not configured") + } + countryFilter, err := s.countryFilter(ctx, StatisticsQuery{RegionID: query.RegionID}) + if err != nil { + return nil, 0, err + } + if countryFilter.Applied && len(countryFilter.Codes) == 0 { + return []CoinSellerRechargeBill{}, 0, nil + } + page := query.Page + if page < 1 { + page = 1 + } + pageSize := query.PageSize + if pageSize < 1 { + pageSize = 10 + } + unionSQL, args := s.coinSellerRechargeBillUnionSQL(query, countryFilter) + + queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout) + defer cancel() + + var total int64 + if err := s.legacyDB.QueryRowContext(queryCtx, `SELECT COUNT(*) FROM (`+unionSQL+`) bills`, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count aslan coin seller recharge bills: %w", err) + } + if total == 0 { + return []CoinSellerRechargeBill{}, 0, nil + } + + rows, err := s.legacyDB.QueryContext(queryCtx, ` +SELECT transaction_id, source, user_id, country_code, amount, created_at +FROM (`+unionSQL+`) bills +ORDER BY created_at DESC, transaction_id DESC +LIMIT ? OFFSET ?`, append(args, pageSize, (page-1)*pageSize)...) + if err != nil { + return nil, 0, fmt.Errorf("query aslan coin seller recharge bills: %w", err) + } + defer rows.Close() + + items := make([]CoinSellerRechargeBill, 0, pageSize) + for rows.Next() { + var item CoinSellerRechargeBill + var amountText string + var createdAt time.Time + if err := rows.Scan(&item.TransactionID, &item.Source, &item.UserID, &item.CountryCode, &amountText, &createdAt); err != nil { + return nil, 0, fmt.Errorf("scan aslan coin seller recharge bill: %w", err) + } + amount, err := decimalTextToMinor(amountText) + if err != nil { + return nil, 0, fmt.Errorf("parse aslan coin seller recharge bill %s amount: %w", item.TransactionID, err) + } + item.CountryCode = aslanMongoCountryCode(item.CountryCode) + item.USDMinorAmount = amount + item.CreatedAtMS = createdAt.UnixMilli() + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("iterate aslan coin seller recharge bills: %w", err) + } + return items, total, nil +} + +func (s *AslanMongoDashboardSource) coinSellerRechargeBillUnionSQL(query CoinSellerRechargeBillQuery, countryFilter externalDashboardCountryFilter) (string, []any) { + goldWhere := []string{ + "ugcr.sys_origin = ?", + "ugcr.gold_coin_source = 2", + "ugcr.recharge_amount > 0", + "ubi.country_code IS NOT NULL", + "ubi.country_code != ''", + } + goldArgs := []any{s.sysOrigin} + freightWhere := []string{ + "fbrw.sys_origin = ?", + "fbrw.origin = 'PURCHASE'", + "fbrw.type = 0", + "(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)", + "ubi.country_code IS NOT NULL", + "ubi.country_code != ''", + } + freightArgs := []any{s.sysOrigin} + if query.StartMS > 0 { + start := time.UnixMilli(query.StartMS) + goldWhere = append(goldWhere, "ugcr.create_time >= ?") + goldArgs = append(goldArgs, start) + freightWhere = append(freightWhere, "fbrw.create_time >= ?") + freightArgs = append(freightArgs, start) + } + if query.EndMS > 0 { + end := time.UnixMilli(query.EndMS) + goldWhere = append(goldWhere, "ugcr.create_time < ?") + goldArgs = append(goldArgs, end) + freightWhere = append(freightWhere, "fbrw.create_time < ?") + freightArgs = append(freightArgs, end) + } + if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + goldWhere = append(goldWhere, "(CAST(ugcr.id AS CHAR) = ? OR CONCAT('aslan-gold-coin-', ugcr.id) = ? OR CAST(ugcr.user_id AS CHAR) = ?)") + goldArgs = append(goldArgs, keyword, keyword, keyword) + freightWhere = append(freightWhere, "(CAST(fbrw.id AS CHAR) = ? OR CONCAT('aslan-freight-purchase-', fbrw.id) = ? OR CAST(fbrw.user_id AS CHAR) = ?)") + freightArgs = append(freightArgs, keyword, keyword, keyword) + } + if countryFilter.Applied { + placeholders := make([]string, 0, len(countryFilter.Codes)) + for _, code := range countryFilter.Codes { + normalized := aslanMongoCountryCode(code) + if normalized == "" { + continue + } + placeholders = append(placeholders, "?") + goldArgs = append(goldArgs, normalized) + freightArgs = append(freightArgs, normalized) + } + if len(placeholders) == 0 { + placeholders = append(placeholders, "?") + goldArgs = append(goldArgs, "__NO_COUNTRY__") + freightArgs = append(freightArgs, "__NO_COUNTRY__") + } + condition := "UPPER(ubi.country_code) IN (" + strings.Join(placeholders, ",") + ")" + goldWhere = append(goldWhere, condition) + freightWhere = append(freightWhere, condition) + } + walletDatabase := strings.TrimSpace(s.legacyWalletDatabase) + if walletDatabase == "" { + walletDatabase = "atyou_wallet" + } + unionSQL := ` +SELECT CONCAT('aslan-gold-coin-', ugcr.id) AS transaction_id, + 'gold_coin_recharge' AS source, + ugcr.user_id, + UPPER(ubi.country_code) AS country_code, + CAST(ugcr.recharge_amount AS CHAR) AS amount, + ugcr.create_time AS created_at +FROM user_gold_coin_recharge ugcr + INNER JOIN user_base_info ubi ON ubi.id = ugcr.user_id +WHERE ` + strings.Join(goldWhere, " AND ") + ` +UNION ALL +SELECT CONCAT('aslan-freight-purchase-', fbrw.id) AS transaction_id, + 'freight_purchase' AS source, + fbrw.user_id, + UPPER(ubi.country_code) AS country_code, + CAST(CASE WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount ELSE COALESCE(fbrw.usd_quantity, 0) END AS CHAR) AS amount, + fbrw.create_time AS created_at +FROM ` + quoteMySQLIdentifier(walletDatabase) + `.user_freight_balance_running_water fbrw + INNER JOIN user_base_info ubi ON ubi.id = fbrw.user_id +WHERE ` + strings.Join(freightWhere, " AND ") + return unionSQL, append(goldArgs, freightArgs...) +} diff --git a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go index 1f2070e2..4f54df0e 100644 --- a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go +++ b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go @@ -223,6 +223,60 @@ func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testin } } +func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + + source := &AslanMongoDashboardSource{ + legacyDB: db, + legacyWalletDatabase: "atyou_wallet", + sysOrigin: "ATYOU", + requestTimeout: time.Second, + } + start := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC) + startArg := time.UnixMilli(start.UnixMilli()) + endArg := time.UnixMilli(end.UnixMilli()) + createdAt := time.Date(2026, 7, 5, 12, 30, 0, 0, time.UTC) + queryPattern := "FROM user_gold_coin_recharge ugcr[\\s\\S]*UNION ALL[\\s\\S]*FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw" + mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*"+queryPattern). + WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(2))) + mock.ExpectQuery("SELECT transaction_id, source, user_id, country_code, amount, created_at[\\s\\S]*"+queryPattern). + WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg, 10, 0). + WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "source", "user_id", "country_code", "amount", "created_at"}). + AddRow("aslan-freight-purchase-99", "freight_purchase", int64(2069828597070528514), "ph", "1327.90", createdAt). + AddRow("aslan-gold-coin-7", "gold_coin_recharge", int64(2069828597070528515), "SA", "42.00", createdAt.Add(-time.Hour))) + + items, total, err := source.ListCoinSellerRechargeBills(context.Background(), CoinSellerRechargeBillQuery{ + StartMS: start.UnixMilli(), + EndMS: end.UnixMilli(), + Page: 1, + PageSize: 10, + }) + if err != nil { + t.Fatalf("ListCoinSellerRechargeBills: %v", err) + } + if total != 2 || len(items) != 2 { + t.Fatalf("unexpected result total=%d items=%+v", total, items) + } + if items[0].TransactionID != "aslan-freight-purchase-99" || items[0].Source != "freight_purchase" || items[0].CountryCode != "PH" { + t.Fatalf("unexpected first item identity: %+v", items[0]) + } + if items[0].USDMinorAmount != 132790 || items[0].CreatedAtMS != createdAt.UnixMilli() { + t.Fatalf("unexpected first item amount/time: %+v", items[0]) + } + if items[1].TransactionID != "aslan-gold-coin-7" || items[1].USDMinorAmount != 4200 { + t.Fatalf("unexpected second item: %+v", items[1]) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations: %v", err) + } +} + func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) { match := bson.M{} aslanMongoExcludeFreightGoldRecharge(match) diff --git a/server/admin/internal/modules/databi/handler.go b/server/admin/internal/modules/databi/handler.go index 21afc31b..318264cf 100644 --- a/server/admin/internal/modules/databi/handler.go +++ b/server/admin/internal/modules/databi/handler.go @@ -1,12 +1,9 @@ package databi import ( - "fmt" "strconv" "strings" - "hyapp-admin-server/internal/middleware" - "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/response" @@ -14,11 +11,6 @@ import ( "github.com/gin-gonic/gin" ) -const ( - kpiManagePermission = "databi-kpi:manage" - kpiViewAllPermission = "databi-kpi:view-all" -) - type Handler struct { service *Service store *repository.Store @@ -34,15 +26,11 @@ func (h *Handler) Master(c *gin.Context) { if !ok { return } - master, err := h.service.Master(c.Request.Context(), access, shared.ActorFromContext(c).UserID, h.canViewAllKpi(c, access)) + master, err := h.service.Master(c.Request.Context(), access, shared.ActorFromContext(c).UserID, access.All) if err != nil { response.ServerError(c, "获取 BI 主数据失败") return } - master.Permissions = map[string]bool{ - "kpi_manage": middleware.HasPermission(c, kpiManagePermission), - "kpi_view_all": h.canViewAllKpi(c, access), - } response.OK(c, master) } @@ -97,150 +85,19 @@ func (h *Handler) Kpi(c *gin.Context) { AppCodes: splitCSV(c.Query("app_codes")), OperatorUserID: queryUint(c, "operator_user_id"), ActorUserID: shared.ActorFromContext(c).UserID, - ViewAll: h.canViewAllKpi(c, access), + ViewAll: access.All, }) if err != nil { if strings.Contains(err.Error(), "period_month") { - response.BadRequest(c, "KPI 月份格式不正确") + response.BadRequest(c, "运营充值月份格式不正确") return } - response.ServerError(c, "获取 KPI 数据失败") + response.ServerError(c, "获取运营充值数据失败") return } response.OK(c, result) } -func (h *Handler) ListKpiTargets(c *gin.Context) { - access, ok := h.moneyAccess(c) - if !ok { - return - } - userIDs := []uint{} - if userID := queryUint(c, "user_id"); userID > 0 { - userIDs = append(userIDs, userID) - } - if !middleware.HasPermission(c, kpiManagePermission) && !h.canViewAllKpi(c, access) { - // 没有管理/全员查看权限时只能查询自己的目标,保证"我的 KPI"可用又不泄露他人目标。 - userIDs = []uint{shared.ActorFromContext(c).UserID} - } - months := splitCSV(c.Query("period_month")) - targets, err := h.store.ListDatabiKpiTargets(months, userIDs) - if err != nil { - response.ServerError(c, "获取 KPI 目标失败") - return - } - appTargets, err := h.store.ListDatabiAppKpiTargets(months) - if err != nil { - response.ServerError(c, "获取 App KPI 目标失败") - return - } - response.OK(c, gin.H{"appItems": appTargets, "items": targets, "total": len(targets)}) -} - -type kpiTargetItemRequest struct { - UserID uint `json:"userId"` - AppCode string `json:"appCode"` - RegionID int64 `json:"regionId"` - PeriodMonth string `json:"periodMonth"` - TargetUSDMinor int64 `json:"targetUsdMinor"` - DailyTargetUSDMinor int64 `json:"dailyTargetUsdMinor"` -} - -type kpiAppTargetItemRequest struct { - AppCode string `json:"appCode"` - PeriodMonth string `json:"periodMonth"` - TargetUSDMinor int64 `json:"targetUsdMinor"` - DailyTargetUSDMinor int64 `json:"dailyTargetUsdMinor"` -} - -type kpiTargetReplaceRequest struct { - Items []kpiTargetItemRequest `json:"items"` - // AppItems 是 App 级总目标(不分运营的全员共同目标)。 - AppItems []kpiAppTargetItemRequest `json:"appItems"` -} - -func (h *Handler) ReplaceKpiTargets(c *gin.Context) { - var request kpiTargetReplaceRequest - if err := c.ShouldBindJSON(&request); err != nil { - response.BadRequest(c, "KPI 目标参数不正确") - return - } - if len(request.Items) == 0 && len(request.AppItems) == 0 { - response.BadRequest(c, "KPI 目标不能为空") - return - } - targets := make([]model.DatabiKpiTarget, 0, len(request.Items)) - for _, item := range request.Items { - targets = append(targets, model.DatabiKpiTarget{ - UserID: item.UserID, - AppCode: item.AppCode, - RegionID: item.RegionID, - PeriodMonth: item.PeriodMonth, - TargetUSDMinor: item.TargetUSDMinor, - DailyTargetUSDMinor: item.DailyTargetUSDMinor, - }) - } - appTargets := make([]model.DatabiAppKpiTarget, 0, len(request.AppItems)) - for _, item := range request.AppItems { - appTargets = append(appTargets, model.DatabiAppKpiTarget{ - AppCode: item.AppCode, - PeriodMonth: item.PeriodMonth, - TargetUSDMinor: item.TargetUSDMinor, - DailyTargetUSDMinor: item.DailyTargetUSDMinor, - }) - } - if len(targets) > 0 { - if err := h.store.UpsertDatabiKpiTargets(targets); err != nil { - response.BadRequest(c, "保存 KPI 目标失败: "+err.Error()) - return - } - } - if len(appTargets) > 0 { - if err := h.store.UpsertDatabiAppKpiTargets(appTargets); err != nil { - response.BadRequest(c, "保存 App KPI 目标失败: "+err.Error()) - return - } - } - shared.OperationLogWithResourceID(c, h.audit, "replace-databi-kpi-targets", "admin_databi_kpi_targets", "", "success", - fmt.Sprintf("items=%d appItems=%d", len(request.Items), len(request.AppItems))) - months := map[string]struct{}{} - userIDs := map[uint]struct{}{} - for _, item := range request.Items { - months[strings.TrimSpace(item.PeriodMonth)] = struct{}{} - userIDs[item.UserID] = struct{}{} - } - for _, item := range request.AppItems { - months[strings.TrimSpace(item.PeriodMonth)] = struct{}{} - } - monthList := make([]string, 0, len(months)) - for month := range months { - monthList = append(monthList, month) - } - userList := make([]uint, 0, len(userIDs)) - for userID := range userIDs { - userList = append(userList, userID) - } - targetsAfter, err := h.store.ListDatabiKpiTargets(monthList, userList) - if err != nil { - response.ServerError(c, "获取 KPI 目标失败") - return - } - appTargetsAfter, err := h.store.ListDatabiAppKpiTargets(monthList) - if err != nil { - response.ServerError(c, "获取 App KPI 目标失败") - return - } - response.OK(c, gin.H{"appItems": appTargetsAfter, "items": targetsAfter, "total": len(targetsAfter)}) -} - -// canViewAllKpi:显式授权 databi-kpi:view-all 或拥有全量数据范围的管理员可以看全员绩效。 -func (h *Handler) canViewAllKpi(c *gin.Context, access repository.MoneyAccess) bool { - if middleware.HasPermission(c, kpiViewAllPermission) || middleware.HasPermission(c, kpiManagePermission) { - return true - } - return access.All -} - func (h *Handler) moneyAccess(c *gin.Context) (repository.MoneyAccess, bool) { if h.store == nil { return repository.MoneyAccess{All: true}, true diff --git a/server/admin/internal/modules/databi/routes.go b/server/admin/internal/modules/databi/routes.go index e6041a0c..ed519bfc 100644 --- a/server/admin/internal/modules/databi/routes.go +++ b/server/admin/internal/modules/databi/routes.go @@ -11,6 +11,4 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.GET("/admin/databi/social/overview", middleware.RequirePermission("overview:view"), h.Overview) protected.GET("/admin/databi/social/funnel", middleware.RequirePermission("overview:view"), h.Funnel) protected.GET("/admin/databi/social/kpi", middleware.RequirePermission("overview:view"), h.Kpi) - protected.GET("/admin/databi/social/kpi-targets", middleware.RequirePermission("overview:view"), h.ListKpiTargets) - protected.PUT("/admin/databi/social/kpi-targets", middleware.RequirePermission(kpiManagePermission), h.ReplaceKpiTargets) } diff --git a/server/admin/internal/modules/databi/service.go b/server/admin/internal/modules/databi/service.go index ebdd733f..864a2ca5 100644 --- a/server/admin/internal/modules/databi/service.go +++ b/server/admin/internal/modules/databi/service.go @@ -175,11 +175,10 @@ type AccessInfo struct { } type MasterData struct { - Apps []AppInfo `json:"apps"` - Regions []RegionInfo `json:"regions"` - Operators []OperatorInfo `json:"operators"` - Access AccessInfo `json:"access"` - Permissions map[string]bool `json:"permissions,omitempty"` + Apps []AppInfo `json:"apps"` + Regions []RegionInfo `json:"regions"` + Operators []OperatorInfo `json:"operators"` + Access AccessInfo `json:"access"` } // Master 返回社交 BI 的筛选主数据:当前用户可见的 App、区域目录、运营人员及各自负责范围。 @@ -522,67 +521,45 @@ type KpiQuery struct { } type KpiRow struct { - OperatorUserID uint `json:"operator_user_id"` - OperatorName string `json:"operator_name"` - OperatorAccount string `json:"operator_account"` - Team string `json:"team"` - TeamID *uint `json:"team_id"` - AppCode string `json:"app_code"` - AppName string `json:"app_name"` - RegionID int64 `json:"region_id"` - RegionCode string `json:"region_code"` - RegionName string `json:"region_name"` - RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"` - RangeNewUsers *int64 `json:"range_new_users"` - RangeActiveUsers *int64 `json:"range_active_users"` - MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"` - MonthTargetUSDMinor int64 `json:"month_target_usd_minor"` - DailyTargetUSDMinor int64 `json:"daily_target_usd_minor"` - MonthAttainmentRate *float64 `json:"month_attainment_rate"` - DataError string `json:"data_error,omitempty"` + OperatorUserID uint `json:"operator_user_id"` + OperatorName string `json:"operator_name"` + OperatorAccount string `json:"operator_account"` + Team string `json:"team"` + TeamID *uint `json:"team_id"` + AppCode string `json:"app_code"` + AppName string `json:"app_name"` + RegionID int64 `json:"region_id"` + RegionCode string `json:"region_code"` + RegionName string `json:"region_name"` + RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"` + RangeNewUsers *int64 `json:"range_new_users"` + RangeActiveUsers *int64 `json:"range_active_users"` + MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"` + DataError string `json:"data_error,omitempty"` } -// KpiOperatorAppRow 是 人×App 级小计:该运营在此 App 下全部负责区域的合计, -// 目标取自 admin_databi_kpi_targets 的 region_id=0 行(该运营的整 App 目标)。 +// KpiOperatorAppRow 是 人×App 级小计:该运营在此 App 下全部负责区域的充值合计。 type KpiOperatorAppRow struct { - OperatorUserID uint `json:"operator_user_id"` - OperatorName string `json:"operator_name"` - OperatorAccount string `json:"operator_account"` - Team string `json:"team"` - TeamID *uint `json:"team_id"` - AppCode string `json:"app_code"` - AppName string `json:"app_name"` - RegionCount int `json:"region_count"` - RegionNames string `json:"region_names"` - WholeAppScope bool `json:"whole_app_scope"` - RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"` - RangeNewUsers *int64 `json:"range_new_users"` - MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"` - MonthTargetUSDMinor int64 `json:"month_target_usd_minor"` - DailyTargetUSDMinor int64 `json:"daily_target_usd_minor"` - MonthAttainmentRate *float64 `json:"month_attainment_rate"` - DataError string `json:"data_error,omitempty"` -} - -// KpiAppRow 是 App 级总目标行:实际值为整 App 充值(不分运营的全员共同目标)。 -type KpiAppRow struct { - AppCode string `json:"app_code"` - AppName string `json:"app_name"` - OperatorCount int `json:"operator_count"` - RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"` - MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"` - MonthTargetUSDMinor int64 `json:"month_target_usd_minor"` - DailyTargetUSDMinor int64 `json:"daily_target_usd_minor"` - MonthAttainmentRate *float64 `json:"month_attainment_rate"` - DataError string `json:"data_error,omitempty"` + OperatorUserID uint `json:"operator_user_id"` + OperatorName string `json:"operator_name"` + OperatorAccount string `json:"operator_account"` + Team string `json:"team"` + TeamID *uint `json:"team_id"` + AppCode string `json:"app_code"` + AppName string `json:"app_name"` + RegionCount int `json:"region_count"` + RegionNames string `json:"region_names"` + WholeAppScope bool `json:"whole_app_scope"` + RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"` + RangeNewUsers *int64 `json:"range_new_users"` + MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"` + DataError string `json:"data_error,omitempty"` } type KpiSummary struct { - RangeRechargeUSDMinor int64 `json:"range_recharge_usd_minor"` - MonthRechargeUSDMinor int64 `json:"month_recharge_usd_minor"` - MonthTargetUSDMinor int64 `json:"month_target_usd_minor"` - MonthAttainmentRate *float64 `json:"month_attainment_rate"` - OperatorCount int `json:"operator_count"` + RangeRechargeUSDMinor int64 `json:"range_recharge_usd_minor"` + MonthRechargeUSDMinor int64 `json:"month_recharge_usd_minor"` + OperatorCount int `json:"operator_count"` } type KpiResult struct { @@ -591,13 +568,11 @@ type KpiResult struct { MonthEndMS int64 `json:"month_end_ms"` Items []KpiRow `json:"items"` OperatorAppRows []KpiOperatorAppRow `json:"operator_app_rows"` - AppRows []KpiAppRow `json:"app_rows"` Summary KpiSummary `json:"summary"` AppErrors map[string]any `json:"app_errors,omitempty"` } -// Kpi 按运营人员负责范围(App×区域)聚合充值/新增,并对齐月度 KPI 目标计算达成率。 -// 无 view-all 权限时只返回本人行,即"我的 KPI"。 +// Kpi 按运营人员负责范围(App×区域)聚合充值/新增;无全量数据范围时只返回本人行。 func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query KpiQuery) (*KpiResult, error) { users, scopesByUser, err := s.store.ListMoneyScopeOperators() if err != nil { @@ -710,19 +685,6 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query } wg.Wait() - operatorIDs := make([]uint, 0, len(operators)) - for _, user := range operators { - operatorIDs = append(operatorIDs, user.ID) - } - targets, err := s.store.ListDatabiKpiTargets([]string{periodMonth}, operatorIDs) - if err != nil { - return nil, err - } - targetByKey := map[string]model.DatabiKpiTarget{} - for _, target := range targets { - targetByKey[kpiTargetKey(target.UserID, target.AppCode, target.RegionID)] = target - } - rows := []KpiRow{} appErrors := map[string]any{} for _, user := range operators { @@ -750,10 +712,6 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query RegionID: regionID, } row.RegionCode, row.RegionName = regionDisplay(catalog[appCode], regionID) - if target, ok := lookupKpiTarget(targetByKey, user.ID, appCode, scope.RegionID, regionID); ok { - row.MonthTargetUSDMinor = target.TargetUSDMinor - row.DailyTargetUSDMinor = target.DailyTargetUSDMinor - } data := dataByApp[appCode] if data == nil { row.DataError = "该 App 未接入统计数据" @@ -767,10 +725,6 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query row.RangeNewUsers = metricPointer(rangeMetrics, "new_users") row.RangeActiveUsers = metricPointer(rangeMetrics, "active_users") row.MonthRechargeUSDMinor = metricPointer(monthMetrics, "recharge_usd_minor") - if row.MonthTargetUSDMinor > 0 && row.MonthRechargeUSDMinor != nil { - rate := safeRatio(*row.MonthRechargeUSDMinor, row.MonthTargetUSDMinor) - row.MonthAttainmentRate = &rate - } } rows = append(rows, row) } @@ -797,18 +751,8 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query return left.RegionID < right.RegionID }) - appTargets, err := s.store.ListDatabiAppKpiTargets([]string{periodMonth}) - if err != nil { - return nil, err - } - appTargetByCode := map[string]model.DatabiAppKpiTarget{} - for _, target := range appTargets { - appTargetByCode[appctx.Normalize(target.AppCode)] = target - } - - operatorAppRows := buildOperatorAppRows(rows, targetByKey) - appRows := buildAppRows(relevantApps, dataByApp, appTargetByCode, rows) - summary := buildKpiSummary(rows, operatorAppRows, appRows) + operatorAppRows := buildOperatorAppRows(rows) + summary := buildKpiSummary(rows, operatorAppRows) result := &KpiResult{ PeriodMonth: periodMonth, @@ -816,7 +760,6 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query MonthEndMS: monthEnd.UnixMilli(), Items: rows, OperatorAppRows: operatorAppRows, - AppRows: appRows, Summary: summary, } if len(appErrors) > 0 { @@ -847,7 +790,7 @@ func nullableAdd(sum *int64, value *int64) *int64 { // buildOperatorAppRows 把 人×App×区域 明细行归并成 人×App 小计: // 该运营有整 App 范围(region 0)时直接取那一行(其值即整 App 口径),否则各区域可空求和。 -func buildOperatorAppRows(rows []KpiRow, targetByKey map[string]model.DatabiKpiTarget) []KpiOperatorAppRow { +func buildOperatorAppRows(rows []KpiRow) []KpiOperatorAppRow { type group struct { row KpiOperatorAppRow regionNames []string @@ -897,18 +840,6 @@ func buildOperatorAppRows(rows []KpiRow, targetByKey map[string]model.DatabiKpiT item.row.MonthRechargeUSDMinor = item.wholeRow.MonthRechargeUSDMinor } item.row.RegionNames = joinRegionNames(item.regionNames) - if target, ok := targetByKey[kpiTargetKey(item.row.OperatorUserID, item.row.AppCode, 0)]; ok { - item.row.MonthTargetUSDMinor = target.TargetUSDMinor - item.row.DailyTargetUSDMinor = target.DailyTargetUSDMinor - } else if item.wholeRow != nil { - // 整 App 范围的运营,其明细行(region 0)目标与 人×App 目标同键,直接沿用。 - item.row.MonthTargetUSDMinor = item.wholeRow.MonthTargetUSDMinor - item.row.DailyTargetUSDMinor = item.wholeRow.DailyTargetUSDMinor - } - if item.row.MonthTargetUSDMinor > 0 && item.row.MonthRechargeUSDMinor != nil { - rate := safeRatio(*item.row.MonthRechargeUSDMinor, item.row.MonthTargetUSDMinor) - item.row.MonthAttainmentRate = &rate - } out = append(out, item.row) } sort.Slice(out, func(i, j int) bool { @@ -938,109 +869,26 @@ func joinRegionNames(names []string) string { return strings.Join(names[:3], " / ") + fmt.Sprintf(" 等%d个", len(names)) } -// buildAppRows 生成 App 级总目标行:实际值 = 整 App 充值(与运营/区域无关的共同目标口径)。 -func buildAppRows(apps []AppInfo, dataByApp map[string]*appKpiData, appTargetByCode map[string]model.DatabiAppKpiTarget, rows []KpiRow) []KpiAppRow { - operatorsByApp := map[string]map[uint]struct{}{} - for _, row := range rows { - set := operatorsByApp[row.AppCode] - if set == nil { - set = map[uint]struct{}{} - operatorsByApp[row.AppCode] = set - } - set[row.OperatorUserID] = struct{}{} - } - out := make([]KpiAppRow, 0, len(apps)) - for _, app := range apps { - row := KpiAppRow{ - AppCode: app.AppCode, - AppName: app.AppName, - OperatorCount: len(operatorsByApp[app.AppCode]), - } - if target, ok := appTargetByCode[app.AppCode]; ok { - row.MonthTargetUSDMinor = target.TargetUSDMinor - row.DailyTargetUSDMinor = target.DailyTargetUSDMinor - } - data := dataByApp[app.AppCode] - if data == nil { - row.DataError = "该 App 未接入统计数据" - } else if data.err != nil { - row.DataError = fmt.Sprintf("获取统计数据失败: %v", data.err) - } else { - row.RangeRechargeUSDMinor = metricPointer(data.rangeTotal, "recharge_usd_minor") - row.MonthRechargeUSDMinor = metricPointer(data.monthTotal, "recharge_usd_minor") - } - if row.MonthTargetUSDMinor > 0 && row.MonthRechargeUSDMinor != nil { - rate := safeRatio(*row.MonthRechargeUSDMinor, row.MonthTargetUSDMinor) - row.MonthAttainmentRate = &rate - } - out = append(out, row) - } - sort.Slice(out, func(i, j int) bool { - left := int64(0) - if out[i].MonthRechargeUSDMinor != nil { - left = *out[i].MonthRechargeUSDMinor - } - right := int64(0) - if out[j].MonthRechargeUSDMinor != nil { - right = *out[j].MonthRechargeUSDMinor - } - if left != right { - return left > right - } - return out[i].AppCode < out[j].AppCode - }) - return out -} - -// buildKpiSummary 的口径:实际充值按 App 行求和(跨运营/区域去重); -// 月目标按 App 逐个取"最权威"的一层——App 总目标 > 人×App 目标合计 > 人×区域明细目标合计。 -func buildKpiSummary(rows []KpiRow, operatorAppRows []KpiOperatorAppRow, appRows []KpiAppRow) KpiSummary { +// buildKpiSummary 只服务列表头部元信息;充值合计按人×App 小计相加,避免同一运营多区域重复。 +func buildKpiSummary(rows []KpiRow, operatorAppRows []KpiOperatorAppRow) KpiSummary { summary := KpiSummary{} operatorSeen := map[uint]struct{}{} - regionTargetByApp := map[string]int64{} for _, row := range rows { operatorSeen[row.OperatorUserID] = struct{}{} - if row.RegionID != 0 { - regionTargetByApp[row.AppCode] += row.MonthTargetUSDMinor - } } summary.OperatorCount = len(operatorSeen) - operatorAppTargetByApp := map[string]int64{} for _, row := range operatorAppRows { - operatorAppTargetByApp[row.AppCode] += row.MonthTargetUSDMinor - } - for _, row := range appRows { if row.RangeRechargeUSDMinor != nil { summary.RangeRechargeUSDMinor += *row.RangeRechargeUSDMinor } if row.MonthRechargeUSDMinor != nil { summary.MonthRechargeUSDMinor += *row.MonthRechargeUSDMinor } - summary.MonthTargetUSDMinor += pickEffectiveAppTarget( - row.MonthTargetUSDMinor, - operatorAppTargetByApp[row.AppCode], - regionTargetByApp[row.AppCode], - ) - } - if summary.MonthTargetUSDMinor > 0 { - rate := safeRatio(summary.MonthRechargeUSDMinor, summary.MonthTargetUSDMinor) - summary.MonthAttainmentRate = &rate } return summary } -// pickEffectiveAppTarget:一个 App 的"生效月目标"取最高层已配置的那级,避免多级目标重复相加。 -func pickEffectiveAppTarget(appTarget int64, operatorAppTargets int64, regionTargets int64) int64 { - if appTarget > 0 { - return appTarget - } - if operatorAppTargets > 0 { - return operatorAppTargets - } - return regionTargets -} - func (s *Service) kpiWindow(ctx context.Context, app AppInfo, resolve func(map[string]any) (RegionInfo, bool), statTZ string, startMS int64, endMS int64) (map[string]any, map[int64]map[string]any, error) { overview, err := s.statisticsOverview(ctx, dashboard.StatisticsQuery{ AppCode: app.AppCode, @@ -1082,30 +930,7 @@ func metricPointer(row map[string]any, key string) *int64 { return &out } -func kpiTargetKey(userID uint, appCode string, regionID int64) string { - return strconv.FormatUint(uint64(userID), 10) + ":" + appctx.Normalize(appCode) + ":" + strconv.FormatInt(regionID, 10) -} - -// lookupKpiTarget 依次用 scope 原始 ID 与归一后的精确 ID 查目标: -// 目标行是前端写入的,legacy 大区可能存的是圆整值,也可能是修复后的精确值。 -func lookupKpiTarget(targetByKey map[string]model.DatabiKpiTarget, userID uint, appCode string, rawRegionID int64, regionID int64) (model.DatabiKpiTarget, bool) { - if target, ok := targetByKey[kpiTargetKey(userID, appCode, rawRegionID)]; ok { - return target, true - } - if regionID != rawRegionID { - if target, ok := targetByKey[kpiTargetKey(userID, appCode, regionID)]; ok { - return target, true - } - } - if rounded := int64(float64(regionID)); rounded != rawRegionID && rounded != regionID { - if target, ok := targetByKey[kpiTargetKey(userID, appCode, rounded)]; ok { - return target, true - } - } - return model.DatabiKpiTarget{}, false -} - -// resolveKpiMonth 解析目标月份并给出该月在统计时区下的起止时间;结束时间截断到明天零点, +// resolveKpiMonth 解析统计月份并给出该月在统计时区下的起止时间;结束时间截断到明天零点, // 避免 legacy Mongo 源为未来日期空跑逐日聚合。 func resolveKpiMonth(periodMonth string, endMS int64, location *time.Location) (string, time.Time, time.Time, error) { now := time.Now().In(location) diff --git a/server/admin/internal/modules/payment/legacy_bill_source.go b/server/admin/internal/modules/payment/legacy_bill_source.go index 922f4fd1..f445efb5 100644 --- a/server/admin/internal/modules/payment/legacy_bill_source.go +++ b/server/admin/internal/modules/payment/legacy_bill_source.go @@ -26,7 +26,7 @@ import ( type legacyRechargeBillQuery struct { Keyword string // RechargeType 支持 google_play_recharge / third_party 的 Mongo 明细口径; - // coin_seller 没有 legacy 原始账单列表,summary/overview 只能从 dashboard 聚合事实补充。 + // coin_seller 优先走 dashboard 外部源暴露的 legacy 明细,缺明细源时返回空列表。 RechargeType string // PaidState=unsynced 只看谷歌实付未同步的账单。 PaidState string @@ -86,6 +86,7 @@ type MongoRechargeBillSource struct { paidStore LegacyGooglePaidStore googleOrders legacyGoogleOrdersClient coinSellerStats legacyCoinSellerRechargeStatsSource + coinSellerBills dashboard.CoinSellerRechargeBillSource } // NewMongoRechargeBillSource 创建 legacy 账单源;regionSources 用于把后台区域 ID 解析成国家码过滤条件。 @@ -132,6 +133,9 @@ func (s *MongoRechargeBillSource) SetDashboardCoinSellerSource(source dashboard. return } s.coinSellerStats = dashboardCoinSellerRechargeStatsSource{source: source} + if billSource, ok := source.(dashboard.CoinSellerRechargeBillSource); ok { + s.coinSellerBills = billSource + } } // SupportsGooglePaidSync 供 handler 判定该源能否响应谷歌实付刷新。 @@ -240,7 +244,13 @@ type legacyPurchaseProduct struct { } func (s *MongoRechargeBillSource) ListRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) { - if s == nil || s.collection == nil { + if s == nil { + return nil, 0, fmt.Errorf("legacy bill source is not configured") + } + if legacyQueryRechargeType(query) == "coin_seller" { + return s.listCoinSellerRechargeBills(ctx, query) + } + if s.collection == nil { return nil, 0, fmt.Errorf("legacy bill source is not configured") } filter, regionCodes, matchable, err := s.billFilter(ctx, query) @@ -264,6 +274,49 @@ func (s *MongoRechargeBillSource) ListRechargeBills(ctx context.Context, query l return s.listBillsByRegionPipeline(ctx, filter, regionCodes, page, pageSize) } +func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) { + if s == nil || s.coinSellerBills == nil || strings.TrimSpace(query.PaidState) != "" { + return []rechargeBillDTO{}, 0, nil + } + rows, total, err := s.coinSellerBills.ListCoinSellerRechargeBills(ctx, dashboard.CoinSellerRechargeBillQuery{ + Keyword: query.Keyword, + RegionID: query.RegionID, + StartMS: query.StartAtMS, + EndMS: query.EndAtMS, + Page: query.Page, + PageSize: query.PageSize, + }) + if err != nil { + return nil, 0, fmt.Errorf("query legacy coin seller recharge bills for %s: %w", s.config.AppCode, err) + } + countryToRegion := s.legacyCountryRegionIndex(ctx) + items := make([]rechargeBillDTO, 0, len(rows)) + for _, row := range rows { + item := rechargeBillDTO{ + AppCode: s.config.AppCode, + TransactionID: row.TransactionID, + RechargeType: "coin_seller_stock_purchase", + Status: "succeeded", + ExternalRef: row.Source, + SellerUserID: row.UserID, + CurrencyCode: "USD", + USDMinorAmount: row.USDMinorAmount, + CreatedAtMS: row.CreatedAtMS, + ProviderCode: "coin_seller", + UserPaidCurrencyCode: "USD", + UserPaidAmountMicro: row.USDMinorAmount * 10_000, + PaidSyncedAtMS: row.CreatedAtMS, + Seller: rechargeBillUserOrFallback(rechargeBillUserDTO{}, row.UserID), + } + if region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(row.CountryCode))]; ok { + item.SellerRegionID = region.RegionID + item.TargetRegionID = region.RegionID + } + items = append(items, item) + } + return items, total, nil +} + func (s *MongoRechargeBillSource) listBillsByFilter(ctx context.Context, filter bson.M, page int, pageSize int) ([]rechargeBillDTO, int64, error) { total, err := s.collection.CountDocuments(ctx, filter) if err != nil { @@ -636,7 +689,7 @@ func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRe case "third_party": filter["factory.factoryCode"] = bson.M{"$ne": "GOOGLE"} case "coin_seller": - // legacy 平台没有可分页的币商原始账单;summary/overview 走 dashboard 聚合补充。 + // coin_seller 明细不在 in_app_purchase_details;调用方会先走 listCoinSellerRechargeBills。 return nil, nil, false, nil } // Aslan 金币代理“发货”历史上也落过 in_app_purchase_details;dashboard/social 已把它归入币商充值, diff --git a/server/admin/internal/modules/payment/legacy_bill_source_test.go b/server/admin/internal/modules/payment/legacy_bill_source_test.go index 8ed7249c..1f4b861f 100644 --- a/server/admin/internal/modules/payment/legacy_bill_source_test.go +++ b/server/admin/internal/modules/payment/legacy_bill_source_test.go @@ -23,6 +23,15 @@ func yumiBillSourceConfig() config.FinanceBillSourceConfig { } } +func aslanBillSourceConfig() config.FinanceBillSourceConfig { + cfg := yumiBillSourceConfig() + cfg.AppCode = "aslan" + cfg.AppName = "Aslan" + cfg.SysOrigin = "ATYOU" + cfg.MongoDatabase = "atyou" + return cfg +} + func TestLegacyRechargeBillDTOMapsGooglePurchase(t *testing.T) { amount, err := bson.ParseDecimal128("164.99") if err != nil { @@ -199,10 +208,14 @@ func TestLegacyBillFilterUnresolvedRegionMatchesNothing(t *testing.T) { } type fakeDashboardRechargeSource struct { - appCode string - overview map[string]any - query dashboard.StatisticsQuery - calls int + appCode string + overview map[string]any + query dashboard.StatisticsQuery + calls int + bills []dashboard.CoinSellerRechargeBill + billTotal int64 + billQuery dashboard.CoinSellerRechargeBillQuery + billCalls int } func (s *fakeDashboardRechargeSource) AppCode() string { @@ -215,6 +228,67 @@ func (s *fakeDashboardRechargeSource) StatisticsOverview(_ context.Context, quer return s.overview, nil } +func (s *fakeDashboardRechargeSource) ListCoinSellerRechargeBills(_ context.Context, query dashboard.CoinSellerRechargeBillQuery) ([]dashboard.CoinSellerRechargeBill, int64, error) { + s.billQuery = query + s.billCalls++ + return s.bills, s.billTotal, nil +} + +func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) { + dashboardSource := &fakeDashboardRechargeSource{ + appCode: "aslan", + billTotal: 1, + bills: []dashboard.CoinSellerRechargeBill{{ + TransactionID: "aslan-freight-purchase-99", + Source: "freight_purchase", + UserID: 2069828597070528514, + CountryCode: "PH", + USDMinorAmount: 132790, + CreatedAtMS: 1_783_530_000_000, + }}, + } + source := &MongoRechargeBillSource{ + config: aslanBillSourceConfig(), + regionSources: []MoneyRegionSource{&staticMoneyRegionSource{regions: []moneyRegionDTO{{ + AppCode: "aslan", + RegionID: 301, + Name: "Philippines", + Countries: []string{"PH"}, + }}}}, + } + source.SetDashboardCoinSellerSource(dashboardSource) + + items, total, err := source.ListRechargeBills(context.Background(), legacyRechargeBillQuery{ + RechargeType: "coin_seller", + StartAtMS: 1_783_468_800_000, + EndAtMS: 1_786_060_800_000, + Page: 1, + PageSize: 20, + }) + if err != nil { + t.Fatalf("ListRechargeBills: %v", err) + } + if total != 1 || len(items) != 1 { + t.Fatalf("unexpected result total=%d items=%+v", total, items) + } + item := items[0] + if item.RechargeType != "coin_seller_stock_purchase" || item.ProviderCode != "coin_seller" { + t.Fatalf("unexpected coin seller mapping: %+v", item) + } + if item.TransactionID != "aslan-freight-purchase-99" || item.SellerUserID != 2069828597070528514 || item.USDMinorAmount != 132790 { + t.Fatalf("unexpected bill fields: %+v", item) + } + if item.SellerRegionID != 301 || item.TargetRegionID != 301 { + t.Fatalf("region mapping failed: %+v", item) + } + if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 { + t.Fatalf("paid facts mismatch: %+v", item) + } + if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 { + t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery) + } +} + func TestLegacyCoinSellerDashboardStatsMergeSummary(t *testing.T) { dashboardSource := &fakeDashboardRechargeSource{ appCode: "Yumi", diff --git a/server/admin/internal/repository/databi_repository.go b/server/admin/internal/repository/databi_repository.go index 2697ef98..e4b73fe2 100644 --- a/server/admin/internal/repository/databi_repository.go +++ b/server/admin/internal/repository/databi_repository.go @@ -1,20 +1,9 @@ package repository import ( - "errors" - "regexp" - "strconv" - "strings" - - "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/model" - - "gorm.io/gorm" - "gorm.io/gorm/clause" ) -var databiPeriodMonthPattern = regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])$`) - // ListMoneyScopeOperators 返回所有配置过财务/数据范围的后台用户及其范围; // 社交 BI 的"运营人员"就是这批人,不再依赖固定 team_id 约定。 func (s *Store) ListMoneyScopeOperators() ([]model.User, map[uint][]model.UserMoneyScope, error) { @@ -39,155 +28,3 @@ func (s *Store) ListMoneyScopeOperators() ([]model.User, map[uint][]model.UserMo } return users, scopesByUser, nil } - -func (s *Store) ListDatabiKpiTargets(periodMonths []string, userIDs []uint) ([]model.DatabiKpiTarget, error) { - query := s.db.Model(&model.DatabiKpiTarget{}) - months := normalizeDatabiPeriodMonths(periodMonths) - if len(months) > 0 { - query = query.Where("period_month IN ?", months) - } - if len(userIDs) > 0 { - query = query.Where("user_id IN ?", userIDs) - } - var targets []model.DatabiKpiTarget - err := query.Order("user_id ASC, app_code ASC, region_id ASC, period_month ASC").Find(&targets).Error - return targets, err -} - -// UpsertDatabiKpiTargets 逐条 upsert 目标;月目标和日目标同时为 0 视为清除该条目标。 -func (s *Store) UpsertDatabiKpiTargets(targets []model.DatabiKpiTarget) error { - normalized, removals, err := normalizeDatabiKpiTargets(targets) - if err != nil { - return err - } - return s.db.Transaction(func(tx *gorm.DB) error { - for _, target := range removals { - if err := tx.Where( - "user_id = ? AND app_code = ? AND region_id = ? AND period_month = ?", - target.UserID, target.AppCode, target.RegionID, target.PeriodMonth, - ).Delete(&model.DatabiKpiTarget{}).Error; err != nil { - return err - } - } - if len(normalized) == 0 { - return nil - } - return tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "user_id"}, {Name: "app_code"}, {Name: "region_id"}, {Name: "period_month"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "target_usd_minor", - "daily_target_usd_minor", - "updated_at_ms", - }), - }).Create(&normalized).Error - }) -} - -func (s *Store) ListDatabiAppKpiTargets(periodMonths []string) ([]model.DatabiAppKpiTarget, error) { - query := s.db.Model(&model.DatabiAppKpiTarget{}) - months := normalizeDatabiPeriodMonths(periodMonths) - if len(months) > 0 { - query = query.Where("period_month IN ?", months) - } - var targets []model.DatabiAppKpiTarget - err := query.Order("app_code ASC, period_month ASC").Find(&targets).Error - return targets, err -} - -// UpsertDatabiAppKpiTargets 逐条 upsert App 级目标;月目标和日目标同时为 0 视为清除。 -func (s *Store) UpsertDatabiAppKpiTargets(targets []model.DatabiAppKpiTarget) error { - seen := map[string]struct{}{} - upserts := make([]model.DatabiAppKpiTarget, 0, len(targets)) - removals := make([]model.DatabiAppKpiTarget, 0) - for _, target := range targets { - target.AppCode = appctx.Normalize(target.AppCode) - target.PeriodMonth = strings.TrimSpace(target.PeriodMonth) - if target.AppCode == "" { - return errors.New("app kpi target app code is required") - } - if !databiPeriodMonthPattern.MatchString(target.PeriodMonth) { - return errors.New("app kpi target period month must be formatted as YYYY-MM") - } - if target.TargetUSDMinor < 0 || target.DailyTargetUSDMinor < 0 { - return errors.New("app kpi target amounts must not be negative") - } - key := target.AppCode + ":" + target.PeriodMonth - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - if target.TargetUSDMinor == 0 && target.DailyTargetUSDMinor == 0 { - removals = append(removals, target) - continue - } - target.ID = 0 - upserts = append(upserts, target) - } - return s.db.Transaction(func(tx *gorm.DB) error { - for _, target := range removals { - if err := tx.Where("app_code = ? AND period_month = ?", target.AppCode, target.PeriodMonth). - Delete(&model.DatabiAppKpiTarget{}).Error; err != nil { - return err - } - } - if len(upserts) == 0 { - return nil - } - return tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "app_code"}, {Name: "period_month"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "target_usd_minor", - "daily_target_usd_minor", - "updated_at_ms", - }), - }).Create(&upserts).Error - }) -} - -func normalizeDatabiKpiTargets(targets []model.DatabiKpiTarget) ([]model.DatabiKpiTarget, []model.DatabiKpiTarget, error) { - seen := map[string]struct{}{} - upserts := make([]model.DatabiKpiTarget, 0, len(targets)) - removals := make([]model.DatabiKpiTarget, 0) - for _, target := range targets { - target.AppCode = appctx.Normalize(target.AppCode) - target.PeriodMonth = strings.TrimSpace(target.PeriodMonth) - if target.UserID == 0 || target.AppCode == "" || target.RegionID < 0 { - return nil, nil, errors.New("kpi target user, app code and region id are required") - } - if !databiPeriodMonthPattern.MatchString(target.PeriodMonth) { - return nil, nil, errors.New("kpi target period month must be formatted as YYYY-MM") - } - if target.TargetUSDMinor < 0 || target.DailyTargetUSDMinor < 0 { - return nil, nil, errors.New("kpi target amounts must not be negative") - } - key := strconv.FormatUint(uint64(target.UserID), 10) + ":" + target.AppCode + ":" + strconv.FormatInt(target.RegionID, 10) + ":" + target.PeriodMonth - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - if target.TargetUSDMinor == 0 && target.DailyTargetUSDMinor == 0 { - removals = append(removals, target) - continue - } - target.ID = 0 - upserts = append(upserts, target) - } - return upserts, removals, nil -} - -func normalizeDatabiPeriodMonths(periodMonths []string) []string { - out := make([]string, 0, len(periodMonths)) - seen := map[string]struct{}{} - for _, month := range periodMonths { - month = strings.TrimSpace(month) - if month == "" || !databiPeriodMonthPattern.MatchString(month) { - continue - } - if _, ok := seen[month]; ok { - continue - } - seen[month] = struct{}{} - out = append(out, month) - } - return out -} diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index 36667170..d9df2960 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -80,8 +80,6 @@ func (s *Store) AutoMigrate() error { &model.OperationLog{}, &model.DataScope{}, &model.UserMoneyScope{}, - &model.DatabiKpiTarget{}, - &model.DatabiAppKpiTarget{}, &model.TemporaryPaymentLinkOwner{}, &model.LegacyGooglePaidDetail{}, &model.FinanceApplication{}, diff --git a/server/admin/migrations/078_drop_databi_kpi_targets.sql b/server/admin/migrations/078_drop_databi_kpi_targets.sql new file mode 100644 index 00000000..5293c343 --- /dev/null +++ b/server/admin/migrations/078_drop_databi_kpi_targets.sql @@ -0,0 +1,12 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +DELETE admin_role_permissions +FROM admin_role_permissions +JOIN admin_permissions ON admin_permissions.id = admin_role_permissions.permission_id +WHERE admin_permissions.code IN ('databi-kpi:manage', 'databi-kpi:view-all'); + +DELETE FROM admin_permissions +WHERE code IN ('databi-kpi:manage', 'databi-kpi:view-all'); + +DROP TABLE IF EXISTS admin_databi_app_kpi_targets; +DROP TABLE IF EXISTS admin_databi_kpi_targets;