diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index aa532dc60..df1709dd4 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -1,4 +1,5 @@ import type { + ProfileMembershipChangeKindToken, ProfileMembershipCycleKind, ProfileMembershipModelAccess, ProfileMembershipPlan, @@ -953,8 +954,8 @@ export interface AdminRechargeRefundHoldPayload { } export interface AdminMembershipOrderChangePayload { - /** `purchase`(新购)或 `upgrade`(补差升级)。 */ - changeKind: 'purchase' | 'upgrade'; + /** `purchase`(新购)或 `upgrade`(补差升级);复用后端生成 token。 */ + changeKind: ProfileMembershipChangeKindToken; planBefore?: ProfileMembershipPlan | null; planAfter?: ProfileMembershipPlan | null; cycleKindBefore?: ProfileMembershipCycleKind | null; diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index c1822b965..f3a02184e 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -9,6 +9,7 @@ import { AdminStatusPill, AdminTag, AdminTagList, + formatAdminCurrencyCents, } from '@genarrative/shared/components'; import { RefreshCcw, ShieldAlert, UserRound, X } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; @@ -382,12 +383,14 @@ export function AdminUserDetailDialog({ {order.productTitle || order.productId} {order.paidAmountCents > 0 - ? formatMoney(order.paidAmountCents) + ? formatAdminCurrencyCents(order.paidAmountCents) : '未支付'} {order.pointsDelta} 泥点 - {formatMoney(order.cumulativeSuccessRefundCents)} + {formatAdminCurrencyCents( + order.cumulativeSuccessRefundCents, + )} 欠账 {order.unrecoveredPoints} 泥点 {formatOrderStatus(order.status)} @@ -442,7 +445,7 @@ function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) { value: ( <> {typeof detail.cumulativeRechargedCents === 'number' - ? formatMoney(detail.cumulativeRechargedCents) + ? formatAdminCurrencyCents(detail.cumulativeRechargedCents) : '未知'} ), @@ -545,7 +548,10 @@ function MembershipSection({ ['状态', formatMembershipStatus(membership.status) || '-'], [ '账期', - `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期`, + // 非会员时后端投影返回占位账期(monthly / 1/1),不展示成“正在计费”。 + membership.status === 'active' + ? `${formatMembershipCycleKind(membership.cycleKind)} 第 ${membership.cycleIndex}/${membership.cycleCount} 期` + : '-', ], ['本账期发放', `${membership.cycleGrantedPoints} 泥点`], ['本账期剩余', `${membership.cycleRemainingPoints} 泥点`], @@ -577,10 +583,6 @@ function MembershipSection({ ); } -function formatMoney(cents: number) { - return `¥${(cents / 100).toFixed(2)}`; -} - function formatMicros(value: number) { if (!Number.isFinite(value) || value <= 0) { return '-'; diff --git a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx index f3692160b..c9198234f 100644 --- a/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx +++ b/apps/admin-web/src/pages/AdminMembershipPlanPage.tsx @@ -8,6 +8,7 @@ import { AdminPage, AdminPageHeading, AdminPanel, + formatAdminCurrencyCents, } from '@genarrative/shared/components'; import { RefreshCcw, Save } from 'lucide-react'; import { FormEvent, useEffect, useState } from 'react'; @@ -92,11 +93,12 @@ export function AdminMembershipPlanPage({ setListErrorMessage(''); try { const response = await listProfileMembershipPlans(token); - setEntries(response.entries); + // 与保存后的排序保持一致:列表按升级基准 `rank` 展示。 + const sortedEntries = sortPlanEntries(response.entries); + setEntries(sortedEntries); setUnlimitedConcurrencyThreshold(response.unlimitedConcurrencyThreshold); const firstEntry = - response.entries.find((entry) => entry.plan === plan) ?? - response.entries[0]; + sortedEntries.find((entry) => entry.plan === plan) ?? sortedEntries[0]; if (firstEntry) { fillForm(firstEntry); } @@ -409,8 +411,8 @@ export function AdminMembershipPlanPage({ - {formatPrice(entry.monthPriceCents)} /{' '} - {formatPrice(entry.yearPriceCents)} + {formatAdminCurrencyCents(entry.monthPriceCents)} /{' '} + {formatAdminCurrencyCents(entry.yearPriceCents)} {entry.periodPoints} {formatPlanLimits(entry)} @@ -459,7 +461,3 @@ function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) { : `${entry.concurrentJobLimit} 个`; return `${modelLabel} · 并发 ${concurrency}`; } - -function formatPrice(priceCents: number) { - return `¥${(priceCents / 100).toFixed(2)}`; -} diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index efca3ed1d..1d5bf6c42 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -13,6 +13,7 @@ import { AdminPanelHeading, AdminTag, AdminTagList, + formatAdminCurrencyCents, } from '@genarrative/shared/components'; import { AlertTriangle, @@ -312,7 +313,7 @@ export function AdminRechargeOrderPage({ setRefundRequestId(requestId); const confirmed = await confirmWrite({ action: '发起微信退款并追回泥点', - target: `${refundOrder.orderId} / ${formatMoney(amountCents)}`, + target: `${refundOrder.orderId} / ${formatAdminCurrencyCents(amountCents)}`, }); if (!confirmed || submitLockRef.current) { return; @@ -721,15 +722,19 @@ export function AdminRechargeOrderPage({
预检后剩余可退, - value: <>{formatMoney(preview.remainingRefundableCents)}, + value: ( + <> + {formatAdminCurrencyCents( + preview.remainingRefundableCents, + )} + + ), }, ]} /> @@ -984,16 +995,32 @@ export function AdminRechargeOrderPage({ }, { label: <>本地订单总额, - value: <>{formatMoney(manualReviewTarget.order.amountCents)}, + value: ( + <> + {formatAdminCurrencyCents( + manualReviewTarget.order.amountCents, + )} + + ), }, { label: <>退款订单总额, - value: <>{formatMoney(manualReviewTarget.refund.totalCents)}, + value: ( + <> + {formatAdminCurrencyCents( + manualReviewTarget.refund.totalCents, + )} + + ), }, { label: <>本次退款金额, value: ( - <>{formatMoney(manualReviewTarget.refund.refundCents)} + <> + {formatAdminCurrencyCents( + manualReviewTarget.refund.refundCents, + )} + ), }, ]} @@ -1107,7 +1134,7 @@ function RechargeOrderRow({ {formatOrderPaidAmount(order)} {order.paidAmountCents > 0 ? null : ( - 订单 {formatMoney(order.amountCents)} + 订单 {formatAdminCurrencyCents(order.amountCents)} )} @@ -1117,7 +1144,9 @@ function RechargeOrderRow({ ) : null} - 累计 {formatMoney(order.cumulativeSuccessRefundCents)} + + 累计 {formatAdminCurrencyCents(order.cumulativeSuccessRefundCents)} + 应追回 {order.targetRecoveryPoints} / 已追回 {order.recoveredPoints} @@ -1313,14 +1342,10 @@ function formatMembershipChangeSummary( return `${action} ${planPath}${cycle}`; } -function formatMoney(cents: number) { - return `¥${(cents / 100).toFixed(2)}`; -} - /** 实付只属于真正支付过的订单:未支付 / 已关闭 / 已过期订单显示“未支付”。 */ function formatOrderPaidAmount(order: AdminRechargeOrderEntryPayload) { return order.paidAmountCents > 0 - ? formatMoney(order.paidAmountCents) + ? formatAdminCurrencyCents(order.paidAmountCents) : '未支付'; } diff --git a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx index 4b6c3c0ea..a53ab5285 100644 --- a/apps/admin-web/src/pages/AdminRechargeProductPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeProductPage.tsx @@ -8,6 +8,7 @@ import { AdminPage, AdminPageHeading, AdminPanel, + formatAdminCurrencyCents, formatAdminDateTime, } from '@genarrative/shared/components'; import { RefreshCcw, Save } from 'lucide-react'; @@ -333,7 +334,7 @@ export function AdminRechargeProductPage({ {entry.productId} - {formatPrice(entry.priceCents)} + {formatAdminCurrencyCents(entry.priceCents)} {formatProductContent(entry)} {entry.enabled ? '启用' : '停用'} @@ -384,10 +385,6 @@ function formatProductContent( return `${entry.pointsAmount} 泥点`; } -function formatPrice(priceCents: number) { - return `¥${(priceCents / 100).toFixed(2)}`; -} - function parseInteger(value: string) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index 620701eb0..eb2d75db9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -290,6 +290,32 @@ mod tests { ); } + #[test] + fn legacy_raw_catalog_payload_defaults_optional_fields() { + // 旧 dev 代理 / 本地 fixture 直接返回裸目录(没有 `ok` 信封),且缺少 + // `agentMode` / `protocol` / `unavailableModels`:必须仍能解析,并按 Codex + + // OpenAI Responses + 空不可用桶兼容。 + let body = serde_json::json!({ + "defaultModelId": "quality", + "models": [{ "id": "quality", "displayName": "高质量" }], + "revision": 9 + }) + .to_string(); + + let catalog = parse_catalog_payload(body.as_bytes()).expect("raw catalog should parse"); + assert_eq!(catalog.default_model_id, "quality"); + assert_eq!(catalog.revision, 9); + assert!(catalog.unavailable_models.is_empty()); + assert_eq!( + catalog.models[0].agent_mode, + shared_contracts::llm::AgcAgentMode::Codex + ); + assert_eq!( + catalog.models[0].protocol, + shared_contracts::llm::AgcModelProtocol::OpenAiResponses + ); + } + #[tokio::test] async fn official_catalog_fails_closed_on_http_error_without_accepting_error_as_data() { let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index fb7e47c20..63d8d9d82 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -1,7 +1,7 @@ +import { resolveMembershipProductId } from '@genarrative/shared'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { type PlatformProfileRechargeNativePaymentState } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal'; -import { resolveMembershipProductId } from '../../../../../packages/shared/src/components/PlatformProfileRechargeModal/membershipProduct'; import type { ProfileMembershipCycleKind, ProfileMembershipPlan, @@ -19,6 +19,7 @@ import { getClientProfileWalletLedger, redeemClientProfileRewardCode, } from '../../services/accountHost'; +import { notifyLlmConfigChanged } from '../../services/llmModelCatalog'; import { useWalletStore } from '../../stores/useWalletStore'; export function useAccountWallet(currentUserId: string) { @@ -363,6 +364,11 @@ export function useAccountWallet(currentUserId: string) { applyRechargeContent(walletSnapshot, response.center); if (response.order.status === 'paid') { setNativeRechargePayment(null); + // 会员到账会改变账号可用的模型档位,但目录 revision 不变;显式通知 + // ConversationModelSelect 重置已应用 revision 并重新读取,否则锁定列表保持陈旧。 + if (response.order.kind === 'membership') { + notifyLlmConfigChanged(); + } void onWalletBalanceMayHaveChanged(); return; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx index ab1accc17..f2b502107 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.test.tsx @@ -84,7 +84,7 @@ afterEach(() => { }); describe('ConversationModelSelect 锁定模型', () => { - it('不可用模型渲染为不可点、可聚焦的原因项', async () => { + it('不可用模型渲染为不可点、可悬停查看原因且不进入 Tab 序列', async () => { mockConfig('basic-model', true); render(); // 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。 @@ -96,8 +96,12 @@ describe('ConversationModelSelect 锁定模型', () => { const planLocked = screen.getByText('专业模型').closest('[role="option"]'); expect(planLocked?.tagName).toBe('DIV'); expect(planLocked?.getAttribute('aria-disabled')).toBe('true'); - expect(planLocked?.getAttribute('tabindex')).toBe('0'); + // 非焦点项:不进入 Tab 序列,避免键盘在不可选项上停留。 + expect(planLocked?.getAttribute('tabindex')).toBeNull(); expect(planLocked?.getAttribute('data-reason')).toBe('plan_required'); + expect(planLocked?.getAttribute('aria-label')).toBe( + '专业模型:订阅计划不支持', + ); expect(planLocked?.getAttribute('data-tooltip')).toBe('订阅计划不支持'); const disabledLocked = screen diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx index 951f2274e..040474bf1 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ConversationModelSelect.tsx @@ -475,7 +475,8 @@ export function ConversationModelSelect({ ) : null} ))} - {/* 目录里存在但当前档位不可用的模型:不可点,仅 hover / 键盘 focus 出原因。 */} + {/* 目录里存在但当前档位不可用的模型:不可点;原因通过 aria-label 与 hover 提示暴露, + 且刻意不进入 Tab 序列,避免键盘用户在不可选项上停留。 */} {unavailableModels.map((model) => { const reasonLabel = unavailableModelReasonLabel(model.reason); return ( @@ -485,7 +486,6 @@ export function ConversationModelSelect({ aria-selected={false} aria-disabled="true" aria-label={`${model.displayName}:${reasonLabel}`} - tabIndex={0} className="conversation-model-menu-option conversation-model-menu-option-locked" data-reason={model.reason} data-tooltip={reasonLabel} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index b32026e6f..b9ce69ecf 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -11365,12 +11365,11 @@ button.design-workspace-tree__entry:hover, position: relative; cursor: not-allowed; } -.conversation-model-menu-option-locked:hover, -.conversation-model-menu-option-locked:focus-visible { +.conversation-model-menu-option-locked:hover { background: var(--platform-button-ghost-fill, #f3f4f6); outline: none; } -/* 锁定项的原因提示:仅 hover / 键盘 focus 出现(AGC 桌面端,无移动端适配)。 */ +/* 锁定项的原因提示:仅 hover 出现;锁定项不进入 Tab 序列,不再有键盘 focus 态。 */ .conversation-model-menu-option-locked[data-tooltip]::after { content: attr(data-tooltip); position: absolute; @@ -11389,8 +11388,7 @@ button.design-workspace-tree__entry:hover, white-space: normal; pointer-events: none; } -.conversation-model-menu-option-locked[data-tooltip]:hover::after, -.conversation-model-menu-option-locked[data-tooltip]:focus-visible::after { +.conversation-model-menu-option-locked[data-tooltip]:hover::after { display: block; } .conversation-model-menu-loading { diff --git a/apps/ai-game-creator-shell/tests/clientApi.test.ts b/apps/ai-game-creator-shell/tests/clientApi.test.ts index 8d7b8ba04..045aa9194 100644 --- a/apps/ai-game-creator-shell/tests/clientApi.test.ts +++ b/apps/ai-game-creator-shell/tests/clientApi.test.ts @@ -13,23 +13,19 @@ beforeEach(() => { vi.mocked(resolveTauriInvoke).mockReturnValue(invoke); }); -test('模型目录缺少 agentMode 时默认使用 codex,同时保留官方 cc 路由', async () => { - invoke.mockResolvedValue({ +test('模型目录直接透传 Rust typed command 的解析结果', async () => { + // agentMode / protocol 缺省已下沉到 Rust 的 load_game_creator_llm_models, + // 渲染层不再做二次归一化,这里断言原样透传即可。 + const catalog = { defaultModelId: 'quality', models: [ { id: 'quality', displayName: '高质量' }, { id: 'claude', displayName: 'Claude', agentMode: 'cc' }, ], revision: 1, - }); + }; + invoke.mockResolvedValue(catalog); - await expect(loadClientLlmModels()).resolves.toEqual({ - defaultModelId: 'quality', - models: [ - { id: 'quality', displayName: '高质量', agentMode: 'codex' }, - { id: 'claude', displayName: 'Claude', agentMode: 'cc' }, - ], - revision: 1, - }); + await expect(loadClientLlmModels()).resolves.toEqual(catalog); expect(invoke).toHaveBeenCalledWith('load_game_creator_llm_models'); }); diff --git a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx index e34229b6a..c75b60aea 100644 --- a/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx +++ b/apps/ai-game-creator-shell/tests/conversationModelSelect.test.tsx @@ -755,3 +755,27 @@ test('keeps the applied catalog when the revision is unchanged', async () => { expect(screen.queryByRole('option', { name: '视觉' })).toBeNull(); expect(screen.getByRole('option', { name: '快速' })).not.toBeNull(); }); + +test('applies a changed account catalog after an explicit config-changed notification', async () => { + const onReady = vi.fn(); + render(); + await waitFor(() => expect(onReady).toHaveBeenLastCalledWith(true)); + + // 会员升降级只改变账号可用档位,目录 revision 不变;显式 notify 必须重置已应用 revision + // 并强制重新应用,否则锁定列表会保持陈旧(上一条用例锁住「仅靠 focus 不会更新」)。 + vi.mocked(loadClientLlmModels).mockResolvedValue({ + defaultModelId: 'quality', + models: [ + { id: 'quality', displayName: '高质量', agentMode: 'codex' }, + { id: 'vision', displayName: '视觉', agentMode: 'codex' }, + ], + revision: 1, + }); + act(() => notifyLlmConfigChanged()); + await waitFor(() => expect(loadClientLlmModels).toHaveBeenCalledTimes(2)); + fireEvent.click(screen.getByRole('button', { name: '对话模型' })); + await waitFor(() => + expect(screen.getByRole('option', { name: '视觉' })).not.toBeNull(), + ); + expect(screen.queryByRole('option', { name: '快速' })).toBeNull(); +}); diff --git a/docs/project-memory/shared-memory/team-conventions.md b/docs/project-memory/shared-memory/team-conventions.md index 8fb88c73c..a584c0285 100644 --- a/docs/project-memory/shared-memory/team-conventions.md +++ b/docs/project-memory/shared-memory/team-conventions.md @@ -57,6 +57,7 @@ - 对已明确退役且无现役调用方、公开契约、持久化数据、活跃实例或迁移要求的对象,直接清理实现、专属测试和说明,将权威文档更新为当前状态;历史由 Git 保存。公开契约、持久化数据和正式迁移按实际需求保留最小兼容及对应测试。 - 修改 `/api/external/v1` 时,同批更新 `docs/openapi/genarrative-external-v1.openapi.json` 与契约测试。 - 修改 SpacetimeDB schema 时遵守字段追加/default 约束,同步 migration、表目录、生成绑定,并运行 schema 检查;删除、改名、重排或改类型前先确认迁移计划。 +- `shared-contracts` 响应 DTO 只增不删:新增字段一律可选(`Option`,或带安全 `#[serde(default ...)]`),身份 / 状态字段保持必填;删除、改名、改类型或改必填属性前先确认版本化计划,不保留仅服务旧客户端的 legacy shim。详见后端架构文档「shared-contracts DTO 变更规则」。 - 日志不递归输出完整配置、应用状态或 provider client;新增字段默认不进入安全摘要。所有 provider 配置类型(`LlmConfig`、`OssConfig`、`WechatConfig`、`WechatPayConfig`、`MattingConfig`、`VolcengineSpeechConfig`、`Hyper3dSettings`、`VectorEngineImageSettings`、`VectorEngineAudioSettings`)都必须手写脱敏 `Debug`:只输出枚举、数值、布尔、有界标识与 `` 占位,凭据、私钥、路径与 URL 一律不递归格式化,并各带一条哨兵值回归用例(`cargo test -p platform-* debug`)。新增同类配置必须照此办理,不允许直接 `derive(Debug)`。 - HTTP 横切能力集中在 Axum/Tower 中间件:正常与降级路由复用追踪层;指标与 trace 使用 `MatchedPath` 模板及固定兜底,不把请求 ID、实际资源 ID 或 query 放入指标标签。在途请求通过 RAII guard 覆盖 Future 取消与 panic unwind;请求执行和响应体存活分别计量,不能把 handler 耗时当作 SSE 全生命周期。 - 业务依赖在组合根显式装配,Axum `FromRef` 只抽取可浅拷贝的窄能力。项目元数据与 External API 鉴权不持有完整 `AppState`,测试经相同接口注入替代依赖。集中鉴权仍保留方法级 fallback、公开入口、MCP 和 body limit 顺序;Provider span 跳过完整参数,不隐藏计费、重试、幂等或事务规则。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d126fdf18..c4098f335 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -151,6 +151,14 @@ npm run check:server-rs-ddd 本地联调和人工排障不要使用 `spacetime --root-dir`。本地数据隔离使用项目脚本或 `--data-dir`;发布目标显式传 `--server` / `--server-url`。 +## shared-contracts DTO 变更规则 + +1. 面向前端 / AGC 壳的**响应** DTO 只增不删:新增字段一律可选——`Option`,或非 `Option` 时带 `#[serde(default ...)]` 且默认值安全(空集合 / `0` / `false` / 最小合法值)。这样「客户端先于服务端发布」的滚动升级不会因为旧服务端缺字段而整包解析失败;这是兼容未来版本的手段,不是给当前旧客户端保留 legacy shim。 +2. 身份与状态类字段(例如 `ProfileMembershipResponse.plan` / `status`、`enabled`)保持必填,不给默认值:fail-open 会把付费会员误显示成非会员或反过来,静默错误比整包失败更难排查。 +3. 未知 token 值继续走 `deserialize_forward_compat_*` 兜底;它与「缺字段默认值」是两条独立路径,都要有契约测试(旧服务端缺字段、新服务端多发未知取值各一条)。 +4. 删除字段、改名、改类型或改必填属性属于破坏性变更,必须先确认版本化 / 迁移计划并更新权威文档;除非明确要求新旧客户端混跑,否则不为旧客户端保留 legacy shim。 +5. 响应 DTO 的 serde 属性不影响 ts-rs 绑定;字段集合或类型变化必须重新生成绑定并运行 `npm run check:generated-bindings`。 + ## 账户充值数据契约 1. `profile_recharge_product_config` 是泥点和会员商品配置真相源,默认商品只在表为空时由 SpacetimeDB 播种。`module-runtime` 中的默认商品 helper 只作为空库种子和兼容入口,不再作为运行期业务真相。 diff --git a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx index 97fd52512..1880bd60c 100644 --- a/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx +++ b/packages/shared/src/components/PlatformMudPointWalletEntry/index.tsx @@ -13,14 +13,13 @@ import MUD_POINT_ICON_SRC from '../../icons/topbar-wallet.png'; import { formatMudPointCount } from '../../utils/format'; import { formatMudPointResetAt, - type ProfileMudPointBalanceView, resolveProfileMudPointBalanceView, shouldShowProfileDailyFreePool, } from '../../utils/mudPoints'; export type PlatformMudPointWalletEntryProps = { balance: number | null; - breakdown?: ProfileMudPointBalance | ProfileMudPointBalanceView | null; + breakdown?: ProfileMudPointBalance | null; isLoading?: boolean; error?: string | null; variant?: 'desktop' | 'mobile' | 'editor'; diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx index 0c15d0211..86cc808b0 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.test.tsx @@ -530,6 +530,50 @@ describe('PlatformProfileRechargeModal', () => { ).toHaveProperty('disabled', true); }); + test('泥点订单提交中时会员结算入口禁用', async () => { + const user = userEvent.setup(); + const renderModal = (submittingProductId: string | null) => ( + + ); + + const { rerender } = render(renderModal(null)); + await user.click( + screen.getByRole('button', { name: 'Starter 月付 开通会员' }), + ); + + // 泥点订单提交中:会员结算 CTA 同样禁用,避免宿主守卫静默丢弃提交后直接关弹层。 + rerender(renderModal('points_60')); + expect( + within(screen.getByRole('dialog', { name: '确认订单' })).getByRole( + 'button', + { name: '支付 ¥39' }, + ), + ).toHaveProperty('disabled', true); + }); + test('shows the empty state when the recharge tab has no point product', async () => { const user = userEvent.setup(); diff --git a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx index 390df4a4d..25764bb1c 100644 --- a/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx +++ b/packages/shared/src/components/PlatformProfileRechargeModal/index.tsx @@ -1074,7 +1074,8 @@ function PlatformRechargeCheckoutDialog({ } const canSubmit = Boolean(plan) && - !submitting && + // 与充点分支一致:任一商品在途都禁用提交,避免静默无操作后直接关弹层。 + !submittingProductId && (!isUpgrade || Boolean(quoteForTarget)) && amountCents > 0; diff --git a/packages/shared/src/components/admin/AdminFormat.test.ts b/packages/shared/src/components/admin/AdminFormat.test.ts index 13b169cab..f3a8c7466 100644 --- a/packages/shared/src/components/admin/AdminFormat.test.ts +++ b/packages/shared/src/components/admin/AdminFormat.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'vitest'; -import { formatAdminDate, formatAdminDateTime } from './AdminFormat'; +import { + formatAdminCurrencyCents, + formatAdminDate, + formatAdminDateTime, +} from './AdminFormat'; describe('formatAdminDateTime', () => { test('把后端的 seconds.microsZ 口径当成同一时刻渲染', () => { @@ -44,3 +48,11 @@ describe('formatAdminDate', () => { expect(formatAdminDate('1791008678.817257Z')).not.toBe('-'); }); }); + +describe('formatAdminCurrencyCents', () => { + test('分转成带两位小数的元', () => { + expect(formatAdminCurrencyCents(0)).toBe('¥0.00'); + expect(formatAdminCurrencyCents(600)).toBe('¥6.00'); + expect(formatAdminCurrencyCents(1234)).toBe('¥12.34'); + }); +}); diff --git a/packages/shared/src/components/admin/AdminFormat.ts b/packages/shared/src/components/admin/AdminFormat.ts index 21e10f666..d69a0edfc 100644 --- a/packages/shared/src/components/admin/AdminFormat.ts +++ b/packages/shared/src/components/admin/AdminFormat.ts @@ -54,6 +54,11 @@ export function formatAdminDateTime( } } +/** 后台统一的金额展示:分 → `¥12.34`;避免各后台页面各自实现导致格式漂移。 */ +export function formatAdminCurrencyCents(cents: number): string { + return `¥${(cents / 100).toFixed(2)}`; +} + /** 后台统一的日期展示(不含时分秒):同样接受 `seconds.microsZ` 口径。 */ export function formatAdminDate( value: string | number | Date | null | undefined, diff --git a/packages/shared/src/components/admin/index.ts b/packages/shared/src/components/admin/index.ts index 8483c98ba..081fcb5ec 100644 --- a/packages/shared/src/components/admin/index.ts +++ b/packages/shared/src/components/admin/index.ts @@ -19,7 +19,11 @@ export type { AdminFormRowProps, } from './AdminField'; export { AdminField, AdminFieldGroup, AdminFormRow } from './AdminField'; -export { formatAdminDate, formatAdminDateTime } from './AdminFormat'; +export { + formatAdminCurrencyCents, + formatAdminDate, + formatAdminDateTime, +} from './AdminFormat'; export type { AdminInfoItemProps, AdminInfoListEntry, diff --git a/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts b/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts index e1cbc53ab..139a2608b 100644 --- a/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts +++ b/packages/shared/src/contracts/generated/AdminUpsertProfileMembershipPlanRequest.ts @@ -15,6 +15,6 @@ plan: ProfileMembershipPlanToken, title: string, monthPriceCents: number, yearPr */ modelAccess: ProfileMembershipModelAccessToken, /** - * 并发上限;`128` 表示不设上限。 + * 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 */ concurrentJobLimit: number, enabled: boolean, }; diff --git a/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts index adca547ab..c2ba7fd73 100644 --- a/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts +++ b/packages/shared/src/contracts/generated/ProfileMembershipPlanResponse.ts @@ -12,7 +12,7 @@ plan: ProfileMembershipPlanToken, title: string, rank: number, monthPriceCents: */ modelAccess: ProfileMembershipModelAccessToken, /** - * 并发上限;`128` 表示不设上限。 + * 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 */ concurrentJobLimit: number, /** diff --git a/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts b/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts index fa5fd8db4..0dd646fd0 100644 --- a/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts +++ b/packages/shared/src/contracts/generated/ProfileMembershipResponse.ts @@ -5,7 +5,7 @@ import type { ProfileMembershipStatusToken } from "./ProfileMembershipStatusToke export type ProfileMembershipResponse = { /** - * 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。 + * 会员状态 token:`normal` / `active`;Rust 客户端反序列化时未知取值按 `active` 兜底,TS 侧不做运行期归一化。 */ status: ProfileMembershipStatusToken, /** diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ecf7a17c8..cf0fbb683 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './components'; +export * from './components/PlatformProfileRechargeModal/membershipProduct'; export * from './contracts/auth'; export type * from './contracts/clientDownload'; export * from './contracts/common'; diff --git a/scripts/admin-web-fake-api.mjs b/scripts/admin-web-fake-api.mjs index b2355f662..298f8dc43 100644 --- a/scripts/admin-web-fake-api.mjs +++ b/scripts/admin-web-fake-api.mjs @@ -454,10 +454,14 @@ const userDetail = { createdAt: iso(60 * 24 * 120), lastLoginAt: iso(30), wallet: { - balance: 1860, - permanentPoints: 1200, + userId: 'u_1001', + totalBalance: 1860, spendableBalance: 1660, + dailyFreePoints: 0, + monthlyPoints: 0, + permanentPoints: 1200, heldPoints: 200, + refundDebtPoints: 0, walletFrozen: false, manualFrozen: false, refundDebtFrozen: false, @@ -771,6 +775,8 @@ function route(pathname, method, url, requestBody = '') { periodPoints, modelAccess, concurrentJobLimit, + // 与后端目录单一真相源 `PROFILE_MEMBERSHIP_RECOMMENDED_PLAN` 对齐:运营推荐 Plus。 + recommended: planId === 'plus', unlimitedConcurrency: concurrentJobLimit >= UNLIMITED_CONCURRENCY_THRESHOLD, enabled, diff --git a/scripts/check-server-rs-ddd-boundaries.mjs b/scripts/check-server-rs-ddd-boundaries.mjs index 9a77b9633..aecacf094 100644 --- a/scripts/check-server-rs-ddd-boundaries.mjs +++ b/scripts/check-server-rs-ddd-boundaries.mjs @@ -70,7 +70,6 @@ const retiredOriginalSpacetimeSources = new Set([ 'runtime/admin_work_visibility.rs', 'runtime/browse_history.rs', 'runtime/creation_entry_config.rs', - 'runtime/profile.rs', 'runtime/settings.rs', 'runtime/snapshots.rs', ]); diff --git a/scripts/check-spacetime-runtime-access.mjs b/scripts/check-spacetime-runtime-access.mjs index e48e102b9..84f43c482 100644 --- a/scripts/check-spacetime-runtime-access.mjs +++ b/scripts/check-spacetime-runtime-access.mjs @@ -54,39 +54,39 @@ const forbiddenSnippets = [ 'ai_result_reference 快照组装应使用 by_ai_result_reference_task_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_save_archive()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', reason: 'profile_save_archive 已有 by_profile_save_archive_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_played_world()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', reason: 'profile_played_world 已有 by_profile_played_world_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_wallet_ledger()\n .iter()\n .filter(|row| row.user_id == validated_input.user_id)', reason: 'profile_wallet_ledger 已有 by_profile_wallet_ledger_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_referral_relation()\n .iter()\n .filter(|row| row.inviter_user_id == user_id)', reason: 'profile_referral_relation 已有 by_profile_referral_inviter_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.profile_recharge_order()\n .iter()\n .filter(|row| row.user_id == user_id)', reason: 'profile_recharge_order 已有 by_profile_recharge_order_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/runtime/profile.rs', + file: 'server-rs/crates/spacetime-module/src/runtime/active/profile.rs', snippet: '.tracking_daily_stat()\n .iter()\n .filter(|row| {', reason: 'tracking_daily_stat 已有 by_tracking_daily_stat_scope_day / event_day 索引,analytics 查询不应整表过滤', diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 833f397c0..ee069594f 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -2656,6 +2656,11 @@ mod tests { RuntimeProfileMembershipErrorCode::PlanCatalogMissing, "会员目录缺少档位 pro", ), + // 目录价格 / 每期泥点倒挂属后端配置错误,与 UpgradeRejected 分开暴露机器码。 + ( + RuntimeProfileMembershipErrorCode::InvalidPlanConfig, + "会员升级被拒绝: catalog_pricing_inverted", + ), ] { let error = map_runtime_profile_client_error(SpacetimeClientError::ProcedureRejected { code: code.into(), diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 057336e08..1569ce7d2 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1506,7 +1506,7 @@ pub enum RuntimeProfileMembershipErrorCode { ProductIdUnparseable, /// 会员商品已迁移到档位目录,旧商品不再可用。 MembershipProductRetired, - /// 后台会员档位改价 / 改权益配置校验失败。 + /// 后台会员档位改价 / 改权益配置校验失败,或读取目录时发现价格 / 每期泥点倒挂。 InvalidPlanConfig, } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 116e51d10..cedd299e1 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -27,9 +27,10 @@ pub use llm_billing::*; pub use membership::{ MEMBERSHIP_UNLIMITED_CONCURRENCY, PROFILE_MEMBERSHIP_RECOMMENDED_PLAN, RuntimeProfileMembershipPlanRecord, RuntimeProfileMembershipPlanSnapshot, - RuntimeProfileMembershipUpgradeQuoteInput, beijing_local_micros, - build_runtime_profile_membership_plan_snapshot, check_expired_membership_upgrade_settlement, - is_unlimited_concurrency, membership_cycle_window, membership_expires_at, + RuntimeProfileMembershipUpgradeQuoteInput, RuntimeProfileMembershipUpgradeRejection, + beijing_local_micros, build_runtime_profile_membership_plan_snapshot, + check_expired_membership_upgrade_settlement, is_unlimited_concurrency, membership_cycle_window, + membership_expires_at, missing_runtime_profile_membership_plan_rows, parse_runtime_profile_membership_product_id, quote_runtime_profile_membership_upgrade, resolve_runtime_profile_membership_plan_row, runtime_profile_membership_plan_catalog, runtime_profile_membership_plan_rank, runtime_profile_membership_product_id, diff --git a/server-rs/crates/module-runtime/src/membership/catalog.rs b/server-rs/crates/module-runtime/src/membership/catalog.rs index 8d0bce005..828f14b55 100644 --- a/server-rs/crates/module-runtime/src/membership/catalog.rs +++ b/server-rs/crates/module-runtime/src/membership/catalog.rs @@ -140,16 +140,28 @@ pub fn resolve_runtime_profile_membership_plan_row( .find(|row| row.plan == plan) } +/// 目录里缺失的档位行(`existing` 中不存在的目录行),按目录顺序返回。 +/// +/// 用于把「只在空表时整表播种」改成「缺哪行补哪行」的幂等回填:老库可能只有部分档位, +/// 只在空表播种会让缺行的库永远缺下去。已存在的行不返回,调用方只插入缺失行、不覆盖后台改价。 +pub fn missing_runtime_profile_membership_plan_rows( + existing: &[RuntimeProfileMembershipPlanRecord], +) -> Vec { + runtime_profile_membership_plan_catalog() + .into_iter() + .filter(|required| !existing.iter().any(|row| row.plan == required.plan)) + .collect() +} + /// 校验后台写入的档位不会破坏目录不变量。 /// /// `candidate` 是本次要落库的行,`existing` 是目录里的其余行(同 `plan` 的旧行会被忽略)。 -/// 只比较 `candidate` 与其余每一行:这样任何单次改写都不能制造新的倒挂,而目录原本单调时 -/// 写完后仍严格单调;同时历史脏目录不会把后台彻底锁死,后台仍能逐档修正。 +/// 先校验 `existing` 覆盖除 `candidate.plan` 外的全部目录档位,再逐行比较 `candidate` 与其余 +/// 每一行:这样任何单次改写都不能制造新的倒挂,也不会因为「漏传某一行」而静默放过倒挂。 /// -/// **前置条件**:`existing` 必须覆盖除 `candidate.plan` 外的全部在售档位。本函数只校验 -/// `candidate` 相对现存行的单调性,无法发现「缺失档位」导致的倒挂(例如漏传某一行时, -/// 月价 / 年价 / 每期泥点的反转关系会静默通过,之后才在报价或目录读取时暴露)。当前唯一 -/// 调用方传入完整目录表,新增调用方时必须保持这一约定。 +/// **调用方前置动作**:缺行的历史库必须先经 `ensure_default_profile_membership_plan` 回填 +/// 缺失档位(该函数按档位逐行补齐,不再只在空表时整表播种),否则本函数会直接拒绝写入。 +/// 这是有意的:缺行库继续允许任意单行改写会让目录长期残缺,报价时才暴露错误。 /// /// `model_access` 与 `concurrent_job_limit` 本期只落字段、只用于展示与人工判断,服务端不做拦截, /// 因此这里不校验它们随 rank 单调;若将来据其做准入判定,需要在此补充倒挂校验。 @@ -157,6 +169,16 @@ pub fn validate_runtime_profile_membership_plan_row_against_catalog( candidate: &RuntimeProfileMembershipPlanRecord, existing: &[RuntimeProfileMembershipPlanRecord], ) -> Result<(), RuntimeProfileFieldError> { + // 覆盖校验:缺一行就无法判断它与 candidate 的相对关系,必须显式拒绝而不是静默跳过。 + for required in runtime_profile_membership_plan_catalog() { + if required.plan == candidate.plan { + continue; + } + if !existing.iter().any(|row| row.plan == required.plan) { + return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); + } + } + for row in existing.iter().filter(|row| row.plan != candidate.plan) { if row.rank == candidate.rank { return Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder); @@ -379,6 +401,38 @@ mod tests { } } + #[test] + fn admin_upsert_rejects_a_catalog_missing_a_plan() { + // 中文注释:缺行的库(例如老版本档位更少)必须先回填再校验。漏传其它档位时, + // 即使 candidate 本身与现存行单调,也必须拒绝,避免残缺目录继续扩大。 + let candidate = row(RuntimeProfileMembershipPlan::Plus); + let existing = vec![candidate.clone()]; + assert_eq!( + validate_runtime_profile_membership_plan_row_against_catalog(&candidate, &existing), + Err(RuntimeProfileFieldError::InvalidMembershipPlanOrder) + ); + } + + #[test] + fn missing_plan_rows_backfills_only_absent_plans() { + // 老库只有 Plus:应补出目录里其余全部档位,且不重复返回已存在的 Plus。 + let existing = vec![row(RuntimeProfileMembershipPlan::Plus)]; + let missing = missing_runtime_profile_membership_plan_rows(&existing); + let plans = missing.iter().map(|row| row.plan).collect::>(); + let expected = runtime_profile_membership_plan_catalog() + .into_iter() + .map(|row| row.plan) + .filter(|plan| *plan != RuntimeProfileMembershipPlan::Plus) + .collect::>(); + assert_eq!(plans, expected); + + // 目录已补齐时不返回任何行:读取入口幂等、不再写库。 + assert!( + missing_runtime_profile_membership_plan_rows(&runtime_profile_membership_plan_catalog()) + .is_empty() + ); + } + #[test] fn admin_upsert_allows_a_monotonic_price_change() { let catalog = runtime_profile_membership_plan_catalog(); diff --git a/server-rs/crates/module-runtime/src/membership/upgrade.rs b/server-rs/crates/module-runtime/src/membership/upgrade.rs index 64a9ae25a..e2677ec35 100644 --- a/server-rs/crates/module-runtime/src/membership/upgrade.rs +++ b/server-rs/crates/module-runtime/src/membership/upgrade.rs @@ -77,6 +77,11 @@ pub enum RuntimeProfileMembershipUpgradeRejection { TargetPlanNotPurchasable, /// 报价入参的数值不变量被破坏(期号越界、账期窗口非法或 `now` 不在窗口内):拒绝,而不是静默夹取。 InvalidQuoteInput, + /// 目录价格 / 每期泥点未随 rank 严格递增(相等或倒挂)。 + /// + /// 与 [`Self::InvalidQuoteInput`] 分开:这是后端目录配置错误,不是调用方入参错误, + /// 对外应映射为配置类错误码(`invalid_plan_config`),便于监控区分。 + CatalogPricingInverted, /// 补差价订单结算时会员已到期,且订单金额不够覆盖整期新购价:应重新下单购买。 MembershipExpiredBeforeSettlement, } @@ -89,9 +94,21 @@ impl RuntimeProfileMembershipUpgradeRejection { Self::NotUpgrade => "not_upgrade", Self::TargetPlanNotPurchasable => "target_plan_not_purchasable", Self::InvalidQuoteInput => "invalid_quote_input", + Self::CatalogPricingInverted => "catalog_pricing_inverted", Self::MembershipExpiredBeforeSettlement => "membership_expired_before_settlement", } } + + /// 对外 procedure 错误码:目录价格 / 每期泥点倒挂属后端配置错误,与调用方入参错误分开, + /// 便于按机器码定位;其余拒绝仍统一为 `UpgradeRejected`。 + pub fn error_code(&self) -> crate::RuntimeProfileMembershipErrorCode { + match self { + Self::CatalogPricingInverted => { + crate::RuntimeProfileMembershipErrorCode::InvalidPlanConfig + } + _ => crate::RuntimeProfileMembershipErrorCode::UpgradeRejected, + } + } } /// 本期剩余比例(ppm):`剩余微秒 / 本期实际微秒`,向下取整,已过期或时长非法时为 0。 @@ -184,8 +201,9 @@ pub fn quote_runtime_profile_membership_upgrade( // 目录价格 / 每期泥点必须严格随 rank 递增;相等或倒挂都直接拒绝, // 否则 checked_sub 会把「相等」算成 0 元升级 / 0 补点,违背目录不变量。 + // 这是后端目录配置错误,用独立变体与调用方入参错误区分。 if target.price_cents <= current.price_cents || target.period_points <= current.period_points { - return Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput); + return Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted); } let price_delta_cents = target.price_cents - current.price_cents; let points_delta = target.period_points - current.period_points; @@ -237,18 +255,17 @@ pub fn check_expired_membership_upgrade_settlement( /// 向上取整的整数除法(金额取整到分)。 /// /// 分母是 `MONTHS_PER_YEAR * REMAINING_RATIO_PPM` 这类编译期常量,恒不为 0; -/// 零分母属程序员错误,`debug_assert` 让它在测试里立刻暴露,而不是被静默算成 0 元。 +/// 零分母属程序员错误,`debug_assert` 让它在测试里立刻暴露,release 下除零 panic, +/// 而不是被静默算成 0 元。 fn ceil_div(numerator: u128, denominator: u128) -> u64 { debug_assert!(denominator != 0, "ceil_div 的分母不能为 0"); - if denominator == 0 { - return 0; - } ((numerator + denominator - 1) / denominator) as u64 } #[cfg(test)] mod tests { use super::*; + use crate::RuntimeProfileMembershipErrorCode; use crate::RuntimeProfileMembershipPlan; use crate::membership::catalog::resolve_runtime_profile_membership_plan_snapshot; use crate::membership::cycle::beijing_local_micros; @@ -487,7 +504,7 @@ mod tests { target: target.clone(), ..base_input() }), - Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) ); target.price_cents = current.price_cents + 1; @@ -498,7 +515,7 @@ mod tests { target: target.clone(), ..base_input() }), - Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) ); // 中文注释:相等同样破坏「严格随 rank 递增」,必须拒绝;旧实现用 checked_sub @@ -511,7 +528,17 @@ mod tests { target, ..base_input() }), - Err(RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput) + Err(RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted) + ); + + // 中文注释:目录倒挂对外映射为配置错误码,与调用方入参错误(UpgradeRejected)区分。 + assert_eq!( + RuntimeProfileMembershipUpgradeRejection::CatalogPricingInverted.error_code(), + RuntimeProfileMembershipErrorCode::InvalidPlanConfig + ); + assert_eq!( + RuntimeProfileMembershipUpgradeRejection::InvalidQuoteInput.error_code(), + RuntimeProfileMembershipErrorCode::UpgradeRejected ); } diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 03a7bc41b..3b869ca74 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -234,6 +234,7 @@ pub struct ProfileMudPointBalanceResponse { pub daily_free_reset_points: u64, pub daily_free_resets_at: String, /// 月度泥点:会员每期额度,到刷新时刻清零再发新额度。 + #[serde(default)] #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub monthly_points: u64, pub monthly_resets_at: Option, @@ -419,6 +420,10 @@ impl ProfileMembershipChangeKindToken { /// 未知取值统一落到「已知的最高能力」档(plan→`max`、status→`active`、cycle→`yearly`、 /// model_access→`full`):展示上按 fail-open 处理,真实权益与下单金额一律以后端重算为准, /// 避免把付费会员误显示成非会员后引导重复购买。 +/// +/// 字段层面同样做前向兼容:本模块新增的响应字段一律带 `#[serde(default ...)]`,让「客户端先于 +/// 服务端发布」的滚动升级不会因为旧服务端缺字段而整包失败。身份 / 状态字段(`plan` / `status` +/// / `enabled`)保持必填:给它们兜底默认值会造成 fail-open 或 fail-closed 的静默误判。 fn deserialize_forward_compat_membership_plan<'de, D>( deserializer: D, ) -> Result @@ -460,11 +465,26 @@ where Ok( match Option::::deserialize(deserializer)?.as_deref() { Some("monthly") => ProfileMembershipCycleKindToken::Monthly, - _ => ProfileMembershipCycleKindToken::Yearly, + _ => default_forward_compat_membership_cycle_kind(), }, ) } +/// `cycle_kind` 缺失时的兜底(旧服务端还没下发该字段):与未知 token 一样落到 `yearly`。 +fn default_forward_compat_membership_cycle_kind() -> ProfileMembershipCycleKindToken { + ProfileMembershipCycleKindToken::Yearly +} + +/// `cycle_index` 缺失时的兜底:1-based,最小合法期号是 1。 +fn default_membership_cycle_index() -> u32 { + 1 +} + +/// `cycle_count` 缺失时的兜底:月付 1 期,是与 1-based 期号自洽的最小值。 +fn default_membership_cycle_count() -> u32 { + 1 +} + fn deserialize_forward_compat_model_access<'de, D>( deserializer: D, ) -> Result @@ -535,11 +555,13 @@ pub struct ProfileMembershipPlanResponse { /// 模型权限 token:`basic` / `full`;未知取值按 `full` 兜底。 #[serde(deserialize_with = "deserialize_forward_compat_model_access")] pub model_access: ProfileMembershipModelAccessToken, - /// 并发上限;`128` 表示不设上限。 + /// 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 pub concurrent_job_limit: u32, /// 是否为运营推荐档位;由后端目录决定,前端不再硬编码档位 token。 + #[serde(default)] pub recommended: bool, /// 是否不设并发上限;由后端按哨兵值派生,前端不再硬编码 `128`。 + #[serde(default)] pub unlimited_concurrency: bool, pub enabled: bool, } @@ -558,7 +580,7 @@ pub struct ProfileMembershipPlanResponse { ) )] pub struct ProfileMembershipResponse { - /// 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。 + /// 会员状态 token:`normal` / `active`;Rust 客户端反序列化时未知取值按 `active` 兜底,TS 侧不做运行期归一化。 #[serde(deserialize_with = "deserialize_forward_compat_membership_status")] pub status: ProfileMembershipStatusToken, /// 当前档位 token;非会员与已到期都是 `normal`;未知取值按 `max` 兜底。 @@ -574,11 +596,16 @@ pub struct ProfileMembershipResponse { #[cfg_attr(feature = "ts-bindings", ts(type = "number"))] pub cycle_remaining_points: u64, /// 当前期序号(1-based)。 + #[serde(default = "default_membership_cycle_index")] pub cycle_index: u32, /// 本次购买总期数(月付 1 / 年付 12)。 + #[serde(default = "default_membership_cycle_count")] pub cycle_count: u32, /// 周期 token:`monthly` / `yearly`;未知取值按 `yearly` 兜底。 - #[serde(deserialize_with = "deserialize_forward_compat_membership_cycle_kind")] + #[serde( + default = "default_forward_compat_membership_cycle_kind", + deserialize_with = "deserialize_forward_compat_membership_cycle_kind" + )] pub cycle_kind: ProfileMembershipCycleKindToken, } @@ -675,6 +702,7 @@ pub struct ProfileRechargeCenterResponse { pub membership: ProfileMembershipResponse, pub point_products: Vec, /// 会员档位目录(后台可改价),会员购买 / 升级的价格与权益都以它为准。 + #[serde(default)] pub membership_plans: Vec, pub latest_order: Option, pub has_points_recharged: bool, @@ -1089,7 +1117,7 @@ pub struct AdminUpsertProfileMembershipPlanRequest { pub period_points: u64, /// 模型权限 token:`basic` / `full`;本期只落字段、不做服务端拦截。 pub model_access: ProfileMembershipModelAccessToken, - /// 并发上限;`128` 表示不设上限。 + /// 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。 pub concurrent_job_limit: u32, #[serde(default = "default_true")] pub enabled: bool, @@ -2431,6 +2459,92 @@ mod tests { assert_eq!(known, ProfileMembershipStatusToken::Normal); } + #[test] + fn membership_response_tolerates_fields_missing_from_older_backend() { + // 客户端先于服务端发布(滚动升级)时,旧服务端缺少本轮新增字段不能整包失败; + // 新增字段一律带默认值,这里锁住默认值的具体取值。 + let membership: ProfileMembershipResponse = serde_json::from_value(json!({ + "status": "active", + "plan": "plus", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0 + })) + .expect("older membership payload must still deserialize"); + assert_eq!(membership.cycle_index, 1); + assert_eq!(membership.cycle_count, 1); + assert_eq!( + membership.cycle_kind, + ProfileMembershipCycleKindToken::Yearly + ); + + let balance: ProfileMudPointBalanceResponse = serde_json::from_value(json!({ + "dailyFreePoints": 0, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-07-12T16:00:00Z", + "monthlyResetsAt": null, + "permanentPoints": 0 + })) + .expect("older balance payload must still deserialize"); + assert_eq!(balance.monthly_points, 0); + + let plan: ProfileMembershipPlanResponse = serde_json::from_value(json!({ + "plan": "plus", + "title": "Plus", + "rank": 3, + "monthPriceCents": 100, + "yearPriceCents": 1000, + "periodPoints": 100, + "modelAccess": "full", + "concurrentJobLimit": 8, + "enabled": true + })) + .expect("older plan payload must still deserialize"); + assert!(!plan.recommended); + assert!(!plan.unlimited_concurrency); + + let center: ProfileRechargeCenterResponse = serde_json::from_value(json!({ + "mudPointBalance": { + "dailyFreePoints": 0, + "dailyFreeResetPoints": 20, + "dailyFreeResetsAt": "2026-07-12T16:00:00Z", + "monthlyPoints": 0, + "monthlyResetsAt": null, + "permanentPoints": 0 + }, + "membership": { + "status": "normal", + "plan": "normal", + "startedAt": null, + "expiresAt": null, + "updatedAt": null, + "cycleStartedAt": null, + "cycleResetsAt": null, + "cycleGrantedPoints": 0, + "cycleRemainingPoints": 0, + "cycleIndex": 1, + "cycleCount": 1, + "cycleKind": "monthly" + }, + "pointProducts": [], + "latestOrder": null, + "hasPointsRecharged": false, + "dailyFreePoints": { + "dayKey": 0, + "grantedPoints": 20, + "remainingPoints": 20, + "resetsAt": "2026-07-12T16:00:00Z", + "updatedAt": "2026-07-12T00:00:00Z" + } + })) + .expect("older recharge center payload must still deserialize"); + assert!(center.membership_plans.is_empty()); + } + #[test] fn membership_request_tokens_stay_strict_for_unknown_values() { // 入参 DTO 不做兜底:非法目标档位必须在反序列化即被拒绝。 diff --git a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs index 18ad8f4ed..6b5cc12fe 100644 --- a/server-rs/crates/spacetime-module/src/runtime/active/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/active/profile.rs @@ -8764,12 +8764,7 @@ fn build_profile_membership_upgrade_quote_record( &target, ctx.timestamp, )) - .map_err(|rejection| { - RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::UpgradeRejected, - format!("会员升级被拒绝: {}", rejection.as_str()), - ) - })?; + .map_err(membership_upgrade_rejection_error)?; Ok(RuntimeProfileMembershipUpgradeQuoteSnapshot { current_plan: current.plan, @@ -8786,6 +8781,19 @@ fn build_profile_membership_upgrade_quote_record( }) } +/// 会员升级拒绝 → procedure 错误码。 +/// +/// 目录价格 / 每期泥点倒挂是后端配置错误,映射为 `InvalidPlanConfig`,与客户端入参错误 +/// (`UpgradeRejected`)区分开,便于按错误码定位;人读文案仍带具体 rejection 原因。 +fn membership_upgrade_rejection_error( + rejection: RuntimeProfileMembershipUpgradeRejection, +) -> RuntimeProfileMembershipDomainError { + RuntimeProfileMembershipDomainError::new( + rejection.error_code(), + format!("会员升级被拒绝: {}", rejection.as_str()), + ) +} + /// 后台改价 / 改权益。`rank` 与 `plan` 不可改:`plan` 是主键(即身份),`rank` 是升级比较基准。 fn upsert_profile_membership_plan_record( ctx: &ReducerContext, @@ -8806,7 +8814,9 @@ fn upsert_profile_membership_plan_record( .map_err(|error| error.to_string())?; ensure_default_profile_membership_plan(ctx); - let updated_at = Timestamp::from_micros_since_unix_epoch(validated.updated_at_micros); + // 审计时间以 SpacetimeDB 事务时间为准:入参 `updated_at_micros` 不再被信任, + // 因为 procedure 可被持有 writer 身份者直接调用,客户端时钟可以伪造。 + let updated_at = ctx.timestamp; let existing = ctx .db .profile_membership_plan() @@ -9363,12 +9373,19 @@ fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig /// 仍要靠读取 helper 懒播种。**代价**:报价 / 充值中心 / 后台查询等只读入口在目录为空的首次调用会写库; /// 表非空后本函数直接返回,后续调用保持只读。若将来改为发布期统一播种,再删除这里的懒播种与注释。 fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { - if ctx.db.profile_membership_plan().count() > 0 { - return; - } + // 中文注释:按档位逐行补齐,而不是只在空表时整表播种——增量发布不会重跑 `init`, + // 老库可能只有部分档位;只在空表播种会让缺行的库永远缺下去,目录覆盖校验与报价都会受影响。 + // 已存在的行保持后台改过的价格 / 权益不动,只补目录里没有的档位。 + let existing = ctx + .db + .profile_membership_plan() + .iter() + .map(|row| membership_plan_record_from_row(&row)) + .collect::>(); + let missing = module_runtime::missing_runtime_profile_membership_plan_rows(&existing); let now = ctx.timestamp; - for row in runtime_profile_membership_plan_catalog() { + for row in missing { ctx.db .profile_membership_plan() .insert(ProfileMembershipPlan { @@ -9389,12 +9406,12 @@ fn ensure_default_profile_membership_plan(ctx: &ReducerContext) { } } -/// 模块初始化生命周期:只在数据库首次创建时把会员档位目录整表播种。 +/// 模块初始化生命周期:数据库首次创建时把会员档位目录整表播种。 /// /// 本 reducer **不覆盖**「目录表已存在但为空」的存放量库:增量发布不会重跑 `init`。这类库由 -/// 读取 helper 里的 [`ensure_default_profile_membership_plan`] 懒播种兜底,因此报价 / 充值中心 / -/// 后台查询等「只读」入口首次调用仍可能写库(表非空后幂等只读)。这是当前有意保留的播种策略, -/// 不是漏改;若改为发布期统一播种,需同步删除懒播种调用并更新本注释。 +/// 读取 helper 里的 [`ensure_default_profile_membership_plan`] 按档位逐行补齐兜底,因此报价 / +/// 充值中心 / 后台查询等「只读」入口首次调用仍可能写库(全部档位补齐后幂等无写)。这是当前 +/// 有意保留的播种策略,不是漏改;若改为发布期统一回填,需同步删除懒补齐调用并更新本注释。 #[spacetimedb::reducer(init)] pub fn init_profile_membership_plan_catalog(ctx: &ReducerContext) { ensure_default_profile_membership_plan(ctx); @@ -10107,12 +10124,7 @@ fn build_profile_membership_order_snapshot( let quote = quote_runtime_profile_membership_upgrade(membership_upgrade_quote_input( &row, ¤t, &target, created_at, )) - .map_err(|rejection| { - RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::UpgradeRejected, - format!("会员升级被拒绝: {}", rejection.as_str()), - ) - })?; + .map_err(membership_upgrade_rejection_error)?; Ok(ProfileMembershipOrderSnapshot { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan, @@ -10731,12 +10743,7 @@ fn build_recomputed_profile_membership_settlement( &target, purchased_at, )) - .map_err(|rejection| { - RuntimeProfileMembershipDomainError::new( - RuntimeProfileMembershipErrorCode::UpgradeRejected, - format!("会员升级被拒绝: {}", rejection.as_str()), - ) - })?; + .map_err(membership_upgrade_rejection_error)?; Ok(ProfileMembershipSettlement { change_kind: RuntimeProfileMembershipChangeKind::Upgrade, plan,