179 lines
7.1 KiB
Go
179 lines
7.1 KiB
Go
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)
|
||
}
|