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,