数据打通 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;
|
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 {
|
message GetRechargeBillOverviewResponse {
|
||||||
repeated RechargeBillDailyBucket daily = 1;
|
repeated RechargeBillDailyBucket daily = 1;
|
||||||
repeated RechargeBillRegionBucket regions = 2;
|
repeated RechargeBillRegionBucket regions = 2;
|
||||||
RechargeBillGooglePaidStats google_paid = 3;
|
RechargeBillGooglePaidStats google_paid = 3;
|
||||||
|
RechargeBillSalaryTransferStats salary_transfer = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message RefreshGooglePaymentPricesRequest {
|
message RefreshGooglePaymentPricesRequest {
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import (
|
|||||||
cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward"
|
cumulativerechargerewardmodule "hyapp-admin-server/internal/modules/cumulativerechargereward"
|
||||||
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
dailytaskmodule "hyapp-admin-server/internal/modules/dailytask"
|
||||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||||
|
databimodule "hyapp-admin-server/internal/modules/databi"
|
||||||
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
|
financeapplicationmodule "hyapp-admin-server/internal/modules/financeapplication"
|
||||||
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
|
financewithdrawalmodule "hyapp-admin-server/internal/modules/financewithdrawal"
|
||||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
@ -297,6 +298,16 @@ func main() {
|
|||||||
financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)),
|
financeapplicationmodule.WithUserClient(userclient.NewGRPC(userConn)),
|
||||||
financeapplicationmodule.WithWalletClient(walletclient.NewGRPC(walletConn)),
|
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{
|
handlers := router.Handlers{
|
||||||
Audit: auditHandler,
|
Audit: auditHandler,
|
||||||
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
Auth: authmodule.New(store, auth, auditHandler, cfg),
|
||||||
@ -312,7 +323,8 @@ func main() {
|
|||||||
CPWeeklyRank: cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
CPWeeklyRank: cpweeklyrankmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
|
||||||
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
CumulativeRecharge: cumulativerechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
DailyTask: dailytaskmodule.New(activityclient.NewGRPC(activityConn), 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),
|
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||||
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
|
FinanceApplication: financeapplicationmodule.New(store, auditHandler, financeApplicationOptions...),
|
||||||
FinanceWithdrawal: financewithdrawalmodule.New(store, walletclient.NewGRPC(walletConn), activityclient.NewGRPC(activityConn), auditHandler),
|
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 {
|
func dashboardRegionResolvers(sources []paymentmodule.MoneyRegionSource) []dashboardmodule.ExternalDashboardRegionResolver {
|
||||||
resolvers := []dashboardmodule.ExternalDashboardRegionResolver{}
|
resolvers := []dashboardmodule.ExternalDashboardRegionResolver{}
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
|
|||||||
@ -404,6 +404,24 @@ func (UserMoneyScope) TableName() string {
|
|||||||
return "admin_user_money_scopes"
|
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 {
|
type TemporaryPaymentLinkOwner struct {
|
||||||
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
|
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
|
||||||
OrderID string `gorm:"size:96;primaryKey" json:"orderId"`
|
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
|
D7RetentionRate float64
|
||||||
D30RetentionRate float64
|
D30RetentionRate float64
|
||||||
CoinTotal *int64
|
CoinTotal *int64
|
||||||
RefreshedAt sql.NullTime
|
// 以下指针字段只有 likei Mongo 源能给出事实;cdc MySQL 源保持 nil,前端展示 "--"。
|
||||||
|
CoinSellerTransferCoin *int64
|
||||||
|
ConsumedCoin *int64
|
||||||
|
OutputCoin *int64
|
||||||
|
RefreshedAt sql.NullTime
|
||||||
}
|
}
|
||||||
|
|
||||||
type externalMetricScanner interface {
|
type externalMetricScanner interface {
|
||||||
@ -480,10 +484,10 @@ func (m externalDashboardMetric) toMap() map[string]any {
|
|||||||
"arppu_usd_minor": m.ARPPUUSDMinor,
|
"arppu_usd_minor": m.ARPPUUSDMinor,
|
||||||
"coin_seller_recharge_usd_minor": m.CoinSellerRechargeUSDMinor,
|
"coin_seller_recharge_usd_minor": m.CoinSellerRechargeUSDMinor,
|
||||||
"coin_seller_stock_coin": nil,
|
"coin_seller_stock_coin": nil,
|
||||||
"coin_seller_transfer_coin": nil,
|
"coin_seller_transfer_coin": nullableInt64(m.CoinSellerTransferCoin),
|
||||||
"coin_total": nullableInt64(m.CoinTotal),
|
"coin_total": nullableInt64(m.CoinTotal),
|
||||||
"consumed_coin": nil,
|
"consumed_coin": nullableInt64(m.ConsumedCoin),
|
||||||
"consume_output_ratio": nil,
|
"consume_output_ratio": consumeOutputRatioValue(m.ConsumedCoin, m.OutputCoin),
|
||||||
"daily_active_user": m.ActiveUsers,
|
"daily_active_user": m.ActiveUsers,
|
||||||
"d1_retention_base_users": m.D1RetentionBaseUsers,
|
"d1_retention_base_users": m.D1RetentionBaseUsers,
|
||||||
"d1_retention_rate": m.D1RetentionRate,
|
"d1_retention_rate": m.D1RetentionRate,
|
||||||
@ -500,6 +504,7 @@ func (m externalDashboardMetric) toMap() map[string]any {
|
|||||||
"game_profit_rate": m.GameProfitRate,
|
"game_profit_rate": m.GameProfitRate,
|
||||||
"game_turnover": m.GameTurnover,
|
"game_turnover": m.GameTurnover,
|
||||||
"gift_coin_spent": m.GiftCoinSpent,
|
"gift_coin_spent": m.GiftCoinSpent,
|
||||||
|
"consume_output_delta": consumeOutputDeltaValue(m.ConsumedCoin, m.OutputCoin),
|
||||||
"google_recharge_usd_minor": m.GoogleRechargeUSDMinor,
|
"google_recharge_usd_minor": m.GoogleRechargeUSDMinor,
|
||||||
"lucky_gift_anchor_share": m.LuckyGiftAnchorShare,
|
"lucky_gift_anchor_share": m.LuckyGiftAnchorShare,
|
||||||
"lucky_gift_payers": m.LuckyGiftPayers,
|
"lucky_gift_payers": m.LuckyGiftPayers,
|
||||||
@ -515,7 +520,7 @@ func (m externalDashboardMetric) toMap() map[string]any {
|
|||||||
"new_dealer_recharge_usd_minor": m.NewDealerRechargeUSDMinor,
|
"new_dealer_recharge_usd_minor": m.NewDealerRechargeUSDMinor,
|
||||||
"new_user_recharge_usd_minor": m.NewUserRechargeUSDMinor,
|
"new_user_recharge_usd_minor": m.NewUserRechargeUSDMinor,
|
||||||
"new_users": m.NewUsers,
|
"new_users": m.NewUsers,
|
||||||
"output_coin": nil,
|
"output_coin": nullableInt64(m.OutputCoin),
|
||||||
"paid_users": m.PaidUsers,
|
"paid_users": m.PaidUsers,
|
||||||
"paid_conversion_rate": m.PaidConversionRate,
|
"paid_conversion_rate": m.PaidConversionRate,
|
||||||
"payer_rate": m.PayerRate,
|
"payer_rate": m.PayerRate,
|
||||||
@ -785,6 +790,20 @@ func nullableInt64(value *int64) any {
|
|||||||
return *value
|
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 {
|
func firstExternalDashboardValue(values ...string) string {
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
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...)}
|
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) {
|
func (h *Handler) DashboardOverview(c *gin.Context) {
|
||||||
overview, err := h.service.Overview()
|
overview, err := h.service.Overview()
|
||||||
if err != nil {
|
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/bson"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo"
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
||||||
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// legacyRechargeBillQuery 是 legacy 账单源支持的筛选子集;用户/币商筛选属于 hyapp 语义,legacy 源不适用。
|
// legacyRechargeBillQuery 是 legacy 账单源支持的筛选子集;用户/币商筛选属于 hyapp 语义,legacy 源不适用。
|
||||||
@ -144,6 +143,8 @@ type legacyPurchaseDocument struct {
|
|||||||
Products []legacyPurchaseProduct `bson:"products"`
|
Products []legacyPurchaseProduct `bson:"products"`
|
||||||
CreateTime time.Time `bson:"createTime"`
|
CreateTime time.Time `bson:"createTime"`
|
||||||
PurchaseDateMS time.Time `bson:"purchaseDateMs"`
|
PurchaseDateMS time.Time `bson:"purchaseDateMs"`
|
||||||
|
// LegacyBillCountry 由聚合阶段(legacyCountryResolveStages)解析:账单国家码优先,回退付款用户资料。
|
||||||
|
LegacyBillCountry string `bson:"legacyBillCountry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type legacyPurchaseFactory struct {
|
type legacyPurchaseFactory struct {
|
||||||
@ -186,10 +187,14 @@ func (s *MongoRechargeBillSource) listBillsByFilter(ctx context.Context, filter
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("count legacy recharge bills for %s: %w", s.config.AppCode, err)
|
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().
|
// 国家解析放在分页之后:每页最多 pageSize 次 $lookup,用于把账单落到区域列展示。
|
||||||
SetSort(bson.D{{Key: "createTime", Value: -1}, {Key: "_id", Value: -1}}).
|
pipeline := append(mongo.Pipeline{
|
||||||
SetSkip(int64(page-1)*int64(pageSize)).
|
bson.D{{Key: "$match", Value: filter}},
|
||||||
SetLimit(int64(pageSize)))
|
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 {
|
if err != nil {
|
||||||
return nil, 0, fmt.Errorf("query legacy recharge bills for %s: %w", s.config.AppCode, err)
|
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) {
|
func (s *MongoRechargeBillSource) decodeBillCursor(ctx context.Context, cursor *mongo.Cursor, pageSize int) ([]rechargeBillDTO, error) {
|
||||||
|
countryToRegion := s.legacyCountryRegionIndex(ctx)
|
||||||
items := make([]rechargeBillDTO, 0, pageSize)
|
items := make([]rechargeBillDTO, 0, pageSize)
|
||||||
for cursor.Next(ctx) {
|
for cursor.Next(ctx) {
|
||||||
var document legacyPurchaseDocument
|
var document legacyPurchaseDocument
|
||||||
if err := cursor.Decode(&document); err != nil {
|
if err := cursor.Decode(&document); err != nil {
|
||||||
return nil, fmt.Errorf("decode legacy recharge bill for %s: %w", s.config.AppCode, err)
|
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 {
|
if err := cursor.Err(); err != nil {
|
||||||
return nil, fmt.Errorf("iterate legacy recharge bills for %s: %w", s.config.AppCode, err)
|
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
|
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) {
|
func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfig, documents []legacyRegionDocument, access repository.MoneyAccess) ([]moneyAppDTO, []moneyRegionDTO, []moneyCountryDTO, error) {
|
||||||
appCode := appctx.Normalize(sourceConfig.AppCode)
|
appCode := appctx.Normalize(sourceConfig.AppCode)
|
||||||
if appCode == "" || !moneyAccessAllowsApp(access, appCode) {
|
if appCode == "" || !moneyAccessAllowsApp(access, appCode) {
|
||||||
|
|||||||
@ -39,10 +39,20 @@ type rechargeBillGooglePaidStatsDTO struct {
|
|||||||
EstNetUsdMinor int64 `json:"estNetUsdMinor"`
|
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 {
|
type rechargeBillOverviewDTO struct {
|
||||||
Daily []rechargeBillDailyBucketDTO `json:"daily"`
|
Daily []rechargeBillDailyBucketDTO `json:"daily"`
|
||||||
Regions []rechargeBillRegionBucketDTO `json:"regions"`
|
Regions []rechargeBillRegionBucketDTO `json:"regions"`
|
||||||
GooglePaid rechargeBillGooglePaidStatsDTO `json:"googlePaid"`
|
GooglePaid rechargeBillGooglePaidStatsDTO `json:"googlePaid"`
|
||||||
|
Withdrawal rechargeBillWithdrawalStatsDTO `json:"withdrawal"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
|
// GetRechargeBillOverview 返回财务概览聚合:按日趋势、区域分布与谷歌实付覆盖度;筛选口径与账单列表一致。
|
||||||
@ -54,18 +64,21 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
|||||||
// 财务系统统一按中国时区切日边界。
|
// 财务系统统一按中国时区切日边界。
|
||||||
tzOffsetMinutes = 480
|
tzOffsetMinutes = 480
|
||||||
}
|
}
|
||||||
|
startAtMS := queryInt64(c, "start_at_ms", "startAtMs")
|
||||||
|
endAtMS := queryInt64(c, "end_at_ms", "endAtMs")
|
||||||
if source, ok := h.billSources[appCode]; ok {
|
if source, ok := h.billSources[appCode]; ok {
|
||||||
overview, err := source.Overview(c.Request.Context(), legacyRechargeBillQuery{
|
overview, err := source.Overview(c.Request.Context(), legacyRechargeBillQuery{
|
||||||
Keyword: options.Keyword,
|
Keyword: options.Keyword,
|
||||||
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
RegionID: queryInt64(c, "region_id", "regionId"),
|
RegionID: queryInt64(c, "region_id", "regionId"),
|
||||||
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
|
StartAtMS: startAtMS,
|
||||||
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
|
EndAtMS: endAtMS,
|
||||||
}, tzOffsetMinutes)
|
}, tzOffsetMinutes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, "获取财务概览失败")
|
response.ServerError(c, "获取财务概览失败")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.fillApprovedWithdrawalStats(&overview, appCode, startAtMS, endAtMS)
|
||||||
response.OK(c, overview)
|
response.OK(c, overview)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -76,8 +89,8 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
|||||||
RegionId: queryInt64(c, "region_id", "regionId"),
|
RegionId: queryInt64(c, "region_id", "regionId"),
|
||||||
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
Status: options.Status,
|
Status: options.Status,
|
||||||
StartAtMs: queryInt64(c, "start_at_ms", "startAtMs"),
|
StartAtMs: startAtMS,
|
||||||
EndAtMs: queryInt64(c, "end_at_ms", "endAtMs"),
|
EndAtMs: endAtMS,
|
||||||
TzOffsetMinutes: tzOffsetMinutes,
|
TzOffsetMinutes: tzOffsetMinutes,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -130,5 +143,21 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
|
|||||||
EstNetUsdMinor: stats.GetEstNetUsdMinor(),
|
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)
|
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())
|
appCode := appctx.FromContext(c.Request.Context())
|
||||||
if source, ok := h.billSources[appCode]; ok {
|
if source, ok := h.billSources[appCode]; ok {
|
||||||
summary, err := source.SummarizeRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
|
summary, err := source.SummarizeRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
|
||||||
Keyword: options.Keyword,
|
Keyword: options.Keyword,
|
||||||
RegionID: queryInt64(c, "region_id", "regionId"),
|
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
|
||||||
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
|
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
|
||||||
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
|
RegionID: queryInt64(c, "region_id", "regionId"),
|
||||||
|
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
|
||||||
|
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, "获取充值汇总失败")
|
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.OperationLog{},
|
||||||
&model.DataScope{},
|
&model.DataScope{},
|
||||||
&model.UserMoneyScope{},
|
&model.UserMoneyScope{},
|
||||||
|
&model.DatabiKpiTarget{},
|
||||||
&model.TemporaryPaymentLinkOwner{},
|
&model.TemporaryPaymentLinkOwner{},
|
||||||
&model.LegacyGooglePaidDetail{},
|
&model.LegacyGooglePaidDetail{},
|
||||||
&model.FinanceApplication{},
|
&model.FinanceApplication{},
|
||||||
|
|||||||
@ -92,6 +92,32 @@ func (s *Store) AuditWithdrawalApplication(id uint, input WithdrawalApplicationA
|
|||||||
return s.GetWithdrawalApplication(id)
|
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 {
|
func applyWithdrawalApplicationFilters(query *gorm.DB, options WithdrawalApplicationListOptions) *gorm.DB {
|
||||||
if appCode := strings.TrimSpace(options.AppCode); appCode != "" {
|
if appCode := strings.TrimSpace(options.AppCode); appCode != "" {
|
||||||
query = query.Where("app_code = ?", appCode)
|
query = query.Where("app_code = ?", appCode)
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import (
|
|||||||
"hyapp-admin-server/internal/modules/cumulativerechargereward"
|
"hyapp-admin-server/internal/modules/cumulativerechargereward"
|
||||||
"hyapp-admin-server/internal/modules/dailytask"
|
"hyapp-admin-server/internal/modules/dailytask"
|
||||||
"hyapp-admin-server/internal/modules/dashboard"
|
"hyapp-admin-server/internal/modules/dashboard"
|
||||||
|
"hyapp-admin-server/internal/modules/databi"
|
||||||
"hyapp-admin-server/internal/modules/financeapplication"
|
"hyapp-admin-server/internal/modules/financeapplication"
|
||||||
"hyapp-admin-server/internal/modules/financewithdrawal"
|
"hyapp-admin-server/internal/modules/financewithdrawal"
|
||||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||||
@ -78,6 +79,7 @@ type Handlers struct {
|
|||||||
CumulativeRecharge *cumulativerechargereward.Handler
|
CumulativeRecharge *cumulativerechargereward.Handler
|
||||||
DailyTask *dailytask.Handler
|
DailyTask *dailytask.Handler
|
||||||
Dashboard *dashboard.Handler
|
Dashboard *dashboard.Handler
|
||||||
|
Databi *databi.Handler
|
||||||
FirstRechargeReward *firstrechargereward.Handler
|
FirstRechargeReward *firstrechargereward.Handler
|
||||||
FinanceApplication *financeapplication.Handler
|
FinanceApplication *financeapplication.Handler
|
||||||
FinanceWithdrawal *financewithdrawal.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)
|
roomrocket.RegisterRoutes(protected, h.RoomRocket)
|
||||||
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward)
|
roomturnoverreward.RegisterRoutes(protected, h.RoomTurnoverReward)
|
||||||
dashboard.RegisterRoutes(protected, h.Dashboard)
|
dashboard.RegisterRoutes(protected, h.Dashboard)
|
||||||
|
databi.RegisterRoutes(protected, h.Databi)
|
||||||
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
|
hostagencypolicy.RegisterRoutes(protected, h.HostAgencyPolicy)
|
||||||
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
|
hostsalarysettlement.RegisterRoutes(protected, h.HostSalarySettlement)
|
||||||
hostorg.RegisterRoutes(protected, h.HostOrg)
|
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
|
EstNetUSDMinor int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// RechargeBillOverview 汇集财务概览页所需的趋势、区域分布与谷歌扣费口径。
|
// RechargeBillSalaryTransferStats 是用户提现口径中的钱包账本部分:用户把工资 USDT 转给币商。
|
||||||
|
type RechargeBillSalaryTransferStats struct {
|
||||||
|
TransferCount int64
|
||||||
|
TransferUSDMinor int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// RechargeBillOverview 汇集财务概览页所需的趋势、区域分布、谷歌扣费与提现口径。
|
||||||
type RechargeBillOverview struct {
|
type RechargeBillOverview struct {
|
||||||
Daily []RechargeBillDailyBucket
|
Daily []RechargeBillDailyBucket
|
||||||
Regions []RechargeBillRegionBucket
|
Regions []RechargeBillRegionBucket
|
||||||
GooglePaid RechargeBillGooglePaidStats
|
GooglePaid RechargeBillGooglePaidStats
|
||||||
|
SalaryTransfer RechargeBillSalaryTransferStats
|
||||||
}
|
}
|
||||||
|
|
||||||
// WalletFeatureFlags 是 App 钱包入口的开关投影;首版由 wallet-service 固定返回,后续可迁移到配置表。
|
// WalletFeatureFlags 是 App 钱包入口的开关投影;首版由 wallet-service 固定返回,后续可迁移到配置表。
|
||||||
|
|||||||
@ -50,6 +50,12 @@ func (r *Repository) GetRechargeBillOverview(ctx context.Context, query ledger.L
|
|||||||
}
|
}
|
||||||
overview.GooglePaid = stats
|
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))
|
dayIndexes := make([]int64, 0, len(dailyBuckets))
|
||||||
for dayIndex := range dailyBuckets {
|
for dayIndex := range dailyBuckets {
|
||||||
@ -202,6 +208,40 @@ func scanOverviewRegionRows(rows overviewRegionRows, buckets map[int64]*ledger.R
|
|||||||
return rows.Err()
|
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 口径。
|
// overviewGooglePaidStats 统计谷歌实付同步覆盖度,并把已同步账单的税/费/净收按实付占比折算到 USD 口径。
|
||||||
// 估算只覆盖“已同步且净收入有效”的账单;负差额按 0 处理,避免退款单把手续费冲成负数。
|
// 估算只覆盖“已同步且净收入有效”的账单;负差额按 0 处理,避免退款单把手续费冲成负数。
|
||||||
func (r *Repository) overviewGooglePaidStats(ctx context.Context, query ledger.ListRechargeBillsQuery) (ledger.RechargeBillGooglePaidStats, error) {
|
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,
|
EstTaxUsdMinor: overview.GooglePaid.EstTaxUSDMinor,
|
||||||
EstNetUsdMinor: overview.GooglePaid.EstNetUSDMinor,
|
EstNetUsdMinor: overview.GooglePaid.EstNetUSDMinor,
|
||||||
},
|
},
|
||||||
|
SalaryTransfer: &walletv1.RechargeBillSalaryTransferStats{
|
||||||
|
TransferCount: overview.SalaryTransfer.TransferCount,
|
||||||
|
TransferUsdMinor: overview.SalaryTransfer.TransferUSDMinor,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, bucket := range overview.Daily {
|
for _, bucket := range overview.Daily {
|
||||||
resp.Daily = append(resp.Daily, &walletv1.RechargeBillDailyBucket{
|
resp.Daily = append(resp.Daily, &walletv1.RechargeBillDailyBucket{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user