package managerapi import ( "context" "fmt" "log/slog" "net/http" "sync" "time" userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/services/gateway-service/internal/transport/http/httpkit" ) // managerDashboardCacheTTL 是团队工资统计的展示缓存时长。 // 统计跨 user-service 组织树和 wallet-service 周期账户两次聚合,产品接受分钟级延迟展示, // 用短 TTL 换掉每次进入经理中心都全量聚合的成本;缓存是单节点内存态,不做跨副本一致性。 const managerDashboardCacheTTL = 2 * time.Minute // managerDashboardFailureCacheTTL 是聚合失败的负缓存时长: // 下游持续故障时避免每个 overview 请求都重跑双聚合打崩上游,同时保证故障恢复后半分钟内回到正常展示。 const managerDashboardFailureCacheTTL = 30 * time.Second // managerTeamAgencyPageSize 是一次拉取经理团队 Agency 的上限,与 user-service 服务端上限保持一致。 const managerTeamAgencyPageSize = 5000 type managerDashboardCacheEntry struct { data map[string]any expiresAt time.Time } type managerDashboardCache struct { mu sync.Mutex entries map[string]managerDashboardCacheEntry } func (c *managerDashboardCache) get(key string, now time.Time) (map[string]any, bool) { c.mu.Lock() defer c.mu.Unlock() entry, ok := c.entries[key] if !ok { return nil, false } if now.After(entry.expiresAt) { delete(c.entries, key) return nil, false } // data 为 nil 表示负缓存命中:负缓存期内直接按“统计不可用”降级,不再重跑聚合。 return entry.data, true } func (c *managerDashboardCache) set(key string, data map[string]any, now time.Time, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() if c.entries == nil { c.entries = map[string]managerDashboardCacheEntry{} } // 顺手清理已过期条目;经理数量有限,线性清理成本可忽略,避免长期运行只进不出。 for cacheKey, entry := range c.entries { if now.After(entry.expiresAt) { delete(c.entries, cacheKey) } } c.entries[key] = managerDashboardCacheEntry{data: data, expiresAt: now.Add(ttl)} } // managerDashboardAggregateTimeout 是团队统计两次聚合 RPC 的总预算。 // 概览主链路(身份+权限)已有十余次串行 RPC,统计只是增强信息,超时直接降级而不是拖垮整个 overview。 const managerDashboardAggregateTimeout = 5 * time.Second // managerTeamDashboard 聚合经理团队工资统计卡片数据: // user-service 解析经理整棵团队树的 Agency 收款人集合,wallet-service 按收款人聚合主播预收入工资。 // 统计失败时返回 nil 并记录日志,经理中心身份信息照常返回,H5 对缺失字段做占位展示。 func (h *Handler) managerTeamDashboard(request *http.Request, managerUserID int64) map[string]any { if h.userHostClient == nil || h.walletClient == nil { return nil } now := time.Now().UTC() cacheKey := fmt.Sprintf("%s:%d", appcode.FromContext(request.Context()), managerUserID) if cached, ok := h.dashboardCache.get(cacheKey, now); ok { return cached } ctx, cancel := context.WithTimeout(request.Context(), managerDashboardAggregateTimeout) defer cancel() request = request.WithContext(ctx) teamResp, err := h.userHostClient.ListManagerTeamAgencies(request.Context(), &userv1.ListManagerTeamAgenciesRequest{ Meta: httpkit.UserMeta(request, ""), ManagerUserId: managerUserID, PageSize: managerTeamAgencyPageSize, }) if err != nil { logx.Warn(request.Context(), "manager_team_agencies_failed", slog.Int64("manager_user_id", managerUserID)) h.dashboardCache.set(cacheKey, nil, now, managerDashboardFailureCacheTTL) return nil } ownerIDs := make([]int64, 0, len(teamResp.GetAgencies())) seen := map[int64]bool{} for _, agency := range teamResp.GetAgencies() { if id := agency.GetOwnerUserId(); id > 0 && !seen[id] { seen[id] = true ownerIDs = append(ownerIDs, id) } } // 工资周期与结算保持同一口径:UTC 月。上月用月初回退一个月计算,避免 AddDate 在月末产生进位漂移。 monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) thisCycle := monthStart.Format("2006-01") lastCycle := monthStart.AddDate(0, -1, 0).Format("2006-01") statsResp, err := h.walletClient.GetTeamHostSalaryStats(request.Context(), &walletv1.GetTeamHostSalaryStatsRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), AgencyOwnerUserIds: ownerIDs, CycleKeys: []string{thisCycle, lastCycle}, NowMs: now.UnixMilli(), }) if err != nil { logx.Warn(request.Context(), "manager_team_salary_stats_failed", slog.Int64("manager_user_id", managerUserID)) h.dashboardCache.set(cacheKey, nil, now, managerDashboardFailureCacheTTL) return nil } statsByCycle := map[string]*walletv1.TeamHostSalaryCycleStat{} for _, stat := range statsResp.GetStats() { statsByCycle[stat.GetCycleKey()] = stat } thisStat := statsByCycle[thisCycle] lastStat := statsByCycle[lastCycle] dashboard := map[string]any{ "month_label": monthStart.Format("Jan 2006"), "cycle_key": thisCycle, "last_cycle_key": lastCycle, // 金额下发美元数值(分转元),H5 formatMoney 负责千分位和货币符号。 "this_month_salary": usdMinorToDollars(thisStat.GetEstimatedHostSalaryUsdMinor()), "this_month_salary_usd_minor": thisStat.GetEstimatedHostSalaryUsdMinor(), "last_month_salary": usdMinorToDollars(lastStat.GetEstimatedHostSalaryUsdMinor()), "last_month_salary_usd_minor": lastStat.GetEstimatedHostSalaryUsdMinor(), "active_hosts": thisStat.GetActiveHostCount(), "active_hosts_last_month": lastStat.GetActiveHostCount(), // growth 直接下发带符号百分比字符串,避免 H5 formatPercent 对 0~1 数值的“小数当比例”歧义。 "growth": formatGrowthPercent(thisStat.GetEstimatedHostSalaryUsdMinor(), lastStat.GetEstimatedHostSalaryUsdMinor()), "team_bd_leaders": teamResp.GetTotalBdLeaders(), "team_bds": teamResp.GetTotalBds(), "team_agencies": len(teamResp.GetAgencies()), // truncated 时统计口径不完整(团队 Agency 超过单次拉取上限),透传给前端而不是静默当全量。 "team_stats_truncated": teamResp.GetTruncated(), "updated_at_ms": now.UnixMilli(), } h.dashboardCache.set(cacheKey, dashboard, now, managerDashboardCacheTTL) return dashboard } func usdMinorToDollars(minor int64) float64 { return float64(minor) / 100 } // formatGrowthPercent 计算本月对上月的环比:上月为 0 时有收入记 +100%,无收入记 0%。 func formatGrowthPercent(current int64, previous int64) string { if previous <= 0 { if current > 0 { return "+100%" } return "0%" } percent := (float64(current) - float64(previous)) / float64(previous) * 100 rounded := int64(percent) if percent > 0 { rounded = int64(percent + 0.5) return fmt.Sprintf("+%d%%", rounded) } if percent < 0 { rounded = int64(percent - 0.5) } return fmt.Sprintf("%d%%", rounded) }