feat(credentials,ui-settings-account): display bonus wallets separately

This commit is contained in:
lsdsjy committed 2026-09-20 11:31:41 +08:00
1 parent 0c0789e4df
commit f0a35eba0d
31 files changed
+118 -38

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-deepseek-account-login.md
2026-09-14-deepseek-account-login.md: f5e15041686600ef8223fb26a91cfea0e6ead6b1
2026-09-14-deepseek-account-login.zh.md: 2c623fca28a73450504f28168a807d7f45f04cd6
2026-09-14-deepseek-account-login.md: ce6fae5854117b8ff1e52ce415559beb1e576d50
2026-09-14-deepseek-account-login.zh.md: 2422750d15cf8a85a816902c9a47e35741351cdd
@@ -30,7 +30,7 @@ Putting the protocol in Electron duplicates the implementation for Web consumers
Sign out removes the local grant before starting Platform POST /auth-api/v0/users/logout with the captured token. Remote failure never restores login. The provider retries at most five times after the initial request, with configurable exponential backoff starting at one second by default. Retry jobs retain only their old token, cannot change later login state, and end at provider shutdown without durable storage. Local credential deletion must succeed before publishing signed-out state. Sign out belongs to the sidebar account menu; Account settings owns profile, balance and sign-in. Both read one plugin-owned Host stream through framework hooks.
API keys and account grants remain separately stored. Account tokens have no expiry or refresh flow; exhausted logout retries can leave the removed token valid remotely. Provider initialization discards grants from a different configured Platform issuer locally, without remote revocation, so switching environments starts signed out instead of aborting Desktop startup. API keys and device identity survive this cleanup. Profile and balance query failures preserve login state; authorization-attempt deadlines apply only before token issuance. The same grant authenticates Platform current and get_user_summary queries on its configured issuer origin. Host preserves Platform-masked contact details and drops response tokens; account changes invalidate pending results. UI shows normal_wallets recharge balances without adding bonus wallets.
API keys and account grants remain separately stored. Account tokens have no expiry or refresh flow; exhausted logout retries can leave the removed token valid remotely. Provider initialization discards grants from a different configured Platform issuer locally, without remote revocation, so switching environments starts signed out instead of aborting Desktop startup. API keys and device identity survive this cleanup. Profile and balance query failures preserve login state; authorization-attempt deadlines apply only before token issuance. The same grant authenticates Platform current and get_user_summary queries on its configured issuer origin. Host preserves Platform-masked contact details and drops response tokens; account changes invalidate pending results. UI shows normal_wallets recharge balances and positive bonus_wallets in separate rows, so promotional credit is never presented as recharge funds.
The account plugin supplies choice, waiting, failure and timeout dialogs through settings.models.sign-in. The model package owns credential readiness and the existing API-key editor; the settings shell coordinates explicit reopening so login and API-key onboarding do not mount competing dialogs.
@@ -30,7 +30,7 @@ Desktop profile 为 Host API 请求头提供原生平台信息。账号与更新
退出登录先删除本地授权,再使用捕获的 token 调用 Platform POST /auth-api/v0/users/logout。远程失败不会恢复登录态。首次请求失败后,提供者最多重试五次,使用可配置的指数退避,默认从一秒开始。重试任务只保留旧 token,不改变后续登录态,并在提供者关闭时结束,不持久化。本地凭证删除成功后才发布已退出状态。退出登录位于侧边栏账号菜单;账号设置负责资料、余额和登录。两者通过框架 hook 读取插件持有的同一条 Host 状态流。
API Key 与账号记录独立保存。账号 token 没有过期或刷新流程;退登重试耗尽后,已从本地删除的 token 在远端仍可能有效。提供者初始化时在本地丢弃与配置的 Platform 来源不同的授权,不调用远端撤销,使环境切换以未登录态启动,而不导致 Desktop 启动失败。API Key 和设备标识保留。资料与余额查询失败保留登录态;授权尝试的有效期仅适用于 token 签发前。同一授权 token 可认证已配置签发来源上的 Platform current 和 get_user_summary 查询。Host 原样保留 Platform 脱敏后的联系方式并丢弃响应 token;账号变化使未完成结果失效。UI 显示 normal_wallets 充值余额,不合并赠送钱包。
API Key 与账号记录独立保存。账号 token 没有过期或刷新流程;退登重试耗尽后,已从本地删除的 token 在远端仍可能有效。提供者初始化时在本地丢弃与配置的 Platform 来源不同的授权,不调用远端撤销,使环境切换以未登录态启动,而不导致 Desktop 启动失败。API Key 和设备标识保留。资料与余额查询失败保留登录态;授权尝试的有效期仅适用于 token 签发前。同一授权 token 可认证已配置签发来源上的 Platform current 和 get_user_summary 查询。Host 原样保留 Platform 脱敏后的联系方式并丢弃响应 token;账号变化使未完成结果失效。UI 分行显示 normal_wallets 充值余额与正额 bonus_wallets 赠金余额,避免将赠金额度显示为充值资金。
账号插件通过 settings.models.sign-in 提供选择、等待、失败和超时对话框。模型包保留凭证就绪检查和现有 API Key 编辑器;设置外壳协调显式重开,避免登录与 API Key 引导同时挂载冲突的对话框。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/credentials.md
credentials.md: 415878ed46f32d89c57ba80767952efb51845979
credentials.zh.md: ea3e8dbe920d21d439aa9dcb4635d7ced44cd934
credentials.md: 75d592bc15d4de8da7225253e2a310e74b9f15fd
credentials.zh.md: 23df660253af3dc035e78cac4856ddb872b2e574
+3 -1
View File
@@ -57,6 +57,8 @@ interface CredentialInfo {
PlatformSession is a Host-only snapshot from getPlatformSession: origin names the configured Platform issuer and token contains its stored account credential. Signed-out accounts return null; a mismatched issuer fails. Native consumers own document invalidation when credentials change. This snapshot is excluded from account-controller RPC, AccountView, and AccountDetails.
AccountDetails.balance projects recharge wallets in value and promotional wallets in bonusWallets, with independent currency and decimal balance strings. Failed queries contain no wallet arrays.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -277,7 +279,7 @@ abstract getState(): Promise<AccountView>
abstract getProfile(): Promise<AccountDetails['profile'] | null>
/**
* Query Platform recharge-wallet balances independently of profile data.
* Query Platform recharge and bonus wallet balances independently of profile data.
* @returns balance outcome, or null if signed out or the grant changed during the query.
*/
abstract getBalance(): Promise<AccountDetails['balance'] | null>
+3 -1
View File
@@ -57,6 +57,8 @@ interface CredentialInfo {
PlatformSession 是 getPlatformSession 返回的仅限 Host 快照:origin 指定所配置的 Platform 签发来源,token 包含其已存账号凭证。退登账号返回 null;签发来源不匹配时失败。原生使用方负责在凭证变化时使文档失效。账号控制器 RPC、AccountView 和 AccountDetails 均不包含此快照。
AccountDetails.balance 将充值钱包投影为 value、赠送钱包投影为 bonusWallets,分别保留币种和十进制余额字符串。查询失败不包含钱包数组。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -277,7 +279,7 @@ abstract getState(): Promise<AccountView>
abstract getProfile(): Promise<AccountDetails['profile'] | null>
/**
* Query Platform recharge-wallet balances independently of profile data.
* Query Platform recharge and bonus wallet balances independently of profile data.
* @returns balance outcome, or null if signed out or the grant changed during the query.
*/
abstract getBalance(): Promise<AccountDetails['balance'] | null>
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-account/README.md
README.md: cc21a5da7c75ece0155653eeb29a4dd7459d90bb
README.zh.md: 6376ea5eed7e97abb61e52b03a69131b54f99916
README.md: 77a64e22823809ef020119365d29e05c4c2b4a79
README.zh.md: 1ff9e8e2c804feeb553aef2ca82d9ea5f7e14201
@@ -44,6 +44,8 @@ The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storag
Account login uses a dismissible dialog before the model onboarding credential editor. The dialog uses a compact, right-aligned action row with the primary action last. The sidebar displays the profile name, falling back to the server-masked phone number or email when the name is absent; it stays blank while the profile is loading. Dialog copy and the sidebar account label are not text-selectable; the copy-link button remains available and restores its label two seconds after each successful copy. Waiting shows a copyable authorization link and a loading indicator; timeout and failure require an explicit retry. Closing a waiting dialog cancels its Host attempt. The sidebar can reopen the same API-key editor through the settings coordinator. Browser login preopens a tab on the click and navigates it only when that attempt receives its authorization URL; blocked popups retain the copy-link route. The original tab retains the authorization window handle and closes it on failure, timeout, or cancellation. Login feedback stays in the original tab; callbacks never navigate to a second Web UI.
The balance card shows recharge funds and positive bonus credit in separate rows. Empty or nonpositive bonus wallets hide the bonus row and its divider; currencies retain their own amounts.
<a id="model-experience"></a>
## Model Experience
@@ -44,6 +44,8 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
账号登录在模型引导的凭证编辑器之前显示可关闭的弹窗。弹窗底部采用紧凑的右对齐横排操作,主操作位于末尾。侧边栏优先显示昵称,缺少昵称时显示服务端返回的脱敏手机号或邮箱;资料加载期间保持留空。 弹窗文字和侧边栏账号状态文字不可选中,复制登录链接按钮始终可用,并在每次复制成功两秒后恢复原文案。等待状态提供可复制的授权链接和加载图标;超时和失败均须用户手动重试。关闭等待弹窗会取消对应 Host 登录流程。侧边栏通过设置协调器重新打开同一个 API Key 编辑器。浏览器登录在点击时预先打开标签页,只有该次流程收到授权地址后才导航;弹窗被拦截时保留复制链接入口。原标签页保留授权窗口引用,并在失败、超时或取消时关闭它。登录反馈留在原标签页,回调不会导航到第二个 Web UI。
余额卡片分行展示充值余额与正额赠金余额。赠送钱包为空或没有正余额时,隐藏赠金行及其分隔线;不同币种分别显示。
<a id="model-experience"></a>
## 模型体验
@@ -179,3 +179,17 @@
line-height: 22px;
font-weight: 500;
}
.amount {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
font-size: 18px;
line-height: 28px;
font-weight: 510;
}
.amount > span + span::before {
content: ' + ';
white-space: pre;
}
@@ -1,4 +1,5 @@
/** Account settings renders safe Host state and explicit login actions. */
import { Big } from 'big.js'
import { useEffect, useState } from 'react'
import { Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
@@ -58,6 +59,8 @@ export function AccountSection({ t, useAccount, start, cancel, refresh, platform
useEffect(() => { void refresh() }, [refresh])
const profile = details?.profile?.status === 'ready' ? details.profile.value : undefined
const wallets = details?.balance?.status === 'ready' ? details.balance.value : undefined
const bonusWallets = details?.balance?.status === 'ready'
? details.balance.bonusWallets.filter(wallet => new Big(wallet.balance).gt(0)) : []
const attempt = state?.attempt
const active = attempt !== null && attempt !== undefined
&& ['initializing', 'waiting-browser', 'exchanging', 'committing'].includes(attempt.phase)
@@ -112,12 +115,21 @@ export function AccountSection({ t, useAccount, start, cancel, refresh, platform
<div className={css.row}>
<span>{t('balance')}</span>
{signedIn && wallets !== undefined && wallets.length > 0
? <span className={css.links}>{wallets.map(wallet => <span key={wallet.currency}>
? <span className={css.amount}>{wallets.map(wallet => <span key={wallet.currency}>
{formatBalance(wallet.balance, wallet.currency === 'CNY' ? '¥' : '$')}
</span>)}</span>
: <span className={css.unavailable}>{t(!signedIn ? 'balanceSignedOut'
: details?.balance === undefined ? 'loading' : 'balanceUnavailable')}</span>}
</div>
{signedIn && bonusWallets.length > 0 && <>
<div className={css.divider} />
<div className={css.row}>
<span>{t('bonusBalance')}</span>
<span className={css.amount}>{bonusWallets.map(wallet => <span key={wallet.currency}>
{formatBalance(wallet.balance, wallet.currency === 'CNY' ? '¥' : '$')}
</span>)}</span>
</div>
</>}
<div className={css.divider} />
<div className={css.row}>
<span className={css.secondary}>{t('more')}</span>
@@ -17,7 +17,7 @@ export const en = {
settingsSignedOutDescription: 'Sign in to DeepSeek Harness to get your dedicated API Key',
signInDescription: 'Use your DeepSeek account to get started.',
profileUnavailable: 'Account details are not available yet.',
balance: 'Account balance', balanceUnavailable: 'View on Platform', balanceSignedOut: 'Sign in to view',
balance: 'Recharge balance', bonusBalance: 'Bonus balance', balanceUnavailable: 'View on Platform', balanceSignedOut: 'Sign in to view',
more: 'More', usage: 'View usage', topUp: 'Top up',
} as const
/** Account locale keys. */
@@ -41,6 +41,6 @@ export const zh: Record<AccountKey, string> = {
settingsSignedOutDescription: '登录 DeepSeek Harness 账号获取专属 API Key',
signInDescription: '登录 DeepSeek 账号以开始使用',
profileUnavailable: '账号资料暂不可用',
balance: '账户余额', balanceUnavailable: '前往开放平台查看', balanceSignedOut: '登录后查看',
balance: '充值余额', bonusBalance: '赠金余额', balanceUnavailable: '前往开放平台查看', balanceSignedOut: '登录后查看',
more: '更多', usage: '查询用量', topUp: '充值',
}
@@ -77,7 +77,7 @@ it.each([en, zh])('opens settings and signs out from the sidebar account menu',
it.each([en, zh])('renders Platform profile and recharge wallet balances', async (copy) => {
mount({ status: 'credential-stored', attempt: null }, copy, {
profile: { status: 'ready', value: { id: null, name: 'Harness Mock (TEST ONLY)', contact: '138****0000' } },
balance: { status: 'ready', value: [{ currency: 'CNY', balance: '1234.56000000' }, { currency: 'USD', balance: '0.00000100' }] },
balance: { status: 'ready', bonusWallets: [], value: [{ currency: 'CNY', balance: '1234.56000000' }, { currency: 'USD', balance: '0.00000100' }] },
})
expect(screen.getByText('Harness Mock (TEST ONLY)')).toBeTruthy()
expect(screen.getByText('138****0000')).toBeTruthy()
@@ -206,3 +206,24 @@ it('retries a changed avatar URL after an image fails', async () => {
view.rerender(<AccountAvatar url={null} />)
expect(view.container.querySelector('img')).toBeNull()
})
it.each([en, zh])('renders positive bonus wallets separately from recharge balances', async (copy) => {
mount({ status: 'credential-stored', attempt: null }, copy, {
balance: { status: 'ready', value: [{ currency: 'CNY', balance: '209.00' }, { currency: 'USD', balance: '20.07' }],
bonusWallets: [{ currency: 'CNY', balance: '5.00' }, { currency: 'USD', balance: '0.000001' }] },
})
expect(screen.getByText(copy.balance).parentElement!.textContent).toBe(`${copy.balance}¥209.00$20.07`)
expect(screen.getByText(copy.bonusBalance).parentElement!.textContent).toBe(`${copy.bonusBalance}¥5.00<$0.01`)
await expect(`${screen.getByRole('region').textContent}\n`)
.toMatchFileSnapshot(`./expected/bonus-${copy === en ? 'en' : 'zh'}.txt`)
})
it.each([[], [{ currency: 'CNY' as const, balance: '0.00' }, { currency: 'USD' as const, balance: '-1.00' }]].map(bonusWallets => ({ bonusWallets })))(
'hides bonus wallets without positive credit', ({ bonusWallets }) => {
mount({ status: 'credential-stored', attempt: null }, en, {
balance: { status: 'ready', value: [{ currency: 'CNY', balance: '0' }], bonusWallets },
})
expect(screen.queryByText(en.bonusBalance)).toBeNull()
expect(screen.getByText('¥0.00')).toBeTruthy()
},
)
@@ -1 +1 @@
Signed in to DeepSeekLoading…Account balanceLoading…MoreView usageTop up
Signed in to DeepSeekLoading…Recharge balanceLoading…MoreView usageTop up
@@ -1 +1 @@
已登录 DeepSeek加载中…账户余额加载中…更多查询用量充值
已登录 DeepSeek加载中…充值余额加载中…更多查询用量充值
@@ -0,0 +1 @@
Signed in to DeepSeekLoading…Recharge balance¥209.00$20.07Bonus balance¥5.00<$0.01MoreView usageTop up
@@ -0,0 +1 @@
已登录 DeepSeek加载中…充值余额¥209.00$20.07赠金余额¥5.00<$0.01更多查询用量充值
@@ -1 +1 @@
Harness Mock (TEST ONLY)138****0000Account balance¥1,234.56<$0.01MoreView usageTop up
Harness Mock (TEST ONLY)138****0000Recharge balance¥1,234.56<$0.01MoreView usageTop up
@@ -1 +1 @@
Harness Mock (TEST ONLY)138****0000账户余额¥1,234.56<$0.01更多查询用量充值
Harness Mock (TEST ONLY)138****0000充值余额¥1,234.56<$0.01更多查询用量充值
@@ -1 +1 @@
Ready User138****0000Account balanceLoading…MoreView usageTop up
Ready User138****0000Recharge balanceLoading…MoreView usageTop up
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/deepseek-account-platform/README.md
README.md: 6a4ff41dbe19e754ce3926eaf5b72b37c595412f
README.zh.md: c2ea4145498fd27e0c375cdb2fe157c53dd822df
README.md: db337c7f999c1ce8055e3a427813189e84d53194
README.zh.md: 510ee10be0d58dc8c4e6b4d78a8ce1f7a837e80a
@@ -28,7 +28,7 @@ Sign in through the system browser and keep the account credential in the existi
The profile projection maps `id_profile.picture` to `avatarUrl`, using null when no picture is configured.
getProfile / getBalance sends the stored grant in the x-dsh-auth-token header to GET /auth-api/v0/users/current and GET /api/v0/users/get_user_summary on platformOrigin. The grant issuer must match that origin. Host projects only the account UID, profile name, avatar URL, phone or email exactly as masked by Platform, and normal_wallets currency/balance strings; response tokens and other fields are discarded. Bonus wallets remain separate from recharge balances. Credential changes and disposal invalidate in-flight queries.
getProfile / getBalance sends the stored grant in the x-dsh-auth-token header to GET /auth-api/v0/users/current and GET /api/v0/users/get_user_summary on platformOrigin. The grant issuer must match that origin. Host projects only the account UID, profile name, avatar URL, phone or email exactly as masked by Platform, and normal_wallets / bonus_wallets currency/balance strings; response tokens and other fields are discarded. Bonus wallets remain separate from recharge balances. Credential changes and disposal invalidate in-flight queries.
Configure platformOrigin, allowLoopbackHttp, requestTimeoutMs, and attemptTimeoutMs in the plugin row. HTTP is accepted only for explicitly enabled loopback development. The provider registers /oauth/callback on the existing Host webServer before auth_init, validates state, exchanges an S256 PKCE code once, and commits a grant before redirecting to auth_exchange.biz_data.authorized_url. Browser destinations default to the configured platform origin and always require the fixed /dsh/authorize or /dsh/authorized path. The completion URL sets `client_type` to the initiating client (`web` or `desktop`) and preserves other Platform-supplied query parameters. Device identity is a separate random UUID record shared by processes using the same credential store; device_model reports OS and architecture.
@@ -28,7 +28,7 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导
账号资料投影将 `id_profile.picture` 映射为 `avatarUrl`,没有配置头像时返回 null。
getProfile / getBalance 将保存的授权 token 通过 x-dsh-auth-token 请求头,向 platformOrigin 上的 GET /auth-api/v0/users/current 和 GET /api/v0/users/get_user_summary 发起请求。授权签发来源必须与该来源一致。Host 只投影账号 UID、资料名称、头像 URL、由 Platform 脱敏的手机号或邮箱(原样保留),以及 normal_wallets 的币种和余额字符串,丢弃响应 token 与其他字段。赠送钱包不计入充值余额。凭证变化和销毁会使进行中的查询失效。
getProfile / getBalance 将保存的授权 token 通过 x-dsh-auth-token 请求头,向 platformOrigin 上的 GET /auth-api/v0/users/current 和 GET /api/v0/users/get_user_summary 发起请求。授权签发来源必须与该来源一致。Host 只投影账号 UID、资料名称、头像 URL、由 Platform 脱敏的手机号或邮箱(原样保留),以及 normal_wallets / bonus_wallets 的币种和余额字符串,丢弃响应 token 与其他字段。赠送钱包不计入充值余额。凭证变化和销毁会使进行中的查询失效。
在插件行配置 platformOrigin、allowLoopbackHttp、requestTimeoutMs 和 attemptTimeoutMs。HTTP 仅用于显式启用的本机开发。提供者先在现有 Host webServer 注册 /oauth/callback,再调用 auth_init;校验 state、使用 S256 PKCE 授权码兑换一次,并在跳转 auth_exchange.biz_data.authorized_url 前提交授权记录。浏览器地址默认要求匹配配置的平台来源,并始终要求固定的 /dsh/authorize 或 /dsh/authorized 路径。完成页地址将 `client_type` 设为发起登录的客户端类型(`web` 或 `desktop`),并保留平台返回的其他查询参数。设备标识是独立的随机 UUID 记录,由使用同一凭证存储的进程共享;device_model 报告操作系统和架构。
@@ -1,4 +1,4 @@
/** Platform Web profile and recharge-wallet queries projected for account UI consumers. */
/** Platform Web profile and wallet queries projected for account UI consumers. */
import { z } from 'zod'
import type { AccountDetails, AccountProfile, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types'
import { PlatformAuthError, requestAccount } from './protocol.ts'
@@ -8,9 +8,10 @@ const user = z.object({
email: z.string(), mobile: z.string().optional(), mobile_number: z.string().optional(),
id_profile: z.object({ name: z.string().nullable(), picture: z.string().nullish() }).nullish(),
})
const summary = z.object({ normal_wallets: z.array(z.object({
const wallet = z.object({
currency: z.enum(['CNY', 'USD']), balance: z.string().regex(/^-?\d+(?:\.\d+)?$/),
})) })
})
const summary = z.object({ normal_wallets: z.array(wallet), bonus_wallets: z.array(wallet) })
/** Project Platform user data without retaining credentials or unneeded fields.
* @param value - current or exchange user response.
@@ -35,13 +36,13 @@ const queries: { [K in keyof AccountDetails]: {
balance: { path: '/api/v0/users/get_user_summary', parse: (value) => {
const parsed = summary.safeParse(value)
if (!parsed.success) throw new PlatformAuthError('protocol')
return { status: 'ready', value: parsed.data.normal_wallets }
return { status: 'ready', value: parsed.data.normal_wallets, bonusWallets: parsed.data.bonus_wallets }
} },
}
/**
* Read one Platform account field without waiting for the other query.
* @param field - profile or recharge-wallet balance.
* @param field - profile or recharge and bonus wallet balances.
* @param origin - configured origin matching the stored grant issuer.
* @param token - stored authorization token; response tokens are discarded.
* @param signal - request and credential lifetime.
@@ -51,6 +51,7 @@ async function fixture(
let logoutHold = false
let logoutCount = 0
const logoutHeaders: Array<string | undefined> = []
let bonusWallets: unknown = [{ currency: 'CNY', balance: '10.00' }]
let invalidSummary = false
const detailsStarted = Promise.withResolvers<undefined>()
const detailRequests: Array<{ path: string; authorization: string | undefined }> = []
@@ -81,7 +82,7 @@ async function fixture(
id_profile: { name: 'Test Account', picture: null } }
: { normal_wallets: [{ currency: 'CNY', balance: invalidSummary ? 'not-a-decimal' : '123.45', token_estimation: '0' },
{ currency: 'USD', balance: '6.78', token_estimation: '0' }],
bonus_wallets: [{ currency: 'CNY', balance: '10.00' }] }
bonus_wallets: bonusWallets }
res.setHeader('content-type', 'application/json')
res.end(JSON.stringify({ code: 0, data: {
biz_code: (profileFailed && req.url === '/auth-api/v0/users/current')
@@ -157,6 +158,7 @@ async function fixture(
redirect: () => { redirect = true },
hold: () => { hold = true },
initResponse: (value: Record<string, unknown>) => { initOverride = value },
bonusWallets: (value: unknown) => { bonusWallets = value },
exchangeResponse: (value: Record<string, unknown>) => { exchangeOverride = value },
fail: (value: number) => { businessCode = value },
init: () => init, count: () => count, callback: (state = init.state) => `${init.redirect_uri}?code=test&state=${state}` }
@@ -293,13 +295,13 @@ async function storeAccount(f: Awaited<ReturnType<typeof fixture>>) {
}))
}
it('queries Platform Web endpoints with the stored grant and projects only masked profile and recharge balances', async () => {
it('queries Platform Web endpoints with the stored grant and projects only masked profile and separate recharge and bonus balances', async () => {
const f = await fixture()
expect(await readDetails(f.account)).toBeNull()
await storeAccount(f)
expect(await readDetails(f.account)).toEqual({
profile: { status: 'ready', value: { id: 'test-user', name: 'Test Account', avatarUrl: null, contact: '138****5678' } },
balance: { status: 'ready', value: [{ currency: 'CNY', balance: '123.45' }, { currency: 'USD', balance: '6.78' }] },
balance: { status: 'ready', bonusWallets: [{ currency: 'CNY', balance: '10.00' }], value: [{ currency: 'CNY', balance: '123.45' }, { currency: 'USD', balance: '6.78' }] },
})
expect(f.detailRequests).toEqual(expect.arrayContaining([
{ path: '/auth-api/v0/users/current', authorization: 'test-platform-grant' },
@@ -761,3 +763,19 @@ it('retains successful profile data on current failure only for the same credent
await f.account.signOut()
expect(await f.account.getProfile()).toBeNull()
})
it.each([[], [{ currency: 'CNY', balance: '0.00' }]].map(wallets => ({ wallets })))('preserves empty or zero bonus wallets', async ({ wallets }) => {
const f = await fixture()
await storeAccount(f)
f.bonusWallets(wallets)
expect(await f.account.getBalance()).toMatchObject({ status: 'ready', bonusWallets: wallets })
})
it.each([undefined, [{ currency: 'EUR', balance: '1' }], [{ currency: 'CNY', balance: 'invalid' }]].map(wallets => ({ wallets })))(
'rejects malformed bonus wallets without reporting zero credit', async ({ wallets }) => {
const f = await fixture()
await storeAccount(f)
f.bonusWallets(wallets)
expect(await f.account.getBalance()).toEqual({ status: 'failed' })
},
)
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/credentials/deepseek-account/README.md
README.md: 396463e0207671faf70e8fcd5457b2c8431622fd
README.zh.md: f7d8b93cfcbccd23b6fc6c6c72e72db624f58e78
README.md: 72b42ee996b809992e48ea4c124f0793d756526a
README.zh.md: b30813e24adc2afc9e8245b839432dd195093c63
@@ -38,6 +38,8 @@ The service defines account operations without maintaining a second credential i
The [credentials subsystem](../../../docs/subsystems/credentials.md) owns storage APIs; the [architecture](../../../docs/architecture.md) explains application composition.
A ready AccountDetails.balance keeps recharge wallets in value and bonus wallets in bonusWallets; both arrays preserve decimal strings and currency. Query failure supplies neither amount.
<a id="model-experience"></a>
## Model Experience
@@ -38,6 +38,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token
[凭证子系统](../../../docs/subsystems/credentials.zh.md)定义存储接口;[架构](../../../docs/architecture.zh.md)说明应用组合。
AccountDetails.balance 就绪时,value 保存充值钱包,bonusWallets 保存赠送钱包;两个数组均保留十进制金额字符串和币种。查询失败不提供金额。
<a id="model-experience"></a>
## 模型体验
@@ -34,7 +34,7 @@ export abstract class DeepSeekAccount extends Service {
*/
abstract getProfile(): Promise<AccountDetails['profile'] | null>
/**
* Query Platform recharge-wallet balances independently of profile data.
* Query Platform recharge and bonus wallet balances independently of profile data.
* @returns balance outcome, or null if signed out or the grant changed during the query.
*/
abstract getBalance(): Promise<AccountDetails['balance'] | null>
@@ -35,7 +35,7 @@ export interface AccountProfile {
/** Profile image URL supplied by Platform, absent when no picture is configured. */
readonly avatarUrl?: string | null
}
/** Platform recharge-wallet balance; decimal strings retain server precision. */
/** Platform recharge or bonus wallet balance; decimal strings retain server precision. */
export interface AccountWallet {
readonly currency: 'CNY' | 'USD'
readonly balance: string
@@ -43,5 +43,5 @@ export interface AccountWallet {
/** Independent query outcomes allow profile and balance failures to render separately. */
export interface AccountDetails {
readonly profile: { readonly status: 'ready'; readonly value: AccountProfile } | { readonly status: 'failed' }
readonly balance: { readonly status: 'ready'; readonly value: readonly AccountWallet[] } | { readonly status: 'failed' }
readonly balance: { readonly status: 'ready'; readonly value: readonly AccountWallet[]; readonly bonusWallets: readonly AccountWallet[] } | { readonly status: 'failed' }
}
@@ -877,7 +877,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'abstract getBalance(): Promise<AccountDetails[\'balance\'] | null>',
description: 'Query Platform recharge-wallet balances independently of profile data.',
description: 'Query Platform recharge and bonus wallet balances independently of profile data.',
parameters: [],
returns: 'balance outcome, or null if signed out or the grant changed during the query.',
},
@@ -4051,7 +4051,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
export const TYPE_API: readonly TypeApiEntry[] = [
{
name: 'AccountDetails',
declaration: 'export interface AccountDetails {\n readonly profile: {\n readonly status: \'ready\';\n readonly value: AccountProfile;\n } | {\n readonly status: \'failed\';\n };\n readonly balance: {\n readonly status: \'ready\';\n readonly value: readonly AccountWallet[];\n } | {\n readonly status: \'failed\';\n };\n}',
declaration: 'export interface AccountDetails {\n readonly profile: {\n readonly status: \'ready\';\n readonly value: AccountProfile;\n } | {\n readonly status: \'failed\';\n };\n readonly balance: {\n readonly status: \'ready\';\n readonly value: readonly AccountWallet[];\n readonly bonusWallets: readonly AccountWallet[];\n } | {\n readonly status: \'failed\';\n };\n}',
},
{
name: 'AccountLinks',