diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json
index 5ed8487..5856fa2 100644
--- a/contracts/admin-openapi.json
+++ b/contracts/admin-openapi.json
@@ -366,6 +366,30 @@
"x-permissions": ["lucky-gift:view"]
}
},
+ "/admin/ops-center/lucky-gifts/user-profiles": {
+ "get": {
+ "operationId": "listLuckyGiftUserProfiles",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "lucky-gift:view",
+ "x-permissions": ["lucky-gift:view"]
+ }
+ },
+ "/admin/ops-center/lucky-gifts/user-profile": {
+ "get": {
+ "operationId": "getLuckyGiftUserProfile",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "lucky-gift:view",
+ "x-permissions": ["lucky-gift:view"]
+ }
+ },
"/admin/ops-center/lucky-gifts/summary": {
"get": {
"operationId": "getLuckyGiftDrawSummary",
diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx
index 54e461e..8ee1da6 100644
--- a/ops-center/src/OpsCenterApp.jsx
+++ b/ops-center/src/OpsCenterApp.jsx
@@ -2,8 +2,9 @@ import AppsOutlined from "@mui/icons-material/AppsOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
+import PeopleAltOutlined from "@mui/icons-material/PeopleAltOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { PERMISSIONS } from "@/app/permissions";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
@@ -19,9 +20,15 @@ import { OpsShell } from "./components/OpsShell.jsx";
import { OverviewView } from "./components/OverviewView.jsx";
import { formatNumber } from "./format.js";
+// 用户画像包含高密度表格和大奖 trace,仅进入该视图才加载代码与数据,避免拖慢配置页首屏。
+const UserProfilesView = lazy(() =>
+ import("./components/UserProfilesView.jsx").then((module) => ({ default: module.UserProfilesView })),
+);
+
const views = [
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
+ { icon: PeopleAltOutlined, key: "profiles", label: "用户画像" },
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
{ icon: AppsOutlined, key: "apps", label: "应用列表" },
];
@@ -48,6 +55,7 @@ export function OpsCenterApp({ can = denyPermission }) {
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
const [dialog, setDialog] = useState(null);
const [poolDialog, setPoolDialog] = useState(null);
+ const [profileRefreshKey, setProfileRefreshKey] = useState(0);
const [adjustingPool, setAdjustingPool] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const { showToast } = useToast();
@@ -75,6 +83,14 @@ export function OpsCenterApp({ can = denyPermission }) {
loadDashboard();
}, [loadDashboard]);
+ const refreshCurrentView = useCallback(() => {
+ if (activeView === "profiles") {
+ setProfileRefreshKey((current) => current + 1);
+ return;
+ }
+ loadDashboard();
+ }, [activeView, loadDashboard]);
+
const { data } = state;
const appOptions = useMemo(
@@ -201,7 +217,7 @@ export function OpsCenterApp({ can = denyPermission }) {
}
- onClick={loadDashboard}
+ onClick={refreshCurrentView}
>
刷新
@@ -246,6 +262,16 @@ export function OpsCenterApp({ can = denyPermission }) {
{activeView === "draws" ? (
) : null}
+ {activeView === "profiles" ? (
+ }>
+
+
+ ) : null}
{activeView === "apps" ? : null}
{dialog ? (
diff --git a/ops-center/src/api.js b/ops-center/src/api.js
index 262c320..4b0cad4 100644
--- a/ops-center/src/api.js
+++ b/ops-center/src/api.js
@@ -77,6 +77,99 @@ export async function fetchLuckyGiftDraws({
};
}
+export async function fetchLuckyGiftUserProfiles({
+ appCode,
+ cursor = "",
+ identityType = "all",
+ keyword = "",
+ page = 1,
+ pageSize = 20,
+ poolId,
+ sortBy = "last_draw_at",
+ sortDirection = "desc",
+ stage = "",
+ status = "",
+} = {}) {
+ const operationPath = apiEndpointPath(API_OPERATIONS.listLuckyGiftUserProfiles);
+ const payload = await opsRequest(opsRelativePath(operationPath), {
+ query: {
+ app_code: String(appCode || "")
+ .trim()
+ .toLowerCase(),
+ cursor: String(cursor || "").trim(),
+ identity_type: normalizeIdentityType(identityType),
+ page,
+ page_size: pageSize,
+ pool_id: String(poolId || "").trim(),
+ sort_by: String(sortBy || "last_draw_at").trim(),
+ sort_direction: String(sortDirection || "desc").trim(),
+ stage: String(stage || "").trim(),
+ status: String(status || "").trim(),
+ user_keyword: String(keyword || "").trim(),
+ },
+ });
+ const snapshotAtMs = numberValue(payload.snapshot_at_ms ?? payload.snapshotAtMs);
+ return {
+ items: normalizeProfileItems(payload).map((item) => normalizeLuckyGiftUserProfile(item, snapshotAtMs)),
+ page: numberValue(payload.page) || page,
+ pageSize: numberValue(payload.page_size ?? payload.pageSize) || pageSize,
+ nextCursor: String(payload.next_cursor ?? payload.nextCursor ?? "").trim(),
+ hasMore: Boolean(payload.has_more ?? payload.hasMore),
+ snapshotAtMs,
+ total: numberValue(payload.total),
+ };
+}
+
+export async function fetchLuckyGiftUserProfile({
+ appCode,
+ identityType,
+ externalUserId,
+ jackpotCursor = "",
+ jackpotPage = 1,
+ jackpotPageSize = 20,
+ poolId,
+ userId,
+} = {}) {
+ const normalizedIdentityType = normalizeIdentityType(identityType);
+ const operationPath = apiEndpointPath(API_OPERATIONS.getLuckyGiftUserProfile);
+ const payload = await opsRequest(opsRelativePath(operationPath), {
+ query: {
+ app_code: String(appCode || "")
+ .trim()
+ .toLowerCase(),
+ // 外部稳定用户键仅用于 owner 内部关联,前端查询和展示始终使用接入方原始 external_user_id。
+ external_user_id: normalizedIdentityType === "external" ? String(externalUserId || "").trim() : "",
+ identity_type: normalizedIdentityType,
+ internal_user_id: normalizedIdentityType === "internal" ? String(userId || "").trim() : "",
+ jackpot_page: jackpotPage,
+ jackpot_page_size: jackpotPageSize,
+ jackpot_cursor: String(jackpotCursor || "").trim(),
+ pool_id: String(poolId || "").trim(),
+ },
+ });
+ const profileSource = payload.profile || payload.item || payload;
+ return {
+ jackpotHits: normalizeItems(payload.jackpot_hits ?? payload.jackpotHits).map(normalizeLuckyGiftJackpotHit),
+ jackpotHitTotal: numberValue(payload.jackpot_hit_total ?? payload.jackpotHitTotal),
+ jackpotPage: numberValue(payload.jackpot_page ?? payload.jackpotPage) || jackpotPage,
+ jackpotPageSize: numberValue(payload.jackpot_page_size ?? payload.jackpotPageSize) || jackpotPageSize,
+ nextJackpotCursor: String(payload.next_jackpot_cursor ?? payload.nextJackpotCursor ?? "").trim(),
+ jackpotHasMore: Boolean(payload.jackpot_has_more ?? payload.jackpotHasMore),
+ multiplierDistribution: aggregateLuckyGiftMultiplierMetrics(
+ normalizeItems(
+ payload.multiplier_distribution ?? payload.multiplierDistribution ?? payload.multiplier_stats,
+ ),
+ ),
+ profile: normalizeLuckyGiftUserProfile(
+ profileSource,
+ payload.snapshot_at_ms ??
+ payload.snapshotAtMs ??
+ profileSource.snapshot_at_ms ??
+ profileSource.snapshotAtMs,
+ ),
+ };
+}
+
export async function saveLuckyGiftConfig(config = {}) {
const payload = luckyGiftConfigPayload(config);
return opsRequest("/lucky-gifts/config", {
@@ -322,6 +415,349 @@ function normalizePoolAdjustmentResult(payload = {}) {
};
}
+function normalizeLuckyGiftUserProfile(profile = {}, fallbackSnapshotAtMs = 0) {
+ const identity = profile.identity || {};
+ const internalUser = profile.internal_user || profile.internalUser || profile.user || identity.user || {};
+ const externalUserID = String(
+ profile.external_user_id ??
+ profile.externalUserId ??
+ identity.external_user_id ??
+ identity.externalUserId ??
+ "",
+ ).trim();
+ const userID = String(
+ profile.internal_user_id ??
+ profile.internalUserId ??
+ profile.user_id ??
+ profile.userId ??
+ internalUser.user_id ??
+ internalUser.userId ??
+ identity.user_id ??
+ identity.userId ??
+ "",
+ ).trim();
+ const identityType = externalUserID
+ ? "external"
+ : normalizeIdentityType(
+ profile.identity_type ?? profile.identityType ?? profile.user_type ?? profile.userType ?? "internal",
+ );
+ const windows = profile.windows || profile.window_metrics || profile.windowMetrics || {};
+ const snapshotAtMs = numberValue(profile.snapshot_at_ms ?? profile.snapshotAtMs ?? fallbackSnapshotAtMs);
+ const profileUpdatedAtMs = numberValue(
+ profile.aggregated_at_ms ??
+ profile.aggregatedAtMs ??
+ profile.profile_updated_at_ms ??
+ profile.profileUpdatedAtMs ??
+ profile.updated_at_ms ??
+ profile.updatedAtMs,
+ );
+ const explicitLag = profile.data_lag_ms ?? profile.dataLagMs;
+ const jackpotCounts = profile.jackpot_counts || profile.jackpotCounts || {};
+
+ // 画像接口是独立读模型,字段名可能经过 protobuf JSON 或 admin envelope 转成 camelCase;
+ // 在边界一次性归一化,组件只消费稳定模型,且明确丢弃 owner 内部的 strategy_user_id / user_key 哈希。
+ return {
+ appCode: String(profile.app_code ?? profile.appCode ?? "")
+ .trim()
+ .toLowerCase(),
+ cumulativeSpendJackpotCount: numberValue(
+ profile.cumulative_spend_jackpot_count ??
+ profile.cumulativeSpendJackpotCount ??
+ jackpotCounts.cumulative_spend ??
+ jackpotCounts.cumulativeSpend,
+ ),
+ dataLagMs:
+ explicitLag !== undefined
+ ? Math.max(0, numberValue(explicitLag))
+ : snapshotAtMs && profileUpdatedAtMs
+ ? Math.max(0, snapshotAtMs - profileUpdatedAtMs)
+ : 0,
+ downweightActive: Boolean(
+ profile.downweight_active ??
+ profile.downweightActive ??
+ profile.user_24h_rtp_downweight_active ??
+ profile.user24hRtpDownweightActive,
+ ),
+ equivalentDraws: numberValue(profile.equivalent_draws ?? profile.equivalentDraws),
+ externalUserId: identityType === "external" ? externalUserID : "",
+ failedDrawCount: numberValue(profile.failed_draw_count ?? profile.failedDrawCount),
+ firstDrawAtMs: numberValue(profile.first_draw_at_ms ?? profile.firstDrawAtMs),
+ grantedDrawCount: numberValue(profile.granted_draw_count ?? profile.grantedDrawCount),
+ guaranteeDrawsRemaining: numberValue(
+ profile.guarantee_draws_remaining ?? profile.guaranteeDrawsRemaining ?? profile.loss_protection_remaining,
+ ),
+ guaranteeThreshold: numberValue(
+ profile.guarantee_threshold ?? profile.guaranteeThreshold ?? profile.loss_streak_guarantee,
+ ),
+ highMultiplierOrdinaryWinCount: numberValue(
+ profile.high_multiplier_ordinary_win_count ?? profile.highMultiplierOrdinaryWinCount,
+ ),
+ identityType,
+ currentRtpPpm: numberValue(profile.current_rtp_ppm ?? profile.currentRtpPpm),
+ hasCurrentRtp: Boolean(profile.has_current_rtp ?? profile.hasCurrentRtp),
+ key: `${profile.app_code ?? profile.appCode ?? ""}:${profile.pool_id ?? profile.poolId ?? ""}:${identityType}:${
+ identityType === "external" ? externalUserID : userID
+ }`,
+ lastDrawAtMs: numberValue(profile.last_draw_at_ms ?? profile.lastDrawAtMs),
+ latestMultiplierPpm: numberValue(profile.latest_multiplier_ppm ?? profile.latestMultiplierPpm),
+ latestRuleVersion: numberValue(
+ profile.latest_rule_version ?? profile.latestRuleVersion ?? profile.rule_version,
+ ),
+ latestStrategyVersion: String(
+ profile.latest_strategy_version ?? profile.latestStrategyVersion ?? profile.strategy_version ?? "",
+ ).trim(),
+ latestWaterZone: String(
+ profile.latest_water_zone ?? profile.latestWaterZone ?? profile.water_zone ?? "",
+ ).trim(),
+ lossStreak: numberValue(profile.loss_streak ?? profile.lossStreak),
+ maxMultiplierPpm: numberValue(profile.max_multiplier_ppm ?? profile.maxMultiplierPpm),
+ maxRewardCoins: numberValue(profile.max_reward_coins ?? profile.maxRewardCoins),
+ ordinaryWinCount: numberValue(profile.ordinary_win_count ?? profile.ordinaryWinCount),
+ pendingCumulativeSpendTokens: numberValue(
+ profile.pending_spend_jackpot_tokens ??
+ profile.pendingSpendJackpotTokens ??
+ profile.pending_cumulative_spend_tokens ??
+ profile.pendingCumulativeSpendTokens,
+ ),
+ pendingRtpCompensationTokens: numberValue(
+ profile.pending_rtp_compensation_tokens ?? profile.pendingRtpCompensationTokens,
+ ),
+ poolId: String(profile.pool_id ?? profile.poolId ?? "").trim(),
+ pendingDrawCount: numberValue(profile.pending_draw_count ?? profile.pendingDrawCount),
+ paidDraws: numberValue(profile.paid_draws ?? profile.paidDraws),
+ profileUpdatedAtMs,
+ rtpCompensationJackpotCount: numberValue(
+ profile.rtp_compensation_jackpot_count ??
+ profile.rtpCompensationJackpotCount ??
+ jackpotCounts.rtp_compensation ??
+ jackpotCounts.rtpCompensation,
+ ),
+ snapshotAtMs,
+ stage: String(profile.stage ?? profile.current_stage ?? profile.currentStage ?? "").trim(),
+ baseRewardCoins: numberValue(profile.base_reward_coins ?? profile.baseRewardCoins),
+ guaranteeHitCount: numberValue(profile.guarantee_hit_count ?? profile.guaranteeHitCount),
+ user: {
+ avatar: String(internalUser.avatar ?? internalUser.avatar_url ?? internalUser.avatarUrl ?? "").trim(),
+ defaultDisplayUserId: String(
+ internalUser.default_display_user_id ?? internalUser.defaultDisplayUserId ?? "",
+ ).trim(),
+ displayUserId: String(internalUser.display_user_id ?? internalUser.displayUserId ?? "").trim(),
+ prettyDisplayUserId: String(
+ internalUser.pretty_display_user_id ?? internalUser.prettyDisplayUserId ?? "",
+ ).trim(),
+ prettyId: String(internalUser.pretty_id ?? internalUser.prettyId ?? "").trim(),
+ status: String(internalUser.status || "").trim(),
+ userId: identityType === "internal" ? userID : "",
+ username: String(internalUser.username ?? internalUser.name ?? "").trim(),
+ },
+ user24hOrdinaryWinFactorPpm: numberValue(
+ profile.downweight_factor_ppm ??
+ profile.downweightFactorPpm ??
+ profile.user_24h_ordinary_win_factor_ppm ??
+ profile.user24hOrdinaryWinFactorPpm,
+ ),
+ user24hRtpThresholdPpm: numberValue(profile.user_24h_rtp_threshold_ppm ?? profile.user24hRtpThresholdPpm),
+ windows: {
+ fortyEightHours: normalizeLuckyGiftWindowMetric(
+ windows.forty_eight_hours ??
+ windows.fortyEightHours ??
+ windows.last_48h ??
+ windows.h48 ??
+ profile.forty_eight_hours ??
+ profile.fortyEightHours,
+ ),
+ lifetime: normalizeLuckyGiftWindowMetric(
+ windows.lifetime ??
+ windows.all ??
+ windows.total ??
+ profile.lifetime ??
+ profile.all_time ??
+ profile.allTime,
+ ),
+ oneHour: normalizeLuckyGiftWindowMetric(
+ windows.one_hour ??
+ windows.oneHour ??
+ windows.last_1h ??
+ windows.h1 ??
+ profile.one_hour ??
+ profile.oneHour,
+ ),
+ twentyFourHours: normalizeLuckyGiftWindowMetric(
+ windows.twenty_four_hours ??
+ windows.twentyFourHours ??
+ windows.last_24h ??
+ windows.h24 ??
+ profile.twenty_four_hours ??
+ profile.twentyFourHours,
+ ),
+ },
+ zeroDrawCount: numberValue(profile.zero_draw_count ?? profile.zeroDrawCount),
+ };
+}
+
+function normalizeLuckyGiftWindowMetric(metric = {}) {
+ const wagerCoins = numberValue(
+ metric.wager_coins ?? metric.wagerCoins ?? metric.spent_coins ?? metric.spentCoins ?? metric.consumed_coins,
+ );
+ const payoutCoins = numberValue(
+ metric.payout_coins ?? metric.payoutCoins ?? metric.reward_coins ?? metric.rewardCoins ?? metric.returned_coins,
+ );
+ return {
+ drawCount: numberValue(metric.draw_count ?? metric.drawCount ?? metric.draws ?? metric.total_draws),
+ hasRtp: Boolean(metric.has_rtp ?? metric.hasRtp ?? wagerCoins > 0),
+ // 净输赢固定采用用户视角“返奖 - 消耗”,不直接接受含义可能相反的平台 profit 字段。
+ netCoins: payoutCoins - wagerCoins,
+ payoutCoins,
+ rtpPpm: numberValue(metric.rtp_ppm ?? metric.rtpPpm ?? metric.actual_rtp_ppm ?? metric.actualRtpPpm),
+ wagerCoins,
+ };
+}
+
+function normalizeLuckyGiftMultiplierMetric(metric = {}) {
+ const hitType = String(metric.hit_type ?? metric.hitType ?? "")
+ .trim()
+ .toLowerCase();
+ const drawCount = numberValue(metric.draw_count ?? metric.drawCount);
+ const ordinaryHitCount =
+ numberValue(
+ metric.ordinary_hit_count ?? metric.ordinaryHitCount ?? metric.ordinary_count ?? metric.ordinaryCount,
+ ) || (hitType === "ordinary" ? drawCount : 0);
+ const rtpCompensationJackpotCount =
+ numberValue(metric.rtp_compensation_jackpot_count ?? metric.rtpCompensationJackpotCount) ||
+ (hitType === "rtp_compensation" ? drawCount : 0);
+ const cumulativeSpendJackpotCount =
+ numberValue(metric.cumulative_spend_jackpot_count ?? metric.cumulativeSpendJackpotCount) ||
+ (["cumulative_spend", "spend_milestone"].includes(hitType) ? drawCount : 0);
+ const zeroDrawCount =
+ numberValue(metric.zero_draw_count ?? metric.zeroDrawCount) || (hitType === "zero" ? drawCount : 0);
+ return {
+ cumulativeSpendJackpotCount,
+ hitCount:
+ numberValue(metric.hit_count ?? metric.hitCount) ||
+ ordinaryHitCount + rtpCompensationJackpotCount + cumulativeSpendJackpotCount + zeroDrawCount,
+ multiplierPpm: numberValue(metric.multiplier_ppm ?? metric.multiplierPpm),
+ ordinaryHitCount,
+ payoutCoins: numberValue(
+ metric.payout_coins ?? metric.payoutCoins ?? metric.reward_coins ?? metric.rewardCoins,
+ ),
+ rtpCompensationJackpotCount,
+ wagerCoins: numberValue(metric.wager_coins ?? metric.wagerCoins),
+ zeroDrawCount,
+ };
+}
+
+function aggregateLuckyGiftMultiplierMetrics(metrics) {
+ const byMultiplier = new Map();
+ metrics.map(normalizeLuckyGiftMultiplierMetric).forEach((metric) => {
+ const current = byMultiplier.get(metric.multiplierPpm) || {
+ cumulativeSpendJackpotCount: 0,
+ hitCount: 0,
+ multiplierPpm: metric.multiplierPpm,
+ ordinaryHitCount: 0,
+ payoutCoins: 0,
+ rtpCompensationJackpotCount: 0,
+ wagerCoins: 0,
+ zeroDrawCount: 0,
+ };
+ current.cumulativeSpendJackpotCount += metric.cumulativeSpendJackpotCount;
+ current.hitCount += metric.hitCount;
+ current.ordinaryHitCount += metric.ordinaryHitCount;
+ current.payoutCoins += metric.payoutCoins;
+ current.rtpCompensationJackpotCount += metric.rtpCompensationJackpotCount;
+ current.wagerCoins += metric.wagerCoins;
+ current.zeroDrawCount += metric.zeroDrawCount;
+ byMultiplier.set(metric.multiplierPpm, current);
+ });
+ return [...byMultiplier.values()];
+}
+
+function normalizeLuckyGiftJackpotHit(hit = {}) {
+ const mechanism = String(
+ hit.mechanism ?? hit.jackpot_mechanism ?? hit.jackpotMechanism ?? hit.jackpot_type ?? hit.jackpotType ?? "",
+ ).trim();
+ return {
+ conditions: normalizeLuckyGiftJackpotConditions(
+ hit.conditions ??
+ hit.conditions_json ??
+ hit.conditionsJson ??
+ hit.condition_snapshot_json ??
+ hit.conditionSnapshotJson,
+ ),
+ drawId: String(hit.draw_id ?? hit.drawId ?? "").trim(),
+ eventKey: String(hit.event_key ?? hit.eventKey ?? "").trim(),
+ mechanism,
+ multiplierPpm: numberValue(hit.multiplier_ppm ?? hit.multiplierPpm),
+ occurredAtMs: numberValue(hit.occurred_at_ms ?? hit.occurredAtMs ?? hit.created_at_ms ?? hit.createdAtMs),
+ payoutCoins: numberValue(hit.payout_coins ?? hit.payoutCoins ?? hit.reward_coins ?? hit.rewardCoins),
+ reason: String(
+ hit.reason_summary ??
+ hit.reasonSummary ??
+ hit.reason ??
+ hit.final_reason ??
+ hit.finalReason ??
+ hit.decision_reason ??
+ hit.decisionReason ??
+ "",
+ ).trim(),
+ reasonCode: String(hit.reason_code ?? hit.reasonCode ?? "").trim(),
+ requestId: String(hit.request_id ?? hit.requestId ?? "").trim(),
+ ruleVersion: numberValue(hit.rule_version ?? hit.ruleVersion),
+ stage: String(hit.stage ?? "").trim(),
+ tierId: String(hit.tier_id ?? hit.tierId ?? "").trim(),
+ wagerCoins: numberValue(hit.wager_coins ?? hit.wagerCoins),
+ };
+}
+
+function normalizeLuckyGiftJackpotConditions(value) {
+ const parsed = parseJSONValue(value);
+ if (!Array.isArray(parsed)) {
+ return parsed;
+ }
+ return parsed.map((condition) => ({
+ actual: String(condition.actual ?? condition.actual_value ?? condition.actualValue ?? "").trim(),
+ denominator: numberValue(condition.denominator),
+ factorPpm: numberValue(condition.factor_ppm ?? condition.factorPpm),
+ label: String(condition.label || "").trim(),
+ limitPpm: numberValue(condition.limit_ppm ?? condition.limitPpm),
+ name: String(condition.name || "").trim(),
+ numerator: numberValue(condition.numerator),
+ operator: String(condition.operator || "").trim(),
+ passed: Boolean(condition.passed),
+ ratioPpm: numberValue(condition.ratio_ppm ?? condition.ratioPpm),
+ reason: String(condition.reason || "").trim(),
+ threshold: String(condition.threshold ?? condition.threshold_value ?? condition.thresholdValue ?? "").trim(),
+ }));
+}
+
+function normalizeIdentityType(value) {
+ const normalized = String(value || "all")
+ .trim()
+ .toLowerCase();
+ return normalized === "internal" || normalized === "external" ? normalized : "all";
+}
+
+function parseJSONValue(value) {
+ if (!value) {
+ return {};
+ }
+ if (typeof value === "object") {
+ return value;
+ }
+ try {
+ return JSON.parse(value);
+ } catch {
+ return { detail: String(value) };
+ }
+}
+
+function normalizeProfileItems(value) {
+ const items = normalizeItems(value);
+ if (items.length || !value || typeof value !== "object") {
+ return items;
+ }
+ return Array.isArray(value.profiles) ? value.profiles : [];
+}
+
function opsRelativePath(operationPath) {
const prefix = "/v1/admin/ops-center";
if (!operationPath.startsWith(prefix)) {
diff --git a/ops-center/src/components/UserProfileDetail.jsx b/ops-center/src/components/UserProfileDetail.jsx
new file mode 100644
index 0000000..3778a8f
--- /dev/null
+++ b/ops-center/src/components/UserProfileDetail.jsx
@@ -0,0 +1,384 @@
+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 (
+
+
+ {!detailState || detailState.loading ?
: null}
+ {detailState?.error ? (
+
+ {detailState.error}
+
+
+ ) : null}
+ {detailState?.data && !detailState.loading ? (
+
+ ) : null}
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+ 0
+ ? `还差 ${formatNumber(profile.guaranteeDrawsRemaining)} 抽`
+ : "保护剩余 0 抽"
+ }
+ />
+
+
+
+
+
+
+ } />
+
+
+
+
+
+ {distribution.length ? (
+
+
+ 倍率
+ 未中奖
+ 普通命中
+ RTP 补偿大奖
+ 累计消费大奖
+ 合计命中
+ 返奖金币
+
+ {distribution.map((metric) => (
+
+ {formatMultiplierFromPPM(metric.multiplierPpm)}
+ {formatNumber(metric.zeroDrawCount)}
+ {formatNumber(metric.ordinaryHitCount)}
+ {formatNumber(metric.rtpCompensationJackpotCount)}
+ {formatNumber(metric.cumulativeSpendJackpotCount)}
+ {formatNumber(metric.hitCount)}
+ {formatNumber(metric.payoutCoins)}
+
+ ))}
+
+ ) : (
+ 当前没有倍率命中数据
+ )}
+
+
+
+
+ {jackpotHits.length ? (
+ <>
+
+ {jackpotHits.map((hit, index) => (
+
+ ))}
+
+ {data.jackpotHasMore && data.nextJackpotCursor ? (
+
+
+
+ ) : null}
+ >
+ ) : (
+ 该用户当前没有大奖命中记录
+ )}
+
+
+ );
+}
+
+function DetailMetric({ label, value }) {
+ return (
+
+ - {label}
+ - {value || "-"}
+
+ );
+}
+
+function JackpotHit({ hit }) {
+ const conditions = flattenConditions(hit.conditions);
+ const mechanism = mechanismLabel(hit.mechanism);
+ return (
+
+
+
+
+ {formatMultiplierFromPPM(hit.multiplierPpm)}
+ 返奖 {formatNumber(hit.payoutCoins)} 金币
+
+
+
+
+ 为什么命中
+ {reasonLabel(hit.reason, hit.reasonCode, hit.mechanism)}
+
+ {conditions.length ? (
+
+ {conditions.map((condition) => (
+
+
- {condition.value?.label || conditionLabel(condition.key)}
+ - {conditionValue(condition.key, condition.value)}
+
+ ))}
+
+ ) : (
+ 该历史记录没有保存更多判断快照
+ )}
+
+
+ );
+}
+
+function DetailSkeleton() {
+ return (
+
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+ ))}
+
+
+
+
+ );
+}
+
+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 || "未记录";
+}
diff --git a/ops-center/src/components/UserProfileTable.jsx b/ops-center/src/components/UserProfileTable.jsx
new file mode 100644
index 0000000..4029e1d
--- /dev/null
+++ b/ops-center/src/components/UserProfileTable.jsx
@@ -0,0 +1,268 @@
+import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
+import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
+import PublicOutlined from "@mui/icons-material/PublicOutlined";
+import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
+import { TimeText } from "@/shared/ui/TimeText.jsx";
+import { formatNumber, formatPercentFromPPM } from "../format.js";
+import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
+
+export function buildUserProfileColumns(expandedKey) {
+ return [
+ {
+ fixed: "left",
+ key: "identity",
+ label: "用户身份",
+ render: (profile) => ,
+ width: "270px",
+ },
+ windowColumn("one_hour", "近 1 小时", (profile) => profile.windows.oneHour),
+ windowColumn("twenty_four_hours", "近 24 小时", (profile) => profile.windows.twentyFourHours),
+ windowColumn("forty_eight_hours", "近 48 小时", (profile) => profile.windows.fortyEightHours),
+ windowColumn("lifetime", "累计", (profile) => profile.windows.lifetime),
+ {
+ key: "control_state",
+ label: "最近开奖阶段 / 连续未中",
+ render: (profile) => ,
+ width: "190px",
+ },
+ {
+ key: "downweight",
+ label: "24 小时 RTP 降权",
+ render: (profile) => ,
+ width: "190px",
+ },
+ {
+ key: "ordinary_wins",
+ label: "普通中奖",
+ render: (profile) => (
+
+ {formatNumber(profile.ordinaryWinCount)} 次
+ 其中高倍率 {formatNumber(profile.highMultiplierOrdinaryWinCount)} 次
+ 未中奖 {formatNumber(profile.zeroDrawCount)} 次
+ 普通奖与大奖通道独立统计
+
+ ),
+ width: "170px",
+ },
+ {
+ key: "jackpots",
+ label: "两类大奖",
+ render: (profile) => (
+
+
+ {formatNumber(profile.rtpCompensationJackpotCount + profile.cumulativeSpendJackpotCount)} 次
+
+ RTP 补偿 {formatNumber(profile.rtpCompensationJackpotCount)}
+ 累计消费 {formatNumber(profile.cumulativeSpendJackpotCount)}
+ 点击行查看每次命中依据
+
+ ),
+ width: "180px",
+ },
+ {
+ key: "last_draw_at_ms",
+ label: "最近抽奖",
+ render: (profile) => (
+
+
+
+ v{profile.latestRuleVersion || "-"} · {profile.latestStrategyVersion || "-"}
+
+
+ ),
+ width: "180px",
+ },
+ {
+ key: "data_lag_ms",
+ label: "画像时效",
+ render: (profile) => (
+
+ {formatDuration(profile.dataLagMs)}
+
+ {profile.failedDrawCount > 0
+ ? `失败 ${formatNumber(profile.failedDrawCount)} 抽`
+ : `已发 ${formatNumber(profile.grantedDrawCount)} · 待处理 ${formatNumber(profile.pendingDrawCount)}`}
+
+
+ ),
+ width: "150px",
+ },
+ ];
+}
+
+function windowColumn(key, label, selectMetric) {
+ return {
+ key,
+ label,
+ render: (profile) => ,
+ width: "205px",
+ };
+}
+
+function WindowMetricCell({ metric }) {
+ return (
+
+
+ {metric.hasRtp ? formatObservedRTP(metric.rtpPpm) : "-"}
+ {formatNumber(metric.drawCount)} 抽
+
+
+
+
- 消耗
+ - {formatNumber(metric.wagerCoins)}
+
+
+
- 返奖
+ - {formatNumber(metric.payoutCoins)}
+
+ 0 ? "is-profit" : metric.netCoins < 0 ? "is-loss" : ""}>
+
- 用户净输赢
+ - {formatSignedNumber(metric.netCoins)}
+
+
+
+ );
+}
+
+function UserIdentityCell({ expanded, profile }) {
+ return (
+
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ {profile.identityType === "external" ? (
+
+
+
+
+
+ {profile.externalUserId || "-"}
+ external_user_id
+
+
+ ) : (
+
+
+
内部用户{profile.user.status ? ` · ${userStatusLabel(profile.user.status)}` : ""}
+
+ )}
+
+ );
+}
+
+function ControlStateCell({ profile }) {
+ const remaining = profile.guaranteeDrawsRemaining;
+ return (
+
+
+
+ 等价抽数 {formatNumber(profile.equivalentDraws)}
+
+
连续未中 {formatNumber(profile.lossStreak)} 次
+
{remaining > 0 ? `距连续未中保护 ${formatNumber(remaining)} 抽` : "连续未中保护剩余 0 抽"}
+ {profile.pendingCumulativeSpendTokens > 0 ? (
+
+ 待兑累计消费大奖 {formatNumber(profile.pendingCumulativeSpendTokens)} 次
+
+ ) : null}
+
+ );
+}
+
+function DownweightCell({ profile }) {
+ const threshold = profile.user24hRtpThresholdPpm;
+ const factor = profile.user24hOrdinaryWinFactorPpm;
+ return (
+
+ {profile.downweightActive ? (
+
+ ) : (
+
+ )}
+
+ 当前{" "}
+ {profile.hasCurrentRtp || profile.windows.twentyFourHours.hasRtp
+ ? formatObservedRTP(
+ profile.hasCurrentRtp ? profile.currentRtpPpm : profile.windows.twentyFourHours.rtpPpm,
+ )
+ : "暂无消耗"}
+
+
+ {threshold > 0
+ ? `严格高于 ${formatPercentFromPPM(threshold)} 时保留 ${formatPercentFromPPM(factor)}`
+ : profile.downweightActive
+ ? `普通中奖机会当前按 ${formatPercentFromPPM(factor)} 发放`
+ : "当前普通中奖机会未被降权"}
+
+
+ );
+}
+
+function internalIdentityRows(user) {
+ const shortID = user.defaultDisplayUserId || user.displayUserId;
+ return [shortID ? `短号 ${shortID}` : "", user.userId ? `长号 ${user.userId}` : ""].filter(Boolean);
+}
+
+function userStatusLabel(status) {
+ const normalized = String(status || "").toLowerCase();
+ const labels = {
+ active: "正常",
+ banned: "已封禁",
+ disabled: "已停用",
+ inactive: "未激活",
+ };
+ return labels[normalized] || status;
+}
+
+function stageLabel(stage) {
+ const normalized = String(stage || "").toLowerCase();
+ if (normalized === "novice" || normalized === "newbie" || normalized === "new") {
+ return "新手阶段";
+ }
+ if (normalized === "normal" || normalized === "ordinary") {
+ return "普通阶段";
+ }
+ if (["advanced", "high", "high_value", "premium"].includes(normalized)) {
+ return "高价阶段";
+ }
+ return stage || "阶段未记录";
+}
+
+function stageTone(stage) {
+ return stage ? "succeeded" : "";
+}
+
+function formatSignedNumber(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) {
+ return "-";
+ }
+ return `${number > 0 ? "+" : ""}${formatNumber(number)}`;
+}
+
+// 已有消耗时 0 ppm 是真实的 0% 返奖率,不是“暂无数据”;配置字段仍沿用通用格式化器的空值语义。
+function formatObservedRTP(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number) || number < 0) {
+ return "-";
+ }
+ return `${(number / 10_000).toFixed(2)}%`;
+}
+
+function formatDuration(value) {
+ const milliseconds = Math.max(0, Number(value) || 0);
+ if (milliseconds < 1_000) {
+ return "实时";
+ }
+ if (milliseconds < 60_000) {
+ return `${Math.floor(milliseconds / 1_000)} 秒`;
+ }
+ if (milliseconds < 3_600_000) {
+ return `${Math.floor(milliseconds / 60_000)} 分钟`;
+ }
+ return `${(milliseconds / 3_600_000).toFixed(1)} 小时`;
+}
diff --git a/ops-center/src/components/UserProfilesView.jsx b/ops-center/src/components/UserProfilesView.jsx
new file mode 100644
index 0000000..f5c94c1
--- /dev/null
+++ b/ops-center/src/components/UserProfilesView.jsx
@@ -0,0 +1,456 @@
+import SearchOutlined from "@mui/icons-material/SearchOutlined";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import {
+ AdminFilterResetButton,
+ AdminFilterSelect,
+ AdminListToolbar,
+ AdminSearchBox,
+} from "@/shared/ui/AdminListLayout.jsx";
+import { Button } from "@/shared/ui/Button.jsx";
+import { DataState } from "@/shared/ui/DataState.jsx";
+import { DataTable } from "@/shared/ui/DataTable.jsx";
+import { fetchLuckyGiftUserProfile, fetchLuckyGiftUserProfiles } from "../api.js";
+import { UserProfileDetail } from "./UserProfileDetail.jsx";
+import { buildUserProfileColumns } from "./UserProfileTable.jsx";
+
+const PAGE_SIZE = 20;
+const IDENTITY_OPTIONS = [
+ ["all", "全部用户"],
+ ["internal", "内部用户"],
+ ["external", "外部接入用户"],
+];
+const STAGE_OPTIONS = [
+ ["", "全部最近开奖阶段"],
+ ["novice", "最近为新手阶段"],
+ ["normal", "最近为普通阶段"],
+ ["advanced", "最近为高阶阶段"],
+];
+const STATUS_OPTIONS = [
+ ["", "全部状态"],
+ ["jackpot", "中出过大奖"],
+ ["loss_streak", "连续未中奖"],
+ ["pending_token", "有待兑消费大奖"],
+];
+const SORT_OPTIONS = [
+ ["last_draw_at", "最近抽奖"],
+ ["lifetime_rtp", "累计 RTP"],
+ ["lifetime_wager", "累计消耗"],
+ ["lifetime_payout", "累计返奖"],
+ ["jackpot_count", "大奖次数"],
+ ["loss_streak", "连续未中奖"],
+];
+const SORT_DIRECTION_OPTIONS = [
+ ["desc", "从高到低 / 从新到旧"],
+ ["asc", "从低到高 / 从旧到新"],
+];
+
+export function UserProfilesView({ apps, defaultAppCode, poolIDsByApp }) {
+ const [appCode, setAppCode] = useState(defaultAppCode);
+ const [poolId, setPoolId] = useState("");
+ const [identityType, setIdentityType] = useState("all");
+ const [stage, setStage] = useState("");
+ const [status, setStatus] = useState("");
+ const [sortBy, setSortBy] = useState("last_draw_at");
+ const [sortDirection, setSortDirection] = useState("desc");
+ const [keywordDraft, setKeywordDraft] = useState("");
+ const [keyword, setKeyword] = useState("");
+ const [page, setPage] = useState(1);
+ const [reloadKey, setReloadKey] = useState(0);
+ const [expandedKey, setExpandedKey] = useState("");
+ const [details, setDetails] = useState({});
+ // 页码只负责兼容通用 DataTable 的滚动触发;真正翻页使用 owner 返回的不可解释 cursor,
+ // 因此并发开奖插入新画像时不会因为 OFFSET 位移而把同一用户追加两次。
+ const cursorsByPage = useRef({ 1: "" });
+ const [state, setState] = useState({
+ error: "",
+ hasMore: false,
+ items: [],
+ loading: false,
+ snapshotAtMs: 0,
+ total: 0,
+ });
+
+ const appOptions = useMemo(
+ () =>
+ apps.map((item) => [
+ item.app_code ?? item.appCode,
+ `${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
+ ]),
+ [apps],
+ );
+ const poolIDs = useMemo(() => poolIDsByApp.get(appCode) || [], [appCode, poolIDsByApp]);
+ const poolOptions = useMemo(() => poolIDs.map((value) => [value, value]), [poolIDs]);
+
+ useEffect(() => {
+ setAppCode((current) => current || defaultAppCode);
+ }, [defaultAppCode]);
+
+ useEffect(() => {
+ // 奖池是画像聚合主键的一部分。应用切换或配置刚发布后若当前奖池不再存在,自动落到该应用第一口池,
+ // 不带空 pool_id 请求,避免后端退化成跨池扫描并把两个独立奖池的用户数据混在一起。
+ setPoolId((current) => (current && poolIDs.includes(current) ? current : poolIDs[0] || ""));
+ }, [poolIDs]);
+
+ useEffect(() => {
+ if (!appCode || !poolId) {
+ setState({
+ error: "",
+ hasMore: false,
+ items: [],
+ loading: false,
+ snapshotAtMs: 0,
+ total: 0,
+ });
+ return undefined;
+ }
+ let cancelled = false;
+ setState((current) => ({ ...current, error: "", loading: true }));
+ fetchLuckyGiftUserProfiles({
+ appCode,
+ cursor: page > 1 ? cursorsByPage.current[page] || "" : "",
+ identityType,
+ keyword,
+ page,
+ pageSize: PAGE_SIZE,
+ poolId,
+ sortBy,
+ sortDirection,
+ stage,
+ status,
+ })
+ .then((result) => {
+ if (cancelled) {
+ return;
+ }
+ if (result.hasMore && result.nextCursor) {
+ cursorsByPage.current[page + 1] = result.nextCursor;
+ } else {
+ delete cursorsByPage.current[page + 1];
+ }
+ setState((current) => ({
+ error: "",
+ hasMore: result.hasMore && Boolean(result.nextCursor),
+ // cursor 保证顺序稳定,前端仍按身份键防御性去重,避免故障代理重复响应污染 React row key。
+ items: page > 1 ? mergeProfiles(current.items, result.items) : result.items,
+ loading: false,
+ snapshotAtMs: result.snapshotAtMs,
+ total: result.total,
+ }));
+ })
+ .catch((error) => {
+ if (cancelled) {
+ return;
+ }
+ setState((current) => ({
+ ...current,
+ error: error.message || "获取用户画像失败",
+ loading: false,
+ }));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [appCode, identityType, keyword, page, poolId, reloadKey, sortBy, sortDirection, stage, status]);
+
+ const resetQueryState = useCallback(() => {
+ cursorsByPage.current = { 1: "" };
+ setPage(1);
+ setExpandedKey("");
+ setDetails({});
+ }, []);
+
+ const reloadProfiles = useCallback(() => {
+ cursorsByPage.current = { 1: "" };
+ setPage(1);
+ setExpandedKey("");
+ setDetails({});
+ setReloadKey((current) => current + 1);
+ }, []);
+
+ const changeAppCode = useCallback(
+ (nextAppCode) => {
+ setAppCode(nextAppCode);
+ setPoolId((poolIDsByApp.get(nextAppCode) || [])[0] || "");
+ resetQueryState();
+ },
+ [poolIDsByApp, resetQueryState],
+ );
+
+ const changePoolId = useCallback(
+ (nextPoolId) => {
+ setPoolId(nextPoolId);
+ resetQueryState();
+ },
+ [resetQueryState],
+ );
+
+ const changeIdentityType = useCallback(
+ (nextIdentityType) => {
+ setIdentityType(nextIdentityType);
+ setKeywordDraft("");
+ setKeyword("");
+ resetQueryState();
+ },
+ [resetQueryState],
+ );
+
+ const submitSearch = useCallback(
+ (event) => {
+ event.preventDefault();
+ setKeyword(keywordDraft.trim());
+ resetQueryState();
+ },
+ [keywordDraft, resetQueryState],
+ );
+
+ const resetFilters = useCallback(() => {
+ setIdentityType("all");
+ setStage("");
+ setStatus("");
+ setSortBy("last_draw_at");
+ setSortDirection("desc");
+ setKeywordDraft("");
+ setKeyword("");
+ resetQueryState();
+ }, [resetQueryState]);
+
+ const loadDetail = useCallback(async (profile, jackpotCursor = "", append = false) => {
+ const key = profile.key;
+ setDetails((current) => ({
+ ...current,
+ [key]: {
+ data: current[key]?.data,
+ error: "",
+ loading: !append,
+ loadingMore: append,
+ },
+ }));
+ try {
+ const data = await fetchLuckyGiftUserProfile({
+ appCode: profile.appCode,
+ externalUserId: profile.externalUserId,
+ identityType: profile.identityType,
+ jackpotCursor,
+ poolId: profile.poolId,
+ userId: profile.user.userId,
+ });
+ setDetails((current) => {
+ const previous = current[key]?.data;
+ const nextData =
+ append && previous
+ ? {
+ ...data,
+ // owner cursor 已固定在最后一条大奖事实之后;前端再按事实键去重,防止代理重放响应,
+ // 同时不吞掉 request_id 为空的历史记录。
+ jackpotHits: mergeJackpotHits(previous.jackpotHits, data.jackpotHits),
+ multiplierDistribution: previous.multiplierDistribution,
+ profile: previous.profile,
+ }
+ : data;
+ return { ...current, [key]: { data: nextData, error: "", loading: false, loadingMore: false } };
+ });
+ } catch (error) {
+ setDetails((current) => ({
+ ...current,
+ [key]: {
+ data: current[key]?.data,
+ error: error.message || "获取画像明细失败",
+ loading: false,
+ loadingMore: false,
+ },
+ }));
+ }
+ }, []);
+
+ const loadMoreDetail = useCallback(
+ (profile) => {
+ const detail = details[profile.key]?.data;
+ if (!detail?.jackpotHasMore || !detail.nextJackpotCursor) {
+ return;
+ }
+ loadDetail(profile, detail.nextJackpotCursor, true);
+ },
+ [details, loadDetail],
+ );
+
+ const toggleProfile = useCallback(
+ (profile) => {
+ if (expandedKey === profile.key) {
+ setExpandedKey("");
+ return;
+ }
+ setExpandedKey(profile.key);
+ if (!details[profile.key]?.data && !details[profile.key]?.loading) {
+ loadDetail(profile);
+ }
+ },
+ [details, expandedKey, loadDetail],
+ );
+
+ const columns = useMemo(() => buildUserProfileColumns(expandedKey), [expandedKey]);
+ const loadMoreProfiles = useCallback(() => {
+ if (!state.loading && state.hasMore) {
+ setPage((current) => current + 1);
+ }
+ }, [state.hasMore, state.loading]);
+ const hasActiveFilters =
+ identityType !== "all" ||
+ Boolean(stage || status || keywordDraft || keyword) ||
+ sortBy !== "last_draw_at" ||
+ sortDirection !== "desc";
+ const searchPlaceholder =
+ identityType === "external"
+ ? "只支持 external_user_id"
+ : identityType === "internal"
+ ? "短号 / 长号 / 靓号"
+ : "短号 / 长号 / 靓号 / external_user_id";
+
+ return (
+
+
+
+
+
+ {
+ setStage(value);
+ resetQueryState();
+ }}
+ />
+ {
+ setStatus(value);
+ resetQueryState();
+ }}
+ />
+ {
+ setSortBy(value);
+ resetQueryState();
+ }}
+ />
+ {
+ setSortDirection(value);
+ resetQueryState();
+ }}
+ />
+
+
+ >
+ }
+ />
+ {state.error && state.items.length ? (
+
+ {state.error}
+
+
+ ) : null}
+
+ ({
+ "aria-expanded": expandedKey === profile.key,
+ className:
+ expandedKey === profile.key ? "ops-user-profile-row is-expanded" : "ops-user-profile-row",
+ role: "button",
+ tabIndex: 0,
+ onClick: () => toggleProfile(profile),
+ onKeyDown: (event) => {
+ if (event.key !== "Enter" && event.key !== " ") {
+ return;
+ }
+ event.preventDefault();
+ toggleProfile(profile);
+ },
+ })}
+ items={state.items}
+ infiniteScroll={{
+ doneLabel: state.items.length ? "已加载全部用户" : "",
+ hasMore: !state.error && state.hasMore,
+ loading: state.loading && state.items.length > 0,
+ onLoadMore: loadMoreProfiles,
+ }}
+ minWidth="1830px"
+ renderRowDetail={(profile) =>
+ expandedKey === profile.key ? (
+ loadMoreDetail(profile)}
+ profile={profile}
+ onRetry={() => loadDetail(profile)}
+ />
+ ) : null
+ }
+ rowKey={(profile) => profile.key}
+ tableClassName="ops-user-profile-grid"
+ title={`${appCode || "-"} / ${poolId || "-"} 用户画像${state.loading ? "(更新中)" : ""}`}
+ total={state.total}
+ />
+
+
+ );
+}
+
+function mergeProfiles(previous = [], next = []) {
+ const byKey = new Map(previous.map((profile) => [profile.key, profile]));
+ next.forEach((profile) => byKey.set(profile.key, profile));
+ return [...byKey.values()];
+}
+
+function mergeJackpotHits(previous = [], next = []) {
+ const seen = new Set(previous.map(jackpotEventKey));
+ return [
+ ...previous,
+ ...next.filter((hit) => {
+ const key = jackpotEventKey(hit);
+ if (seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ return true;
+ }),
+ ];
+}
+
+function jackpotEventKey(hit) {
+ return hit.eventKey || `${hit.drawId}:${hit.requestId}:${hit.occurredAtMs}:${hit.mechanism}`;
+}
diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css
index 3a56f4b..690a358 100644
--- a/ops-center/src/styles/index.css
+++ b/ops-center/src/styles/index.css
@@ -808,6 +808,479 @@ body {
padding: var(--space-2) var(--space-6);
}
+/* ---- 用户画像:四个统计窗口在单元格内纵向收敛,横向只保留控制链路需要对比的维度 ---- */
+
+.ops-user-profile-search {
+ display: flex;
+ min-width: 360px;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.ops-user-profile-search .search-box {
+ flex: 1;
+}
+
+.ops-user-profile-table {
+ min-height: 560px;
+}
+
+.ops-user-profile-grid {
+ --table-row-height: 112px;
+}
+
+.ops-user-profile-row {
+ transition:
+ background var(--motion-fast) var(--ease-standard),
+ box-shadow var(--motion-fast) var(--ease-standard);
+}
+
+.ops-user-profile-row:hover,
+.ops-user-profile-row.is-expanded {
+ background: var(--primary-surface);
+}
+
+.ops-user-profile-row:focus-visible {
+ z-index: 2;
+ outline: 2px solid var(--primary);
+ outline-offset: -2px;
+}
+
+.ops-profile-identity {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.ops-profile-expand {
+ display: inline-flex;
+ flex: 0 0 22px;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-tertiary);
+}
+
+.ops-profile-internal,
+.ops-profile-external {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.ops-profile-internal {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.ops-profile-internal > small {
+ color: var(--text-tertiary);
+ font-size: 10px;
+ font-weight: 700;
+ padding-left: 42px;
+}
+
+.ops-profile-external__icon {
+ display: inline-flex;
+ width: 34px;
+ height: 34px;
+ flex: 0 0 34px;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ background: var(--info-surface);
+ color: var(--info);
+}
+
+.ops-profile-external > span:last-child {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+}
+
+.ops-profile-external strong,
+.ops-profile-external small {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ops-profile-external strong {
+ color: var(--text-primary);
+ font-size: 13px;
+}
+
+.ops-profile-external small {
+ color: var(--text-tertiary);
+ font-size: 10px;
+}
+
+.ops-profile-window {
+ display: grid;
+ min-width: 0;
+ gap: var(--space-1);
+}
+
+.ops-profile-window__head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+
+.ops-profile-window__head strong {
+ color: var(--text-primary);
+ font-size: 16px;
+ font-variant-numeric: tabular-nums;
+}
+
+.ops-profile-window__head small {
+ color: var(--text-tertiary);
+ font-size: 10px;
+ white-space: nowrap;
+}
+
+.ops-profile-window dl {
+ display: grid;
+ gap: 2px;
+ margin: 0;
+}
+
+.ops-profile-window dl > div {
+ display: flex;
+ min-width: 0;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-2);
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.ops-profile-window dt,
+.ops-profile-window dd {
+ margin: 0;
+}
+
+.ops-profile-window dd {
+ overflow: hidden;
+ color: var(--text-primary);
+ font-variant-numeric: tabular-nums;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ops-profile-window .is-profit dd {
+ color: var(--success);
+}
+
+.ops-profile-window .is-loss dd {
+ color: var(--danger);
+}
+
+.ops-profile-control,
+.ops-profile-downweight,
+.ops-profile-counts,
+.ops-profile-time {
+ display: grid;
+ min-width: 0;
+ align-content: center;
+ gap: 3px;
+}
+
+.ops-profile-control > div {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.ops-profile-control > div > span:last-child,
+.ops-profile-control small,
+.ops-profile-downweight small,
+.ops-profile-counts small,
+.ops-profile-time small {
+ overflow: hidden;
+ color: var(--text-tertiary);
+ font-size: 10px;
+ line-height: 1.35;
+ text-overflow: ellipsis;
+}
+
+.ops-profile-control strong,
+.ops-profile-counts strong,
+.ops-profile-time strong {
+ color: var(--text-primary);
+ font-size: 13px;
+}
+
+.ops-profile-control .is-warning {
+ color: var(--warning);
+ font-weight: 700;
+}
+
+.ops-profile-downweight > span,
+.ops-profile-counts > span,
+.ops-profile-time > span {
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.ops-user-profile-detail-row {
+ min-height: 0;
+ background: var(--fill-secondary);
+}
+
+.ops-user-profile-detail-cell {
+ min-width: 0;
+ grid-column: 1 / -1;
+ padding: 0 var(--space-4) var(--space-4) 44px;
+}
+
+.ops-user-profile-detail {
+ display: grid;
+ border-top: 1px solid var(--border);
+ background: var(--bg-card);
+}
+
+.ops-user-profile-detail__summary {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(140px, 1fr));
+ border-bottom: 1px solid var(--border);
+}
+
+.ops-user-profile-detail__summary dl {
+ min-width: 0;
+ margin: 0;
+ border-right: 1px solid var(--border);
+ padding: var(--space-3);
+}
+
+.ops-user-profile-detail__summary dl:last-child {
+ border-right: 0;
+}
+
+.ops-user-profile-detail__summary dt {
+ color: var(--text-tertiary);
+ font-size: 10px;
+ font-weight: 800;
+}
+
+.ops-user-profile-detail__summary dd {
+ margin: var(--space-1) 0 0;
+ color: var(--text-primary);
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1.45;
+}
+
+.ops-user-profile-detail__section {
+ display: grid;
+ min-width: 0;
+ gap: var(--space-3);
+ border-bottom: 1px solid var(--border);
+ padding: var(--space-4) 0;
+}
+
+.ops-user-profile-detail__section:last-child {
+ border-bottom: 0;
+}
+
+.ops-user-profile-detail__section > header {
+ display: flex;
+ min-width: 0;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--space-4);
+ padding: 0 var(--space-3);
+}
+
+.ops-user-profile-detail__section h3,
+.ops-user-profile-detail__section p {
+ margin: 0;
+}
+
+.ops-user-profile-detail__section h3 {
+ color: var(--text-primary);
+ font-size: 13px;
+}
+
+.ops-user-profile-detail__section p,
+.ops-user-profile-detail__section > header > span {
+ color: var(--text-tertiary);
+ font-size: 10px;
+}
+
+.ops-user-profile-detail__section p {
+ margin-top: 2px;
+}
+
+.ops-profile-multiplier-table {
+ display: grid;
+ min-width: 820px;
+ overflow: hidden;
+ border-top: 1px solid var(--row-border);
+ border-bottom: 1px solid var(--row-border);
+}
+
+.ops-profile-multiplier-row {
+ display: grid;
+ min-height: 34px;
+ align-items: center;
+ grid-template-columns: repeat(7, minmax(112px, 1fr));
+ border-bottom: 1px solid var(--row-border);
+ padding: 0 var(--space-3);
+ color: var(--text-secondary);
+ font-size: 11px;
+}
+
+.ops-profile-multiplier-row:last-child {
+ border-bottom: 0;
+}
+
+.ops-profile-multiplier-row.is-head {
+ background: var(--bg-card-strong);
+ color: var(--text-tertiary);
+ font-weight: 800;
+}
+
+.ops-profile-multiplier-row strong {
+ color: var(--text-primary);
+}
+
+.ops-profile-jackpot-list {
+ display: grid;
+}
+
+.ops-profile-jackpot-more {
+ display: flex;
+ justify-content: center;
+ border-top: 1px solid var(--row-border);
+ padding: var(--space-3);
+}
+
+.ops-profile-jackpot-hit {
+ display: grid;
+ min-width: 0;
+ gap: var(--space-3);
+ border-top: 1px solid var(--row-border);
+ padding: var(--space-3);
+}
+
+.ops-profile-jackpot-hit__head,
+.ops-profile-jackpot-hit__head > div,
+.ops-profile-jackpot-hit footer {
+ display: flex;
+ min-width: 0;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.ops-profile-jackpot-hit__head {
+ justify-content: space-between;
+}
+
+.ops-profile-jackpot-hit__head strong {
+ color: var(--text-primary);
+ font-size: 15px;
+}
+
+.ops-profile-jackpot-hit__head > span,
+.ops-profile-jackpot-hit__head div > span,
+.ops-profile-jackpot-hit footer {
+ color: var(--text-tertiary);
+ font-size: 10px;
+}
+
+.ops-profile-jackpot-hit__reason {
+ display: grid;
+ grid-template-columns: 92px minmax(0, 1fr);
+ align-items: start;
+ gap: var(--space-3);
+ border-left: 3px solid var(--primary);
+ background: var(--primary-surface);
+ padding: var(--space-2) var(--space-3);
+ color: var(--text-secondary);
+ font-size: 11px;
+ line-height: 1.55;
+}
+
+.ops-profile-jackpot-hit__reason strong {
+ color: var(--primary);
+}
+
+.ops-profile-jackpot-conditions {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(160px, 1fr));
+ gap: 1px;
+ margin: 0;
+ background: var(--border);
+}
+
+.ops-profile-jackpot-conditions > div {
+ min-width: 0;
+ background: var(--bg-card);
+ padding: var(--space-2) var(--space-3);
+}
+
+.ops-profile-jackpot-conditions dt {
+ overflow: hidden;
+ color: var(--text-tertiary);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ops-profile-jackpot-conditions dd {
+ overflow: hidden;
+ margin: 2px 0 0;
+ color: var(--text-primary);
+ font-size: 11px;
+ font-weight: 700;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ops-profile-jackpot-hit footer {
+ flex-wrap: wrap;
+}
+
+.ops-profile-jackpot-hit__no-conditions,
+.ops-user-profile-detail__empty {
+ color: var(--text-tertiary);
+ font-size: 11px;
+ padding: var(--space-3);
+}
+
+.ops-user-profile-detail-skeleton {
+ display: grid;
+ gap: var(--space-3);
+ border-top: 1px solid var(--border);
+ background: var(--bg-card);
+ padding: var(--space-4);
+}
+
+.ops-user-profile-detail-skeleton > div {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(120px, 1fr));
+ gap: var(--space-2);
+}
+
+.ops-user-profile-detail-error {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ border-top: 1px solid var(--danger-border);
+ background: var(--danger-surface);
+ color: var(--danger);
+ padding: var(--space-3) var(--space-4);
+ font-size: 12px;
+ font-weight: 700;
+}
+
/* ---- 响应式 ---- */
@media (max-width: 1279px) {
diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts
index 2fe5977..1370cc8 100644
--- a/src/shared/api/generated/endpoints.ts
+++ b/src/shared/api/generated/endpoints.ts
@@ -148,6 +148,7 @@ export const API_OPERATIONS = {
getJob: "getJob",
getLuckyGiftConfig: "getLuckyGiftConfig",
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
+ getLuckyGiftUserProfile: "getLuckyGiftUserProfile",
getRechargeBillOverview: "getRechargeBillOverview",
getRechargeBillSummary: "getRechargeBillSummary",
getRedPacket: "getRedPacket",
@@ -236,6 +237,7 @@ export const API_OPERATIONS = {
listLuckyGiftConfigs: "listLuckyGiftConfigs",
listLuckyGiftDraws: "listLuckyGiftDraws",
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
+ listLuckyGiftUserProfiles: "listLuckyGiftUserProfiles",
listManagers: "listManagers",
listOperationLogs: "listOperationLogs",
listPermissions: "listPermissions",
@@ -1358,6 +1360,13 @@ export const API_ENDPOINTS: Record = {
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
+ getLuckyGiftUserProfile: {
+ method: "GET",
+ operationId: API_OPERATIONS.getLuckyGiftUserProfile,
+ path: "/v1/admin/ops-center/lucky-gifts/user-profile",
+ permission: "lucky-gift:view",
+ permissions: ["lucky-gift:view"]
+ },
getRechargeBillOverview: {
method: "GET",
operationId: API_OPERATIONS.getRechargeBillOverview,
@@ -1972,6 +1981,13 @@ export const API_ENDPOINTS: Record = {
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
+ listLuckyGiftUserProfiles: {
+ method: "GET",
+ operationId: API_OPERATIONS.listLuckyGiftUserProfiles,
+ path: "/v1/admin/ops-center/lucky-gifts/user-profiles",
+ permission: "lucky-gift:view",
+ permissions: ["lucky-gift:view"]
+ },
listManagers: {
method: "GET",
operationId: API_OPERATIONS.listManagers,
diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts
index 87c1e47..d5ce803 100644
--- a/src/shared/api/generated/schema.d.ts
+++ b/src/shared/api/generated/schema.d.ts
@@ -292,6 +292,38 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/admin/ops-center/lucky-gifts/user-profiles": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["listLuckyGiftUserProfiles"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/admin/ops-center/lucky-gifts/user-profile": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get: operations["getLuckyGiftUserProfile"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/admin/ops-center/lucky-gifts/summary": {
parameters: {
query?: never;
@@ -8329,6 +8361,30 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
+ listLuckyGiftUserProfiles: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: components["responses"]["EmptyResponse"];
+ };
+ };
+ getLuckyGiftUserProfile: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ 200: components["responses"]["EmptyResponse"];
+ };
+ };
getLuckyGiftDrawSummary: {
parameters: {
query?: never;