test(desktop): cover deepseek account name extraction for wechat + phone logins
Desktop Client Build / Resolve Build Metadata (push) Successful in 28s
Frontend CI / Frontend (push) Successful in 4m5s
Backend CI / Backend (push) Successful in 17m7s
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled
Desktop Client Build / Build Desktop Client (push) Has been cancelled
Desktop Client Build / Resolve Build Metadata (push) Successful in 28s
Frontend CI / Frontend (push) Successful in 4m5s
Backend CI / Backend (push) Successful in 17m7s
Desktop Client Build / Publish Client Artifacts to NAS (push) Has been cancelled
Desktop Client Build / Build Desktop Client (push) Has been cancelled
Refactor readDeepseekAccountIdentity so the JSON payload parser is a pure
ts function (extractDeepseekIdentityFromPayload). The webContents IIFE now
just polls for userToken and returns the raw API JSON; payload picking lives
in ts and is unit-tested.
Tests lock the contract for both login types we observed in the wild:
- WeChat-bound account: id_profile.name = '阿白', picture present.
- Phone-only login: id_profile = null, mobile_number = '177******08',
email = '' (empty string, not null) — the case that originally regressed.
Plus regressions for the priority order (profile.name > mobile_number > email),
the all-empty fallback, and non-record payloads.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { extractDeepseekIdentityFromPayload } from './account-identity'
|
||||
|
||||
describe('extractDeepseekIdentityFromPayload', () => {
|
||||
it('returns the WeChat profile name + avatar for WECHAT-bound accounts', () => {
|
||||
const wechatPayload = {
|
||||
code: 0,
|
||||
msg: '',
|
||||
data: {
|
||||
biz_code: 0,
|
||||
biz_msg: '',
|
||||
biz_data: {
|
||||
id: '7a15e288-1560-49be-a519-3e3da071071c',
|
||||
email: '',
|
||||
mobile_number: '130******73',
|
||||
area_code: '+86',
|
||||
status: 0,
|
||||
id_profile: {
|
||||
provider: 'WECHAT',
|
||||
id: 'e5a1861a-6c1b-48ae-9d2c-00e643e6b4bd',
|
||||
name: '阿白',
|
||||
picture: 'https://static.deepseek.com/user-avatar/LwJ3Jq-HLkgwCGbl0HS1lECW',
|
||||
locale: 'zh_CN',
|
||||
email: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(extractDeepseekIdentityFromPayload(wechatPayload)).toEqual({
|
||||
displayName: '阿白',
|
||||
avatarUrl: 'https://static.deepseek.com/user-avatar/LwJ3Jq-HLkgwCGbl0HS1lECW',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to mobile_number for phone-only logins (id_profile is null, email is empty string)', () => {
|
||||
const phonePayload = {
|
||||
code: 0,
|
||||
msg: '',
|
||||
data: {
|
||||
biz_code: 0,
|
||||
biz_msg: '',
|
||||
biz_data: {
|
||||
id: 'd782f68b-7358-4290-b891-38cb7a1e11fa',
|
||||
email: '',
|
||||
mobile_number: '177******08',
|
||||
area_code: '+86',
|
||||
status: 0,
|
||||
id_profile: null,
|
||||
id_profiles: [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(extractDeepseekIdentityFromPayload(phonePayload)).toEqual({
|
||||
displayName: '177******08',
|
||||
avatarUrl: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers id_profile.name over mobile_number when both are present', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
biz_data: {
|
||||
mobile_number: '130******73',
|
||||
id_profile: {
|
||||
provider: 'WECHAT',
|
||||
name: '阿白',
|
||||
picture: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(extractDeepseekIdentityFromPayload(payload).displayName).toBe('阿白')
|
||||
})
|
||||
|
||||
it('uses email as last-resort fallback when id_profile and mobile_number are empty', () => {
|
||||
const payload = {
|
||||
data: {
|
||||
biz_data: {
|
||||
id_profile: null,
|
||||
mobile_number: '',
|
||||
email: 'user@example.com',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(extractDeepseekIdentityFromPayload(payload).displayName).toBe('user@example.com')
|
||||
})
|
||||
|
||||
it('returns null displayName when nothing usable is present', () => {
|
||||
expect(
|
||||
extractDeepseekIdentityFromPayload({
|
||||
data: { biz_data: { id_profile: null, email: '', mobile_number: '' } },
|
||||
}),
|
||||
).toEqual({ displayName: null, avatarUrl: null })
|
||||
})
|
||||
|
||||
it('rejects non-record payloads without throwing', () => {
|
||||
expect(extractDeepseekIdentityFromPayload(null)).toEqual({
|
||||
displayName: null,
|
||||
avatarUrl: null,
|
||||
})
|
||||
expect(extractDeepseekIdentityFromPayload('hello')).toEqual({
|
||||
displayName: null,
|
||||
avatarUrl: null,
|
||||
})
|
||||
expect(extractDeepseekIdentityFromPayload([])).toEqual({
|
||||
displayName: null,
|
||||
avatarUrl: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,18 +14,52 @@ function normalizeText(value: unknown): string | null {
|
||||
return trimmed || null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function pickString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value : null
|
||||
}
|
||||
|
||||
// Pure parser, exported for tests. Both 微信登录 and 手机号登录 must work:
|
||||
// 微信: { biz_data: { id_profile: { provider: 'WECHAT', name: '阿白', picture: '...' }, ... } }
|
||||
// 手机: { biz_data: { id_profile: null, mobile_number: '177******08', email: '', ... } }
|
||||
// Empty-string fields (email = "") are common, so we use truthy checks rather than ??.
|
||||
export function extractDeepseekIdentityFromPayload(
|
||||
payload: unknown,
|
||||
): DeepseekAccountIdentityHints {
|
||||
if (!isRecord(payload)) {
|
||||
return { displayName: null, avatarUrl: null }
|
||||
}
|
||||
|
||||
const data = isRecord(payload.data) ? payload.data : null
|
||||
const bizData = data && isRecord(data.biz_data) ? data.biz_data : null
|
||||
if (!bizData) {
|
||||
return { displayName: null, avatarUrl: null }
|
||||
}
|
||||
|
||||
const profile = isRecord(bizData.id_profile) ? bizData.id_profile : null
|
||||
|
||||
const displayName =
|
||||
pickString(profile?.name) ??
|
||||
pickString(bizData.mobile_number) ??
|
||||
pickString(bizData.email)
|
||||
const avatarUrl = pickString(profile?.picture)
|
||||
|
||||
return {
|
||||
displayName: normalizeText(displayName),
|
||||
avatarUrl: normalizeText(avatarUrl),
|
||||
}
|
||||
}
|
||||
|
||||
// DeepSeek (chat.deepseek.com) 没有 Redux/Pinia/window state,登录态只有 localStorage
|
||||
// 里的 `userToken`(JSON 包了 value 字段)。账号信息要走 /api/v0/users/current,
|
||||
// 用 Authorization: Bearer <token> 调。Cookie 不带 token,因此只能在页面上下文里
|
||||
// 执行 fetch(自带 token)。
|
||||
//
|
||||
// 不同登录方式响应差异较大:
|
||||
// 微信登录: id_profile = { provider: 'WECHAT', name: '阿白', picture: '...' }
|
||||
// 手机登录: id_profile = null, mobile_number = '177******08'(其它字段是空串而非 null)
|
||||
// 所以这里 fallback 必须用 truthy 判断而不是 ??,否则空串会击穿后续兜底。
|
||||
// 里的 `userToken`(JSON 包了 value 字段)。账号信息要走 GET /api/v0/users/current,
|
||||
// 用 Authorization: Bearer <token> 调(cookie 不带 token)。
|
||||
//
|
||||
// probe 通常在隐藏窗口、刚加载完就跑,React app 可能还没把 userToken 写入 localStorage,
|
||||
// 所以要 poll 一下直到 token 出现,再调 API。
|
||||
// 所以要 poll 一下直到 token 出现,再调 API。webContents 里的 IIFE 仅拉原始 JSON,
|
||||
// 解析与字段挑选放到 extractDeepseekIdentityFromPayload 里以便单测。
|
||||
export async function readDeepseekAccountIdentity(
|
||||
webContents: WebContents | null | undefined,
|
||||
): Promise<DeepseekAccountIdentityHints> {
|
||||
@@ -33,7 +67,7 @@ export async function readDeepseekAccountIdentity(
|
||||
return { displayName: null, avatarUrl: null }
|
||||
}
|
||||
|
||||
const result = await webContents
|
||||
const rawJson = await webContents
|
||||
.executeJavaScript(
|
||||
`(async () => {
|
||||
try {
|
||||
@@ -64,17 +98,7 @@ export async function readDeepseekAccountIdentity(
|
||||
headers: { Authorization: 'Bearer ' + token },
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const payload = await response.json().catch(() => null);
|
||||
const bizData = payload?.data?.biz_data ?? null;
|
||||
const profile = bizData?.id_profile ?? null;
|
||||
const pickString = (value) =>
|
||||
typeof value === 'string' && value.trim() ? value : null;
|
||||
const name =
|
||||
pickString(profile?.name) ??
|
||||
pickString(bizData?.mobile_number) ??
|
||||
pickString(bizData?.email);
|
||||
const picture = pickString(profile?.picture);
|
||||
return { displayName: name, avatarUrl: picture };
|
||||
return await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -83,13 +107,16 @@ export async function readDeepseekAccountIdentity(
|
||||
)
|
||||
.catch(() => null)
|
||||
|
||||
if (!result || typeof result !== 'object') {
|
||||
if (typeof rawJson !== 'string' || !rawJson) {
|
||||
return { displayName: null, avatarUrl: null }
|
||||
}
|
||||
|
||||
const typed = result as { displayName?: unknown; avatarUrl?: unknown }
|
||||
return {
|
||||
displayName: normalizeText(typed.displayName),
|
||||
avatarUrl: normalizeText(typed.avatarUrl),
|
||||
let payload: unknown
|
||||
try {
|
||||
payload = JSON.parse(rawJson)
|
||||
} catch {
|
||||
return { displayName: null, avatarUrl: null }
|
||||
}
|
||||
|
||||
return extractDeepseekIdentityFromPayload(payload)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user