385 lines
17 KiB
JavaScript
385 lines
17 KiB
JavaScript
import Skeleton from "@mui/material/Skeleton";
|
||
import { Button } from "@/shared/ui/Button.jsx";
|
||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||
import { formatMultiplierFromPPM, formatNumber, formatPercentFromPPM } from "../format.js";
|
||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||
|
||
export function UserProfileDetail({ detailState, onLoadMore, onRetry, profile }) {
|
||
return (
|
||
<div className="admin-row ops-user-profile-detail-row">
|
||
<div className="ops-user-profile-detail-cell">
|
||
{!detailState || detailState.loading ? <DetailSkeleton /> : null}
|
||
{detailState?.error ? (
|
||
<div className="ops-user-profile-detail-error" role="alert">
|
||
<span>{detailState.error}</span>
|
||
<Button size="small" onClick={onRetry}>
|
||
重试
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
{detailState?.data && !detailState.loading ? (
|
||
<ProfileDetailContent
|
||
data={detailState.data}
|
||
fallbackProfile={profile}
|
||
loadingMore={detailState.loadingMore}
|
||
onLoadMore={onLoadMore}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProfileDetailContent({ data, fallbackProfile, loadingMore, onLoadMore }) {
|
||
const profile = data.profile?.key ? data.profile : fallbackProfile;
|
||
const distribution = [...(data.multiplierDistribution || [])].sort(
|
||
(left, right) => right.multiplierPpm - left.multiplierPpm,
|
||
);
|
||
const jackpotHits = data.jackpotHits || [];
|
||
|
||
return (
|
||
<div className="ops-user-profile-detail">
|
||
<section className="ops-user-profile-detail__summary" aria-label="用户控制状态详情">
|
||
<DetailMetric label="最近开奖阶段" value={stageLabel(profile.stage)} />
|
||
<DetailMetric label="连续未中奖" value={`${formatNumber(profile.lossStreak)} 次`} />
|
||
<DetailMetric label="等价抽数" value={formatNumber(profile.equivalentDraws)} />
|
||
<DetailMetric
|
||
label="保底状态"
|
||
value={
|
||
profile.guaranteeDrawsRemaining > 0
|
||
? `还差 ${formatNumber(profile.guaranteeDrawsRemaining)} 抽`
|
||
: "保护剩余 0 抽"
|
||
}
|
||
/>
|
||
<DetailMetric
|
||
label="24 小时降权"
|
||
value={
|
||
profile.downweightActive
|
||
? `已触发,普通中奖保留 ${formatPercentFromPPM(profile.user24hOrdinaryWinFactorPpm)}`
|
||
: "未触发"
|
||
}
|
||
/>
|
||
<DetailMetric label="待兑消费大奖" value={`${formatNumber(profile.pendingCumulativeSpendTokens)} 次`} />
|
||
<DetailMetric label="累计有效抽数" value={`${formatNumber(profile.paidDraws)} 抽`} />
|
||
<DetailMetric label="基础返奖" value={`${formatNumber(profile.baseRewardCoins)} 金币`} />
|
||
<DetailMetric label="保底命中" value={`${formatNumber(profile.guaranteeHitCount)} 次`} />
|
||
<DetailMetric
|
||
label="历史最高单次"
|
||
value={`${formatMultiplierFromPPM(profile.maxMultiplierPpm)} / ${formatNumber(profile.maxRewardCoins)} 金币`}
|
||
/>
|
||
<DetailMetric label="首次抽奖" value={<TimeText value={profile.firstDrawAtMs} />} />
|
||
<DetailMetric
|
||
label="处理结果"
|
||
value={`已发 ${formatNumber(profile.grantedDrawCount)} / 待处理 ${formatNumber(
|
||
profile.pendingDrawCount,
|
||
)} / 失败 ${formatNumber(profile.failedDrawCount)}`}
|
||
/>
|
||
</section>
|
||
|
||
<section className="ops-user-profile-detail__section">
|
||
<header>
|
||
<div>
|
||
<h3>倍率命中分布</h3>
|
||
<p>普通随机和两条大奖通道分列,倍率相同时也不会混为一次命中。</p>
|
||
</div>
|
||
<span>{distribution.length} 个倍率</span>
|
||
</header>
|
||
{distribution.length ? (
|
||
<div className="ops-profile-multiplier-table">
|
||
<div className="ops-profile-multiplier-row is-head">
|
||
<span>倍率</span>
|
||
<span>未中奖</span>
|
||
<span>普通命中</span>
|
||
<span>RTP 补偿大奖</span>
|
||
<span>累计消费大奖</span>
|
||
<span>合计命中</span>
|
||
<span>返奖金币</span>
|
||
</div>
|
||
{distribution.map((metric) => (
|
||
<div className="ops-profile-multiplier-row" key={metric.multiplierPpm}>
|
||
<strong>{formatMultiplierFromPPM(metric.multiplierPpm)}</strong>
|
||
<span>{formatNumber(metric.zeroDrawCount)}</span>
|
||
<span>{formatNumber(metric.ordinaryHitCount)}</span>
|
||
<span>{formatNumber(metric.rtpCompensationJackpotCount)}</span>
|
||
<span>{formatNumber(metric.cumulativeSpendJackpotCount)}</span>
|
||
<span>{formatNumber(metric.hitCount)}</span>
|
||
<span>{formatNumber(metric.payoutCoins)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="ops-user-profile-detail__empty">当前没有倍率命中数据</div>
|
||
)}
|
||
</section>
|
||
|
||
<section className="ops-user-profile-detail__section">
|
||
<header>
|
||
<div>
|
||
<h3>大奖命中依据</h3>
|
||
<p>逐次展示走哪条大奖通道、命中时的关键门槛和实际判断结果。</p>
|
||
</div>
|
||
<span>
|
||
展示 {jackpotHits.length} / 共 {formatNumber(data.jackpotHitTotal || jackpotHits.length)} 次
|
||
</span>
|
||
</header>
|
||
{jackpotHits.length ? (
|
||
<>
|
||
<div className="ops-profile-jackpot-list">
|
||
{jackpotHits.map((hit, index) => (
|
||
<JackpotHit
|
||
hit={hit}
|
||
key={hit.eventKey || `${hit.drawId}:${hit.requestId}:${hit.occurredAtMs}:${index}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
{data.jackpotHasMore && data.nextJackpotCursor ? (
|
||
<div className="ops-profile-jackpot-more">
|
||
<Button disabled={loadingMore} onClick={onLoadMore} size="small">
|
||
{loadingMore ? "加载中…" : "加载更早的大奖记录"}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</>
|
||
) : (
|
||
<div className="ops-user-profile-detail__empty">该用户当前没有大奖命中记录</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DetailMetric({ label, value }) {
|
||
return (
|
||
<dl>
|
||
<dt>{label}</dt>
|
||
<dd>{value || "-"}</dd>
|
||
</dl>
|
||
);
|
||
}
|
||
|
||
function JackpotHit({ hit }) {
|
||
const conditions = flattenConditions(hit.conditions);
|
||
const mechanism = mechanismLabel(hit.mechanism);
|
||
return (
|
||
<article className="ops-profile-jackpot-hit">
|
||
<div className="ops-profile-jackpot-hit__head">
|
||
<div>
|
||
<OpsStatusBadge label={mechanism} tone={mechanismTone(hit.mechanism)} />
|
||
<strong>{formatMultiplierFromPPM(hit.multiplierPpm)}</strong>
|
||
<span>返奖 {formatNumber(hit.payoutCoins)} 金币</span>
|
||
</div>
|
||
<TimeText value={hit.occurredAtMs} />
|
||
</div>
|
||
<div className="ops-profile-jackpot-hit__reason">
|
||
<strong>为什么命中</strong>
|
||
<span>{reasonLabel(hit.reason, hit.reasonCode, hit.mechanism)}</span>
|
||
</div>
|
||
{conditions.length ? (
|
||
<dl className="ops-profile-jackpot-conditions">
|
||
{conditions.map((condition) => (
|
||
<div key={condition.key}>
|
||
<dt>{condition.value?.label || conditionLabel(condition.key)}</dt>
|
||
<dd>{conditionValue(condition.key, condition.value)}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
) : (
|
||
<div className="ops-profile-jackpot-hit__no-conditions">该历史记录没有保存更多判断快照</div>
|
||
)}
|
||
<footer>
|
||
<span>阶段:{stageLabel(hit.stage)}</span>
|
||
<span>规则:v{hit.ruleVersion || "-"}</span>
|
||
<span>消耗:{formatNumber(hit.wagerCoins)} 金币</span>
|
||
{hit.tierId ? <span>奖档:{hit.tierId}</span> : null}
|
||
{hit.drawId ? <span>抽奖单:{hit.drawId}</span> : null}
|
||
{hit.requestId ? <span>请求号:{hit.requestId}</span> : null}
|
||
</footer>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function DetailSkeleton() {
|
||
return (
|
||
<div className="ops-user-profile-detail-skeleton" aria-hidden="true">
|
||
<div>
|
||
{Array.from({ length: 6 }, (_, index) => (
|
||
<Skeleton animation="wave" height={42} key={index} variant="rounded" />
|
||
))}
|
||
</div>
|
||
<Skeleton animation="wave" height={112} variant="rounded" />
|
||
<Skeleton animation="wave" height={156} variant="rounded" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function flattenConditions(value, prefix = "") {
|
||
if (Array.isArray(value)) {
|
||
return value
|
||
.filter((condition) => condition && !isInternalIdentityKey(condition.name))
|
||
.map((condition, index) => ({
|
||
key: condition.name || `condition_${index + 1}`,
|
||
value: condition,
|
||
}));
|
||
}
|
||
if (!value || typeof value !== "object") {
|
||
return [];
|
||
}
|
||
return Object.entries(value).flatMap(([key, child]) => {
|
||
const path = prefix ? `${prefix}.${key}` : key;
|
||
// stable hash、strategy user key 等仅用于 owner 内部关联,尤其外部用户不能从诊断快照泄露。
|
||
if (isInternalIdentityKey(path)) {
|
||
return [];
|
||
}
|
||
if (child && typeof child === "object" && !Array.isArray(child)) {
|
||
return flattenConditions(child, path);
|
||
}
|
||
return [{ key: path, value: Array.isArray(child) ? child.join("、") : child }];
|
||
});
|
||
}
|
||
|
||
function isInternalIdentityKey(key) {
|
||
const normalized = String(key).toLowerCase();
|
||
return ["strategy_user_id", "user_key", "stable_user", "hashed_user", "user_hash"].some((token) =>
|
||
normalized.includes(token),
|
||
);
|
||
}
|
||
|
||
function mechanismLabel(mechanism) {
|
||
const normalized = String(mechanism || "").toLowerCase();
|
||
if (["rtp_compensation", "mechanism_1", "jackpot_mechanism_1", "compensation"].includes(normalized)) {
|
||
return "RTP 补偿大奖";
|
||
}
|
||
if (["cumulative_spend", "spend_milestone", "mechanism_2", "jackpot_mechanism_2", "spend"].includes(normalized)) {
|
||
return "累计消费大奖";
|
||
}
|
||
return mechanism || "大奖";
|
||
}
|
||
|
||
function mechanismTone(mechanism) {
|
||
return mechanismLabel(mechanism) === "累计消费大奖" ? "warning" : "succeeded";
|
||
}
|
||
|
||
function reasonLabel(reason, reasonCode, mechanism) {
|
||
const normalized = String(reason || "").trim();
|
||
const known = {
|
||
cumulative_spend_token_redeemed: "累计消费达到门槛后生成大奖机会,本抽满足支付条件并完成兑付。",
|
||
jackpot_token_redeemed: "此前生成的大奖机会在本抽满足支付条件并完成兑付。",
|
||
milestone_token_jackpot: "用户累计消费已获得一次大奖机会,且奖池余额足够支付,因此触发累计消费大奖。",
|
||
rtp_compensation_eligible: "用户和全局 RTP 等补偿条件均满足,本抽进入 RTP 补偿大奖通道并命中。",
|
||
rtp_compensation_jackpot: "用户与奖池均满足补偿条件,且奖池余额足够支付,因此触发 RTP 补偿大奖。",
|
||
rtp_compensation_token_redeemed: "RTP 补偿机会已生成,本抽满足奖池支付条件并完成兑付。",
|
||
};
|
||
const normalizedCode = String(reasonCode || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
if (known[normalizedCode] || known[normalized.toLowerCase()] || normalized) {
|
||
return known[normalizedCode] || known[normalized.toLowerCase()] || normalized;
|
||
}
|
||
return mechanismLabel(mechanism) === "累计消费大奖"
|
||
? "累计消费达到规则门槛,生成的大奖机会在本抽完成兑付。"
|
||
: "用户和全局 RTP、奖池水位等门槛均允许,RTP 补偿机会在本抽完成兑付。";
|
||
}
|
||
|
||
function conditionLabel(key) {
|
||
const normalized = String(key).split(".").pop()?.toLowerCase() || key;
|
||
const labels = {
|
||
pool_balance_coins: "命中前奖池余额",
|
||
pool_payable: "奖池能否支付",
|
||
global_rtp_ppm: "命中前全局 RTP",
|
||
global_rtp_max_ppm: "全局 RTP 上限",
|
||
user_24h_rtp_ppm: "命中前用户 24 小时 RTP",
|
||
user_48h_rtp_ppm: "命中前用户 48 小时 RTP",
|
||
user_48h_rtp_max_ppm: "用户 48 小时 RTP 上限",
|
||
user_round_rtp_ppm: "命中前用户本轮 RTP",
|
||
user_round_rtp_max_ppm: "用户本轮 RTP 上限",
|
||
daily_hit_count: "当日已中大奖次数",
|
||
daily_hit_limit: "单日大奖次数上限",
|
||
cumulative_spend_coins: "累计消费金币",
|
||
cumulative_spend_threshold_coins: "累计消费门槛",
|
||
pending_token_count: "命中前待兑大奖机会",
|
||
multiplier_ppm: "命中倍率",
|
||
stage: "命中阶段",
|
||
eligible: "是否满足条件",
|
||
allowed: "是否允许发放",
|
||
};
|
||
return labels[normalized] || humanizeConditionKey(normalized);
|
||
}
|
||
|
||
function conditionValue(key, value) {
|
||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||
const parts = [];
|
||
const hasFriendlyValues = Boolean(value.actual || value.threshold);
|
||
if (hasFriendlyValues) {
|
||
parts.push(
|
||
[value.actual ? `实际 ${value.actual}` : "", operatorLabel(value.operator), value.threshold || ""]
|
||
.filter(Boolean)
|
||
.join(" "),
|
||
);
|
||
}
|
||
if (!hasFriendlyValues && value.denominator > 0) {
|
||
parts.push(`${formatNumber(value.numerator)} / ${formatNumber(value.denominator)}`);
|
||
}
|
||
if (!hasFriendlyValues && (value.ratioPpm || value.limitPpm)) {
|
||
parts.push(`实际 ${formatPercentFromPPM(value.ratioPpm)} / 门槛 ${formatPercentFromPPM(value.limitPpm)}`);
|
||
}
|
||
if (value.factorPpm) {
|
||
parts.push(`权重 ${formatPercentFromPPM(value.factorPpm)}`);
|
||
}
|
||
parts.push(value.passed ? "通过" : "未通过");
|
||
if (value.reason) {
|
||
parts.push(value.reason);
|
||
}
|
||
return parts.join(" · ");
|
||
}
|
||
const normalized = String(key).toLowerCase();
|
||
if (typeof value === "boolean") {
|
||
return value ? "是" : "否";
|
||
}
|
||
if (value === null || value === undefined || value === "") {
|
||
return "-";
|
||
}
|
||
if (normalized.endsWith("_ppm") || normalized.includes("rtp_ppm")) {
|
||
return formatPercentFromPPM(value);
|
||
}
|
||
if (normalized.endsWith("_coins") || normalized.includes("count") || normalized.includes("limit")) {
|
||
return formatNumber(value);
|
||
}
|
||
return String(value);
|
||
}
|
||
|
||
function operatorLabel(value) {
|
||
const labels = {
|
||
eq: "等于",
|
||
gt: "高于",
|
||
gte: "不低于",
|
||
lt: "低于",
|
||
lte: "不高于",
|
||
};
|
||
const normalized = String(value || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
return labels[normalized] || value || "";
|
||
}
|
||
|
||
function humanizeConditionKey(key) {
|
||
return String(key)
|
||
.replaceAll("_", " ")
|
||
.replace(/\bRtp\b/gi, "RTP")
|
||
.replace(/\bPpm\b/gi, "百分比")
|
||
.replace(/\bCoins\b/gi, "金币");
|
||
}
|
||
|
||
function stageLabel(stage) {
|
||
const normalized = String(stage || "").toLowerCase();
|
||
if (["novice", "newbie", "new"].includes(normalized)) {
|
||
return "新手阶段";
|
||
}
|
||
if (["normal", "ordinary"].includes(normalized)) {
|
||
return "普通阶段";
|
||
}
|
||
if (["advanced", "high", "high_value", "premium"].includes(normalized)) {
|
||
return "高价阶段";
|
||
}
|
||
return stage || "未记录";
|
||
}
|