三方支付临时链接
This commit is contained in:
parent
bc9484c60d
commit
e51a4401ef
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
],
|
||||
},
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}` : "-");
|
||||
|
||||
@ -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: "白银会员" },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
|
||||
106
src/features/payment/hooks/useTemporaryPaymentLinksPage.js
Normal file
106
src/features/payment/hooks/useTemporaryPaymentLinksPage.js
Normal 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 || "-";
|
||||
}
|
||||
303
src/features/payment/pages/TemporaryPaymentLinksPage.jsx
Normal file
303
src/features/payment/pages/TemporaryPaymentLinksPage.jsx
Normal 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();
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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: () =>
|
||||
|
||||
48
src/features/risk-config/api.ts
Normal file
48
src/features/risk-config/api.ts
Normal 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);
|
||||
}
|
||||
80
src/features/risk-config/hooks/useRiskConfigPage.js
Normal file
80
src/features/risk-config/hooks/useRiskConfigPage.js
Normal 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,
|
||||
};
|
||||
}
|
||||
65
src/features/risk-config/pages/RiskConfigPage.jsx
Normal file
65
src/features/risk-config/pages/RiskConfigPage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/features/risk-config/permissions.js
Normal file
11
src/features/risk-config/permissions.js
Normal 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),
|
||||
};
|
||||
}
|
||||
53
src/features/risk-config/risk-config.module.css
Normal file
53
src/features/risk-config/risk-config.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
12
src/features/risk-config/routes.js
Normal file
12
src/features/risk-config/routes.js
Normal 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,
|
||||
},
|
||||
];
|
||||
7
src/features/risk-config/schema.ts
Normal file
7
src/features/risk-config/schema.ts
Normal 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>;
|
||||
@ -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,
|
||||
|
||||
104
src/shared/api/generated/schema.d.ts
vendored
104
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user