去掉kpi相关

This commit is contained in:
zhx 2026-07-06 11:46:58 +08:00
parent ff9321a1e2
commit ccebdcb28a
11 changed files with 430 additions and 573 deletions

View File

@ -404,39 +404,6 @@ func (UserMoneyScope) TableName() string {
return "admin_user_money_scopes"
}
// DatabiKpiTarget 是社交 BI 里运营人员按 App×区域×月份 的充值 KPI 目标;
// 实际达成从统计链路实时聚合,目标只在 admin 库维护,不回写任何业务库。
type DatabiKpiTarget struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index:uk_admin_databi_kpi_target,unique;not null" json:"userId"`
AppCode string `gorm:"size:32;index:uk_admin_databi_kpi_target,unique;not null" json:"appCode"`
RegionID int64 `gorm:"index:uk_admin_databi_kpi_target,unique;not null;default:0" json:"regionId"`
PeriodMonth string `gorm:"size:7;column:period_month;index:uk_admin_databi_kpi_target,unique;index:idx_admin_databi_kpi_targets_period;not null" json:"periodMonth"`
TargetUSDMinor int64 `gorm:"column:target_usd_minor;not null;default:0" json:"targetUsdMinor"`
DailyTargetUSDMinor int64 `gorm:"column:daily_target_usd_minor;not null;default:0" json:"dailyTargetUsdMinor"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (DatabiKpiTarget) TableName() string {
return "admin_databi_kpi_targets"
}
// DatabiAppKpiTarget 是 App 级充值 KPI 月度目标:不分运营的全员共同目标。
type DatabiAppKpiTarget struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:uk_admin_databi_app_kpi_target,unique;not null" json:"appCode"`
PeriodMonth string `gorm:"size:7;column:period_month;index:uk_admin_databi_app_kpi_target,unique;not null" json:"periodMonth"`
TargetUSDMinor int64 `gorm:"column:target_usd_minor;not null;default:0" json:"targetUsdMinor"`
DailyTargetUSDMinor int64 `gorm:"column:daily_target_usd_minor;not null;default:0" json:"dailyTargetUsdMinor"`
CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli" json:"createdAtMs"`
UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"`
}
func (DatabiAppKpiTarget) TableName() string {
return "admin_databi_app_kpi_targets"
}
type TemporaryPaymentLinkOwner struct {
AppCode string `gorm:"size:32;primaryKey" json:"appCode"`
OrderID string `gorm:"size:96;primaryKey" json:"orderId"`

View File

@ -0,0 +1,182 @@
package dashboard
import (
"context"
"fmt"
"strings"
"time"
)
// CoinSellerRechargeBillQuery 是 finance legacy 列表需要的币商进货明细筛选。
// 它只服务只读展示;金额口径必须与 StatisticsOverview 的 coin_seller_recharge_usd_minor 保持一致。
type CoinSellerRechargeBillQuery struct {
Keyword string
RegionID int64
StartMS int64
EndMS int64
Page int
PageSize int
}
type CoinSellerRechargeBill struct {
TransactionID string
Source string
UserID int64
CountryCode string
USDMinorAmount int64
CreatedAtMS int64
}
type CoinSellerRechargeBillSource interface {
ListCoinSellerRechargeBills(ctx context.Context, query CoinSellerRechargeBillQuery) ([]CoinSellerRechargeBill, int64, error)
}
func (s *AslanMongoDashboardSource) ListCoinSellerRechargeBills(ctx context.Context, query CoinSellerRechargeBillQuery) ([]CoinSellerRechargeBill, int64, error) {
if s == nil || s.legacyDB == nil {
return nil, 0, fmt.Errorf("aslan legacy mysql is not configured")
}
countryFilter, err := s.countryFilter(ctx, StatisticsQuery{RegionID: query.RegionID})
if err != nil {
return nil, 0, err
}
if countryFilter.Applied && len(countryFilter.Codes) == 0 {
return []CoinSellerRechargeBill{}, 0, nil
}
page := query.Page
if page < 1 {
page = 1
}
pageSize := query.PageSize
if pageSize < 1 {
pageSize = 10
}
unionSQL, args := s.coinSellerRechargeBillUnionSQL(query, countryFilter)
queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout)
defer cancel()
var total int64
if err := s.legacyDB.QueryRowContext(queryCtx, `SELECT COUNT(*) FROM (`+unionSQL+`) bills`, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count aslan coin seller recharge bills: %w", err)
}
if total == 0 {
return []CoinSellerRechargeBill{}, 0, nil
}
rows, err := s.legacyDB.QueryContext(queryCtx, `
SELECT transaction_id, source, user_id, country_code, amount, created_at
FROM (`+unionSQL+`) bills
ORDER BY created_at DESC, transaction_id DESC
LIMIT ? OFFSET ?`, append(args, pageSize, (page-1)*pageSize)...)
if err != nil {
return nil, 0, fmt.Errorf("query aslan coin seller recharge bills: %w", err)
}
defer rows.Close()
items := make([]CoinSellerRechargeBill, 0, pageSize)
for rows.Next() {
var item CoinSellerRechargeBill
var amountText string
var createdAt time.Time
if err := rows.Scan(&item.TransactionID, &item.Source, &item.UserID, &item.CountryCode, &amountText, &createdAt); err != nil {
return nil, 0, fmt.Errorf("scan aslan coin seller recharge bill: %w", err)
}
amount, err := decimalTextToMinor(amountText)
if err != nil {
return nil, 0, fmt.Errorf("parse aslan coin seller recharge bill %s amount: %w", item.TransactionID, err)
}
item.CountryCode = aslanMongoCountryCode(item.CountryCode)
item.USDMinorAmount = amount
item.CreatedAtMS = createdAt.UnixMilli()
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, fmt.Errorf("iterate aslan coin seller recharge bills: %w", err)
}
return items, total, nil
}
func (s *AslanMongoDashboardSource) coinSellerRechargeBillUnionSQL(query CoinSellerRechargeBillQuery, countryFilter externalDashboardCountryFilter) (string, []any) {
goldWhere := []string{
"ugcr.sys_origin = ?",
"ugcr.gold_coin_source = 2",
"ugcr.recharge_amount > 0",
"ubi.country_code IS NOT NULL",
"ubi.country_code != ''",
}
goldArgs := []any{s.sysOrigin}
freightWhere := []string{
"fbrw.sys_origin = ?",
"fbrw.origin = 'PURCHASE'",
"fbrw.type = 0",
"(COALESCE(fbrw.amount, 0) <> 0 OR COALESCE(fbrw.usd_quantity, 0) <> 0)",
"ubi.country_code IS NOT NULL",
"ubi.country_code != ''",
}
freightArgs := []any{s.sysOrigin}
if query.StartMS > 0 {
start := time.UnixMilli(query.StartMS)
goldWhere = append(goldWhere, "ugcr.create_time >= ?")
goldArgs = append(goldArgs, start)
freightWhere = append(freightWhere, "fbrw.create_time >= ?")
freightArgs = append(freightArgs, start)
}
if query.EndMS > 0 {
end := time.UnixMilli(query.EndMS)
goldWhere = append(goldWhere, "ugcr.create_time < ?")
goldArgs = append(goldArgs, end)
freightWhere = append(freightWhere, "fbrw.create_time < ?")
freightArgs = append(freightArgs, end)
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
goldWhere = append(goldWhere, "(CAST(ugcr.id AS CHAR) = ? OR CONCAT('aslan-gold-coin-', ugcr.id) = ? OR CAST(ugcr.user_id AS CHAR) = ?)")
goldArgs = append(goldArgs, keyword, keyword, keyword)
freightWhere = append(freightWhere, "(CAST(fbrw.id AS CHAR) = ? OR CONCAT('aslan-freight-purchase-', fbrw.id) = ? OR CAST(fbrw.user_id AS CHAR) = ?)")
freightArgs = append(freightArgs, keyword, keyword, keyword)
}
if countryFilter.Applied {
placeholders := make([]string, 0, len(countryFilter.Codes))
for _, code := range countryFilter.Codes {
normalized := aslanMongoCountryCode(code)
if normalized == "" {
continue
}
placeholders = append(placeholders, "?")
goldArgs = append(goldArgs, normalized)
freightArgs = append(freightArgs, normalized)
}
if len(placeholders) == 0 {
placeholders = append(placeholders, "?")
goldArgs = append(goldArgs, "__NO_COUNTRY__")
freightArgs = append(freightArgs, "__NO_COUNTRY__")
}
condition := "UPPER(ubi.country_code) IN (" + strings.Join(placeholders, ",") + ")"
goldWhere = append(goldWhere, condition)
freightWhere = append(freightWhere, condition)
}
walletDatabase := strings.TrimSpace(s.legacyWalletDatabase)
if walletDatabase == "" {
walletDatabase = "atyou_wallet"
}
unionSQL := `
SELECT CONCAT('aslan-gold-coin-', ugcr.id) AS transaction_id,
'gold_coin_recharge' AS source,
ugcr.user_id,
UPPER(ubi.country_code) AS country_code,
CAST(ugcr.recharge_amount AS CHAR) AS amount,
ugcr.create_time AS created_at
FROM user_gold_coin_recharge ugcr
INNER JOIN user_base_info ubi ON ubi.id = ugcr.user_id
WHERE ` + strings.Join(goldWhere, " AND ") + `
UNION ALL
SELECT CONCAT('aslan-freight-purchase-', fbrw.id) AS transaction_id,
'freight_purchase' AS source,
fbrw.user_id,
UPPER(ubi.country_code) AS country_code,
CAST(CASE WHEN fbrw.amount IS NOT NULL AND fbrw.amount <> 0 THEN fbrw.amount ELSE COALESCE(fbrw.usd_quantity, 0) END AS CHAR) AS amount,
fbrw.create_time AS created_at
FROM ` + quoteMySQLIdentifier(walletDatabase) + `.user_freight_balance_running_water fbrw
INNER JOIN user_base_info ubi ON ubi.id = fbrw.user_id
WHERE ` + strings.Join(freightWhere, " AND ")
return unionSQL, append(goldArgs, freightArgs...)
}

View File

@ -223,6 +223,60 @@ func TestAslanMongoLegacyFreightPurchaseRechargeFeedsCoinSellerChannel(t *testin
}
}
func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) {
db, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer db.Close()
source := &AslanMongoDashboardSource{
legacyDB: db,
legacyWalletDatabase: "atyou_wallet",
sysOrigin: "ATYOU",
requestTimeout: time.Second,
}
start := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 7, 7, 0, 0, 0, 0, time.UTC)
startArg := time.UnixMilli(start.UnixMilli())
endArg := time.UnixMilli(end.UnixMilli())
createdAt := time.Date(2026, 7, 5, 12, 30, 0, 0, time.UTC)
queryPattern := "FROM user_gold_coin_recharge ugcr[\\s\\S]*UNION ALL[\\s\\S]*FROM `atyou_wallet`\\.user_freight_balance_running_water fbrw"
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM \\([\\s\\S]*"+queryPattern).
WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(int64(2)))
mock.ExpectQuery("SELECT transaction_id, source, user_id, country_code, amount, created_at[\\s\\S]*"+queryPattern).
WithArgs("ATYOU", startArg, endArg, "ATYOU", startArg, endArg, 10, 0).
WillReturnRows(sqlmock.NewRows([]string{"transaction_id", "source", "user_id", "country_code", "amount", "created_at"}).
AddRow("aslan-freight-purchase-99", "freight_purchase", int64(2069828597070528514), "ph", "1327.90", createdAt).
AddRow("aslan-gold-coin-7", "gold_coin_recharge", int64(2069828597070528515), "SA", "42.00", createdAt.Add(-time.Hour)))
items, total, err := source.ListCoinSellerRechargeBills(context.Background(), CoinSellerRechargeBillQuery{
StartMS: start.UnixMilli(),
EndMS: end.UnixMilli(),
Page: 1,
PageSize: 10,
})
if err != nil {
t.Fatalf("ListCoinSellerRechargeBills: %v", err)
}
if total != 2 || len(items) != 2 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
}
if items[0].TransactionID != "aslan-freight-purchase-99" || items[0].Source != "freight_purchase" || items[0].CountryCode != "PH" {
t.Fatalf("unexpected first item identity: %+v", items[0])
}
if items[0].USDMinorAmount != 132790 || items[0].CreatedAtMS != createdAt.UnixMilli() {
t.Fatalf("unexpected first item amount/time: %+v", items[0])
}
if items[1].TransactionID != "aslan-gold-coin-7" || items[1].USDMinorAmount != 4200 {
t.Fatalf("unexpected second item: %+v", items[1])
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("sql expectations: %v", err)
}
}
func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) {
match := bson.M{}
aslanMongoExcludeFreightGoldRecharge(match)

View File

@ -1,12 +1,9 @@
package databi
import (
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/response"
@ -14,11 +11,6 @@ import (
"github.com/gin-gonic/gin"
)
const (
kpiManagePermission = "databi-kpi:manage"
kpiViewAllPermission = "databi-kpi:view-all"
)
type Handler struct {
service *Service
store *repository.Store
@ -34,15 +26,11 @@ func (h *Handler) Master(c *gin.Context) {
if !ok {
return
}
master, err := h.service.Master(c.Request.Context(), access, shared.ActorFromContext(c).UserID, h.canViewAllKpi(c, access))
master, err := h.service.Master(c.Request.Context(), access, shared.ActorFromContext(c).UserID, access.All)
if err != nil {
response.ServerError(c, "获取 BI 主数据失败")
return
}
master.Permissions = map[string]bool{
"kpi_manage": middleware.HasPermission(c, kpiManagePermission),
"kpi_view_all": h.canViewAllKpi(c, access),
}
response.OK(c, master)
}
@ -97,150 +85,19 @@ func (h *Handler) Kpi(c *gin.Context) {
AppCodes: splitCSV(c.Query("app_codes")),
OperatorUserID: queryUint(c, "operator_user_id"),
ActorUserID: shared.ActorFromContext(c).UserID,
ViewAll: h.canViewAllKpi(c, access),
ViewAll: access.All,
})
if err != nil {
if strings.Contains(err.Error(), "period_month") {
response.BadRequest(c, "KPI 月份格式不正确")
response.BadRequest(c, "运营充值月份格式不正确")
return
}
response.ServerError(c, "获取 KPI 数据失败")
response.ServerError(c, "获取运营充值数据失败")
return
}
response.OK(c, result)
}
func (h *Handler) ListKpiTargets(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
userIDs := []uint{}
if userID := queryUint(c, "user_id"); userID > 0 {
userIDs = append(userIDs, userID)
}
if !middleware.HasPermission(c, kpiManagePermission) && !h.canViewAllKpi(c, access) {
// 没有管理/全员查看权限时只能查询自己的目标,保证"我的 KPI"可用又不泄露他人目标。
userIDs = []uint{shared.ActorFromContext(c).UserID}
}
months := splitCSV(c.Query("period_month"))
targets, err := h.store.ListDatabiKpiTargets(months, userIDs)
if err != nil {
response.ServerError(c, "获取 KPI 目标失败")
return
}
appTargets, err := h.store.ListDatabiAppKpiTargets(months)
if err != nil {
response.ServerError(c, "获取 App KPI 目标失败")
return
}
response.OK(c, gin.H{"appItems": appTargets, "items": targets, "total": len(targets)})
}
type kpiTargetItemRequest struct {
UserID uint `json:"userId"`
AppCode string `json:"appCode"`
RegionID int64 `json:"regionId"`
PeriodMonth string `json:"periodMonth"`
TargetUSDMinor int64 `json:"targetUsdMinor"`
DailyTargetUSDMinor int64 `json:"dailyTargetUsdMinor"`
}
type kpiAppTargetItemRequest struct {
AppCode string `json:"appCode"`
PeriodMonth string `json:"periodMonth"`
TargetUSDMinor int64 `json:"targetUsdMinor"`
DailyTargetUSDMinor int64 `json:"dailyTargetUsdMinor"`
}
type kpiTargetReplaceRequest struct {
Items []kpiTargetItemRequest `json:"items"`
// AppItems 是 App 级总目标(不分运营的全员共同目标)。
AppItems []kpiAppTargetItemRequest `json:"appItems"`
}
func (h *Handler) ReplaceKpiTargets(c *gin.Context) {
var request kpiTargetReplaceRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "KPI 目标参数不正确")
return
}
if len(request.Items) == 0 && len(request.AppItems) == 0 {
response.BadRequest(c, "KPI 目标不能为空")
return
}
targets := make([]model.DatabiKpiTarget, 0, len(request.Items))
for _, item := range request.Items {
targets = append(targets, model.DatabiKpiTarget{
UserID: item.UserID,
AppCode: item.AppCode,
RegionID: item.RegionID,
PeriodMonth: item.PeriodMonth,
TargetUSDMinor: item.TargetUSDMinor,
DailyTargetUSDMinor: item.DailyTargetUSDMinor,
})
}
appTargets := make([]model.DatabiAppKpiTarget, 0, len(request.AppItems))
for _, item := range request.AppItems {
appTargets = append(appTargets, model.DatabiAppKpiTarget{
AppCode: item.AppCode,
PeriodMonth: item.PeriodMonth,
TargetUSDMinor: item.TargetUSDMinor,
DailyTargetUSDMinor: item.DailyTargetUSDMinor,
})
}
if len(targets) > 0 {
if err := h.store.UpsertDatabiKpiTargets(targets); err != nil {
response.BadRequest(c, "保存 KPI 目标失败: "+err.Error())
return
}
}
if len(appTargets) > 0 {
if err := h.store.UpsertDatabiAppKpiTargets(appTargets); err != nil {
response.BadRequest(c, "保存 App KPI 目标失败: "+err.Error())
return
}
}
shared.OperationLogWithResourceID(c, h.audit, "replace-databi-kpi-targets", "admin_databi_kpi_targets", "", "success",
fmt.Sprintf("items=%d appItems=%d", len(request.Items), len(request.AppItems)))
months := map[string]struct{}{}
userIDs := map[uint]struct{}{}
for _, item := range request.Items {
months[strings.TrimSpace(item.PeriodMonth)] = struct{}{}
userIDs[item.UserID] = struct{}{}
}
for _, item := range request.AppItems {
months[strings.TrimSpace(item.PeriodMonth)] = struct{}{}
}
monthList := make([]string, 0, len(months))
for month := range months {
monthList = append(monthList, month)
}
userList := make([]uint, 0, len(userIDs))
for userID := range userIDs {
userList = append(userList, userID)
}
targetsAfter, err := h.store.ListDatabiKpiTargets(monthList, userList)
if err != nil {
response.ServerError(c, "获取 KPI 目标失败")
return
}
appTargetsAfter, err := h.store.ListDatabiAppKpiTargets(monthList)
if err != nil {
response.ServerError(c, "获取 App KPI 目标失败")
return
}
response.OK(c, gin.H{"appItems": appTargetsAfter, "items": targetsAfter, "total": len(targetsAfter)})
}
// canViewAllKpi显式授权 databi-kpi:view-all 或拥有全量数据范围的管理员可以看全员绩效。
func (h *Handler) canViewAllKpi(c *gin.Context, access repository.MoneyAccess) bool {
if middleware.HasPermission(c, kpiViewAllPermission) || middleware.HasPermission(c, kpiManagePermission) {
return true
}
return access.All
}
func (h *Handler) moneyAccess(c *gin.Context) (repository.MoneyAccess, bool) {
if h.store == nil {
return repository.MoneyAccess{All: true}, true

View File

@ -11,6 +11,4 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/databi/social/overview", middleware.RequirePermission("overview:view"), h.Overview)
protected.GET("/admin/databi/social/funnel", middleware.RequirePermission("overview:view"), h.Funnel)
protected.GET("/admin/databi/social/kpi", middleware.RequirePermission("overview:view"), h.Kpi)
protected.GET("/admin/databi/social/kpi-targets", middleware.RequirePermission("overview:view"), h.ListKpiTargets)
protected.PUT("/admin/databi/social/kpi-targets", middleware.RequirePermission(kpiManagePermission), h.ReplaceKpiTargets)
}

View File

@ -175,11 +175,10 @@ type AccessInfo struct {
}
type MasterData struct {
Apps []AppInfo `json:"apps"`
Regions []RegionInfo `json:"regions"`
Operators []OperatorInfo `json:"operators"`
Access AccessInfo `json:"access"`
Permissions map[string]bool `json:"permissions,omitempty"`
Apps []AppInfo `json:"apps"`
Regions []RegionInfo `json:"regions"`
Operators []OperatorInfo `json:"operators"`
Access AccessInfo `json:"access"`
}
// Master 返回社交 BI 的筛选主数据:当前用户可见的 App、区域目录、运营人员及各自负责范围。
@ -522,67 +521,45 @@ type KpiQuery struct {
}
type KpiRow struct {
OperatorUserID uint `json:"operator_user_id"`
OperatorName string `json:"operator_name"`
OperatorAccount string `json:"operator_account"`
Team string `json:"team"`
TeamID *uint `json:"team_id"`
AppCode string `json:"app_code"`
AppName string `json:"app_name"`
RegionID int64 `json:"region_id"`
RegionCode string `json:"region_code"`
RegionName string `json:"region_name"`
RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"`
RangeNewUsers *int64 `json:"range_new_users"`
RangeActiveUsers *int64 `json:"range_active_users"`
MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"`
MonthTargetUSDMinor int64 `json:"month_target_usd_minor"`
DailyTargetUSDMinor int64 `json:"daily_target_usd_minor"`
MonthAttainmentRate *float64 `json:"month_attainment_rate"`
DataError string `json:"data_error,omitempty"`
OperatorUserID uint `json:"operator_user_id"`
OperatorName string `json:"operator_name"`
OperatorAccount string `json:"operator_account"`
Team string `json:"team"`
TeamID *uint `json:"team_id"`
AppCode string `json:"app_code"`
AppName string `json:"app_name"`
RegionID int64 `json:"region_id"`
RegionCode string `json:"region_code"`
RegionName string `json:"region_name"`
RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"`
RangeNewUsers *int64 `json:"range_new_users"`
RangeActiveUsers *int64 `json:"range_active_users"`
MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"`
DataError string `json:"data_error,omitempty"`
}
// KpiOperatorAppRow 是 人×App 级小计:该运营在此 App 下全部负责区域的合计,
// 目标取自 admin_databi_kpi_targets 的 region_id=0 行(该运营的整 App 目标)。
// KpiOperatorAppRow 是 人×App 级小计:该运营在此 App 下全部负责区域的充值合计。
type KpiOperatorAppRow struct {
OperatorUserID uint `json:"operator_user_id"`
OperatorName string `json:"operator_name"`
OperatorAccount string `json:"operator_account"`
Team string `json:"team"`
TeamID *uint `json:"team_id"`
AppCode string `json:"app_code"`
AppName string `json:"app_name"`
RegionCount int `json:"region_count"`
RegionNames string `json:"region_names"`
WholeAppScope bool `json:"whole_app_scope"`
RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"`
RangeNewUsers *int64 `json:"range_new_users"`
MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"`
MonthTargetUSDMinor int64 `json:"month_target_usd_minor"`
DailyTargetUSDMinor int64 `json:"daily_target_usd_minor"`
MonthAttainmentRate *float64 `json:"month_attainment_rate"`
DataError string `json:"data_error,omitempty"`
}
// KpiAppRow 是 App 级总目标行:实际值为整 App 充值(不分运营的全员共同目标)。
type KpiAppRow struct {
AppCode string `json:"app_code"`
AppName string `json:"app_name"`
OperatorCount int `json:"operator_count"`
RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"`
MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"`
MonthTargetUSDMinor int64 `json:"month_target_usd_minor"`
DailyTargetUSDMinor int64 `json:"daily_target_usd_minor"`
MonthAttainmentRate *float64 `json:"month_attainment_rate"`
DataError string `json:"data_error,omitempty"`
OperatorUserID uint `json:"operator_user_id"`
OperatorName string `json:"operator_name"`
OperatorAccount string `json:"operator_account"`
Team string `json:"team"`
TeamID *uint `json:"team_id"`
AppCode string `json:"app_code"`
AppName string `json:"app_name"`
RegionCount int `json:"region_count"`
RegionNames string `json:"region_names"`
WholeAppScope bool `json:"whole_app_scope"`
RangeRechargeUSDMinor *int64 `json:"range_recharge_usd_minor"`
RangeNewUsers *int64 `json:"range_new_users"`
MonthRechargeUSDMinor *int64 `json:"month_recharge_usd_minor"`
DataError string `json:"data_error,omitempty"`
}
type KpiSummary struct {
RangeRechargeUSDMinor int64 `json:"range_recharge_usd_minor"`
MonthRechargeUSDMinor int64 `json:"month_recharge_usd_minor"`
MonthTargetUSDMinor int64 `json:"month_target_usd_minor"`
MonthAttainmentRate *float64 `json:"month_attainment_rate"`
OperatorCount int `json:"operator_count"`
RangeRechargeUSDMinor int64 `json:"range_recharge_usd_minor"`
MonthRechargeUSDMinor int64 `json:"month_recharge_usd_minor"`
OperatorCount int `json:"operator_count"`
}
type KpiResult struct {
@ -591,13 +568,11 @@ type KpiResult struct {
MonthEndMS int64 `json:"month_end_ms"`
Items []KpiRow `json:"items"`
OperatorAppRows []KpiOperatorAppRow `json:"operator_app_rows"`
AppRows []KpiAppRow `json:"app_rows"`
Summary KpiSummary `json:"summary"`
AppErrors map[string]any `json:"app_errors,omitempty"`
}
// Kpi 按运营人员负责范围App×区域聚合充值/新增,并对齐月度 KPI 目标计算达成率。
// 无 view-all 权限时只返回本人行,即"我的 KPI"。
// Kpi 按运营人员负责范围App×区域聚合充值/新增;无全量数据范围时只返回本人行。
func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query KpiQuery) (*KpiResult, error) {
users, scopesByUser, err := s.store.ListMoneyScopeOperators()
if err != nil {
@ -710,19 +685,6 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query
}
wg.Wait()
operatorIDs := make([]uint, 0, len(operators))
for _, user := range operators {
operatorIDs = append(operatorIDs, user.ID)
}
targets, err := s.store.ListDatabiKpiTargets([]string{periodMonth}, operatorIDs)
if err != nil {
return nil, err
}
targetByKey := map[string]model.DatabiKpiTarget{}
for _, target := range targets {
targetByKey[kpiTargetKey(target.UserID, target.AppCode, target.RegionID)] = target
}
rows := []KpiRow{}
appErrors := map[string]any{}
for _, user := range operators {
@ -750,10 +712,6 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query
RegionID: regionID,
}
row.RegionCode, row.RegionName = regionDisplay(catalog[appCode], regionID)
if target, ok := lookupKpiTarget(targetByKey, user.ID, appCode, scope.RegionID, regionID); ok {
row.MonthTargetUSDMinor = target.TargetUSDMinor
row.DailyTargetUSDMinor = target.DailyTargetUSDMinor
}
data := dataByApp[appCode]
if data == nil {
row.DataError = "该 App 未接入统计数据"
@ -767,10 +725,6 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query
row.RangeNewUsers = metricPointer(rangeMetrics, "new_users")
row.RangeActiveUsers = metricPointer(rangeMetrics, "active_users")
row.MonthRechargeUSDMinor = metricPointer(monthMetrics, "recharge_usd_minor")
if row.MonthTargetUSDMinor > 0 && row.MonthRechargeUSDMinor != nil {
rate := safeRatio(*row.MonthRechargeUSDMinor, row.MonthTargetUSDMinor)
row.MonthAttainmentRate = &rate
}
}
rows = append(rows, row)
}
@ -797,18 +751,8 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query
return left.RegionID < right.RegionID
})
appTargets, err := s.store.ListDatabiAppKpiTargets([]string{periodMonth})
if err != nil {
return nil, err
}
appTargetByCode := map[string]model.DatabiAppKpiTarget{}
for _, target := range appTargets {
appTargetByCode[appctx.Normalize(target.AppCode)] = target
}
operatorAppRows := buildOperatorAppRows(rows, targetByKey)
appRows := buildAppRows(relevantApps, dataByApp, appTargetByCode, rows)
summary := buildKpiSummary(rows, operatorAppRows, appRows)
operatorAppRows := buildOperatorAppRows(rows)
summary := buildKpiSummary(rows, operatorAppRows)
result := &KpiResult{
PeriodMonth: periodMonth,
@ -816,7 +760,6 @@ func (s *Service) Kpi(ctx context.Context, access repository.MoneyAccess, query
MonthEndMS: monthEnd.UnixMilli(),
Items: rows,
OperatorAppRows: operatorAppRows,
AppRows: appRows,
Summary: summary,
}
if len(appErrors) > 0 {
@ -847,7 +790,7 @@ func nullableAdd(sum *int64, value *int64) *int64 {
// buildOperatorAppRows 把 人×App×区域 明细行归并成 人×App 小计:
// 该运营有整 App 范围region 0时直接取那一行其值即整 App 口径),否则各区域可空求和。
func buildOperatorAppRows(rows []KpiRow, targetByKey map[string]model.DatabiKpiTarget) []KpiOperatorAppRow {
func buildOperatorAppRows(rows []KpiRow) []KpiOperatorAppRow {
type group struct {
row KpiOperatorAppRow
regionNames []string
@ -897,18 +840,6 @@ func buildOperatorAppRows(rows []KpiRow, targetByKey map[string]model.DatabiKpiT
item.row.MonthRechargeUSDMinor = item.wholeRow.MonthRechargeUSDMinor
}
item.row.RegionNames = joinRegionNames(item.regionNames)
if target, ok := targetByKey[kpiTargetKey(item.row.OperatorUserID, item.row.AppCode, 0)]; ok {
item.row.MonthTargetUSDMinor = target.TargetUSDMinor
item.row.DailyTargetUSDMinor = target.DailyTargetUSDMinor
} else if item.wholeRow != nil {
// 整 App 范围的运营其明细行region 0目标与 人×App 目标同键,直接沿用。
item.row.MonthTargetUSDMinor = item.wholeRow.MonthTargetUSDMinor
item.row.DailyTargetUSDMinor = item.wholeRow.DailyTargetUSDMinor
}
if item.row.MonthTargetUSDMinor > 0 && item.row.MonthRechargeUSDMinor != nil {
rate := safeRatio(*item.row.MonthRechargeUSDMinor, item.row.MonthTargetUSDMinor)
item.row.MonthAttainmentRate = &rate
}
out = append(out, item.row)
}
sort.Slice(out, func(i, j int) bool {
@ -938,109 +869,26 @@ func joinRegionNames(names []string) string {
return strings.Join(names[:3], " / ") + fmt.Sprintf(" 等%d个", len(names))
}
// buildAppRows 生成 App 级总目标行:实际值 = 整 App 充值(与运营/区域无关的共同目标口径)。
func buildAppRows(apps []AppInfo, dataByApp map[string]*appKpiData, appTargetByCode map[string]model.DatabiAppKpiTarget, rows []KpiRow) []KpiAppRow {
operatorsByApp := map[string]map[uint]struct{}{}
for _, row := range rows {
set := operatorsByApp[row.AppCode]
if set == nil {
set = map[uint]struct{}{}
operatorsByApp[row.AppCode] = set
}
set[row.OperatorUserID] = struct{}{}
}
out := make([]KpiAppRow, 0, len(apps))
for _, app := range apps {
row := KpiAppRow{
AppCode: app.AppCode,
AppName: app.AppName,
OperatorCount: len(operatorsByApp[app.AppCode]),
}
if target, ok := appTargetByCode[app.AppCode]; ok {
row.MonthTargetUSDMinor = target.TargetUSDMinor
row.DailyTargetUSDMinor = target.DailyTargetUSDMinor
}
data := dataByApp[app.AppCode]
if data == nil {
row.DataError = "该 App 未接入统计数据"
} else if data.err != nil {
row.DataError = fmt.Sprintf("获取统计数据失败: %v", data.err)
} else {
row.RangeRechargeUSDMinor = metricPointer(data.rangeTotal, "recharge_usd_minor")
row.MonthRechargeUSDMinor = metricPointer(data.monthTotal, "recharge_usd_minor")
}
if row.MonthTargetUSDMinor > 0 && row.MonthRechargeUSDMinor != nil {
rate := safeRatio(*row.MonthRechargeUSDMinor, row.MonthTargetUSDMinor)
row.MonthAttainmentRate = &rate
}
out = append(out, row)
}
sort.Slice(out, func(i, j int) bool {
left := int64(0)
if out[i].MonthRechargeUSDMinor != nil {
left = *out[i].MonthRechargeUSDMinor
}
right := int64(0)
if out[j].MonthRechargeUSDMinor != nil {
right = *out[j].MonthRechargeUSDMinor
}
if left != right {
return left > right
}
return out[i].AppCode < out[j].AppCode
})
return out
}
// buildKpiSummary 的口径:实际充值按 App 行求和(跨运营/区域去重);
// 月目标按 App 逐个取"最权威"的一层——App 总目标 > 人×App 目标合计 > 人×区域明细目标合计。
func buildKpiSummary(rows []KpiRow, operatorAppRows []KpiOperatorAppRow, appRows []KpiAppRow) KpiSummary {
// buildKpiSummary 只服务列表头部元信息充值合计按人×App 小计相加,避免同一运营多区域重复。
func buildKpiSummary(rows []KpiRow, operatorAppRows []KpiOperatorAppRow) KpiSummary {
summary := KpiSummary{}
operatorSeen := map[uint]struct{}{}
regionTargetByApp := map[string]int64{}
for _, row := range rows {
operatorSeen[row.OperatorUserID] = struct{}{}
if row.RegionID != 0 {
regionTargetByApp[row.AppCode] += row.MonthTargetUSDMinor
}
}
summary.OperatorCount = len(operatorSeen)
operatorAppTargetByApp := map[string]int64{}
for _, row := range operatorAppRows {
operatorAppTargetByApp[row.AppCode] += row.MonthTargetUSDMinor
}
for _, row := range appRows {
if row.RangeRechargeUSDMinor != nil {
summary.RangeRechargeUSDMinor += *row.RangeRechargeUSDMinor
}
if row.MonthRechargeUSDMinor != nil {
summary.MonthRechargeUSDMinor += *row.MonthRechargeUSDMinor
}
summary.MonthTargetUSDMinor += pickEffectiveAppTarget(
row.MonthTargetUSDMinor,
operatorAppTargetByApp[row.AppCode],
regionTargetByApp[row.AppCode],
)
}
if summary.MonthTargetUSDMinor > 0 {
rate := safeRatio(summary.MonthRechargeUSDMinor, summary.MonthTargetUSDMinor)
summary.MonthAttainmentRate = &rate
}
return summary
}
// pickEffectiveAppTarget一个 App 的"生效月目标"取最高层已配置的那级,避免多级目标重复相加。
func pickEffectiveAppTarget(appTarget int64, operatorAppTargets int64, regionTargets int64) int64 {
if appTarget > 0 {
return appTarget
}
if operatorAppTargets > 0 {
return operatorAppTargets
}
return regionTargets
}
func (s *Service) kpiWindow(ctx context.Context, app AppInfo, resolve func(map[string]any) (RegionInfo, bool), statTZ string, startMS int64, endMS int64) (map[string]any, map[int64]map[string]any, error) {
overview, err := s.statisticsOverview(ctx, dashboard.StatisticsQuery{
AppCode: app.AppCode,
@ -1082,30 +930,7 @@ func metricPointer(row map[string]any, key string) *int64 {
return &out
}
func kpiTargetKey(userID uint, appCode string, regionID int64) string {
return strconv.FormatUint(uint64(userID), 10) + ":" + appctx.Normalize(appCode) + ":" + strconv.FormatInt(regionID, 10)
}
// lookupKpiTarget 依次用 scope 原始 ID 与归一后的精确 ID 查目标:
// 目标行是前端写入的legacy 大区可能存的是圆整值,也可能是修复后的精确值。
func lookupKpiTarget(targetByKey map[string]model.DatabiKpiTarget, userID uint, appCode string, rawRegionID int64, regionID int64) (model.DatabiKpiTarget, bool) {
if target, ok := targetByKey[kpiTargetKey(userID, appCode, rawRegionID)]; ok {
return target, true
}
if regionID != rawRegionID {
if target, ok := targetByKey[kpiTargetKey(userID, appCode, regionID)]; ok {
return target, true
}
}
if rounded := int64(float64(regionID)); rounded != rawRegionID && rounded != regionID {
if target, ok := targetByKey[kpiTargetKey(userID, appCode, rounded)]; ok {
return target, true
}
}
return model.DatabiKpiTarget{}, false
}
// resolveKpiMonth 解析目标月份并给出该月在统计时区下的起止时间;结束时间截断到明天零点,
// resolveKpiMonth 解析统计月份并给出该月在统计时区下的起止时间;结束时间截断到明天零点,
// 避免 legacy Mongo 源为未来日期空跑逐日聚合。
func resolveKpiMonth(periodMonth string, endMS int64, location *time.Location) (string, time.Time, time.Time, error) {
now := time.Now().In(location)

View File

@ -26,7 +26,7 @@ import (
type legacyRechargeBillQuery struct {
Keyword string
// RechargeType 支持 google_play_recharge / third_party 的 Mongo 明细口径;
// coin_seller 没有 legacy 原始账单列表summary/overview 只能从 dashboard 聚合事实补充
// coin_seller 优先走 dashboard 外部源暴露的 legacy 明细,缺明细源时返回空列表
RechargeType string
// PaidState=unsynced 只看谷歌实付未同步的账单。
PaidState string
@ -86,6 +86,7 @@ type MongoRechargeBillSource struct {
paidStore LegacyGooglePaidStore
googleOrders legacyGoogleOrdersClient
coinSellerStats legacyCoinSellerRechargeStatsSource
coinSellerBills dashboard.CoinSellerRechargeBillSource
}
// NewMongoRechargeBillSource 创建 legacy 账单源regionSources 用于把后台区域 ID 解析成国家码过滤条件。
@ -132,6 +133,9 @@ func (s *MongoRechargeBillSource) SetDashboardCoinSellerSource(source dashboard.
return
}
s.coinSellerStats = dashboardCoinSellerRechargeStatsSource{source: source}
if billSource, ok := source.(dashboard.CoinSellerRechargeBillSource); ok {
s.coinSellerBills = billSource
}
}
// SupportsGooglePaidSync 供 handler 判定该源能否响应谷歌实付刷新。
@ -240,7 +244,13 @@ type legacyPurchaseProduct struct {
}
func (s *MongoRechargeBillSource) ListRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) {
if s == nil || s.collection == nil {
if s == nil {
return nil, 0, fmt.Errorf("legacy bill source is not configured")
}
if legacyQueryRechargeType(query) == "coin_seller" {
return s.listCoinSellerRechargeBills(ctx, query)
}
if s.collection == nil {
return nil, 0, fmt.Errorf("legacy bill source is not configured")
}
filter, regionCodes, matchable, err := s.billFilter(ctx, query)
@ -264,6 +274,49 @@ func (s *MongoRechargeBillSource) ListRechargeBills(ctx context.Context, query l
return s.listBillsByRegionPipeline(ctx, filter, regionCodes, page, pageSize)
}
func (s *MongoRechargeBillSource) listCoinSellerRechargeBills(ctx context.Context, query legacyRechargeBillQuery) ([]rechargeBillDTO, int64, error) {
if s == nil || s.coinSellerBills == nil || strings.TrimSpace(query.PaidState) != "" {
return []rechargeBillDTO{}, 0, nil
}
rows, total, err := s.coinSellerBills.ListCoinSellerRechargeBills(ctx, dashboard.CoinSellerRechargeBillQuery{
Keyword: query.Keyword,
RegionID: query.RegionID,
StartMS: query.StartAtMS,
EndMS: query.EndAtMS,
Page: query.Page,
PageSize: query.PageSize,
})
if err != nil {
return nil, 0, fmt.Errorf("query legacy coin seller recharge bills for %s: %w", s.config.AppCode, err)
}
countryToRegion := s.legacyCountryRegionIndex(ctx)
items := make([]rechargeBillDTO, 0, len(rows))
for _, row := range rows {
item := rechargeBillDTO{
AppCode: s.config.AppCode,
TransactionID: row.TransactionID,
RechargeType: "coin_seller_stock_purchase",
Status: "succeeded",
ExternalRef: row.Source,
SellerUserID: row.UserID,
CurrencyCode: "USD",
USDMinorAmount: row.USDMinorAmount,
CreatedAtMS: row.CreatedAtMS,
ProviderCode: "coin_seller",
UserPaidCurrencyCode: "USD",
UserPaidAmountMicro: row.USDMinorAmount * 10_000,
PaidSyncedAtMS: row.CreatedAtMS,
Seller: rechargeBillUserOrFallback(rechargeBillUserDTO{}, row.UserID),
}
if region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(row.CountryCode))]; ok {
item.SellerRegionID = region.RegionID
item.TargetRegionID = region.RegionID
}
items = append(items, item)
}
return items, total, nil
}
func (s *MongoRechargeBillSource) listBillsByFilter(ctx context.Context, filter bson.M, page int, pageSize int) ([]rechargeBillDTO, int64, error) {
total, err := s.collection.CountDocuments(ctx, filter)
if err != nil {
@ -636,7 +689,7 @@ func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRe
case "third_party":
filter["factory.factoryCode"] = bson.M{"$ne": "GOOGLE"}
case "coin_seller":
// legacy 平台没有可分页的币商原始账单summary/overview 走 dashboard 聚合补充
// coin_seller 明细不在 in_app_purchase_details调用方会先走 listCoinSellerRechargeBills
return nil, nil, false, nil
}
// Aslan 金币代理“发货”历史上也落过 in_app_purchase_detailsdashboard/social 已把它归入币商充值,

View File

@ -23,6 +23,15 @@ func yumiBillSourceConfig() config.FinanceBillSourceConfig {
}
}
func aslanBillSourceConfig() config.FinanceBillSourceConfig {
cfg := yumiBillSourceConfig()
cfg.AppCode = "aslan"
cfg.AppName = "Aslan"
cfg.SysOrigin = "ATYOU"
cfg.MongoDatabase = "atyou"
return cfg
}
func TestLegacyRechargeBillDTOMapsGooglePurchase(t *testing.T) {
amount, err := bson.ParseDecimal128("164.99")
if err != nil {
@ -199,10 +208,14 @@ func TestLegacyBillFilterUnresolvedRegionMatchesNothing(t *testing.T) {
}
type fakeDashboardRechargeSource struct {
appCode string
overview map[string]any
query dashboard.StatisticsQuery
calls int
appCode string
overview map[string]any
query dashboard.StatisticsQuery
calls int
bills []dashboard.CoinSellerRechargeBill
billTotal int64
billQuery dashboard.CoinSellerRechargeBillQuery
billCalls int
}
func (s *fakeDashboardRechargeSource) AppCode() string {
@ -215,6 +228,67 @@ func (s *fakeDashboardRechargeSource) StatisticsOverview(_ context.Context, quer
return s.overview, nil
}
func (s *fakeDashboardRechargeSource) ListCoinSellerRechargeBills(_ context.Context, query dashboard.CoinSellerRechargeBillQuery) ([]dashboard.CoinSellerRechargeBill, int64, error) {
s.billQuery = query
s.billCalls++
return s.bills, s.billTotal, nil
}
func TestLegacyCoinSellerListUsesDashboardBillSource(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "aslan",
billTotal: 1,
bills: []dashboard.CoinSellerRechargeBill{{
TransactionID: "aslan-freight-purchase-99",
Source: "freight_purchase",
UserID: 2069828597070528514,
CountryCode: "PH",
USDMinorAmount: 132790,
CreatedAtMS: 1_783_530_000_000,
}},
}
source := &MongoRechargeBillSource{
config: aslanBillSourceConfig(),
regionSources: []MoneyRegionSource{&staticMoneyRegionSource{regions: []moneyRegionDTO{{
AppCode: "aslan",
RegionID: 301,
Name: "Philippines",
Countries: []string{"PH"},
}}}},
}
source.SetDashboardCoinSellerSource(dashboardSource)
items, total, err := source.ListRechargeBills(context.Background(), legacyRechargeBillQuery{
RechargeType: "coin_seller",
StartAtMS: 1_783_468_800_000,
EndAtMS: 1_786_060_800_000,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListRechargeBills: %v", err)
}
if total != 1 || len(items) != 1 {
t.Fatalf("unexpected result total=%d items=%+v", total, items)
}
item := items[0]
if item.RechargeType != "coin_seller_stock_purchase" || item.ProviderCode != "coin_seller" {
t.Fatalf("unexpected coin seller mapping: %+v", item)
}
if item.TransactionID != "aslan-freight-purchase-99" || item.SellerUserID != 2069828597070528514 || item.USDMinorAmount != 132790 {
t.Fatalf("unexpected bill fields: %+v", item)
}
if item.SellerRegionID != 301 || item.TargetRegionID != 301 {
t.Fatalf("region mapping failed: %+v", item)
}
if item.UserPaidCurrencyCode != "USD" || item.UserPaidAmountMicro != 1_327_900_000 {
t.Fatalf("paid facts mismatch: %+v", item)
}
if dashboardSource.billCalls != 1 || dashboardSource.billQuery.PageSize != 20 {
t.Fatalf("dashboard bill source not called as expected: calls=%d query=%+v", dashboardSource.billCalls, dashboardSource.billQuery)
}
}
func TestLegacyCoinSellerDashboardStatsMergeSummary(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "Yumi",

View File

@ -1,20 +1,9 @@
package repository
import (
"errors"
"regexp"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var databiPeriodMonthPattern = regexp.MustCompile(`^\d{4}-(0[1-9]|1[0-2])$`)
// ListMoneyScopeOperators 返回所有配置过财务/数据范围的后台用户及其范围;
// 社交 BI 的"运营人员"就是这批人,不再依赖固定 team_id 约定。
func (s *Store) ListMoneyScopeOperators() ([]model.User, map[uint][]model.UserMoneyScope, error) {
@ -39,155 +28,3 @@ func (s *Store) ListMoneyScopeOperators() ([]model.User, map[uint][]model.UserMo
}
return users, scopesByUser, nil
}
func (s *Store) ListDatabiKpiTargets(periodMonths []string, userIDs []uint) ([]model.DatabiKpiTarget, error) {
query := s.db.Model(&model.DatabiKpiTarget{})
months := normalizeDatabiPeriodMonths(periodMonths)
if len(months) > 0 {
query = query.Where("period_month IN ?", months)
}
if len(userIDs) > 0 {
query = query.Where("user_id IN ?", userIDs)
}
var targets []model.DatabiKpiTarget
err := query.Order("user_id ASC, app_code ASC, region_id ASC, period_month ASC").Find(&targets).Error
return targets, err
}
// UpsertDatabiKpiTargets 逐条 upsert 目标;月目标和日目标同时为 0 视为清除该条目标。
func (s *Store) UpsertDatabiKpiTargets(targets []model.DatabiKpiTarget) error {
normalized, removals, err := normalizeDatabiKpiTargets(targets)
if err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
for _, target := range removals {
if err := tx.Where(
"user_id = ? AND app_code = ? AND region_id = ? AND period_month = ?",
target.UserID, target.AppCode, target.RegionID, target.PeriodMonth,
).Delete(&model.DatabiKpiTarget{}).Error; err != nil {
return err
}
}
if len(normalized) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "app_code"}, {Name: "region_id"}, {Name: "period_month"}},
DoUpdates: clause.AssignmentColumns([]string{
"target_usd_minor",
"daily_target_usd_minor",
"updated_at_ms",
}),
}).Create(&normalized).Error
})
}
func (s *Store) ListDatabiAppKpiTargets(periodMonths []string) ([]model.DatabiAppKpiTarget, error) {
query := s.db.Model(&model.DatabiAppKpiTarget{})
months := normalizeDatabiPeriodMonths(periodMonths)
if len(months) > 0 {
query = query.Where("period_month IN ?", months)
}
var targets []model.DatabiAppKpiTarget
err := query.Order("app_code ASC, period_month ASC").Find(&targets).Error
return targets, err
}
// UpsertDatabiAppKpiTargets 逐条 upsert App 级目标;月目标和日目标同时为 0 视为清除。
func (s *Store) UpsertDatabiAppKpiTargets(targets []model.DatabiAppKpiTarget) error {
seen := map[string]struct{}{}
upserts := make([]model.DatabiAppKpiTarget, 0, len(targets))
removals := make([]model.DatabiAppKpiTarget, 0)
for _, target := range targets {
target.AppCode = appctx.Normalize(target.AppCode)
target.PeriodMonth = strings.TrimSpace(target.PeriodMonth)
if target.AppCode == "" {
return errors.New("app kpi target app code is required")
}
if !databiPeriodMonthPattern.MatchString(target.PeriodMonth) {
return errors.New("app kpi target period month must be formatted as YYYY-MM")
}
if target.TargetUSDMinor < 0 || target.DailyTargetUSDMinor < 0 {
return errors.New("app kpi target amounts must not be negative")
}
key := target.AppCode + ":" + target.PeriodMonth
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if target.TargetUSDMinor == 0 && target.DailyTargetUSDMinor == 0 {
removals = append(removals, target)
continue
}
target.ID = 0
upserts = append(upserts, target)
}
return s.db.Transaction(func(tx *gorm.DB) error {
for _, target := range removals {
if err := tx.Where("app_code = ? AND period_month = ?", target.AppCode, target.PeriodMonth).
Delete(&model.DatabiAppKpiTarget{}).Error; err != nil {
return err
}
}
if len(upserts) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "app_code"}, {Name: "period_month"}},
DoUpdates: clause.AssignmentColumns([]string{
"target_usd_minor",
"daily_target_usd_minor",
"updated_at_ms",
}),
}).Create(&upserts).Error
})
}
func normalizeDatabiKpiTargets(targets []model.DatabiKpiTarget) ([]model.DatabiKpiTarget, []model.DatabiKpiTarget, error) {
seen := map[string]struct{}{}
upserts := make([]model.DatabiKpiTarget, 0, len(targets))
removals := make([]model.DatabiKpiTarget, 0)
for _, target := range targets {
target.AppCode = appctx.Normalize(target.AppCode)
target.PeriodMonth = strings.TrimSpace(target.PeriodMonth)
if target.UserID == 0 || target.AppCode == "" || target.RegionID < 0 {
return nil, nil, errors.New("kpi target user, app code and region id are required")
}
if !databiPeriodMonthPattern.MatchString(target.PeriodMonth) {
return nil, nil, errors.New("kpi target period month must be formatted as YYYY-MM")
}
if target.TargetUSDMinor < 0 || target.DailyTargetUSDMinor < 0 {
return nil, nil, errors.New("kpi target amounts must not be negative")
}
key := strconv.FormatUint(uint64(target.UserID), 10) + ":" + target.AppCode + ":" + strconv.FormatInt(target.RegionID, 10) + ":" + target.PeriodMonth
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
if target.TargetUSDMinor == 0 && target.DailyTargetUSDMinor == 0 {
removals = append(removals, target)
continue
}
target.ID = 0
upserts = append(upserts, target)
}
return upserts, removals, nil
}
func normalizeDatabiPeriodMonths(periodMonths []string) []string {
out := make([]string, 0, len(periodMonths))
seen := map[string]struct{}{}
for _, month := range periodMonths {
month = strings.TrimSpace(month)
if month == "" || !databiPeriodMonthPattern.MatchString(month) {
continue
}
if _, ok := seen[month]; ok {
continue
}
seen[month] = struct{}{}
out = append(out, month)
}
return out
}

View File

@ -80,8 +80,6 @@ func (s *Store) AutoMigrate() error {
&model.OperationLog{},
&model.DataScope{},
&model.UserMoneyScope{},
&model.DatabiKpiTarget{},
&model.DatabiAppKpiTarget{},
&model.TemporaryPaymentLinkOwner{},
&model.LegacyGooglePaidDetail{},
&model.FinanceApplication{},

View File

@ -0,0 +1,12 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
DELETE admin_role_permissions
FROM admin_role_permissions
JOIN admin_permissions ON admin_permissions.id = admin_role_permissions.permission_id
WHERE admin_permissions.code IN ('databi-kpi:manage', 'databi-kpi:view-all');
DELETE FROM admin_permissions
WHERE code IN ('databi-kpi:manage', 'databi-kpi:view-all');
DROP TABLE IF EXISTS admin_databi_app_kpi_targets;
DROP TABLE IF EXISTS admin_databi_kpi_targets;