refactor: 收口平台钱包余额 delta
This commit is contained in:
@@ -1427,6 +1427,14 @@
|
|||||||
- 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete`、`npm run check:encoding`。
|
- 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete`、`npm run check:encoding`。
|
||||||
- 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
- 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
|
## 2026-06-04 Platform Profile Wallet Delta Model 收口
|
||||||
|
|
||||||
|
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护钱包余额归一、本地 delta 乐观更新和服务端 dashboard 刷新后的 delta 抵消,壳层需要理解余额非负、整数截断、借贷方向和服务端快照对账。
|
||||||
|
- 决策:新增 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,收口 `resolveProfileWalletBalance`、`adjustProfileDashboardWalletBalance` 与 `reconcileProfileWalletLocalDeltaWithServerDashboard`。壳层只保留 API 请求、React ref、state 写入和刷新触发副作用。
|
||||||
|
- 影响范围:创作入口泥点展示、生成前泥点校验、扣点 / 返还后的个人 dashboard 乐观更新、后台刷新 dashboard 时的本地 delta 对账。
|
||||||
|
- 验证方式:`npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。
|
||||||
|
- 关联文档:`docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-03 Public Work Presentation 收口
|
## 2026-06-03 Public Work Presentation 收口
|
||||||
|
|
||||||
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。
|
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
|
|||||||
|
|
||||||
后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。
|
后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。
|
||||||
|
|
||||||
|
平台入口个人钱包本地 delta、dashboard 乐观更新与服务端快照对账规则收口到 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,平台壳只保留 API、ref 与 state 副作用,规则见 [【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md)。
|
||||||
|
|
||||||
RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。
|
RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。
|
||||||
|
|
||||||
平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 【前端架构】Platform Profile Wallet Delta Model 收口计划
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
`PlatformEntryFlowShellImpl.tsx` 仍内联维护个人钱包余额的本地 delta 规则:余额归一化、本地扣点 / 返还后的 dashboard 乐观更新,以及刷新服务端 dashboard 时如何抵消已经被服务端反映的本地 delta。
|
||||||
|
|
||||||
|
这些规则是纯展示状态计算,但留在平台壳层会让壳层同时理解钱包余额边界、整数截断、负数保护和服务端快照对账。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
新增 `platformProfileWalletDeltaModel.ts`,收口钱包余额本地 delta 的纯规则:
|
||||||
|
|
||||||
|
- `resolveProfileWalletBalance(...)` 负责把 dashboard 余额归一为非负整数。
|
||||||
|
- `adjustProfileDashboardWalletBalance(...)` 负责把本地 delta 应用到 dashboard,并刷新 `updatedAt`。
|
||||||
|
- `reconcileProfileWalletLocalDeltaWithServerDashboard(...)` 负责在拿到新服务端 dashboard 后扣除已被服务端反映的本地借贷变化。
|
||||||
|
|
||||||
|
`PlatformEntryFlowShellImpl.tsx` 继续保留 API 请求、React ref、state 写入和刷新触发副作用。
|
||||||
|
|
||||||
|
## 接口约束
|
||||||
|
|
||||||
|
- 非数字、无穷值或空 dashboard 的余额按 `0` 处理。
|
||||||
|
- 本地 delta 必须先 `Math.trunc`,余额不得低于 `0`。
|
||||||
|
- 当服务端最新余额已经反映本地扣点时,剩余负 delta 应减少;已经全部反映时归零。
|
||||||
|
- 当服务端最新余额已经反映本地返还 / 奖励时,剩余正 delta 应减少;已经全部反映时归零。
|
||||||
|
- 服务端余额变化方向与本地 delta 相反时,不得错误抵消。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- `npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts`
|
||||||
|
- 针对新 Module 与 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint。
|
||||||
|
- `npm run typecheck`
|
||||||
|
- `npm run check:encoding`
|
||||||
@@ -22,6 +22,8 @@
|
|||||||
|
|
||||||
后端拼图发布 / 待发布门槛同样必须要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整:`module-puzzle` preview blockers 与 `api-server` session stage 判定不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。
|
后端拼图发布 / 待发布门槛同样必须要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整:`module-puzzle` preview blockers 与 `api-server` session stage 判定不得只凭 cover、标题、描述和标签把半成品标为 `publishReady` 或 `ready_to_publish`。
|
||||||
|
|
||||||
|
平台入口个人钱包本地 delta 由 `platformProfileWalletDeltaModel.ts` 判定:余额归一、本地扣点 / 返还后的 dashboard 乐观更新,以及服务端 dashboard 刷新后的 delta 对账不得散落在平台壳层;壳层只负责 API、React ref 和 state 写入。
|
||||||
|
|
||||||
RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。
|
RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。
|
||||||
|
|
||||||
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
|
||||||
|
|||||||
@@ -539,6 +539,11 @@ import {
|
|||||||
buildWoodenFishSessionFromWorkDetail,
|
buildWoodenFishSessionFromWorkDetail,
|
||||||
} from './platformMiniGameSessionMappingModel';
|
} from './platformMiniGameSessionMappingModel';
|
||||||
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
|
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
|
||||||
|
import {
|
||||||
|
adjustProfileDashboardWalletBalance,
|
||||||
|
reconcileProfileWalletLocalDeltaWithServerDashboard,
|
||||||
|
resolveProfileWalletBalance,
|
||||||
|
} from './platformProfileWalletDeltaModel';
|
||||||
import {
|
import {
|
||||||
type PlatformPublicCodeSearchStep,
|
type PlatformPublicCodeSearchStep,
|
||||||
resolvePlatformPublicCodeSearchPlan,
|
resolvePlatformPublicCodeSearchPlan,
|
||||||
@@ -1049,60 +1054,6 @@ function openPuzzleRuntimeStage(
|
|||||||
writePuzzleRuntimeUrlState(state);
|
writePuzzleRuntimeUrlState(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProfileWalletBalance(
|
|
||||||
dashboard: { walletBalance?: number | null } | null | undefined,
|
|
||||||
) {
|
|
||||||
const walletBalance = dashboard?.walletBalance;
|
|
||||||
return typeof walletBalance === 'number' && Number.isFinite(walletBalance)
|
|
||||||
? Math.max(0, Math.floor(walletBalance))
|
|
||||||
: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function adjustProfileDashboardWalletBalance(
|
|
||||||
dashboard: ProfileDashboardSummary | null,
|
|
||||||
delta: number,
|
|
||||||
): ProfileDashboardSummary | null {
|
|
||||||
if (!dashboard || !Number.isFinite(delta) || delta === 0) {
|
|
||||||
return dashboard;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...dashboard,
|
|
||||||
walletBalance: Math.max(
|
|
||||||
0,
|
|
||||||
resolveProfileWalletBalance(dashboard) + Math.trunc(delta),
|
|
||||||
),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function reconcileProfileWalletLocalDeltaWithServerDashboard(
|
|
||||||
previousDashboard: ProfileDashboardSummary | null,
|
|
||||||
latestDashboard: ProfileDashboardSummary | null,
|
|
||||||
localDelta: number,
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
!previousDashboard ||
|
|
||||||
!latestDashboard ||
|
|
||||||
!Number.isFinite(localDelta) ||
|
|
||||||
localDelta === 0
|
|
||||||
) {
|
|
||||||
return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const previousBalance = resolveProfileWalletBalance(previousDashboard);
|
|
||||||
const latestBalance = resolveProfileWalletBalance(latestDashboard);
|
|
||||||
const normalizedDelta = Math.trunc(localDelta);
|
|
||||||
|
|
||||||
if (normalizedDelta < 0) {
|
|
||||||
const reflectedDebit = Math.max(0, previousBalance - latestBalance);
|
|
||||||
return Math.min(0, normalizedDelta + reflectedDebit);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reflectedCredit = Math.max(0, latestBalance - previousBalance);
|
|
||||||
return Math.max(0, normalizedDelta - reflectedCredit);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPuzzleFormOnlyDraft(session: PuzzleAgentSessionSnapshot | null) {
|
function isPuzzleFormOnlyDraft(session: PuzzleAgentSessionSnapshot | null) {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
session?.stage === 'collecting_anchors' && session.draft?.formDraft,
|
session?.stage === 'collecting_anchors' && session.draft?.formDraft,
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import {
|
||||||
|
adjustProfileDashboardWalletBalance,
|
||||||
|
reconcileProfileWalletLocalDeltaWithServerDashboard,
|
||||||
|
resolveProfileWalletBalance,
|
||||||
|
} from './platformProfileWalletDeltaModel';
|
||||||
|
|
||||||
|
const NOW = Date.parse('2026-06-04T04:30:00.000Z');
|
||||||
|
|
||||||
|
function buildDashboard(
|
||||||
|
overrides: Partial<ProfileDashboardSummary> = {},
|
||||||
|
): ProfileDashboardSummary {
|
||||||
|
return {
|
||||||
|
walletBalance: 100,
|
||||||
|
totalPlayTimeMs: 0,
|
||||||
|
playedWorldCount: 0,
|
||||||
|
updatedAt: '2026-06-01T00:00:00.000Z',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('platformProfileWalletDeltaModel', () => {
|
||||||
|
test('normalizes wallet balance to a non-negative integer', () => {
|
||||||
|
expect(resolveProfileWalletBalance(buildDashboard({ walletBalance: 12.8 }))).toBe(
|
||||||
|
12,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
resolveProfileWalletBalance(buildDashboard({ walletBalance: -4 })),
|
||||||
|
).toBe(0);
|
||||||
|
expect(resolveProfileWalletBalance({ walletBalance: Number.NaN })).toBe(0);
|
||||||
|
expect(resolveProfileWalletBalance(null)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies local delta and refreshes dashboard timestamp', () => {
|
||||||
|
expect(
|
||||||
|
adjustProfileDashboardWalletBalance(buildDashboard(), -3.8),
|
||||||
|
).toMatchObject({
|
||||||
|
walletBalance: 97,
|
||||||
|
updatedAt: '2026-06-04T04:30:00.000Z',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
adjustProfileDashboardWalletBalance(buildDashboard({ walletBalance: 2 }), -10),
|
||||||
|
).toMatchObject({
|
||||||
|
walletBalance: 0,
|
||||||
|
});
|
||||||
|
expect(adjustProfileDashboardWalletBalance(null, 5)).toBeNull();
|
||||||
|
const dashboard = buildDashboard();
|
||||||
|
expect(adjustProfileDashboardWalletBalance(dashboard, Number.POSITIVE_INFINITY)).toBe(
|
||||||
|
dashboard,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconciles debit delta already reflected by latest server dashboard', () => {
|
||||||
|
const previous = buildDashboard({ walletBalance: 100 });
|
||||||
|
expect(
|
||||||
|
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||||
|
previous,
|
||||||
|
buildDashboard({ walletBalance: 98 }),
|
||||||
|
-5,
|
||||||
|
),
|
||||||
|
).toBe(-3);
|
||||||
|
expect(
|
||||||
|
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||||
|
previous,
|
||||||
|
buildDashboard({ walletBalance: 92 }),
|
||||||
|
-5,
|
||||||
|
),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconciles credit delta already reflected by latest server dashboard', () => {
|
||||||
|
const previous = buildDashboard({ walletBalance: 100 });
|
||||||
|
expect(
|
||||||
|
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||||
|
previous,
|
||||||
|
buildDashboard({ walletBalance: 103 }),
|
||||||
|
8,
|
||||||
|
),
|
||||||
|
).toBe(5);
|
||||||
|
expect(
|
||||||
|
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||||
|
previous,
|
||||||
|
buildDashboard({ walletBalance: 120 }),
|
||||||
|
8,
|
||||||
|
),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not reconcile when server balance moves against local delta', () => {
|
||||||
|
const previous = buildDashboard({ walletBalance: 100 });
|
||||||
|
expect(
|
||||||
|
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||||
|
previous,
|
||||||
|
buildDashboard({ walletBalance: 104 }),
|
||||||
|
-5,
|
||||||
|
),
|
||||||
|
).toBe(-5);
|
||||||
|
expect(
|
||||||
|
reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||||
|
previous,
|
||||||
|
buildDashboard({ walletBalance: 96 }),
|
||||||
|
8,
|
||||||
|
),
|
||||||
|
).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
|
||||||
|
type ProfileWalletBalanceSource =
|
||||||
|
| Pick<ProfileDashboardSummary, 'walletBalance'>
|
||||||
|
| { walletBalance?: number | null }
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export function resolveProfileWalletBalance(
|
||||||
|
dashboard: ProfileWalletBalanceSource,
|
||||||
|
) {
|
||||||
|
const walletBalance = dashboard?.walletBalance;
|
||||||
|
return typeof walletBalance === 'number' && Number.isFinite(walletBalance)
|
||||||
|
? Math.max(0, Math.floor(walletBalance))
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adjustProfileDashboardWalletBalance(
|
||||||
|
dashboard: ProfileDashboardSummary | null,
|
||||||
|
delta: number,
|
||||||
|
): ProfileDashboardSummary | null {
|
||||||
|
if (!dashboard || !Number.isFinite(delta) || delta === 0) {
|
||||||
|
return dashboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...dashboard,
|
||||||
|
walletBalance: Math.max(
|
||||||
|
0,
|
||||||
|
resolveProfileWalletBalance(dashboard) + Math.trunc(delta),
|
||||||
|
),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileProfileWalletLocalDeltaWithServerDashboard(
|
||||||
|
previousDashboard: ProfileDashboardSummary | null,
|
||||||
|
latestDashboard: ProfileDashboardSummary | null,
|
||||||
|
localDelta: number,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!previousDashboard ||
|
||||||
|
!latestDashboard ||
|
||||||
|
!Number.isFinite(localDelta) ||
|
||||||
|
localDelta === 0
|
||||||
|
) {
|
||||||
|
return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBalance = resolveProfileWalletBalance(previousDashboard);
|
||||||
|
const latestBalance = resolveProfileWalletBalance(latestDashboard);
|
||||||
|
const normalizedDelta = Math.trunc(localDelta);
|
||||||
|
|
||||||
|
if (normalizedDelta < 0) {
|
||||||
|
const reflectedDebit = Math.max(0, previousBalance - latestBalance);
|
||||||
|
return Math.min(0, normalizedDelta + reflectedDebit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reflectedCredit = Math.max(0, latestBalance - previousBalance);
|
||||||
|
return Math.max(0, normalizedDelta - reflectedCredit);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user