diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.i18n.yaml new file mode 100644 index 0000000000..b9d795e2db --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.md +/: + en: 5121efbea911b60c + zh: 5fceabd7a9de1627 +/update-account-notification-consumers: + en: 586faed9ab20343f + zh: fb3cc099b39b0386 +/update-account-notification-consumers/change: + en: 66a0d330922e4794 + zh: ebf07a21bb01e64c +/update-account-notification-consumers/migration: + en: c177a8cfccf3a2e1 + zh: 4529b8b553c3eaef diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.md b/docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.md new file mode 100644 index 0000000000..d3038b771c --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "AccountBonusNotification removes unused bonus fields and permits notices without an expiry." +--- + +# Update account notification consumers + +English | [中文](guide.zh.md) + +## Change + +The public `AccountBonusNotification` returned by `DeepSeekAccount.getUnnotifiedBonuses()` and `account/getUnnotifiedBonuses` no longer includes `campaign`, `amount`, `currency`, or `grantedAt`. Notifications retain `orderId`, `message`, and `expiresAt`, and add an optional `title`. An empty `expiresAt` means the notification never expires. `orderId` accepts arbitrary strings rather than requiring UUIDs. + +## Migration + +1. Remove reads of the deleted fields from account notification consumers. Display `message` and use the client's default title when `title` is absent. +2. Treat an empty `expiresAt` as no expiry before parsing timestamps. Keep `orderId` unchanged when acknowledging a notification. +3. Update provider fixtures to use the current fields. Confirm that a notification with a non-UUID ID and empty expiry displays and can be acknowledged. diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.zh.md new file mode 100644 index 0000000000..a2a093cdfd --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/account-notification-fields/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "AccountBonusNotification 移除未使用的赠金字段,并允许通知永不过期。" +--- + +# 更新账号通知使用方 + +[English](guide.md) | 中文 + +## 变更 + +`DeepSeekAccount.getUnnotifiedBonuses()` 和 `account/getUnnotifiedBonuses` 返回的公开类型 `AccountBonusNotification` 不再包含 `campaign`、`amount`、`currency` 或 `grantedAt`。通知保留 `orderId`、`message` 和 `expiresAt`,并新增可选的 `title`。空字符串 `expiresAt` 表示通知永不过期。`orderId` 接受任意字符串,不再要求 UUID 格式。 + +## 迁移 + +1. 移除账号通知使用方对已删除字段的读取。展示 `message`,在 `title` 缺省时使用客户端默认标题。 +2. 解析时间戳前,将空字符串 `expiresAt` 视为永不过期。确认通知时原样传递 `orderId`。 +3. 更新提供者测试夹具以使用当前字段。确认非 UUID 标识、空过期时间的通知可以展示并确认。 diff --git a/packages/client/ui-settings-account/src/client/AccountNotice.tsx b/packages/client/ui-settings-account/src/client/AccountNotice.tsx index 35beeacc74..a20cb2b91a 100644 --- a/packages/client/ui-settings-account/src/client/AccountNotice.tsx +++ b/packages/client/ui-settings-account/src/client/AccountNotice.tsx @@ -98,10 +98,6 @@ function NoticeCard({ notice, depth, ready, title, closeLabel, onShown, onDismis }) { const foreground = depth === 0 const reported = useRef(false) - const card = useRef(null) - useLayoutEffect(() => { - if (card.current) card.current.inert = !foreground - }, [foreground]) useEffect(() => { if (!ready || !foreground || reported.current || !live(notice.expiresAt)) return let cancel: (() => void) | undefined @@ -122,7 +118,7 @@ function NoticeCard({ notice, depth, ready, title, closeLabel, onShown, onDismis cancel?.() } }, [ready, foreground, notice.orderId, notice.expiresAt, onShown]) - return } diff --git a/packages/client/ui-settings-account/src/client/bonus-notices.ts b/packages/client/ui-settings-account/src/client/bonus-notices.ts index 86ffaee650..146f98c0a2 100644 --- a/packages/client/ui-settings-account/src/client/bonus-notices.ts +++ b/packages/client/ui-settings-account/src/client/bonus-notices.ts @@ -257,10 +257,10 @@ export function createBonusNoticeController(options: BonusNoticeControllerOption shown(orderId) { setCurrent(current.filter(notice => !expired(notice.expiresAt, Date.now()))) if (current[0]?.orderId !== orderId) return - if (expired(current[0].expiresAt, Date.now())) return report(orderId) }, dismiss(orderId) { + setCurrent(current.filter(notice => !expired(notice.expiresAt, Date.now()))) if (current[0]?.orderId !== orderId) return // Closing the card is the user seeing it, so queue its acknowledgement before withdrawing. report(orderId) diff --git a/packages/client/ui-settings-account/tests/bonus-notices.client.spec.ts b/packages/client/ui-settings-account/tests/bonus-notices.client.spec.ts index 8f894a07d2..6ab5b50104 100644 --- a/packages/client/ui-settings-account/tests/bonus-notices.client.spec.ts +++ b/packages/client/ui-settings-account/tests/bonus-notices.client.spec.ts @@ -588,3 +588,25 @@ it('publishes a title-only refresh without acknowledging the same card twice', a expect(acknowledge).toHaveBeenCalledTimes(1) controller.end() }) + +it('does not read or acknowledge when refreshed outside an active account lifecycle', async () => { + const { controller, read, acknowledge } = setup() + await controller.refresh() + expect(read).not.toHaveBeenCalled() + expect(acknowledge).not.toHaveBeenCalled() +}) + +it('closes the visible notice when the preceding card expires before its display callback', async () => { + const { controller, read, acknowledge, stacks } = setup() + read.mockResolvedValue({ accountId: 'account-a' as AccountUserId, bonuses: [ + ...batch('account-a', 'first', '2099-01-01T00:00:00Z').bonuses, + ...batch('account-a', 'second', '').bonuses, + ] }) + controller.begin() + await vi.waitFor(() => { expect(stacks.at(-1)).toHaveLength(2) }) + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2100-01-01T00:00:00Z')) + controller.dismiss('second' as AccountBonusOrderId) + expect(stacks.at(-1)).toEqual([]) + await vi.waitFor(() => { expect(acknowledge).toHaveBeenCalledExactlyOnceWith('account-a', 'second') }) + controller.end() +}) diff --git a/packages/client/ui-settings-account/tests/notice.client.spec.tsx b/packages/client/ui-settings-account/tests/notice.client.spec.tsx index e6c08e7c23..5fbbfc3994 100644 --- a/packages/client/ui-settings-account/tests/notice.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/notice.client.spec.tsx @@ -156,16 +156,26 @@ it('keeps back cards mounted but inaccessible and reports them only after promot const second = screen.getByText('second copy').closest('aside') expect(document.querySelectorAll('aside')).toHaveLength(3) expect(screen.getAllByRole('status')).toHaveLength(1) - expect(second?.inert).toBe(true) + expect(second?.querySelector('button')?.tabIndex).toBe(-1) await presentFrames() expect(onShown).toHaveBeenCalledExactlyOnceWith('first') fireEvent.click(screen.getByRole('button', { name: 'Close' })) expect(onDismiss).toHaveBeenCalledExactlyOnceWith('first') view.rerender(tree(notices.slice(1))) expect(screen.getByText('second copy').closest('aside')).toBe(second) - expect(second?.inert).toBe(false) + expect(second?.querySelector('button')?.tabIndex).toBe(0) expect(onShown).toHaveBeenCalledTimes(1) await presentFrames() expect(onShown).toHaveBeenLastCalledWith('second') expect(onShown).toHaveBeenCalledTimes(2) }) + +it('does not acknowledge a notice before an anchor is mounted', async () => { + const onShown = vi.fn() + render(()} title="News" closeLabel="Close" + notices={[{ orderId: 'notice' as AccountBonusOrderId, message: 'News', expiresAt: '' }]} + onShown={onShown} onDismiss={vi.fn()} />) + await presentFrames() + expect(onShown).not.toHaveBeenCalled() + expect(screen.queryByRole('status')).toBeNull() +}) diff --git a/packages/credentials/deepseek-account-platform/README.i18n.yaml b/packages/credentials/deepseek-account-platform/README.i18n.yaml index a2ba74e109..eb818c91bb 100644 --- a/packages/credentials/deepseek-account-platform/README.i18n.yaml +++ b/packages/credentials/deepseek-account-platform/README.i18n.yaml @@ -15,8 +15,8 @@ en: af856ba79fe31b8e zh: 15b7d1a5d1856de2 /deepseek-ai-dsh-deepseek-account-platform/use-this-package: - en: ebd1d8ddc0d2a6c0 - zh: ca2ce6b6f859fe93 + en: b64ce39acb777c13 + zh: c5f138c695be73d1 /deepseek-ai-dsh-deepseek-account-platform/understand-the-implementation: en: 76d2da2dab100ac3 zh: 2c9404f27915daab diff --git a/packages/credentials/deepseek-account-platform/README.md b/packages/credentials/deepseek-account-platform/README.md index 8ecfc9f941..078129ecce 100644 --- a/packages/credentials/deepseek-account-platform/README.md +++ b/packages/credentials/deepseek-account-platform/README.md @@ -38,7 +38,7 @@ Token resolution refuses requests while local sign-out is in progress. Successfu 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. A balance or bonus-amount string is accepted when it matches the numeric grammar Platform's Web client already passes to big.js, which allows an omitted integer or fraction part and a decimal exponent, so values such as 0E-16 and 5.0000000000000000 stay valid; the original string is retained and NaN, Infinity and malformed text are rejected. 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 with a JSON body naming the order 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, retains the string fields order_id, expires_at and msg, and maps them to orderId, expiresAt and message. An optional string title is forwarded unchanged. An empty, null or absent expires_at becomes an empty expiresAt and means the notice never expires. Order IDs need not be UUIDs; campaign, amount, currency and granted_at are ignored and may be absent; malformed payloads, other HTTP failures and other business failures throw, while a credential rejection (HTTP 401 or response code `40003`) invalidates the current grant and yields null or false. +getUnnotifiedBonuses reads GET /api/v0/users/get_unnotified_bonuses and ackBonusNotified posts POST /api/v0/users/ack_bonus_notified with a JSON body naming the order 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, retains the string fields order_id, expires_at and msg, and maps them to orderId, expiresAt and message. An optional string title is forwarded unchanged; null or absent titles use the client default. An empty, null or absent expires_at becomes an empty expiresAt and means the notice never expires. Order IDs need not be UUIDs; campaign, amount, currency and granted_at are ignored and may be absent; malformed payloads, other HTTP failures and other business failures throw, while a credential rejection (HTTP 401 or response code `40003`) invalidates the current grant and yields null or false. Balance requests use balanceTimeoutMs (30,000ms by default); timeout returns a failed outcome without clearing the account or manufacturing a zero balance. Configure platformOrigin, allowLoopbackHttp, requestTimeoutMs, balanceTimeoutMs, 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. diff --git a/packages/credentials/deepseek-account-platform/README.zh.md b/packages/credentials/deepseek-account-platform/README.zh.md index 5d698e5e23..a31862799a 100644 --- a/packages/credentials/deepseek-account-platform/README.zh.md +++ b/packages/credentials/deepseek-account-platform/README.zh.md @@ -38,7 +38,7 @@ 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 与其他字段。余额和赠金金额字符串只要符合 Platform Web 客户端交给 big.js 的数字文法即可通过,该文法和 big.js 一致,允许省略整数或小数部分以及十进制指数,因此 0E-16、5.0000000000000000 这类值仍然有效;原始字符串会保留,NaN、Infinity 和非法文本仍被拒绝。赠送钱包不计入充值余额。凭证变化和销毁会使进行中的查询失效。 -getUnnotifiedBonuses 读取同一来源上的 GET /api/v0/users/get_unnotified_bonuses,ackBonusNotified 向 POST /api/v0/users/ack_bonus_notified 发送仅含订单号的 JSON 正文,两者都按调用方元数据组装这五个客户端请求头。二者都先为捕获到的授权解析账号身份,因此退登或切号时返回 null 或 false,而不会确认另一个账号的赠金;一次读取只有在同一凭证生命周期内全部结算后才发布。Host 按 Platform 返回顺序转发赠金列表,保留字符串字段 order_id、expires_at 和 msg,并映射为 orderId、expiresAt 和 message。可选的字符串 title 原样转发。expires_at 为空字符串、null 或缺省时,统一转为空字符串 expiresAt,表示通知永不过期。订单号不要求 UUID 格式;campaign、amount、currency 和 granted_at 会被忽略,也可以缺省;格式错误、其他 HTTP 失败和其他业务失败都会抛出,而凭据被拒绝(HTTP 401 或响应码 `40003`)会使当前授权失效并返回 null 或 false。 +getUnnotifiedBonuses 读取同一来源上的 GET /api/v0/users/get_unnotified_bonuses,ackBonusNotified 向 POST /api/v0/users/ack_bonus_notified 发送仅含订单号的 JSON 正文,两者都按调用方元数据组装这五个客户端请求头。二者都先为捕获到的授权解析账号身份,因此退登或切号时返回 null 或 false,而不会确认另一个账号的赠金;一次读取只有在同一凭证生命周期内全部结算后才发布。Host 按 Platform 返回顺序转发赠金列表,保留字符串字段 order_id、expires_at 和 msg,并映射为 orderId、expiresAt 和 message。可选的字符串 title 原样转发;null 或缺省表示使用客户端默认标题。expires_at 为空字符串、null 或缺省时,统一转为空字符串 expiresAt,表示通知永不过期。订单号不要求 UUID 格式;campaign、amount、currency 和 granted_at 会被忽略,也可以缺省;格式错误、其他 HTTP 失败和其他业务失败都会抛出,而凭据被拒绝(HTTP 401 或响应码 `40003`)会使当前授权失效并返回 null 或 false。 余额请求使用 balanceTimeoutMs(默认 30,000ms);超时返回失败结果,不清除账号,也不制造零余额。在插件行配置 platformOrigin、allowLoopbackHttp、requestTimeoutMs、balanceTimeoutMs 和 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 报告操作系统和架构。 diff --git a/packages/credentials/deepseek-account-platform/src/details.ts b/packages/credentials/deepseek-account-platform/src/details.ts index 85ed81a0ba..86f59e3d15 100644 --- a/packages/credentials/deepseek-account-platform/src/details.ts +++ b/packages/credentials/deepseek-account-platform/src/details.ts @@ -16,7 +16,7 @@ const wallet = z.object({ }) const summary = z.object({ normal_wallets: z.array(wallet), bonus_wallets: z.array(wallet) }) const bonus = z.object({ - order_id: z.string(), title: z.string().optional(), expires_at: z.string().nullish().transform(value => value ?? ''), msg: z.string(), + order_id: z.string(), title: z.string().nullish().transform(value => value ?? undefined), expires_at: z.string().nullish().transform(value => value ?? ''), msg: z.string(), }) // The shared response reader already unwraps biz_data, so the payload is the bonus list itself. const unnotified = z.array(bonus) diff --git a/packages/credentials/deepseek-account-platform/tests/bonus.spec.ts b/packages/credentials/deepseek-account-platform/tests/bonus.spec.ts index be4c618cfe..e8aaf5d7c7 100644 --- a/packages/credentials/deepseek-account-platform/tests/bonus.spec.ts +++ b/packages/credentials/deepseek-account-platform/tests/bonus.spec.ts @@ -401,3 +401,16 @@ it('refuses a cached identity that carries no Platform id', async () => { f.failProfile() await expect(f.account.ackBonusNotified(USER as AccountUserId, ORDER as AccountBonusOrderId, clientMetadata('en'))).rejects.toThrow('account: protocol') }) + +it('treats a null title as absent without rejecting other notifications', async () => { + const f = await fixture() + await f.grant('test-account-token') + f.bonuses([ + { order_id: 'untitled', title: null, msg: 'News' }, + { order_id: 'titled', title: 'Update', msg: 'Details' }, + ]) + expect((await f.account.getUnnotifiedBonuses(clientMetadata('en')))?.bonuses).toEqual([ + { orderId: 'untitled', expiresAt: '', message: 'News' }, + { orderId: 'titled', title: 'Update', expiresAt: '', message: 'Details' }, + ]) +})