From a4dd49c33ddb29956512e19c0edde4f0d6e77f45 Mon Sep 17 00:00:00 2001 From: zhx Date: Sun, 12 Jul 2026 14:17:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E7=94=A8=E6=88=B7=E4=BF=A1?= =?UTF-8?q?=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 85 ++++++++++ src/app/permissions.ts | 1 + src/features/app-users/api.test.ts | 92 ++++++++++ src/features/app-users/api.ts | 124 ++++++++++++++ .../components/AppUserDetailProvider.test.jsx | 56 +++++- .../components/AppUserDetailView.jsx | 160 +++++++++++++++++- .../pages/AppUserDetailPage.test.jsx | 3 +- src/shared/api/generated/endpoints.ts | 8 + src/shared/api/generated/schema.d.ts | 85 ++++++++++ src/shared/api/types.ts | 39 +++++ 10 files changed, 648 insertions(+), 5 deletions(-) diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index b5f3aab..ea51bd0 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -5857,6 +5857,23 @@ "x-permissions": ["app-user:update"] } }, + "/app/users/{id}/access-token": { + "post": { + "operationId": "appIssueUserAccessToken", + "responses": { + "200": { + "$ref": "#/components/responses/AppUserAccessTokenResponse" + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/Id" + } + ], + "x-permission": "app-user:token", + "x-permissions": ["app-user:token"] + } + }, "/app/users/{id}/ban": { "post": { "operationId": "appBanUser", @@ -7647,6 +7664,14 @@ } } }, + "AppUserAccessTokenResponse": { + "description": "OK", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ApiResponseAppUserAccessToken" } + } + } + }, "AgencyPageResponse": { "description": "OK", "content": { @@ -8321,6 +8346,15 @@ { "type": "object", "properties": { "data": { "$ref": "#/components/schemas/AppUserExportJob" } } } ] }, + "ApiResponseAppUserAccessToken": { + "allOf": [ + { "$ref": "#/components/schemas/Envelope" }, + { + "type": "object", + "properties": { "data": { "$ref": "#/components/schemas/AppUserAccessToken" } } + } + ] + }, "ApiPageAppUser": { "type": "object", "required": ["items", "page", "pageSize", "total"], @@ -8351,6 +8385,19 @@ "regionId": { "type": "integer", "format": "int64" }, "regionName": { "type": "string" }, "registerDevice": { "type": "string" }, + "registerDeviceId": { "type": "string" }, + "registerOsVersion": { "type": "string" }, + "lastLoginIp": { "type": "string" }, + "lastLoginIpCountryCode": { "type": "string" }, + "lastLoginIpAtMs": { "type": "integer", "format": "int64" }, + "appVersion": { "type": "string" }, + "buildNumber": { "type": "string" }, + "appVersionAtMs": { "type": "integer", "format": "int64" }, + "thirdPartyIdentities": { + "type": "array", + "items": { "$ref": "#/components/schemas/AppUserThirdPartyIdentity" } + }, + "devices": { "type": "array", "items": { "$ref": "#/components/schemas/AppUserDevice" } }, "roles": { "type": "array", "items": { "type": "string" } }, "levels": { "$ref": "#/components/schemas/AppUserLevels" }, "ban": { "$ref": "#/components/schemas/AppUserBan" }, @@ -8462,6 +8509,44 @@ "updatedAtMs": { "type": "integer", "format": "int64" } } }, + "AppUserThirdPartyIdentity": { + "type": "object", + "required": ["provider", "openid"], + "properties": { + "provider": { "type": "string" }, + "openid": { "type": "string" }, + "unionId": { "type": "string" }, + "createdAtMs": { "type": "integer", "format": "int64" } + } + }, + "AppUserDevice": { + "type": "object", + "required": ["deviceId"], + "properties": { + "deviceId": { "type": "string" }, + "platform": { "type": "string" }, + "deviceModel": { "type": "string" }, + "manufacturer": { "type": "string" }, + "osVersion": { "type": "string" }, + "imei": { "type": "string" }, + "appVersion": { "type": "string" }, + "buildNumber": { "type": "string" }, + "firstSeenAtMs": { "type": "integer", "format": "int64" }, + "lastSeenAtMs": { "type": "integer", "format": "int64" } + } + }, + "AppUserAccessToken": { + "type": "object", + "required": ["accessToken", "tokenType", "expiresInSec"], + "properties": { + "accessToken": { "type": "string" }, + "tokenType": { "type": "string" }, + "expiresInSec": { "type": "integer", "format": "int64" }, + "sessionId": { "type": "string" }, + "deviceId": { "type": "string" }, + "sessionLastHeartbeatAtMs": { "type": "integer", "format": "int64" } + } + }, "AppUserUpdateInput": { "type": "object", "properties": { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index 322e7d2..179f5a5 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -113,6 +113,7 @@ export const PERMISSIONS = { appUserUpdate: "app-user:update", appUserStatus: "app-user:status", appUserPassword: "app-user:password", + appUserToken: "app-user:token", appUserLevel: "app-user:level", appUserExport: "app-user:export", prettyIdView: "pretty-id:view", diff --git a/src/features/app-users/api.test.ts b/src/features/app-users/api.test.ts index f68617d..b4fe01a 100644 --- a/src/features/app-users/api.test.ts +++ b/src/features/app-users/api.test.ts @@ -5,6 +5,7 @@ import { banAppUser, createAppUserExport, getAppUser, + issueAppUserAccessToken, listAppUsers, listPrettyDisplayIDs, recyclePrettyDisplayID, @@ -371,6 +372,8 @@ test("app user projection normalizes levels roles ban and successful login field code: 0, data: { age: 26, + app_version: "3.2.1", + app_version_at_ms: 1779990000000, ban: { active: true, expires_at_ms: 1790000000000, @@ -380,8 +383,26 @@ test("app user projection normalizes levels roles ban and successful login field source: "admin", }, birth: "2000-02-29", + build_number: "345", cumulative_recharge_usd_minor: 12345, + devices: [ + { + app_version: "3.2.1", + build_number: "345", + device_id: "android_abcdef", + device_model: "Redmi Note 8 Pro", + first_seen_at_ms: 1770000000000, + imei: "", + last_seen_at_ms: 1781500000000, + manufacturer: "Xiaomi", + os_version: "13", + platform: "android", + }, + ], last_login_at_ms: 1780000000000, + last_login_ip: "203.0.113.7", + last_login_ip_at_ms: 1781600000000, + last_login_ip_country_code: "AE", last_operated_at_ms: 1781000000000, last_operator: { action: "ban", admin_id: "7", name: "运营" }, levels: { @@ -395,7 +416,17 @@ test("app user projection normalizes levels roles ban and successful login field }, }, register_device: "iPhone17,2", + register_device_id: "ios_1F2E3D", + register_os_version: "17.5", roles: ["主播", "BD"], + third_party_identities: [ + { + created_at_ms: 1760000000000, + openid: "g-openid-1", + provider: "google", + union_id: "g-union-1", + }, + ], user_id: "10001", }, }), @@ -407,10 +438,30 @@ test("app user projection normalizes levels roles ban and successful login field expect(result).toMatchObject({ age: 26, + appVersion: "3.2.1", + appVersionAtMs: 1779990000000, ban: { active: true, expiresAtMs: 1790000000000, id: "ban-1", permanent: false }, birth: "2000-02-29", + buildNumber: "345", cumulativeRechargeUsdMinor: 12345, + devices: [ + { + appVersion: "3.2.1", + buildNumber: "345", + deviceId: "android_abcdef", + deviceModel: "Redmi Note 8 Pro", + firstSeenAtMs: 1770000000000, + imei: "", + lastSeenAtMs: 1781500000000, + manufacturer: "Xiaomi", + osVersion: "13", + platform: "android", + }, + ], lastLoginAtMs: 1780000000000, + lastLoginIp: "203.0.113.7", + lastLoginIpAtMs: 1781600000000, + lastLoginIpCountryCode: "AE", lastOperatedAtMs: 1781000000000, lastOperator: { action: "ban", adminId: "7", name: "运营" }, levels: { @@ -422,7 +473,48 @@ test("app user projection normalizes levels roles ban and successful login field }), }, registerDevice: "iPhone17,2", + registerDeviceId: "ios_1F2E3D", + registerOsVersion: "17.5", roles: ["主播", "BD"], + thirdPartyIdentities: [ + { createdAtMs: 1760000000000, openid: "g-openid-1", provider: "google", unionId: "g-union-1" }, + ], + }); +}); + +test("issueAppUserAccessToken posts to access-token endpoint and normalizes the token payload", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + code: 0, + data: { + access_token: "eyJhbGciOiJIUzI1NiJ9.payload.signature", + device_id: "android_abcdef", + expires_in_sec: 7200, + session_id: "session-1", + session_last_heartbeat_at_ms: 1781700000000, + token_type: "Bearer", + }, + }), + ), + ), + ); + + const result = await issueAppUserAccessToken("10001"); + const [url, init] = vi.mocked(fetch).mock.calls[0]; + + expect(String(url)).toContain("/api/v1/app/users/10001/access-token"); + expect(init?.method).toBe("POST"); + expect(result).toEqual({ + accessToken: "eyJhbGciOiJIUzI1NiJ9.payload.signature", + deviceId: "android_abcdef", + expiresInSec: 7200, + sessionId: "session-1", + sessionLastHeartbeatAtMs: 1781700000000, + tokenType: "Bearer", }); }); diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts index a8577ab..0b08f77 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -3,13 +3,16 @@ import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/gen import type { AdminJobDto, ApiPage, + AppUserAccessTokenDto, AppUserBanPayload, AppUserBanRecordDto, + AppUserDeviceDto, AppUserExportJobDto, AppUserLevelAdjustmentPayload, AppUserLoginLogDto, AppUserDto, AppUserPasswordPayload, + AppUserThirdPartyIdentityDto, AppUserUnbanPayload, AppUserUpdatePayload, AdminGrantPrettyDisplayIDResultDto, @@ -131,6 +134,16 @@ export function downloadAppUserExport(jobId: EntityId): Promise { return apiRequest(apiEndpointPath(API_OPERATIONS.downloadJobArtifact, { id: jobId }), { raw: true }); } +// 当前 Token 属于敏感凭证,服务端每次签发都会记录后台操作日志;因此只提供按需获取接口,不随详情自动拉取。 +export async function issueAppUserAccessToken(userId: EntityId): Promise { + const endpoint = API_ENDPOINTS.appIssueUserAccessToken; + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.appIssueUserAccessToken, { id: userId }), + { method: endpoint.method }, + ); + return normalizeAppUserAccessToken(data); +} + export function setAppUserPassword( userId: EntityId, payload: AppUserPasswordPayload, @@ -253,10 +266,16 @@ export async function setPrettyDisplayIDStatus( // 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。 interface RawAppUser { age?: number | null; + app_version?: string; + appVersion?: string; + app_version_at_ms?: number; + appVersionAtMs?: number; avatar?: string; balances?: RawAppUserAssetBalance[]; ban?: RawAppUserBan; birth?: string; + build_number?: string; + buildNumber?: string; coin?: number; country?: string; country_display_name?: string; @@ -267,6 +286,7 @@ interface RawAppUser { createdAtMs?: number; default_display_user_id?: string; defaultDisplayUserId?: string; + devices?: RawAppUserDevice[]; diamond?: number; display_user_id?: string; displayUserId?: string; @@ -277,6 +297,12 @@ interface RawAppUser { lastActiveAtMs?: number; last_login_at_ms?: number; lastLoginAtMs?: number; + last_login_ip?: string; + lastLoginIp?: string; + last_login_ip_at_ms?: number; + lastLoginIpAtMs?: number; + last_login_ip_country_code?: string; + lastLoginIpCountryCode?: string; last_operated_at_ms?: number; lastOperatedAtMs?: number; last_operator?: RawAppUserOperator; @@ -292,9 +318,15 @@ interface RawAppUser { regionName?: string; register_device?: string; registerDevice?: string; + register_device_id?: string; + registerDeviceId?: string; + register_os_version?: string; + registerOsVersion?: string; resources?: RawAppUserResource[]; roles?: string[]; status?: string; + third_party_identities?: RawAppUserThirdPartyIdentity[]; + thirdPartyIdentities?: RawAppUserThirdPartyIdentity[]; updated_at_ms?: number; updatedAtMs?: number; user_id?: EntityId; @@ -309,6 +341,50 @@ interface RawAppUserStatusMutation extends RawAppUser { user?: RawAppUser; } +interface RawAppUserThirdPartyIdentity { + created_at_ms?: number; + createdAtMs?: number; + openid?: string; + provider?: string; + union_id?: string; + unionId?: string; +} + +interface RawAppUserDevice { + app_version?: string; + appVersion?: string; + build_number?: string; + buildNumber?: string; + device_id?: string; + deviceId?: string; + device_model?: string; + deviceModel?: string; + first_seen_at_ms?: number; + firstSeenAtMs?: number; + imei?: string; + last_seen_at_ms?: number; + lastSeenAtMs?: number; + manufacturer?: string; + os_version?: string; + osVersion?: string; + platform?: string; +} + +interface RawAppUserAccessToken { + access_token?: string; + accessToken?: string; + device_id?: string; + deviceId?: string; + expires_in_sec?: number; + expiresInSec?: number; + session_id?: string; + sessionId?: string; + session_last_heartbeat_at_ms?: number; + sessionLastHeartbeatAtMs?: number; + token_type?: string; + tokenType?: string; +} + interface RawAppUserLevel { display_level?: number; displayLevel?: number; @@ -605,22 +681,29 @@ function normalizePage( function normalizeAppUser(item: RawAppUser = {}): AppUserDto { return { age: optionalNumberValue(item.age), + appVersion: stringValue(item.appVersion ?? item.app_version), + appVersionAtMs: numberValue(item.appVersionAtMs ?? item.app_version_at_ms), avatar: stringValue(item.avatar), balances: (item.balances || []).map(normalizeAppUserAssetBalance), ban: normalizeAppUserBan(item.ban), birth: stringValue(item.birth), + buildNumber: stringValue(item.buildNumber ?? item.build_number), coin: numberValue(item.coin), country: stringValue(item.country), countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name), countryName: stringValue(item.countryName ?? item.country_name), createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id), + devices: (item.devices || []).map(normalizeAppUserDevice), 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), lastLoginAtMs: numberValue(item.lastLoginAtMs ?? item.last_login_at_ms), + lastLoginIp: stringValue(item.lastLoginIp ?? item.last_login_ip), + lastLoginIpAtMs: numberValue(item.lastLoginIpAtMs ?? item.last_login_ip_at_ms), + lastLoginIpCountryCode: stringValue(item.lastLoginIpCountryCode ?? item.last_login_ip_country_code), lastOperatedAtMs: numberValue(item.lastOperatedAtMs ?? item.last_operated_at_ms), lastOperator: normalizeAppUserOperator(item.lastOperator ?? item.last_operator), levels: normalizeAppUserLevels(item.levels), @@ -629,9 +712,14 @@ function normalizeAppUser(item: RawAppUser = {}): AppUserDto { regionId: numberValue(item.regionId ?? item.region_id), regionName: stringValue(item.regionName ?? item.region_name), registerDevice: stringValue(item.registerDevice ?? item.register_device), + registerDeviceId: stringValue(item.registerDeviceId ?? item.register_device_id), + registerOsVersion: stringValue(item.registerOsVersion ?? item.register_os_version), resources: (item.resources || []).map(normalizeAppUserResource), roles: Array.isArray(item.roles) ? item.roles.map(stringValue).filter(Boolean) : [], status: stringValue(item.status), + thirdPartyIdentities: (item.thirdPartyIdentities ?? item.third_party_identities ?? []).map( + normalizeAppUserThirdPartyIdentity, + ), updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms), userId: stringValue(item.userId ?? item.user_id), username: stringValue(item.username), @@ -642,6 +730,42 @@ function normalizeAppUser(item: RawAppUser = {}): AppUserDto { }; } +function normalizeAppUserThirdPartyIdentity(item: RawAppUserThirdPartyIdentity = {}): AppUserThirdPartyIdentityDto { + return { + createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms), + openid: stringValue(item.openid), + provider: stringValue(item.provider), + unionId: stringValue(item.unionId ?? item.union_id), + }; +} + +function normalizeAppUserDevice(item: RawAppUserDevice = {}): AppUserDeviceDto { + return { + appVersion: stringValue(item.appVersion ?? item.app_version), + buildNumber: stringValue(item.buildNumber ?? item.build_number), + deviceId: stringValue(item.deviceId ?? item.device_id), + deviceModel: stringValue(item.deviceModel ?? item.device_model), + firstSeenAtMs: numberValue(item.firstSeenAtMs ?? item.first_seen_at_ms), + imei: stringValue(item.imei), + lastSeenAtMs: numberValue(item.lastSeenAtMs ?? item.last_seen_at_ms), + manufacturer: stringValue(item.manufacturer), + osVersion: stringValue(item.osVersion ?? item.os_version), + platform: stringValue(item.platform), + }; +} + +// Token 响应同样做 snake/camel 双形态兼容,复制按钮必须拿到完整 accessToken,不能只保留截断展示值。 +function normalizeAppUserAccessToken(item: RawAppUserAccessToken = {}): AppUserAccessTokenDto { + return { + accessToken: stringValue(item.accessToken ?? item.access_token), + deviceId: stringValue(item.deviceId ?? item.device_id), + expiresInSec: numberValue(item.expiresInSec ?? item.expires_in_sec), + sessionId: stringValue(item.sessionId ?? item.session_id), + sessionLastHeartbeatAtMs: numberValue(item.sessionLastHeartbeatAtMs ?? item.session_last_heartbeat_at_ms), + tokenType: stringValue(item.tokenType ?? item.token_type), + }; +} + function normalizeAppUserLevels(item: RawAppUserLevels = {}) { return { charm: normalizeAppUserLevel(item.charm, "charm"), diff --git a/src/features/app-users/components/AppUserDetailProvider.test.jsx b/src/features/app-users/components/AppUserDetailProvider.test.jsx index f42f720..4ee72ee 100644 --- a/src/features/app-users/components/AppUserDetailProvider.test.jsx +++ b/src/features/app-users/components/AppUserDetailProvider.test.jsx @@ -2,11 +2,11 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, expect, test, vi } from "vitest"; import { MemoryRouter } from "react-router-dom"; import { ToastProvider } from "@/shared/ui/ToastProvider.jsx"; -import { getAppUser } from "@/features/app-users/api"; +import { getAppUser, issueAppUserAccessToken } from "@/features/app-users/api"; import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx"; import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx"; -vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn() })); +vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() })); beforeEach(() => { vi.clearAllMocks(); @@ -15,18 +15,38 @@ beforeEach(() => { test("detail drawer keeps the identity hero and shows the expanded admin projection", async () => { vi.mocked(getAppUser).mockResolvedValue({ age: 26, + appVersion: "3.2.1", + appVersionAtMs: 1780990000000, avatar: "", ban: { active: true, permanent: true, reason: "risk" }, birth: "2000-02-29", + buildNumber: "345", coin: 1200, countryDisplayName: "阿联酋", createdAtMs: 1780000000000, cumulativeRechargeUsdMinor: 12345, defaultDisplayUserId: "163337", + devices: [ + { + appVersion: "3.2.1", + buildNumber: "345", + deviceId: "android_abcdef", + deviceModel: "Redmi Note 8 Pro", + firstSeenAtMs: 1770000000000, + imei: "", + lastSeenAtMs: 1781500000000, + manufacturer: "Xiaomi", + osVersion: "13", + platform: "android", + }, + ], diamond: 88, displayUserId: "163337", gender: "female", lastLoginAtMs: 1781000000000, + lastLoginIp: "203.0.113.7", + lastLoginIpAtMs: 1781600000000, + lastLoginIpCountryCode: "AE", lastOperatedAtMs: 1782000000000, lastOperator: { action: "ban", adminId: "7", name: "运营" }, levels: { @@ -38,13 +58,26 @@ test("detail drawer keeps the identity hero and shows the expanded admin project prettyId: "pretty-888", regionName: "中东区", registerDevice: "iPhone17,2", + registerDeviceId: "ios_1F2E3D", + registerOsVersion: "17.5", roles: ["主播", "BD"], status: "banned", + thirdPartyIdentities: [ + { createdAtMs: 1760000000000, openid: "g-openid-1", provider: "google", unionId: "g-union-1" }, + ], updatedAtMs: 1782000000000, userId: "10001", username: "tester", vip: { active: true, expiresAtMs: 1795000000000, level: 3, name: "黄金会员" }, }); + vi.mocked(issueAppUserAccessToken).mockResolvedValue({ + accessToken: "eyJhbGciOiJIUzI1NiJ9.this-is-a-long-enough-jwt-payload.signature-tail", + deviceId: "android_abcdef", + expiresInSec: 7200, + sessionId: "session-1", + sessionLastHeartbeatAtMs: 1781700000000, + tokenType: "Bearer", + }); render( @@ -72,6 +105,25 @@ test("detail drawer keeps the identity hero and shows the expanded admin project fireEvent.click(screen.getByRole("tab", { name: "状态记录" })); expect(screen.getByText("永久")).toBeInTheDocument(); + expect(screen.getByText("203.0.113.7")).toBeInTheDocument(); + expect(screen.getByText(/AE · 记录于/)).toBeInTheDocument(); + expect(screen.getByText("3.2.1 (build 345)")).toBeInTheDocument(); + expect(screen.getByText("google")).toBeInTheDocument(); + expect(screen.getByText("g-openid-1")).toBeInTheDocument(); + + // Token 属于敏感凭证,必须显式点击按钮才发起签发请求,展示为截断形态但复制值仍是完整 Token。 + expect(issueAppUserAccessToken).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "获取 Token" })); + expect(await screen.findByText("eyJhbGciOiJIUz…ature-tail")).toBeInTheDocument(); + expect(issueAppUserAccessToken).toHaveBeenCalledWith("10001"); + expect(screen.getByText("有效期 7200 秒")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("tab", { name: "设备" })); + expect(screen.getByText("Xiaomi Redmi Note 8 Pro")).toBeInTheDocument(); + expect(screen.getByText("android_abcdef")).toBeInTheDocument(); + expect(screen.getByText("注册系统版本")).toBeInTheDocument(); + expect(screen.getByText("17.5")).toBeInTheDocument(); + expect(screen.getByText("ios_1F2E3D")).toBeInTheDocument(); fireEvent.click(screen.getByRole("link", { name: /完整详情/ })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); diff --git a/src/features/app-users/components/AppUserDetailView.jsx b/src/features/app-users/components/AppUserDetailView.jsx index ebdaade..0e33379 100644 --- a/src/features/app-users/components/AppUserDetailView.jsx +++ b/src/features/app-users/components/AppUserDetailView.jsx @@ -7,6 +7,7 @@ import { Link, useLocation } from "react-router-dom"; import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx"; import { Button } from "@/shared/ui/Button.jsx"; import { formatMillis } from "@/shared/utils/time.js"; +import { issueAppUserAccessToken } from "@/features/app-users/api"; import { appUserStatusLabels } from "@/features/app-users/constants.js"; import { BanCountdown, @@ -24,6 +25,7 @@ const detailTabs = [ ["assets", "资产"], ["resources", "资源"], ["status", "状态记录"], + ["device", "设备"], ]; export function AppUserDetailView({ @@ -104,6 +106,7 @@ export function AppUserDetailView({ ) : null} {activeTab === "status" ? : null} + {activeTab === "device" ? : null} ); @@ -188,6 +191,10 @@ function StatusPanel({ user }) { + } wide /> + + } wide /> + } wide /> } @@ -200,6 +207,51 @@ function StatusPanel({ user }) { ); } +function DevicePanel({ user }) { + const devices = user.devices || []; + return ( + <> + + {devices.length ? ( + devices.map((device) => ( +
+ + {/* iOS 设备也会上报,所以统一叫“系统版本”而不是“安卓版本”。 */} + + + } + /> + + + +
+ )) + ) : ( +
暂无设备上报数据(需要 App ≥ 携带设备信息的版本)
+ )} +
+ +
+ + + } + /> +
+
+ + ); +} + function DetailSection({ children, title }) { return (
@@ -230,7 +282,7 @@ function SummaryMetric({ label, value }) { ); } -function CopyableValue({ label, value }) { +function CopyableValue({ display, label, value }) { const [copied, setCopied] = useState(false); const normalized = String(value ?? "").trim(); @@ -239,6 +291,7 @@ function CopyableValue({ label, value }) { } const copy = async () => { + // display 只影响展示(如超长 Token 截断),复制必须始终写入完整原值,否则复制出的凭证不可用。 await copyText(normalized); setCopied(true); window.setTimeout(() => setCopied(false), 1200); @@ -246,7 +299,7 @@ function CopyableValue({ label, value }) { return ( - {normalized} + {display || normalized} @@ -254,6 +307,102 @@ function CopyableValue({ label, value }) { ); } +function LatestLoginIpValue({ user }) { + if (!user.lastLoginIp) { + return "-"; + } + // 国家码与记录时间放在 IP 后做纯文本补充,复制按钮只复制 IP 本身,方便直接贴进风控查询。 + const suffix = [ + user.lastLoginIpCountryCode, + user.lastLoginIpAtMs ? `记录于 ${formatMillis(user.lastLoginIpAtMs)}` : "", + ] + .filter(Boolean) + .join(" · "); + return ( + + + {suffix ? {suffix} : null} + + ); +} + +function OpenIdListValue({ identities }) { + const list = identities || []; + if (!list.length) { + return "-"; + } + // 一个用户可能绑定多个三方账号(每个 provider 一条),逐行展示避免混淆不同渠道的 openid。 + return ( + + {list.map((identity) => ( + + {identity.provider || "-"} + · + + + ))} + + ); +} + +function AccessTokenValue({ userId }) { + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [token, setToken] = useState(null); + + useEffect(() => { + // 抽屉复用组件切换用户时清空上一位用户的 Token,避免把他人凭证误复制到当前用户的排查里。 + setError(""); + setLoading(false); + setToken(null); + }, [userId]); + + const fetchToken = async () => { + setError(""); + setLoading(true); + try { + setToken(await issueAppUserAccessToken(userId)); + } catch (issueError) { + // 服务端会返回“该用户当前没有活跃会话”等具体原因,直接透出比通用兜底文案更利于排查。 + setToken(null); + setError(issueError instanceof Error && issueError.message ? issueError.message : "获取 Token 失败"); + } finally { + setLoading(false); + } + }; + + if (token) { + return ( + + + {token.expiresInSec > 0 ? 有效期 {token.expiresInSec} 秒 : null} + + ); + } + + return ( + + {/* Token 签发在服务端会写入操作日志且属于敏感凭证,因此不随 Tab 打开自动获取,只保留显式按钮。 + 详情视图没有权限上下文(useAuth 依赖 AuthProvider,抽屉/详情页测试均不包裹), + 所以不在前端隐藏按钮:无 app-user:token 权限时后端返回 403,错误文案原样透出。 */} + + {error ? {error} : null} + + ); +} + +function truncateToken(value) { + const normalized = String(value || ""); + // JWT 通常远超一行宽度,仅保留首尾便于肉眼比对;完整值通过复制按钮获取。 + return normalized.length > 28 ? `${normalized.slice(0, 14)}…${normalized.slice(-10)}` : normalized; +} + function PrettyValue({ user }) { if (!user.prettyId || !user.prettyDisplayUserId) { return "-"; @@ -354,6 +503,13 @@ function formatCompactUsdMinor(value) { }).format(amount); } +function formatAppVersion(appVersion, buildNumber) { + if (!appVersion) { + return "-"; + } + return buildNumber ? `${appVersion} (build ${buildNumber})` : appVersion; +} + function formatCountry(user) { return user.countryDisplayName || user.countryName || user.country || "-"; } diff --git a/src/features/app-users/pages/AppUserDetailPage.test.jsx b/src/features/app-users/pages/AppUserDetailPage.test.jsx index 427211e..50fceae 100644 --- a/src/features/app-users/pages/AppUserDetailPage.test.jsx +++ b/src/features/app-users/pages/AppUserDetailPage.test.jsx @@ -5,7 +5,8 @@ import { QueryProvider } from "@/shared/query/QueryProvider.jsx"; import { getAppUser } from "@/features/app-users/api"; import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx"; -vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn() })); +// AppUserDetailView 会静态导入 issueAppUserAccessToken,mock 工厂缺少该导出会直接导致模块加载失败。 +vi.mock("@/features/app-users/api", () => ({ getAppUser: vi.fn(), issueAppUserAccessToken: vi.fn() })); test("full user detail route loads the URL user and returns to its source list", async () => { vi.mocked(getAppUser).mockResolvedValue({ diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 8ae1e0e..523f146 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -19,6 +19,7 @@ export const API_OPERATIONS = { appBanUser: "appBanUser", appExportUsers: "appExportUsers", appGetUser: "appGetUser", + appIssueUserAccessToken: "appIssueUserAccessToken", appListBannedUsers: "appListBannedUsers", appListLoginLogs: "appListLoginLogs", appListUserLoginLogs: "appListUserLoginLogs", @@ -421,6 +422,13 @@ export const API_ENDPOINTS: Record = { permission: "app-user:view", permissions: ["app-user:view"] }, + appIssueUserAccessToken: { + method: "POST", + operationId: API_OPERATIONS.appIssueUserAccessToken, + path: "/v1/app/users/{id}/access-token", + permission: "app-user:token", + permissions: ["app-user:token"] + }, appListBannedUsers: { method: "GET", operationId: API_OPERATIONS.appListBannedUsers, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 448f516..25affac 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -3384,6 +3384,22 @@ export interface paths { patch: operations["appUpdateUser"]; trace?: never; }; + "/app/users/{id}/access-token": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["appIssueUserAccessToken"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/app/users/{id}/ban": { parameters: { query?: never; @@ -4350,6 +4366,9 @@ export interface components { ApiResponseAppUserExportJob: components["schemas"]["Envelope"] & { data?: components["schemas"]["AppUserExportJob"]; }; + ApiResponseAppUserAccessToken: components["schemas"]["Envelope"] & { + data?: components["schemas"]["AppUserAccessToken"]; + }; ApiPageAppUser: { items: components["schemas"]["AppUser"][]; page: number; @@ -4375,6 +4394,18 @@ export interface components { regionId?: number; regionName?: string; registerDevice?: string; + registerDeviceId?: string; + registerOsVersion?: string; + lastLoginIp?: string; + lastLoginIpCountryCode?: string; + /** Format: int64 */ + lastLoginIpAtMs?: number; + appVersion?: string; + buildNumber?: string; + /** Format: int64 */ + appVersionAtMs?: number; + thirdPartyIdentities?: components["schemas"]["AppUserThirdPartyIdentity"][]; + devices?: components["schemas"]["AppUserDevice"][]; roles: string[]; levels: components["schemas"]["AppUserLevels"]; ban: components["schemas"]["AppUserBan"]; @@ -4486,6 +4517,37 @@ export interface components { /** Format: int64 */ updatedAtMs?: number; }; + AppUserThirdPartyIdentity: { + provider: string; + openid: string; + unionId?: string; + /** Format: int64 */ + createdAtMs?: number; + }; + AppUserDevice: { + deviceId: string; + platform?: string; + deviceModel?: string; + manufacturer?: string; + osVersion?: string; + imei?: string; + appVersion?: string; + buildNumber?: string; + /** Format: int64 */ + firstSeenAtMs?: number; + /** Format: int64 */ + lastSeenAtMs?: number; + }; + AppUserAccessToken: { + accessToken: string; + tokenType: string; + /** Format: int64 */ + expiresInSec: number; + sessionId?: string; + deviceId?: string; + /** Format: int64 */ + sessionLastHeartbeatAtMs?: number; + }; AppUserUpdateInput: { username?: string; avatar?: string; @@ -5889,6 +5951,15 @@ export interface components { }; }; /** @description OK */ + AppUserAccessTokenResponse: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiResponseAppUserAccessToken"]; + }; + }; + /** @description OK */ AgencyPageResponse: { headers: { [name: string]: unknown; @@ -10696,6 +10767,20 @@ export interface operations { 200: components["responses"]["AppUserResponse"]; }; }; + appIssueUserAccessToken: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["Id"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: components["responses"]["AppUserAccessTokenResponse"]; + }; + }; appBanUser: { parameters: { query?: never; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index af42df9..612579b 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -1042,22 +1042,29 @@ export interface CoinSellerSalaryRatesPayload { export interface AppUserDto { age?: number; + appVersion?: string; + appVersionAtMs?: number; avatar?: string; balances?: AppUserAssetBalanceDto[]; ban?: AppUserBanDto; birth?: string; + buildNumber?: string; coin?: number; country?: string; countryDisplayName?: string; countryName?: string; createdAtMs?: number; defaultDisplayUserId?: string; + devices?: AppUserDeviceDto[]; diamond?: number; displayUserId?: string; equippedResources?: AppUserResourceDto[]; gender?: string; lastActiveAtMs?: number; lastLoginAtMs?: number; + lastLoginIp?: string; + lastLoginIpAtMs?: number; + lastLoginIpCountryCode?: string; lastOperatedAtMs?: number; lastOperator?: AppUserOperatorDto; levels?: AppUserLevelsDto; @@ -1066,9 +1073,12 @@ export interface AppUserDto { regionId?: number; regionName?: string; registerDevice?: string; + registerDeviceId?: string; + registerOsVersion?: string; resources?: AppUserResourceDto[]; roles?: string[]; status?: string; + thirdPartyIdentities?: AppUserThirdPartyIdentityDto[]; updatedAtMs?: number; userId: string; username?: string; @@ -1076,6 +1086,35 @@ export interface AppUserDto { cumulativeRechargeUsdMinor?: number; } +export interface AppUserThirdPartyIdentityDto { + provider: string; + openid: string; + unionId: string; + createdAtMs: number; +} + +export interface AppUserDeviceDto { + deviceId: string; + platform: string; + deviceModel: string; + manufacturer: string; + osVersion: string; + imei: string; + appVersion: string; + buildNumber: string; + firstSeenAtMs: number; + lastSeenAtMs: number; +} + +export interface AppUserAccessTokenDto { + accessToken: string; + tokenType: string; + expiresInSec: number; + sessionId: string; + deviceId: string; + sessionLastHeartbeatAtMs: number; +} + export interface AppUserLevelDto { displayLevel: number; displayValue: number;