数据打通 yumi aslan
This commit is contained in:
parent
c53b710539
commit
a2252a7115
File diff suppressed because it is too large
Load Diff
@ -1140,10 +1140,18 @@ message RechargeBillGooglePaidStats {
|
||||
int64 est_net_usd_minor = 7;
|
||||
}
|
||||
|
||||
// RechargeBillSalaryTransferStats 是“用户提现”口径中来自钱包账本的部分:
|
||||
// 用户把工资 USDT 转给币商(salary_transfer_to_coin_seller)。
|
||||
message RechargeBillSalaryTransferStats {
|
||||
int64 transfer_count = 1;
|
||||
int64 transfer_usd_minor = 2;
|
||||
}
|
||||
|
||||
message GetRechargeBillOverviewResponse {
|
||||
repeated RechargeBillDailyBucket daily = 1;
|
||||
repeated RechargeBillRegionBucket regions = 2;
|
||||
RechargeBillGooglePaidStats google_paid = 3;
|
||||
RechargeBillSalaryTransferStats salary_transfer = 4;
|
||||
}
|
||||
|
||||
message RefreshGooglePaymentPricesRequest {
|
||||
|
||||
@ -41,6 +41,7 @@ import (
|
||||
cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward"
|
||||
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||
databimodule "hyapp-admin-server/internal/modules/databi"
|
||||
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
|
||||
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
|
||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
@ -297,6 +298,16 @@ func main() {
|
||||
financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)),
|
||||
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
|
||||
)
|
||||
// 社交 BI 与大屏共享同一个 dashboard 服务实例,保证外部源路由和统计口径一致。
|
||||
dashboardService := dashboardmodule.NewService(store, cfg, userclient.NewGRPC(userConn), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...))
|
||||
databiService := databimodule.NewService(
|
||||
store,
|
||||
dashboardService,
|
||||
appregistrymodule.NewService(userDB),
|
||||
userclient.NewGRPC(userConn),
|
||||
databimodule.WithLegacyApps(databiLegacyApps(cfg.DashboardExternalSources)...),
|
||||
databimodule.WithLegacyRegionCatalogs(databiLegacyRegionCatalogs(moneyRegionSources)...),
|
||||
)
|
||||
handlers := router.Handlers{
|
||||
Audit: auditHandler,
|
||||
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
||||
@ -312,7 +323,8 @@ func main() {
|
||||
CPWeeklyRank: cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||
Dashboard: dashboardmodule.New(store, cfg, userclient.NewGRPC(userConn), dashboardmodule.WithExternalDashboardSources(dashboardExternalSources...)),
|
||||
Dashboard: dashboardmodule.NewWithService(dashboardService),
|
||||
Databi: databimodule.New(databiService, store, auditHandler),
|
||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
|
||||
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
||||
@ -508,6 +520,38 @@ func wireLegacyGooglePaidSync(sources []paymentmodule.RechargeBillSource, config
|
||||
}
|
||||
}
|
||||
|
||||
// databiLegacyApps 把 dashboard 外部源配置映射成社交 BI 的外接 App 目录;
|
||||
// 同一 App 配多个源(如 cdc MySQL + Mongo 补充)时只取一次。
|
||||
func databiLegacyApps(configs []config.DashboardExternalSourceConfig) []databimodule.LegacyAppDescriptor {
|
||||
out := []databimodule.LegacyAppDescriptor{}
|
||||
seen := map[string]struct{}{}
|
||||
for _, sourceConfig := range configs {
|
||||
if !sourceConfig.Enabled {
|
||||
continue
|
||||
}
|
||||
appCode := strings.ToLower(strings.TrimSpace(sourceConfig.AppCode))
|
||||
if appCode == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[appCode]; ok {
|
||||
continue
|
||||
}
|
||||
seen[appCode] = struct{}{}
|
||||
out = append(out, databimodule.LegacyAppDescriptor{AppCode: appCode, AppName: strings.TrimSpace(sourceConfig.AppName)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func databiLegacyRegionCatalogs(sources []paymentmodule.MoneyRegionSource) []databimodule.LegacyRegionCatalogSource {
|
||||
out := []databimodule.LegacyRegionCatalogSource{}
|
||||
for _, source := range sources {
|
||||
if catalog, ok := source.(databimodule.LegacyRegionCatalogSource); ok && catalog != nil {
|
||||
out = append(out, catalog)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashboardmodule.ExternalDashboardRegionResolver {
|
||||
resolvers := []dashboardmodule.ExternalDashboardRegionResolver{}
|
||||
for _, source := range sources {
|
||||
|
||||
@ -404,6 +404,24 @@ 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"
|
||||
}
|
||||
|
||||
type TemporaryPaymentLinkOwner struct {
|
||||
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
|
||||
OrderID string `gorm:"size:96;primaryKey" json:"orderId"`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -96,7 +96,11 @@ type externalDashboardMetric struct {
|
||||
D7RetentionRate float64
|
||||
D30RetentionRate float64
|
||||
CoinTotal *int64
|
||||
RefreshedAt sql.NullTime
|
||||
// 以下指针字段只有 likei Mongo 源能给出事实;cdc MySQL 源保持 nil,前端展示 "--"。
|
||||
CoinSellerTransferCoin *int64
|
||||
ConsumedCoin *int64
|
||||
OutputCoin *int64
|
||||
RefreshedAt sql.NullTime
|
||||
}
|
||||
|
||||
type externalMetricScanner interface {
|
||||
@ -480,10 +484,10 @@ func (m externalDashboardMetric) toMap() map[string]any {
|
||||
"arppu_usd_minor": m.ARPPUUSDMinor,
|
||||
"coin_seller_recharge_usd_minor": m.CoinSellerRechargeUSDMinor,
|
||||
"coin_seller_stock_coin": nil,
|
||||
"coin_seller_transfer_coin": nil,
|
||||
"coin_seller_transfer_coin": nullableInt64(m.CoinSellerTransferCoin),
|
||||
"coin_total": nullableInt64(m.CoinTotal),
|
||||
"consumed_coin": nil,
|
||||
"consume_output_ratio": nil,
|
||||
"consumed_coin": nullableInt64(m.ConsumedCoin),
|
||||
"consume_output_ratio": consumeOutputRatioValue(m.ConsumedCoin, m.OutputCoin),
|
||||
"daily_active_user": m.ActiveUsers,
|
||||
"d1_retention_base_users": m.D1RetentionBaseUsers,
|
||||
"d1_retention_rate": m.D1RetentionRate,
|
||||
@ -500,6 +504,7 @@ func (m externalDashboardMetric) toMap() map[string]any {
|
||||
"game_profit_rate": m.GameProfitRate,
|
||||
"game_turnover": m.GameTurnover,
|
||||
"gift_coin_spent": m.GiftCoinSpent,
|
||||
"consume_output_delta": consumeOutputDeltaValue(m.ConsumedCoin, m.OutputCoin),
|
||||
"google_recharge_usd_minor": m.GoogleRechargeUSDMinor,
|
||||
"lucky_gift_anchor_share": m.LuckyGiftAnchorShare,
|
||||
"lucky_gift_payers": m.LuckyGiftPayers,
|
||||
@ -515,7 +520,7 @@ func (m externalDashboardMetric) toMap() map[string]any {
|
||||
"new_dealer_recharge_usd_minor": m.NewDealerRechargeUSDMinor,
|
||||
"new_user_recharge_usd_minor": m.NewUserRechargeUSDMinor,
|
||||
"new_users": m.NewUsers,
|
||||
"output_coin": nil,
|
||||
"output_coin": nullableInt64(m.OutputCoin),
|
||||
"paid_users": m.PaidUsers,
|
||||
"paid_conversion_rate": m.PaidConversionRate,
|
||||
"payer_rate": m.PayerRate,
|
||||
@ -785,6 +790,20 @@ func nullableInt64(value *int64) any {
|
||||
return *value
|
||||
}
|
||||
|
||||
func consumeOutputRatioValue(consumed *int64, output *int64) any {
|
||||
if consumed == nil || output == nil {
|
||||
return nil
|
||||
}
|
||||
return ratioFloat64(*consumed, *output)
|
||||
}
|
||||
|
||||
func consumeOutputDeltaValue(consumed *int64, output *int64) any {
|
||||
if consumed == nil || output == nil {
|
||||
return nil
|
||||
}
|
||||
return *consumed - *output
|
||||
}
|
||||
|
||||
func firstExternalDashboardValue(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
|
||||
@ -19,6 +19,11 @@ func New(store *repository.Store, cfg config.Config, user userclient.Client, opt
|
||||
return &Handler{service: NewService(store, cfg, user, options...)}
|
||||
}
|
||||
|
||||
// NewWithService 复用已构建的 DashboardService;社交 BI 模块与大屏共享同一份外部源路由。
|
||||
func NewWithService(service *DashboardService) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
func (h *Handler) DashboardOverview(c *gin.Context) {
|
||||
overview, err := h.service.Overview()
|
||||
if err != nil {
|
||||
|
||||
217
server/admin/internal/modules/databi/handler.go
Normal file
217
server/admin/internal/modules/databi/handler.go
Normal file
@ -0,0 +1,217 @@
|
||||
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"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
kpiManagePermission = "databi-kpi:manage"
|
||||
kpiViewAllPermission = "databi-kpi:view-all"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
store *repository.Store
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(service *Service, store *repository.Store, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: service, store: store, audit: audit}
|
||||
}
|
||||
|
||||
func (h *Handler) Master(c *gin.Context) {
|
||||
access, ok := h.moneyAccess(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
master, err := h.service.Master(c.Request.Context(), access, shared.ActorFromContext(c).UserID, h.canViewAllKpi(c, access))
|
||||
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)
|
||||
}
|
||||
|
||||
func (h *Handler) Overview(c *gin.Context) {
|
||||
access, ok := h.moneyAccess(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Overview(c.Request.Context(), access, OverviewQuery{
|
||||
StatTZ: c.Query("stat_tz"),
|
||||
StartMS: queryInt64(c, "start_ms"),
|
||||
EndMS: queryInt64(c, "end_ms"),
|
||||
AppCodes: splitCSV(c.Query("app_codes")),
|
||||
RegionID: queryInt64(c, "region_id"),
|
||||
})
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取 BI 统计数据失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) Kpi(c *gin.Context) {
|
||||
access, ok := h.moneyAccess(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Kpi(c.Request.Context(), access, KpiQuery{
|
||||
StatTZ: c.Query("stat_tz"),
|
||||
StartMS: queryInt64(c, "start_ms"),
|
||||
EndMS: queryInt64(c, "end_ms"),
|
||||
PeriodMonth: c.Query("period_month"),
|
||||
AppCodes: splitCSV(c.Query("app_codes")),
|
||||
OperatorUserID: queryUint(c, "operator_user_id"),
|
||||
ActorUserID: shared.ActorFromContext(c).UserID,
|
||||
ViewAll: h.canViewAllKpi(c, access),
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "period_month") {
|
||||
response.BadRequest(c, "KPI 月份格式不正确")
|
||||
return
|
||||
}
|
||||
response.ServerError(c, "获取 KPI 数据失败")
|
||||
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
|
||||
}
|
||||
response.OK(c, gin.H{"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 kpiTargetReplaceRequest struct {
|
||||
Items []kpiTargetItemRequest `json:"items"`
|
||||
}
|
||||
|
||||
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 {
|
||||
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,
|
||||
})
|
||||
}
|
||||
if err := h.store.UpsertDatabiKpiTargets(targets); err != nil {
|
||||
response.BadRequest(c, "保存 KPI 目标失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "replace-databi-kpi-targets", "admin_databi_kpi_targets", "", "success", fmt.Sprintf("items=%d", len(request.Items)))
|
||||
months := map[string]struct{}{}
|
||||
userIDs := map[uint]struct{}{}
|
||||
for _, item := range request.Items {
|
||||
months[strings.TrimSpace(item.PeriodMonth)] = struct{}{}
|
||||
userIDs[item.UserID] = 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
|
||||
}
|
||||
response.OK(c, gin.H{"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
|
||||
}
|
||||
access, err := h.store.MoneyAccessForUser(shared.ActorFromContext(c).UserID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取数据范围失败")
|
||||
return repository.MoneyAccess{}, false
|
||||
}
|
||||
return access, true
|
||||
}
|
||||
|
||||
func queryInt64(c *gin.Context, key string) int64 {
|
||||
parsed, _ := strconv.ParseInt(strings.TrimSpace(c.Query(key)), 10, 64)
|
||||
return parsed
|
||||
}
|
||||
|
||||
func queryUint(c *gin.Context, key string) uint {
|
||||
parsed, _ := strconv.ParseUint(strings.TrimSpace(c.Query(key)), 10, 64)
|
||||
return uint(parsed)
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
270
server/admin/internal/modules/databi/metrics.go
Normal file
270
server/admin/internal/modules/databi/metrics.go
Normal file
@ -0,0 +1,270 @@
|
||||
package databi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// additiveMetricKeys 是各统计源(statistics-service / dashboard 外接源)国家行里可以线性相加的指标;
|
||||
// 国家按用户归属互斥,所以计数与金额按国家求和即为区域/整体口径,比例类字段一律丢弃后重算。
|
||||
var additiveMetricKeys = []string{
|
||||
"new_users",
|
||||
"registered_users",
|
||||
"registrations",
|
||||
"active_users",
|
||||
"paid_users",
|
||||
"new_paid_users",
|
||||
"recharge_users",
|
||||
"recharge_usd_minor",
|
||||
"total_recharge_usd_minor",
|
||||
"user_recharge_usd_minor",
|
||||
"new_user_recharge_usd_minor",
|
||||
"coin_seller_recharge_usd_minor",
|
||||
"coin_seller_stock_coin",
|
||||
"coin_seller_transfer_coin",
|
||||
"new_dealer_recharge_usd_minor",
|
||||
"mifapay_recharge_usd_minor",
|
||||
"google_recharge_usd_minor",
|
||||
"coin_total",
|
||||
"consumed_coin",
|
||||
"output_coin",
|
||||
"platform_grant_coin",
|
||||
"manual_grant_coin",
|
||||
"salary_usd_minor",
|
||||
"salary_exchange_usd_minor",
|
||||
"salary_transfer_usd_minor",
|
||||
"salary_transfer_coin",
|
||||
"mic_online_ms",
|
||||
"mic_online_users",
|
||||
"gift_coin_spent",
|
||||
"lucky_gift_turnover",
|
||||
"lucky_gift_payout",
|
||||
"lucky_gift_payers",
|
||||
"lucky_gift_anchor_share",
|
||||
"room_lucky_gift_turnover",
|
||||
"game_turnover",
|
||||
"game_payout",
|
||||
"game_refund",
|
||||
"game_players",
|
||||
"super_lucky_gift_turnover",
|
||||
"super_lucky_gift_payout",
|
||||
"d1_retention_users",
|
||||
"d1_retention_base_users",
|
||||
"d7_retention_users",
|
||||
"d7_retention_base_users",
|
||||
"d30_retention_users",
|
||||
"d30_retention_base_users",
|
||||
}
|
||||
|
||||
type metricAccumulator struct {
|
||||
values map[string]int64
|
||||
present map[string]bool
|
||||
}
|
||||
|
||||
func newMetricAccumulator() *metricAccumulator {
|
||||
return &metricAccumulator{values: map[string]int64{}, present: map[string]bool{}}
|
||||
}
|
||||
|
||||
func (acc *metricAccumulator) addRow(row map[string]any) {
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range additiveMetricKeys {
|
||||
raw, ok := row[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
value, ok := metricInt64(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
acc.values[key] += value
|
||||
acc.present[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// toMap 输出与统计源同名的指标字段:可加字段直接给累加值,派生字段(比例/均值/利润)统一重算;
|
||||
// 某个字段在所有来源行里都缺失(legacy 口径未接入)时输出 nil,前端照旧显示 "--"。
|
||||
func (acc *metricAccumulator) toMap() map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, key := range additiveMetricKeys {
|
||||
if acc.present[key] {
|
||||
out[key] = acc.values[key]
|
||||
} else {
|
||||
out[key] = nil
|
||||
}
|
||||
}
|
||||
|
||||
recharge, hasRecharge := acc.value("recharge_usd_minor")
|
||||
active, hasActive := acc.value("active_users")
|
||||
paid, hasPaid := acc.value("paid_users")
|
||||
rechargeUsers, hasRechargeUsers := acc.value("recharge_users")
|
||||
|
||||
out["arpu_usd_minor"] = nilUnless(hasRecharge && hasActive, divideRound(recharge, active))
|
||||
out["arppu_usd_minor"] = nilUnless(hasRecharge && hasPaid, divideRound(recharge, paid))
|
||||
out["payer_rate"] = nilUnless(hasPaid && hasActive, safeRatio(paid, active))
|
||||
out["paid_conversion_rate"] = nilUnless(hasPaid && hasActive, safeRatio(paid, active))
|
||||
out["recharge_conversion_rate"] = nilUnless(hasRechargeUsers && hasActive, safeRatio(rechargeUsers, active))
|
||||
|
||||
gameTurnover, hasGameTurnover := acc.value("game_turnover")
|
||||
gamePayout, hasGamePayout := acc.value("game_payout")
|
||||
if hasGameTurnover && hasGamePayout {
|
||||
profit := gameTurnover - gamePayout
|
||||
out["game_profit"] = profit
|
||||
out["game_profit_rate"] = safeRatio(profit, gameTurnover)
|
||||
} else {
|
||||
out["game_profit"] = nil
|
||||
out["game_profit_rate"] = nil
|
||||
}
|
||||
|
||||
luckyTurnover, hasLuckyTurnover := acc.value("lucky_gift_turnover")
|
||||
luckyPayout, hasLuckyPayout := acc.value("lucky_gift_payout")
|
||||
if hasLuckyTurnover && hasLuckyPayout {
|
||||
profit := luckyTurnover - luckyPayout
|
||||
out["lucky_gift_profit"] = profit
|
||||
out["lucky_gift_payout_rate"] = safeRatio(luckyPayout, luckyTurnover)
|
||||
out["lucky_gift_profit_rate"] = safeRatio(profit, luckyTurnover)
|
||||
} else {
|
||||
out["lucky_gift_profit"] = nil
|
||||
out["lucky_gift_payout_rate"] = nil
|
||||
out["lucky_gift_profit_rate"] = nil
|
||||
}
|
||||
|
||||
superTurnover, hasSuperTurnover := acc.value("super_lucky_gift_turnover")
|
||||
superPayout, hasSuperPayout := acc.value("super_lucky_gift_payout")
|
||||
if hasSuperTurnover && hasSuperPayout {
|
||||
profit := superTurnover - superPayout
|
||||
out["super_lucky_gift_profit"] = profit
|
||||
out["super_lucky_gift_profit_rate"] = safeRatio(profit, superTurnover)
|
||||
} else {
|
||||
out["super_lucky_gift_profit"] = nil
|
||||
out["super_lucky_gift_profit_rate"] = nil
|
||||
}
|
||||
|
||||
consumed, hasConsumed := acc.value("consumed_coin")
|
||||
output, hasOutput := acc.value("output_coin")
|
||||
out["consume_output_ratio"] = nilUnless(hasConsumed && hasOutput, safeRatio(consumed, output))
|
||||
out["consume_output_delta"] = nilUnless(hasConsumed && hasOutput, consumed-output)
|
||||
|
||||
micMS, hasMicMS := acc.value("mic_online_ms")
|
||||
micUsers, hasMicUsers := acc.value("mic_online_users")
|
||||
out["avg_mic_online_ms"] = nilUnless(hasMicMS && hasMicUsers, divideRound(micMS, micUsers))
|
||||
|
||||
for _, day := range []string{"d1", "d7", "d30"} {
|
||||
users, hasUsers := acc.value(day + "_retention_users")
|
||||
base, hasBase := acc.value(day + "_retention_base_users")
|
||||
out[day+"_retention_rate"] = nilUnless(hasUsers && hasBase, safeRatio(users, base))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (acc *metricAccumulator) value(key string) (int64, bool) {
|
||||
return acc.values[key], acc.present[key]
|
||||
}
|
||||
|
||||
func nilUnless(condition bool, value any) any {
|
||||
if !condition {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func divideRound(numerator int64, denominator int64) int64 {
|
||||
if denominator == 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(math.Round(float64(numerator) / float64(denominator)))
|
||||
}
|
||||
|
||||
func safeRatio(numerator int64, denominator int64) float64 {
|
||||
if denominator == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(numerator) / float64(denominator)
|
||||
}
|
||||
|
||||
func metricInt64(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return 0, false
|
||||
case int64:
|
||||
return typed, true
|
||||
case int32:
|
||||
return int64(typed), true
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case uint:
|
||||
return int64(typed), true
|
||||
case uint64:
|
||||
return int64(typed), true
|
||||
case float64:
|
||||
return int64(math.Round(typed)), true
|
||||
case float32:
|
||||
return int64(math.Round(float64(typed))), true
|
||||
case json.Number:
|
||||
if parsed, err := typed.Int64(); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
if parsed, err := typed.Float64(); err == nil {
|
||||
return int64(math.Round(parsed)), true
|
||||
}
|
||||
return 0, false
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if trimmed == "" {
|
||||
return 0, false
|
||||
}
|
||||
if parsed, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
if parsed, err := strconv.ParseFloat(trimmed, 64); err == nil {
|
||||
return int64(math.Round(parsed)), true
|
||||
}
|
||||
return 0, false
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func anyRowSlice(value any) []map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]any:
|
||||
return typed
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
if row, ok := item.(map[string]any); ok {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func rowString(row map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if raw, ok := row[key]; ok && raw != nil {
|
||||
if text, ok := raw.(string); ok {
|
||||
if trimmed := strings.TrimSpace(text); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func rowInt64(row map[string]any, keys ...string) (int64, bool) {
|
||||
for _, key := range keys {
|
||||
if raw, ok := row[key]; ok && raw != nil {
|
||||
if value, ok := metricInt64(raw); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
112
server/admin/internal/modules/databi/metrics_test.go
Normal file
112
server/admin/internal/modules/databi/metrics_test.go
Normal file
@ -0,0 +1,112 @@
|
||||
package databi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRollupByRegionGroupsCountriesAndRecomputesDerived(t *testing.T) {
|
||||
regions := []RegionInfo{
|
||||
{AppCode: "aslan", RegionID: 11, RegionCode: "MENA", RegionName: "中东", Countries: []string{"SA", "AE"}},
|
||||
{AppCode: "aslan", RegionID: 12, RegionCode: "SEA", RegionName: "东南亚", Countries: []string{"ID"}},
|
||||
}
|
||||
rows := []map[string]any{
|
||||
{"country_code": "SA", "recharge_usd_minor": int64(10000), "active_users": int64(100), "paid_users": int64(10), "recharge_users": int64(10), "gift_coin_spent": nil},
|
||||
{"country_code": "AE", "recharge_usd_minor": json.Number("5000"), "active_users": json.Number("50"), "paid_users": json.Number("5"), "recharge_users": json.Number("5"), "gift_coin_spent": nil},
|
||||
{"country_code": "ID", "recharge_usd_minor": int64(2000), "active_users": int64(40), "paid_users": int64(2), "recharge_users": int64(2), "gift_coin_spent": int64(700)},
|
||||
{"country_code": "XX", "recharge_usd_minor": int64(1), "active_users": int64(1), "paid_users": int64(0), "recharge_users": int64(0)},
|
||||
}
|
||||
out := rollupByRegion(rows, regionResolver(regions))
|
||||
if len(out) != 3 {
|
||||
t.Fatalf("expected 3 region rows (MENA/SEA/未划分), got %d", len(out))
|
||||
}
|
||||
mena := out[0]
|
||||
if got, _ := rowInt64(mena, "region_id"); got != 11 {
|
||||
t.Fatalf("expected first row to be region 11 by recharge desc, got %d", got)
|
||||
}
|
||||
if got, _ := rowInt64(mena, "recharge_usd_minor"); got != 15000 {
|
||||
t.Fatalf("expected MENA recharge 15000, got %d", got)
|
||||
}
|
||||
if got, _ := rowInt64(mena, "active_users"); got != 150 {
|
||||
t.Fatalf("expected MENA active users 150, got %d", got)
|
||||
}
|
||||
if got, ok := mena["arppu_usd_minor"].(int64); !ok || got != 1000 {
|
||||
t.Fatalf("expected MENA arppu 1000, got %v", mena["arppu_usd_minor"])
|
||||
}
|
||||
if mena["gift_coin_spent"] != nil {
|
||||
t.Fatalf("expected MENA gift_coin_spent nil when all rows nil, got %v", mena["gift_coin_spent"])
|
||||
}
|
||||
sea := out[1]
|
||||
if got, _ := rowInt64(sea, "gift_coin_spent"); got != 700 {
|
||||
t.Fatalf("expected SEA gift_coin_spent 700, got %d", got)
|
||||
}
|
||||
unassigned := out[2]
|
||||
if got, _ := rowInt64(unassigned, "region_id"); got != 0 {
|
||||
t.Fatalf("expected unassigned region id 0, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollupByRegionPrefersRowRegionID(t *testing.T) {
|
||||
regions := []RegionInfo{{AppCode: "lalu", RegionID: 3, RegionCode: "LATAM", RegionName: "拉美", Countries: []string{"BR"}}}
|
||||
rows := []map[string]any{
|
||||
{"region_id": json.Number("3"), "country_id": int64(55), "recharge_usd_minor": int64(100)},
|
||||
{"region_id": int64(3), "country_id": int64(56), "recharge_usd_minor": int64(200)},
|
||||
}
|
||||
out := rollupByRegion(rows, regionResolver(regions))
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("expected 1 region row, got %d", len(out))
|
||||
}
|
||||
if got := out[0]["region_name"]; got != "拉美" {
|
||||
t.Fatalf("expected region name 拉美, got %v", got)
|
||||
}
|
||||
if got, _ := rowInt64(out[0], "recharge_usd_minor"); got != 300 {
|
||||
t.Fatalf("expected recharge 300, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterRowsByRegion(t *testing.T) {
|
||||
regions := []RegionInfo{
|
||||
{AppCode: "yumi", RegionID: 21, Countries: []string{"SA"}},
|
||||
{AppCode: "yumi", RegionID: 22, Countries: []string{"EG"}},
|
||||
}
|
||||
rows := []map[string]any{
|
||||
{"country_code": "SA"},
|
||||
{"country_code": "EG"},
|
||||
{"country_code": "??"},
|
||||
}
|
||||
out := filterRowsByRegion(rows, regionResolver(regions), int64Set([]int64{21}))
|
||||
if len(out) != 1 || out[0]["country_code"] != "SA" {
|
||||
t.Fatalf("expected only SA row, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowedRegions(t *testing.T) {
|
||||
access := testAccess()
|
||||
if all, _ := allowedRegions(access, "lalu"); !all {
|
||||
t.Fatalf("expected whole-app access for lalu (region 0 scope)")
|
||||
}
|
||||
all, ids := allowedRegions(access, "aslan")
|
||||
if all || len(ids) != 2 {
|
||||
t.Fatalf("expected two region scopes for aslan, got all=%v ids=%v", all, ids)
|
||||
}
|
||||
if all, ids := allowedRegions(access, "yumi"); all || len(ids) != 0 {
|
||||
t.Fatalf("expected no access for yumi, got all=%v ids=%v", all, ids)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKpiMonthClampsFuture(t *testing.T) {
|
||||
location := mustLocation(t, "Asia/Shanghai")
|
||||
month, start, end, err := resolveKpiMonth("2020-02", 0, location)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if month != "2020-02" {
|
||||
t.Fatalf("expected month 2020-02, got %s", month)
|
||||
}
|
||||
if start.Format("2006-01-02") != "2020-02-01" || end.Format("2006-01-02") != "2020-03-01" {
|
||||
t.Fatalf("unexpected month window %s ~ %s", start, end)
|
||||
}
|
||||
if _, _, _, err := resolveKpiMonth("2020/02", 0, location); err == nil {
|
||||
t.Fatalf("expected format error for 2020/02")
|
||||
}
|
||||
}
|
||||
15
server/admin/internal/modules/databi/routes.go
Normal file
15
server/admin/internal/modules/databi/routes.go
Normal file
@ -0,0 +1,15 @@
|
||||
package databi
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.GET("/admin/databi/social/master", middleware.RequirePermission("overview:view"), h.Master)
|
||||
protected.GET("/admin/databi/social/overview", middleware.RequirePermission("overview:view"), h.Overview)
|
||||
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)
|
||||
}
|
||||
1040
server/admin/internal/modules/databi/service.go
Normal file
1040
server/admin/internal/modules/databi/service.go
Normal file
File diff suppressed because it is too large
Load Diff
58
server/admin/internal/modules/databi/service_test.go
Normal file
58
server/admin/internal/modules/databi/service_test.go
Normal file
@ -0,0 +1,58 @@
|
||||
package databi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/model"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
)
|
||||
|
||||
func testAccess() repository.MoneyAccess {
|
||||
return repository.MoneyAccess{
|
||||
UserID: 7,
|
||||
Scopes: []model.UserMoneyScope{
|
||||
{UserID: 7, AppCode: "lalu", RegionID: 0},
|
||||
{UserID: 7, AppCode: "aslan", RegionID: 11},
|
||||
{UserID: 7, AppCode: "aslan", RegionID: 12},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func mustLocation(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
location, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
t.Fatalf("load location %s: %v", name, err)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func TestRegionDisplay(t *testing.T) {
|
||||
regions := []RegionInfo{{AppCode: "aslan", RegionID: 11, RegionCode: "MENA", RegionName: "中东"}}
|
||||
if _, name := regionDisplay(regions, 0); name != "全部区域" {
|
||||
t.Fatalf("expected 全部区域 for region 0, got %s", name)
|
||||
}
|
||||
if code, name := regionDisplay(regions, 11); code != "MENA" || name != "中东" {
|
||||
t.Fatalf("unexpected display for region 11: %s %s", code, name)
|
||||
}
|
||||
if _, name := regionDisplay(regions, 99); name != "区域 99" {
|
||||
t.Fatalf("expected fallback name for unknown region, got %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTopLevelMetricsStripsStructuralKeys(t *testing.T) {
|
||||
out := topLevelMetrics(map[string]any{
|
||||
"recharge_usd_minor": int64(5),
|
||||
"daily_series": []any{"x"},
|
||||
"country_breakdown": []any{"y"},
|
||||
"daily_country_breakdown": []any{"z"},
|
||||
"report_metric_sources": []any{},
|
||||
})
|
||||
if _, ok := out["daily_series"]; ok {
|
||||
t.Fatalf("expected daily_series stripped")
|
||||
}
|
||||
if out["recharge_usd_minor"] != int64(5) {
|
||||
t.Fatalf("expected metric kept, got %v", out["recharge_usd_minor"])
|
||||
}
|
||||
}
|
||||
@ -18,7 +18,6 @@ import (
|
||||
|
||||
"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 源不适用。
|
||||
@ -144,6 +143,8 @@ type legacyPurchaseDocument struct {
|
||||
Products []legacyPurchaseProduct `bson:"products"`
|
||||
CreateTime time.Time `bson:"createTime"`
|
||||
PurchaseDateMS time.Time `bson:"purchaseDateMs"`
|
||||
// LegacyBillCountry 由聚合阶段(legacyCountryResolveStages)解析:账单国家码优先,回退付款用户资料。
|
||||
LegacyBillCountry string `bson:"legacyBillCountry"`
|
||||
}
|
||||
|
||||
type legacyPurchaseFactory struct {
|
||||
@ -186,10 +187,14 @@ func (s *MongoRechargeBillSource) listBillsByFilter(ctx context.Context, filter
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("count legacy recharge bills for %s: %w", s.config.AppCode, err)
|
||||
}
|
||||
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)))
|
||||
// 国家解析放在分页之后:每页最多 pageSize 次 $lookup,用于把账单落到区域列展示。
|
||||
pipeline := append(mongo.Pipeline{
|
||||
bson.D{{Key: "$match", Value: filter}},
|
||||
bson.D{{Key: "$sort", Value: bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}}},
|
||||
bson.D{{Key: "$skip", Value: int64(page-1) * int64(pageSize)}},
|
||||
bson.D{{Key: "$limit", Value: int64(pageSize)}},
|
||||
}, legacyCountryResolveStages()...)
|
||||
cursor, err := s.collection.Aggregate(ctx, pipeline)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query legacy recharge bills for %s: %w", s.config.AppCode, err)
|
||||
}
|
||||
@ -246,13 +251,21 @@ func (s *MongoRechargeBillSource) listBillsByRegionPipeline(ctx context.Context,
|
||||
}
|
||||
|
||||
func (s *MongoRechargeBillSource) decodeBillCursor(ctx context.Context, cursor *mongo.Cursor, pageSize int) ([]rechargeBillDTO, error) {
|
||||
countryToRegion := s.legacyCountryRegionIndex(ctx)
|
||||
items := make([]rechargeBillDTO, 0, pageSize)
|
||||
for cursor.Next(ctx) {
|
||||
var document legacyPurchaseDocument
|
||||
if err := cursor.Decode(&document); err != nil {
|
||||
return nil, fmt.Errorf("decode legacy recharge bill for %s: %w", s.config.AppCode, err)
|
||||
}
|
||||
items = append(items, legacyRechargeBillDTO(s.config, document))
|
||||
item := legacyRechargeBillDTO(s.config, document)
|
||||
// likei 账单没有区域 ID;用解析出的国家码映射区域目录,供列表区域列与导出展示。
|
||||
if country := strings.ToUpper(strings.TrimSpace(document.LegacyBillCountry)); country != "" {
|
||||
if region, ok := countryToRegion[country]; ok {
|
||||
item.TargetRegionID = region.RegionID
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := cursor.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate legacy recharge bills for %s: %w", s.config.AppCode, err)
|
||||
|
||||
@ -160,6 +160,40 @@ func (s *MongoMoneyRegionSource) CountryCodesForRegion(ctx context.Context, appC
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
// RegionCatalogEntry 是给其他模块(社交 BI 等)消费的 legacy 区域目录导出形态;
|
||||
// RegionID 与财务范围/大屏过滤使用同一套合成 int64 口径。
|
||||
type RegionCatalogEntry struct {
|
||||
AppCode string
|
||||
AppName string
|
||||
RegionID int64
|
||||
RegionCode string
|
||||
RegionName string
|
||||
Countries []string
|
||||
}
|
||||
|
||||
func (s *MongoMoneyRegionSource) RegionCatalog(ctx context.Context) ([]RegionCatalogEntry, error) {
|
||||
if s == nil || s.collection == nil {
|
||||
return nil, nil
|
||||
}
|
||||
_, regions, _, err := s.ListMoneyMasterData(ctx, repository.MoneyAccess{All: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appName := strings.TrimSpace(s.config.AppName)
|
||||
entries := make([]RegionCatalogEntry, 0, len(regions))
|
||||
for _, region := range regions {
|
||||
entries = append(entries, RegionCatalogEntry{
|
||||
AppCode: region.AppCode,
|
||||
AppName: appName,
|
||||
RegionID: region.RegionID,
|
||||
RegionCode: region.RegionCode,
|
||||
RegionName: region.Name,
|
||||
Countries: region.Countries,
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfig, documents []legacyRegionDocument, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
|
||||
appCode := appctx.Normalize(sourceConfig.AppCode)
|
||||
if appCode == "" || !moneyAccessAllowsApp(access, appCode) {
|
||||
|
||||
@ -39,10 +39,20 @@ type rechargeBillGooglePaidStatsDTO struct {
|
||||
EstNetUsdMinor int64 `json:"estNetUsdMinor"`
|
||||
}
|
||||
|
||||
// rechargeBillWithdrawalStatsDTO 是“用户提现”合并口径:工资转币商(钱包账本)+ 审核通过的提现申请(admin 库)。
|
||||
type rechargeBillWithdrawalStatsDTO struct {
|
||||
TransferCount int64 `json:"transferCount"`
|
||||
TransferUsdMinor int64 `json:"transferUsdMinor"`
|
||||
ApprovedCount int64 `json:"approvedCount"`
|
||||
ApprovedUsdMinor int64 `json:"approvedUsdMinor"`
|
||||
TotalUsdMinor int64 `json:"totalUsdMinor"`
|
||||
}
|
||||
|
||||
type rechargeBillOverviewDTO struct {
|
||||
Daily []rechargeBillDailyBucketDTO `json:"daily"`
|
||||
Regions []rechargeBillRegionBucketDTO `json:"regions"`
|
||||
GooglePaid rechargeBillGooglePaidStatsDTO `json:"googlePaid"`
|
||||
Withdrawal rechargeBillWithdrawalStatsDTO `json:"withdrawal"`
|
||||
}
|
||||
|
||||
// GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
|
||||
@ -54,18 +64,21 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
||||
// 财务系统统一按中国时区切日边界。
|
||||
tzOffsetMinutes = 480
|
||||
}
|
||||
startAtMS := queryInt64(c, "start_at_ms", "startAtMs")
|
||||
endAtMS := queryInt64(c, "end_at_ms", "endAtMs")
|
||||
if source, ok := h.billSources[appCode]; ok {
|
||||
overview, err := source.Overview(c.Request.Context(), legacyRechargeBillQuery{
|
||||
Keyword: options.Keyword,
|
||||
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||
RegionID: queryInt64(c, "region_id", "regionId"),
|
||||
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
|
||||
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
|
||||
StartAtMS: startAtMS,
|
||||
EndAtMS: endAtMS,
|
||||
}, tzOffsetMinutes)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取财务概览失败")
|
||||
return
|
||||
}
|
||||
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
|
||||
response.OK(c, overview)
|
||||
return
|
||||
}
|
||||
@ -76,8 +89,8 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
||||
RegionId: queryInt64(c, "region_id", "regionId"),
|
||||
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||
Status: options.Status,
|
||||
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
|
||||
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
|
||||
StartAtMs: startAtMS,
|
||||
EndAtMs: endAtMS,
|
||||
TzOffsetMinutes: tzOffsetMinutes,
|
||||
})
|
||||
if err != nil {
|
||||
@ -130,5 +143,21 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
||||
EstNetUsdMinor: stats.GetEstNetUsdMinor(),
|
||||
}
|
||||
}
|
||||
if transfer := resp.GetSalaryTransfer(); transfer != nil {
|
||||
overview.Withdrawal.TransferCount = transfer.GetTransferCount()
|
||||
overview.Withdrawal.TransferUsdMinor = transfer.GetTransferUsdMinor()
|
||||
}
|
||||
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
|
||||
response.OK(c, overview)
|
||||
}
|
||||
|
||||
// fillApprovedWithdrawalStats 把 admin 库的“审核通过提现申请”并入用户提现口径;查询失败只降级该卡片,不阻断概览。
|
||||
func (h *Handler) fillApprovedWithdrawalStats(overview *rechargeBillOverviewDTO, appCode string, startAtMS int64, endAtMS int64) {
|
||||
if h.store != nil {
|
||||
if stats, err := h.store.ApprovedWithdrawalStats(appCode, startAtMS, endAtMS); err == nil {
|
||||
overview.Withdrawal.ApprovedCount = stats.ApprovedCount
|
||||
overview.Withdrawal.ApprovedUsdMinor = stats.ApprovedUSDMinor
|
||||
}
|
||||
}
|
||||
overview.Withdrawal.TotalUsdMinor = overview.Withdrawal.TransferUsdMinor + overview.Withdrawal.ApprovedUsdMinor
|
||||
}
|
||||
|
||||
@ -75,10 +75,12 @@ func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
|
||||
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"),
|
||||
Keyword: options.Keyword,
|
||||
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
|
||||
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, "获取充值汇总失败")
|
||||
|
||||
132
server/admin/internal/repository/databi_repository.go
Normal file
132
server/admin/internal/repository/databi_repository.go
Normal file
@ -0,0 +1,132 @@
|
||||
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) {
|
||||
var scopes []model.UserMoneyScope
|
||||
if err := s.db.Order("user_id ASC, app_code ASC, region_id ASC").Find(&scopes).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
scopesByUser := map[uint][]model.UserMoneyScope{}
|
||||
userIDs := make([]uint, 0, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
if _, ok := scopesByUser[scope.UserID]; !ok {
|
||||
userIDs = append(userIDs, scope.UserID)
|
||||
}
|
||||
scopesByUser[scope.UserID] = append(scopesByUser[scope.UserID], scope)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return []model.User{}, scopesByUser, nil
|
||||
}
|
||||
var users []model.User
|
||||
if err := s.db.Where("id IN ?", userIDs).Order("id ASC").Find(&users).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
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 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
|
||||
}
|
||||
@ -80,6 +80,7 @@ func (s *Store) AutoMigrate() error {
|
||||
&model.OperationLog{},
|
||||
&model.DataScope{},
|
||||
&model.UserMoneyScope{},
|
||||
&model.DatabiKpiTarget{},
|
||||
&model.TemporaryPaymentLinkOwner{},
|
||||
&model.LegacyGooglePaidDetail{},
|
||||
&model.FinanceApplication{},
|
||||
|
||||
@ -92,6 +92,32 @@ func (s *Store) AuditWithdrawalApplication(id uint, input WithdrawalApplicationA
|
||||
return s.GetWithdrawalApplication(id)
|
||||
}
|
||||
|
||||
// ApprovedWithdrawalStats 按审批通过时间聚合用户提现申请的 USDT 金额(withdraw_amount_minor,美分口径)。
|
||||
type ApprovedWithdrawalStats struct {
|
||||
ApprovedCount int64
|
||||
ApprovedUSDMinor int64
|
||||
}
|
||||
|
||||
func (s *Store) ApprovedWithdrawalStats(appCode string, startAtMS int64, endAtMS int64) (ApprovedWithdrawalStats, error) {
|
||||
query := s.db.Model(&model.UserWithdrawalApplication{}).
|
||||
Where("status = ?", model.WithdrawalApplicationStatusApproved)
|
||||
if appCode = strings.TrimSpace(appCode); appCode != "" {
|
||||
query = query.Where("app_code = ?", appCode)
|
||||
}
|
||||
if startAtMS > 0 {
|
||||
query = query.Where("approved_at_ms >= ?", startAtMS)
|
||||
}
|
||||
if endAtMS > 0 {
|
||||
query = query.Where("approved_at_ms < ?", endAtMS)
|
||||
}
|
||||
var stats ApprovedWithdrawalStats
|
||||
row := query.Select("COUNT(*), COALESCE(SUM(withdraw_amount_minor), 0)").Row()
|
||||
if err := row.Scan(&stats.ApprovedCount, &stats.ApprovedUSDMinor); err != nil {
|
||||
return ApprovedWithdrawalStats{}, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func applyWithdrawalApplicationFilters(query *gorm.DB, options WithdrawalApplicationListOptions) *gorm.DB {
|
||||
if appCode := strings.TrimSpace(options.AppCode); appCode != "" {
|
||||
query = query.Where("app_code = ?", appCode)
|
||||
|
||||
@ -18,6 +18,7 @@ import (
|
||||
"hyapp-admin-server/internal/modules/cumulativerechargereward"
|
||||
"hyapp-admin-server/internal/modules/dailytask"
|
||||
"hyapp-admin-server/internal/modules/dashboard"
|
||||
"hyapp-admin-server/internal/modules/databi"
|
||||
"hyapp-admin-server/internal/modules/financeapplication"
|
||||
"hyapp-admin-server/internal/modules/financewithdrawal"
|
||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
@ -78,6 +79,7 @@ type Handlers struct {
|
||||
CumulativeRecharge *cumulativerechargereward.Handler
|
||||
DailyTask *dailytask.Handler
|
||||
Dashboard *dashboard.Handler
|
||||
Databi *databi.Handler
|
||||
FirstRechargeReward *firstrechargereward.Handler
|
||||
FinanceApplication *financeapplication.Handler
|
||||
FinanceWithdrawal *financewithdrawal.Handler
|
||||
@ -160,6 +162,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
roomrocket.RegisterRoutes(protected, h.RoomRocket)
|
||||
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward)
|
||||
dashboard.RegisterRoutes(protected, h.Dashboard)
|
||||
databi.RegisterRoutes(protected, h.Databi)
|
||||
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
|
||||
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
|
||||
hostorg.RegisterRoutes(protected, h.HostOrg)
|
||||
|
||||
34
server/admin/migrations/075_databi_kpi_targets.sql
Normal file
34
server/admin/migrations/075_databi_kpi_targets.sql
Normal file
@ -0,0 +1,34 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_databi_kpi_targets (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT UNSIGNED NOT NULL COMMENT '后台用户 ID(运营人员)',
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域 ID,0 表示整个 app',
|
||||
period_month CHAR(7) NOT NULL COMMENT '目标月份,格式 YYYY-MM',
|
||||
target_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '当月充值目标,USD 分',
|
||||
daily_target_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '当日充值目标,USD 分;0 表示按月目标/自然天数折算',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
UNIQUE KEY uk_admin_databi_kpi_target (user_id, app_code, region_id, period_month),
|
||||
KEY idx_admin_databi_kpi_targets_period (period_month, app_code, region_id),
|
||||
CONSTRAINT fk_admin_databi_kpi_targets_user FOREIGN KEY (user_id) REFERENCES admin_users(id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='社交 BI 运营人员充值 KPI 月度目标';
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('BI KPI 目标配置', 'databi-kpi:manage', 'button', '允许配置社交 BI 运营人员的充值 KPI 目标', @now_ms, @now_ms),
|
||||
('BI 全员绩效查看', 'databi-kpi:view-all', 'button', '允许查看所有运营人员的 KPI 绩效;无此权限时只能查看自己', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
kind = VALUES(kind),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code = 'platform-admin'
|
||||
AND admin_permission.code IN ('databi-kpi:manage', 'databi-kpi:view-all');
|
||||
@ -99,11 +99,18 @@ type RechargeBillGooglePaidStats struct {
|
||||
EstNetUSDMinor int64
|
||||
}
|
||||
|
||||
// RechargeBillOverview 汇集财务概览页所需的趋势、区域分布与谷歌扣费口径。
|
||||
// RechargeBillSalaryTransferStats 是用户提现口径中的钱包账本部分:用户把工资 USDT 转给币商。
|
||||
type RechargeBillSalaryTransferStats struct {
|
||||
TransferCount int64
|
||||
TransferUSDMinor int64
|
||||
}
|
||||
|
||||
// RechargeBillOverview 汇集财务概览页所需的趋势、区域分布、谷歌扣费与提现口径。
|
||||
type RechargeBillOverview struct {
|
||||
Daily []RechargeBillDailyBucket
|
||||
Regions []RechargeBillRegionBucket
|
||||
GooglePaid RechargeBillGooglePaidStats
|
||||
Daily []RechargeBillDailyBucket
|
||||
Regions []RechargeBillRegionBucket
|
||||
GooglePaid RechargeBillGooglePaidStats
|
||||
SalaryTransfer RechargeBillSalaryTransferStats
|
||||
}
|
||||
|
||||
// WalletFeatureFlags 是 App 钱包入口的开关投影;首版由 wallet-service 固定返回,后续可迁移到配置表。
|
||||
|
||||
@ -50,6 +50,12 @@ func (r *Repository) GetRechargeBillOverview(ctx context.Context, query ledger.L
|
||||
}
|
||||
overview.GooglePaid = stats
|
||||
}
|
||||
// 用户提现(转币商)与充值渠道筛选无关,始终按 App/时间/区域口径统计。
|
||||
salaryTransfer, err := r.overviewSalaryTransferStats(ctx, query)
|
||||
if err != nil {
|
||||
return ledger.RechargeBillOverview{}, err
|
||||
}
|
||||
overview.SalaryTransfer = salaryTransfer
|
||||
|
||||
dayIndexes := make([]int64, 0, len(dailyBuckets))
|
||||
for dayIndex := range dailyBuckets {
|
||||
@ -202,6 +208,40 @@ func scanOverviewRegionRows(rows overviewRegionRows, buckets map[int64]*ledger.R
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// overviewSalaryTransferStats 汇总用户工资转币商的 USDT:金额在交易 metadata 的 salary_usd_minor,
|
||||
// 该业务量级小(人工触发),JSON 提取聚合不构成性能压力。
|
||||
func (r *Repository) overviewSalaryTransferStats(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillSalaryTransferStats, error) {
|
||||
where := `WHERE app_code = ? AND biz_type = ?`
|
||||
args := []any{query.AppCode, bizTypeSalaryTransferToCoinSeller}
|
||||
if query.Status != "" {
|
||||
where += ` AND status = ?`
|
||||
args = append(args, query.Status)
|
||||
}
|
||||
if query.RegionID > 0 {
|
||||
where += ` AND COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.region_id')) AS SIGNED), 0) = ?`
|
||||
args = append(args, query.RegionID)
|
||||
}
|
||||
if query.StartAtMS > 0 {
|
||||
where += ` AND created_at_ms >= ?`
|
||||
args = append(args, query.StartAtMS)
|
||||
}
|
||||
if query.EndAtMS > 0 {
|
||||
where += ` AND created_at_ms < ?`
|
||||
args = append(args, query.EndAtMS)
|
||||
}
|
||||
var stats ledger.RechargeBillSalaryTransferStats
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*), COALESCE(SUM(COALESCE(CAST(JSON_UNQUOTE(JSON_EXTRACT(metadata, '$.salary_usd_minor')) AS SIGNED), 0)), 0)
|
||||
FROM wallet_transactions
|
||||
`+where,
|
||||
args...,
|
||||
).Scan(&stats.TransferCount, &stats.TransferUSDMinor)
|
||||
if err != nil {
|
||||
return ledger.RechargeBillSalaryTransferStats{}, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// overviewGooglePaidStats 统计谷歌实付同步覆盖度,并把已同步账单的税/费/净收按实付占比折算到 USD 口径。
|
||||
// 估算只覆盖“已同步且净收入有效”的账单;负差额按 0 处理,避免退款单把手续费冲成负数。
|
||||
func (r *Repository) overviewGooglePaidStats(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillGooglePaidStats, error) {
|
||||
|
||||
@ -88,6 +88,10 @@ func (s *Server) GetRechargeBillOverview(ctx context.Context, req *walletv1.GetR
|
||||
EstTaxUsdMinor: overview.GooglePaid.EstTaxUSDMinor,
|
||||
EstNetUsdMinor: overview.GooglePaid.EstNetUSDMinor,
|
||||
},
|
||||
SalaryTransfer: &walletv1.RechargeBillSalaryTransferStats{
|
||||
TransferCount: overview.SalaryTransfer.TransferCount,
|
||||
TransferUsdMinor: overview.SalaryTransfer.TransferUSDMinor,
|
||||
},
|
||||
}
|
||||
for _, bucket := range overview.Daily {
|
||||
resp.Daily = append(resp.Daily, &walletv1.RechargeBillDailyBucket{
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user