diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index 4b9677bd..4c9f7e87 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -422,6 +422,21 @@ func (DatabiKpiTarget) TableName() string { return "admin_databi_kpi_targets" } +// DatabiAppKpiTarget 是 App 级充值 KPI 月度目标:不分运营的全员共同目标。 +type DatabiAppKpiTarget struct { + ID uint `gorm:"primaryKey" json:"id"` + AppCode string `gorm:"size:32;index:uk_admin_databi_app_kpi_target,unique;not null" json:"appCode"` + PeriodMonth string `gorm:"size:7;column:period_month;index:uk_admin_databi_app_kpi_target,unique;not null" json:"periodMonth"` + TargetUSDMinor int64 `gorm:"column:target_usd_minor;not null;default:0" json:"targetUsdMinor"` + DailyTargetUSDMinor int64 `gorm:"column:daily_target_usd_minor;not null;default:0" json:"dailyTargetUsdMinor"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` +} + +func (DatabiAppKpiTarget) TableName() string { + return "admin_databi_app_kpi_targets" +} + type TemporaryPaymentLinkOwner struct { AppCode string `gorm:"size:32;primaryKey" json:"appCode"` OrderID string `gorm:"size:96;primaryKey" json:"orderId"` diff --git a/server/admin/internal/modules/databi/handler.go b/server/admin/internal/modules/databi/handler.go index 893f6f51..449c2010 100644 --- a/server/admin/internal/modules/databi/handler.go +++ b/server/admin/internal/modules/databi/handler.go @@ -110,7 +110,12 @@ func (h *Handler) ListKpiTargets(c *gin.Context) { response.ServerError(c, "获取 KPI 目标失败") return } - response.OK(c, gin.H{"items": targets, "total": len(targets)}) + appTargets, err := h.store.ListDatabiAppKpiTargets(months) + if err != nil { + response.ServerError(c, "获取 App KPI 目标失败") + return + } + response.OK(c, gin.H{"appItems": appTargets, "items": targets, "total": len(targets)}) } type kpiTargetItemRequest struct { @@ -122,8 +127,17 @@ type kpiTargetItemRequest struct { DailyTargetUSDMinor int64 `json:"dailyTargetUsdMinor"` } +type kpiAppTargetItemRequest struct { + AppCode string `json:"appCode"` + PeriodMonth string `json:"periodMonth"` + TargetUSDMinor int64 `json:"targetUsdMinor"` + DailyTargetUSDMinor int64 `json:"dailyTargetUsdMinor"` +} + type kpiTargetReplaceRequest struct { Items []kpiTargetItemRequest `json:"items"` + // AppItems 是 App 级总目标(不分运营的全员共同目标)。 + AppItems []kpiAppTargetItemRequest `json:"appItems"` } func (h *Handler) ReplaceKpiTargets(c *gin.Context) { @@ -132,7 +146,7 @@ func (h *Handler) ReplaceKpiTargets(c *gin.Context) { response.BadRequest(c, "KPI 目标参数不正确") return } - if len(request.Items) == 0 { + if len(request.Items) == 0 && len(request.AppItems) == 0 { response.BadRequest(c, "KPI 目标不能为空") return } @@ -147,17 +161,38 @@ func (h *Handler) ReplaceKpiTargets(c *gin.Context) { DailyTargetUSDMinor: item.DailyTargetUSDMinor, }) } - if err := h.store.UpsertDatabiKpiTargets(targets); err != nil { - response.BadRequest(c, "保存 KPI 目标失败: "+err.Error()) - return + appTargets := make([]model.DatabiAppKpiTarget, 0, len(request.AppItems)) + for _, item := range request.AppItems { + appTargets = append(appTargets, model.DatabiAppKpiTarget{ + AppCode: item.AppCode, + PeriodMonth: item.PeriodMonth, + TargetUSDMinor: item.TargetUSDMinor, + DailyTargetUSDMinor: item.DailyTargetUSDMinor, + }) } - shared.OperationLogWithResourceID(c, h.audit, "replace-databi-kpi-targets", "admin_databi_kpi_targets", "", "success", fmt.Sprintf("items=%d", len(request.Items))) + if len(targets) > 0 { + if err := h.store.UpsertDatabiKpiTargets(targets); err != nil { + response.BadRequest(c, "保存 KPI 目标失败: "+err.Error()) + return + } + } + if len(appTargets) > 0 { + if err := h.store.UpsertDatabiAppKpiTargets(appTargets); err != nil { + response.BadRequest(c, "保存 App KPI 目标失败: "+err.Error()) + return + } + } + shared.OperationLogWithResourceID(c, h.audit, "replace-databi-kpi-targets", "admin_databi_kpi_targets", "", "success", + fmt.Sprintf("items=%d appItems=%d", len(request.Items), len(request.AppItems))) months := map[string]struct{}{} userIDs := map[uint]struct{}{} for _, item := range request.Items { months[strings.TrimSpace(item.PeriodMonth)] = struct{}{} userIDs[item.UserID] = struct{}{} } + for _, item := range request.AppItems { + months[strings.TrimSpace(item.PeriodMonth)] = struct{}{} + } monthList := make([]string, 0, len(months)) for month := range months { monthList = append(monthList, month) @@ -171,7 +206,12 @@ func (h *Handler) ReplaceKpiTargets(c *gin.Context) { response.ServerError(c, "获取 KPI 目标失败") return } - response.OK(c, gin.H{"items": targetsAfter, "total": len(targetsAfter)}) + appTargetsAfter, err := h.store.ListDatabiAppKpiTargets(monthList) + if err != nil { + response.ServerError(c, "获取 App KPI 目标失败") + return + } + response.OK(c, gin.H{"appItems": appTargetsAfter, "items": targetsAfter, "total": len(targetsAfter)}) } // canViewAllKpi:显式授权 databi-kpi:view-all 或拥有全量数据范围的管理员可以看全员绩效。 diff --git a/server/admin/internal/modules/databi/service.go b/server/admin/internal/modules/databi/service.go index aeb191fb..f3f612fb 100644 --- a/server/admin/internal/modules/databi/service.go +++ b/server/admin/internal/modules/databi/service.go @@ -403,6 +403,41 @@ type KpiRow struct { DataError string `json:"data_error,omitempty"` } +// KpiOperatorAppRow 是 人×App 级小计:该运营在此 App 下全部负责区域的合计, +// 目标取自 admin_databi_kpi_targets 的 region_id=0 行(该运营的整 App 目标)。 +type KpiOperatorAppRow struct { + OperatorUserID uint `json:"operator_user_id"` + OperatorName string `json:"operator_name"` + OperatorAccount string `json:"operator_account"` + Team string `json:"team"` + TeamID *uint `json:"team_id"` + AppCode string `json:"app_code"` + AppName string `json:"app_name"` + RegionCount int `json:"region_count"` + RegionNames string `json:"region_names"` + WholeAppScope bool `json:"whole_app_scope"` + RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"` + RangeNewUsers *int64 `json:"range_new_users"` + MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"` + MonthTargetUSDMinor int64 `json:"month_target_usd_minor"` + DailyTargetUSDMinor int64 `json:"daily_target_usd_minor"` + MonthAttainmentRate *float64 `json:"month_attainment_rate"` + DataError string `json:"data_error,omitempty"` +} + +// KpiAppRow 是 App 级总目标行:实际值为整 App 充值(不分运营的全员共同目标)。 +type KpiAppRow struct { + AppCode string `json:"app_code"` + AppName string `json:"app_name"` + OperatorCount int `json:"operator_count"` + RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"` + MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"` + MonthTargetUSDMinor int64 `json:"month_target_usd_minor"` + DailyTargetUSDMinor int64 `json:"daily_target_usd_minor"` + MonthAttainmentRate *float64 `json:"month_attainment_rate"` + DataError string `json:"data_error,omitempty"` +} + type KpiSummary struct { RangeRechargeUSDMinor int64 `json:"range_recharge_usd_minor"` MonthRechargeUSDMinor int64 `json:"month_recharge_usd_minor"` @@ -412,12 +447,14 @@ type KpiSummary struct { } type KpiResult struct { - PeriodMonth string `json:"period_month"` - MonthStartMS int64 `json:"month_start_ms"` - MonthEndMS int64 `json:"month_end_ms"` - Items []KpiRow `json:"items"` - Summary KpiSummary `json:"summary"` - AppErrors map[string]any `json:"app_errors,omitempty"` + PeriodMonth string `json:"period_month"` + MonthStartMS int64 `json:"month_start_ms"` + MonthEndMS int64 `json:"month_end_ms"` + Items []KpiRow `json:"items"` + OperatorAppRows []KpiOperatorAppRow `json:"operator_app_rows"` + AppRows []KpiAppRow `json:"app_rows"` + Summary KpiSummary `json:"summary"` + AppErrors map[string]any `json:"app_errors,omitempty"` } // Kpi 按运营人员负责范围(App×区域)聚合充值/新增,并对齐月度 KPI 目标计算达成率。 @@ -487,13 +524,6 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query return nil, err } - type appKpiData struct { - rangeTotal map[string]any - rangeRegion map[int64]map[string]any - monthTotal map[string]any - monthRegion map[int64]map[string]any - err error - } dataByApp := make(map[string]*appKpiData, len(relevantApps)) var mu sync.Mutex var wg sync.WaitGroup @@ -628,34 +658,248 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query return left.RegionID < right.RegionID }) + appTargets, err := s.store.ListDatabiAppKpiTargets([]string{periodMonth}) + if err != nil { + return nil, err + } + appTargetByCode := map[string]model.DatabiAppKpiTarget{} + for _, target := range appTargets { + appTargetByCode[appctx.Normalize(target.AppCode)] = target + } + + operatorAppRows := buildOperatorAppRows(rows, targetByKey) + appRows := buildAppRows(relevantApps, dataByApp, appTargetByCode, rows) + summary := buildKpiSummary(rows, operatorAppRows, appRows) + + result := &KpiResult{ + PeriodMonth: periodMonth, + MonthStartMS: monthStart.UnixMilli(), + MonthEndMS: monthEnd.UnixMilli(), + Items: rows, + OperatorAppRows: operatorAppRows, + AppRows: appRows, + Summary: summary, + } + if len(appErrors) > 0 { + result.AppErrors = appErrors + } + return result, nil +} + +type appKpiData struct { + rangeTotal map[string]any + rangeRegion map[int64]map[string]any + monthTotal map[string]any + monthRegion map[int64]map[string]any + err error +} + +func nullableAdd(sum *int64, value *int64) *int64 { + if value == nil { + return sum + } + if sum == nil { + out := *value + return &out + } + out := *sum + *value + return &out +} + +// buildOperatorAppRows 把 人×App×区域 明细行归并成 人×App 小计: +// 该运营有整 App 范围(region 0)时直接取那一行(其值即整 App 口径),否则各区域可空求和。 +func buildOperatorAppRows(rows []KpiRow, targetByKey map[string]model.DatabiKpiTarget) []KpiOperatorAppRow { + type group struct { + row KpiOperatorAppRow + regionNames []string + wholeRow *KpiRow + } + groups := map[string]*group{} + order := []string{} + for index := range rows { + row := rows[index] + key := strconv.FormatUint(uint64(row.OperatorUserID), 10) + "|" + row.AppCode + item := groups[key] + if item == nil { + item = &group{row: KpiOperatorAppRow{ + AppCode: row.AppCode, + AppName: row.AppName, + OperatorAccount: row.OperatorAccount, + OperatorName: row.OperatorName, + OperatorUserID: row.OperatorUserID, + Team: row.Team, + TeamID: row.TeamID, + }} + groups[key] = item + order = append(order, key) + } + item.row.RegionCount++ + item.regionNames = append(item.regionNames, row.RegionName) + if row.DataError != "" && item.row.DataError == "" { + item.row.DataError = row.DataError + } + if row.RegionID == 0 { + item.row.WholeAppScope = true + item.wholeRow = &rows[index] + continue + } + item.row.RangeRechargeUSDMinor = nullableAdd(item.row.RangeRechargeUSDMinor, row.RangeRechargeUSDMinor) + item.row.RangeNewUsers = nullableAdd(item.row.RangeNewUsers, row.RangeNewUsers) + item.row.MonthRechargeUSDMinor = nullableAdd(item.row.MonthRechargeUSDMinor, row.MonthRechargeUSDMinor) + } + + out := make([]KpiOperatorAppRow, 0, len(order)) + for _, key := range order { + item := groups[key] + if item.wholeRow != nil { + // 整 App 范围行的取数已是整 App 口径,区域行是它的子集,避免相加重复计数。 + item.row.RangeRechargeUSDMinor = item.wholeRow.RangeRechargeUSDMinor + item.row.RangeNewUsers = item.wholeRow.RangeNewUsers + item.row.MonthRechargeUSDMinor = item.wholeRow.MonthRechargeUSDMinor + } + item.row.RegionNames = joinRegionNames(item.regionNames) + if target, ok := targetByKey[kpiTargetKey(item.row.OperatorUserID, item.row.AppCode, 0)]; ok { + item.row.MonthTargetUSDMinor = target.TargetUSDMinor + item.row.DailyTargetUSDMinor = target.DailyTargetUSDMinor + } else if item.wholeRow != nil { + // 整 App 范围的运营,其明细行(region 0)目标与 人×App 目标同键,直接沿用。 + item.row.MonthTargetUSDMinor = item.wholeRow.MonthTargetUSDMinor + item.row.DailyTargetUSDMinor = item.wholeRow.DailyTargetUSDMinor + } + if item.row.MonthTargetUSDMinor > 0 && item.row.MonthRechargeUSDMinor != nil { + rate := safeRatio(*item.row.MonthRechargeUSDMinor, item.row.MonthTargetUSDMinor) + item.row.MonthAttainmentRate = &rate + } + out = append(out, item.row) + } + sort.Slice(out, func(i, j int) bool { + left := int64(0) + if out[i].MonthRechargeUSDMinor != nil { + left = *out[i].MonthRechargeUSDMinor + } + right := int64(0) + if out[j].MonthRechargeUSDMinor != nil { + right = *out[j].MonthRechargeUSDMinor + } + if left != right { + return left > right + } + if out[i].OperatorUserID != out[j].OperatorUserID { + return out[i].OperatorUserID < out[j].OperatorUserID + } + return out[i].AppCode < out[j].AppCode + }) + return out +} + +func joinRegionNames(names []string) string { + if len(names) <= 3 { + return strings.Join(names, " / ") + } + return strings.Join(names[:3], " / ") + fmt.Sprintf(" 等%d个", len(names)) +} + +// buildAppRows 生成 App 级总目标行:实际值 = 整 App 充值(与运营/区域无关的共同目标口径)。 +func buildAppRows(apps []AppInfo, dataByApp map[string]*appKpiData, appTargetByCode map[string]model.DatabiAppKpiTarget, rows []KpiRow) []KpiAppRow { + operatorsByApp := map[string]map[uint]struct{}{} + for _, row := range rows { + set := operatorsByApp[row.AppCode] + if set == nil { + set = map[uint]struct{}{} + operatorsByApp[row.AppCode] = set + } + set[row.OperatorUserID] = struct{}{} + } + out := make([]KpiAppRow, 0, len(apps)) + for _, app := range apps { + row := KpiAppRow{ + AppCode: app.AppCode, + AppName: app.AppName, + OperatorCount: len(operatorsByApp[app.AppCode]), + } + if target, ok := appTargetByCode[app.AppCode]; ok { + row.MonthTargetUSDMinor = target.TargetUSDMinor + row.DailyTargetUSDMinor = target.DailyTargetUSDMinor + } + data := dataByApp[app.AppCode] + if data == nil { + row.DataError = "该 App 未接入统计数据" + } else if data.err != nil { + row.DataError = fmt.Sprintf("获取统计数据失败: %v", data.err) + } else { + row.RangeRechargeUSDMinor = metricPointer(data.rangeTotal, "recharge_usd_minor") + row.MonthRechargeUSDMinor = metricPointer(data.monthTotal, "recharge_usd_minor") + } + if row.MonthTargetUSDMinor > 0 && row.MonthRechargeUSDMinor != nil { + rate := safeRatio(*row.MonthRechargeUSDMinor, row.MonthTargetUSDMinor) + row.MonthAttainmentRate = &rate + } + out = append(out, row) + } + sort.Slice(out, func(i, j int) bool { + left := int64(0) + if out[i].MonthRechargeUSDMinor != nil { + left = *out[i].MonthRechargeUSDMinor + } + right := int64(0) + if out[j].MonthRechargeUSDMinor != nil { + right = *out[j].MonthRechargeUSDMinor + } + if left != right { + return left > right + } + return out[i].AppCode < out[j].AppCode + }) + return out +} + +// buildKpiSummary 的口径:实际充值按 App 行求和(跨运营/区域去重); +// 月目标按 App 逐个取"最权威"的一层——App 总目标 > 人×App 目标合计 > 人×区域明细目标合计。 +func buildKpiSummary(rows []KpiRow, operatorAppRows []KpiOperatorAppRow, appRows []KpiAppRow) KpiSummary { summary := KpiSummary{} operatorSeen := map[uint]struct{}{} + regionTargetByApp := map[string]int64{} for _, row := range rows { + operatorSeen[row.OperatorUserID] = struct{}{} + if row.RegionID != 0 { + regionTargetByApp[row.AppCode] += row.MonthTargetUSDMinor + } + } + summary.OperatorCount = len(operatorSeen) + + operatorAppTargetByApp := map[string]int64{} + for _, row := range operatorAppRows { + operatorAppTargetByApp[row.AppCode] += row.MonthTargetUSDMinor + } + for _, row := range appRows { if row.RangeRechargeUSDMinor != nil { summary.RangeRechargeUSDMinor += *row.RangeRechargeUSDMinor } if row.MonthRechargeUSDMinor != nil { summary.MonthRechargeUSDMinor += *row.MonthRechargeUSDMinor } - summary.MonthTargetUSDMinor += row.MonthTargetUSDMinor - operatorSeen[row.OperatorUserID] = struct{}{} + summary.MonthTargetUSDMinor += pickEffectiveAppTarget( + row.MonthTargetUSDMinor, + operatorAppTargetByApp[row.AppCode], + regionTargetByApp[row.AppCode], + ) } - summary.OperatorCount = len(operatorSeen) if summary.MonthTargetUSDMinor > 0 { rate := safeRatio(summary.MonthRechargeUSDMinor, summary.MonthTargetUSDMinor) summary.MonthAttainmentRate = &rate } - result := &KpiResult{ - PeriodMonth: periodMonth, - MonthStartMS: monthStart.UnixMilli(), - MonthEndMS: monthEnd.UnixMilli(), - Items: rows, - Summary: summary, + return summary +} + +// pickEffectiveAppTarget:一个 App 的"生效月目标"取最高层已配置的那级,避免多级目标重复相加。 +func pickEffectiveAppTarget(appTarget int64, operatorAppTargets int64, regionTargets int64) int64 { + if appTarget > 0 { + return appTarget } - if len(appErrors) > 0 { - result.AppErrors = appErrors + if operatorAppTargets > 0 { + return operatorAppTargets } - return result, nil + return regionTargets } func (s *Service) kpiWindow(ctx context.Context, app AppInfo, resolve func(map[string]any) (RegionInfo, bool), statTZ string, startMS int64, endMS int64) (map[string]any, map[int64]map[string]any, error) { diff --git a/server/admin/internal/repository/databi_repository.go b/server/admin/internal/repository/databi_repository.go index 0ead73e5..2697ef98 100644 --- a/server/admin/internal/repository/databi_repository.go +++ b/server/admin/internal/repository/databi_repository.go @@ -83,6 +83,67 @@ func (s *Store) UpsertDatabiKpiTargets(targets []model.DatabiKpiTarget) error { }) } +func (s *Store) ListDatabiAppKpiTargets(periodMonths []string) ([]model.DatabiAppKpiTarget, error) { + query := s.db.Model(&model.DatabiAppKpiTarget{}) + months := normalizeDatabiPeriodMonths(periodMonths) + if len(months) > 0 { + query = query.Where("period_month IN ?", months) + } + var targets []model.DatabiAppKpiTarget + err := query.Order("app_code ASC, period_month ASC").Find(&targets).Error + return targets, err +} + +// UpsertDatabiAppKpiTargets 逐条 upsert App 级目标;月目标和日目标同时为 0 视为清除。 +func (s *Store) UpsertDatabiAppKpiTargets(targets []model.DatabiAppKpiTarget) error { + seen := map[string]struct{}{} + upserts := make([]model.DatabiAppKpiTarget, 0, len(targets)) + removals := make([]model.DatabiAppKpiTarget, 0) + for _, target := range targets { + target.AppCode = appctx.Normalize(target.AppCode) + target.PeriodMonth = strings.TrimSpace(target.PeriodMonth) + if target.AppCode == "" { + return errors.New("app kpi target app code is required") + } + if !databiPeriodMonthPattern.MatchString(target.PeriodMonth) { + return errors.New("app kpi target period month must be formatted as YYYY-MM") + } + if target.TargetUSDMinor < 0 || target.DailyTargetUSDMinor < 0 { + return errors.New("app kpi target amounts must not be negative") + } + key := target.AppCode + ":" + target.PeriodMonth + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if target.TargetUSDMinor == 0 && target.DailyTargetUSDMinor == 0 { + removals = append(removals, target) + continue + } + target.ID = 0 + upserts = append(upserts, target) + } + return s.db.Transaction(func(tx *gorm.DB) error { + for _, target := range removals { + if err := tx.Where("app_code = ? AND period_month = ?", target.AppCode, target.PeriodMonth). + Delete(&model.DatabiAppKpiTarget{}).Error; err != nil { + return err + } + } + if len(upserts) == 0 { + return nil + } + return tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "app_code"}, {Name: "period_month"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "target_usd_minor", + "daily_target_usd_minor", + "updated_at_ms", + }), + }).Create(&upserts).Error + }) +} + func normalizeDatabiKpiTargets(targets []model.DatabiKpiTarget) ([]model.DatabiKpiTarget, []model.DatabiKpiTarget, error) { seen := map[string]struct{}{} upserts := make([]model.DatabiKpiTarget, 0, len(targets)) diff --git a/server/admin/internal/repository/repository.go b/server/admin/internal/repository/repository.go index da01cc59..36667170 100644 --- a/server/admin/internal/repository/repository.go +++ b/server/admin/internal/repository/repository.go @@ -81,6 +81,7 @@ func (s *Store) AutoMigrate() error { &model.DataScope{}, &model.UserMoneyScope{}, &model.DatabiKpiTarget{}, + &model.DatabiAppKpiTarget{}, &model.TemporaryPaymentLinkOwner{}, &model.LegacyGooglePaidDetail{}, &model.FinanceApplication{}, diff --git a/server/admin/migrations/076_databi_app_kpi_targets.sql b/server/admin/migrations/076_databi_app_kpi_targets.sql new file mode 100644 index 00000000..4f1b93f7 --- /dev/null +++ b/server/admin/migrations/076_databi_app_kpi_targets.sql @@ -0,0 +1,13 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- App 级充值 KPI 月度目标:不分运营的全员共同目标;人级目标仍在 admin_databi_kpi_targets(region_id=0 表示该运营的整 App 目标)。 +CREATE TABLE IF NOT EXISTS admin_databi_app_kpi_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + 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 分', + 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_app_kpi_target (app_code, period_month) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='社交 BI App 级充值 KPI 月度目标(不分运营)';