hyapp-admin-platform/databi/src/social/useSocialBiData.js
2026-07-11 02:26:23 +08:00

365 lines
13 KiB
JavaScript
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.

// 社交 BI v2 的数据层:负责 master/overview/funnel/kpi 接口的拉取与派生行,
// 视图不直接调 API只消费这里的产物原始 snake_case 指标行 + 维度字段)。
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchSocialBiFunnel, fetchSocialBiKpi, fetchSocialBiMaster, fetchSocialBiOverview, fetchSocialBiRequirements } from "../api.js";
import { DEFAULT_TIME_ZONE, rangeEndMs, rangeStartMs } from "../utils/time.js";
import { ALL, appSelectionMatches, regionSelectionMatches, resolveDateRange } from "./state.js";
export const SOCIAL_BI_TZ = DEFAULT_TIME_ZONE;
export const SOCIAL_BI_FUNNEL_APP_CODES = ["lalu", "huwaa", "fami"];
export function useSocialBiData(filters, { includeFunnel = false } = {}) {
const [master, setMaster] = useState(null);
// 数据请求等 master 结果落地后再发:否则会先用兜底 App 列表打一轮master 到达后立刻重打一轮(整轮浪费)。
const [isMasterSettled, setIsMasterSettled] = useState(false);
const [overview, setOverview] = useState(null);
const [funnel, setFunnel] = useState(null);
const [kpi, setKpi] = useState(null);
const [requirements, setRequirements] = useState(null);
const [requirementsError, setRequirementsError] = useState("");
const [isRequirementsLoading, setIsRequirementsLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [errors, setErrors] = useState([]);
const [refreshToken, setRefreshToken] = useState(0);
const requestSeq = useRef(0);
const requirementsSeq = useRef(0);
useEffect(() => {
let ignore = false;
fetchSocialBiMaster()
.then((data) => {
if (!ignore && data) {
setMaster(data);
}
})
.catch(() => {
if (!ignore) {
setMaster(null);
}
})
.finally(() => {
if (!ignore) {
setIsMasterSettled(true);
}
});
return () => {
ignore = true;
};
}, []);
// range 只依赖日期相关筛选:切换粒度/区域/App 多选不应触发接口重拉(那些是纯前端派生)。
const { customEnd, customStart, preset } = filters;
const range = useMemo(
() => resolveDateRange({ customEnd, customStart, preset }, SOCIAL_BI_TZ),
[customEnd, customStart, preset]
);
const appCodes = useMemo(() => {
const available = (master?.apps || []).map((app) => app.app_code);
if (!filters.apps.length || filters.apps.includes(ALL)) {
return available;
}
return filters.apps.filter((appCode) => !available.length || available.includes(appCode));
}, [filters.apps, master]);
const funnelAppCodes = useMemo(() => {
const available = (master?.apps || []).map((app) => app.app_code).filter((appCode) => SOCIAL_BI_FUNNEL_APP_CODES.includes(appCode));
if (!filters.apps.length || filters.apps.includes(ALL)) {
return available;
}
const selected = filters.apps.filter((appCode) => available.includes(appCode));
return selected.length ? selected : available;
}, [filters.apps, master]);
useEffect(() => {
if (!isMasterSettled) {
return;
}
const sequence = ++requestSeq.current;
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
setIsLoading(true);
// 漏斗会执行 cohort 聚合,只在用户进入漏斗视图时请求;其他视图不能为不可见数据承担查询成本或错误提示。
const funnelRequest = includeFunnel
? fetchSocialBiFunnel({ appCodes: funnelAppCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
: Promise.resolve(null);
Promise.allSettled([
fetchSocialBiOverview({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ }),
funnelRequest,
fetchSocialBiKpi({ appCodes, endMs, startMs, statTz: SOCIAL_BI_TZ })
]).then(([overviewResult, funnelResult, kpiResult]) => {
if (sequence !== requestSeq.current) {
return;
}
const nextErrors = [];
if (overviewResult.status === "fulfilled") {
setOverview(overviewResult.value);
(overviewResult.value?.apps || []).forEach((app) => {
if (app.error) {
nextErrors.push(`${app.app_name || app.app_code}: ${app.error}`);
}
});
} else {
setOverview(null);
nextErrors.push(`统计数据加载失败: ${overviewResult.reason?.message || "未知错误"}`);
}
if (!includeFunnel) {
setFunnel(null);
} else if (funnelResult.status === "fulfilled") {
setFunnel(funnelResult.value);
(funnelResult.value?.apps || []).forEach((app) => {
if (app.error) {
nextErrors.push(`${app.app_name || app.app_code}: ${app.error}`);
}
});
} else {
setFunnel(null);
nextErrors.push(`埋点漏斗加载失败: ${funnelResult.reason?.message || "未知错误"}`);
}
if (kpiResult.status === "fulfilled") {
setKpi(kpiResult.value);
Object.entries(kpiResult.value?.app_errors || {}).forEach(([appCode, message]) => {
nextErrors.push(`${appCode}: ${message}`);
});
} else {
setKpi(null);
nextErrors.push(`运营充值数据加载失败: ${kpiResult.reason?.message || "未知错误"}`);
}
setErrors(nextErrors);
setIsLoading(false);
});
}, [appCodes, funnelAppCodes, includeFunnel, isMasterSettled, range, refreshToken]);
const loadRequirements = useCallback(
({ newUserType, payerType, section, userRole } = {}) => {
if (!isMasterSettled) {
return Promise.resolve(null);
}
const sequence = ++requirementsSeq.current;
const startMs = rangeStartMs(range, SOCIAL_BI_TZ);
const endMs = rangeEndMs(range, SOCIAL_BI_TZ);
const requestScopes = buildRequirementRequestScopes(appCodes, filters.regions);
setIsRequirementsLoading(true);
setRequirementsError("");
setRequirements(null);
if (!requestScopes.length) {
const emptyResult = { access: overview?.access || null, apps: [] };
setRequirements(emptyResult);
setIsRequirementsLoading(false);
return Promise.resolve(emptyResult);
}
// 数据需求按表格页局部 section/角色/付费筛选请求;全局 App/日期仍复用 Social BI 顶栏口径。
return Promise.all(requestScopes.map((scope) => fetchSocialBiRequirements({
...scope,
endMs,
newUserType,
payerType,
section,
startMs,
statTz: SOCIAL_BI_TZ,
userRole
})))
.then((results) => {
if (sequence !== requirementsSeq.current) {
return null;
}
const data = mergeRequirementResponses(results);
setRequirements(data);
return data;
})
.catch((error) => {
if (sequence === requirementsSeq.current) {
setRequirements(null);
setRequirementsError(error?.message || "数据需求加载失败");
}
return null;
})
.finally(() => {
if (sequence === requirementsSeq.current) {
setIsRequirementsLoading(false);
}
});
},
[appCodes, filters.regions, isMasterSettled, overview?.access, range]
);
const refresh = useCallback(() => {
setRefreshToken((current) => current + 1);
}, []);
const derived = useMemo(() => deriveRows(overview, filters, master), [filters, master, overview]);
return {
appCodes,
derived,
errors,
funnel,
funnelAppCodes,
isLoading,
isRequirementsLoading,
kpi,
loadRequirements,
master,
overview,
range,
refreshToken,
requirements,
requirementsError,
refresh
};
}
// deriveRows 输出四类行,全部带维度字段 + 原始指标字段:
// appTotals/appDailyApp 粒度、regionTotals/regionDaily大区粒度已按顶栏区域选择过滤
// countryTotals国家粒度已按国家→区域目录归一 region_id 并跟随区域筛选。restricted=true 表示该 App 已被数据范围裁剪。
function deriveRows(overview, filters, master) {
const appTotals = [];
const appDaily = [];
const regionTotals = [];
const regionDaily = [];
const countryTotals = [];
const appErrors = [];
const regionByCountry = buildRegionByCountryIndex(master);
(overview?.apps || []).forEach((app) => {
if (!appSelectionMatches(filters.apps, app.app_code)) {
return;
}
const context = {
app_code: app.app_code,
app_name: app.app_name || app.app_code,
kind: app.kind,
restricted: Boolean(app.restricted)
};
if (app.error) {
appErrors.push({ ...context, error: app.error });
return;
}
appTotals.push({ ...context, ...(app.total || {}) });
(app.daily_series || []).forEach((row) => {
appDaily.push({ ...context, ...row, stat_day: row.stat_day || row.label });
});
(app.region_breakdown || []).forEach((row) => {
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
return;
}
regionTotals.push({
...context,
...row,
region_id: Number(row.region_id ?? 0),
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`
});
});
(app.daily_region_breakdown || []).forEach((row) => {
if (!regionSelectionMatches(filters.regions, app.app_code, Number(row.region_id ?? 0))) {
return;
}
regionDaily.push({
...context,
...row,
region_id: Number(row.region_id ?? 0),
region_name: row.region_name || row.region_code || `区域 ${row.region_id}`,
stat_day: row.stat_day || row.label
});
});
(app.country_breakdown || []).forEach((row) => {
const countryCode = row.country_code || String(row.country_id || "");
// legacy 国家行不带 region_id用区域目录countries 列表)归一,保证下钻与区域筛选口径一致。
let regionId = Number(row.region_id ?? 0);
let regionName = row.region_name || "";
if (!regionId) {
const region = regionByCountry.get(`${app.app_code}:${String(countryCode).toUpperCase()}`);
if (region) {
regionId = region.region_id;
regionName = region.region_name || region.region_code || regionName;
}
}
if (!regionSelectionMatches(filters.regions, app.app_code, regionId)) {
return;
}
countryTotals.push({
...context,
...row,
country_code: countryCode,
country_name: row.country_name || row.country || row.country_code || `国家 ${row.country_id}`,
region_id: regionId,
region_name: regionName
});
});
});
// 顶栏选了区域时App 合计/日行切换成所选区域行的聚合视角由各视图用 regionScoped 判断。
const regionScoped = Boolean(filters.regions.length && !filters.regions.includes(ALL));
return { appDaily, appErrors, appTotals, countryTotals, regionDaily, regionScoped, regionTotals };
}
function buildRegionByCountryIndex(master) {
const index = new Map();
(master?.regions || []).forEach((region) => {
(region.countries || []).forEach((countryCode) => {
const key = `${region.app_code}:${String(countryCode).toUpperCase()}`;
if (!index.has(key)) {
index.set(key, region);
}
});
});
return index;
}
function buildRequirementRequestScopes(appCodes, regions) {
const selectedApps = new Set((appCodes || []).filter(Boolean));
if (!regions?.length || regions.includes(ALL)) {
return [{ appCodes: [...selectedApps] }];
}
const byApp = new Map();
regions.forEach((selection) => {
const value = String(selection || "").trim();
if (!value || value === ALL) {
return;
}
if (value.startsWith("app:")) {
const appCode = value.slice(4).trim().toLowerCase();
if (appCode) {
byApp.set(appCode, null);
}
return;
}
const [rawAppCode, rawRegionID] = value.split(":");
const appCode = String(rawAppCode || "").trim().toLowerCase();
const regionID = Number(rawRegionID);
if (!appCode || !Number.isFinite(regionID) || regionID <= 0 || byApp.get(appCode) === null) {
return;
}
if (!byApp.has(appCode)) {
byApp.set(appCode, new Set());
}
byApp.get(appCode).add(regionID);
});
const scopes = [];
byApp.forEach((regionIDs, appCode) => {
if (selectedApps.size && !selectedApps.has(appCode)) {
return;
}
if (regionIDs === null) {
scopes.push({ appCodes: [appCode] });
return;
}
const ids = [...regionIDs].sort((left, right) => left - right);
if (ids.length === 1) {
scopes.push({ appCodes: [appCode], regionId: ids[0] });
} else if (ids.length > 1) {
scopes.push({ appCodes: [appCode], regionIds: ids });
}
});
return scopes;
}
function mergeRequirementResponses(results) {
const merged = { access: results.find((item) => item?.access)?.access || null, apps: [] };
results.forEach((item) => {
if (Array.isArray(item?.apps)) {
merged.apps.push(...item.apps);
}
});
return merged;
}