Merge branch 'feat/pricing-plan-fix' into feat/pricing-plan
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
<td>{order.productTitle || order.productId}</td>
|
||||
<td>
|
||||
{order.paidAmountCents > 0
|
||||
? formatMoney(order.paidAmountCents)
|
||||
? formatAdminCurrencyCents(order.paidAmountCents)
|
||||
: '未支付'}
|
||||
</td>
|
||||
<td>{order.pointsDelta} 泥点</td>
|
||||
<td>
|
||||
{formatMoney(order.cumulativeSuccessRefundCents)}
|
||||
{formatAdminCurrencyCents(
|
||||
order.cumulativeSuccessRefundCents,
|
||||
)}
|
||||
<small>欠账 {order.unrecoveredPoints} 泥点</small>
|
||||
</td>
|
||||
<td>{formatOrderStatus(order.status)}</td>
|
||||
@@ -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 '-';
|
||||
|
||||
@@ -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({
|
||||
</small>
|
||||
</td>
|
||||
<td>
|
||||
{formatPrice(entry.monthPriceCents)} /{' '}
|
||||
{formatPrice(entry.yearPriceCents)}
|
||||
{formatAdminCurrencyCents(entry.monthPriceCents)} /{' '}
|
||||
{formatAdminCurrencyCents(entry.yearPriceCents)}
|
||||
</td>
|
||||
<td>{entry.periodPoints}</td>
|
||||
<td>{formatPlanLimits(entry)}</td>
|
||||
@@ -459,7 +461,3 @@ function formatPlanLimits(entry: ProfileMembershipPlanAdminResponse) {
|
||||
: `${entry.concurrentJobLimit} 个`;
|
||||
return `${modelLabel} · 并发 ${concurrency}`;
|
||||
}
|
||||
|
||||
function formatPrice(priceCents: number) {
|
||||
return `¥${(priceCents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<div className="admin-refund-summary-grid">
|
||||
<Metric
|
||||
label="订单实付"
|
||||
value={formatMoney(refundOrder.paidAmountCents)}
|
||||
value={formatAdminCurrencyCents(refundOrder.paidAmountCents)}
|
||||
/>
|
||||
<Metric
|
||||
label="累计已退"
|
||||
value={formatMoney(refundOrder.cumulativeSuccessRefundCents)}
|
||||
value={formatAdminCurrencyCents(
|
||||
refundOrder.cumulativeSuccessRefundCents,
|
||||
)}
|
||||
/>
|
||||
<Metric
|
||||
label="剩余可退"
|
||||
value={formatMoney(refundOrder.remainingRefundableCents)}
|
||||
value={formatAdminCurrencyCents(
|
||||
refundOrder.remainingRefundableCents,
|
||||
)}
|
||||
/>
|
||||
<Metric
|
||||
label="永久泥点"
|
||||
@@ -859,7 +864,13 @@ export function AdminRechargeOrderPage({
|
||||
},
|
||||
{
|
||||
label: <>预检后剩余可退</>,
|
||||
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({
|
||||
<td>
|
||||
<strong>{formatOrderPaidAmount(order)}</strong>
|
||||
{order.paidAmountCents > 0 ? null : (
|
||||
<small>订单 {formatMoney(order.amountCents)}</small>
|
||||
<small>订单 {formatAdminCurrencyCents(order.amountCents)}</small>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
@@ -1117,7 +1144,9 @@ function RechargeOrderRow({
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<span>累计 {formatMoney(order.cumulativeSuccessRefundCents)}</span>
|
||||
<span>
|
||||
累计 {formatAdminCurrencyCents(order.cumulativeSuccessRefundCents)}
|
||||
</span>
|
||||
<small>
|
||||
应追回 {order.targetRecoveryPoints} / 已追回 {order.recoveredPoints}
|
||||
</small>
|
||||
@@ -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)
|
||||
: '未支付';
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
</AdminButton>
|
||||
<small>{entry.productId}</small>
|
||||
</td>
|
||||
<td>{formatPrice(entry.priceCents)}</td>
|
||||
<td>{formatAdminCurrencyCents(entry.priceCents)}</td>
|
||||
<td>{formatProductContent(entry)}</td>
|
||||
<td>{entry.enabled ? '启用' : '停用'}</td>
|
||||
</tr>
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+6
-2
@@ -84,7 +84,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('ConversationModelSelect 锁定模型', () => {
|
||||
it('不可用模型渲染为不可点、可聚焦的原因项', async () => {
|
||||
it('不可用模型渲染为不可点、可悬停查看原因且不进入 Tab 序列', async () => {
|
||||
mockConfig('basic-model', true);
|
||||
render(<ConversationModelSelect disabled={false} />);
|
||||
// 等挂载时的目录同步落地,避免把异步状态更新留到断言之后。
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -475,7 +475,8 @@ export function ConversationModelSelect({
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{/* 目录里存在但当前档位不可用的模型:不可点,仅 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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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(<ConversationModelSelect disabled={false} onReady={onReady} />);
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -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`:只输出枚举、数值、布尔、有界标识与 `<redacted>` 占位,凭据、私钥、路径与 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 跳过完整参数,不隐藏计费、重试、幂等或事务规则。
|
||||
|
||||
@@ -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<T>`,或非 `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 只作为空库种子和兼容入口,不再作为运行期业务真相。
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -530,6 +530,50 @@ describe('PlatformProfileRechargeModal', () => {
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('泥点订单提交中时会员结算入口禁用', async () => {
|
||||
const user = userEvent.setup();
|
||||
const renderModal = (submittingProductId: string | null) => (
|
||||
<PlatformProfileRechargeModal
|
||||
center={{
|
||||
mudPointBalance: buildMudPointBalance(29),
|
||||
membership: buildNormalMembership(),
|
||||
pointProducts: buildPointProducts(),
|
||||
membershipPlans: [STARTER_PLAN],
|
||||
latestOrder: null,
|
||||
hasPointsRecharged: false,
|
||||
}}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
submittingProductId={submittingProductId}
|
||||
nativePayment={null}
|
||||
membershipQuote={null}
|
||||
isLoadingMembershipQuote={false}
|
||||
membershipQuoteError={null}
|
||||
onClose={vi.fn()}
|
||||
onRetry={vi.fn()}
|
||||
onBuy={vi.fn()}
|
||||
onRequestMembershipQuote={vi.fn()}
|
||||
onSubmitMembershipCheckout={vi.fn()}
|
||||
onConfirmNativePayment={vi.fn()}
|
||||
onCloseNativePayment={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -1074,7 +1074,8 @@ function PlatformRechargeCheckoutDialog({
|
||||
}
|
||||
const canSubmit =
|
||||
Boolean(plan) &&
|
||||
!submitting &&
|
||||
// 与充点分支一致:任一商品在途都禁用提交,避免静默无操作后直接关弹层。
|
||||
!submittingProductId &&
|
||||
(!isUpgrade || Boolean(quoteForTarget)) &&
|
||||
amountCents > 0;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -15,6 +15,6 @@ plan: ProfileMembershipPlanToken, title: string, monthPriceCents: number, yearPr
|
||||
*/
|
||||
modelAccess: ProfileMembershipModelAccessToken,
|
||||
/**
|
||||
* 并发上限;`128` 表示不设上限。
|
||||
* 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。
|
||||
*/
|
||||
concurrentJobLimit: number, enabled: boolean, };
|
||||
|
||||
@@ -12,7 +12,7 @@ plan: ProfileMembershipPlanToken, title: string, rank: number, monthPriceCents:
|
||||
*/
|
||||
modelAccess: ProfileMembershipModelAccessToken,
|
||||
/**
|
||||
* 并发上限;`128` 表示不设上限。
|
||||
* 并发上限;达到后端下发的 `unlimitedConcurrencyThreshold` 即表示不设上限,前端不硬编码哨兵值。
|
||||
*/
|
||||
concurrentJobLimit: number,
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ProfileMembershipStatusToken } from "./ProfileMembershipStatusToke
|
||||
|
||||
export type ProfileMembershipResponse = {
|
||||
/**
|
||||
* 会员状态 token:`normal` / `active`;未知取值按 `active` 兜底。
|
||||
* 会员状态 token:`normal` / `active`;Rust 客户端反序列化时未知取值按 `active` 兜底,TS 侧不做运行期归一化。
|
||||
*/
|
||||
status: ProfileMembershipStatusToken,
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user