三方支付临时链接
This commit is contained in:
parent
bc9484c60d
commit
e51a4401ef
@ -2637,6 +2637,40 @@
|
|||||||
"x-permissions": ["payment-product:delete"]
|
"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": {
|
"/admin/payment/third-party-channels": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listThirdPartyPaymentChannels",
|
"operationId": "listThirdPartyPaymentChannels",
|
||||||
@ -3612,6 +3646,44 @@
|
|||||||
"x-permissions": ["region-block:update"]
|
"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": {
|
"/app/users": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "appListUsers",
|
"operationId": "appListUsers",
|
||||||
|
|||||||
@ -115,6 +115,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
||||||
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
||||||
routeNavItem("app-user-pretty-ids", { icon: LocalOfferOutlined }),
|
routeNavItem("app-user-pretty-ids", { icon: LocalOfferOutlined }),
|
||||||
|
routeNavItem("app-user-risk-config", { icon: ShieldOutlined }),
|
||||||
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -198,6 +199,7 @@ export const fallbackNavigation = [
|
|||||||
children: [
|
children: [
|
||||||
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined }),
|
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined }),
|
||||||
routeNavItem("payment-third-party", { icon: WalletOutlined }),
|
routeNavItem("payment-third-party", { icon: WalletOutlined }),
|
||||||
|
routeNavItem("payment-temporary-links", { icon: ReceiptLongOutlined }),
|
||||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -52,6 +52,7 @@ export const PERMISSIONS = {
|
|||||||
paymentProductCreate: "payment-product:create",
|
paymentProductCreate: "payment-product:create",
|
||||||
paymentProductUpdate: "payment-product:update",
|
paymentProductUpdate: "payment-product:update",
|
||||||
paymentProductDelete: "payment-product:delete",
|
paymentProductDelete: "payment-product:delete",
|
||||||
|
paymentTemporaryLinkView: "payment-temporary-link:view",
|
||||||
paymentThirdPartyView: "payment-third-party:view",
|
paymentThirdPartyView: "payment-third-party:view",
|
||||||
paymentThirdPartyUpdate: "payment-third-party:update",
|
paymentThirdPartyUpdate: "payment-third-party:update",
|
||||||
gameView: "game:view",
|
gameView: "game:view",
|
||||||
@ -89,6 +90,8 @@ export const PERMISSIONS = {
|
|||||||
prettyIdGrant: "pretty-id:grant",
|
prettyIdGrant: "pretty-id:grant",
|
||||||
levelConfigView: "level-config:view",
|
levelConfigView: "level-config:view",
|
||||||
levelConfigUpdate: "level-config:update",
|
levelConfigUpdate: "level-config:update",
|
||||||
|
riskConfigView: "risk-config:view",
|
||||||
|
riskConfigUpdate: "risk-config:update",
|
||||||
regionBlockView: "region-block:view",
|
regionBlockView: "region-block:view",
|
||||||
regionBlockUpdate: "region-block:update",
|
regionBlockUpdate: "region-block:update",
|
||||||
roomView: "room:view",
|
roomView: "room:view",
|
||||||
@ -190,6 +193,7 @@ export const MENU_CODES = {
|
|||||||
appUserBans: "app-user-bans",
|
appUserBans: "app-user-bans",
|
||||||
appUserLoginLogs: "app-user-login-logs",
|
appUserLoginLogs: "app-user-login-logs",
|
||||||
appUserLevelConfig: "app-user-level-config",
|
appUserLevelConfig: "app-user-level-config",
|
||||||
|
appUserRiskConfig: "app-user-risk-config",
|
||||||
appUserRegionBlocks: "app-user-region-blocks",
|
appUserRegionBlocks: "app-user-region-blocks",
|
||||||
appUserPrettyIds: "app-user-pretty-ids",
|
appUserPrettyIds: "app-user-pretty-ids",
|
||||||
rooms: "rooms",
|
rooms: "rooms",
|
||||||
@ -252,6 +256,7 @@ export const MENU_CODES = {
|
|||||||
payment: "payment",
|
payment: "payment",
|
||||||
paymentBillList: "payment-bill-list",
|
paymentBillList: "payment-bill-list",
|
||||||
paymentRechargeProducts: "payment-recharge-products",
|
paymentRechargeProducts: "payment-recharge-products",
|
||||||
|
paymentTemporaryLinks: "payment-temporary-links",
|
||||||
paymentThirdParty: "payment-third-party",
|
paymentThirdParty: "payment-third-party",
|
||||||
games: "games",
|
games: "games",
|
||||||
gameList: "game-list",
|
gameList: "game-list",
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { registrationRewardRoutes } from "@/features/registration-reward/routes.
|
|||||||
import { redPacketRoutes } from "@/features/red-packets/routes.js";
|
import { redPacketRoutes } from "@/features/red-packets/routes.js";
|
||||||
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
|
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
|
||||||
import { resourceRoutes } from "@/features/resources/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 { rolesRoutes } from "@/features/roles/routes.js";
|
||||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||||
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
|
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
|
||||||
@ -40,6 +41,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...dashboardRoutes,
|
...dashboardRoutes,
|
||||||
...appUserRoutes,
|
...appUserRoutes,
|
||||||
...levelConfigRoutes,
|
...levelConfigRoutes,
|
||||||
|
...riskConfigRoutes,
|
||||||
...regionBlockRoutes,
|
...regionBlockRoutes,
|
||||||
...roomRoutes,
|
...roomRoutes,
|
||||||
...appConfigRoutes,
|
...appConfigRoutes,
|
||||||
|
|||||||
@ -106,6 +106,12 @@ test("listAppUsers normalizes pretty display id fields from snake case", async (
|
|||||||
pretty_id: "pretty-2026",
|
pretty_id: "pretty-2026",
|
||||||
user_id: "10001",
|
user_id: "10001",
|
||||||
username: "tester",
|
username: "tester",
|
||||||
|
vip: {
|
||||||
|
active: true,
|
||||||
|
expires_at_ms: 1790000000000,
|
||||||
|
level: 3,
|
||||||
|
name: "黄金会员",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -127,10 +133,15 @@ test("listAppUsers normalizes pretty display id fields from snake case", async (
|
|||||||
prettyId: "pretty-2026",
|
prettyId: "pretty-2026",
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
username: "tester",
|
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(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(
|
vi.fn(
|
||||||
@ -144,6 +155,39 @@ test("getAppUser uses detail endpoint and normalizes pretty display id", async (
|
|||||||
pretty_display_user_id: "VIP888",
|
pretty_display_user_id: "VIP888",
|
||||||
pretty_id: "pretty-888",
|
pretty_id: "pretty-888",
|
||||||
user_id: "10001",
|
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.defaultDisplayUserId).toBe("123456");
|
||||||
expect(result.prettyDisplayUserId).toBe("VIP888");
|
expect(result.prettyDisplayUserId).toBe("VIP888");
|
||||||
expect(result.prettyId).toBe("pretty-888");
|
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 边界归一化后,共享用户组件只消费稳定字段。
|
// 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。
|
||||||
interface RawAppUser {
|
interface RawAppUser {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
balances?: RawAppUserAssetBalance[];
|
||||||
coin?: number;
|
coin?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
country_display_name?: string;
|
country_display_name?: string;
|
||||||
@ -211,6 +212,8 @@ interface RawAppUser {
|
|||||||
diamond?: number;
|
diamond?: number;
|
||||||
display_user_id?: string;
|
display_user_id?: string;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
equipped_resources?: RawAppUserResource[];
|
||||||
|
equippedResources?: RawAppUserResource[];
|
||||||
gender?: string;
|
gender?: string;
|
||||||
last_active_at_ms?: number;
|
last_active_at_ms?: number;
|
||||||
lastActiveAtMs?: number;
|
lastActiveAtMs?: number;
|
||||||
@ -222,16 +225,77 @@ interface RawAppUser {
|
|||||||
regionId?: number;
|
regionId?: number;
|
||||||
region_name?: string;
|
region_name?: string;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
|
resources?: RawAppUserResource[];
|
||||||
status?: string;
|
status?: string;
|
||||||
updated_at_ms?: number;
|
updated_at_ms?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
user_id?: EntityId;
|
user_id?: EntityId;
|
||||||
userId?: EntityId;
|
userId?: EntityId;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
vip?: RawAppUserVIP;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawAppUserBrief = RawAppUser;
|
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 {
|
interface RawAppUserBanOperator extends RawAppUserBrief {
|
||||||
account?: string;
|
account?: string;
|
||||||
admin_id?: EntityId;
|
admin_id?: EntityId;
|
||||||
@ -410,6 +474,7 @@ function normalizePage<TRaw, TItem>(
|
|||||||
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
||||||
return {
|
return {
|
||||||
avatar: stringValue(item.avatar),
|
avatar: stringValue(item.avatar),
|
||||||
|
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
|
||||||
coin: numberValue(item.coin),
|
coin: numberValue(item.coin),
|
||||||
country: stringValue(item.country),
|
country: stringValue(item.country),
|
||||||
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
|
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),
|
defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id),
|
||||||
diamond: numberValue(item.diamond),
|
diamond: numberValue(item.diamond),
|
||||||
displayUserId: stringValue(item.displayUserId ?? item.display_user_id),
|
displayUserId: stringValue(item.displayUserId ?? item.display_user_id),
|
||||||
|
equippedResources: (item.equippedResources ?? item.equipped_resources ?? []).map(normalizeAppUserResource),
|
||||||
gender: stringValue(item.gender),
|
gender: stringValue(item.gender),
|
||||||
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
|
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
|
||||||
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
|
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
|
||||||
prettyId: stringValue(item.prettyId ?? item.pretty_id),
|
prettyId: stringValue(item.prettyId ?? item.pretty_id),
|
||||||
regionId: numberValue(item.regionId ?? item.region_id),
|
regionId: numberValue(item.regionId ?? item.region_id),
|
||||||
regionName: stringValue(item.regionName ?? item.region_name),
|
regionName: stringValue(item.regionName ?? item.region_name),
|
||||||
|
resources: (item.resources || []).map(normalizeAppUserResource),
|
||||||
status: stringValue(item.status),
|
status: stringValue(item.status),
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: stringValue(item.userId ?? item.user_id),
|
||||||
username: stringValue(item.username),
|
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;
|
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 {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
|||||||
@ -70,6 +70,9 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
if (!open || !user) {
|
if (!open || !user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const balances = user.balances || [];
|
||||||
|
const equippedResources = user.equippedResources || [];
|
||||||
|
const resources = user.resources || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
|
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
|
||||||
@ -93,6 +96,7 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
||||||
<div className={styles.detailGrid}>
|
<div className={styles.detailGrid}>
|
||||||
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
<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.coin)} />
|
||||||
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
||||||
<DetailItem label="国家" value={formatCountry(user)} />
|
<DetailItem label="国家" value={formatCountry(user)} />
|
||||||
@ -105,6 +109,18 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</SideDrawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -128,6 +144,67 @@ function PrettyValue({ user }) {
|
|||||||
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
|
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 }) {
|
function AppUserStatus({ status }) {
|
||||||
const tone = status === "active" ? "running" : "danger";
|
const tone = status === "active" ? "running" : "danger";
|
||||||
return (
|
return (
|
||||||
@ -190,6 +267,39 @@ function formatNumber(value) {
|
|||||||
return Number.isFinite(number) ? number.toLocaleString() : "-";
|
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) {
|
function firstNonEmpty(...values) {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const normalized = String(value ?? "").trim();
|
const normalized = String(value ?? "").trim();
|
||||||
|
|||||||
@ -90,6 +90,12 @@ export function AppUserListPage() {
|
|||||||
}),
|
}),
|
||||||
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "vip",
|
||||||
|
label: "VIP",
|
||||||
|
width: "minmax(80px, 0.45fr)",
|
||||||
|
render: (user) => <VipValue vip={user.vip} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "location",
|
key: "location",
|
||||||
label: "国家 / 区域",
|
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 }) {
|
function UserLocation({ page, user }) {
|
||||||
const countryLabel = formatCountry(user);
|
const countryLabel = formatCountry(user);
|
||||||
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
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 />);
|
render(<AppUserListPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("VIP 2")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
||||||
|
|
||||||
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
||||||
@ -121,5 +122,6 @@ function userFixture() {
|
|||||||
status: "active",
|
status: "active",
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
username: "d",
|
username: "d",
|
||||||
|
vip: { active: true, level: 2, name: "白银会员" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import type {
|
|||||||
RechargeBillDto,
|
RechargeBillDto,
|
||||||
RechargeProductDto,
|
RechargeProductDto,
|
||||||
RechargeProductPayload,
|
RechargeProductPayload,
|
||||||
|
TemporaryPaymentLinkDto,
|
||||||
ThirdPartyPaymentChannelDto,
|
ThirdPartyPaymentChannelDto,
|
||||||
ThirdPartyPaymentMethodDto,
|
ThirdPartyPaymentMethodDto,
|
||||||
ThirdPartyPaymentRateSyncDto,
|
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(
|
export function setThirdPartyPaymentMethodStatus(
|
||||||
methodId: EntityId,
|
methodId: EntityId,
|
||||||
payload: { enabled: boolean },
|
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;
|
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 {
|
.identity {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export function usePaymentAbilities() {
|
|||||||
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
|
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
|
||||||
canViewBill: can(PERMISSIONS.paymentBillView),
|
canViewBill: can(PERMISSIONS.paymentBillView),
|
||||||
canViewProduct: can(PERMISSIONS.paymentProductView),
|
canViewProduct: can(PERMISSIONS.paymentProductView),
|
||||||
|
canViewTemporaryLink: can(PERMISSIONS.paymentTemporaryLinkView),
|
||||||
canUpdateThirdParty: can(PERMISSIONS.paymentThirdPartyUpdate),
|
canUpdateThirdParty: can(PERMISSIONS.paymentThirdPartyUpdate),
|
||||||
canViewThirdParty: can(PERMISSIONS.paymentThirdPartyView),
|
canViewThirdParty: can(PERMISSIONS.paymentThirdPartyView),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,6 +17,15 @@ export const paymentRoutes = [
|
|||||||
path: "/payment/third-party",
|
path: "/payment/third-party",
|
||||||
permission: PERMISSIONS.paymentThirdPartyView,
|
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: "支付内购商品",
|
label: "支付内购商品",
|
||||||
loader: () =>
|
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",
|
getResource: "getResource",
|
||||||
getResourceGroup: "getResourceGroup",
|
getResourceGroup: "getResourceGroup",
|
||||||
getResourceIdentityAutoGrantConfig: "getResourceIdentityAutoGrantConfig",
|
getResourceIdentityAutoGrantConfig: "getResourceIdentityAutoGrantConfig",
|
||||||
|
getRiskConfig: "getRiskConfig",
|
||||||
getRoleDataScopes: "getRoleDataScopes",
|
getRoleDataScopes: "getRoleDataScopes",
|
||||||
getRoomConfig: "getRoomConfig",
|
getRoomConfig: "getRoomConfig",
|
||||||
getRoomRocketConfig: "getRoomRocketConfig",
|
getRoomRocketConfig: "getRoomRocketConfig",
|
||||||
@ -126,6 +127,7 @@ export const API_OPERATIONS = {
|
|||||||
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
|
||||||
getRoomWhitelistConfig: "getRoomWhitelistConfig",
|
getRoomWhitelistConfig: "getRoomWhitelistConfig",
|
||||||
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
|
||||||
|
getTemporaryPaymentLink: "getTemporaryPaymentLink",
|
||||||
getUser: "getUser",
|
getUser: "getUser",
|
||||||
getWeeklyStarCycle: "getWeeklyStarCycle",
|
getWeeklyStarCycle: "getWeeklyStarCycle",
|
||||||
getWheelConfig: "getWheelConfig",
|
getWheelConfig: "getWheelConfig",
|
||||||
@ -197,6 +199,7 @@ export const API_OPERATIONS = {
|
|||||||
listSystemMenus: "listSystemMenus",
|
listSystemMenus: "listSystemMenus",
|
||||||
listTaskDefinitions: "listTaskDefinitions",
|
listTaskDefinitions: "listTaskDefinitions",
|
||||||
listTeamSalaryPolicies: "listTeamSalaryPolicies",
|
listTeamSalaryPolicies: "listTeamSalaryPolicies",
|
||||||
|
listTemporaryPaymentLinks: "listTemporaryPaymentLinks",
|
||||||
listThirdPartyPaymentChannels: "listThirdPartyPaymentChannels",
|
listThirdPartyPaymentChannels: "listThirdPartyPaymentChannels",
|
||||||
listUserLeaderboards: "listUserLeaderboards",
|
listUserLeaderboards: "listUserLeaderboards",
|
||||||
listUsers: "listUsers",
|
listUsers: "listUsers",
|
||||||
@ -270,6 +273,7 @@ export const API_OPERATIONS = {
|
|||||||
updateResourceGroup: "updateResourceGroup",
|
updateResourceGroup: "updateResourceGroup",
|
||||||
updateResourceGroupItems: "updateResourceGroupItems",
|
updateResourceGroupItems: "updateResourceGroupItems",
|
||||||
updateResourceIdentityAutoGrantConfig: "updateResourceIdentityAutoGrantConfig",
|
updateResourceIdentityAutoGrantConfig: "updateResourceIdentityAutoGrantConfig",
|
||||||
|
updateRiskConfig: "updateRiskConfig",
|
||||||
updateRole: "updateRole",
|
updateRole: "updateRole",
|
||||||
updateRoom: "updateRoom",
|
updateRoom: "updateRoom",
|
||||||
updateRoomConfig: "updateRoomConfig",
|
updateRoomConfig: "updateRoomConfig",
|
||||||
@ -1031,6 +1035,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "resource-group:view",
|
permission: "resource-group:view",
|
||||||
permissions: ["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: {
|
getRoleDataScopes: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getRoleDataScopes,
|
operationId: API_OPERATIONS.getRoleDataScopes,
|
||||||
@ -1087,6 +1098,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "seven-day-checkin:view",
|
permission: "seven-day-checkin:view",
|
||||||
permissions: ["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: {
|
getUser: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.getUser,
|
operationId: API_OPERATIONS.getUser,
|
||||||
@ -1582,6 +1600,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "team-salary-policy:view",
|
permission: "team-salary-policy:view",
|
||||||
permissions: ["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: {
|
listThirdPartyPaymentChannels: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.listThirdPartyPaymentChannels,
|
operationId: API_OPERATIONS.listThirdPartyPaymentChannels,
|
||||||
@ -2081,6 +2106,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "resource-group:update",
|
permission: "resource-group:update",
|
||||||
permissions: ["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: {
|
updateRole: {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
operationId: API_OPERATIONS.updateRole,
|
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;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/admin/payment/third-party-channels": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2532,6 +2564,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/app/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -5925,6 +5973,32 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
listThirdPartyPaymentChannels: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -6704,6 +6778,36 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
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: {
|
appListUsers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -473,6 +473,30 @@ export interface ThirdPartyPaymentChannelDto {
|
|||||||
status?: string;
|
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 {
|
export interface ThirdPartyPaymentRateSyncDto {
|
||||||
missingCurrencies?: string[];
|
missingCurrencies?: string[];
|
||||||
rates?: Record<string, string>;
|
rates?: Record<string, string>;
|
||||||
@ -730,6 +754,7 @@ export interface CoinSellerSalaryRatesPayload {
|
|||||||
|
|
||||||
export interface AppUserDto {
|
export interface AppUserDto {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
balances?: AppUserAssetBalanceDto[];
|
||||||
coin?: number;
|
coin?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
countryDisplayName?: string;
|
countryDisplayName?: string;
|
||||||
@ -738,16 +763,57 @@ export interface AppUserDto {
|
|||||||
defaultDisplayUserId?: string;
|
defaultDisplayUserId?: string;
|
||||||
diamond?: number;
|
diamond?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
equippedResources?: AppUserResourceDto[];
|
||||||
gender?: string;
|
gender?: string;
|
||||||
lastActiveAtMs?: number;
|
lastActiveAtMs?: number;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
regionId?: number;
|
regionId?: number;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
|
resources?: AppUserResourceDto[];
|
||||||
status?: string;
|
status?: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
username?: 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 {
|
export interface AppUserBriefDto {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user