fix(account): 保持取消静默并合并失败诊断

This commit is contained in:
lsdsjy
2026-10-14 14:11:13 +08:00
parent c9d56d5073
commit d0b0bfcd79
7 changed files with 160 additions and 41 deletions
@@ -220,8 +220,7 @@ it('keeps successful account refreshes silent and reports only failed reads', as
await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().details?.profile).toEqual(profile) })
await actions.refreshAccount()
for (const spy of [output, info, log]) {
expect(spy.mock.calls.filter(call => String(call[0]).startsWith('[account-debug]')
|| String(call[0]).startsWith('[deepseek-account]'))).toEqual([])
expect(spy.mock.calls.filter(call => String(call[0]).startsWith('[deepseek-account]'))).toEqual([])
}
c.mock.remote.account.getProfile.mockResolvedValueOnce(ok({ status: 'failed' }))
c.mock.remote.account.getBalance.mockRejectedValueOnce(new Error('private-transport-detail'))
@@ -15,8 +15,8 @@
en: af856ba79fe31b8e
zh: 15b7d1a5d1856de2
/deepseek-ai-dsh-deepseek-account-platform/use-this-package:
en: dfeda5f1385bc499
zh: 03ba6a8e8071d5ea
en: 03ce750274b355f6
zh: 0bfb86570c686e17
/deepseek-ai-dsh-deepseek-account-platform/understand-the-implementation:
en: 76d2da2dab100ac3
zh: 2c9404f27915daab
@@ -59,7 +59,7 @@ A private patch can read environment variables through Cordis expressions:
The authenticated client supplies its browser-accessible callback origin, including the local SSH forwarding port, and its UI kind. The provider fixes the callback path and binds the resulting redirect_uri to both initialization and exchange. Callback requests use state and PKCE validation rather than RPC authentication. Completion, cancellation and disposal remove only the attempt route. Exchange failures publish failed state without retrying: Web closes its authorization tab and keeps the failure dialog in the original tab; the callback page attempts to close itself and offers a manual-close message. Desktop receives HTTP 204 and focuses its existing login UI through the account stream.
Host diagnostics use `console.error` with the `[deepseek-account]` prefix on standard error and in the Host inspector Console, only for request, response-validation, or sign-in failures. Successful requests and normal account state changes are silent. They include endpoint paths, HTTP status, numeric response codes, failure stages, invalid field paths, received types and validation codes, and browser URL rejection rules; headers, request and response bodies, authorization URLs, and raw exceptions are excluded.
Host diagnostics use `console.error` with the `[deepseek-account]` prefix on standard error and in the Host inspector Console, only for request, response-validation, or sign-in failures. Successful requests, caller cancellation, and normal account state changes are silent; request timeouts remain failures. Failure diagnostics include endpoint paths, HTTP status, numeric response codes, failure stages, invalid field paths, received types and validation codes, and browser URL rejection rules; headers, request and response bodies, authorization URLs, and raw exceptions are excluded.
`rewriteBrowserOrigin` defaults to `false`, requiring same-origin browser URLs. A private development patch may set it to `true` to map authorization and completion URLs to `platformOrigin`, preserving their fixed paths and query strings. Source URLs must use HTTPS or already match the configured origin; user information, fragments and unexpected paths remain rejected. Shipped profiles retain strict same-origin validation.
@@ -59,7 +59,7 @@ getUnnotifiedBonuses 读取同一来源上的 GET /api/v0/users/get_unnotified_b
已鉴权客户端提供浏览器可访问的回调来源(含 SSH 本地转发端口)和 UI 类型。提供者固定回调路径,并在初始化与兑换时使用同一个 redirect_uri。回调请求使用 state 和 PKCE 校验,不要求 RPC 鉴权。完成、取消和卸载仅移除本次尝试的路由。兑换失败发布 failed 状态,不自动重试:Web 关闭授权标签页,原标签页显示失败弹窗;回调页尝试自行关闭,并提供手动关闭提示。Desktop 收到 HTTP 204,并通过账号状态流聚焦现有登录界面。
Host 诊断仅在请求、响应校验或登录失败时,通过 `console.error` 写入标准错误及 Host 调试器 Console,前缀为 `[deepseek-account]`。成功请求和正常账号状态变化不输出日志。日志包含接口路径、HTTP 状态、数值响应码、失败阶段、校验失败的字段路径、实际类型和校验码,以及浏览器 URL 拒绝规则,不包含请求头、请求和响应正文、授权 URL 或原始异常。
Host 诊断仅在请求、响应校验或登录失败时,通过 `console.error` 写入标准错误及 Host 调试器 Console,前缀为 `[deepseek-account]`。成功请求、调用方主动取消和正常账号状态变化不输出日志;请求超时仍记录为失败。失败诊断包含接口路径、HTTP 状态、数值响应码、失败阶段、校验失败的字段路径、实际类型和校验码,以及浏览器 URL 拒绝规则,不包含请求头、请求和响应正文、授权 URL 或原始异常。
`rewriteBrowserOrigin` 默认为 `false`,要求浏览器地址同源。私有开发 patch 可设为 `true`,将授权页和完成页地址映射到 `platformOrigin`,保留固定路径和查询字符串。原地址必须使用 HTTPS 或已匹配配置来源;仍拒绝用户名密码、片段和非预期路径。发布的 profile 保持严格同源校验。
@@ -29,28 +29,39 @@ const unnotified = z.array(bonus)
export function profile(value: unknown): AccountProfile {
const parsed = user.safeParse(value)
if (!parsed.success) throw new PlatformAuthError('protocol')
const { email, mobile, mobile_number: mobileNumber, id_profile: identity } = parsed.data
return projectProfile(parsed.data)
}
function projectProfile(value: z.infer<typeof user>): AccountProfile {
const { email, mobile, mobile_number: mobileNumber, id_profile: identity } = value
return {
id: parsed.data.id == null ? null : parsed.data.id as AccountUserId,
id: value.id == null ? null : value.id as AccountUserId,
avatarUrl: identity?.picture || null,
name: identity?.name || null, contact: mobile || mobileNumber || email || null,
}
}
function accountPayload<T>(schema: z.ZodType<T>, value: unknown, field: 'profile' | 'balance' | 'bonus'): T {
const parsed = schema.safeParse(value)
if (!parsed.success) {
console.error(`[deepseek-account] ${field} schema rejected`, {
issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })),
})
throw new PlatformAuthError('protocol')
}
return parsed.data
}
const queries: { [K in keyof AccountDetails]: {
path: '/auth-api/v0/users/current' | '/api/v0/users/get_user_summary'
parse: (value: unknown) => AccountDetails[K]
} } = {
profile: { path: '/auth-api/v0/users/current', parse: value => ({ status: 'ready', value: profile(value) }) },
profile: { path: '/auth-api/v0/users/current', parse: value => ({
status: 'ready', value: projectProfile(accountPayload(user, value, 'profile')),
}) },
balance: { path: '/api/v0/users/get_user_summary', parse: (value) => {
const parsed = summary.safeParse(value)
if (!parsed.success) {
console.error('[deepseek-account] balance schema rejected', {
issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })),
})
throw new PlatformAuthError('protocol')
}
return { status: 'ready', value: parsed.data.normal_wallets, bonusWallets: parsed.data.bonus_wallets }
const parsed = accountPayload(summary, value, 'balance')
return { status: 'ready', value: parsed.normal_wallets, bonusWallets: parsed.bonus_wallets }
} },
}
@@ -69,7 +80,6 @@ export async function readAccountDetail<K extends keyof AccountDetails>(field: K
const query = queries[field]
try { return query.parse(await requestAccount(origin, query.path, token, signal, headers)) }
catch (error) {
console.error('[deepseek-account] detail failed', { field })
if (error instanceof AccountUnauthorizedError) throw error
return { status: 'failed' }
}
@@ -85,14 +95,8 @@ export async function readAccountDetail<K extends keyof AccountDetails>(field: K
*/
export async function readUnnotifiedBonuses(origin: string, token: string,
signal: AbortSignal, headers: Record<string, string>): Promise<readonly AccountBonusNotification[]> {
const parsed = unnotified.safeParse(await requestUnnotifiedBonuses(origin, token, signal, headers))
if (!parsed.success) {
console.error('[deepseek-account] bonus schema rejected', {
issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })),
})
throw new PlatformAuthError('protocol')
}
return parsed.data.map(row => ({
const parsed = accountPayload(unnotified, await requestUnnotifiedBonuses(origin, token, signal, headers), 'bonus')
return parsed.map(row => ({
orderId: row.order_id as AccountBonusOrderId, campaign: row.campaign, amount: row.amount,
currency: row.currency, grantedAt: row.granted_at, expiresAt: row.expires_at, message: row.msg,
}))
@@ -82,7 +82,7 @@ export const initialization = z.object({
export const exchange = z.object({ token: z.string().regex(/^[\x21-\x7e]+$/), authorized_url: z.url(), user: z.unknown().optional() })
/**
* Read one bounded platform response; failures emit non-secret diagnostics to stderr.
* Read one bounded platform response; non-cancellation failures emit non-secret diagnostics to stderr.
* @param origin - validated platform origin.
* @param method - platform endpoint suffix.
* @param body - protocol request, never logged.
@@ -163,22 +163,28 @@ export async function logoutAccount(origin: string, token: string,
}, signal)
}
/** Caller cancellation stays silent; standard TimeoutError aborts remain failures. */
function callerCancelled(signal: AbortSignal): boolean {
const reason: unknown = signal.reason
return signal.aborted && !(reason instanceof Error && reason.name === 'TimeoutError')
}
async function platformRequest(url: string, init: RequestInit, signal: AbortSignal): Promise<unknown> {
const path = new URL(url).pathname
let response: Response
try {
response = await fetch(url, { ...init, redirect: 'error', signal })
} catch {
console.error('[deepseek-account] request failed', { path, errorCode: 'no-response', aborted: signal.aborted })
if (!callerCancelled(signal)) console.error('[deepseek-account] request failed', { path, errorCode: 'no-response', aborted: signal.aborted })
throw new PlatformAuthError('no-response')
}
if (response.status === 401 && new Headers(init.headers).has('x-dsh-auth-token')) {
console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'expired' })
if (!callerCancelled(signal)) console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'expired' })
await response.body?.cancel()
throw new AccountUnauthorizedError()
}
if (!response.ok || response.body === null) {
console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'network' })
if (!callerCancelled(signal)) console.error('[deepseek-account] request failed', { path, status: response.status, errorCode: 'network' })
await response.body?.cancel()
throw new PlatformAuthError('network')
}
@@ -186,6 +192,8 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign
const chunks: Uint8Array[] = []
let size = 0
let stage = 'read-body'
let codes: { code: number; data?: { biz_code: number } | undefined } | undefined
let validationError: z.ZodError | undefined
try {
while (true) {
const next = await reader.read()
@@ -199,12 +207,8 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign
}
stage = 'parse-json'
const payload: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
const codes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload)
if (codes.success && (codes.data.code !== 0 || (codes.data.data !== undefined && codes.data.data.biz_code !== 0))) {
console.error('[deepseek-account] response codes', {
path, status: response.status, code: codes.data.code, bizCode: codes.data.data?.biz_code,
})
}
const responseCodes = z.object({ code: z.number().int(), data: z.object({ biz_code: z.number().int() }).optional() }).safeParse(payload)
if (responseCodes.success) codes = responseCodes.data
const authorization = z.object({ code: z.literal(40003) }).safeParse(payload)
if (authorization.success && new Headers(init.headers).has('x-dsh-auth-token')) {
throw new AccountUnauthorizedError()
@@ -212,9 +216,7 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign
stage = 'envelope'
const parsed = envelope.safeParse(payload)
if (!parsed.success) {
console.error('[deepseek-account] envelope rejected', {
path, issues: parsed.error.issues.map(issue => ({ path: issue.path, code: issue.code })),
})
validationError = parsed.error
throw new PlatformAuthError('protocol')
}
stage = 'business-code'
@@ -225,8 +227,11 @@ async function platformRequest(url: string, init: RequestInit, signal: AbortSign
}
return parsed.data.data.biz_data
} catch (error) {
console.error('[deepseek-account] response rejected', { path, status: response.status, stage,
errorCode: error instanceof PlatformAuthError ? error.code : 'protocol' })
if (!callerCancelled(signal)) console.error('[deepseek-account] response rejected', {
path, status: response.status, stage, code: codes?.code, bizCode: codes?.data?.biz_code,
issues: validationError?.issues.map(issue => ({ path: issue.path, code: issue.code })),
errorCode: error instanceof PlatformAuthError ? error.code : 'protocol',
})
if (error instanceof PlatformAuthError) throw error
throw new PlatformAuthError('protocol')
} finally {
@@ -28,6 +28,7 @@ it('reports business failure codes without credentials or response messages', as
await expect(requestPlatform('https://platform.deepseek.com', 'auth_init',
{ code_verifier: 'request-secret' }, new AbortController().signal,
{ Cookie: 'cookie-secret' })).rejects.toThrow('account: protocol')
expect(output).toHaveBeenCalledOnce()
const logged = JSON.stringify(output.mock.calls)
expect(logged).toContain('/auth-api/v0/dsh/auth_init')
expect(logged).toContain('40123')
@@ -189,3 +190,113 @@ it('reports invalid bonus fields without logging bonus data', async () => {
output.mockRestore()
}
})
it.each([
{ stage: 'fetch', code: 'no-response' },
{ stage: 'unauthorized', code: 'expired' },
{ stage: 'http', code: 'network' },
{ stage: 'body', code: 'protocol' },
])('keeps caller cancellation silent during $stage', async ({ stage, code }) => {
const output = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const fetcher = vi.spyOn(globalThis, 'fetch')
const cancellation = new AbortController()
const reading = Promise.withResolvers<undefined>()
let body: ReadableStreamDefaultController<Uint8Array> | undefined
try {
if (stage === 'body') {
fetcher.mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
start(controller) { body = controller },
pull() { reading.resolve(undefined) },
})))
} else if (stage === 'fetch') {
cancellation.abort()
fetcher.mockRejectedValue(cancellation.signal.reason)
} else {
fetcher.mockImplementation(async () => {
cancellation.abort()
return new Response('private-response', { status: stage === 'unauthorized' ? 401 : 503 })
})
}
const request = requestAccount('https://platform.deepseek.com', '/auth-api/v0/users/current',
'private-token', cancellation.signal, {})
if (stage === 'body') {
await reading.promise
cancellation.abort()
body!.error(cancellation.signal.reason)
}
await expect(request).rejects.toThrow(`account: ${code}`)
expect(output).not.toHaveBeenCalled()
} finally {
body?.error()
fetcher.mockRestore()
output.mockRestore()
}
})
it('keeps cancellation with a non-error reason silent', async () => {
const output = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const fetcher = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('private-network-error'))
const cancellation = new AbortController()
cancellation.abort('private-cancel-reason')
try {
expect(await readAccountDetail('profile', 'https://platform.deepseek.com', 'private-token',
cancellation.signal, {})).toEqual({ status: 'failed' })
expect(output).not.toHaveBeenCalled()
} finally {
fetcher.mockRestore()
output.mockRestore()
}
})
it.each(['fetch', 'body'] as const)('reports request timeouts during %s once without raw exceptions', async (stage) => {
const output = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const fetcher = vi.spyOn(globalThis, 'fetch')
const timeout = new AbortController()
const reading = Promise.withResolvers<undefined>()
let body: ReadableStreamDefaultController<Uint8Array> | undefined
try {
if (stage === 'body') {
fetcher.mockResolvedValue(new Response(new ReadableStream<Uint8Array>({
start(controller) { body = controller },
pull() { reading.resolve(undefined) },
})))
} else {
timeout.abort(new DOMException('private-timeout-message', 'TimeoutError'))
fetcher.mockRejectedValue(timeout.signal.reason)
}
const request = readAccountDetail('profile', 'https://platform.deepseek.com', 'private-token', timeout.signal, {})
if (stage === 'body') {
await reading.promise
timeout.abort(new DOMException('private-timeout-message', 'TimeoutError'))
body!.error(timeout.signal.reason)
}
expect(await request).toEqual({ status: 'failed' })
expect(output).toHaveBeenCalledOnce()
expect(JSON.stringify(output.mock.calls)).not.toContain('private-')
} finally {
body?.error()
fetcher.mockRestore()
output.mockRestore()
}
})
it('reports invalid profile fields once without logging profile values', async () => {
const output = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const fetcher = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({
code: 0, data: { biz_code: 0, biz_data: { email: 42, id: 'private-id' } },
})))
try {
expect(await readAccountDetail('profile', 'https://platform.deepseek.com', 'private-token',
new AbortController().signal, {})).toEqual({ status: 'failed' })
expect(output).toHaveBeenCalledOnce()
expect(output.mock.calls[0]?.[0]).toBe('[deepseek-account] profile schema rejected')
const logged = JSON.stringify(output.mock.calls)
expect(logged).toContain('email')
expect(logged).toContain('invalid_type')
expect(logged).not.toContain('private-')
} finally {
fetcher.mockRestore()
output.mockRestore()
}
})