From 01525834fd326a0fbcd6f537b11d6f278470f992 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Tue, 8 Sep 2026 23:04:54 +0800 Subject: [PATCH] feat: add expiring reset card wallet --- ...60908-reset-card-wallet-client-6c3e8a1f.md | 59 +++++ electron/api/routes/works.ts | 153 +++++++++++- src/components/layout/Sidebar.tsx | 235 +++++++++++++++++- src/lib/works-square.ts | 37 +++ tests/e2e/reset-card-wallet.spec.ts | 123 +++++++++ tests/unit/sidebar-token-points.test.tsx | 126 +++++++++- tests/unit/works-routes.test.ts | 176 +++++++++++++ tests/unit/works-square.test.ts | 39 +++ 8 files changed, 943 insertions(+), 5 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260908-reset-card-wallet-client-6c3e8a1f.md create mode 100644 tests/e2e/reset-card-wallet.spec.ts diff --git a/.project-docs/30-worklog/tasks/20260908-reset-card-wallet-client-6c3e8a1f.md b/.project-docs/30-worklog/tasks/20260908-reset-card-wallet-client-6c3e8a1f.md new file mode 100644 index 0000000..ad4bb8c --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260908-reset-card-wallet-client-6c3e8a1f.md @@ -0,0 +1,59 @@ +# Task: Integrate expiring reset-card wallet into Makelore desktop + +## Identity + +- Task ID: 20260908-reset-card-wallet-client-6c3e8a1f +- Mode: Feature +- Branch: main +- Worktree: D:\Datas\OthersProjects\makelore +- Base commit: 91ae7912ab15559c7fe8859974bfe1da9e4e7451 +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Add Electron Main-owned Works Square proxy routes for listing and redeeming expiring reset-card grants. +- Strictly project the public reset-card DTO before it reaches Renderer and preserve only a closed set of redemption error codes. +- Add a reset-card wallet to the existing account menu with available, expired, and redeemed states, explicit expiry, and manual redemption. +- Refresh both the reset-card wallet and authoritative Token Point V2 balance after successful redemption. +- Add focused Main route, Renderer API, component, and user-visible interaction coverage. + +## Intent And Constraints + +- Keep Works credentials and upstream response shaping behind the existing Host API/Main route boundary; do not add direct Renderer-to-Works requests or new IPC contracts. +- Treat Works Square `/api/billing/reset-cards` and `/api/billing/reset-cards/{card_id}/redeem` as the authority; the client must not calculate reset allowances or mutate card state optimistically. +- Operations-granted cards are manually redeemed inventory with an explicit expiry. Existing paid reset-card immediate fulfilment is unchanged and is not represented as desktop inventory. +- Allow a self-owned youth entitlement to redeem its card, but disable redemption while the current balance is family-shared; the server remains the final ownership authority. +- Derive an available card as expired when its `expires_at` is no longer in the future so stale UI cannot invite a known-invalid redemption. +- A successful or idempotently replayed redemption must refresh the card and balance authorities. Definitive failures must remain retryable by the user without inventing a new mutation identity or local success. +- Use the existing Makelore light visual system, compact account-menu disclosure, tabular dates, specific transitions, and at least 40px redemption targets. +- Preserve and do not commit the three adopted foreign task records from 20260901-20260902. + +## Outcome + +- Added Main-owned reset-card list/redeem routes that authenticate upstream with the existing Works token, strictly project the public card DTO, derive elapsed available cards as expired, and expose only a closed set of safe redemption errors to Renderer. +- Added typed Renderer helpers for the Host API routes without introducing direct Renderer-to-Works requests or a new IPC contract. +- Added an account-menu reset-card wallet with lazy loading, available-card count, explicit local-time expiry, available/expired/redeemed states, retry handling, and a 40px manual redemption action. +- Kept operations-granted cards separate from the existing Token Point balance rows and existing immediate paid reset-card flow. +- Prevented known-expired or family-shared-wallet redemptions in the client while retaining the server as the final authority; successful server fulfillment refreshes both the authoritative card list and Token Point V2 balance. +- Added Main route, Renderer helper, component, and Electron interaction coverage, including strict projection, safe error mapping, local expiry, shared-wallet behavior, single redemption, and post-redemption balance refresh. + +## Verification + +- `pnpm exec vitest run tests/unit/works-routes.test.ts tests/unit/works-square.test.ts tests/unit/sidebar-token-points.test.tsx --maxWorkers=2` — 3 files / 78 tests passed. +- `pnpm exec eslint electron/api/routes/works.ts src/lib/works-square.ts src/components/layout/Sidebar.tsx tests/unit/works-routes.test.ts tests/unit/works-square.test.ts tests/unit/sidebar-token-points.test.tsx tests/e2e/reset-card-wallet.spec.ts` — passed with no warnings or errors. +- `pnpm run typecheck` — passed. +- `pnpm run lint:check` — passed with 0 errors and the repository's 5 existing warnings in unchanged Home/Makelore files. +- `pnpm run build:vite` — Renderer, Main, Preload, and utility production builds passed; only the repository's existing Browserslist/import/chunk-size warnings were emitted. +- `pnpm exec playwright test tests/e2e/reset-card-wallet.spec.ts --repeat-each=3` — 3/3 Electron runs passed. +- `pnpm test` — 224 files / 1,952 tests passed and 2 skipped before the existing Pi real-process cold timing assertion measured 2,124ms against its 2,000ms threshold; the same file then passed 6/6 in isolation with one worker, and the separately invoked pressure test passed 1/1. This recurring unrelated timing shape is already recorded in prior project tasks and does not intersect the reset-card paths. +- `git diff --check` — passed. + +## Follow-ups + +- Deploy the Works Square reset-card backend and apply Alembic revision `20260908_reset_card_grants_0087` before shipping or enabling this client wallet against that environment. +- Rebuild/release Makelore together with the compatible server version, then smoke-test one Operations grant, one desktop redemption, and the resulting authoritative weekly balance refresh. + +## Promotion Candidates + +- During Integration, update the canonical desktop billing data-flow/current-state documentation with the Main-owned reset-card proxy boundary and account-menu wallet behavior. diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index bc7267c..a2e39a0 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -81,6 +81,13 @@ const TOKEN_POINT_METADATA_FIELDS = [ const TOKEN_POINT_ENTITLEMENT_SOURCES = new Set(['self', 'family_owner', 'shared_group']); const TOKEN_POINT_UPGRADE_ACTIONS = new Set(['self_service', 'contact_family_owner']); const TOKEN_POINT_VALUE_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/u; +const RESET_CARD_STATUSES = new Set(['available', 'expired', 'redeemed']); +const RESET_CARD_ERROR_MESSAGES: Record = { + reset_card_expired: '这张重置卡已过期。', + token_point_wallet_owner_required: '当前额度由家庭管理员管理,无法使用这张重置卡。', + billing_temporarily_paused: '计费服务正在切换,请稍后再试。', + token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。', +}; function readRequiredString(value: unknown, field: string): string { if (typeof value !== 'string' || !value.trim()) { @@ -168,7 +175,11 @@ function getErrorMessage(payload: unknown, fallback: string): string { function getPayloadErrorCode(payload: unknown): string | undefined { if (!isRecord(payload)) return undefined; const detail = isRecord(payload.detail) ? payload.detail : undefined; - return readOptionalString(payload.code) ?? (detail ? readOptionalString(detail.code) : undefined); + return readOptionalString(payload.code) + ?? readOptionalString(payload.error_code) + ?? (detail + ? readOptionalString(detail.code) ?? readOptionalString(detail.error_code) + : undefined); } function stripReadOnlyAgentProfileFields(body: AgentProfileUpdateInput): AgentProfileUpdateInput { @@ -190,6 +201,31 @@ async function sendUpstreamError( }); } +async function sendResetCardUpstreamError( + res: ServerResponse, + response: Response, +): Promise { + const payload = await readResponsePayload(response); + const upstreamCode = getPayloadErrorCode(payload); + const code = upstreamCode && RESET_CARD_ERROR_MESSAGES[upstreamCode] + ? upstreamCode + : undefined; + const status = response.status >= 400 && response.status < 500 ? response.status : 502; + const error = code + ? RESET_CARD_ERROR_MESSAGES[code] + : response.status === 404 + ? '重置卡不存在或不可用。' + : response.status === 409 + ? '当前有待处理的计费操作,暂时不能使用重置卡。' + : '重置卡服务暂时不可用,请稍后重试。'; + sendJson(res, status, { + success: false, + status: response.status, + ...(code ? { code } : {}), + error, + }); +} + function sendPublishSourceFailure( res: ServerResponse, status: number, @@ -344,6 +380,56 @@ function projectSafeTokenPointBalance(value: unknown): Record | }; } +function readResetCardTimestamp(value: unknown): string | null { + if (typeof value !== 'string' || !value.trim()) return null; + const normalized = value.trim(); + return Number.isNaN(Date.parse(normalized)) ? null : normalized; +} + +function projectSafeResetCard(value: unknown): Record | null { + if (!isRecord(value)) return null; + const id = readOptionalString(value.id); + const status = readOptionalString(value.status); + const grantedAt = readResetCardTimestamp(value.granted_at); + const expiresAt = readResetCardTimestamp(value.expires_at); + const redeemedAt = value.redeemed_at === null + ? null + : readResetCardTimestamp(value.redeemed_at); + const redeemedCycleId = readNullableStringField(value, 'redeemed_cycle_id'); + if ( + !id + || !status + || !RESET_CARD_STATUSES.has(status) + || !grantedAt + || !expiresAt + || redeemedAt === null && value.redeemed_at !== null + || redeemedCycleId === undefined + ) return null; + + if (status === 'redeemed') { + if (!redeemedAt || !redeemedCycleId) return null; + } else if (redeemedAt !== null || redeemedCycleId !== null) { + return null; + } + + return { + id, + status: status === 'available' && Date.parse(expiresAt) <= Date.now() ? 'expired' : status, + granted_at: grantedAt, + expires_at: expiresAt, + redeemed_at: redeemedAt, + redeemed_cycle_id: redeemedCycleId, + }; +} + +function projectSafeResetCardPage(value: unknown): Record[] | null { + if (!isRecord(value) || !Array.isArray(value.items)) return null; + const cards = value.items.map(projectSafeResetCard); + return cards.some((card) => card === null) + ? null + : cards as Record[]; +} + function projectSafeProject( value: unknown, options: { requireStatus?: boolean } = {}, @@ -716,6 +802,60 @@ async function handleGetBillingTokenPoints( sendJson(res, response.status, { success: true, points }); } +async function handleListBillingResetCards( + req: IncomingMessage, + res: ServerResponse, +): Promise { + const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); + const response = await proxyAwareFetch(createWorksUrl('/api/billing/reset-cards').toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + await sendResetCardUpstreamError(res, response); + return; + } + + const cards = projectSafeResetCardPage(await readResponsePayload(response)); + if (!cards) { + sendJson(res, 502, { success: false, error: 'Works Square returned an invalid reset-card list' }); + return; + } + sendJson(res, response.status, { success: true, cards }); +} + +async function handleRedeemBillingResetCard( + req: IncomingMessage, + res: ServerResponse, + cardId: string, +): Promise { + const accessToken = readRequiredHeader(req, 'x-niancode-access-token'); + const response = await proxyAwareFetch( + createWorksUrl(`/api/billing/reset-cards/${encodeURIComponent(readRequiredString(cardId, 'card_id'))}/redeem`).toString(), + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + await sendResetCardUpstreamError(res, response); + return; + } + + const card = projectSafeResetCard(await readResponsePayload(response)); + if (!card) { + sendJson(res, 502, { success: false, error: 'Works Square returned an invalid reset-card result' }); + return; + } + sendJson(res, response.status, { success: true, card }); +} + async function handleAgentProfile( req: IncomingMessage, res: ServerResponse, @@ -1500,6 +1640,17 @@ export async function handleWorksRoutes( return true; } + if (url.pathname === '/api/works/billing/reset-cards' && req.method === 'GET') { + await handleListBillingResetCards(req, res); + return true; + } + + const resetCardRedeemMatch = url.pathname.match(/^\/api\/works\/billing\/reset-cards\/([^/]+)\/redeem$/); + if (resetCardRedeemMatch && req.method === 'POST') { + await handleRedeemBillingResetCard(req, res, decodeURIComponent(resetCardRedeemMatch[1])); + return true; + } + if (url.pathname === '/api/works/ai-gateway/images/generations' && req.method === 'POST') { await handleSubmitImageGeneration(req, res); return true; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 0af78ce..1d8a0f2 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -13,6 +13,7 @@ import { LogOut, Plus, Settings as SettingsIcon, + Ticket, UserCircle2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -34,7 +35,14 @@ import { subscribeCodingProjectCreationRequest, subscribeCodingProjectOpenRequest, } from '@/lib/coding-project-entry'; -import { fetchWorksTokenPointBalance, type WorksTokenPointBalance } from '@/lib/works-square'; +import { AppError } from '@/lib/error-model'; +import { + fetchWorksResetCards, + fetchWorksTokenPointBalance, + redeemWorksResetCard, + type WorksResetCard, + type WorksTokenPointBalance, +} from '@/lib/works-square'; import { formatWorksTokenPointValue, isWorksTokenPointBalanceExhausted, @@ -67,6 +75,11 @@ type TokenPointState = { balance: WorksTokenPointBalance | null; }; +type ResetCardState = { + status: 'idle' | 'loading' | 'loaded' | 'error'; + cards: WorksResetCard[]; +}; + type ProjectEntryError = { project: CodingProjectSummary; message: string; @@ -96,6 +109,34 @@ function getRefreshTimeLabel(value: string | null | undefined): string | null { return formatted ? `刷新时间 ${formatted}` : null; } +function formatResetCardTime(value: string | null | undefined): string | null { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return `${date.getFullYear()}-${padTimePart(date.getMonth() + 1)}-${padTimePart(date.getDate())} ${padTimePart(date.getHours())}:${padTimePart(date.getMinutes())}`; +} + +function getEffectiveResetCardStatus(card: WorksResetCard): WorksResetCard['status'] { + if (card.status === 'available' && Date.parse(card.expires_at) <= Date.now()) return 'expired'; + return card.status; +} + +function getResetCardErrorMessage(error: unknown): string { + const details = error instanceof AppError ? error.details : undefined; + const backendCode = typeof details?.backendCode === 'string' ? details.backendCode : null; + const status = typeof details?.status === 'number' ? details.status : null; + const messages: Record = { + reset_card_expired: '这张重置卡已过期,卡包已刷新。', + token_point_wallet_owner_required: '当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。', + billing_temporarily_paused: '计费服务正在切换,请稍后再试。', + token_point_transaction_conflict: '重置卡状态刚刚发生变化,请刷新后重试。', + }; + if (backendCode && messages[backendCode]) return messages[backendCode]; + if (status === 404) return '重置卡不存在或已不可用。'; + if (status === 409) return '当前有待处理的计费操作,暂时不能使用重置卡。'; + return '重置卡暂时无法使用,请稍后重试。'; +} + function isTokenPointBalanceExhausted(state: TokenPointState): boolean { if (state.status !== 'loaded' || !state.balance) return false; return isWorksTokenPointBalanceExhausted(state.balance); @@ -191,7 +232,10 @@ export function Sidebar({ const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [profileDialogOpen, setProfileDialogOpen] = useState(false); const [usageDrawerOpen, setUsageDrawerOpen] = useState(false); + const [resetCardDrawerOpen, setResetCardDrawerOpen] = useState(false); const [tokenPointState, setTokenPointState] = useState({ status: 'idle', balance: null }); + const [resetCardState, setResetCardState] = useState({ status: 'idle', cards: [] }); + const [redeemingResetCardId, setRedeemingResetCardId] = useState(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newProjectName, setNewProjectName] = useState(''); const [newProjectSelectedPath, setNewProjectSelectedPath] = useState(''); @@ -206,6 +250,7 @@ export function Sidebar({ const accountButtonRef = useRef(null); const accountMenuRef = useRef(null); const tokenPointRequestIdRef = useRef(0); + const resetCardRequestIdRef = useRef(0); const navigate = useNavigate(); const location = useLocation(); const activeModule = getAiModuleForPath(location.pathname); @@ -227,6 +272,10 @@ export function Sidebar({ tokenPointBalance && !tokenPointBalance.can_manage_membership, ); const nextRefreshLabel = getRefreshTimeLabel(tokenPointBalance?.next_refresh_at); + const availableResetCardCount = resetCardState.cards.filter( + (card) => getEffectiveResetCardStatus(card) === 'available', + ).length; + const resetCardRedemptionBlocked = tokenPointBalance?.entitlement_source === 'shared_group'; const activeRobotAgentId = isRobotModule ? robotSelectedAgentId : null; const openRobotRoute = useCallback((agentId: string | null = null, openCreate = false) => { @@ -334,6 +383,7 @@ export function Sidebar({ useEffect(() => { if (!accountMenuOpen) { setUsageDrawerOpen(false); + setResetCardDrawerOpen(false); } }, [accountMenuOpen]); @@ -366,19 +416,51 @@ export function Sidebar({ }); }, [authUser, getValidAccessToken]); + const refreshResetCards = useCallback(async (): Promise => { + if (!authUser) { + resetCardRequestIdRef.current += 1; + setResetCardState({ status: 'idle', cards: [] }); + return; + } + + const requestId = resetCardRequestIdRef.current + 1; + resetCardRequestIdRef.current = requestId; + setResetCardState((current) => ({ status: 'loading', cards: current.cards })); + try { + const accessToken = await getValidAccessToken(); + if (!accessToken) throw new Error('请先登录后再查看重置卡'); + const cards = await fetchWorksResetCards(accessToken); + if (resetCardRequestIdRef.current === requestId) { + setResetCardState({ status: 'loaded', cards }); + } + } catch { + if (resetCardRequestIdRef.current === requestId) { + setResetCardState((current) => ({ status: 'error', cards: current.cards })); + } + } + }, [authUser, getValidAccessToken]); + useEffect(() => { refreshTokenPoints(); }, [refreshTokenPoints]); + useEffect(() => { + resetCardRequestIdRef.current += 1; + setResetCardState({ status: 'idle', cards: [] }); + setRedeemingResetCardId(null); + }, [authUser?.userId]); + useEffect(() => { if (!authUser) return undefined; const handleFocus = () => { refreshTokenPoints(); + if (resetCardDrawerOpen) void refreshResetCards(); }; const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { refreshTokenPoints(); + if (resetCardDrawerOpen) void refreshResetCards(); } }; @@ -388,7 +470,7 @@ export function Sidebar({ window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [authUser, refreshTokenPoints]); + }, [authUser, refreshResetCards, refreshTokenPoints, resetCardDrawerOpen]); useEffect(() => { if (authUser && accountMenuOpen) { @@ -396,6 +478,12 @@ export function Sidebar({ } }, [accountMenuOpen, authUser, refreshTokenPoints]); + useEffect(() => { + if (authUser && resetCardDrawerOpen) { + void refreshResetCards(); + } + }, [authUser, refreshResetCards, resetCardDrawerOpen]); + const openCreateProject = useCallback(() => { setNewProjectName(''); @@ -512,12 +600,14 @@ export function Sidebar({ const navigateFromAccountMenu = (path: string) => { setAccountMenuOpen(false); setUsageDrawerOpen(false); + setResetCardDrawerOpen(false); navigate(path); }; const openProfileDialog = () => { setAccountMenuOpen(false); setUsageDrawerOpen(false); + setResetCardDrawerOpen(false); setProfileDialogOpen(true); void syncProfileNow().catch(() => undefined); }; @@ -526,9 +616,44 @@ export function Sidebar({ void openWorksSquareSubscriptionUpgrade().catch(() => undefined); }; + const handleRedeemResetCard = async (card: WorksResetCard) => { + if (redeemingResetCardId) return; + if (getEffectiveResetCardStatus(card) !== 'available') { + toast.error('这张重置卡已过期。'); + void refreshResetCards(); + return; + } + if (resetCardRedemptionBlocked) { + toast.error('当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。'); + return; + } + + setRedeemingResetCardId(card.id); + try { + const accessToken = await getValidAccessToken(); + if (!accessToken) throw new Error('请先登录后再使用重置卡'); + const redeemedCard = await redeemWorksResetCard(accessToken, card.id); + setResetCardState((current) => ({ + status: 'loaded', + cards: current.cards.map((item) => item.id === redeemedCard.id ? redeemedCard : item), + })); + toast.success('重置卡已使用,本周额度已刷新。'); + refreshTokenPoints(); + void refreshResetCards(); + } catch (error) { + toast.error(getResetCardErrorMessage(error)); + void refreshResetCards(); + } finally { + setRedeemingResetCardId((current) => current === card.id ? null : current); + } + }; + const handleAccountLogout = async () => { setAccountMenuOpen(false); setUsageDrawerOpen(false); + setResetCardDrawerOpen(false); + resetCardRequestIdRef.current += 1; + setResetCardState({ status: 'idle', cards: [] }); await logout(); navigate('/login'); }; @@ -1021,7 +1146,10 @@ export function Sidebar({ aria-expanded={usageDrawerOpen} aria-controls="sidebar-account-usage-drawer" className="flex min-h-8 w-full items-center gap-2 rounded-md px-1 py-1 text-left text-sm font-medium transition-colors duration-100 hover:bg-surface-subtle" - onClick={() => setUsageDrawerOpen((open) => !open)} + onClick={() => { + setUsageDrawerOpen((open) => !open); + setResetCardDrawerOpen(false); + }} > 剩余用量 @@ -1113,6 +1241,107 @@ export function Sidebar({ ) : null} + + + {resetCardState.status === 'loading' && resetCardState.cards.length === 0 ? ( +

正在加载重置卡…

+ ) : null} + {resetCardState.status === 'error' ? ( +
+

重置卡暂时无法获取。

+ +
+ ) : null} + {resetCardState.status === 'loaded' && resetCardState.cards.length === 0 ? ( +

暂无赠送的重置卡。

+ ) : null} + {resetCardRedemptionBlocked && resetCardState.cards.length > 0 ? ( +

+ 当前使用家庭共享额度,请由家庭管理员使用自己的重置卡。 +

+ ) : null} + {resetCardState.cards.length > 0 ? ( +
+ {resetCardState.cards.map((card) => { + const status = getEffectiveResetCardStatus(card); + const expiresAt = formatResetCardTime(card.expires_at) ?? '时间不可用'; + const redeemedAt = formatResetCardTime(card.redeemed_at); + const redeeming = redeemingResetCardId === card.id; + return ( +
+
+ 额度重置卡 + + {status === 'available' ? '可使用' : status === 'expired' ? '已过期' : '已使用'} + +
+

+ 有效期至 {expiresAt} +

+ {status === 'redeemed' && redeemedAt ? ( +

使用于 {redeemedAt}

+ ) : null} + {status === 'available' ? ( + + ) : null} +
+ ); + })} +
+ ) : null} +