三方支付临时链接

This commit is contained in:
zhx 2026-06-25 11:20:49 +08:00
parent bc9484c60d
commit e51a4401ef
26 changed files with 1467 additions and 1 deletions

View File

@ -2637,6 +2637,40 @@
"x-permissions": ["payment-product:delete"]
}
},
"/admin/payment/temporary-links": {
"get": {
"operationId": "listTemporaryPaymentLinks",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "payment-temporary-link:view",
"x-permissions": ["payment-temporary-link:view"]
}
},
"/admin/payment/temporary-links/{order_id}": {
"get": {
"operationId": "getTemporaryPaymentLink",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "order_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "payment-temporary-link:view",
"x-permissions": ["payment-temporary-link:view"]
}
},
"/admin/payment/third-party-channels": {
"get": {
"operationId": "listThirdPartyPaymentChannels",
@ -3612,6 +3646,44 @@
"x-permissions": ["region-block:update"]
}
},
"/admin/users/risk-config": {
"get": {
"operationId": "getRiskConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "risk-config:view",
"x-permissions": ["risk-config:view"]
},
"put": {
"operationId": "updateRiskConfig",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["max_accounts_per_device"],
"properties": {
"max_accounts_per_device": {
"type": "integer",
"minimum": 1
}
}
}
}
}
},
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "risk-config:update",
"x-permissions": ["risk-config:update"]
}
},
"/app/users": {
"get": {
"operationId": "appListUsers",

View File

@ -115,6 +115,7 @@ export const fallbackNavigation = [
routeNavItem("app-user-bans", { icon: BlockOutlined }),
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
routeNavItem("app-user-pretty-ids", { icon: LocalOfferOutlined }),
routeNavItem("app-user-risk-config", { icon: ShieldOutlined }),
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
],
},
@ -198,6 +199,7 @@ export const fallbackNavigation = [
children: [
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined }),
routeNavItem("payment-third-party", { icon: WalletOutlined }),
routeNavItem("payment-temporary-links", { icon: ReceiptLongOutlined }),
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
],
},

View File

@ -52,6 +52,7 @@ export const PERMISSIONS = {
paymentProductCreate: "payment-product:create",
paymentProductUpdate: "payment-product:update",
paymentProductDelete: "payment-product:delete",
paymentTemporaryLinkView: "payment-temporary-link:view",
paymentThirdPartyView: "payment-third-party:view",
paymentThirdPartyUpdate: "payment-third-party:update",
gameView: "game:view",
@ -89,6 +90,8 @@ export const PERMISSIONS = {
prettyIdGrant: "pretty-id:grant",
levelConfigView: "level-config:view",
levelConfigUpdate: "level-config:update",
riskConfigView: "risk-config:view",
riskConfigUpdate: "risk-config:update",
regionBlockView: "region-block:view",
regionBlockUpdate: "region-block:update",
roomView: "room:view",
@ -190,6 +193,7 @@ export const MENU_CODES = {
appUserBans: "app-user-bans",
appUserLoginLogs: "app-user-login-logs",
appUserLevelConfig: "app-user-level-config",
appUserRiskConfig: "app-user-risk-config",
appUserRegionBlocks: "app-user-region-blocks",
appUserPrettyIds: "app-user-pretty-ids",
rooms: "rooms",
@ -252,6 +256,7 @@ export const MENU_CODES = {
payment: "payment",
paymentBillList: "payment-bill-list",
paymentRechargeProducts: "payment-recharge-products",
paymentTemporaryLinks: "payment-temporary-links",
paymentThirdParty: "payment-third-party",
games: "games",
gameList: "game-list",

View File

@ -22,6 +22,7 @@ import { registrationRewardRoutes } from "@/features/registration-reward/routes.
import { redPacketRoutes } from "@/features/red-packets/routes.js";
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
import { resourceRoutes } from "@/features/resources/routes.js";
import { riskConfigRoutes } from "@/features/risk-config/routes.js";
import { rolesRoutes } from "@/features/roles/routes.js";
import { roomRoutes } from "@/features/rooms/routes.js";
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
@ -40,6 +41,7 @@ export const adminRoutes: AdminRoute[] = [
...dashboardRoutes,
...appUserRoutes,
...levelConfigRoutes,
...riskConfigRoutes,
...regionBlockRoutes,
...roomRoutes,
...appConfigRoutes,

View File

@ -106,6 +106,12 @@ test("listAppUsers normalizes pretty display id fields from snake case", async (
pretty_id: "pretty-2026",
user_id: "10001",
username: "tester",
vip: {
active: true,
expires_at_ms: 1790000000000,
level: 3,
name: "黄金会员",
},
},
],
page: 1,
@ -127,10 +133,15 @@ test("listAppUsers normalizes pretty display id fields from snake case", async (
prettyId: "pretty-2026",
userId: "10001",
username: "tester",
vip: expect.objectContaining({
active: true,
level: 3,
name: "黄金会员",
}),
});
});
test("getAppUser uses detail endpoint and normalizes pretty display id", async () => {
test("getAppUser uses detail endpoint and normalizes assets", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
@ -144,6 +155,39 @@ test("getAppUser uses detail endpoint and normalizes pretty display id", async (
pretty_display_user_id: "VIP888",
pretty_id: "pretty-888",
user_id: "10001",
balances: [
{
asset_type: "COIN",
available_amount: 1200,
frozen_amount: 50,
total_amount: 1250,
version: 7,
},
],
equipped_resources: [
{
entitlement_id: "ent-1",
equipped: true,
name: "金色头像框",
quantity: 1,
remaining_quantity: 1,
resource_code: "frame_gold",
resource_id: 9,
resource_type: "avatar_frame",
},
],
resources: [
{
entitlement_id: "ent-1",
equipped: true,
name: "金色头像框",
quantity: 1,
remaining_quantity: 1,
resource_code: "frame_gold",
resource_id: 9,
resource_type: "avatar_frame",
},
],
},
}),
),
@ -158,4 +202,20 @@ test("getAppUser uses detail endpoint and normalizes pretty display id", async (
expect(result.defaultDisplayUserId).toBe("123456");
expect(result.prettyDisplayUserId).toBe("VIP888");
expect(result.prettyId).toBe("pretty-888");
expect(result.balances?.[0]).toMatchObject({
assetType: "COIN",
availableAmount: 1200,
frozenAmount: 50,
totalAmount: 1250,
version: 7,
});
expect(result.equippedResources?.[0]).toMatchObject({
equipped: true,
name: "金色头像框",
resourceType: "avatar_frame",
});
expect(result.resources?.[0]).toMatchObject({
entitlementId: "ent-1",
resourceCode: "frame_gold",
});
});

View File

@ -198,6 +198,7 @@ export async function setPrettyDisplayIDStatus(
// 用户相关接口历史上既返回过 snake_case也返回过 camelCase在 API 边界归一化后,共享用户组件只消费稳定字段。
interface RawAppUser {
avatar?: string;
balances?: RawAppUserAssetBalance[];
coin?: number;
country?: string;
country_display_name?: string;
@ -211,6 +212,8 @@ interface RawAppUser {
diamond?: number;
display_user_id?: string;
displayUserId?: string;
equipped_resources?: RawAppUserResource[];
equippedResources?: RawAppUserResource[];
gender?: string;
last_active_at_ms?: number;
lastActiveAtMs?: number;
@ -222,16 +225,77 @@ interface RawAppUser {
regionId?: number;
region_name?: string;
regionName?: string;
resources?: RawAppUserResource[];
status?: string;
updated_at_ms?: number;
updatedAtMs?: number;
user_id?: EntityId;
userId?: EntityId;
username?: string;
vip?: RawAppUserVIP;
}
type RawAppUserBrief = RawAppUser;
interface RawAppUserAssetBalance {
asset_type?: string;
assetType?: string;
available_amount?: number;
availableAmount?: number;
frozen_amount?: number;
frozenAmount?: number;
total_amount?: number;
totalAmount?: number;
updated_at_ms?: number;
updatedAtMs?: number;
version?: number;
}
interface RawAppUserVIP {
active?: boolean;
expires_at_ms?: number;
expiresAtMs?: number;
level?: number;
name?: string;
started_at_ms?: number;
startedAtMs?: number;
updated_at_ms?: number;
updatedAtMs?: number;
}
interface RawAppUserResource {
animation_url?: string;
animationUrl?: string;
asset_url?: string;
assetUrl?: string;
created_at_ms?: number;
createdAtMs?: number;
effective_at_ms?: number;
effectiveAtMs?: number;
entitlement_id?: string;
entitlementId?: string;
equipped?: boolean;
expires_at_ms?: number;
expiresAtMs?: number;
name?: string;
preview_url?: string;
previewUrl?: string;
quantity?: number;
remaining_quantity?: number;
remainingQuantity?: number;
resource_code?: string;
resourceCode?: string;
resource_id?: number;
resourceId?: number;
resource_type?: string;
resourceType?: string;
source_grant_id?: string;
sourceGrantId?: string;
status?: string;
updated_at_ms?: number;
updatedAtMs?: number;
}
interface RawAppUserBanOperator extends RawAppUserBrief {
account?: string;
admin_id?: EntityId;
@ -410,6 +474,7 @@ function normalizePage<TRaw, TItem>(
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
return {
avatar: stringValue(item.avatar),
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
coin: numberValue(item.coin),
country: stringValue(item.country),
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
@ -418,16 +483,65 @@ function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id),
diamond: numberValue(item.diamond),
displayUserId: stringValue(item.displayUserId ?? item.display_user_id),
equippedResources: (item.equippedResources ?? item.equipped_resources ?? []).map(normalizeAppUserResource),
gender: stringValue(item.gender),
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
prettyId: stringValue(item.prettyId ?? item.pretty_id),
regionId: numberValue(item.regionId ?? item.region_id),
regionName: stringValue(item.regionName ?? item.region_name),
resources: (item.resources || []).map(normalizeAppUserResource),
status: stringValue(item.status),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
userId: stringValue(item.userId ?? item.user_id),
username: stringValue(item.username),
vip: normalizeAppUserVIP(item.vip),
};
}
function normalizeAppUserAssetBalance(item: RawAppUserAssetBalance = {}) {
const availableAmount = numberValue(item.availableAmount ?? item.available_amount);
const frozenAmount = numberValue(item.frozenAmount ?? item.frozen_amount);
return {
assetType: stringValue(item.assetType ?? item.asset_type).toUpperCase(),
availableAmount,
frozenAmount,
totalAmount: numberValue(item.totalAmount ?? item.total_amount ?? availableAmount + frozenAmount),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
version: numberValue(item.version),
};
}
function normalizeAppUserVIP(item: RawAppUserVIP = {}) {
return {
active: Boolean(item.active),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
level: numberValue(item.level),
name: stringValue(item.name),
startedAtMs: numberValue(item.startedAtMs ?? item.started_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}
function normalizeAppUserResource(item: RawAppUserResource = {}) {
return {
animationUrl: stringValue(item.animationUrl ?? item.animation_url),
assetUrl: stringValue(item.assetUrl ?? item.asset_url),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
effectiveAtMs: numberValue(item.effectiveAtMs ?? item.effective_at_ms),
entitlementId: stringValue(item.entitlementId ?? item.entitlement_id),
equipped: Boolean(item.equipped),
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
name: stringValue(item.name),
previewUrl: stringValue(item.previewUrl ?? item.preview_url),
quantity: numberValue(item.quantity),
remainingQuantity: numberValue(item.remainingQuantity ?? item.remaining_quantity),
resourceCode: stringValue(item.resourceCode ?? item.resource_code),
resourceId: numberValue(item.resourceId ?? item.resource_id),
resourceType: stringValue(item.resourceType ?? item.resource_type),
sourceGrantId: stringValue(item.sourceGrantId ?? item.source_grant_id),
status: stringValue(item.status),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
};
}

View File

@ -441,6 +441,155 @@
font-weight: 650;
}
.vipBadge,
.vipBadgeMuted {
display: inline-flex;
width: fit-content;
align-items: center;
justify-content: center;
padding: 3px 8px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 800;
line-height: 1.2;
}
.vipBadge {
border: 1px solid color-mix(in srgb, #f2b84b 55%, var(--border));
background: #fff7df;
color: #8a5a00;
}
.vipBadgeMuted {
border: 1px solid var(--border);
background: var(--neutral-surface);
color: var(--text-tertiary);
}
.emptyInline {
display: flex;
min-height: 40px;
align-items: center;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.assetList {
display: grid;
gap: var(--space-2);
}
.assetRow {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
}
.assetName {
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 800;
}
.assetMeta,
.resourceMeta {
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.35;
}
.assetAmount {
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 800;
text-align: right;
}
.resourceList {
display: grid;
gap: var(--space-2);
padding: 0;
margin: 0;
list-style: none;
}
.resourceItem {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
gap: var(--space-3);
align-items: center;
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
}
.resourceThumb,
.resourceThumbFallback {
width: 44px;
height: 44px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--neutral-surface);
}
.resourceThumb {
display: block;
object-fit: cover;
}
.resourceThumbFallback {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
font-size: 16px;
font-weight: 800;
}
.resourceBody {
display: grid;
min-width: 0;
gap: 3px;
}
.resourceTitle {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 800;
}
.resourceTitle span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.resourceTag {
flex: 0 0 auto;
padding: 2px 7px;
border: 1px solid var(--success-border);
border-radius: var(--radius-sm);
background: var(--success-surface);
color: var(--success);
font-size: 11px;
font-weight: 800;
line-height: 1.2;
}
.stack {
display: grid;
gap: 1px;

View File

@ -70,6 +70,9 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
if (!open || !user) {
return null;
}
const balances = user.balances || [];
const equippedResources = user.equippedResources || [];
const resources = user.resources || [];
return (
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
@ -93,6 +96,7 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
<h3 className={styles.detailSectionTitle}>账号状态</h3>
<div className={styles.detailGrid}>
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
<DetailItem label="金币" value={formatNumber(user.coin)} />
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
<DetailItem label="国家" value={formatCountry(user)} />
@ -105,6 +109,18 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
</div>
</section>
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>资产余额</h3>
<AssetBalanceList balances={balances} />
</section>
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>当前佩戴</h3>
<ResourceList resources={equippedResources} />
</section>
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>资源资产</h3>
<ResourceList resources={resources} />
</section>
</SideDrawer>
);
}
@ -128,6 +144,67 @@ function PrettyValue({ user }) {
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
}
function VipDetailValue({ vip }) {
const level = Number(vip?.level || 0);
if (level <= 0 || !vip?.active) {
return "VIP 0";
}
return vip.name ? `VIP ${level} · ${vip.name}` : `VIP ${level}`;
}
function AssetBalanceList({ balances }) {
if (!balances.length) {
return <div className={styles.emptyInline}>当前无数据</div>;
}
return (
<div className={styles.assetList}>
{balances.map((balance) => (
<div className={styles.assetRow} key={balance.assetType}>
<div>
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
<div className={styles.assetMeta}>冻结 {formatNumber(balance.frozenAmount)}</div>
</div>
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
</div>
))}
</div>
);
}
function ResourceList({ resources }) {
if (!resources.length) {
return <div className={styles.emptyInline}>当前无数据</div>;
}
return (
<ul className={styles.resourceList}>
{resources.map((resource) => (
<li className={styles.resourceItem} key={resource.entitlementId || resource.resourceId}>
<ResourceThumb resource={resource} />
<div className={styles.resourceBody}>
<div className={styles.resourceTitle}>
<span>{resource.name || resource.resourceCode || `资源 ${resource.resourceId}`}</span>
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
</div>
<div className={styles.resourceMeta}>
{formatResourceType(resource.resourceType)} · 剩余{" "}
{formatNumber(resource.remainingQuantity)} / {formatNumber(resource.quantity)}
</div>
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
</div>
</li>
))}
</ul>
);
}
function ResourceThumb({ resource }) {
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
if (!imageUrl) {
return <span className={styles.resourceThumbFallback}>{resource.name?.slice(0, 1) || "资"}</span>;
}
return <img alt="" className={styles.resourceThumb} src={imageUrl} />;
}
function AppUserStatus({ status }) {
const tone = status === "active" ? "running" : "danger";
return (
@ -190,6 +267,39 @@ function formatNumber(value) {
return Number.isFinite(number) ? number.toLocaleString() : "-";
}
function formatAssetType(value) {
const normalized = String(value || "").toUpperCase();
const labels = {
COIN: "金币",
DIAMOND: "钻石",
};
return labels[normalized] || normalized || "-";
}
function formatResourceType(value) {
const labels = {
avatar_frame: "头像框",
badge: "徽章",
chat_bubble: "聊天气泡",
emoji_pack: "表情包",
floating_screen: "飘屏",
gift: "礼物",
mic_seat_animation: "麦位动画",
mic_seat_icon: "麦位图标",
profile_card: "资料卡",
vehicle: "座驾",
};
return labels[value] || value || "-";
}
function formatExpireTime(value) {
const expiresAtMs = Number(value || 0);
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) {
return "永久";
}
return formatMillis(expiresAtMs);
}
function firstNonEmpty(...values) {
for (const value of values) {
const normalized = String(value ?? "").trim();

View File

@ -90,6 +90,12 @@ export function AppUserListPage() {
}),
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
},
{
key: "vip",
label: "VIP",
width: "minmax(80px, 0.45fr)",
render: (user) => <VipValue vip={user.vip} />,
},
{
key: "location",
label: "国家 / 区域",
@ -316,6 +322,14 @@ function CoinValue({ page, user }) {
);
}
function VipValue({ vip }) {
const level = Number(vip?.level || 0);
if (level <= 0 || !vip?.active) {
return <span className={styles.vipBadgeMuted}>VIP 0</span>;
}
return <span className={styles.vipBadge}>VIP {level}</span>;
}
function UserLocation({ page, user }) {
const countryLabel = formatCountry(user);
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");

View File

@ -28,6 +28,7 @@ test("clicking an app user's coin opens the coin ledger drawer", () => {
render(<AppUserListPage />);
expect(screen.getByText("VIP 2")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
@ -121,5 +122,6 @@ function userFixture() {
status: "active",
userId: "10001",
username: "d",
vip: { active: true, level: 2, name: "白银会员" },
};
}

View File

@ -8,6 +8,7 @@ import type {
RechargeBillDto,
RechargeProductDto,
RechargeProductPayload,
TemporaryPaymentLinkDto,
ThirdPartyPaymentChannelDto,
ThirdPartyPaymentMethodDto,
ThirdPartyPaymentRateSyncDto,
@ -71,6 +72,27 @@ export function listThirdPartyPaymentChannels(): Promise<ApiList<ThirdPartyPayme
);
}
export function listTemporaryPaymentLinks(query: PageQuery = {}): Promise<ApiPage<TemporaryPaymentLinkDto>> {
const endpoint = API_ENDPOINTS.listTemporaryPaymentLinks;
return apiRequest<ApiPage<TemporaryPaymentLinkDto>>(
apiEndpointPath(API_OPERATIONS.listTemporaryPaymentLinks),
{
method: endpoint.method,
query,
},
);
}
export function getTemporaryPaymentLink(orderId: EntityId): Promise<TemporaryPaymentLinkDto> {
const endpoint = API_ENDPOINTS.getTemporaryPaymentLink;
return apiRequest<TemporaryPaymentLinkDto>(
apiEndpointPath(API_OPERATIONS.getTemporaryPaymentLink, { order_id: orderId }),
{
method: endpoint.method,
},
);
}
export function setThirdPartyPaymentMethodStatus(
methodId: EntityId,
payload: { enabled: boolean },

View File

@ -0,0 +1,106 @@
import { useMemo, useState } from "react";
import { getTemporaryPaymentLink, listTemporaryPaymentLinks } from "@/features/payment/api";
import { usePaymentAbilities } from "@/features/payment/permissions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
const pageSize = 50;
export function useTemporaryPaymentLinksPage() {
const abilities = usePaymentAbilities();
const { showToast } = useToast();
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [providerCode, setProviderCode] = useState("");
const [page, setPage] = useState(1);
const [loadingAction, setLoadingAction] = useState("");
const filters = useMemo(
() => ({
keyword,
provider_code: providerCode,
status,
}),
[keyword, providerCode, status],
);
const result = usePaginatedQuery({
errorMessage: "加载三方临时支付链接失败",
fetcher: listTemporaryPaymentLinks,
filters,
page,
pageSize,
queryKey: ["payment", "temporary-links", filters, page],
});
const resetPage = (setter) => (value) => {
setter(value);
setPage(1);
};
const resetFilters = () => {
setKeyword("");
setStatus("");
setProviderCode("");
setPage(1);
};
const verifyOrder = async (orderId) => {
// 单条核验必须走 admin-server由 wallet-service 按 provider 重新查询状态;前端只传订单号,避免三方签名参数外露。
const id = String(orderId || "").trim();
if (!id || !abilities.canViewTemporaryLink) {
return null;
}
setLoadingAction(`verify:${id}`);
try {
const order = await getTemporaryPaymentLink(id);
await result.reload();
showToast(order?.status === "paid" ? "支付已成功" : `当前状态:${temporaryStatusLabel(order?.status)}`, "success");
return order;
} catch (err) {
showToast(err.message || "验证支付状态失败", "error");
return null;
} finally {
setLoadingAction("");
}
};
return {
abilities,
data: result.data,
error: result.error,
keyword,
loading: result.loading,
loadingAction,
page,
pageSize,
providerCode,
reload: result.reload,
resetFilters,
setKeyword: resetPage(setKeyword),
setPage,
setProviderCode: resetPage(setProviderCode),
setStatus: resetPage(setStatus),
status,
verifyOrder,
};
}
export function temporaryStatusLabel(value) {
if (value === "paid") {
return "已支付";
}
if (value === "redirected") {
return "已生成链接";
}
if (value === "pending") {
return "待支付";
}
if (value === "failed") {
return "失败";
}
if (value === "credited") {
return "已入账";
}
return value || "-";
}

View File

@ -0,0 +1,303 @@
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
import VerifiedOutlined from "@mui/icons-material/VerifiedOutlined";
import { useMemo, useState } from "react";
import { useTemporaryPaymentLinksPage, temporaryStatusLabel } from "@/features/payment/hooks/useTemporaryPaymentLinksPage.js";
import styles from "@/features/payment/payment.module.css";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} 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 { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const statusOptions = [
["", "全部状态"],
["pending", "待支付"],
["redirected", "已生成链接"],
["paid", "已支付"],
["failed", "失败"],
];
const providerOptions = [
["", "全部渠道"],
["mifapay", "MiFaPay"],
["v5pay", "V5Pay"],
];
export function TemporaryPaymentLinksPage() {
const page = useTemporaryPaymentLinksPage();
const items = page.data?.items || [];
const total = page.data?.total || 0;
const columns = useMemo(() => temporaryPaymentLinkColumns(page), [page]);
return (
<AdminListPage>
<AdminListToolbar
actions={
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetFilters}>
重置筛选
</Button>
}
filters={
<>
<StatusSelect
label="支付渠道"
options={providerOptions}
value={page.providerCode}
onChange={page.setProviderCode}
/>
<StatusSelect
label="支付状态"
options={statusOptions}
value={page.status}
onChange={page.setStatus}
/>
</>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={columns}
emptyLabel="当前无数据"
items={items}
minWidth="1420px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data?.pageSize || page.pageSize,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(item) => item.orderId}
/>
</AdminListBody>
</DataState>
</AdminListPage>
);
}
function temporaryPaymentLinkColumns(page) {
return [
{
key: "orderId",
label: "订单号",
width: "minmax(160px, 0.8fr)",
filter: createTextColumnFilter({
placeholder: "搜索订单号、三方单号",
value: page.keyword,
onChange: page.setKeyword,
}),
render: (item) => <CopyableText label="复制订单号" value={item.orderId} />,
},
{
key: "provider",
label: "支付方式",
width: "minmax(170px, 0.85fr)",
filter: createOptionsColumnFilter({
options: providerOptions,
placeholder: "搜索支付渠道",
value: page.providerCode,
onChange: page.setProviderCode,
}),
render: (item) => (
<Stack
primary={providerLabel(item.providerCode)}
secondary={[item.payWay, item.payType].filter(Boolean).join(" / ") || "-"}
/>
),
},
{
key: "amount",
label: "金币/美元",
width: "minmax(140px, 0.65fr)",
render: (item) => (
<Stack primary={`${formatNumber(item.coinAmount)} 金币`} secondary={formatMoney(item.usdMinorAmount, "USD")} />
),
},
{
key: "providerAmount",
label: "三方金额",
width: "minmax(130px, 0.6fr)",
render: (item) => formatMoney(item.providerAmountMinor, item.currencyCode),
},
{
key: "payUrl",
label: "支付链接",
width: "minmax(260px, 1.2fr)",
render: (item) => <PaymentLink value={item.payUrl} />,
},
{
key: "providerOrderId",
label: "三方订单",
width: "minmax(160px, 0.75fr)",
render: (item) => <CopyableText label="复制三方订单" value={item.providerOrderId} />,
},
{
key: "status",
label: "支付状态",
width: "minmax(110px, 0.55fr)",
filter: createOptionsColumnFilter({
options: statusOptions,
placeholder: "搜索状态",
value: page.status,
onChange: page.setStatus,
}),
render: (item) => <StatusBadge status={item.status} />,
},
{
key: "time",
label: "创建时间",
width: "minmax(160px, 0.75fr)",
render: (item) => <Stack primary={formatMillis(item.createdAtMs)} secondary={formatMillis(item.updatedAtMs)} />,
},
{
key: "actions",
label: "操作",
width: "128px",
fixed: "right",
resizable: false,
render: (item) => (
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canViewTemporaryLink || page.loadingAction === `verify:${item.orderId}`}
label="验证支付"
tooltip="验证支付"
onClick={() => page.verifyOrder(item.orderId)}
>
<VerifiedOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!item.payUrl}
label="打开链接"
tooltip="打开链接"
onClick={() => openLink(item.payUrl)}
>
<OpenInNewOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
),
},
];
}
function PaymentLink({ value }) {
if (!value) {
return "-";
}
return (
<span className={styles.temporaryLinkCell}>
<span title={value}>{value}</span>
<CopyButton label="复制支付链接" value={value} />
</span>
);
}
function CopyableText({ label, value }) {
if (!value) {
return "-";
}
return (
<span className={styles.copyableText}>
<span title={String(value)}>{value}</span>
<CopyButton label={label} value={value} />
</span>
);
}
function CopyButton({ label, value }) {
const [copied, setCopied] = useState(false);
const copyValue = async () => {
await copyText(String(value || ""));
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
return (
<span className={styles.copyWrap}>
<AdminActionIconButton disabled={!value} label={label} tooltip={copied ? "已复制" : label} onClick={copyValue}>
<ContentCopyOutlined fontSize="small" />
</AdminActionIconButton>
</span>
);
}
function StatusBadge({ status }) {
const tone =
status === "paid" || status === "credited" ? "succeeded" : status === "failed" ? "danger" : "stopped";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{temporaryStatusLabel(status)}
</span>
);
}
function Stack({ primary, secondary }) {
return (
<div className="cell-stack">
<span>{primary || "-"}</span>
<span className="muted">{secondary || "-"}</span>
</div>
);
}
function providerLabel(value) {
return providerOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
}
function formatNumber(value) {
if (value === 0 || value) {
return Number(value).toLocaleString("zh-CN");
}
return "-";
}
function formatMoney(value, currency = "USD") {
if (!(value === 0 || value)) {
return "-";
}
return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", {
maximumFractionDigits: 2,
minimumFractionDigits: 2,
})}`;
}
function openLink(value) {
if (!value) {
return;
}
window.open(value, "_blank", "noopener,noreferrer");
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// Clipboard API 退
}
}
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}

View File

@ -3,6 +3,23 @@
align-items: center;
}
.copyableText,
.temporaryLinkCell {
display: grid;
min-width: 0;
align-items: center;
gap: var(--space-2);
grid-template-columns: minmax(0, 1fr) var(--control-height);
}
.copyableText > span:first-child,
.temporaryLinkCell > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.identity {
display: inline-flex;
min-width: 0;

View File

@ -10,6 +10,7 @@ export function usePaymentAbilities() {
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
canViewBill: can(PERMISSIONS.paymentBillView),
canViewProduct: can(PERMISSIONS.paymentProductView),
canViewTemporaryLink: can(PERMISSIONS.paymentTemporaryLinkView),
canUpdateThirdParty: can(PERMISSIONS.paymentThirdPartyUpdate),
canViewThirdParty: can(PERMISSIONS.paymentThirdPartyView),
};

View File

@ -17,6 +17,15 @@ export const paymentRoutes = [
path: "/payment/third-party",
permission: PERMISSIONS.paymentThirdPartyView,
},
{
label: "三方临时支付链接",
loader: () =>
import("./pages/TemporaryPaymentLinksPage.jsx").then((module) => module.TemporaryPaymentLinksPage),
menuCode: MENU_CODES.paymentTemporaryLinks,
pageKey: "payment-temporary-links",
path: "/payment/temporary-links",
permission: PERMISSIONS.paymentTemporaryLinkView,
},
{
label: "支付内购商品",
loader: () =>

View File

@ -0,0 +1,48 @@
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
import { apiRequest } from "@/shared/api/request";
export interface RiskConfigDto {
appCode: string;
maxAccountsPerDevice: number;
updatedAtMs: number;
updatedByAdminId: number;
}
export interface RiskConfigPayload {
max_accounts_per_device: number;
}
type RawRiskConfig = RiskConfigDto & Record<string, unknown>;
export function getRiskConfig(): Promise<RiskConfigDto> {
const endpoint = API_ENDPOINTS.getRiskConfig;
return apiRequest<RawRiskConfig>(apiEndpointPath(API_OPERATIONS.getRiskConfig), {
method: endpoint.method,
}).then(normalizeRiskConfig);
}
export function updateRiskConfig(payload: RiskConfigPayload): Promise<RiskConfigDto> {
const endpoint = API_ENDPOINTS.updateRiskConfig;
return apiRequest<RawRiskConfig, RiskConfigPayload>(apiEndpointPath(API_OPERATIONS.updateRiskConfig), {
body: payload,
method: endpoint.method,
}).then(normalizeRiskConfig);
}
function normalizeRiskConfig(data: RawRiskConfig): RiskConfigDto {
return {
appCode: stringValue(data.appCode ?? data.app_code),
maxAccountsPerDevice: numberValue(data.maxAccountsPerDevice ?? data.max_accounts_per_device),
updatedAtMs: numberValue(data.updatedAtMs ?? data.updated_at_ms),
updatedByAdminId: numberValue(data.updatedByAdminId ?? data.updated_by_admin_id),
};
}
function numberValue(value: unknown) {
const number = Number(value ?? 0);
return Number.isFinite(number) ? number : 0;
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value == null ? "" : String(value);
}

View File

@ -0,0 +1,80 @@
import { useCallback, useEffect, useState } from "react";
import { parseForm } from "@/shared/forms/validation";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { getRiskConfig, updateRiskConfig } from "@/features/risk-config/api";
import { useRiskConfigAbilities } from "@/features/risk-config/permissions.js";
import { riskConfigSchema } from "@/features/risk-config/schema";
const emptyData = {
appCode: "",
maxAccountsPerDevice: 1,
updatedAtMs: 0,
updatedByAdminId: 0,
};
export function useRiskConfigPage() {
const abilities = useRiskConfigAbilities();
const { showToast } = useToast();
const [form, setForm] = useState(emptyData);
const [loadingAction, setLoadingAction] = useState("");
const queryFn = useCallback(() => getRiskConfig(), []);
const {
data = emptyData,
error,
loading,
reload,
} = useAdminQuery(queryFn, {
errorMessage: "加载风控配置失败",
initialData: emptyData,
queryKey: ["risk-config", "register"],
});
useEffect(() => {
if (!data) {
return;
}
setForm({
appCode: data.appCode || "",
maxAccountsPerDevice: Number(data.maxAccountsPerDevice || 1),
updatedAtMs: Number(data.updatedAtMs || 0),
updatedByAdminId: Number(data.updatedByAdminId || 0),
});
}, [data]);
const changeMaxAccountsPerDevice = (value) => {
setForm((current) => ({ ...current, maxAccountsPerDevice: value }));
};
const submitConfig = async (event) => {
event.preventDefault();
setLoadingAction("save");
try {
const payload = parseForm(riskConfigSchema, {
maxAccountsPerDevice: form.maxAccountsPerDevice,
});
const saved = await updateRiskConfig({
max_accounts_per_device: payload.maxAccountsPerDevice,
});
setForm(saved);
await reload();
showToast("风控配置已保存", "success");
} catch (err) {
showToast(err.message || "保存失败", "error");
} finally {
setLoadingAction("");
}
};
return {
abilities,
changeMaxAccountsPerDevice,
data,
error,
form,
loading,
loadingAction,
reload,
submitConfig,
};
}

View File

@ -0,0 +1,65 @@
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
import TextField from "@mui/material/TextField";
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { useRiskConfigPage } from "@/features/risk-config/hooks/useRiskConfigPage.js";
import styles from "@/features/risk-config/risk-config.module.css";
export function RiskConfigPage() {
const page = useRiskConfigPage();
const canSave = page.abilities.canUpdate && page.loadingAction !== "save";
return (
<AdminListPage>
<AdminListToolbar
actions={
page.abilities.canUpdate ? (
<AdminActionIconButton
disabled={!canSave}
form="risk-config-form"
label="保存"
primary
type="submit"
>
<SaveOutlined fontSize="small" />
</AdminActionIconButton>
) : null
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<form id="risk-config-form" className={styles.configForm} onSubmit={page.submitConfig}>
<section className={styles.configSection}>
<div className={styles.configHeader}>
<span className={styles.configIcon}>
<ShieldOutlined fontSize="small" />
</span>
<div className={styles.configTitle}>
<h2>注册限制</h2>
<span>
{page.form.updatedAtMs ? (
<TimeText value={page.form.updatedAtMs} />
) : (
"默认配置"
)}
</span>
</div>
</div>
<TextField
disabled={!page.abilities.canUpdate}
inputProps={{ min: 1, step: 1 }}
label="单设备最大账号数"
type="number"
value={page.form.maxAccountsPerDevice}
onChange={(event) => page.changeMaxAccountsPerDevice(event.target.value)}
/>
</section>
</form>
</AdminListBody>
</DataState>
</AdminListPage>
);
}

View File

@ -0,0 +1,11 @@
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { PERMISSIONS } from "@/app/permissions";
export function useRiskConfigAbilities() {
const { can } = useAuth();
return {
canUpdate: can(PERMISSIONS.riskConfigUpdate),
canView: can(PERMISSIONS.riskConfigView),
};
}

View File

@ -0,0 +1,53 @@
.configForm {
max-width: 520px;
}
.configSection {
display: grid;
gap: var(--space-5);
padding: var(--space-5);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card);
}
.configHeader {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.configIcon {
display: inline-flex;
width: 36px;
height: 36px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: var(--radius-control);
background: var(--primary-soft);
color: var(--primary);
}
.configTitle {
min-width: 0;
}
.configTitle h2 {
margin: 0;
color: var(--text-primary);
font-size: 16px;
font-weight: 700;
}
.configTitle span {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
@media (max-width: 720px) {
.configForm {
max-width: none;
}
}

View File

@ -0,0 +1,12 @@
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
export const riskConfigRoutes = [
{
label: "风控管理",
loader: () => import("./pages/RiskConfigPage.jsx").then((module) => module.RiskConfigPage),
menuCode: MENU_CODES.appUserRiskConfig,
pageKey: "app-user-risk-config",
path: "/app/users/risk-config",
permission: PERMISSIONS.riskConfigView,
},
];

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const riskConfigSchema = z.object({
maxAccountsPerDevice: z.coerce.number().int("单设备最大账号数必须是整数").min(1, "单设备最大账号数必须大于 0"),
});
export type RiskConfigForm = z.infer<typeof riskConfigSchema>;

View File

@ -118,6 +118,7 @@ export const API_OPERATIONS = {
getResource: "getResource",
getResourceGroup: "getResourceGroup",
getResourceIdentityAutoGrantConfig: "getResourceIdentityAutoGrantConfig",
getRiskConfig: "getRiskConfig",
getRoleDataScopes: "getRoleDataScopes",
getRoomConfig: "getRoomConfig",
getRoomRocketConfig: "getRoomRocketConfig",
@ -126,6 +127,7 @@ export const API_OPERATIONS = {
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
getRoomWhitelistConfig: "getRoomWhitelistConfig",
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
getTemporaryPaymentLink: "getTemporaryPaymentLink",
getUser: "getUser",
getWeeklyStarCycle: "getWeeklyStarCycle",
getWheelConfig: "getWheelConfig",
@ -197,6 +199,7 @@ export const API_OPERATIONS = {
listSystemMenus: "listSystemMenus",
listTaskDefinitions: "listTaskDefinitions",
listTeamSalaryPolicies: "listTeamSalaryPolicies",
listTemporaryPaymentLinks: "listTemporaryPaymentLinks",
listThirdPartyPaymentChannels: "listThirdPartyPaymentChannels",
listUserLeaderboards: "listUserLeaderboards",
listUsers: "listUsers",
@ -270,6 +273,7 @@ export const API_OPERATIONS = {
updateResourceGroup: "updateResourceGroup",
updateResourceGroupItems: "updateResourceGroupItems",
updateResourceIdentityAutoGrantConfig: "updateResourceIdentityAutoGrantConfig",
updateRiskConfig: "updateRiskConfig",
updateRole: "updateRole",
updateRoom: "updateRoom",
updateRoomConfig: "updateRoomConfig",
@ -1031,6 +1035,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-group:view",
permissions: ["resource-group:view"]
},
getRiskConfig: {
method: "GET",
operationId: API_OPERATIONS.getRiskConfig,
path: "/v1/admin/users/risk-config",
permission: "risk-config:view",
permissions: ["risk-config:view"]
},
getRoleDataScopes: {
method: "GET",
operationId: API_OPERATIONS.getRoleDataScopes,
@ -1087,6 +1098,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "seven-day-checkin:view",
permissions: ["seven-day-checkin:view"]
},
getTemporaryPaymentLink: {
method: "GET",
operationId: API_OPERATIONS.getTemporaryPaymentLink,
path: "/v1/admin/payment/temporary-links/{order_id}",
permission: "payment-temporary-link:view",
permissions: ["payment-temporary-link:view"]
},
getUser: {
method: "GET",
operationId: API_OPERATIONS.getUser,
@ -1582,6 +1600,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "team-salary-policy:view",
permissions: ["team-salary-policy:view"]
},
listTemporaryPaymentLinks: {
method: "GET",
operationId: API_OPERATIONS.listTemporaryPaymentLinks,
path: "/v1/admin/payment/temporary-links",
permission: "payment-temporary-link:view",
permissions: ["payment-temporary-link:view"]
},
listThirdPartyPaymentChannels: {
method: "GET",
operationId: API_OPERATIONS.listThirdPartyPaymentChannels,
@ -2081,6 +2106,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-group:update",
permissions: ["resource-group:update"]
},
updateRiskConfig: {
method: "PUT",
operationId: API_OPERATIONS.updateRiskConfig,
path: "/v1/admin/users/risk-config",
permission: "risk-config:update",
permissions: ["risk-config:update"]
},
updateRole: {
method: "PATCH",
operationId: API_OPERATIONS.updateRole,

View File

@ -1828,6 +1828,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/payment/temporary-links": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listTemporaryPaymentLinks"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/temporary-links/{order_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getTemporaryPaymentLink"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/third-party-channels": {
parameters: {
query?: never;
@ -2532,6 +2564,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/users/risk-config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRiskConfig"];
put: operations["updateRiskConfig"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/app/users": {
parameters: {
query?: never;
@ -5925,6 +5973,32 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listTemporaryPaymentLinks: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getTemporaryPaymentLink: {
parameters: {
query?: never;
header?: never;
path: {
order_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listThirdPartyPaymentChannels: {
parameters: {
query?: never;
@ -6704,6 +6778,36 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
getRiskConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateRiskConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: {
content: {
"application/json": {
max_accounts_per_device: number;
};
};
};
responses: {
200: components["responses"]["EmptyResponse"];
};
};
appListUsers: {
parameters: {
query?: never;

View File

@ -473,6 +473,30 @@ export interface ThirdPartyPaymentChannelDto {
status?: string;
}
export interface TemporaryPaymentLinkDto {
appCode?: string;
audienceType?: string;
coinAmount: number;
countryCode?: string;
createdAtMs?: number;
currencyCode?: string;
failureReason?: string;
orderId: string;
payType?: string;
payUrl?: string;
payWay?: string;
paymentMethodId?: EntityId;
productName?: string;
providerAmountMinor?: number;
providerCode?: string;
providerOrderId?: string;
status?: string;
transactionId?: string;
txHash?: string;
updatedAtMs?: number;
usdMinorAmount: number;
}
export interface ThirdPartyPaymentRateSyncDto {
missingCurrencies?: string[];
rates?: Record<string, string>;
@ -730,6 +754,7 @@ export interface CoinSellerSalaryRatesPayload {
export interface AppUserDto {
avatar?: string;
balances?: AppUserAssetBalanceDto[];
coin?: number;
country?: string;
countryDisplayName?: string;
@ -738,16 +763,57 @@ export interface AppUserDto {
defaultDisplayUserId?: string;
diamond?: number;
displayUserId?: string;
equippedResources?: AppUserResourceDto[];
gender?: string;
lastActiveAtMs?: number;
prettyDisplayUserId?: string;
prettyId?: string;
regionId?: number;
regionName?: string;
resources?: AppUserResourceDto[];
status?: string;
updatedAtMs?: number;
userId: string;
username?: string;
vip?: AppUserVIPDto;
}
export interface AppUserAssetBalanceDto {
assetType: string;
availableAmount: number;
frozenAmount: number;
totalAmount: number;
version: number;
updatedAtMs: number;
}
export interface AppUserVIPDto {
active: boolean;
expiresAtMs: number;
level: number;
name?: string;
startedAtMs: number;
updatedAtMs: number;
}
export interface AppUserResourceDto {
animationUrl?: string;
assetUrl?: string;
createdAtMs: number;
effectiveAtMs: number;
entitlementId: string;
equipped: boolean;
expiresAtMs: number;
name: string;
previewUrl?: string;
quantity: number;
remainingQuantity: number;
resourceCode: string;
resourceId: number;
resourceType: string;
sourceGrantId?: string;
status: string;
updatedAtMs: number;
}
export interface AppUserBriefDto {