增加用户信息

This commit is contained in:
zhx 2026-07-12 14:17:50 +08:00
parent 2bdc48e63c
commit a4dd49c33d
10 changed files with 648 additions and 5 deletions

View File

@ -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": {

View File

@ -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",

View File

@ -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",
});
});

View File

@ -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<Response> {
return apiRequest(apiEndpointPath(API_OPERATIONS.downloadJobArtifact, { id: jobId }), { raw: true });
}
// 当前 Token 属于敏感凭证,服务端每次签发都会记录后台操作日志;因此只提供按需获取接口,不随详情自动拉取。
export async function issueAppUserAccessToken(userId: EntityId): Promise<AppUserAccessTokenDto> {
const endpoint = API_ENDPOINTS.appIssueUserAccessToken;
const data = await apiRequest<RawAppUserAccessToken>(
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<TRaw, TItem>(
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"),

View File

@ -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(
<ToastProvider>
@ -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();

View File

@ -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({
<ResourcesPanel equippedResources={equippedResources} resources={resources} />
) : null}
{activeTab === "status" ? <StatusPanel user={user} /> : null}
{activeTab === "device" ? <DevicePanel user={user} /> : null}
</div>
</div>
);
@ -188,6 +191,10 @@ function StatusPanel({ user }) {
<DetailItem label="区域" value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")} />
<DetailItem label="最新登录" value={formatMillis(user.lastLoginAtMs)} />
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
<DetailItem label="最新登录 IP" value={<LatestLoginIpValue user={user} />} wide />
<DetailItem label="当前 App 版本" value={formatAppVersion(user.appVersion, user.buildNumber)} />
<DetailItem label="OpenID" value={<OpenIdListValue identities={user.thirdPartyIdentities} />} wide />
<DetailItem label="当前 Token" value={<AccessTokenValue userId={user.userId} />} wide />
<DetailItem
label="最新操作"
value={<OperatorValue operatedAtMs={user.lastOperatedAtMs} operator={user.lastOperator} />}
@ -200,6 +207,51 @@ function StatusPanel({ user }) {
);
}
function DevicePanel({ user }) {
const devices = user.devices || [];
return (
<>
<DetailSection title={`当前设备 (${devices.length})`}>
{devices.length ? (
devices.map((device) => (
<div className={styles.detailGrid} key={device.deviceId}>
<DetailItem
label="设备型号"
value={[device.manufacturer, device.deviceModel].filter(Boolean).join(" ")}
/>
{/* iOS 设备也会上报,所以统一叫“系统版本”而不是“安卓版本”。 */}
<DetailItem label="系统版本" value={device.osVersion} />
<DetailItem label="IMEI" value={device.imei} />
<DetailItem
label="设备 ID"
value={<CopyableValue label="复制设备 ID" value={device.deviceId} />}
/>
<DetailItem
label="App 版本"
value={formatAppVersion(device.appVersion, device.buildNumber)}
/>
<DetailItem label="最近活跃" value={formatMillis(device.lastSeenAtMs)} />
<DetailItem label="首次记录" value={formatMillis(device.firstSeenAtMs)} />
</div>
))
) : (
<div className={styles.emptyInline}>暂无设备上报数据需要 App 携带设备信息的版本</div>
)}
</DetailSection>
<DetailSection title="注册设备">
<div className={styles.detailGrid}>
<DetailItem label="注册设备" value={user.registerDevice} />
<DetailItem label="注册系统版本" value={user.registerOsVersion} />
<DetailItem
label="注册设备 ID"
value={<CopyableValue label="复制注册设备 ID" value={user.registerDeviceId} />}
/>
</div>
</DetailSection>
</>
);
}
function DetailSection({ children, title }) {
return (
<section className={styles.detailSection}>
@ -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 (
<span className={styles.copyableDetailValue}>
<span>{normalized}</span>
<span>{display || normalized}</span>
<button aria-label={label} className={styles.copyDetailButton} title={copied ? "已复制" : label} type="button" onClick={copy}>
<ContentCopyOutlined fontSize="inherit" />
</button>
@ -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 (
<span className={styles.copyableDetailValue}>
<CopyableValue label="复制登录 IP" value={user.lastLoginIp} />
{suffix ? <span>{suffix}</span> : null}
</span>
);
}
function OpenIdListValue({ identities }) {
const list = identities || [];
if (!list.length) {
return "-";
}
// provider openid
return (
<span>
{list.map((identity) => (
<span className={styles.copyableDetailValue} key={`${identity.provider}-${identity.openid}`}>
<span>{identity.provider || "-"}</span>
<span>·</span>
<CopyableValue label="复制 OpenID" value={identity.openid} />
</span>
))}
</span>
);
}
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 (
<span className={styles.copyableDetailValue}>
<CopyableValue
display={truncateToken(token.accessToken)}
label="复制完整 Token"
value={token.accessToken}
/>
{token.expiresInSec > 0 ? <span>有效期 {token.expiresInSec} </span> : null}
</span>
);
}
return (
<span className={styles.copyableDetailValue}>
{/* Token Tab
详情视图没有权限上下文useAuth 依赖 AuthProvider抽屉/详情页测试均不包裹
所以不在前端隐藏按钮 app-user:token 权限时后端返回 403错误文案原样透出 */}
<Button disabled={loading} onClick={fetchToken}>
{loading ? "获取中..." : "获取 Token"}
</Button>
{error ? <span>{error}</span> : null}
</span>
);
}
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 || "-";
}

View File

@ -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 issueAppUserAccessTokenmock
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({

View File

@ -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<ApiOperationId, ApiEndpoint> = {
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,

View File

@ -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;

View File

@ -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;