feat(account): show bonus notices after visible display
This commit is contained in:
1 parent
3970cc974f
commit
081526aa3e
71 files changed
+2212
-80
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: d9e83e7dc0409316d672a26b483a7e0675128453
|
||||
2026-09-14-deepseek-account-login.zh.md: b8a8c661aaeb378a069a18f4314cbc0585e12e51
|
||||
2026-09-14-deepseek-account-login.md: 2f775333e9e26744c34ced7246aafe719f2e908b
|
||||
2026-09-14-deepseek-account-login.zh.md: e79ccbb74241cf67fab29ea16621e22b70fe3663
|
||||
@@ -56,6 +56,8 @@ The embedded Platform document stays hidden during loading because native child
|
||||
|
||||
Separate accountRequestHeaders route account data and embedded Platform traffic independently of authorization and logout. Cookie overrides merge by name, retaining deployment authentication. Host passes the resolved headers over private process IPC; Electron injects them only at the configured origin and omits them from bootstrap.
|
||||
|
||||
Bonus notices use Platform-provided text and order identities. Reads occur on signed-in startup, successful sign-in, and explicit balance refresh. The settings dialog defers sidebar display until it closes; there is no periodic polling. The client acknowledges an order only after its card becomes visible, and persists the displayed order with pending acknowledgment before sending the request. Account- and origin-scoped local records prevent repeat display after restart; failed acknowledgments retry with capped exponential backoff. Platform acknowledgments suppress delivery on other devices, but concurrent displays before acknowledgment are possible because the API does not reserve delivery. Fetch success alone never records a display.
|
||||
|
||||
The Host binds private Platform sessions to the account provider lifetime. Removal or watch termination clears Electron’s session; replacement subscribes to the new provider, and disposed reads cannot publish old credentials.
|
||||
|
||||
The client distinguishes plugin disposal from terminal account-stream failure. RemoteStream aborts its signal after either outcome, so the plugin owns a separate disposal flag to preserve failure feedback while suppressing reports after unload.
|
||||
|
||||
@@ -56,6 +56,8 @@ exchange 的 user 数据在凭证提交后供首次资料读取使用,展示
|
||||
|
||||
独立的 accountRequestHeaders 将账号资料及内嵌 Platform 流量与授权、退登分开路由。Cookie 覆盖按名称合并,保留部署认证。Host 通过私有进程 IPC 传递合并后的请求头;Electron 仅在配置来源注入,并从 bootstrap 排除。
|
||||
|
||||
赠金通知使用 Platform 提供的文案和订单标识。已登录启动、登录成功和手动刷新余额时读取通知。设置对话框关闭后才展示侧栏通知,不进行定时轮询。客户端只在通知卡实际可见后确认订单,并在发送请求前持久化已展示订单及待确认状态。按账号和来源隔离的本地记录防止重启后重复展示;确认失败时使用带上限的指数退避重试。Platform 确认记录阻止其他设备继续获取通知,但接口不预留展示资格,因此确认前仍可能并发展示。仅获取成功不会记录为已展示。
|
||||
|
||||
Host 将私有 Platform 会话绑定到账号提供者的生命周期。提供者移除或订阅结束时清空 Electron 会话;替换后订阅新的提供者,已释放的读取不得发布旧凭据。
|
||||
|
||||
客户端区分插件卸载和账户状态流的终止错误。RemoteStream 在两种情况下都会中止其信号,因此插件独立记录卸载状态,保留错误反馈并抑制卸载后的报告。
|
||||
|
||||
@@ -17,6 +17,8 @@ class Account extends DeepSeekAccount {
|
||||
override async getState() { return state }
|
||||
override async getProfile() { return null }
|
||||
override async getBalance() { return null }
|
||||
override async getUnnotifiedBonuses() { return null }
|
||||
override async ackBonusNotified() { return false }
|
||||
override async startSignIn() { return state }
|
||||
override async cancelSignIn() { return state }
|
||||
override async signOut() { return state }
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
// Real-composition acceptance for the user-side bonus notice. The scaffold boots the
|
||||
// shipped Web Loader tree, so the real account provider issues the bonus HTTP requests
|
||||
// and the real client renders the card in a real Chromium. The loopback Platform double
|
||||
// is the observation point: the scenario asserts what the product sent and what the user
|
||||
// could read on screen.
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Browser, type Locator, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { openSettings, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/bonus-notice', import.meta.url))
|
||||
const OVERLAY_TEMPLATE = fileURLToPath(new URL('./fixtures/bonus-notice/cordis.patch.yml', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
|
||||
const TOKEN = 'dsh_bonus_notice_test'
|
||||
const ORDER_FIRST = '22222222-2222-4222-8222-222222222222'
|
||||
const ORDER_LATER = '33333333-3333-4333-8333-333333333333'
|
||||
const ORDER_EN = '44444444-4444-4444-8444-444444444444'
|
||||
|
||||
const CLAIM_DAYS = 30
|
||||
|
||||
/** Eligibility window the double reports; neither the card nor the golden renders a date. */
|
||||
function expiresAt(): string {
|
||||
return new Date(Date.now() + CLAIM_DAYS * 86_400_000).toISOString()
|
||||
}
|
||||
|
||||
interface Granted {
|
||||
readonly orderId: string
|
||||
readonly amount: string
|
||||
readonly zh_CN: string
|
||||
readonly en_US: string
|
||||
}
|
||||
|
||||
interface GetRecord {
|
||||
readonly locale: string | undefined
|
||||
readonly query: string
|
||||
}
|
||||
|
||||
interface AckRecord {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly locale: string | undefined
|
||||
readonly orderId: string | null
|
||||
readonly body: string
|
||||
}
|
||||
|
||||
/** Loopback Platform double: auth gate, profile, wallets, unnotified bonuses, and the acknowledgement. */
|
||||
async function mockPlatform() {
|
||||
const unnotified: Granted[] = []
|
||||
const gets: GetRecord[] = []
|
||||
/** Reads already answered; a request the client is still awaiting is not evidence of a delivered response. */
|
||||
const served: GetRecord[] = []
|
||||
const acks: AckRecord[] = []
|
||||
let bonusBalance = '5.00'
|
||||
/** Reply held back while the test controls when a read can answer. */
|
||||
let heldReply: (() => void) | undefined
|
||||
const grant = (orderId: string, amount: string): void => {
|
||||
unnotified.unshift({
|
||||
orderId, amount,
|
||||
zh_CN: `已赠送您 ${amount} 元 DSH 体验赠金。`,
|
||||
en_US: `You received a CNY ${amount} DSH trial credit.`,
|
||||
})
|
||||
}
|
||||
/** @param value - granted bonus. @param locale - request language. @returns its wire fields. */
|
||||
const wire = (value: Granted, locale: string | undefined): unknown => ({
|
||||
order_id: value.orderId, campaign: 'dsh_login_bonus', amount: value.amount, currency: 'CNY',
|
||||
granted_at: new Date().toISOString(), expires_at: expiresAt(),
|
||||
msg: locale === 'en_US' ? value.en_US : value.zh_CN,
|
||||
})
|
||||
const server: Server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
|
||||
const header = req.headers['x-client-locale']
|
||||
const locale = typeof header === 'string' ? header : undefined
|
||||
const reply = (payload: unknown): void => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(payload))
|
||||
}
|
||||
if (req.headers['x-dsh-auth-token'] !== TOKEN) { res.writeHead(401).end(); return }
|
||||
if (url.pathname === '/auth-api/v0/users/current') {
|
||||
// `email` is required by the provider's profile schema even when the account has none.
|
||||
reply({ code: 0, data: { biz_code: 0, biz_data: {
|
||||
id: 'bonus-user', email: '', id_profile: { name: 'Bonus User', picture: null },
|
||||
} } })
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/v0/users/get_user_summary') {
|
||||
reply({ code: 0, data: { biz_code: 0, biz_data: {
|
||||
normal_wallets: [{ currency: 'CNY', balance: '12.34' }],
|
||||
bonus_wallets: [{ currency: 'CNY', balance: bonusBalance }],
|
||||
} } })
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/v0/users/get_unnotified_bonuses') {
|
||||
const record = { locale, query: url.search }
|
||||
gets.push(record)
|
||||
const payload = { code: 0, data: { biz_code: 0, biz_data: unnotified.map(value => wire(value, locale)) } }
|
||||
const answer = (): void => { served.push(record); reply(payload) }
|
||||
if (heldReply === undefined) { answer(); return }
|
||||
heldReply = answer
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/api/v0/users/ack_bonus_notified' && req.method === 'POST') {
|
||||
let body = ''
|
||||
req.setEncoding('utf8')
|
||||
req.on('data', (chunk: string) => { body += chunk })
|
||||
req.on('end', () => {
|
||||
const orderId = url.searchParams.get('order_id')
|
||||
acks.push({ method: req.method ?? '', path: url.pathname, locale, orderId, body })
|
||||
if (orderId === null) { reply({ code: 1, msg: 'BONUS_ORDER_NOT_FOUND', data: null }); return }
|
||||
const index = unnotified.findIndex(value => value.orderId === orderId)
|
||||
if (index >= 0) unnotified.splice(index, 1)
|
||||
reply({ code: 0, msg: '', data: { biz_code: 0, biz_msg: '', biz_data: null } })
|
||||
})
|
||||
return
|
||||
}
|
||||
if (url.pathname === '/auth-api/v0/users/logout') { reply({ code: 0, data: { biz_code: 0, biz_data: null } }); return }
|
||||
res.writeHead(404).end()
|
||||
})
|
||||
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('bonus notice: missing mock listener')
|
||||
return {
|
||||
origin: `http://127.0.0.1:${String(address.port)}`,
|
||||
grant, gets, served, acks,
|
||||
/** @param value - bonus balance the settings page reads next. */
|
||||
setBonusBalance: (value: string): void => { bonusBalance = value },
|
||||
/** Hold the next unnotified-bonus read so the test can observe the in-flight state. */
|
||||
holdNextGet: (): void => { heldReply = () => undefined },
|
||||
releaseGet: (): void => { const held = heldReply; heldReply = undefined; held?.() },
|
||||
close: () => new Promise<void>((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() }),
|
||||
}
|
||||
}
|
||||
|
||||
/** @param page - page under test. @returns every rendered bonus notice card. */
|
||||
function noticeCards(page: Page): Locator {
|
||||
return page.locator('aside').filter({ hasText: '赠金已到账' })
|
||||
}
|
||||
|
||||
/** @param card - a rendered notice card. @returns its non-empty visible lines joined for the golden. */
|
||||
async function cardText(card: Locator): Promise<string> {
|
||||
return (await card.innerText()).split('\n').map(line => line.trim()).filter(line => line !== '').join(' / ')
|
||||
}
|
||||
|
||||
/** Wait for the card carrying this copy, then return the text the user could read from it. */
|
||||
async function shownNotice(page: Page, copy: string): Promise<string> {
|
||||
const card = noticeCards(page).filter({ hasText: copy })
|
||||
await card.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
return cardText(card)
|
||||
}
|
||||
|
||||
/** Wait for the double to answer this many acknowledgements, then return the order ids in arrival order. */
|
||||
async function acked(platform: { acks: AckRecord[] }, count: number): Promise<string> {
|
||||
await expect.poll(() => platform.acks.length, { timeout: 30_000 }).toBe(count)
|
||||
return platform.acks.map(item => String(item.orderId)).join(',')
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: bonus notice', () => {
|
||||
let root: string | undefined
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let platform: Awaited<ReturnType<typeof mockPlatform>>
|
||||
const tripwires: ReturnType<typeof watchConsole>[] = []
|
||||
|
||||
/** @param locale - browser language driving the requested notice locale. @returns a desktop-renderer page. */
|
||||
const openDesktopPage = async (locale: string): Promise<Page> => {
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale })
|
||||
const opened = await context.newPage()
|
||||
tripwires.push(watchConsole(opened))
|
||||
await opened.addInitScript(() => {
|
||||
Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } })
|
||||
})
|
||||
return opened
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
platform = await mockPlatform()
|
||||
platform.grant(ORDER_FIRST, '5.00')
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-bonus-notice-'))
|
||||
const home = join(root, 'home')
|
||||
await mkdir(home, { recursive: true })
|
||||
// A stored grant bound to the double's origin starts the account credential-stored
|
||||
// without driving the browser sign-in flow.
|
||||
await writeFile(join(home, '.credentials.yaml'),
|
||||
`version: 1\nrefs: {}\nrecords:\n deepseek-account-platform/default:\n kind: grant\n payload:\n version: 1\n token: ${TOKEN}\n issuer: ${platform.origin}\n`,
|
||||
{ mode: 0o600 })
|
||||
const overlay = join(root, 'bonus-notice.overlay.yml')
|
||||
await writeFile(overlay, (await readFile(OVERLAY_TEMPLATE, 'utf8')).replaceAll('{{origin}}', platform.origin))
|
||||
scaffold = await launchWebScaffold({ harnessHome: home, extraOverlayPath: overlay })
|
||||
browser = await chromium.launch()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
await platform?.close()
|
||||
if (root !== undefined) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('acknowledges only a displayed notice, refreshes from settings, and localizes the request', async () => {
|
||||
const zhFirst = '已赠送您 5.00 元 DSH 体验赠金。'
|
||||
const zhLater = '已赠送您 8.00 元 DSH 体验赠金。'
|
||||
const enBonus = 'You received a CNY 9.00 DSH trial credit.'
|
||||
const observations: string[] = []
|
||||
const page = await openDesktopPage('zh-CN')
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-bonus-notice'))
|
||||
|
||||
// A read the renderer never received cannot become a displayed notice: while the
|
||||
// response is held there is no card and no acknowledgement.
|
||||
platform.holdNextGet()
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await expect.poll(() => platform.gets.length, { timeout: 30_000 }).toBe(1)
|
||||
observations.push(`startup.held cards=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`)
|
||||
expect(await noticeCards(page).count()).toBe(0)
|
||||
expect(platform.acks).toEqual([])
|
||||
platform.releaseGet()
|
||||
|
||||
// Startup signs in and reads the notice; the card shows it and only then is it acknowledged.
|
||||
observations.push(`startup.notice=${await shownNotice(page, zhFirst)}`)
|
||||
observations.push(`startup.ack orders=${await acked(platform, 1)}`)
|
||||
const get = platform.gets[0]!
|
||||
const ack = platform.acks[0]!
|
||||
observations.push(`startup.get locale=${String(get.locale)} query=${get.query}`)
|
||||
observations.push(`startup.ack method=${ack.method} path=${ack.path} locale=${String(ack.locale)} order_id=${String(ack.orderId)} body=${JSON.stringify(ack.body)}`)
|
||||
expect(get.locale).toBe('zh_CN')
|
||||
expect(get.query).toBe('')
|
||||
expect(ack).toMatchObject({
|
||||
method: 'POST', path: '/api/v0/users/ack_bonus_notified', locale: 'zh_CN', orderId: ORDER_FIRST, body: '',
|
||||
})
|
||||
|
||||
// Closing the card counts as reading it and never acknowledges it twice.
|
||||
await noticeCards(page).getByRole('button', { name: '关闭', exact: true }).click()
|
||||
expect(await noticeCards(page).count()).toBe(0)
|
||||
expect(platform.acks).toHaveLength(1)
|
||||
|
||||
// A new grant and balance arrive while settings is open. Refresh fetches both, but
|
||||
// the card is gated on settings being closed, so the served response is not displayed
|
||||
// and must not be acknowledged yet.
|
||||
await openSettings(page, 'zh')
|
||||
const settings = page.getByRole('dialog', { name: '设置', exact: true })
|
||||
const refresh = settings.getByRole('button', { name: '刷新余额', exact: true })
|
||||
await refresh.waitFor()
|
||||
platform.grant(ORDER_LATER, '8.00')
|
||||
platform.setBonusBalance('13.00')
|
||||
const getsBefore = platform.gets.length
|
||||
// Hold the refresh's notice read so the test observes the button's in-flight state
|
||||
// instead of racing a fast local response.
|
||||
platform.holdNextGet()
|
||||
await refresh.click()
|
||||
await expect.poll(() => platform.gets.length, { timeout: 30_000 }).toBe(getsBefore + 1)
|
||||
expect(platform.gets.length).toBe(getsBefore + 1)
|
||||
await expect.poll(() => refresh.isDisabled(), { timeout: 30_000 }).toBe(true)
|
||||
// The read is in flight: no card, no acknowledgement, and the button has not re-enabled.
|
||||
observations.push(`refresh.held cards=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`)
|
||||
expect(await noticeCards(page).count()).toBe(0)
|
||||
expect(platform.acks).toHaveLength(1)
|
||||
platform.releaseGet()
|
||||
// The refresh completed only once the button re-enabled, which follows both the
|
||||
// balance read and the notice read settling.
|
||||
await expect.poll(() => refresh.isDisabled(), { timeout: 30_000 }).toBe(false)
|
||||
await expect.poll(async () => (await settings.textContent()) ?? '', { timeout: 30_000 }).toContain('¥13.00')
|
||||
|
||||
const refreshGet = platform.served.at(-1)!
|
||||
const panel = (await settings.textContent()) ?? ''
|
||||
const usage = await settings.getByRole('link', { name: '查询用量', exact: true }).getAttribute('href')
|
||||
observations.push(`refresh.get locale=${String(refreshGet.locale)} query=${refreshGet.query} served=true`)
|
||||
observations.push(`refresh.balance bonus-row=${String(panel.includes('赠金余额'))} amount=${String(panel.includes('¥13.00'))} dated=${String(/\d{4}-\d{2}-\d{2}/.test(panel))}`)
|
||||
observations.push(`refresh.cards-while-settings-open=${String(await noticeCards(page).count())} acks=${String(platform.acks.length)}`)
|
||||
observations.push(`refresh.usage-link=${String(usage).replace(platform.origin, '{{origin}}')}`)
|
||||
expect(refreshGet.locale).toBe('zh_CN')
|
||||
expect(refreshGet.query).toBe('')
|
||||
expect(panel).toContain('赠金余额')
|
||||
expect(panel).toContain('¥13.00')
|
||||
expect(panel).not.toMatch(/\d{4}-\d{2}-\d{2}/)
|
||||
expect(usage).toBe(`${platform.origin}/usage`)
|
||||
expect(await noticeCards(page).count()).toBe(0)
|
||||
expect(platform.acks).toHaveLength(1)
|
||||
|
||||
// Leaving settings releases the already-fetched notice; it is displayed, and only now acknowledged.
|
||||
await page.keyboard.press('Escape')
|
||||
observations.push(`refresh.released notice=${await shownNotice(page, zhLater)}`)
|
||||
observations.push(`refresh.released ack orders=${await acked(platform, 2)}`)
|
||||
await page.close()
|
||||
|
||||
// Another device in another language gets the copy the server localized for it.
|
||||
platform.grant(ORDER_EN, '9.00')
|
||||
const enPage = await openDesktopPage('en-US')
|
||||
await enPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const enCard = enPage.locator('aside').filter({ hasText: 'Bonus credited' }).filter({ hasText: enBonus })
|
||||
await enCard.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
observations.push(`en.notice=${await cardText(enCard)}`)
|
||||
const enGet = platform.gets.find(entry => entry.locale === 'en_US')!
|
||||
observations.push(`en.get locale=${String(enGet.locale)} query=${enGet.query}`)
|
||||
expect(enGet.locale).toBe('en_US')
|
||||
expect(enGet.query).toBe('')
|
||||
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'notice-flow.expected.md'), observations.join('\n'), MODE)
|
||||
for (const tripwire of tripwires) {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['notice-flow.expected.md'])
|
||||
}, 180_000)
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
startup.held cards=0 acks=0
|
||||
startup.notice=赠金已到账 / 已赠送您 5.00 元 DSH 体验赠金。
|
||||
startup.ack orders=22222222-2222-4222-8222-222222222222
|
||||
startup.get locale=zh_CN query=
|
||||
startup.ack method=POST path=/api/v0/users/ack_bonus_notified locale=zh_CN order_id=22222222-2222-4222-8222-222222222222 body=""
|
||||
refresh.held cards=0 acks=1
|
||||
refresh.get locale=zh_CN query= served=true
|
||||
refresh.balance bonus-row=true amount=true dated=false
|
||||
refresh.cards-while-settings-open=0 acks=1
|
||||
refresh.usage-link={{origin}}/usage
|
||||
refresh.released notice=赠金已到账 / 已赠送您 8.00 元 DSH 体验赠金。
|
||||
refresh.released ack orders=22222222-2222-4222-8222-222222222222,33333333-3333-4333-8333-333333333333
|
||||
en.notice=Bonus credited / You received a CNY 9.00 DSH trial credit.
|
||||
en.get locale=en_US query=
|
||||
@@ -0,0 +1,8 @@
|
||||
# The bonus notice scenario points the shipped account provider at a loopback
|
||||
# Platform double. The scaffold substitutes {{origin}} after the listener binds,
|
||||
# so no port is ever committed here. No notice timing is overridden: every read
|
||||
# comes from the product's own startup and settings-refresh paths.
|
||||
- id: deepseek-account
|
||||
config:
|
||||
platformOrigin: "{{origin}}"
|
||||
allowLoopbackHttp: true
|
||||
@@ -81,6 +81,7 @@
|
||||
"tests/declared-reasoning.e2e.ts",
|
||||
"tests/onboarding-deepseek-config.e2e.ts",
|
||||
"tests/onboarding-native.e2e.ts",
|
||||
"tests/bonus-notice.e2e.ts",
|
||||
"tests/onboarding-usable-provider.e2e.ts",
|
||||
"tests/remote-welcome.e2e.ts",
|
||||
"tests/public-mount.e2e.ts",
|
||||
|
||||
@@ -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/config-catalog.md
|
||||
config-catalog.md: 6f2ddeaf9e18c7f25109e7188c442b1695904439
|
||||
config-catalog.zh.md: d76c88fb806998abee414347822a3d11c3b2b7de
|
||||
config-catalog.md: 3e37d60ba829b268169a238b4a17046675163311
|
||||
config-catalog.zh.md: 046f88a2eb01d87360c5410de2ce836ee98e1c4f
|
||||
@@ -504,12 +504,16 @@ Source: [`packages/client/ui-plugin-manager/src/index.ts:15`](../packages/client
|
||||
## `@deepseek-ai/dsh-client-ui-settings-account`
|
||||
|
||||
```ts config-catalog
|
||||
/** Questionnaire destination and its supported source option. */
|
||||
/** Questionnaire destination and bonus notice timings shared by Host and Client. */
|
||||
export interface Config {
|
||||
/** HTTPS questionnaire URL; override for a test form. */
|
||||
contactFormUrl: string
|
||||
/** Questionnaire source option; empty until Harness is supported by the form. */
|
||||
contactSource: string
|
||||
/** First delay before retrying a failed bonus acknowledgement. */
|
||||
bonusAckRetryDelayMs: number
|
||||
/** Ceiling for the acknowledgement retry backoff. */
|
||||
bonusAckRetryMaxDelayMs: number
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -506,12 +506,16 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-client-ui-settings-account`
|
||||
|
||||
```ts config-catalog
|
||||
/** Questionnaire destination and its supported source option. */
|
||||
/** Questionnaire destination and bonus notice timings shared by Host and Client. */
|
||||
export interface Config {
|
||||
/** HTTPS questionnaire URL; override for a test form. */
|
||||
contactFormUrl: string
|
||||
/** Questionnaire source option; empty until Harness is supported by the form. */
|
||||
contactSource: string
|
||||
/** First delay before retrying a failed bonus acknowledgement. */
|
||||
bonusAckRetryDelayMs: number
|
||||
/** Ceiling for the acknowledgement retry backoff. */
|
||||
bonusAckRetryMaxDelayMs: number
|
||||
}
|
||||
```
|
||||
|
||||
@@ -724,7 +728,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/credentials/deepseek-account-platform/src/index.ts:23`](../packages/credentials/deepseek-account-platform/src/index.ts)
|
||||
来源: [`packages/credentials/deepseek-account-platform/src/index.ts:26`](../packages/credentials/deepseek-account-platform/src/index.ts)
|
||||
|
||||
<a id="deepseek-aidsh-experimental-agent-team"></a>
|
||||
|
||||
|
||||
@@ -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: 9aaf68a94b502e152f4a1ed5a5b8fc746ec84f76
|
||||
credentials.zh.md: 2941e2fed8787990c5e674b5ba1cf8cfe939c483
|
||||
credentials.md: 4201ac739a8f9542a826436b454b84c3cf5889fd
|
||||
credentials.zh.md: 20f1f9c74bfa9bd87b4d4f159a1673966a394ede
|
||||
@@ -59,6 +59,8 @@ PlatformSession is a Host-only snapshot from getPlatformSession: origin names th
|
||||
|
||||
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.
|
||||
|
||||
Bonus notification queries return an AccountBonusBatch with the current Platform account id and eligible orders in server order. AccountBonusNotification retains the server message and expiry without projecting credentials. Acknowledgment carries the expected account id and order id; the Host refuses it after an account change. Both notification operations use the initiating UI language through x-client-locale, without a language query parameter.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -286,6 +288,22 @@ abstract getProfile(client: AccountClientMetadata): Promise<AccountDetails['prof
|
||||
*/
|
||||
abstract getBalance(client: AccountClientMetadata): Promise<AccountDetails['balance'] | null>
|
||||
|
||||
/**
|
||||
* Query the granted bonuses Platform has not yet recorded as displayed.
|
||||
* @param client - identity of the requesting UI for this call; its language selects the server-authored message.
|
||||
* @returns bonuses with their account, or null if signed out or the grant changed during the query.
|
||||
*/
|
||||
abstract getUnnotifiedBonuses(client: AccountClientMetadata): Promise<AccountBonusBatch | null>
|
||||
|
||||
/**
|
||||
* Record one displayed bonus as notified for the account it belongs to.
|
||||
* @param accountId - account the notification was read for; a different current account is never acknowledged.
|
||||
* @param orderId - granted bonus order the user saw.
|
||||
* @param client - identity of the requesting UI for this call.
|
||||
* @returns true once Platform records the acknowledgement; false if signed out or the account changed.
|
||||
*/
|
||||
abstract ackBonusNotified(accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Join an active attempt or start browser authorization.
|
||||
* @param client - identity of the requesting UI; a new attempt captures it, and joining retains the original attempt's identity.
|
||||
|
||||
@@ -59,6 +59,8 @@ PlatformSession 是 getPlatformSession 返回的仅限 Host 快照:origin 指
|
||||
|
||||
AccountDetails.balance 将充值钱包投影为 value、赠送钱包投影为 bonusWallets,分别保留币种和十进制余额字符串。查询失败不包含钱包数组。
|
||||
|
||||
赠金通知查询返回 AccountBonusBatch,包含当前 Platform 账号 id 和按服务端顺序排列的可通知订单。AccountBonusNotification 保留服务端消息与到期时间,不投影凭证。确认请求携带预期账号 id 和订单 id;账号变化后 Host 拒绝该请求。两项通知操作都通过 x-client-locale 传递发起界面的语言,不使用语言查询参数。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -286,6 +288,22 @@ abstract getProfile(client: AccountClientMetadata): Promise<AccountDetails['prof
|
||||
*/
|
||||
abstract getBalance(client: AccountClientMetadata): Promise<AccountDetails['balance'] | null>
|
||||
|
||||
/**
|
||||
* Query the granted bonuses Platform has not yet recorded as displayed.
|
||||
* @param client - identity of the requesting UI for this call; its language selects the server-authored message.
|
||||
* @returns bonuses with their account, or null if signed out or the grant changed during the query.
|
||||
*/
|
||||
abstract getUnnotifiedBonuses(client: AccountClientMetadata): Promise<AccountBonusBatch | null>
|
||||
|
||||
/**
|
||||
* Record one displayed bonus as notified for the account it belongs to.
|
||||
* @param accountId - account the notification was read for; a different current account is never acknowledged.
|
||||
* @param orderId - granted bonus order the user saw.
|
||||
* @param client - identity of the requesting UI for this call.
|
||||
* @returns true once Platform records the acknowledgement; false if signed out or the account changed.
|
||||
*/
|
||||
abstract ackBonusNotified(accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Join an active attempt or start browser authorization.
|
||||
* @param client - identity of the requesting UI; a new attempt captures it, and joining retains the original attempt's identity.
|
||||
|
||||
@@ -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/api/account-controller/README.md
|
||||
README.md: 61a3b2196e7bf7ebb1e88aeef1afc458542105fa
|
||||
README.zh.md: 1b288f318ef368155a60d3b037508659b5e1a78b
|
||||
README.md: 1cf16d4590c7c769716e9bbb317b3c00077e64df
|
||||
README.zh.md: a8dbf4811a651fce79739b731a4bf1bc48285dc3
|
||||
@@ -20,9 +20,9 @@ Account screens use authenticated Remote commands and a snapshot stream. The con
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The account namespace exposes getState, getProfile / getBalance, startSignIn, cancelSignIn, signOut, and watch. watch emits an initial complete state and subsequent complete states; disconnecting stops observation, not the login attempt. Cancellation names the attempt ID so a stale screen cannot cancel a newer login.
|
||||
The account namespace exposes getState, getProfile / getBalance, getUnnotifiedBonuses, ackBonusNotified, startSignIn, cancelSignIn, signOut, and watch. watch emits an initial complete state and subsequent complete states; disconnecting stops observation, not the login attempt. Cancellation names the attempt ID so a stale screen cannot cancel a newer login. getUnnotifiedBonuses returns null and ackBonusNotified returns false while the account is absent or has changed; failures the caller should retry arrive as thrown Remote errors.
|
||||
|
||||
Profile, balance, sign-in, and sign-out commands take the calling UI's `AccountClientMetadata`: client version, active language, and UTC offset in seconds. Cancellation retains the original attempt's metadata; state reads and subscriptions require none.
|
||||
Every operation that reaches Platform takes the calling UI's `AccountClientMetadata` — client version, active language, and UTC offset in seconds — so the Host reports the requesting UI rather than the last caller it saw. Cancellation and watch remain identity-free because they never reach Platform.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
@@ -20,9 +20,9 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
account 命名空间提供 getState、getProfile / getBalance、startSignIn、cancelSignIn、signOut 和 watch。watch 先发送完整初始状态,随后发送完整状态变化;断开连接只停止观察,不取消登录。取消操作必须指定尝试 ID,防止旧页面取消新登录。
|
||||
account 命名空间提供 getState、getProfile / getBalance、getUnnotifiedBonuses、ackBonusNotified、startSignIn、cancelSignIn、signOut 和 watch。watch 先发送完整初始状态,随后发送完整状态变化;断开连接只停止观察,不取消登录。取消操作必须指定尝试 ID,防止旧页面取消新登录。账号缺失或已切换时 getUnnotifiedBonuses 返回 null、ackBonusNotified 返回 false;需要调用方重试的失败以抛出的 Remote 错误返回。
|
||||
|
||||
资料、余额、登录和退出命令接收调用界面的 `AccountClientMetadata`:客户端版本、当前语言和以秒为单位的 UTC 偏移。取消操作沿用原登录尝试的元数据;状态读取和订阅无需该参数。
|
||||
到达 Platform 的每个操作都接收调用界面的 `AccountClientMetadata`——客户端版本、当前语言和以秒为单位的 UTC 偏移——因此 Host 报告的是发起请求的界面,而不是它上一次见到的调用方。取消和 watch 不接该参数,因为它们不会到达 Platform。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {} from '@deepseek-ai/dsh-deepseek-account'
|
||||
import type { AccountClientMetadata, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { AccountBonusBatch, AccountBonusOrderId, AccountClientMetadata, AccountDetails, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { AccountView, SignInAttemptId } from './types.ts'
|
||||
|
||||
/** Account commands and reconnect-safe state stream. */
|
||||
@@ -34,6 +34,26 @@ export class AccountController extends TypertRemoteService {
|
||||
getBalance(client: AccountClientMetadata): Promise<AccountDetails['balance'] | null> {
|
||||
return this.ctx.deepseekAccount.getBalance(client)
|
||||
}
|
||||
/**
|
||||
* Query the granted bonuses Platform has not yet recorded as displayed.
|
||||
* @param client - identity of the requesting UI; its language selects the server-authored message.
|
||||
* @returns bonuses with their account, or null when the account grant is absent or changed.
|
||||
*/
|
||||
@Remote
|
||||
getUnnotifiedBonuses(client: AccountClientMetadata): Promise<AccountBonusBatch | null> {
|
||||
return this.ctx.deepseekAccount.getUnnotifiedBonuses(client)
|
||||
}
|
||||
/**
|
||||
* Record one displayed bonus as notified for the account it belongs to.
|
||||
* @param accountId - account the notification was read for.
|
||||
* @param orderId - granted bonus order the user saw.
|
||||
* @param client - identity of the requesting UI; the Host derives Platform request headers from it.
|
||||
* @returns true once Platform records the acknowledgement; false when the account is absent or changed.
|
||||
*/
|
||||
@Remote
|
||||
ackBonusNotified(accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata): Promise<boolean> {
|
||||
return this.ctx.deepseekAccount.ackBonusNotified(accountId, orderId, client)
|
||||
}
|
||||
/**
|
||||
* Begin browser sign-in.
|
||||
* @param client - identity of the requesting UI, captured by a new attempt.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
/** Account Remote values contain no credential payloads. */
|
||||
export type { AccountClientMetadata, AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
export type { AccountBonusBatch, AccountBonusNotification, AccountBonusOrderId, AccountClientMetadata, AccountDetails, AccountProfile, AccountUserId, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Account Remote delegation preserves provider results, failures, and stream lifetime. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account'
|
||||
import type { AccountClientMetadata } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { AccountBonusBatch, AccountBonusOrderId, AccountClientMetadata, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { AccountView, SignInAttemptId } from '../src/types.ts'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import AccountController from '../src/index.ts'
|
||||
@@ -18,6 +18,8 @@ function fixture() {
|
||||
getState: vi.fn<DeepSeekAccount['getState']>().mockResolvedValue(state),
|
||||
getProfile: vi.fn<DeepSeekAccount['getProfile']>().mockResolvedValue({ status: 'failed' }),
|
||||
getBalance: vi.fn<DeepSeekAccount['getBalance']>().mockResolvedValue(null),
|
||||
getUnnotifiedBonuses: vi.fn<DeepSeekAccount['getUnnotifiedBonuses']>().mockResolvedValue(null),
|
||||
ackBonusNotified: vi.fn<DeepSeekAccount['ackBonusNotified']>().mockResolvedValue(false),
|
||||
startSignIn: vi.fn<DeepSeekAccount['startSignIn']>().mockResolvedValue(state),
|
||||
cancelSignIn: vi.fn<DeepSeekAccount['cancelSignIn']>().mockResolvedValue(state),
|
||||
signOut: vi.fn<DeepSeekAccount['signOut']>().mockResolvedValue(state),
|
||||
@@ -45,6 +47,23 @@ it('delegates account operations without coupling profile and balance queries',
|
||||
await expect(controller.signOut(client)).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('delegates bonus reads and acknowledgements without altering their values', async () => {
|
||||
const { provider, controller } = fixture()
|
||||
const accountId = 'account' as AccountUserId
|
||||
const orderId = 'order' as AccountBonusOrderId
|
||||
const batch: AccountBonusBatch = { accountId, bonuses: [{ orderId, campaign: 'dsh_login_bonus', amount: '5.00',
|
||||
currency: 'CNY', grantedAt: '2026-09-21T12:00:00Z', expiresAt: '2026-10-21T12:00:00Z', message: '已赠送您 5.00 元 DSH 体验赠金。' }] }
|
||||
provider.getUnnotifiedBonuses.mockResolvedValueOnce(batch)
|
||||
expect(await controller.getUnnotifiedBonuses(client)).toBe(batch)
|
||||
expect(provider.getUnnotifiedBonuses).toHaveBeenCalledExactlyOnceWith(client)
|
||||
provider.ackBonusNotified.mockResolvedValueOnce(true)
|
||||
expect(await controller.ackBonusNotified(accountId, orderId, client)).toBe(true)
|
||||
expect(provider.ackBonusNotified).toHaveBeenCalledExactlyOnceWith(accountId, orderId, client)
|
||||
const failure = new Error('unavailable')
|
||||
provider.getUnnotifiedBonuses.mockRejectedValueOnce(failure)
|
||||
await expect(controller.getUnnotifiedBonuses(client)).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('passes the subscriber lifetime to the provider and returns its state stream', async () => {
|
||||
const { provider, controller } = fixture()
|
||||
const lifetime = new AbortController()
|
||||
|
||||
@@ -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: db9e3c9531c3b15d0f907c70e6a52d5250afd228
|
||||
README.zh.md: e8cabd188e0a22f73b87903a7972a3e04da68796
|
||||
README.md: 7352c4521d278687ba96c0acd25cad3b21991494
|
||||
README.zh.md: 7a9cc629e25d2200c88d5ffb884e7d472f658bbb
|
||||
@@ -38,6 +38,10 @@ The sidebar account menu uses the shared Menu surface, backdrop blur, spacing, a
|
||||
|
||||
The account card’s More account information link opens `https://platform.deepseek.com` in the system browser.
|
||||
|
||||
A granted bonus appears as a server-authored notice above the sidebar account launcher without taking focus. The client reads the unnotified bonus when an account becomes active and when the settings Refresh balance action runs; it never polls. The server orders the candidates and owns the copy, so the client displays the first one and renders its message verbatim.
|
||||
|
||||
The notice is shown only while the settings panel is closed, the document is visible, and the card has passed one presented frame. A pending record is written before the server acknowledgement is requested, so an interruption between the two retries instead of showing the award twice. Closing the card also counts as seen. Records are scoped to the Platform origin and account in browser storage by order id, survive restarts and sign-ins, and leave the previous account’s notice unacknowledged if the account changed mid-request. Acknowledgement failures retry with a capped backoff and never surface as account UI errors. If browser storage is unavailable, records last for the current page only.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
@@ -52,7 +56,7 @@ Account login uses a dismissible dialog before the model onboarding credential e
|
||||
|
||||
A terminal account-state stream failure appears in the sign-in dialog or Account settings. Plugin unload suppresses late failure reports.
|
||||
|
||||
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.
|
||||
The balance card shows recharge funds and bonus credit in separate rows. The bonus row is present while signed in and carries the refresh action; without positive bonus credit it states that no bonus is available, and a failed wallet read shows the existing unavailable copy with the same action still available. Currencies retain their own amounts, and a notice date is rendered only inside the server’s own message.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
@@ -69,6 +73,8 @@ No model request prefix changes.
|
||||
|
||||
- Profile and recharge-wallet balances use the existing Platform Web endpoints through Host getProfile / getBalance. The page refreshes when opened and after login or reconnect, preserves server-masked contact data, and shows query failures independently without manufacturing a zero balance. Usage and top-up use Host-provided links derived from platformOrigin and the browser’s own login; the links never carry a DSH token.
|
||||
|
||||
- The notice copy is server-localized for the UI language in effect when the client reads it. Switching language does not itself re-read the unnotified bonus, so a notice already on screen keeps the copy the server sent for the earlier language until the user refreshes.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
|
||||
|
||||
账号卡片的“更多账号信息”链接通过系统浏览器打开 `https://platform.deepseek.com`。
|
||||
|
||||
赠金到账时,侧栏账号入口上方显示一条由服务端提供文案的通知,不抢占焦点。客户端仅在账号进入登录态时、以及用户点击设置页的“刷新余额”时读取未通知赠金,不做轮询。候选顺序与文案均由服务端决定,客户端只展示第一条并原样渲染其消息。
|
||||
|
||||
仅在设置面板关闭、文档可见且卡片已完成一帧绘制后才会展示通知。客户端先写入待确认记录,再向服务端发起确认;两步之间中断时会重试而不是再次展示同一笔赠金。用户关闭卡片同样视为已看到。记录按 Platform origin 与账号、以订单 ID 为单位写入浏览器存储,可跨重启和重新登录,账号在处理请求期间变化时不会确认上一个账号的通知。确认失败按带上限的退避重试,且不会在账号界面报错。浏览器存储不可用时,记录仅在当前页面内保留。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
@@ -52,7 +56,7 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
|
||||
|
||||
账户状态流的终止错误会显示在登录对话框或账户设置中。插件卸载后不再报告延迟到达的错误。
|
||||
|
||||
余额卡片分行展示充值余额与正额赠金余额。赠送钱包为空或没有正余额时,隐藏赠金行及其分隔线;不同币种分别显示。
|
||||
余额卡片分行展示充值余额与赠金余额。赠金行在登录态下常驻,并承载刷新操作;没有正额赠金时显示暂无可用赠金,赠金读取失败时沿用既有的不可用文案且刷新操作仍可用。不同币种分别显示;日期只在服务端自己的消息中出现。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
@@ -69,6 +73,8 @@ Desktop 的用量和充值操作在 48px 返回栏下方打开隔离的原生 Pl
|
||||
|
||||
- 资料和充值钱包余额通过 Host getProfile / getBalance 复用已有 Platform Web 接口。页面在打开、登录及重连后刷新,原样显示服务端脱敏后的联系方式,并分别处理查询失败,不将失败显示为零余额。用量和充值使用 Host 按 platformOrigin 派生的链接,并沿用浏览器自身登录态,不携带 DSH token。
|
||||
|
||||
- 通知文案按客户端读取时的界面语言由服务端本地化。切换语言本身不会重新读取未通知赠金,因此已在屏幕上的通知会沿用服务端先前语言下发的文案,直到用户手动刷新。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
/** Sidebar account launcher and locally authoritative sign-out action. */
|
||||
import { useState } from 'react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Menu, IconPaperPlaneOutlineMedium, IconSettingsOutlineMedium, IconUserOutlineMedium } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AccountSectionInjected } from './AccountSection.tsx'
|
||||
import { SignInDialog } from './SignInDialog.tsx'
|
||||
import { LogoutIcon } from './LogoutIcon.tsx'
|
||||
import { AccountAvatar } from './AccountAvatar.tsx'
|
||||
import { AccountNoticeCard } from './AccountNotice.tsx'
|
||||
import css from './AccountMenu.module.css'
|
||||
|
||||
/** Account launcher composed by the settings shell. */
|
||||
@@ -16,8 +17,10 @@ export type AccountMenuProps = PropsRuntime<'settings.launcher'> & PropsLocale<'
|
||||
* @returns account menu launcher.
|
||||
*/
|
||||
export function AccountMenu({
|
||||
wide, openSettings, openOnboarding, useAccount, useTheme, signOut, contactUs, showLogin, start, cancel, t,
|
||||
wide, openSettings, openOnboarding, settingsOpen, useAccount, useTheme, signOut, contactUs, showLogin, start, cancel,
|
||||
bonusNoticeShown, bonusNoticeDismissed, t,
|
||||
}: AccountMenuProps) {
|
||||
const anchor = useRef<HTMLDivElement>(null)
|
||||
const account = useAccount(state => state)
|
||||
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
|
||||
const signedIn = account.view?.status === 'credential-stored'
|
||||
@@ -36,7 +39,10 @@ export function AccountMenu({
|
||||
}
|
||||
// The plugin's start publishes `loginFailed` before it rejects, so the dialog owns the report.
|
||||
const beginSignIn = (): void => { setOpen(false); void start().catch(() => undefined) }
|
||||
return <div className={css.root}>
|
||||
return <div ref={anchor} className={css.root}>
|
||||
{signedIn && !settingsOpen && account.notice && <AccountNoticeCard key={account.notice.orderId} notice={account.notice}
|
||||
anchor={anchor} title={t('bonusNoticeTitle')} closeLabel={t('close')}
|
||||
onShown={bonusNoticeShown} onDismiss={bonusNoticeDismissed} />}
|
||||
<Menu open={open} side="top" portal autoFocus className={css.anchor}
|
||||
anchor={<button type="button" className={css.trigger} data-collapsed={!wide} aria-label={t('menu')}
|
||||
aria-haspopup="menu" aria-expanded={open} onClick={() => { setOpen(value => !value) }}>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
.card { position: fixed; z-index: 40; box-sizing: border-box; width: 280px; max-width: calc(100vw - 16px); padding: 20px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 16px; background: var(--dsw-alias-bg-module-platform); box-shadow: 0 2px 4px #0000000d; color: var(--dsw-alias-label-secondary); font-size: 14px; line-height: 22px; }
|
||||
.copy { display: flex; flex-direction: column; gap: 2px; overflow-wrap: anywhere; }
|
||||
.copy p { margin: 0; white-space: pre-wrap; }
|
||||
.title { display: flex; align-items: flex-start; gap: 2px; line-height: 20px; }
|
||||
.icon { display: flex; flex: none; width: 14px; height: 14px; margin-top: 3px; color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); }
|
||||
.close { position: absolute; top: 6px; right: 6px; display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; padding: 4px; border: 0; background: transparent; color: var(--dsw-alias-label-caption); cursor: pointer; }
|
||||
.close:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 2px; border-radius: 4px; }
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Sidebar bonus notice. The card reports the difference between a notice the
|
||||
* server offered and one the user actually saw: it asks for display only after
|
||||
* the card has a position, the document is visible, and one frame has been
|
||||
* presented. Closing counts as seen, because the user acted on it. An award
|
||||
* that expired before a render is not drawn at all.
|
||||
*/
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { IconCloseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { BonusNotice } from './bonus-notices.ts'
|
||||
import css from './AccountNotice.module.css'
|
||||
|
||||
/** @param expiresAt - server expiration timestamp. @returns whether the award can still be spent. */
|
||||
function live(expiresAt: string): boolean {
|
||||
const expires = Date.parse(expiresAt)
|
||||
// An unparsable timestamp is not evidence of expiry, so the notice still shows.
|
||||
return Number.isNaN(expires) || expires > Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param run - callback to invoke once one presented frame has passed.
|
||||
* @returns a canceller for a card that unmounts or hides first.
|
||||
*/
|
||||
function afterPaint(run: () => void): () => void {
|
||||
if (typeof requestAnimationFrame !== 'function') {
|
||||
// Without a frame clock, the following task is the best available paint boundary.
|
||||
const timer = setTimeout(run, 0)
|
||||
return () => { clearTimeout(timer) }
|
||||
}
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const frame = requestAnimationFrame(() => {
|
||||
// The frame callback runs before its paint; the following task runs after it.
|
||||
timer = setTimeout(() => {
|
||||
if (!cancelled) run()
|
||||
}, 0)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelAnimationFrame(frame)
|
||||
if (timer !== undefined) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
/** @param props - notice copy and identity, sidebar anchor, and display callbacks.
|
||||
* @returns a non-modal notice above the account launcher until closed.
|
||||
*/
|
||||
export function AccountNoticeCard({ notice, anchor, title, closeLabel, onShown, onDismiss }: {
|
||||
notice: BonusNotice
|
||||
anchor: RefObject<HTMLElement>
|
||||
title: string
|
||||
closeLabel: string
|
||||
/** Called once, after the visible card has passed a presented frame. */
|
||||
onShown: (orderId: BonusNotice['orderId']) => void
|
||||
/** Called when the user closes the card, which counts as having seen it. */
|
||||
onDismiss: (orderId: BonusNotice['orderId']) => void
|
||||
}) {
|
||||
const [position, setPosition] = useState<{ left: number; bottom: number }>()
|
||||
const reported = useRef(false)
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor.current || !live(notice.expiresAt)) return
|
||||
const element = anchor.current
|
||||
const update = () => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
setPosition({ left: Math.max(8, Math.min(rect.left - 6, window.innerWidth - 288)),
|
||||
bottom: Math.max(8, window.innerHeight - rect.top + 4) })
|
||||
}
|
||||
update()
|
||||
const observer = new ResizeObserver(update)
|
||||
observer.observe(element)
|
||||
window.addEventListener('resize', update)
|
||||
window.addEventListener('scroll', update, true)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', update)
|
||||
window.removeEventListener('scroll', update, true)
|
||||
}
|
||||
}, [anchor, notice.orderId, notice.expiresAt])
|
||||
useEffect(() => {
|
||||
if (position === undefined || reported.current || !live(notice.expiresAt)) return
|
||||
let cancel: (() => void) | undefined
|
||||
const attempt = () => {
|
||||
if (reported.current || cancel !== undefined || !live(notice.expiresAt)) return
|
||||
if (document.visibilityState === 'hidden') return
|
||||
cancel = afterPaint(() => {
|
||||
cancel = undefined
|
||||
if (reported.current || document.visibilityState === 'hidden' || !live(notice.expiresAt)) return
|
||||
reported.current = true
|
||||
onShown(notice.orderId)
|
||||
})
|
||||
}
|
||||
attempt()
|
||||
document.addEventListener('visibilitychange', attempt)
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', attempt)
|
||||
cancel?.()
|
||||
}
|
||||
}, [position, notice.orderId, notice.expiresAt, onShown])
|
||||
// Rendering nothing keeps an expired notice off screen without disturbing the
|
||||
// hook order above.
|
||||
if (!live(notice.expiresAt)) return null
|
||||
return createPortal(<aside className={css.card} style={position ?? { visibility: 'hidden' }}>
|
||||
<div role="status" className={css.copy}>
|
||||
<div className={css.title}><span className={css.icon} aria-hidden="true">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M3.6355 6.93969C3.6355 4.8398 5.33779 3.1375 7.43768 3.1375C9.53757 3.1375 11.23987 4.8398 11.23987 6.93968V11.363H3.6355V6.93969Z" fill="currentColor" fillOpacity="0.2" stroke="currentColor" strokeWidth="0.875" />
|
||||
<path d="M1.641 11.3645H12.36M5.053 13.3095H8.9479M7.4375 4.27422V2.70137912" stroke="currentColor" strokeWidth="0.875" />
|
||||
</svg>
|
||||
</span><span>{title}</span></div>
|
||||
<p>{notice.message}</p>
|
||||
</div>
|
||||
<button type="button" className={css.close} aria-label={closeLabel} onClick={() => { onDismiss(notice.orderId) }}><IconCloseOutlineRegular size={10} /></button>
|
||||
</aside>, document.body)
|
||||
}
|
||||
@@ -195,3 +195,42 @@
|
||||
}
|
||||
|
||||
.accountInfo { display: inline-flex; align-items: center; gap: 2px; flex: none; color: var(--dsw-alias-label-primary); font-size: 13px; line-height: 22px; text-decoration: underline; }
|
||||
|
||||
/* Refresh action beside the bonus row: icon-only, so it keeps the row's
|
||||
alignment while remaining an explicit button with a localized name. */
|
||||
.refresh {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.refresh:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-button-primary-fill);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.refresh:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.bonusValue {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Account settings renders safe Host state and explicit login actions. */
|
||||
import { Big } from 'big.js'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button, IconRefreshOutlineRegular, IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { PropsRuntime, PropsLocale, InjectFace, HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
@@ -9,10 +9,14 @@ import { PlatformOverlay, type PlatformBridge } from './PlatformOverlay.tsx'
|
||||
import { formatBalance } from './formatBalance.ts'
|
||||
import { AccountAvatar } from './AccountAvatar.tsx'
|
||||
import { authorizeUrlWithTheme } from './authorize-url.ts'
|
||||
import type { BonusNotice } from './bonus-notices.ts'
|
||||
import css from './AccountSection.module.css'
|
||||
|
||||
/** Safe account snapshot shared by the settings page and launcher. */
|
||||
export interface AccountSnapshot {
|
||||
/** Server-authored bonus notice awaiting display, absent when none is available. */
|
||||
notice?: BonusNotice
|
||||
|
||||
/** Latest Host state, absent until the stream responds. */
|
||||
view: AccountView | undefined
|
||||
/** Sanitized profile and balance query outcomes, absent while loading. */
|
||||
@@ -40,12 +44,18 @@ export interface AccountSectionInjected {
|
||||
}
|
||||
/** @returns after account details are refreshed; concurrent refreshes share a request. */
|
||||
refresh: () => Promise<void>
|
||||
/** @returns after the recharge/bonus balances and the unnotified-bonus read are refreshed. */
|
||||
refreshBonus: () => Promise<void>
|
||||
/** Open the external support questionnaire with the current build and browser environment. */
|
||||
contactUs: () => void
|
||||
/** Open or dismiss the login dialog. */
|
||||
showLogin: (visible: boolean) => void
|
||||
/** Claim dialog ownership for the onboarding step. */
|
||||
setOnboarding: (active: boolean) => void
|
||||
/** @param orderId - notice whose card finished a presented frame while visible. */
|
||||
bonusNoticeShown: (orderId: BonusNotice['orderId']) => void
|
||||
/** @param orderId - notice the user closed. */
|
||||
bonusNoticeDismissed: (orderId: BonusNotice['orderId']) => void
|
||||
/** @returns after the login attempt is created. */
|
||||
start: () => Promise<void>
|
||||
/** @param id - attempt to cancel. @returns after cancellation or an already-admitted commit. */
|
||||
@@ -57,12 +67,13 @@ export interface AccountSectionInjected {
|
||||
export type AccountSectionProps =
|
||||
PropsRuntime<'settings.section'> & PropsLocale<'settings.account'> & InjectFace<AccountSectionInjected>
|
||||
/** @param props - localized actions and account subscription. @returns account settings UI. */
|
||||
export function AccountSection({ t, useAccount, useTheme, start, cancel, refresh, platform }: AccountSectionProps) {
|
||||
export function AccountSection({ t, useAccount, useTheme, start, cancel, refresh, refreshBonus, platform }: AccountSectionProps) {
|
||||
const { view: state, details, failed: streamFailed } = useAccount(value => value)
|
||||
const colorScheme = useTheme(snapshot => snapshot.active.colorScheme)
|
||||
const [platformPage, setPlatformPage] = useState<'usage' | 'top-up'>()
|
||||
const [failed, setFailed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [refreshingBonus, setRefreshingBonus] = useState(false)
|
||||
useEffect(() => { void refresh() }, [refresh])
|
||||
const profile = details?.profile?.status === 'ready' ? details.profile.value : undefined
|
||||
const wallets = details?.balance?.status === 'ready' ? details.balance.value : undefined
|
||||
@@ -78,6 +89,17 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, refresh
|
||||
setFailed(false)
|
||||
try { await action() } catch { setFailed(true) } finally { setBusy(false) }
|
||||
}
|
||||
// Both reads are independent: a failed bonus payout read still publishes a
|
||||
// refreshed wallet, and the action reports only that it settled.
|
||||
const reloadBonus = async (): Promise<void> => {
|
||||
setRefreshingBonus(true)
|
||||
try { await refreshBonus() }
|
||||
catch {
|
||||
// Each read already reports its own failure in its row; this catch only
|
||||
// keeps a rejected action from escaping as an unhandled rejection.
|
||||
}
|
||||
finally { setRefreshingBonus(false) }
|
||||
}
|
||||
const status = failed || streamFailed || attempt?.phase === 'failed' ? t('failed')
|
||||
: attempt?.phase === 'expired' ? t('expired')
|
||||
: active ? t(attempt.phase === 'initializing' ? 'initializing' : attempt.phase === 'waiting-browser' ? 'waiting' : 'completing')
|
||||
@@ -131,13 +153,21 @@ export function AccountSection({ t, useAccount, useTheme, start, cancel, refresh
|
||||
: <span className={css.unavailable}>{t(!signedIn ? 'balanceSignedOut'
|
||||
: details?.balance === undefined ? 'loading' : 'balanceUnavailable')}</span>}
|
||||
</div>
|
||||
{signedIn && bonusWallets.length > 0 && <>
|
||||
{signedIn && <>
|
||||
<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>
|
||||
<span className={css.bonusValue}>
|
||||
{bonusWallets.length > 0
|
||||
? <span className={css.amount}>{bonusWallets.map(wallet => <span key={wallet.currency}>
|
||||
{formatBalance(wallet.balance, wallet.currency === 'CNY' ? '¥' : '$')}
|
||||
</span>)}</span>
|
||||
: <span className={css.unavailable}>{t(details?.balance === undefined ? 'loading'
|
||||
: details.balance.status === 'failed' ? 'balanceUnavailable' : 'bonusEmpty')}</span>}
|
||||
<button type="button" className={css.refresh} title={t('refreshBonus')}
|
||||
aria-label={t('refreshBonus')} disabled={refreshingBonus}
|
||||
onClick={() => { void reloadBonus() }}><IconRefreshOutlineRegular size={14} /></button>
|
||||
</span>
|
||||
</div>
|
||||
</>}
|
||||
<div className={css.divider} />
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Local record of bonus notices the user has actually seen, scoped to one
|
||||
* Platform origin and account. A notice is written before its server
|
||||
* acknowledgement is attempted, so an interruption between the two leaves a
|
||||
* retryable record instead of a notice that is shown again.
|
||||
*
|
||||
* When the browser denies storage, the record falls back to a module-level map
|
||||
* so signing out and back in during one page load still does not repeat a
|
||||
* notice. That fallback does not survive a reload, which is the best available
|
||||
* behavior without storage.
|
||||
* @module @deepseek-ai/dsh-client-ui-settings-account/src/client/bonus-notice-store
|
||||
*/
|
||||
import type { AccountBonusOrderId, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
|
||||
/** Whether a shown notice still awaits server acknowledgement. */
|
||||
export type BonusNoticeAckState = 'pending' | 'acknowledged'
|
||||
|
||||
/** Local record of shown bonus notices for one Platform origin and account. */
|
||||
export interface BonusNoticeStore {
|
||||
/** @param orderId - notice to look up. @returns its recorded state, or undefined when it was never shown. */
|
||||
state(orderId: AccountBonusOrderId): BonusNoticeAckState | undefined
|
||||
/** @returns order ids still awaiting server acknowledgement, oldest written first. */
|
||||
pending(): readonly AccountBonusOrderId[]
|
||||
/** @param orderId - shown notice. @param state - acknowledgement state to record. */
|
||||
write(orderId: AccountBonusOrderId, state: BonusNoticeAckState): void
|
||||
}
|
||||
|
||||
/** Records of a page whose browser storage is unavailable. */
|
||||
const memoryRecords = new Map<string, Map<AccountBonusOrderId, BonusNoticeAckState>>()
|
||||
|
||||
/**
|
||||
* Storage key for one Platform origin and account.
|
||||
* @param origin - Platform origin the account belongs to.
|
||||
* @param accountId - Platform account.
|
||||
* @returns the record key.
|
||||
*/
|
||||
export function bonusNoticeStorageKey(origin: string, accountId: AccountUserId): string {
|
||||
return `dsh-account-bonus:${origin}:${accountId}`
|
||||
}
|
||||
|
||||
/** @returns browser storage, or undefined when the context denies access to it. */
|
||||
function browserStorage(): Storage | undefined {
|
||||
try { return globalThis.localStorage }
|
||||
catch {
|
||||
// Private and embedded contexts can deny the property itself.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** @param raw - stored text. @returns the recorded states of a well-formed record; an unusable record reads as empty. */
|
||||
function parseRecords(raw: string): Map<AccountBonusOrderId, BonusNoticeAckState> {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
// A record this module did not write is not usable; the next write replaces it.
|
||||
return new Map()
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return new Map()
|
||||
const records = new Map<AccountBonusOrderId, BonusNoticeAckState>()
|
||||
for (const [orderId, state] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (orderId === '') continue
|
||||
if (state === 'pending' || state === 'acknowledged') records.set(orderId as AccountBonusOrderId, state)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the record for one Platform origin and account.
|
||||
* @param key - storage key from {@link bonusNoticeStorageKey}.
|
||||
* @returns the record, backed by browser storage when it is available.
|
||||
*/
|
||||
export function createBonusNoticeStore(key: string): BonusNoticeStore {
|
||||
// A page fallback outranks storage: it exists only because a write could not
|
||||
// reach storage, and the stored copy is then the older one.
|
||||
const fallback = memoryRecords.get(key)
|
||||
const backing = fallback === undefined ? readBacking(key) : undefined
|
||||
const records = fallback ?? backing?.records ?? new Map<AccountBonusOrderId, BonusNoticeAckState>()
|
||||
if (backing === undefined) memoryRecords.set(key, records)
|
||||
return {
|
||||
state: orderId => records.get(orderId),
|
||||
pending: () => [...records].filter(([, state]) => state === 'pending').map(([orderId]) => orderId),
|
||||
write(orderId, state) {
|
||||
records.set(orderId, state)
|
||||
if (backing === undefined) return
|
||||
try {
|
||||
backing.storage.setItem(key, JSON.stringify(Object.fromEntries(records)))
|
||||
// Storage now holds the record, so drop a fallback left by an earlier failure.
|
||||
memoryRecords.delete(key)
|
||||
}
|
||||
catch {
|
||||
// Persistence can be denied or full. Keeping this page's records makes
|
||||
// the next open of this key prefer them over the stale stored copy.
|
||||
memoryRecords.set(key, records)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** @param key - record key. @returns browser storage with its current records, or undefined when storage is denied. */
|
||||
function readBacking(key: string): { storage: Storage; records: Map<AccountBonusOrderId, BonusNoticeAckState> } | undefined {
|
||||
const storage = browserStorage()
|
||||
if (storage === undefined) return undefined
|
||||
try {
|
||||
const raw = storage.getItem(key)
|
||||
return { storage, records: raw === null ? new Map<AccountBonusOrderId, BonusNoticeAckState>() : parseRecords(raw) }
|
||||
} catch {
|
||||
// A denied read takes the page fallback, like a denied property.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Lifecycle of bonus notices: one read at a time, one displayed notice at a
|
||||
* time, and acknowledgement of a notice only after its card reports a
|
||||
* presented frame. Reads happen when the account becomes active and when the
|
||||
* user asks for a refresh; the controller never polls.
|
||||
*
|
||||
* The server is the sole authority for which bonus is unnotified and owns the
|
||||
* copy, so this controller never synthesizes a notice from wallet balances and
|
||||
* never acknowledges an order the user did not see. Reads and acknowledgements
|
||||
* carry the UI locale so the server can localize its copy.
|
||||
* @module @deepseek-ai/dsh-client-ui-settings-account/src/client/bonus-notices
|
||||
*/
|
||||
import type {
|
||||
AccountBonusBatch, AccountBonusNotification, AccountBonusOrderId, AccountUserId,
|
||||
} from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { bonusNoticeStorageKey, createBonusNoticeStore, type BonusNoticeStore } from './bonus-notice-store.ts'
|
||||
|
||||
/** Notice eligible for display; the message is server-authored for the active locale. */
|
||||
export interface BonusNotice {
|
||||
/** Server order identity. */
|
||||
readonly orderId: AccountBonusOrderId
|
||||
/** Server-localized plain text, including any award amount or expiration. */
|
||||
readonly message: string
|
||||
/** Server expiration, re-checked at display time so a notice cannot appear after its award expired. */
|
||||
readonly expiresAt: string
|
||||
}
|
||||
|
||||
/** Deployment-varying acknowledgement retry timings. */
|
||||
export interface BonusNoticeTiming {
|
||||
/** Delay before the first acknowledgement retry, doubled per further failure. */
|
||||
readonly ackRetryDelayMs: number
|
||||
/** Ceiling for the acknowledgement retry backoff. */
|
||||
readonly ackRetryMaxDelayMs: number
|
||||
}
|
||||
|
||||
/** Collaborators and timings of one bonus notice lifecycle. */
|
||||
export interface BonusNoticeControllerOptions extends BonusNoticeTiming {
|
||||
/** Read the signed-in account's unnotified bonus. @returns the batch, or null when no account is signed in. */
|
||||
read: () => Promise<AccountBonusBatch | null>
|
||||
/**
|
||||
* Record one order as notified.
|
||||
* @param accountId - account the order belongs to.
|
||||
* @param orderId - shown order.
|
||||
* @returns false when the account changed or signed out; a protocol failure rejects.
|
||||
*/
|
||||
acknowledge: (accountId: AccountUserId, orderId: AccountBonusOrderId) => Promise<boolean>
|
||||
/** @param notice - notice to display, or null when none is shown. */
|
||||
publish: (notice: BonusNotice | null) => void
|
||||
}
|
||||
|
||||
/** Commands the account plugin issues to the bonus notice lifecycle. */
|
||||
export interface BonusNoticeController {
|
||||
/**
|
||||
* Start the lifecycle for one signed-in account and read once, discarding any
|
||||
* previous account's notice. The plugin calls this for every signed-in account
|
||||
* frame, so an account switch never leaves the previous account's card on screen.
|
||||
* @param origin - Platform origin the local record is scoped to.
|
||||
*/
|
||||
begin(origin: string): void
|
||||
/** Stop the lifecycle, drop the displayed notice, and cancel acknowledgement retries. */
|
||||
end(): void
|
||||
/**
|
||||
* Read the unnotified bonus once, for the user's explicit refresh.
|
||||
* @returns after the read settles, so callers can keep their own controls
|
||||
* disabled until the request is no longer in flight.
|
||||
*/
|
||||
refresh(): Promise<void>
|
||||
/** @param orderId - notice whose card passed a presented frame while visible. */
|
||||
shown(orderId: AccountBonusOrderId): void
|
||||
/** @param orderId - notice the user closed, which counts as seen. */
|
||||
dismiss(orderId: AccountBonusOrderId): void
|
||||
}
|
||||
|
||||
/** Signed-in scope; the account and its record appear once a read names them. */
|
||||
interface NoticeScope {
|
||||
readonly origin: string
|
||||
accountId?: AccountUserId
|
||||
store?: BonusNoticeStore
|
||||
}
|
||||
|
||||
/**
|
||||
* @param expiresAt - server expiration timestamp.
|
||||
* @param at - milliseconds since epoch.
|
||||
* @returns whether the bonus can no longer be shown.
|
||||
*/
|
||||
function expired(expiresAt: string, at: number): boolean {
|
||||
const expires = Date.parse(expiresAt)
|
||||
// An unparsable timestamp is not evidence of expiry, so the notice still shows.
|
||||
return !Number.isNaN(expires) && expires <= at
|
||||
}
|
||||
|
||||
/**
|
||||
* Own the read, display, and acknowledgement lifecycle of bonus notices.
|
||||
* @param options - collaborators and timings.
|
||||
* @returns the controller the account plugin drives from account and settings state.
|
||||
*/
|
||||
export function createBonusNoticeController(options: BonusNoticeControllerOptions): BonusNoticeController {
|
||||
let scope: NoticeScope | undefined
|
||||
/** Notice currently published to the sidebar, including the expiration checked at display time. */
|
||||
let current: BonusNotice | undefined
|
||||
let readToken = 0
|
||||
let ackTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let ackInFlight = false
|
||||
let ackDelay = options.ackRetryDelayMs
|
||||
const clearAckTimer = (): void => { if (ackTimer !== undefined) { clearTimeout(ackTimer); ackTimer = undefined } }
|
||||
const setCurrent = (notice: BonusNotice | undefined): void => {
|
||||
if (notice === undefined && current === undefined) return
|
||||
if (notice?.orderId === current?.orderId && notice?.message === current?.message
|
||||
&& notice?.expiresAt === current?.expiresAt) return
|
||||
current = notice
|
||||
options.publish(notice ?? null)
|
||||
}
|
||||
const scheduleAck = (delay: number): void => {
|
||||
clearAckTimer()
|
||||
ackTimer = setTimeout(() => { void runAcks() }, delay)
|
||||
}
|
||||
const startAcks = (): void => { if (scope?.store !== undefined) scheduleAck(0) }
|
||||
|
||||
/**
|
||||
* Apply one read result. A notice already on screen stays until the user
|
||||
* closes it; a candidate this account already recorded never returns.
|
||||
*/
|
||||
const apply = (scopeAtRead: NoticeScope, batch: AccountBonusBatch): void => {
|
||||
if (scopeAtRead.accountId !== batch.accountId) {
|
||||
// A different account answered: its records, card, and pending
|
||||
// acknowledgements belong to it, and the previous account's card must not
|
||||
// stay on screen.
|
||||
scopeAtRead.accountId = batch.accountId
|
||||
scopeAtRead.store = createBonusNoticeStore(bonusNoticeStorageKey(scopeAtRead.origin, batch.accountId))
|
||||
setCurrent(undefined)
|
||||
startAcks()
|
||||
}
|
||||
const store = scopeAtRead.store
|
||||
const candidate: AccountBonusNotification | undefined = batch.bonuses[0]
|
||||
if (store === undefined) return
|
||||
if (candidate === undefined) {
|
||||
// No candidate: a card whose display was already recorded stays until the
|
||||
// user closes it, while a card that never reached a presented frame is a
|
||||
// stale candidate and is withdrawn.
|
||||
if (current !== undefined && store.state(current.orderId) === undefined) setCurrent(undefined)
|
||||
return
|
||||
}
|
||||
if (store.state(candidate.orderId) !== undefined) return
|
||||
if (candidate.orderId === current?.orderId) {
|
||||
// Same order still on screen: refresh its copy, and validate expiration at display time.
|
||||
setCurrent({ orderId: candidate.orderId, message: candidate.message, expiresAt: candidate.expiresAt })
|
||||
return
|
||||
}
|
||||
if (expired(candidate.expiresAt, Date.now())) return
|
||||
setCurrent({ orderId: candidate.orderId, message: candidate.message, expiresAt: candidate.expiresAt })
|
||||
}
|
||||
|
||||
/** Read once; a result that arrives after the lifecycle or account changed is dropped. */
|
||||
async function runRead(): Promise<void> {
|
||||
const scopeAtRead = scope
|
||||
if (scopeAtRead === undefined) return
|
||||
const token = ++readToken
|
||||
let batch: AccountBonusBatch | null
|
||||
try { batch = await options.read() }
|
||||
catch {
|
||||
// A failed read reports nothing to the user; the next explicit read retries it.
|
||||
batch = null
|
||||
}
|
||||
if (scope !== scopeAtRead || token !== readToken) return
|
||||
if (batch !== null) apply(scopeAtRead, batch)
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge one recorded order at a time. A protocol failure backs off up
|
||||
* to the ceiling; a false answer means this session no longer owns the
|
||||
* account, so the record stays pending and the next sign-in retries it.
|
||||
* A pending record already proves the card was presented, so a hidden window
|
||||
* keeps retrying: finishing the acknowledgement is what stops another device
|
||||
* from showing the same award.
|
||||
*/
|
||||
async function runAcks(): Promise<void> {
|
||||
const scopeAtCall = scope
|
||||
if (scopeAtCall === undefined || ackInFlight) return
|
||||
const store = scopeAtCall.store
|
||||
const accountId = scopeAtCall.accountId
|
||||
const orderId = store?.pending()[0]
|
||||
if (store === undefined || accountId === undefined || orderId === undefined) return
|
||||
ackInFlight = true
|
||||
let acknowledged: boolean
|
||||
try { acknowledged = await options.acknowledge(accountId, orderId) }
|
||||
catch {
|
||||
ackInFlight = false
|
||||
if (scope !== scopeAtCall) { startAcks(); return }
|
||||
scheduleAck(ackDelay)
|
||||
ackDelay = Math.min(ackDelay * 2, options.ackRetryMaxDelayMs)
|
||||
return
|
||||
}
|
||||
ackInFlight = false
|
||||
if (scope !== scopeAtCall) { startAcks(); return }
|
||||
if (!acknowledged) return
|
||||
store.write(orderId, 'acknowledged')
|
||||
ackDelay = options.ackRetryDelayMs
|
||||
scheduleAck(0)
|
||||
}
|
||||
|
||||
/** Record a shown order before acknowledging it, so an interruption between the two still retries. */
|
||||
const record = (orderId: AccountBonusOrderId): void => {
|
||||
const store = scope?.store
|
||||
if (store === undefined || current?.orderId !== orderId) return
|
||||
if (store.state(orderId) === undefined) store.write(orderId, 'pending')
|
||||
ackDelay = options.ackRetryDelayMs
|
||||
scheduleAck(0)
|
||||
}
|
||||
|
||||
const endLifecycle = (): void => {
|
||||
if (scope === undefined) return
|
||||
clearAckTimer()
|
||||
readToken++
|
||||
scope = undefined
|
||||
setCurrent(undefined)
|
||||
}
|
||||
|
||||
return {
|
||||
begin(origin) {
|
||||
endLifecycle()
|
||||
scope = { origin }
|
||||
void runRead()
|
||||
},
|
||||
end: endLifecycle,
|
||||
refresh(): Promise<void> {
|
||||
if (scope === undefined) return Promise.resolve()
|
||||
startAcks()
|
||||
return runRead()
|
||||
},
|
||||
shown(orderId) {
|
||||
if (current?.orderId !== orderId) return
|
||||
if (expired(current.expiresAt, Date.now())) return
|
||||
record(orderId)
|
||||
},
|
||||
dismiss(orderId) {
|
||||
if (current?.orderId !== orderId) return
|
||||
// Closing the card is the user seeing it, so record before withdrawing.
|
||||
record(orderId)
|
||||
setCurrent(undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { contactUrl } from './contact-url.ts'
|
||||
import { AccountOnboarding } from './AccountOnboarding.tsx'
|
||||
import { AccountMenu } from './AccountMenu.tsx'
|
||||
import { AccountSection, type AccountSnapshot, type AccountSectionInjected } from './AccountSection.tsx'
|
||||
import { createBonusNoticeController } from './bonus-notices.ts'
|
||||
import { accountClientMetadata } from './client-metadata.ts'
|
||||
import { en, zh, type AccountKey } from './locales.ts'
|
||||
export type { AccountSectionInjected, AccountSectionProps } from './AccountSection.tsx'
|
||||
@@ -34,8 +35,34 @@ export function apply(ctx: Context): void {
|
||||
let snapshot: AccountSnapshot = { view: undefined, details: undefined, failed: false, loginVisible: false }
|
||||
const listeners = new Set<() => void>()
|
||||
const publish = (value: AccountSnapshot) => { snapshot = value; for (const listener of listeners) listener() }
|
||||
/** @returns the client identity sampled for one account call. */
|
||||
/** @returns the client identity for one account call, read at call time so it carries the language and zone in effect then. */
|
||||
const client = () => accountClientMetadata(ctx.locale.getSnapshot().active, process.env.DSH_CLIENT_VERSION)
|
||||
/** @param view - latest account state. @returns the Platform origin that scopes local notice records. */
|
||||
const platformOriginOf = (view: AccountView): string => new URL(view.links.usageUrl).origin
|
||||
// The browser half of the notice lifecycle: reads when the account becomes
|
||||
// active and on an explicit refresh, and acknowledges an order only after its
|
||||
// card reports a presented frame. The Host owns which bonus is unnotified and
|
||||
// the server owns the copy.
|
||||
const notices = createBonusNoticeController({
|
||||
ackRetryDelayMs: config.bonusAckRetryDelayMs,
|
||||
ackRetryMaxDelayMs: config.bonusAckRetryMaxDelayMs,
|
||||
read: async () => {
|
||||
const result = await ctx.remote.account.getUnnotifiedBonuses(client())
|
||||
if (!result.ok) throw new Error('account bonus read failed')
|
||||
return result.value
|
||||
},
|
||||
acknowledge: async (accountId, orderId) => {
|
||||
const result = await ctx.remote.account.ackBonusNotified(accountId, orderId, client())
|
||||
if (!result.ok) throw new Error('account bonus acknowledgement failed')
|
||||
return result.value
|
||||
},
|
||||
publish: (notice) => {
|
||||
const { notice: _replaced, ...withoutNotice } = snapshot
|
||||
// exactOptionalPropertyTypes distinguishes an absent notice from an undefined one.
|
||||
publish(notice === null ? withoutNotice : { ...withoutNotice, notice })
|
||||
},
|
||||
})
|
||||
ctx.effect(() => () => { notices.end() }, 'account: bonus notice lifetime')
|
||||
let revision = 0
|
||||
let refreshing: Promise<void> | undefined
|
||||
const refresh = (): Promise<void> => {
|
||||
@@ -75,8 +102,12 @@ export function apply(ctx: Context): void {
|
||||
for await (const frame of stream) {
|
||||
revision++
|
||||
refreshing = undefined
|
||||
publish({ ...snapshot, view: frame.value, details: undefined, failed: false })
|
||||
const { notice, ...previous } = snapshot
|
||||
publish({ ...previous, view: frame.value, details: undefined, failed: false,
|
||||
...(frame.value.status === 'credential-stored' && notice ? { notice } : {}) })
|
||||
frame.accept()
|
||||
if (frame.value.status === 'credential-stored') notices.begin(platformOriginOf(frame.value))
|
||||
else notices.end()
|
||||
void refresh()
|
||||
}
|
||||
})().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) })
|
||||
@@ -84,6 +115,25 @@ export function apply(ctx: Context): void {
|
||||
const operations: AccountSectionInjected = {
|
||||
...nativePlatform === undefined ? {} : { platform: nativePlatform },
|
||||
refresh,
|
||||
async refreshBonus() {
|
||||
// The user's refresh: recharge/bonus balances and the unnotified-bonus read.
|
||||
const generation = revision
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
let balance: AccountDetails['balance']
|
||||
try {
|
||||
const result = await ctx.remote.account.getBalance(client())
|
||||
if (!result.ok) throw new Error('account balance failed')
|
||||
if (result.value === null) return
|
||||
balance = result.value
|
||||
} catch { balance = { status: 'failed' } }
|
||||
// A response for an account that signed out mid-request must not publish.
|
||||
if (generation !== revision) return
|
||||
publish({ ...snapshot, details: { ...snapshot.details, balance } })
|
||||
})(),
|
||||
notices.refresh(),
|
||||
])
|
||||
},
|
||||
contactUs() {
|
||||
const url = contactUrl(config, {
|
||||
version: process.env.DSH_CLIENT_VERSION,
|
||||
@@ -94,6 +144,8 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
showLogin(visible) { publish({ ...snapshot, loginVisible: visible }) },
|
||||
setOnboarding(active) { publish({ ...snapshot, onboarding: active }) },
|
||||
bonusNoticeShown(orderId) { notices.shown(orderId) },
|
||||
bonusNoticeDismissed(orderId) { notices.dismiss(orderId) },
|
||||
hooks: {
|
||||
account: {
|
||||
getSnapshot: () => snapshot,
|
||||
|
||||
@@ -20,6 +20,8 @@ export const en = {
|
||||
profileUnavailable: 'Account details are not available yet.',
|
||||
balance: 'Recharge balance', bonusBalance: 'Bonus balance', balanceUnavailable: 'View on Platform', balanceSignedOut: 'Sign in to view',
|
||||
accountInfo: 'More account information', more: 'More', usage: 'View usage', topUp: 'Top up',
|
||||
bonusNoticeTitle: 'Bonus credited',
|
||||
bonusEmpty: 'No bonus available', refreshBonus: 'Refresh balance',
|
||||
} as const
|
||||
/** Account locale keys. */
|
||||
export type AccountKey = keyof typeof en
|
||||
@@ -45,4 +47,6 @@ export const zh: Record<AccountKey, string> = {
|
||||
profileUnavailable: '账号资料暂不可用',
|
||||
balance: '充值余额', bonusBalance: '赠金余额', balanceUnavailable: '前往开放平台查看', balanceSignedOut: '登录后查看',
|
||||
accountInfo: '更多账号信息', more: '更多', usage: '查询用量', topUp: '充值',
|
||||
bonusNoticeTitle: '赠金已到账',
|
||||
bonusEmpty: '暂无可用赠金', refreshBonus: '刷新余额',
|
||||
}
|
||||
@@ -1,17 +1,23 @@
|
||||
/** Public questionnaire deployment options shared by Host and Client. */
|
||||
/** Public contact and bonus notice options shared by Host and Client. */
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
|
||||
/** Questionnaire destination and its supported source option. */
|
||||
/** Questionnaire destination and bonus notice timings shared by Host and Client. */
|
||||
export interface Config {
|
||||
/** HTTPS questionnaire URL; override for a test form. */
|
||||
contactFormUrl: string
|
||||
/** Questionnaire source option; empty until Harness is supported by the form. */
|
||||
contactSource: string
|
||||
/** First delay before retrying a failed bonus acknowledgement. */
|
||||
bonusAckRetryDelayMs: number
|
||||
/** Ceiling for the acknowledgement retry backoff. */
|
||||
bonusAckRetryMaxDelayMs: number
|
||||
}
|
||||
/** Validate public questionnaire options. */
|
||||
export const Config: z<Partial<Config>, Config> = z.object({
|
||||
contactFormUrl: z.string().pattern(/^https:\/\/[^/\s]+\//).default('https://trtgsjkv6r.feishu.cn/share/base/form/shrcnlCoGElW7MQznGy9r3YYXcg'),
|
||||
contactSource: z.string().default(''),
|
||||
bonusAckRetryDelayMs: z.number().min(1).default(1_000),
|
||||
bonusAckRetryMaxDelayMs: z.number().min(1).default(60_000),
|
||||
})
|
||||
/** Bootstrap key containing no account credentials. */
|
||||
export const CONTACT_CONFIG_GLOBAL = '__DSH_CONTACT_CONFIG__'
|
||||
@@ -6,6 +6,7 @@ import type { AccountDetails, AccountView, SignInAttemptId } from '@deepseek-ai/
|
||||
import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import type { PlatformBridge } from '../src/client/PlatformOverlay.tsx'
|
||||
import { AccountSection, type AccountSectionInjected, type AccountSnapshot } from '../src/client/AccountSection.tsx'
|
||||
import type { BonusNotice } from '../src/client/bonus-notices.ts'
|
||||
import type {} from '../src/client/index.ts'
|
||||
import { en, zh, type AccountKey } from '../src/client/locales.ts'
|
||||
|
||||
@@ -28,7 +29,8 @@ function operationsOf(state: Omit<AccountView, 'links'>, details?: Partial<Accou
|
||||
theme: { getSnapshot: () => themeOf('light'), subscribe: () => () => {} },
|
||||
},
|
||||
contactUs: vi.fn(), showLogin: vi.fn(), setOnboarding: vi.fn(),
|
||||
refresh: vi.fn(() => Promise.resolve()),
|
||||
bonusNoticeShown: vi.fn(), bonusNoticeDismissed: vi.fn(),
|
||||
refresh: vi.fn(() => Promise.resolve()), refreshBonus: vi.fn(() => Promise.resolve()),
|
||||
start: vi.fn(() => Promise.resolve()), cancel: vi.fn(() => Promise.resolve()), signOut: vi.fn(() => Promise.resolve()),
|
||||
}
|
||||
}
|
||||
@@ -47,7 +49,7 @@ function mount(state: Omit<AccountView, 'links'>, copy: typeof en | typeof zh =
|
||||
it.each([en, zh])('renders account cards without inventing profile or balance data', async (copy) => {
|
||||
mount({ status: 'credential-stored', attempt: null }, copy)
|
||||
expect(screen.getByText(copy.signedIn)).toBeTruthy()
|
||||
expect(screen.getAllByText(copy.loading)).toHaveLength(2)
|
||||
expect(screen.getAllByText(copy.loading)).toHaveLength(3)
|
||||
expect(screen.getByRole('link', { name: copy.usage }).getAttribute('href')).toBe('http://localhost:8081/usage')
|
||||
expect(screen.getByRole('link', { name: copy.topUp }).getAttribute('href')).toBe('http://localhost:8081/top_up')
|
||||
expect(screen.queryByRole('button', { name: copy.signOut })).toBeNull()
|
||||
@@ -71,7 +73,7 @@ it.each([en, zh])('opens settings and signs out from the sidebar account menu',
|
||||
const operations = mount({ status: 'credential-stored', attempt: null }, copy)
|
||||
cleanup()
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} signOut={signOut}
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} settingsOpen={false} signOut={signOut}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide openOnboarding={() => {}} openSettings={openSettings}
|
||||
t={key => key in copy ? copy[key as AccountKey] : key} />)
|
||||
@@ -101,7 +103,7 @@ it('reports a failed start in the login dialog, not as a sidebar alert', async (
|
||||
snapshot = { ...snapshot, loginVisible: true, loginFailed: true }
|
||||
return Promise.reject(new Error('account start failed'))
|
||||
})
|
||||
const view = render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} start={start}
|
||||
const view = render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} settingsOpen={false} start={start}
|
||||
useAccount={selector => selector(snapshot)} useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
|
||||
wide openOnboarding={() => {}} openSettings={() => {}}
|
||||
t={key => key in en ? en[key as AccountKey] : key} />)
|
||||
@@ -109,7 +111,7 @@ it('reports a failed start in the login dialog, not as a sidebar alert', async (
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: en.signIn })) })
|
||||
expect(start).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
view.rerender(<AccountMenu {...({} as GlobalStandardProps)} {...operations} start={start}
|
||||
view.rerender(<AccountMenu {...({} as GlobalStandardProps)} {...operations} settingsOpen={false} start={start}
|
||||
useAccount={selector => selector(snapshot)} useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
|
||||
wide openOnboarding={() => {}} openSettings={() => {}}
|
||||
t={key => key in en ? en[key as AccountKey] : key} />)
|
||||
@@ -124,7 +126,7 @@ it('keeps the sidebar alert for a failed sign-out', async () => {
|
||||
const operations = mount({ status: 'credential-stored', attempt: null })
|
||||
cleanup()
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} settingsOpen={false}
|
||||
signOut={() => Promise.reject(new Error('account sign-out failed'))}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())} wide
|
||||
@@ -233,7 +235,8 @@ it('shows the profile while the balance is still loading', async () => {
|
||||
profile: { status: 'ready', value: { id: null, name: 'Ready User', contact: '138****0000' } },
|
||||
})
|
||||
expect(screen.getByText('Ready User')).toBeTruthy()
|
||||
expect(screen.getByText(en.loading)).toBeTruthy()
|
||||
// Both the recharge and the bonus row report their own pending read.
|
||||
expect(screen.getAllByText(en.loading)).toHaveLength(2)
|
||||
expect(screen.queryByText(en.balanceUnavailable)).toBeNull()
|
||||
await expect(`${screen.getByRole('region').textContent}\n`).toMatchFileSnapshot('./expected/profile-before-balance.txt')
|
||||
})
|
||||
@@ -270,7 +273,7 @@ it.each([
|
||||
})
|
||||
cleanup()
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} settingsOpen={false}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
|
||||
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
|
||||
@@ -282,7 +285,7 @@ it('shows the profile image in settings and the sidebar, with independent load-e
|
||||
profile: { status: 'ready', value: { id: null, name: 'User', contact: null, avatarUrl: 'https://example.test/avatar.png' } },
|
||||
})
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations}
|
||||
render(<AccountMenu {...({} as GlobalStandardProps)} {...operations} settingsOpen={false}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
useTheme={selector => selector(operations.hooks.theme.getSnapshot())}
|
||||
wide openSettings={() => {}} openOnboarding={() => {}} t={key => en[key as AccountKey]} />)
|
||||
@@ -323,12 +326,16 @@ it.each([en, zh])('renders positive bonus wallets separately from recharge balan
|
||||
})
|
||||
|
||||
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 }) => {
|
||||
'keeps the bonus row refreshable without inventing credit for zero or negative wallets', ({ bonusWallets }) => {
|
||||
mount({ status: 'credential-stored', attempt: null }, en, {
|
||||
balance: { status: 'ready', value: [{ currency: 'CNY', balance: '0' }], bonusWallets },
|
||||
})
|
||||
expect(screen.queryByText(en.bonusBalance)).toBeNull()
|
||||
// The row itself stays: it carries the refresh action and states the absence.
|
||||
expect(screen.getByText(en.bonusBalance)).toBeTruthy()
|
||||
expect(screen.getByText(en.bonusEmpty)).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: en.refreshBonus })).toBeTruthy()
|
||||
expect(screen.getByText('¥0.00')).toBeTruthy()
|
||||
expect(screen.queryByText('¥-1.00')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -375,7 +382,8 @@ it('shows unavailable details and keeps external Platform links usable in a brow
|
||||
profile: { status: 'failed' }, balance: { status: 'failed' },
|
||||
})
|
||||
expect(screen.getByText(en.profileUnavailable)).toBeTruthy()
|
||||
expect(screen.getByText(en.balanceUnavailable)).toBeTruthy()
|
||||
// The failed balance reports the same unavailable copy in both balance rows.
|
||||
expect(screen.getAllByText(en.balanceUnavailable)).toHaveLength(2)
|
||||
for (const name of [en.usage, en.topUp]) {
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
screen.getByRole('link', { name }).dispatchEvent(event)
|
||||
@@ -408,7 +416,7 @@ it('dismisses a collapsed menu and hands its login dialog to the API-key onboard
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
const openOnboarding = vi.fn()
|
||||
let snapshot = operations.hooks.account.getSnapshot()
|
||||
const props = { ...({} as GlobalStandardProps), ...operations, wide: false, openSettings: vi.fn(), openOnboarding,
|
||||
const props = { ...({} as GlobalStandardProps), ...operations, wide: false, settingsOpen: false, openSettings: vi.fn(), openOnboarding,
|
||||
useAccount: <T,>(select: (value: AccountSnapshot) => T) => select(snapshot),
|
||||
useTheme: <T,>(select: (value: ThemeSnapshot) => T) => select(operations.hooks.theme.getSnapshot()),
|
||||
t: (key: string) => en[key as AccountKey] }
|
||||
@@ -428,6 +436,61 @@ it('dismisses a collapsed menu and hands its login dialog to the API-key onboard
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('mounts the bonus notice only while settings is closed', async () => {
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
|
||||
const operations = mount({ status: 'credential-stored', attempt: null }, en, {
|
||||
profile: { status: 'ready', value: { id: null, name: 'User', contact: null } },
|
||||
})
|
||||
cleanup()
|
||||
const { AccountMenu } = await import('../src/client/AccountMenu.tsx')
|
||||
const notice = { orderId: 'order-1' as BonusNotice['orderId'], message: 'Awarded 5.00', expiresAt: '2099-01-01T00:00:00Z' }
|
||||
const snapshot: AccountSnapshot = { ...operations.hooks.account.getSnapshot(), notice }
|
||||
const renderMenu = (settingsOpen: boolean) => <AccountMenu {...({} as GlobalStandardProps)} {...operations}
|
||||
settingsOpen={settingsOpen} useAccount={selector => selector(snapshot)} wide
|
||||
openOnboarding={() => {}} openSettings={() => {}} t={key => en[key as AccountKey]} />
|
||||
const view = render(renderMenu(true))
|
||||
// The settings dialog covers the sidebar, so the card waits for it to close.
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
view.rerender(renderMenu(false))
|
||||
expect(screen.getByRole('status').textContent).toBe(`${en.bonusNoticeTitle}${notice.message}`)
|
||||
// Reopening settings unmounts the card without another display report.
|
||||
view.rerender(renderMenu(true))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([en, zh])('refreshes balances and the bonus read from the bonus row', async (copy) => {
|
||||
const operations = mount({ status: 'credential-stored', attempt: null }, copy, {
|
||||
balance: { status: 'ready', value: [{ currency: 'CNY', balance: '12.34' }], bonusWallets: [] },
|
||||
})
|
||||
expect(operations.refreshBonus).not.toHaveBeenCalled()
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: copy.refreshBonus })) })
|
||||
expect(operations.refreshBonus).toHaveBeenCalledOnce()
|
||||
// The row stays available with no bonus balance, and says so without inventing a currency.
|
||||
expect(screen.getByText(copy.bonusEmpty)).toBeTruthy()
|
||||
expect(screen.queryByText('¥0.00')).toBeNull()
|
||||
})
|
||||
|
||||
it('disables the refresh button until both reads settle and keeps it usable after a failure', async () => {
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const operations = mount({ status: 'credential-stored', attempt: null }, en, {
|
||||
balance: { status: 'failed' },
|
||||
})
|
||||
operations.refreshBonus = vi.fn(() => pending.promise)
|
||||
cleanup()
|
||||
const props = { ...({} as GlobalStandardProps), ...operations }
|
||||
render(<AccountSection {...props}
|
||||
useAccount={selector => selector(operations.hooks.account.getSnapshot())}
|
||||
close={() => {}} t={key => en[key as AccountKey]} />)
|
||||
const button = screen.getByRole('button', { name: en.refreshBonus })
|
||||
expect(button.hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(button)
|
||||
expect(screen.getByRole('button', { name: en.refreshBonus }).hasAttribute('disabled')).toBe(true)
|
||||
// Both balance rows report the failed read.
|
||||
expect(screen.getAllByText(en.balanceUnavailable)).toHaveLength(2)
|
||||
await act(async () => { pending.resolve(undefined); await pending.promise })
|
||||
expect(screen.getByRole('button', { name: en.refreshBonus }).hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports resize failure, ignores late native failures, and tolerates a removed IPC receiver', async () => {
|
||||
let resize: (() => void) | undefined
|
||||
vi.stubGlobal('ResizeObserver', class { constructor(callback: () => void) { resize = callback } observe() {} disconnect() {} })
|
||||
|
||||
@@ -4,7 +4,9 @@ import { afterEach, beforeEach, expect, vi } from 'vitest'
|
||||
import { ok } from '@deepseek-ai/dsh-remote-mock'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { createClientTest, type TestClient, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
import type { AccountDetails, AccountView, AccountUserId, SignInAttemptId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type {
|
||||
AccountBonusBatch, AccountBonusOrderId, AccountDetails, AccountUserId, AccountView, SignInAttemptId,
|
||||
} from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import type { ThemeRuntime } from '@deepseek-ai/dsh-client-ui-theme/client'
|
||||
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { AccountSectionInjected } from '../src/client/AccountSection.tsx'
|
||||
@@ -12,15 +14,28 @@ import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.ts'
|
||||
|
||||
const it = createClientTest({ roster: webApp })
|
||||
const SELF = '@deepseek-ai/dsh-client-ui-settings-account'
|
||||
const view: AccountView = { status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } }
|
||||
const view: AccountView = {
|
||||
status: 'signed-out', attempt: null,
|
||||
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' },
|
||||
}
|
||||
const stored: AccountView = { ...view, status: 'credential-stored' }
|
||||
const profile: AccountDetails['profile'] = { status: 'ready', value: { id: 'account-user' as AccountUserId, name: 'User', contact: null } }
|
||||
/** @param orderId - server order. @param message - server copy. @returns one unnotified bonus for this account. */
|
||||
function bonus(orderId: string, message = 'Awarded 5.00'): AccountBonusBatch {
|
||||
return {
|
||||
accountId: 'account-user' as AccountUserId,
|
||||
bonuses: [{
|
||||
orderId: orderId as AccountBonusOrderId, campaign: 'dsh_login_bonus', amount: '5.00', currency: 'CNY',
|
||||
grantedAt: '2026-09-21T12:00:00Z', expiresAt: '2099-01-01T00:00:00Z', message,
|
||||
}],
|
||||
}
|
||||
}
|
||||
function operations(c: TestClient): AccountSectionInjected {
|
||||
const injected: object = c.ctx.slots.entries('settings.launcher')[0]!.inject!()
|
||||
return injected as AccountSectionInjected
|
||||
}
|
||||
beforeEach(() => { vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks() })
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); vi.restoreAllMocks(); localStorage.clear() })
|
||||
|
||||
it('keeps account UI and account RPC inactive in a plain browser, including after reload', async ({ start, mock }) => {
|
||||
const c = await start()
|
||||
@@ -119,7 +134,8 @@ it('uses the Desktop login carrier and exposes operation errors', async ({ start
|
||||
const actions = operations(c)
|
||||
mock.remote.account.startSignIn.mockResolvedValue(ok(view))
|
||||
await actions.start()
|
||||
expect(mock.remote.account.startSignIn).toHaveBeenCalledWith(expect.objectContaining({ locale: 'en', version: '0.0.0-test' }), window.location.origin, 'desktop')
|
||||
expect(mock.remote.account.startSignIn).toHaveBeenCalledWith(expect.objectContaining({ locale: 'en', version: '0.0.0-test' }),
|
||||
window.location.origin, 'desktop')
|
||||
const failure = { ok: false as const, error: new RemoteError('gateway/internal', 'offline', {}) }
|
||||
mock.remote.account.startSignIn.mockResolvedValueOnce(failure)
|
||||
await expect(actions.start()).rejects.toThrow('account start failed')
|
||||
@@ -144,10 +160,87 @@ it('uses the Desktop stream origin and exposes the native platform bridge', asyn
|
||||
expect(actions.platform).toBe(platform)
|
||||
mock.remote.account.startSignIn.mockResolvedValue(ok(view))
|
||||
await actions.start()
|
||||
expect(mock.remote.account.startSignIn).toHaveBeenCalledWith(expect.objectContaining({ locale: 'en', version: '0.0.0-test' }), 'http://localhost:9876', 'desktop')
|
||||
expect(mock.remote.account.startSignIn).toHaveBeenCalledWith(expect.objectContaining({ locale: 'en' }), 'http://localhost:9876', 'desktop')
|
||||
}, 60_000)
|
||||
|
||||
|
||||
it('reads the unnotified bonus in the active locale and acknowledges only after the card renders', async ({ start }) => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const c = await start()
|
||||
const actions = operations(c)
|
||||
const orderId = '4c1b0000-0000-4000-8000-000000000000'
|
||||
c.mock.remote.account.getUnnotifiedBonuses.mockResolvedValue(ok(bonus(orderId)))
|
||||
c.mock.remote.account.ackBonusNotified.mockResolvedValue(ok(true))
|
||||
c.mock.streams.push('account/watch', stored)
|
||||
await vi.waitFor(() => {
|
||||
expect(actions.hooks.account.getSnapshot().notice).toMatchObject({ orderId, message: 'Awarded 5.00' })
|
||||
})
|
||||
expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalledWith(expect.objectContaining({ locale: 'en' }))
|
||||
// A successful read is not a display, so nothing may be acknowledged yet.
|
||||
expect(c.mock.remote.account.ackBonusNotified).not.toHaveBeenCalled()
|
||||
actions.bonusNoticeShown(orderId as AccountBonusOrderId)
|
||||
await vi.waitFor(() => {
|
||||
expect(c.mock.remote.account.ackBonusNotified).toHaveBeenCalledWith('account-user', orderId, expect.objectContaining({ locale: 'en' }))
|
||||
})
|
||||
actions.bonusNoticeDismissed(orderId as AccountBonusOrderId)
|
||||
expect(actions.hooks.account.getSnapshot().notice).toBeUndefined()
|
||||
}, 60_000)
|
||||
|
||||
it('drops the previous account notice and stops reading after sign-out', async ({ start }) => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const c = await start()
|
||||
const actions = operations(c)
|
||||
c.mock.remote.account.getUnnotifiedBonuses.mockResolvedValue(ok(bonus('4c1b0000-0000-4000-8000-000000000001')))
|
||||
c.mock.streams.push('account/watch', stored)
|
||||
await vi.waitFor(() => {
|
||||
expect(actions.hooks.account.getSnapshot().notice?.message).toBe('Awarded 5.00')
|
||||
})
|
||||
c.mock.streams.push('account/watch', view)
|
||||
await vi.waitFor(() => { expect(actions.hooks.account.getSnapshot().view).toEqual(view) })
|
||||
expect(actions.hooks.account.getSnapshot().notice).toBeUndefined()
|
||||
// Signing out ends the lifecycle, so no further read follows.
|
||||
const reads = c.mock.remote.account.getUnnotifiedBonuses.mock.calls.length
|
||||
await new Promise((resolve) => { setTimeout(resolve, 20) })
|
||||
expect(c.mock.remote.account.getUnnotifiedBonuses.mock.calls.length).toBe(reads)
|
||||
}, 60_000)
|
||||
|
||||
it('refreshes balances and the bonus read from the settings action, without polling', async ({ start }) => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const c = await start()
|
||||
const actions = operations(c)
|
||||
const balance: AccountDetails['balance'] = {
|
||||
status: 'ready', value: [{ currency: 'CNY', balance: '12.34' }], bonusWallets: [{ currency: 'CNY', balance: '5.00' }],
|
||||
}
|
||||
c.mock.remote.account.getBalance.mockResolvedValue(ok(balance))
|
||||
c.mock.remote.account.getUnnotifiedBonuses.mockResolvedValue(ok(null))
|
||||
c.mock.streams.push('account/watch', stored)
|
||||
// Signing in reads once; nothing else reads on a timer.
|
||||
await vi.waitFor(() => { expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalledTimes(1) })
|
||||
await new Promise((resolve) => { setTimeout(resolve, 20) })
|
||||
expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalledTimes(1)
|
||||
// Signing in already read the wallet once through the details refresh.
|
||||
const balances = c.mock.remote.account.getBalance.mock.calls.length
|
||||
await actions.refreshBonus()
|
||||
expect(c.mock.remote.account.getBalance).toHaveBeenCalledTimes(balances + 1)
|
||||
expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalledTimes(2)
|
||||
// The bonus read carries the active UI language.
|
||||
expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenLastCalledWith(expect.objectContaining({ locale: 'en' }))
|
||||
}, 60_000)
|
||||
|
||||
it('publishes a failed balance from a manual refresh without dropping the bonus read', async ({ start }) => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const c = await start()
|
||||
const actions = operations(c)
|
||||
c.mock.remote.account.getBalance.mockResolvedValueOnce({ ok: false, error: new RemoteError('gateway/internal', 'offline', {}) })
|
||||
c.mock.remote.account.getUnnotifiedBonuses.mockResolvedValue(ok(null))
|
||||
c.mock.streams.push('account/watch', stored)
|
||||
await vi.waitFor(() => { expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalledTimes(1) })
|
||||
await actions.refreshBonus()
|
||||
expect(actions.hooks.account.getSnapshot().details?.balance).toEqual({ status: 'failed' })
|
||||
// The bonus read still ran: a failed wallet read does not cancel it.
|
||||
expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalledTimes(2)
|
||||
}, 60_000)
|
||||
|
||||
it('publishes a terminal state-stream failure without mistaking it for plugin disposal', async ({ start }) => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const c = await start()
|
||||
@@ -176,3 +269,22 @@ it('ignores a terminal stream error when plugin disposal already owns teardown',
|
||||
await disposal
|
||||
expect(actions.hooks.account.getSnapshot().failed).toBe(false)
|
||||
}, 60_000)
|
||||
|
||||
it('samples the build version, language, and UTC offset for every account call', async ({ start }) => {
|
||||
vi.stubGlobal('dshDesktop', {})
|
||||
const c = await start()
|
||||
const actions = operations(c)
|
||||
const offset = vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(480)
|
||||
c.mock.remote.account.getUnnotifiedBonuses.mockResolvedValue(ok(null))
|
||||
c.mock.streams.push('account/watch', stored)
|
||||
await vi.waitFor(() => { expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenCalledTimes(1) })
|
||||
expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenLastCalledWith({
|
||||
version: '0.0.0-test', locale: 'en', timezoneOffsetSeconds: -28_800,
|
||||
})
|
||||
// The next call reads the zone again instead of reusing the first sample.
|
||||
offset.mockReturnValue(-300)
|
||||
await actions.refreshBonus()
|
||||
expect(c.mock.remote.account.getUnnotifiedBonuses).toHaveBeenLastCalledWith({
|
||||
version: '0.0.0-test', locale: 'en', timezoneOffsetSeconds: 18_000,
|
||||
})
|
||||
}, 60_000)
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The local bonus record is what keeps a shown award from appearing twice. It
|
||||
* therefore has to survive a storage write that fails while the page keeps
|
||||
* running, and it has to ignore anything it did not write.
|
||||
*/
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { AccountBonusOrderId, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { bonusNoticeStorageKey, createBonusNoticeStore } from '../src/client/bonus-notice-store.ts'
|
||||
|
||||
const ORDER = 'order-1' as AccountBonusOrderId
|
||||
|
||||
/**
|
||||
* Storage keys are the isolation unit for this module: records of a page whose
|
||||
* browser storage was refused live in a module-level map keyed the same way, so
|
||||
* one key per case keeps that page-lifetime state from leaking between cases.
|
||||
* @param account - account name distinguishing this case's key.
|
||||
* @returns the record key for that account.
|
||||
*/
|
||||
function keyFor(account: string): string {
|
||||
return bonusNoticeStorageKey('https://platform.test', account as AccountUserId)
|
||||
}
|
||||
|
||||
/** @param overrides - storage members to replace. @returns a storage stub the store accepts. */
|
||||
function storage(overrides: Partial<Storage>): Storage {
|
||||
return { getItem: () => null, setItem: () => {}, clear: () => {}, ...overrides } as unknown as Storage
|
||||
}
|
||||
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
it('records and re-reads a shown order through browser storage', () => {
|
||||
const KEY = keyFor('browser-storage')
|
||||
const first = createBonusNoticeStore(KEY)
|
||||
expect(first.state(ORDER)).toBeUndefined()
|
||||
first.write(ORDER, 'pending')
|
||||
expect(first.state(ORDER)).toBe('pending')
|
||||
expect(first.pending()).toEqual([ORDER])
|
||||
// A later open reads the stored record rather than starting over.
|
||||
const second = createBonusNoticeStore(KEY)
|
||||
expect(second.state(ORDER)).toBe('pending')
|
||||
second.write(ORDER, 'acknowledged')
|
||||
expect(second.pending()).toEqual([])
|
||||
expect(createBonusNoticeStore(KEY).state(ORDER)).toBe('acknowledged')
|
||||
})
|
||||
|
||||
it('prefers this page\'s records after a write storage refused, instead of the stale stored copy', () => {
|
||||
const KEY = keyFor('write-refused')
|
||||
const setItem = vi.fn(() => { throw new Error('quota exceeded') })
|
||||
vi.spyOn(globalThis, 'localStorage', 'get').mockReturnValue(storage({ setItem }))
|
||||
const first = createBonusNoticeStore(KEY)
|
||||
first.write(ORDER, 'pending')
|
||||
expect(setItem).toHaveBeenCalledOnce()
|
||||
// Storage still holds nothing, so a second open must not resurrect the award.
|
||||
const second = createBonusNoticeStore(KEY)
|
||||
expect(second.state(ORDER)).toBe('pending')
|
||||
expect(second.pending()).toEqual([ORDER])
|
||||
})
|
||||
|
||||
it('falls back to page-lifetime records when the browser denies storage', () => {
|
||||
const KEY = keyFor('storage-denied')
|
||||
vi.spyOn(globalThis, 'localStorage', 'get').mockImplementation(() => { throw new Error('blocked') })
|
||||
const first = createBonusNoticeStore(KEY)
|
||||
first.write(ORDER, 'acknowledged')
|
||||
expect(first.state(ORDER)).toBe('acknowledged')
|
||||
// Signing out and back in during one page load still sees the record.
|
||||
expect(createBonusNoticeStore(KEY).state(ORDER)).toBe('acknowledged')
|
||||
})
|
||||
|
||||
it('keeps records in memory when a storage read is denied', () => {
|
||||
const KEY = keyFor('read-denied')
|
||||
const getItem = vi.fn(() => { throw new Error('blocked') })
|
||||
vi.spyOn(globalThis, 'localStorage', 'get').mockReturnValue(storage({ getItem }))
|
||||
const store = createBonusNoticeStore(KEY)
|
||||
expect(store.pending()).toEqual([])
|
||||
store.write(ORDER, 'pending')
|
||||
expect(createBonusNoticeStore(KEY).state(ORDER)).toBe('pending')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an array', '[{"order-1":"pending"}]'],
|
||||
['a scalar', '"pending"'],
|
||||
['malformed JSON', '{not json'],
|
||||
])('ignores %s left under its key', (_label, raw) => {
|
||||
const KEY = keyFor(`unusable-${_label}`)
|
||||
vi.spyOn(globalThis, 'localStorage', 'get').mockReturnValue(storage({ getItem: () => raw }))
|
||||
expect(createBonusNoticeStore(KEY).state(ORDER)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops entries that are not order ids with a known state', () => {
|
||||
const KEY = keyFor('unknown-state')
|
||||
vi.spyOn(globalThis, 'localStorage', 'get').mockReturnValue(storage({
|
||||
getItem: () => JSON.stringify({ '': 'pending', 'order-1': 'expired', 'order-2': 'pending' }),
|
||||
}))
|
||||
const store = createBonusNoticeStore(KEY)
|
||||
expect(store.state(ORDER)).toBeUndefined()
|
||||
expect(store.pending()).toEqual(['order-2'])
|
||||
})
|
||||
@@ -0,0 +1,242 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Bonus notice lifecycle: reads happen when the account becomes active and on
|
||||
* an explicit refresh, never on a timer; a notice is acknowledged only after
|
||||
* its card reports a presented frame, with retry, backoff, and durable
|
||||
* deduplication across pages and accounts.
|
||||
*/
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { AccountBonusBatch, AccountBonusOrderId, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { createBonusNoticeController, type BonusNotice } from '../src/client/bonus-notices.ts'
|
||||
|
||||
const TIMING = { ackRetryDelayMs: 1_000, ackRetryMaxDelayMs: 60_000 }
|
||||
|
||||
/**
|
||||
* @param accountId - owning account.
|
||||
* @param orderId - server order.
|
||||
* @param expiresAt - server expiration.
|
||||
* @returns one unnotified bonus as the Host projects it.
|
||||
*/
|
||||
function batch(accountId: string, orderId: string, expiresAt = '2099-01-01T00:00:00Z'): AccountBonusBatch {
|
||||
return {
|
||||
accountId: accountId as AccountUserId,
|
||||
bonuses: [{
|
||||
orderId: orderId as AccountBonusOrderId,
|
||||
campaign: 'dsh_login_bonus',
|
||||
amount: '5.00',
|
||||
currency: 'CNY',
|
||||
grantedAt: '2026-09-21T12:00:00Z',
|
||||
expiresAt,
|
||||
message: 'Server copy 5.00',
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const read = vi.fn(async (): Promise<AccountBonusBatch | null> => null)
|
||||
const acknowledge = vi.fn(async (): Promise<boolean> => true)
|
||||
const published: (BonusNotice | null)[] = []
|
||||
const controller = createBonusNoticeController({
|
||||
...TIMING, read, acknowledge, publish: (notice) => { published.push(notice) },
|
||||
})
|
||||
/** @returns the most recently published notice. */
|
||||
const latest = (): BonusNotice | null | undefined => published.at(-1)
|
||||
return { controller, read, acknowledge, published, latest }
|
||||
}
|
||||
|
||||
let visibility: 'visible' | 'hidden' = 'visible'
|
||||
/** @param value - visibility the document reports. */
|
||||
function setVisibility(value: 'visible' | 'hidden'): void {
|
||||
visibility = value
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
visibility = 'visible'
|
||||
localStorage.clear()
|
||||
// jsdom exposes visibilityState as a getter the controller reads through properties it can be given.
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers() })
|
||||
|
||||
it('reads once when the account becomes active and never on a timer', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { controller, read } = setup()
|
||||
controller.begin('https://platform.test')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
// Nothing else schedules a read: only an explicit refresh does.
|
||||
await vi.advanceTimersByTimeAsync(TIMING.ackRetryMaxDelayMs)
|
||||
expect(read).toHaveBeenCalledTimes(1)
|
||||
await controller.refresh()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(read).toHaveBeenCalledTimes(2)
|
||||
controller.end()
|
||||
})
|
||||
|
||||
it('publishes the server copy for the first unnotified order', async () => {
|
||||
const { controller, read, latest } = setup()
|
||||
read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(latest()).toMatchObject({ orderId: 'order-1', message: 'Server copy 5.00' }) })
|
||||
controller.end()
|
||||
})
|
||||
|
||||
it('acknowledges only after the card reports a presented frame, then keeps the card until it is closed', async () => {
|
||||
const { controller, read, acknowledge, published, latest } = setup()
|
||||
read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(latest()).toMatchObject({ orderId: 'order-1' }) })
|
||||
expect(acknowledge).not.toHaveBeenCalled()
|
||||
controller.shown('order-1' as AccountBonusOrderId)
|
||||
await vi.waitFor(() => { expect(acknowledge).toHaveBeenCalledWith('account-a', 'order-1') })
|
||||
// A refresh still offering the order must not republish the open card.
|
||||
const publishedBefore = published.length
|
||||
await controller.refresh()
|
||||
await vi.waitFor(() => { expect(read).toHaveBeenCalledTimes(2) })
|
||||
expect(published.length).toBe(publishedBefore)
|
||||
expect(latest()).toMatchObject({ orderId: 'order-1' })
|
||||
controller.dismiss('order-1' as AccountBonusOrderId)
|
||||
expect(latest()).toBeNull()
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1)
|
||||
controller.end()
|
||||
})
|
||||
|
||||
it('retries a failed acknowledgement with a growing backoff starting at the configured delay', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { controller, read, acknowledge } = setup()
|
||||
read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
acknowledge.mockRejectedValue(new Error('offline'))
|
||||
controller.begin('https://platform.test')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
controller.shown('order-1' as AccountBonusOrderId)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(TIMING.ackRetryDelayMs - 1)
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1)
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
expect(acknowledge).toHaveBeenCalledTimes(2)
|
||||
await vi.advanceTimersByTimeAsync(TIMING.ackRetryDelayMs * 2)
|
||||
expect(acknowledge).toHaveBeenCalledTimes(3)
|
||||
controller.end()
|
||||
})
|
||||
|
||||
it('keeps acknowledging a presented notice while the window is hidden', async () => {
|
||||
const { controller, read, acknowledge } = setup()
|
||||
read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(read).toHaveBeenCalledTimes(1) })
|
||||
controller.shown('order-1' as AccountBonusOrderId)
|
||||
setVisibility('hidden')
|
||||
// A pending record already means the card was presented, so the acknowledgement
|
||||
// still completes and no other device repeats the award.
|
||||
await vi.waitFor(() => { expect(acknowledge).toHaveBeenCalledTimes(1) })
|
||||
controller.end()
|
||||
})
|
||||
|
||||
it('does not repeat an order recorded by an earlier page or sign-in', async () => {
|
||||
const first = setup()
|
||||
first.read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
first.controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(first.latest()).toMatchObject({ orderId: 'order-1' }) })
|
||||
first.controller.shown('order-1' as AccountBonusOrderId)
|
||||
await vi.waitFor(() => { expect(first.acknowledge).toHaveBeenCalledTimes(1) })
|
||||
first.controller.end()
|
||||
const second = setup()
|
||||
second.read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
second.controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(second.read).toHaveBeenCalledTimes(1) })
|
||||
expect(second.published).toEqual([])
|
||||
// A new award still displays.
|
||||
second.read.mockResolvedValue(batch('account-a', 'order-2'))
|
||||
await second.controller.refresh()
|
||||
await vi.waitFor(() => { expect(second.latest()).toMatchObject({ orderId: 'order-2' }) })
|
||||
second.controller.end()
|
||||
})
|
||||
|
||||
it('keeps a notice recorded for another account pending instead of clearing it', async () => {
|
||||
const { controller, read, acknowledge, latest } = setup()
|
||||
acknowledge.mockResolvedValue(false)
|
||||
read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(latest()).toMatchObject({ orderId: 'order-1' }) })
|
||||
controller.shown('order-1' as AccountBonusOrderId)
|
||||
await vi.waitFor(() => { expect(acknowledge).toHaveBeenCalledWith('account-a', 'order-1') })
|
||||
// A false answer stops this session; the record stays for account-a's next sign-in.
|
||||
read.mockResolvedValue(batch('account-b', 'order-2'))
|
||||
await controller.refresh()
|
||||
await vi.waitFor(() => { expect(latest()).toMatchObject({ orderId: 'order-2' }) })
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1)
|
||||
controller.end()
|
||||
// Signing account-a back in retries its pending acknowledgement.
|
||||
const again = setup()
|
||||
again.read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
again.controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(again.acknowledge).toHaveBeenCalledWith('account-a', 'order-1') })
|
||||
again.controller.end()
|
||||
})
|
||||
|
||||
it('withdraws the displayed card when the signed-in account changes', async () => {
|
||||
const { controller, read, latest } = setup()
|
||||
read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(latest()).toMatchObject({ orderId: 'order-1' }) })
|
||||
read.mockResolvedValue(batch('account-b', 'order-2'))
|
||||
await controller.refresh()
|
||||
await vi.waitFor(() => { expect(latest()).toMatchObject({ orderId: 'order-2' }) })
|
||||
controller.end()
|
||||
})
|
||||
|
||||
it('never acknowledges after the plugin unloads', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { controller, read, acknowledge } = setup()
|
||||
read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
controller.begin('https://platform.test')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
controller.end()
|
||||
controller.shown('order-1' as AccountBonusOrderId)
|
||||
await vi.advanceTimersByTimeAsync(TIMING.ackRetryMaxDelayMs)
|
||||
expect(acknowledge).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('suppresses a bonus that expired before its first presented frame', async () => {
|
||||
const { controller, read, latest } = setup()
|
||||
read.mockResolvedValue(batch('account-a', 'order-1', '2000-01-01T00:00:00Z'))
|
||||
controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(read).toHaveBeenCalledTimes(1) })
|
||||
expect(latest()).toBeUndefined()
|
||||
controller.end()
|
||||
})
|
||||
|
||||
it('does not record a display when the award expired before the card could render', async () => {
|
||||
const { controller, read, acknowledge, latest } = setup()
|
||||
read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(latest()).toMatchObject({ orderId: 'order-1' }) })
|
||||
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2100-01-01T00:00:00Z'))
|
||||
// The card refuses to render an expired award, so no display is reported.
|
||||
controller.shown('order-1' as AccountBonusOrderId)
|
||||
expect(acknowledge).not.toHaveBeenCalled()
|
||||
controller.end()
|
||||
})
|
||||
|
||||
it('keeps in-page deduplication when browser storage is unavailable', async () => {
|
||||
const denied = {
|
||||
getItem: () => { throw new Error('blocked') },
|
||||
setItem: () => { throw new Error('blocked') },
|
||||
} as unknown as Storage
|
||||
vi.spyOn(globalThis, 'localStorage', 'get').mockReturnValue(denied)
|
||||
const first = setup()
|
||||
first.read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
first.controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(first.latest()).toMatchObject({ orderId: 'order-1' }) })
|
||||
first.controller.shown('order-1' as AccountBonusOrderId)
|
||||
await vi.waitFor(() => { expect(first.acknowledge).toHaveBeenCalledTimes(1) })
|
||||
first.controller.end()
|
||||
const second = setup()
|
||||
second.read.mockResolvedValue(batch('account-a', 'order-1'))
|
||||
second.controller.begin('https://platform.test')
|
||||
await vi.waitFor(() => { expect(second.read).toHaveBeenCalledTimes(1) })
|
||||
expect(second.published).toEqual([])
|
||||
second.controller.end()
|
||||
})
|
||||
@@ -1 +1 @@
|
||||
Signed in to DeepSeekLoading…More account informationRecharge balanceLoading…MoreView usageTop up
|
||||
Signed in to DeepSeekLoading…More account informationRecharge balanceLoading…Bonus balanceLoading…MoreView usageTop up
|
||||
@@ -1 +1 @@
|
||||
已登录 DeepSeek加载中…更多账号信息充值余额加载中…更多查询用量充值
|
||||
已登录 DeepSeek加载中…更多账号信息充值余额加载中…赠金余额加载中…更多查询用量充值
|
||||
@@ -1 +1 @@
|
||||
Harness Mock (TEST ONLY)138****0000More account informationRecharge balance¥1,234.56<$0.01MoreView usageTop up
|
||||
Harness Mock (TEST ONLY)138****0000More account informationRecharge balance¥1,234.56<$0.01Bonus balanceNo bonus availableMoreView 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赠金余额暂无可用赠金更多查询用量充值
|
||||
@@ -0,0 +1 @@
|
||||
Bonus creditedServer copy 5.00
|
||||
@@ -1 +1 @@
|
||||
Ready User138****0000More account informationRecharge balanceLoading…MoreView usageTop up
|
||||
Ready User138****0000More account informationRecharge balanceLoading…Bonus balanceLoading…MoreView usageTop up
|
||||
@@ -0,0 +1,117 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The bonus notice card reports display only when the server's copy is
|
||||
* actually on screen: it needs an anchor position, a visible document, and one
|
||||
* presented frame. Closing counts as seen because the user acted on it.
|
||||
*/
|
||||
import { createRef } from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import type { AccountBonusOrderId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { AccountNoticeCard } from '../src/client/AccountNotice.tsx'
|
||||
|
||||
const disconnect = vi.fn()
|
||||
let visibility: 'visible' | 'hidden' = 'visible'
|
||||
/** Frame callbacks the card scheduled, in order. */
|
||||
let frames: (() => void)[] = []
|
||||
/** @param value - visibility the card observes. */
|
||||
function setVisibility(value: 'visible' | 'hidden'): void {
|
||||
visibility = value
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
visibility = 'visible'
|
||||
frames = []
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility })
|
||||
// jsdom drives requestAnimationFrame from its own clock, so the test presents frames explicitly.
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => { frames.push(callback); return frames.length })
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); disconnect.mockClear() })
|
||||
|
||||
function mount(id: string, onShown = vi.fn(), onDismiss = vi.fn()) {
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect = disconnect })
|
||||
const anchor = createRef<HTMLDivElement>()
|
||||
const view = render(<>
|
||||
<div ref={anchor} style={{ position: 'absolute', left: 40, top: 200 }} />
|
||||
<AccountNoticeCard key={id} anchor={anchor} title="Bonus credited" closeLabel="Close"
|
||||
notice={{ orderId: id as AccountBonusOrderId, message: 'Server copy 5.00', expiresAt: '2099-01-01T00:00:00Z' }}
|
||||
onShown={onShown} onDismiss={onDismiss} />
|
||||
</>)
|
||||
return { view, onShown, onDismiss }
|
||||
}
|
||||
|
||||
/**
|
||||
* Present every scheduled frame, then run the following task that reports the display.
|
||||
* @param after - hook between the frame and its follow-up task.
|
||||
*/
|
||||
async function presentFrames(after?: () => void): Promise<void> {
|
||||
await act(async () => {
|
||||
for (const frame of frames.splice(0)) frame()
|
||||
after?.()
|
||||
await new Promise((resolve) => { setTimeout(resolve, 0) })
|
||||
})
|
||||
}
|
||||
|
||||
it('renders server copy without moving focus and reports display after a presented frame', async () => {
|
||||
const initialFocus = document.activeElement
|
||||
const { onShown, onDismiss } = mount('order-1')
|
||||
expect(screen.getByRole('status').textContent).toBe('Bonus creditedServer copy 5.00')
|
||||
expect(document.activeElement).toBe(initialFocus)
|
||||
await expect(`${screen.getByRole('status').textContent}\n`).toMatchFileSnapshot('./expected/notice.txt')
|
||||
expect(onShown).not.toHaveBeenCalled()
|
||||
await presentFrames()
|
||||
expect(onShown).toHaveBeenCalledWith('order-1')
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('defers the display report until the document is visible', async () => {
|
||||
setVisibility('hidden')
|
||||
const { onShown } = mount('order-1')
|
||||
await presentFrames()
|
||||
expect(onShown).not.toHaveBeenCalled()
|
||||
setVisibility('visible')
|
||||
await presentFrames()
|
||||
expect(onShown).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('waits for another visible frame when the document hides before the follow-up task', async () => {
|
||||
const onShown = vi.fn()
|
||||
mount('order-1', onShown)
|
||||
// The frame presents, then the window hides before the after-paint task runs.
|
||||
await presentFrames(() => { setVisibility('hidden') })
|
||||
expect(onShown).not.toHaveBeenCalled()
|
||||
setVisibility('visible')
|
||||
expect(onShown).not.toHaveBeenCalled()
|
||||
await presentFrames()
|
||||
expect(onShown).toHaveBeenCalledExactlyOnceWith('order-1')
|
||||
})
|
||||
|
||||
it('reports dismissal so closing the card counts as seen', async () => {
|
||||
const { onShown, onDismiss } = mount('order-1')
|
||||
await presentFrames()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onDismiss).toHaveBeenCalledWith('order-1')
|
||||
expect(onShown).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports neither display nor dismissal when unmounted before its frame', async () => {
|
||||
const onShown = vi.fn()
|
||||
const onDismiss = vi.fn()
|
||||
const { view } = mount('order-1', onShown, onDismiss)
|
||||
view.unmount()
|
||||
await presentFrames()
|
||||
expect(onShown).not.toHaveBeenCalled()
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not render an award that expired before the card mounted', async () => {
|
||||
const onShown = vi.fn()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2100-01-01T00:00:00Z'))
|
||||
mount('order-1', onShown)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
await presentFrames()
|
||||
expect(onShown).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -30,6 +30,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte
|
||||
useTheme: <T,>(select: (snapshot: ThemeSnapshot) => T) => select(theme),
|
||||
start: vi.fn(async () => {}), cancel: vi.fn(async () => {}), signOut: vi.fn(async () => {}),
|
||||
refresh: vi.fn(async () => {}), contactUs: vi.fn(), t: makeTranslate(en),
|
||||
refreshBonus: vi.fn(async () => {}), bonusNoticeShown: vi.fn(), bonusNoticeDismissed: vi.fn(),
|
||||
}
|
||||
const view = render(<AccountOnboarding {...props} />)
|
||||
expect(props.setOnboarding).toHaveBeenCalledExactlyOnceWith(true)
|
||||
|
||||
@@ -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-general/README.md
|
||||
README.md: 36d3fd7b260f95cffb67977e5a6f37852bf195aa
|
||||
README.zh.md: 9df8fe8bc3a39fceeccfc8b08a8e18cc5eeaf752
|
||||
README.md: 1640a4644c809bafa76057b11e7644d5c79c9b6c
|
||||
README.zh.md: ff61534df6361f69b5f811f8cc2ce08c28afab83
|
||||
@@ -54,7 +54,7 @@ The onboarding ledger projects in ascending order and mounts exactly one step at
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
The shell declares settings.launcher for an account-owned sidebar menu and retains the Settings button as its fallback. Closing the dialog returns focus to the active launcher.
|
||||
The shell declares settings.launcher for an account-owned sidebar menu and retains the Settings button as its fallback. The launcher receives settingsOpen so it can defer notices until the dialog closes. Closing the dialog returns focus to the active launcher.
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
@@ -54,7 +54,7 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
外壳声明 settings.launcher,供账号功能提供侧边栏菜单,并以设置按钮作为回退。关闭对话框后,焦点返回当前入口。
|
||||
外壳声明 settings.launcher,供账号功能提供侧边栏菜单,并以设置按钮作为回退。入口接收 settingsOpen,以便在对话框关闭后再显示通知。关闭对话框后,焦点返回当前入口。
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
@@ -221,7 +221,7 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<div ref={triggerRow} className={clsx(css.triggerRow, !wide && css.railRow)}>
|
||||
{renderSlot('settings.launcher', { wide, openSettings: () => { setOpen(true) }, openOnboarding: (id) => { setOpen(false); setRequestedOnboarding(id) } }, { fallback: <button
|
||||
{renderSlot('settings.launcher', { wide, settingsOpen: open, openSettings: () => { setOpen(true) }, openOnboarding: (id) => { setOpen(false); setRequestedOnboarding(id) } }, { fallback: <button
|
||||
ref={triggerButton}
|
||||
type="button"
|
||||
className={clsx(css.trigger, !wide && css.rail)}
|
||||
|
||||
@@ -449,7 +449,11 @@ it('explicitly reopens one onboarding editor during an existing session', () =>
|
||||
it('opens Account from the contributed sidebar launcher', () => {
|
||||
const { renderSlot } = mount({ rows: [{ id: 'account', order: -10, label: 'Account' }] })
|
||||
const launcher = renderSlot.mock.calls.find(call => call[0] === 'settings.launcher')!
|
||||
expect(launcher[1]).toMatchObject({ settingsOpen: false })
|
||||
act(() => { (launcher[1] as { openSettings: () => void }).openSettings() })
|
||||
expect(screen.getByTestId('section-account')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Account' }).querySelector('svg')).not.toBeNull()
|
||||
expect(renderSlot.mock.calls.filter(call => call[0] === 'settings.launcher').at(-1)?.[1]).toMatchObject({ settingsOpen: true })
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(renderSlot.mock.calls.filter(call => call[0] === 'settings.launcher').at(-1)?.[1]).toMatchObject({ settingsOpen: false })
|
||||
})
|
||||
@@ -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/README.md
|
||||
README.md: 67b094993acbff92917594aafd0731818b5daa1b
|
||||
README.zh.md: eec972705e9113594b06aca0ee5b8b149b5c62ca
|
||||
README.md: 01e18838ce52fe66a5d2cf8a0eac1ba5eb76b7b9
|
||||
README.zh.md: 4cae826395f5e4a33bac0e149fb011ca5958533e
|
||||
@@ -50,7 +50,7 @@ A committed write folds its answer into the shared mirror. Refused writes refres
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
The optional settings.launcher contribution receives wide and openSettings to supply a sidebar account menu; the shell retains its plain Settings trigger when no launcher is registered.
|
||||
The optional settings.launcher contribution receives wide, settingsOpen, and openSettings to supply a sidebar account menu; the shell retains its plain Settings trigger when no launcher is registered.
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
@@ -50,7 +50,7 @@ kind: "package-reference"
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
可选的 settings.launcher 贡献接收 wide 和 openSettings,以提供侧边栏账号菜单;未注册时,外壳保留普通设置按钮。
|
||||
可选的 settings.launcher 贡献接收 wide、settingsOpen 和 openSettings,以提供侧边栏账号菜单;未注册时,外壳保留普通设置按钮。
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
@@ -144,6 +144,8 @@ export interface SettingsOnboardingOwnerProps {
|
||||
export interface SettingsLauncherOwnerProps {
|
||||
/** Whether the sidebar shows labels. */
|
||||
wide: boolean
|
||||
/** Whether the settings dialog covers the sidebar; defer launcher notices until it closes. */
|
||||
settingsOpen: boolean
|
||||
/** Open the settings panel. */
|
||||
openSettings: () => void
|
||||
/** @param id - registered onboarding editor to open explicitly. */
|
||||
|
||||
@@ -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: 152ed7ef797a3a534745ce7e27ce80a352ef4b40
|
||||
README.zh.md: 0650fddc2a6729739b3b909ea4bb177d7dd20fb4
|
||||
README.md: daac03d519c7b80faaf7634e814f769a7bcdd3b9
|
||||
README.zh.md: ca2fd1802c235aa4717c156739ce87dcc5e1a8e4
|
||||
@@ -30,6 +30,8 @@ The profile projection maps `id_profile.picture` to `avatarUrl`, using null when
|
||||
|
||||
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.
|
||||
|
||||
getUnnotifiedBonuses reads GET /api/v0/users/get_unnotified_bonuses and ackBonusNotified posts POST /api/v0/users/ack_bonus_notified?order_id=… on the same origin, each sending the five client headers built from the caller's metadata. Both resolve the account identity for the captured grant first, so a signed-out or switched account yields null or false instead of acknowledging another account's bonus, and a read publishes only after the whole query settled under one credential lifetime. The Host forwards the bonus list in Platform order, maps each msg to message, and discards nothing else; malformed payloads, HTTP failures and business failures throw.
|
||||
|
||||
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 `login_source` 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.
|
||||
|
||||
Platform URLs have one configuration owner: `platformOrigin`. Authorization requests, browser validation, completion redirects, usage, and top-up destinations all use that origin. Set it in a private `$DSH_HOME/cordis.patch.yml`; deployment addresses do not belong in source control. HTTPS is required except for loopback HTTP with `allowLoopbackHttp: true`. Model and file token destinations use the separate `inferenceOrigin` configuration.
|
||||
|
||||
@@ -30,6 +30,8 @@ getPlatformSession 仅在已存授权的 issuer 与 platformOrigin 一致时导
|
||||
|
||||
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 与其他字段。赠送钱包不计入充值余额。凭证变化和销毁会使进行中的查询失效。
|
||||
|
||||
getUnnotifiedBonuses 读取同一来源上的 GET /api/v0/users/get_unnotified_bonuses,ackBonusNotified 向 POST /api/v0/users/ack_bonus_notified?order_id=… 发请求,两者都按调用方元数据组装这五个客户端请求头。二者都先为捕获到的授权解析账号身份,因此退登或切号时返回 null 或 false,而不会确认另一个账号的赠金;一次读取只有在同一凭证生命周期内全部结算后才发布。Host 按 Platform 返回顺序转发赠金列表,把每条的 msg 映射为 message,其余字段不额外丢弃;格式错误、HTTP 失败和业务失败都会抛出。
|
||||
|
||||
在插件行配置 platformOrigin、allowLoopbackHttp、requestTimeoutMs 和 attemptTimeoutMs。HTTP 仅用于显式启用的本机开发。提供者先在现有 Host webServer 注册 /oauth/callback,再调用 auth_init;校验 state、使用 S256 PKCE 授权码兑换一次,并在跳转 auth_exchange.biz_data.authorized_url 前提交授权记录。浏览器地址默认要求匹配配置的平台来源,并始终要求固定的 /dsh/authorize 或 /dsh/authorized 路径。完成页地址将 `login_source` 设为发起登录的客户端类型(`web` 或 `desktop`),并保留平台返回的其他查询参数。设备标识是独立的随机 UUID 记录,由使用同一凭证存储的进程共享;device_model 报告操作系统和架构。
|
||||
|
||||
开放平台 URL 统一由 `platformOrigin` 配置管理。授权请求、浏览器地址校验、完成跳转、用量和充值入口都使用该来源。将配置放在私有的 `$DSH_HOME/cordis.patch.yml`,部署地址不进入源码仓库。默认要求 HTTPS;只有本机 HTTP 可通过 `allowLoopbackHttp: true` 显式启用。模型及文件 token 的目标由独立的 `inferenceOrigin` 配置管理。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** 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'
|
||||
import type { AccountBonusNotification, AccountBonusOrderId, AccountDetails, AccountProfile, AccountUserId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { PlatformAuthError, requestAccount, requestBonusNotified, requestUnnotifiedBonuses } from './protocol.ts'
|
||||
|
||||
const user = z.object({
|
||||
id: z.string().nullish(),
|
||||
@@ -12,6 +12,12 @@ 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) })
|
||||
const bonus = z.object({
|
||||
order_id: z.uuid(), campaign: z.string(), amount: z.string().regex(/^-?\d+(?:\.\d+)?$/),
|
||||
currency: z.enum(['CNY', 'USD']), granted_at: z.string(), expires_at: z.string(), msg: z.string(),
|
||||
})
|
||||
// The shared response reader already unwraps biz_data, so the payload is the bonus list itself.
|
||||
const unnotified = z.array(bonus)
|
||||
|
||||
/** Project Platform user data without retaining credentials or unneeded fields.
|
||||
* @param value - current or exchange user response.
|
||||
@@ -55,3 +61,35 @@ export async function readAccountDetail<K extends keyof AccountDetails>(field: K
|
||||
try { return query.parse(await requestAccount(origin, query.path, token, signal, headers)) }
|
||||
catch { return { status: 'failed' } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the unnotified bonus list for one captured credential.
|
||||
* @param origin - configured origin matching the stored grant issuer.
|
||||
* @param token - captured account grant; the caller has already bound it to the account.
|
||||
* @param signal - credential lifetime and request timeout.
|
||||
* @param headers - deployment and client identity headers for the configured origin.
|
||||
* @returns notifications in Platform order; a malformed payload is a protocol failure.
|
||||
*/
|
||||
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) throw new PlatformAuthError('protocol')
|
||||
return parsed.data.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,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Record one displayed bonus as notified with the credential it was read under.
|
||||
* @param origin - configured origin matching the stored grant issuer.
|
||||
* @param token - captured account grant; the caller has already bound it to the account.
|
||||
* @param orderId - granted bonus order the user saw.
|
||||
* @param signal - credential lifetime and request timeout.
|
||||
* @param headers - deployment and client identity headers for the configured origin.
|
||||
* @returns after Platform records the acknowledgement; HTTP and business failures throw.
|
||||
*/
|
||||
export async function sendBonusNotified(origin: string, token: string, orderId: AccountBonusOrderId,
|
||||
signal: AbortSignal, headers: Record<string, string>): Promise<void> {
|
||||
await requestBonusNotified(origin, token, orderId, signal, headers)
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import { finished } from 'node:stream/promises'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import { z } from 'zod'
|
||||
import { DeepSeekAccount, mergePlatformCookies, platformClientHeaders, type AccountClientMetadata, type AccountDetails, type AccountView, type PlatformSession, type SignInAttemptId, type SignInAttemptView } from '@deepseek-ai/dsh-deepseek-account'
|
||||
import { DeepSeekAccount, mergePlatformCookies, platformClientHeaders, type AccountBonusBatch, type AccountBonusOrderId, type AccountClientMetadata, type AccountDetails, type AccountUserId, type AccountView, type PlatformSession, type SignInAttemptId, type SignInAttemptView } from '@deepseek-ai/dsh-deepseek-account'
|
||||
import { credentialKey } from '@deepseek-ai/dsh-credentials'
|
||||
import type { AuthorizationSession } from '@deepseek-ai/dsh-authorization'
|
||||
import { profile, readAccountDetail } from './details.ts'
|
||||
import { profile, readAccountDetail, readUnnotifiedBonuses, sendBonusNotified } from './details.ts'
|
||||
import { revokeAccount, type LogoutRetryPolicy } from './logout.ts'
|
||||
import { PlatformAuthError, platformHeaders, platformOrigin, browserUrl, requestPlatform, initialization, exchange, loginOrigin } from './protocol.ts'
|
||||
|
||||
@@ -191,9 +191,60 @@ export class PlatformAccount extends DeepSeekAccount {
|
||||
return this.getDetail('balance', this.detailHeaders(client))
|
||||
}
|
||||
|
||||
private async getDetail<K extends keyof AccountDetails>(field: K, headers: Record<string, string>): Promise<AccountDetails[K] | null> {
|
||||
override async getUnnotifiedBonuses(client: AccountClientMetadata): Promise<AccountBonusBatch | null> {
|
||||
const lifetime = this.detailsLifetime
|
||||
const headers = this.detailHeaders(client)
|
||||
const stored = await this.readCurrentGrant(lifetime)
|
||||
if (stored === null) return null
|
||||
const accountId = await this.currentAccountId(lifetime, stored, headers)
|
||||
if (accountId === null) return null
|
||||
const bonuses = await readUnnotifiedBonuses(this.origin, stored.token,
|
||||
AbortSignal.any([lifetime.signal, AbortSignal.timeout(this.requestTimeout)]), headers)
|
||||
return { accountId, bonuses }
|
||||
}
|
||||
|
||||
override async ackBonusNotified(accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata): Promise<boolean> {
|
||||
const lifetime = this.detailsLifetime
|
||||
const headers = this.detailHeaders(client)
|
||||
const stored = await this.readCurrentGrant(lifetime)
|
||||
if (stored === null) return false
|
||||
const current = await this.currentAccountId(lifetime, stored, headers)
|
||||
if (current === null || current !== accountId) return false
|
||||
await sendBonusNotified(this.origin, stored.token, orderId,
|
||||
AbortSignal.any([lifetime.signal, AbortSignal.timeout(this.requestTimeout)]), headers)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the profile identity bound to one captured grant, within that grant's credential lifetime.
|
||||
* @param lifetime - credential lifetime the grant was captured under; a change discards the result.
|
||||
* @param stored - captured grant; the identity query never re-reads a newer credential.
|
||||
* @param headers - client identity headers of the calling operation.
|
||||
* @returns the account identity, or null while signed out or after the credential changed.
|
||||
*/
|
||||
private async currentAccountId(lifetime: AbortController, stored: z.infer<typeof grant>,
|
||||
headers: Record<string, string>): Promise<AccountUserId | null> {
|
||||
const details = await this.getDetail('profile', headers, { lifetime, stored })
|
||||
if (details === null) return null
|
||||
if (details.status === 'failed') {
|
||||
// A failed query keeps the same-credential cache, matching getProfile; without either, identity is unknown.
|
||||
const cached = this.lastProfile?.value.id
|
||||
if (cached === undefined) throw new PlatformAuthError('network')
|
||||
if (cached === null) throw new PlatformAuthError('protocol')
|
||||
return cached
|
||||
}
|
||||
this.lastProfile = details
|
||||
if (details.value.id === null) {
|
||||
// Notifications are per account; without a stable Platform id they cannot be isolated.
|
||||
throw new PlatformAuthError('protocol')
|
||||
}
|
||||
return details.value.id
|
||||
}
|
||||
|
||||
private async getDetail<K extends keyof AccountDetails>(field: K, headers: Record<string, string>,
|
||||
captured?: { lifetime: AbortController; stored: z.infer<typeof grant> }): Promise<AccountDetails[K] | null> {
|
||||
const lifetime = captured?.lifetime ?? this.detailsLifetime
|
||||
const stored = captured?.stored ?? await this.readCurrentGrant(lifetime)
|
||||
if (stored === null || lifetime.signal.aborted) return null
|
||||
if (field === 'profile' && this.attempt?.initialProfile?.token === stored.token) {
|
||||
const initial = this.attempt.initialProfile.value
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Validated platform HTTP messages and restricted browser destinations. */
|
||||
import { z } from 'zod'
|
||||
import type { AccountBonusOrderId } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
|
||||
/** Protocol errors expose a stable code, never a response body or authorization URL. */
|
||||
export class PlatformAuthError extends Error {
|
||||
@@ -103,7 +104,41 @@ export async function requestPlatform(origin: string, method: string, body: unkn
|
||||
export function requestAccount(origin: string, path: '/auth-api/v0/users/current' | '/api/v0/users/get_user_summary',
|
||||
token: string,
|
||||
signal: AbortSignal, headers: Record<string, string>): Promise<unknown> {
|
||||
return platformRequest(`${origin}${path}`, { method: 'GET', headers: { ...headers, 'x-dsh-auth-token': token } }, signal)
|
||||
return platformRequest(`${origin}${path}`, { method: 'GET', headers: accountHeaders(headers, token) }, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the granted bonuses Platform has not yet recorded as displayed.
|
||||
* @param origin - configured origin matching the grant issuer.
|
||||
* @param token - stored account grant.
|
||||
* @param signal - credential lifetime and request timeout.
|
||||
* @param headers - deployment and client identity headers for this origin.
|
||||
* @returns successful business payload holding the unnotified bonus list.
|
||||
*/
|
||||
export function requestUnnotifiedBonuses(origin: string, token: string,
|
||||
signal: AbortSignal, headers: Record<string, string>): Promise<unknown> {
|
||||
return platformRequest(`${origin}/api/v0/users/get_unnotified_bonuses`,
|
||||
{ method: 'GET', headers: accountHeaders(headers, token) }, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge an actually displayed bonus to Platform.
|
||||
* @param origin - configured origin matching the grant issuer.
|
||||
* @param token - stored account grant.
|
||||
* @param orderId - granted bonus order the user saw.
|
||||
* @param signal - credential lifetime and request timeout.
|
||||
* @param headers - deployment and client identity headers for this origin.
|
||||
* @returns successful business payload, which carries no data.
|
||||
*/
|
||||
export function requestBonusNotified(origin: string, token: string, orderId: AccountBonusOrderId,
|
||||
signal: AbortSignal, headers: Record<string, string>): Promise<unknown> {
|
||||
return platformRequest(`${origin}/api/v0/users/ack_bonus_notified?order_id=${encodeURIComponent(orderId)}`,
|
||||
{ method: 'POST', headers: accountHeaders(headers, token) }, signal)
|
||||
}
|
||||
|
||||
// The grant is provider-owned; deployment requestHeaders cannot override it or the client identity.
|
||||
function accountHeaders(headers: Record<string, string>, token: string): Record<string, string> {
|
||||
return { ...headers, 'x-dsh-auth-token': token }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/** Bonus notification reads and acknowledgements over the real Host HTTP path. */
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AuthorizationService from '@deepseek-ai/dsh-authorization'
|
||||
import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local'
|
||||
import { credentialKey } from '@deepseek-ai/dsh-credentials'
|
||||
import type { AccountBonusBatch, AccountBonusOrderId, AccountClientMetadata, AccountUserId } from '@deepseek-ai/dsh-deepseek-account'
|
||||
import { PlatformAccount } from '../src/index.ts'
|
||||
|
||||
const cleanups: Array<() => Promise<unknown>> = []
|
||||
afterEach(async () => { while (cleanups.length) await cleanups.pop()!() })
|
||||
|
||||
const ORDER = '4c1b0000-0000-4000-8000-000000000000'
|
||||
const SECOND_ORDER = '5d2c0000-0000-4000-8000-000000000000'
|
||||
const USER = 'test-user'
|
||||
|
||||
/** Client identity supplied with each account operation; only the locale's primary subtag selects the wire locale. */
|
||||
const clientMetadata = (locale = 'en-US'): AccountClientMetadata => ({ version: '1.2.3', locale, timezoneOffsetSeconds: 28_800 })
|
||||
|
||||
interface Recorded {
|
||||
readonly method: string | undefined
|
||||
readonly url: string | undefined
|
||||
readonly token: string | undefined
|
||||
readonly locale: string | undefined
|
||||
readonly bundleId: string | undefined
|
||||
readonly platform: string | undefined
|
||||
readonly version: string | undefined
|
||||
readonly timezoneOffset: string | undefined
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-bonus-'))
|
||||
cleanups.push(() => rm(home, { recursive: true, force: true }))
|
||||
const requests: Recorded[] = []
|
||||
let profileId: string | null = USER
|
||||
let profileBizCode = 0
|
||||
let profileStatus = 200
|
||||
let bonusBizCode = 0
|
||||
let bonusStatus = 200
|
||||
let bonuses: unknown = [
|
||||
{ order_id: ORDER, campaign: 'dsh_login_bonus', amount: '5.00', currency: 'CNY',
|
||||
granted_at: '2026-09-21T12:00:00Z', expires_at: '2026-10-21T12:00:00Z', msg: '已赠送您 5.00 元 DSH 体验赠金。' },
|
||||
{ order_id: SECOND_ORDER, campaign: 'dsh_login_bonus', amount: '1.50', currency: 'CNY',
|
||||
granted_at: '2026-09-20T12:00:00Z', expires_at: '2026-10-20T12:00:00Z', msg: '已赠送您 1.50 元 DSH 体验赠金。' },
|
||||
]
|
||||
let ackBizCode = 0
|
||||
let ackStatus = 200
|
||||
let beforeProfileResponse: (() => Promise<void>) | undefined
|
||||
let origin = ''
|
||||
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
expect(req.headers.authorization).toBeUndefined()
|
||||
requests.push({ method: req.method, url: req.url, token: req.headers['x-dsh-auth-token'] as string | undefined,
|
||||
locale: req.headers['x-client-locale'] as string | undefined,
|
||||
bundleId: req.headers['x-client-bundle-id'] as string | undefined,
|
||||
platform: req.headers['x-client-platform'] as string | undefined,
|
||||
version: req.headers['x-client-version'] as string | undefined,
|
||||
timezoneOffset: req.headers['x-client-timezone-offset'] as string | undefined })
|
||||
res.setHeader('content-type', 'application/json')
|
||||
if (req.url === '/auth-api/v0/users/current') {
|
||||
await beforeProfileResponse?.()
|
||||
res.writeHead(profileStatus).end(JSON.stringify({ code: profileStatus === 200 ? 0 : 1, data: { biz_code: profileBizCode,
|
||||
biz_data: { id: profileId, email: 't***@example.invalid', id_profile: { name: 'Test', picture: null } } } }))
|
||||
return
|
||||
}
|
||||
if (req.url === '/api/v0/users/get_unnotified_bonuses') {
|
||||
res.writeHead(bonusStatus).end(JSON.stringify({ code: bonusStatus === 200 ? 0 : 1,
|
||||
data: { biz_code: bonusBizCode, biz_data: bonuses } }))
|
||||
return
|
||||
}
|
||||
if (req.url?.startsWith('/api/v0/users/ack_bonus_notified')) {
|
||||
res.writeHead(ackStatus).end(JSON.stringify(ackStatus === 200
|
||||
? { code: ackBizCode === 0 ? 0 : 1, msg: ackBizCode === 0 ? '' : 'BONUS_ORDER_NOT_FOUND',
|
||||
data: ackBizCode === 0 ? { biz_code: 0, biz_msg: '', biz_data: null } : null }
|
||||
: { code: 1, msg: 'INTERNAL', data: null }))
|
||||
return
|
||||
}
|
||||
res.writeHead(404).end('{}')
|
||||
}
|
||||
const server = createServer((req, res) => { void handle(req, res).catch(() => { res.writeHead(500).end('{}') }) })
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('missing listener')
|
||||
origin = `http://127.0.0.1:${address.port}`
|
||||
cleanups.push(async () => {
|
||||
await new Promise<void>((resolve) => { server.close(() => { resolve() }); server.closeAllConnections() })
|
||||
})
|
||||
const ctx = new Context()
|
||||
const credentials = ctx.plugin(LocalCredentialProvider, { path: join(home, 'credentials.yaml'), watch: false })
|
||||
await credentials
|
||||
const authorization = ctx.plugin(AuthorizationService)
|
||||
await authorization
|
||||
// The composed client identity is this fixture's choice; `null` is the web profile's documented setting.
|
||||
const provider = ctx.plugin(PlatformAccount, {
|
||||
platformOrigin: origin, allowLoopbackHttp: true, requestTimeoutMs: 5_000, desktopPlatform: null,
|
||||
})
|
||||
await provider
|
||||
cleanups.push(async () => { await provider.dispose(); await authorization.dispose(); await credentials.dispose() })
|
||||
const key = credentialKey('deepseek-account-platform', 'default')
|
||||
return {
|
||||
account: ctx.deepseekAccount, requests, key,
|
||||
grant: (token: string) => ctx.credentials.modifyRecord(key, () => Promise.resolve({
|
||||
kind: 'grant', payload: { version: 1, token, issuer: origin },
|
||||
})),
|
||||
signOut: () => ctx.credentials.deleteRecord(key),
|
||||
profileId: (value: string | null) => { profileId = value },
|
||||
failProfile: (bizCode = 17, status = 200) => { profileBizCode = bizCode; profileStatus = status },
|
||||
bonuses: (value: unknown) => { bonuses = value },
|
||||
failBonuses: (bizCode = 17, status = 200) => { bonusBizCode = bizCode; bonusStatus = status },
|
||||
failAck: (bizCode = 0, status = 200) => { ackBizCode = bizCode; ackStatus = status },
|
||||
onProfile: (hook: () => Promise<void>) => { beforeProfileResponse = hook },
|
||||
}
|
||||
}
|
||||
|
||||
it('sends one call\'s client identity headers on both requests of a bonus chain', async () => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
const client = { version: '3.0.0', locale: 'zh-TW', timezoneOffsetSeconds: -18_000 }
|
||||
await f.account.getUnnotifiedBonuses(client)
|
||||
await f.account.ackBonusNotified(USER as AccountUserId, ORDER as AccountBonusOrderId, client)
|
||||
expect(f.requests.map(request => [
|
||||
request.url, request.bundleId, request.platform, request.version, request.locale, request.timezoneOffset,
|
||||
])).toEqual([
|
||||
['/auth-api/v0/users/current', '', 'web', '3.0.0', 'zh_CN', '-18000'],
|
||||
['/api/v0/users/get_unnotified_bonuses', '', 'web', '3.0.0', 'zh_CN', '-18000'],
|
||||
['/auth-api/v0/users/current', '', 'web', '3.0.0', 'zh_CN', '-18000'],
|
||||
[`/api/v0/users/ack_bonus_notified?order_id=${ORDER}`, '', 'web', '3.0.0', 'zh_CN', '-18000'],
|
||||
])
|
||||
})
|
||||
|
||||
it('reads unnotified bonuses with the platform origin, grant header, and locale header', async () => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
const batch = await f.account.getUnnotifiedBonuses(clientMetadata('zh-CN'))
|
||||
expect(batch).toEqual<AccountBonusBatch>({
|
||||
accountId: USER as AccountUserId,
|
||||
bonuses: [
|
||||
{ orderId: ORDER as AccountBonusOrderId, campaign: 'dsh_login_bonus', amount: '5.00', currency: 'CNY',
|
||||
grantedAt: '2026-09-21T12:00:00Z', expiresAt: '2026-10-21T12:00:00Z', message: '已赠送您 5.00 元 DSH 体验赠金。' },
|
||||
{ orderId: SECOND_ORDER as AccountBonusOrderId, campaign: 'dsh_login_bonus', amount: '1.50', currency: 'CNY',
|
||||
grantedAt: '2026-09-20T12:00:00Z', expiresAt: '2026-10-20T12:00:00Z', message: '已赠送您 1.50 元 DSH 体验赠金。' },
|
||||
],
|
||||
})
|
||||
expect(f.requests).toEqual([
|
||||
{ method: 'GET', url: '/auth-api/v0/users/current', token: 'test-account-token', locale: 'zh_CN',
|
||||
bundleId: '', platform: 'web', version: '1.2.3', timezoneOffset: '28800' },
|
||||
{ method: 'GET', url: '/api/v0/users/get_unnotified_bonuses', token: 'test-account-token', locale: 'zh_CN',
|
||||
bundleId: '', platform: 'web', version: '1.2.3', timezoneOffset: '28800' },
|
||||
])
|
||||
})
|
||||
|
||||
it('selects en_US copy without a language query parameter', async () => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
await f.account.getUnnotifiedBonuses(clientMetadata('en-US'))
|
||||
expect(f.requests.map(request => request.locale)).toEqual(['en_US', 'en_US'])
|
||||
expect(f.requests.map(request => request.url)).not.toContain('/api/v0/users/get_unnotified_bonuses?language=en')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-UUID order id', [{ order_id: 'order', campaign: 'c', amount: '5.00', currency: 'CNY', granted_at: 'a', expires_at: 'b', msg: 'm' }]],
|
||||
['a non-decimal amount', [{ order_id: ORDER, campaign: 'c', amount: 'five', currency: 'CNY', granted_at: 'a', expires_at: 'b', msg: 'm' }]],
|
||||
['an unknown currency', [{ order_id: ORDER, campaign: 'c', amount: '5.00', currency: 'EUR', granted_at: 'a', expires_at: 'b', msg: 'm' }]],
|
||||
['a missing message', [{ order_id: ORDER, campaign: 'c', amount: '5.00', currency: 'CNY', granted_at: 'a', expires_at: 'b' }]],
|
||||
['a payload that is not a list', { bonuses: [] }],
|
||||
])('rejects a malformed unnotified payload: %s', async (_name, payload) => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
f.bonuses(payload)
|
||||
await expect(f.account.getUnnotifiedBonuses(clientMetadata('en'))).rejects.toThrow('account: protocol')
|
||||
})
|
||||
|
||||
it('acknowledges a displayed bonus with the grant, locale, and encoded order id', async () => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
expect(await f.account.ackBonusNotified(USER as AccountUserId, ORDER as AccountBonusOrderId, clientMetadata('zh-CN'))).toBe(true)
|
||||
expect(f.requests).toEqual([
|
||||
{ method: 'GET', url: '/auth-api/v0/users/current', token: 'test-account-token', locale: 'zh_CN',
|
||||
bundleId: '', platform: 'web', version: '1.2.3', timezoneOffset: '28800' },
|
||||
{ method: 'POST', url: `/api/v0/users/ack_bonus_notified?order_id=${ORDER}`, token: 'test-account-token', locale: 'zh_CN',
|
||||
bundleId: '', platform: 'web', version: '1.2.3', timezoneOffset: '28800' },
|
||||
])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a business failure', 1, 200, 'account: protocol'],
|
||||
['an HTTP failure', 0, 500, 'account: network'],
|
||||
])('surfaces %s as a retryable acknowledgement failure', async (_name, bizCode, status, message) => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
f.failAck(bizCode, status)
|
||||
await expect(f.account.ackBonusNotified(USER as AccountUserId, ORDER as AccountBonusOrderId, clientMetadata('en'))).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('does not acknowledge a notification from another account', async () => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
expect(await f.account.ackBonusNotified('other-user' as AccountUserId, ORDER as AccountBonusOrderId, clientMetadata('en'))).toBe(false)
|
||||
expect(f.requests.map(request => request.url)).toEqual(['/auth-api/v0/users/current'])
|
||||
})
|
||||
|
||||
it('returns signed-out outcomes without any request', async () => {
|
||||
const f = await fixture()
|
||||
expect(await f.account.getUnnotifiedBonuses(clientMetadata('en'))).toBeNull()
|
||||
expect(await f.account.ackBonusNotified(USER as AccountUserId, ORDER as AccountBonusOrderId, clientMetadata('en'))).toBe(false)
|
||||
expect(f.requests).toEqual([])
|
||||
})
|
||||
|
||||
it('discards a read that a credential change invalidated before it published', async () => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
f.onProfile(async () => { await f.grant('rotated-token') })
|
||||
expect(await f.account.getUnnotifiedBonuses(clientMetadata('en'))).toBeNull()
|
||||
expect(f.requests.map(request => request.url)).toEqual(['/auth-api/v0/users/current'])
|
||||
})
|
||||
|
||||
it('reports an unknown identity as retryable and an absent id as a protocol failure', async () => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
f.failProfile()
|
||||
await expect(f.account.getUnnotifiedBonuses(clientMetadata('en'))).rejects.toThrow('account: network')
|
||||
const absent = await fixture()
|
||||
await absent.grant('test-account-token')
|
||||
absent.profileId(null)
|
||||
await expect(absent.account.getUnnotifiedBonuses(clientMetadata('en'))).rejects.toThrow('account: protocol')
|
||||
})
|
||||
|
||||
it('reuses a cached identity for the same credential after a failed query', async () => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
expect(await f.account.getProfile(clientMetadata())).toMatchObject({ status: 'ready' })
|
||||
f.failProfile()
|
||||
await expect(f.account.ackBonusNotified(USER as AccountUserId, ORDER as AccountBonusOrderId, clientMetadata('en'))).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a cached identity that carries no Platform id', async () => {
|
||||
const f = await fixture()
|
||||
await f.grant('test-account-token')
|
||||
f.profileId(null)
|
||||
expect(await f.account.getProfile(clientMetadata())).toMatchObject({ status: 'ready' })
|
||||
f.failProfile()
|
||||
await expect(f.account.ackBonusNotified(USER as AccountUserId, ORDER as AccountBonusOrderId, clientMetadata('en'))).rejects.toThrow('account: protocol')
|
||||
})
|
||||
@@ -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: a46301fa497271a0deca787a7d5e772cc365f067
|
||||
README.zh.md: d4b5ef538d5ca4a2f59848bcdc5b0ec94e9ed9b7
|
||||
README.md: 8ac10d5dfdf63331e172232f7b6aa10fba808d74
|
||||
README.zh.md: 816b3c26f28fe724b0c4aab664b8eabd8058edce
|
||||
@@ -11,6 +11,8 @@ getPlatformSession returns a Host-only origin/token snapshot for native Platform
|
||||
|
||||
`platformClientHeaders` builds the five Platform client headers for one call from its `AccountClientMetadata` and the composition's desktop platform: `x-client-bundle-id` is intentionally empty, `x-client-platform` is `web` unless the Desktop profile supplies `darwin` or `win32`, `x-client-version` is the calling build's version, `x-client-locale` reduces the active UI language to `zh_CN` or `en_US`, and `x-client-timezone-offset` is whole seconds east of UTC.
|
||||
|
||||
getUnnotifiedBonuses returns the granted bonuses Platform has not yet recorded as displayed, together with the account they belong to; ackBonusNotified records one bonus the user actually saw. The acknowledgement names that account, so a notification read under one account is never confirmed for another.
|
||||
|
||||
## Summary
|
||||
|
||||
Account consumers read stored login state, start or cancel a browser login, and sign out without editing API keys. Host model consumers resolve account credentials only for the provider-configured inference origin.
|
||||
|
||||
@@ -11,6 +11,8 @@ getPlatformSession 为原生 Platform 内嵌提供仅限 Host 的 origin/token
|
||||
|
||||
`platformClientHeaders` 依据单次调用的 `AccountClientMetadata` 与组合出的桌面平台生成五个 Platform 客户端请求头:`x-client-bundle-id` 有意保持为空字符串,`x-client-platform` 在没有 Desktop profile 提供 `darwin` 或 `win32` 时为 `web`,`x-client-version` 为调用方构建的版本,`x-client-locale` 将当前界面语言归约为 `zh_CN` 或 `en_US`,`x-client-timezone-offset` 为东为正的整秒偏移。
|
||||
|
||||
getUnnotifiedBonuses 返回平台尚未记录为已展示的赠金及其所属账号;ackBonusNotified 记录用户实际看到的那一笔赠金。确认请求携带该账号,因此在一个账号下读取的通知绝不会被确认到另一个账号。
|
||||
|
||||
## 概述
|
||||
|
||||
账号使用方可读取本地登录状态、发起或取消浏览器登录,并在保留 API Key 的情况下退出。Host 模型使用方仅能为提供者配置的推理来源解析账号凭证。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Account Service Definition shared by platform, API, and model consumers. */
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import type { AccountClientMetadata, AccountDetails, AccountView, SignInAttemptId } from './types.ts'
|
||||
export type { AccountClientMetadata, AccountDetails, AccountProfile, AccountWallet, AccountLinks, AccountView, SignInAttemptId, SignInAttemptView, SignInErrorCode } from './types.ts'
|
||||
import type { AccountBonusBatch, AccountBonusOrderId, AccountClientMetadata, AccountDetails, AccountUserId, AccountView, SignInAttemptId } from './types.ts'
|
||||
export type { AccountBonusBatch, AccountBonusNotification, AccountBonusOrderId, AccountClientMetadata, AccountDetails, AccountProfile, AccountUserId, AccountWallet, AccountLinks, AccountView, SignInAttemptId, SignInAttemptView, SignInErrorCode } from './types.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -40,6 +40,20 @@ export abstract class DeepSeekAccount extends Service {
|
||||
* @returns balance outcome, or null if signed out or the grant changed during the query.
|
||||
*/
|
||||
abstract getBalance(client: AccountClientMetadata): Promise<AccountDetails['balance'] | null>
|
||||
/**
|
||||
* Query the granted bonuses Platform has not yet recorded as displayed.
|
||||
* @param client - identity of the requesting UI for this call; its language selects the server-authored message.
|
||||
* @returns bonuses with their account, or null if signed out or the grant changed during the query.
|
||||
*/
|
||||
abstract getUnnotifiedBonuses(client: AccountClientMetadata): Promise<AccountBonusBatch | null>
|
||||
/**
|
||||
* Record one displayed bonus as notified for the account it belongs to.
|
||||
* @param accountId - account the notification was read for; a different current account is never acknowledged.
|
||||
* @param orderId - granted bonus order the user saw.
|
||||
* @param client - identity of the requesting UI for this call.
|
||||
* @returns true once Platform records the acknowledgement; false if signed out or the account changed.
|
||||
*/
|
||||
abstract ackBonusNotified(accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata): Promise<boolean>
|
||||
/**
|
||||
* Join an active attempt or start browser authorization.
|
||||
* @param client - identity of the requesting UI; a new attempt captures it, and joining retains the original attempt's identity.
|
||||
|
||||
@@ -54,3 +54,25 @@ export interface AccountDetails {
|
||||
readonly profile: { readonly status: 'ready'; readonly value: AccountProfile } | { readonly status: 'failed' }
|
||||
readonly balance: { readonly status: 'ready'; readonly value: readonly AccountWallet[]; readonly bonusWallets: readonly AccountWallet[] } | { readonly status: 'failed' }
|
||||
}
|
||||
|
||||
/** Identity of one granted bonus order; stable across devices and sign-ins. */
|
||||
export type AccountBonusOrderId = Branded<'AccountBonusOrderId'>
|
||||
/** One granted bonus Platform has not yet recorded as displayed; copy is server-authored. */
|
||||
export interface AccountBonusNotification {
|
||||
readonly orderId: AccountBonusOrderId
|
||||
readonly campaign: string
|
||||
/** Granted decimal amount, preserving server precision; never the remaining balance. */
|
||||
readonly amount: string
|
||||
readonly currency: 'CNY' | 'USD'
|
||||
/** Grant time as supplied by Platform. */
|
||||
readonly grantedAt: string
|
||||
/** Expiry as supplied by Platform. */
|
||||
readonly expiresAt: string
|
||||
/** Server-localized plain text ready for display. */
|
||||
readonly message: string
|
||||
}
|
||||
/** Unnotified bonuses with the account they belong to, in Platform order. */
|
||||
export interface AccountBonusBatch {
|
||||
readonly accountId: AccountUserId
|
||||
readonly bonuses: readonly AccountBonusNotification[]
|
||||
}
|
||||
@@ -2402,7 +2402,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
doc: 'Optional sidebar account launcher; opens the shell-owned settings panel.',
|
||||
registerOptions: [],
|
||||
ownerProps: [
|
||||
'/** Sidebar launcher geometry and settings navigation. */\nexport interface SettingsLauncherOwnerProps {\n /** Whether the sidebar shows labels. */\n wide: boolean\n /** Open the settings panel. */\n openSettings: () => void\n /** @param id - registered onboarding editor to open explicitly. */\n openOnboarding: (id: string) => void\n}',
|
||||
'/** Sidebar launcher geometry and settings navigation. */\nexport interface SettingsLauncherOwnerProps {\n /** Whether the sidebar shows labels. */\n wide: boolean\n /** Whether the settings dialog covers the sidebar; defer launcher notices until it closes. */\n settingsOpen: boolean\n /** Open the settings panel. */\n openSettings: () => void\n /** @param id - registered onboarding editor to open explicitly. */\n openOnboarding: (id: string) => void\n}',
|
||||
],
|
||||
ownerPropsReferences: [],
|
||||
standardProps: [
|
||||
|
||||
@@ -865,6 +865,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [{ name: 'client', description: 'identity of the requesting UI for this call.' }],
|
||||
returns: 'balance outcome, or null if signed out or the grant changed during the query.',
|
||||
},
|
||||
{
|
||||
signature: 'abstract getUnnotifiedBonuses(client: AccountClientMetadata): Promise<AccountBonusBatch | null>',
|
||||
description: 'Query the granted bonuses Platform has not yet recorded as displayed.',
|
||||
parameters: [{ name: 'client', description: 'identity of the requesting UI for this call; its language selects the server-authored message.' }],
|
||||
returns: 'bonuses with their account, or null if signed out or the grant changed during the query.',
|
||||
},
|
||||
{
|
||||
signature: 'abstract ackBonusNotified(accountId: AccountUserId, orderId: AccountBonusOrderId, client: AccountClientMetadata): Promise<boolean>',
|
||||
description: 'Record one displayed bonus as notified for the account it belongs to.',
|
||||
parameters: [{ name: 'accountId', description: 'account the notification was read for; a different current account is never acknowledged.' }, { name: 'orderId', description: 'granted bonus order the user saw.' }, { name: 'client', description: 'identity of the requesting UI for this call.' }],
|
||||
returns: 'true once Platform records the acknowledgement; false if signed out or the account changed.',
|
||||
},
|
||||
{
|
||||
signature: 'abstract startSignIn(client: AccountClientMetadata, callbackOrigin: string, loginSource: \'web\' | \'desktop\'): Promise<AccountView>',
|
||||
description: 'Join an active attempt or start browser authorization.',
|
||||
@@ -4221,6 +4233,18 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
|
||||
/** Shapes of every exported type the Service and Event signatures reference (transitively), sorted by name. */
|
||||
export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
{
|
||||
name: 'AccountBonusBatch',
|
||||
declaration: 'export interface AccountBonusBatch {\n readonly accountId: AccountUserId;\n readonly bonuses: readonly AccountBonusNotification[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'AccountBonusNotification',
|
||||
declaration: 'export interface AccountBonusNotification {\n readonly orderId: AccountBonusOrderId;\n readonly campaign: string;\n readonly amount: string;\n readonly currency: \'CNY\' | \'USD\';\n readonly grantedAt: string;\n readonly expiresAt: string;\n readonly message: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'AccountBonusOrderId',
|
||||
declaration: 'export type AccountBonusOrderId = Branded<\'AccountBonusOrderId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'AccountClientMetadata',
|
||||
declaration: 'export interface AccountClientMetadata {\n readonly version: string;\n readonly locale: string;\n readonly timezoneOffsetSeconds: number;\n}',
|
||||
|
||||
@@ -37,6 +37,9 @@ export const remoteDefaultResponses: RemoteTable = {
|
||||
// ui-settings-account refreshes details after a stored-grant snapshot.
|
||||
'account/getProfile': ok(null),
|
||||
'account/getBalance': ok(null),
|
||||
// ui-settings-account bonus notice read and acknowledgement at signing in.
|
||||
'account/getUnnotifiedBonuses': ok(null),
|
||||
'account/ackBonusNotified': ok(true),
|
||||
},
|
||||
// Stream endpoints the roster opens later than boot; declared so a spec that forgets the script gets a stream miss.
|
||||
streams: [
|
||||
|
||||
@@ -690,6 +690,9 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
AccountView: 'credentials.md',
|
||||
AccountDetails: 'credentials.md',
|
||||
AccountClientMetadata: 'credentials.md',
|
||||
AccountBonusBatch: 'credentials.md',
|
||||
AccountBonusOrderId: 'credentials.md',
|
||||
AccountUserId: 'credentials.md',
|
||||
PlatformSession: 'credentials.md',
|
||||
SignInAttemptId: 'credentials.md',
|
||||
AuthorizationPrompt: 'credentials.md',
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"apps/web/tests/models-settings-recovery.e2e.ts",
|
||||
"apps/web/tests/onboarding-deepseek-config.e2e.ts",
|
||||
"apps/web/tests/onboarding-native.e2e.ts",
|
||||
"apps/web/tests/bonus-notice.e2e.ts",
|
||||
"apps/web/tests/onboarding-usable-provider.e2e.ts",
|
||||
"apps/web/tests/remote-welcome.e2e.ts",
|
||||
"apps/web/tests/public-mount.e2e.ts",
|
||||
|
||||
Reference in new issue
Block a user