2026-07-09 13:02:49 +08:00

179 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}