增加用户信息
This commit is contained in:
parent
2bdc48e63c
commit
a4dd49c33d
@ -5857,6 +5857,23 @@
|
|||||||
"x-permissions": ["app-user:update"]
|
"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": {
|
"/app/users/{id}/ban": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "appBanUser",
|
"operationId": "appBanUser",
|
||||||
@ -7647,6 +7664,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"AppUserAccessTokenResponse": {
|
||||||
|
"description": "OK",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": { "$ref": "#/components/schemas/ApiResponseAppUserAccessToken" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"AgencyPageResponse": {
|
"AgencyPageResponse": {
|
||||||
"description": "OK",
|
"description": "OK",
|
||||||
"content": {
|
"content": {
|
||||||
@ -8321,6 +8346,15 @@
|
|||||||
{ "type": "object", "properties": { "data": { "$ref": "#/components/schemas/AppUserExportJob" } } }
|
{ "type": "object", "properties": { "data": { "$ref": "#/components/schemas/AppUserExportJob" } } }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ApiResponseAppUserAccessToken": {
|
||||||
|
"allOf": [
|
||||||
|
{ "$ref": "#/components/schemas/Envelope" },
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "data": { "$ref": "#/components/schemas/AppUserAccessToken" } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"ApiPageAppUser": {
|
"ApiPageAppUser": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["items", "page", "pageSize", "total"],
|
"required": ["items", "page", "pageSize", "total"],
|
||||||
@ -8351,6 +8385,19 @@
|
|||||||
"regionId": { "type": "integer", "format": "int64" },
|
"regionId": { "type": "integer", "format": "int64" },
|
||||||
"regionName": { "type": "string" },
|
"regionName": { "type": "string" },
|
||||||
"registerDevice": { "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" } },
|
"roles": { "type": "array", "items": { "type": "string" } },
|
||||||
"levels": { "$ref": "#/components/schemas/AppUserLevels" },
|
"levels": { "$ref": "#/components/schemas/AppUserLevels" },
|
||||||
"ban": { "$ref": "#/components/schemas/AppUserBan" },
|
"ban": { "$ref": "#/components/schemas/AppUserBan" },
|
||||||
@ -8462,6 +8509,44 @@
|
|||||||
"updatedAtMs": { "type": "integer", "format": "int64" }
|
"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": {
|
"AppUserUpdateInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@ -113,6 +113,7 @@ export const PERMISSIONS = {
|
|||||||
appUserUpdate: "app-user:update",
|
appUserUpdate: "app-user:update",
|
||||||
appUserStatus: "app-user:status",
|
appUserStatus: "app-user:status",
|
||||||
appUserPassword: "app-user:password",
|
appUserPassword: "app-user:password",
|
||||||
|
appUserToken: "app-user:token",
|
||||||
appUserLevel: "app-user:level",
|
appUserLevel: "app-user:level",
|
||||||
appUserExport: "app-user:export",
|
appUserExport: "app-user:export",
|
||||||
prettyIdView: "pretty-id:view",
|
prettyIdView: "pretty-id:view",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
banAppUser,
|
banAppUser,
|
||||||
createAppUserExport,
|
createAppUserExport,
|
||||||
getAppUser,
|
getAppUser,
|
||||||
|
issueAppUserAccessToken,
|
||||||
listAppUsers,
|
listAppUsers,
|
||||||
listPrettyDisplayIDs,
|
listPrettyDisplayIDs,
|
||||||
recyclePrettyDisplayID,
|
recyclePrettyDisplayID,
|
||||||
@ -371,6 +372,8 @@ test("app user projection normalizes levels roles ban and successful login field
|
|||||||
code: 0,
|
code: 0,
|
||||||
data: {
|
data: {
|
||||||
age: 26,
|
age: 26,
|
||||||
|
app_version: "3.2.1",
|
||||||
|
app_version_at_ms: 1779990000000,
|
||||||
ban: {
|
ban: {
|
||||||
active: true,
|
active: true,
|
||||||
expires_at_ms: 1790000000000,
|
expires_at_ms: 1790000000000,
|
||||||
@ -380,8 +383,26 @@ test("app user projection normalizes levels roles ban and successful login field
|
|||||||
source: "admin",
|
source: "admin",
|
||||||
},
|
},
|
||||||
birth: "2000-02-29",
|
birth: "2000-02-29",
|
||||||
|
build_number: "345",
|
||||||
cumulative_recharge_usd_minor: 12345,
|
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_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_operated_at_ms: 1781000000000,
|
||||||
last_operator: { action: "ban", admin_id: "7", name: "运营" },
|
last_operator: { action: "ban", admin_id: "7", name: "运营" },
|
||||||
levels: {
|
levels: {
|
||||||
@ -395,7 +416,17 @@ test("app user projection normalizes levels roles ban and successful login field
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
register_device: "iPhone17,2",
|
register_device: "iPhone17,2",
|
||||||
|
register_device_id: "ios_1F2E3D",
|
||||||
|
register_os_version: "17.5",
|
||||||
roles: ["主播", "BD"],
|
roles: ["主播", "BD"],
|
||||||
|
third_party_identities: [
|
||||||
|
{
|
||||||
|
created_at_ms: 1760000000000,
|
||||||
|
openid: "g-openid-1",
|
||||||
|
provider: "google",
|
||||||
|
union_id: "g-union-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
user_id: "10001",
|
user_id: "10001",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@ -407,10 +438,30 @@ test("app user projection normalizes levels roles ban and successful login field
|
|||||||
|
|
||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
age: 26,
|
age: 26,
|
||||||
|
appVersion: "3.2.1",
|
||||||
|
appVersionAtMs: 1779990000000,
|
||||||
ban: { active: true, expiresAtMs: 1790000000000, id: "ban-1", permanent: false },
|
ban: { active: true, expiresAtMs: 1790000000000, id: "ban-1", permanent: false },
|
||||||
birth: "2000-02-29",
|
birth: "2000-02-29",
|
||||||
|
buildNumber: "345",
|
||||||
cumulativeRechargeUsdMinor: 12345,
|
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,
|
lastLoginAtMs: 1780000000000,
|
||||||
|
lastLoginIp: "203.0.113.7",
|
||||||
|
lastLoginIpAtMs: 1781600000000,
|
||||||
|
lastLoginIpCountryCode: "AE",
|
||||||
lastOperatedAtMs: 1781000000000,
|
lastOperatedAtMs: 1781000000000,
|
||||||
lastOperator: { action: "ban", adminId: "7", name: "运营" },
|
lastOperator: { action: "ban", adminId: "7", name: "运营" },
|
||||||
levels: {
|
levels: {
|
||||||
@ -422,7 +473,48 @@ test("app user projection normalizes levels roles ban and successful login field
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
registerDevice: "iPhone17,2",
|
registerDevice: "iPhone17,2",
|
||||||
|
registerDeviceId: "ios_1F2E3D",
|
||||||
|
registerOsVersion: "17.5",
|
||||||
roles: ["主播", "BD"],
|
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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -3,13 +3,16 @@ import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/gen
|
|||||||
import type {
|
import type {
|
||||||
AdminJobDto,
|
AdminJobDto,
|
||||||
ApiPage,
|
ApiPage,
|
||||||
|
AppUserAccessTokenDto,
|
||||||
AppUserBanPayload,
|
AppUserBanPayload,
|
||||||
AppUserBanRecordDto,
|
AppUserBanRecordDto,
|
||||||
|
AppUserDeviceDto,
|
||||||
AppUserExportJobDto,
|
AppUserExportJobDto,
|
||||||
AppUserLevelAdjustmentPayload,
|
AppUserLevelAdjustmentPayload,
|
||||||
AppUserLoginLogDto,
|
AppUserLoginLogDto,
|
||||||
AppUserDto,
|
AppUserDto,
|
||||||
AppUserPasswordPayload,
|
AppUserPasswordPayload,
|
||||||
|
AppUserThirdPartyIdentityDto,
|
||||||
AppUserUnbanPayload,
|
AppUserUnbanPayload,
|
||||||
AppUserUpdatePayload,
|
AppUserUpdatePayload,
|
||||||
AdminGrantPrettyDisplayIDResultDto,
|
AdminGrantPrettyDisplayIDResultDto,
|
||||||
@ -131,6 +134,16 @@ export function downloadAppUserExport(jobId: EntityId): Promise<Response> {
|
|||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.downloadJobArtifact, { id: jobId }), { raw: true });
|
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(
|
export function setAppUserPassword(
|
||||||
userId: EntityId,
|
userId: EntityId,
|
||||||
payload: AppUserPasswordPayload,
|
payload: AppUserPasswordPayload,
|
||||||
@ -253,10 +266,16 @@ export async function setPrettyDisplayIDStatus(
|
|||||||
// 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。
|
// 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。
|
||||||
interface RawAppUser {
|
interface RawAppUser {
|
||||||
age?: number | null;
|
age?: number | null;
|
||||||
|
app_version?: string;
|
||||||
|
appVersion?: string;
|
||||||
|
app_version_at_ms?: number;
|
||||||
|
appVersionAtMs?: number;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
balances?: RawAppUserAssetBalance[];
|
balances?: RawAppUserAssetBalance[];
|
||||||
ban?: RawAppUserBan;
|
ban?: RawAppUserBan;
|
||||||
birth?: string;
|
birth?: string;
|
||||||
|
build_number?: string;
|
||||||
|
buildNumber?: string;
|
||||||
coin?: number;
|
coin?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
country_display_name?: string;
|
country_display_name?: string;
|
||||||
@ -267,6 +286,7 @@ interface RawAppUser {
|
|||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
default_display_user_id?: string;
|
default_display_user_id?: string;
|
||||||
defaultDisplayUserId?: string;
|
defaultDisplayUserId?: string;
|
||||||
|
devices?: RawAppUserDevice[];
|
||||||
diamond?: number;
|
diamond?: number;
|
||||||
display_user_id?: string;
|
display_user_id?: string;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
@ -277,6 +297,12 @@ interface RawAppUser {
|
|||||||
lastActiveAtMs?: number;
|
lastActiveAtMs?: number;
|
||||||
last_login_at_ms?: number;
|
last_login_at_ms?: number;
|
||||||
lastLoginAtMs?: 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;
|
last_operated_at_ms?: number;
|
||||||
lastOperatedAtMs?: number;
|
lastOperatedAtMs?: number;
|
||||||
last_operator?: RawAppUserOperator;
|
last_operator?: RawAppUserOperator;
|
||||||
@ -292,9 +318,15 @@ interface RawAppUser {
|
|||||||
regionName?: string;
|
regionName?: string;
|
||||||
register_device?: string;
|
register_device?: string;
|
||||||
registerDevice?: string;
|
registerDevice?: string;
|
||||||
|
register_device_id?: string;
|
||||||
|
registerDeviceId?: string;
|
||||||
|
register_os_version?: string;
|
||||||
|
registerOsVersion?: string;
|
||||||
resources?: RawAppUserResource[];
|
resources?: RawAppUserResource[];
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
|
third_party_identities?: RawAppUserThirdPartyIdentity[];
|
||||||
|
thirdPartyIdentities?: RawAppUserThirdPartyIdentity[];
|
||||||
updated_at_ms?: number;
|
updated_at_ms?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
user_id?: EntityId;
|
user_id?: EntityId;
|
||||||
@ -309,6 +341,50 @@ interface RawAppUserStatusMutation extends RawAppUser {
|
|||||||
user?: 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 {
|
interface RawAppUserLevel {
|
||||||
display_level?: number;
|
display_level?: number;
|
||||||
displayLevel?: number;
|
displayLevel?: number;
|
||||||
@ -605,22 +681,29 @@ function normalizePage<TRaw, TItem>(
|
|||||||
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
||||||
return {
|
return {
|
||||||
age: optionalNumberValue(item.age),
|
age: optionalNumberValue(item.age),
|
||||||
|
appVersion: stringValue(item.appVersion ?? item.app_version),
|
||||||
|
appVersionAtMs: numberValue(item.appVersionAtMs ?? item.app_version_at_ms),
|
||||||
avatar: stringValue(item.avatar),
|
avatar: stringValue(item.avatar),
|
||||||
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
|
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
|
||||||
ban: normalizeAppUserBan(item.ban),
|
ban: normalizeAppUserBan(item.ban),
|
||||||
birth: stringValue(item.birth),
|
birth: stringValue(item.birth),
|
||||||
|
buildNumber: stringValue(item.buildNumber ?? item.build_number),
|
||||||
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),
|
||||||
countryName: stringValue(item.countryName ?? item.country_name),
|
countryName: stringValue(item.countryName ?? item.country_name),
|
||||||
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id),
|
defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id),
|
||||||
|
devices: (item.devices || []).map(normalizeAppUserDevice),
|
||||||
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),
|
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),
|
||||||
lastLoginAtMs: numberValue(item.lastLoginAtMs ?? item.last_login_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),
|
lastOperatedAtMs: numberValue(item.lastOperatedAtMs ?? item.last_operated_at_ms),
|
||||||
lastOperator: normalizeAppUserOperator(item.lastOperator ?? item.last_operator),
|
lastOperator: normalizeAppUserOperator(item.lastOperator ?? item.last_operator),
|
||||||
levels: normalizeAppUserLevels(item.levels),
|
levels: normalizeAppUserLevels(item.levels),
|
||||||
@ -629,9 +712,14 @@ function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
|||||||
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),
|
||||||
registerDevice: stringValue(item.registerDevice ?? item.register_device),
|
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),
|
resources: (item.resources || []).map(normalizeAppUserResource),
|
||||||
roles: Array.isArray(item.roles) ? item.roles.map(stringValue).filter(Boolean) : [],
|
roles: Array.isArray(item.roles) ? item.roles.map(stringValue).filter(Boolean) : [],
|
||||||
status: stringValue(item.status),
|
status: stringValue(item.status),
|
||||||
|
thirdPartyIdentities: (item.thirdPartyIdentities ?? item.third_party_identities ?? []).map(
|
||||||
|
normalizeAppUserThirdPartyIdentity,
|
||||||
|
),
|
||||||
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),
|
||||||
@ -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 = {}) {
|
function normalizeAppUserLevels(item: RawAppUserLevels = {}) {
|
||||||
return {
|
return {
|
||||||
charm: normalizeAppUserLevel(item.charm, "charm"),
|
charm: normalizeAppUserLevel(item.charm, "charm"),
|
||||||
|
|||||||
@ -2,11 +2,11 @@ import { fireEvent, render, screen } from "@testing-library/react";
|
|||||||
import { beforeEach, expect, test, vi } from "vitest";
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
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 { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
|
||||||
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@ -15,18 +15,38 @@ beforeEach(() => {
|
|||||||
test("detail drawer keeps the identity hero and shows the expanded admin projection", async () => {
|
test("detail drawer keeps the identity hero and shows the expanded admin projection", async () => {
|
||||||
vi.mocked(getAppUser).mockResolvedValue({
|
vi.mocked(getAppUser).mockResolvedValue({
|
||||||
age: 26,
|
age: 26,
|
||||||
|
appVersion: "3.2.1",
|
||||||
|
appVersionAtMs: 1780990000000,
|
||||||
avatar: "",
|
avatar: "",
|
||||||
ban: { active: true, permanent: true, reason: "risk" },
|
ban: { active: true, permanent: true, reason: "risk" },
|
||||||
birth: "2000-02-29",
|
birth: "2000-02-29",
|
||||||
|
buildNumber: "345",
|
||||||
coin: 1200,
|
coin: 1200,
|
||||||
countryDisplayName: "阿联酋",
|
countryDisplayName: "阿联酋",
|
||||||
createdAtMs: 1780000000000,
|
createdAtMs: 1780000000000,
|
||||||
cumulativeRechargeUsdMinor: 12345,
|
cumulativeRechargeUsdMinor: 12345,
|
||||||
defaultDisplayUserId: "163337",
|
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,
|
diamond: 88,
|
||||||
displayUserId: "163337",
|
displayUserId: "163337",
|
||||||
gender: "female",
|
gender: "female",
|
||||||
lastLoginAtMs: 1781000000000,
|
lastLoginAtMs: 1781000000000,
|
||||||
|
lastLoginIp: "203.0.113.7",
|
||||||
|
lastLoginIpAtMs: 1781600000000,
|
||||||
|
lastLoginIpCountryCode: "AE",
|
||||||
lastOperatedAtMs: 1782000000000,
|
lastOperatedAtMs: 1782000000000,
|
||||||
lastOperator: { action: "ban", adminId: "7", name: "运营" },
|
lastOperator: { action: "ban", adminId: "7", name: "运营" },
|
||||||
levels: {
|
levels: {
|
||||||
@ -38,13 +58,26 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
|
|||||||
prettyId: "pretty-888",
|
prettyId: "pretty-888",
|
||||||
regionName: "中东区",
|
regionName: "中东区",
|
||||||
registerDevice: "iPhone17,2",
|
registerDevice: "iPhone17,2",
|
||||||
|
registerDeviceId: "ios_1F2E3D",
|
||||||
|
registerOsVersion: "17.5",
|
||||||
roles: ["主播", "BD"],
|
roles: ["主播", "BD"],
|
||||||
status: "banned",
|
status: "banned",
|
||||||
|
thirdPartyIdentities: [
|
||||||
|
{ createdAtMs: 1760000000000, openid: "g-openid-1", provider: "google", unionId: "g-union-1" },
|
||||||
|
],
|
||||||
updatedAtMs: 1782000000000,
|
updatedAtMs: 1782000000000,
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
username: "tester",
|
username: "tester",
|
||||||
vip: { active: true, expiresAtMs: 1795000000000, level: 3, name: "黄金会员" },
|
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(
|
render(
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
@ -72,6 +105,25 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
|
|||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "状态记录" }));
|
fireEvent.click(screen.getByRole("tab", { name: "状态记录" }));
|
||||||
expect(screen.getByText("永久")).toBeInTheDocument();
|
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: /完整详情/ }));
|
fireEvent.click(screen.getByRole("link", { name: /完整详情/ }));
|
||||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Link, useLocation } from "react-router-dom";
|
|||||||
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
import { issueAppUserAccessToken } from "@/features/app-users/api";
|
||||||
import { appUserStatusLabels } from "@/features/app-users/constants.js";
|
import { appUserStatusLabels } from "@/features/app-users/constants.js";
|
||||||
import {
|
import {
|
||||||
BanCountdown,
|
BanCountdown,
|
||||||
@ -24,6 +25,7 @@ const detailTabs = [
|
|||||||
["assets", "资产"],
|
["assets", "资产"],
|
||||||
["resources", "资源"],
|
["resources", "资源"],
|
||||||
["status", "状态记录"],
|
["status", "状态记录"],
|
||||||
|
["device", "设备"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AppUserDetailView({
|
export function AppUserDetailView({
|
||||||
@ -104,6 +106,7 @@ export function AppUserDetailView({
|
|||||||
<ResourcesPanel equippedResources={equippedResources} resources={resources} />
|
<ResourcesPanel equippedResources={equippedResources} resources={resources} />
|
||||||
) : null}
|
) : null}
|
||||||
{activeTab === "status" ? <StatusPanel user={user} /> : null}
|
{activeTab === "status" ? <StatusPanel user={user} /> : null}
|
||||||
|
{activeTab === "device" ? <DevicePanel user={user} /> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -188,6 +191,10 @@ function StatusPanel({ user }) {
|
|||||||
<DetailItem label="区域" value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")} />
|
<DetailItem label="区域" value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")} />
|
||||||
<DetailItem label="最新登录" value={formatMillis(user.lastLoginAtMs)} />
|
<DetailItem label="最新登录" value={formatMillis(user.lastLoginAtMs)} />
|
||||||
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
|
<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
|
<DetailItem
|
||||||
label="最新操作"
|
label="最新操作"
|
||||||
value={<OperatorValue operatedAtMs={user.lastOperatedAtMs} operator={user.lastOperator} />}
|
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 }) {
|
function DetailSection({ children, title }) {
|
||||||
return (
|
return (
|
||||||
<section className={styles.detailSection}>
|
<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 [copied, setCopied] = useState(false);
|
||||||
const normalized = String(value ?? "").trim();
|
const normalized = String(value ?? "").trim();
|
||||||
|
|
||||||
@ -239,6 +291,7 @@ function CopyableValue({ label, value }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copy = async () => {
|
const copy = async () => {
|
||||||
|
// display 只影响展示(如超长 Token 截断),复制必须始终写入完整原值,否则复制出的凭证不可用。
|
||||||
await copyText(normalized);
|
await copyText(normalized);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
window.setTimeout(() => setCopied(false), 1200);
|
window.setTimeout(() => setCopied(false), 1200);
|
||||||
@ -246,7 +299,7 @@ function CopyableValue({ label, value }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<span className={styles.copyableDetailValue}>
|
<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}>
|
<button aria-label={label} className={styles.copyDetailButton} title={copied ? "已复制" : label} type="button" onClick={copy}>
|
||||||
<ContentCopyOutlined fontSize="inherit" />
|
<ContentCopyOutlined fontSize="inherit" />
|
||||||
</button>
|
</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 }) {
|
function PrettyValue({ user }) {
|
||||||
if (!user.prettyId || !user.prettyDisplayUserId) {
|
if (!user.prettyId || !user.prettyDisplayUserId) {
|
||||||
return "-";
|
return "-";
|
||||||
@ -354,6 +503,13 @@ function formatCompactUsdMinor(value) {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAppVersion(appVersion, buildNumber) {
|
||||||
|
if (!appVersion) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return buildNumber ? `${appVersion} (build ${buildNumber})` : appVersion;
|
||||||
|
}
|
||||||
|
|
||||||
function formatCountry(user) {
|
function formatCountry(user) {
|
||||||
return user.countryDisplayName || user.countryName || user.country || "-";
|
return user.countryDisplayName || user.countryName || user.country || "-";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,8 @@ import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
|
|||||||
import { getAppUser } from "@/features/app-users/api";
|
import { getAppUser } from "@/features/app-users/api";
|
||||||
import { AppUserDetailPage } from "@/features/app-users/pages/AppUserDetailPage.jsx";
|
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 () => {
|
test("full user detail route loads the URL user and returns to its source list", async () => {
|
||||||
vi.mocked(getAppUser).mockResolvedValue({
|
vi.mocked(getAppUser).mockResolvedValue({
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export const API_OPERATIONS = {
|
|||||||
appBanUser: "appBanUser",
|
appBanUser: "appBanUser",
|
||||||
appExportUsers: "appExportUsers",
|
appExportUsers: "appExportUsers",
|
||||||
appGetUser: "appGetUser",
|
appGetUser: "appGetUser",
|
||||||
|
appIssueUserAccessToken: "appIssueUserAccessToken",
|
||||||
appListBannedUsers: "appListBannedUsers",
|
appListBannedUsers: "appListBannedUsers",
|
||||||
appListLoginLogs: "appListLoginLogs",
|
appListLoginLogs: "appListLoginLogs",
|
||||||
appListUserLoginLogs: "appListUserLoginLogs",
|
appListUserLoginLogs: "appListUserLoginLogs",
|
||||||
@ -421,6 +422,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
permission: "app-user:view",
|
permission: "app-user:view",
|
||||||
permissions: ["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: {
|
appListBannedUsers: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
operationId: API_OPERATIONS.appListBannedUsers,
|
operationId: API_OPERATIONS.appListBannedUsers,
|
||||||
|
|||||||
85
src/shared/api/generated/schema.d.ts
vendored
85
src/shared/api/generated/schema.d.ts
vendored
@ -3384,6 +3384,22 @@ export interface paths {
|
|||||||
patch: operations["appUpdateUser"];
|
patch: operations["appUpdateUser"];
|
||||||
trace?: never;
|
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": {
|
"/app/users/{id}/ban": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4350,6 +4366,9 @@ export interface components {
|
|||||||
ApiResponseAppUserExportJob: components["schemas"]["Envelope"] & {
|
ApiResponseAppUserExportJob: components["schemas"]["Envelope"] & {
|
||||||
data?: components["schemas"]["AppUserExportJob"];
|
data?: components["schemas"]["AppUserExportJob"];
|
||||||
};
|
};
|
||||||
|
ApiResponseAppUserAccessToken: components["schemas"]["Envelope"] & {
|
||||||
|
data?: components["schemas"]["AppUserAccessToken"];
|
||||||
|
};
|
||||||
ApiPageAppUser: {
|
ApiPageAppUser: {
|
||||||
items: components["schemas"]["AppUser"][];
|
items: components["schemas"]["AppUser"][];
|
||||||
page: number;
|
page: number;
|
||||||
@ -4375,6 +4394,18 @@ export interface components {
|
|||||||
regionId?: number;
|
regionId?: number;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
registerDevice?: 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[];
|
roles: string[];
|
||||||
levels: components["schemas"]["AppUserLevels"];
|
levels: components["schemas"]["AppUserLevels"];
|
||||||
ban: components["schemas"]["AppUserBan"];
|
ban: components["schemas"]["AppUserBan"];
|
||||||
@ -4486,6 +4517,37 @@ export interface components {
|
|||||||
/** Format: int64 */
|
/** Format: int64 */
|
||||||
updatedAtMs?: number;
|
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: {
|
AppUserUpdateInput: {
|
||||||
username?: string;
|
username?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
@ -5889,6 +5951,15 @@ export interface components {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description OK */
|
/** @description OK */
|
||||||
|
AppUserAccessTokenResponse: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiResponseAppUserAccessToken"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description OK */
|
||||||
AgencyPageResponse: {
|
AgencyPageResponse: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
@ -10696,6 +10767,20 @@ export interface operations {
|
|||||||
200: components["responses"]["AppUserResponse"];
|
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: {
|
appBanUser: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -1042,22 +1042,29 @@ export interface CoinSellerSalaryRatesPayload {
|
|||||||
|
|
||||||
export interface AppUserDto {
|
export interface AppUserDto {
|
||||||
age?: number;
|
age?: number;
|
||||||
|
appVersion?: string;
|
||||||
|
appVersionAtMs?: number;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
balances?: AppUserAssetBalanceDto[];
|
balances?: AppUserAssetBalanceDto[];
|
||||||
ban?: AppUserBanDto;
|
ban?: AppUserBanDto;
|
||||||
birth?: string;
|
birth?: string;
|
||||||
|
buildNumber?: string;
|
||||||
coin?: number;
|
coin?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
countryDisplayName?: string;
|
countryDisplayName?: string;
|
||||||
countryName?: string;
|
countryName?: string;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
defaultDisplayUserId?: string;
|
defaultDisplayUserId?: string;
|
||||||
|
devices?: AppUserDeviceDto[];
|
||||||
diamond?: number;
|
diamond?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
equippedResources?: AppUserResourceDto[];
|
equippedResources?: AppUserResourceDto[];
|
||||||
gender?: string;
|
gender?: string;
|
||||||
lastActiveAtMs?: number;
|
lastActiveAtMs?: number;
|
||||||
lastLoginAtMs?: number;
|
lastLoginAtMs?: number;
|
||||||
|
lastLoginIp?: string;
|
||||||
|
lastLoginIpAtMs?: number;
|
||||||
|
lastLoginIpCountryCode?: string;
|
||||||
lastOperatedAtMs?: number;
|
lastOperatedAtMs?: number;
|
||||||
lastOperator?: AppUserOperatorDto;
|
lastOperator?: AppUserOperatorDto;
|
||||||
levels?: AppUserLevelsDto;
|
levels?: AppUserLevelsDto;
|
||||||
@ -1066,9 +1073,12 @@ export interface AppUserDto {
|
|||||||
regionId?: number;
|
regionId?: number;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
registerDevice?: string;
|
registerDevice?: string;
|
||||||
|
registerDeviceId?: string;
|
||||||
|
registerOsVersion?: string;
|
||||||
resources?: AppUserResourceDto[];
|
resources?: AppUserResourceDto[];
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
status?: string;
|
status?: string;
|
||||||
|
thirdPartyIdentities?: AppUserThirdPartyIdentityDto[];
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
@ -1076,6 +1086,35 @@ export interface AppUserDto {
|
|||||||
cumulativeRechargeUsdMinor?: number;
|
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 {
|
export interface AppUserLevelDto {
|
||||||
displayLevel: number;
|
displayLevel: number;
|
||||||
displayValue: number;
|
displayValue: number;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user