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