From 55471e15de7f893ce0beac74d2f8a84135cc5b15 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 13 Jul 2026 17:25:58 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=90=8E=E5=8F=B0=E5=85=91?= =?UTF-8?q?=E6=8D=A2=E7=A0=81=E6=9C=89=E6=95=88=E6=9C=9F=E4=B8=8E=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E6=BB=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为兑换码增加可选生效和截止时间,并按左闭右开区间校验 同步 SpacetimeDB 迁移、生成绑定、共享契约和后台管理界面 让后台侧边栏与主内容区域独立滚动 修正兑换业务拒绝的 HTTP 状态映射并补充回归测试和文档 --- apps/admin-web/src/api/adminApiTypes.ts | 4 + .../src/pages/AdminRedeemCodePage.test.tsx | 166 ++++++++++++++++++ .../src/pages/AdminRedeemCodePage.tsx | 126 ++++++++++++- apps/admin-web/src/styles/admin.css | 7 + apps/admin-web/src/styles/admin.test.ts | 33 ++++ .../shared-memory/decision-log.md | 14 ++ ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 3 + packages/shared/src/contracts/runtime.ts | 4 + .../crates/api-server/src/runtime_profile.rs | 117 ++++++++++-- .../crates/module-runtime/src/application.rs | 10 ++ .../crates/module-runtime/src/commands.rs | 48 ++++- server-rs/crates/module-runtime/src/domain.rs | 8 + server-rs/crates/module-runtime/src/errors.rs | 6 + server-rs/crates/module-runtime/src/lib.rs | 18 +- .../tests/redeem_code_validity.rs | 89 ++++++++++ .../crates/shared-contracts/src/runtime.rs | 39 ++++ .../src/mapper/runtime_profile.rs | 4 + .../profile_redeem_code_type.rs | 6 + ...ile_redeem_code_admin_upsert_input_type.rs | 2 + ...ntime_profile_redeem_code_snapshot_type.rs | 2 + .../crates/spacetime-client/src/runtime.rs | 4 + .../crates/spacetime-module/src/migration.rs | 22 +++ .../spacetime-module/src/runtime/profile.rs | 19 ++ 23 files changed, 729 insertions(+), 22 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx create mode 100644 apps/admin-web/src/styles/admin.test.ts create mode 100644 server-rs/crates/module-runtime/tests/redeem_code_validity.rs diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 0322d9e9d..0cc109130 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -553,6 +553,8 @@ export interface AdminUpsertProfileRedeemCodeRequest { enabled: boolean; allowedUserIds: string[]; allowedPublicUserCodes: string[]; + startsAt?: string | null; + expiresAt?: string | null; } export interface AdminUpsertProfileInviteCodeRequest { @@ -614,6 +616,8 @@ export interface ProfileRedeemCodeAdminResponse { globalUsedCount: number; enabled: boolean; allowedUserIds: string[]; + startsAt?: string | null; + expiresAt?: string | null; createdBy: string; createdAt: string; updatedAt: string; diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx new file mode 100644 index 000000000..2e3884bdd --- /dev/null +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.test.tsx @@ -0,0 +1,166 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import { + disableProfileRedeemCode, + listProfileRedeemCodes, + upsertProfileRedeemCode, +} from '../api/adminApiClient'; +import type { ProfileRedeemCodeAdminResponse } from '../api/adminApiTypes'; +import { AdminRedeemCodePage } from './AdminRedeemCodePage'; + +vi.mock('../api/adminApiClient', () => ({ + disableProfileRedeemCode: vi.fn(), + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + isAdminApiError: vi.fn(() => false), + listProfileRedeemCodes: vi.fn(), + upsertProfileRedeemCode: vi.fn(), +})); + +const baseEntry: ProfileRedeemCodeAdminResponse = { + code: 'LONG-LIVED', + mode: 'public', + rewardPoints: 100, + maxUses: 1, + globalUsedCount: 0, + enabled: true, + allowedUserIds: [], + startsAt: null, + expiresAt: null, + createdBy: 'admin', + createdAt: '2026-07-13T01:00:00Z', + updatedAt: '2026-07-13T01:00:00Z', +}; + +const entries: ProfileRedeemCodeAdminResponse[] = [ + baseEntry, + { + ...baseEntry, + code: 'PENDING', + startsAt: '2999-01-01T00:00:00Z', + }, + { + ...baseEntry, + code: 'EXPIRED', + expiresAt: '2000-01-01T00:00:00Z', + }, + { + ...baseEntry, + code: 'ACTIVE', + startsAt: '2000-01-01T00:00:00Z', + expiresAt: '2999-01-01T00:00:00Z', + }, + { + ...baseEntry, + code: 'DISABLED', + enabled: false, + startsAt: '2000-01-01T00:00:00Z', + expiresAt: '2999-01-01T00:00:00Z', + }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(listProfileRedeemCodes).mockResolvedValue({ + entries, + operations: [], + }); + vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry); + vi.mocked(disableProfileRedeemCode).mockResolvedValue({ + ...baseEntry, + enabled: false, + }); +}); + +test('兑换码列表展示生效状态与日期范围', async () => { + render(); + + expect((await rowForCode('LONG-LIVED')).textContent).toContain('长期有效'); + expect((await rowForCode('LONG-LIVED')).textContent).toContain('立即 / 长期'); + expect((await rowForCode('PENDING')).textContent).toContain('未生效'); + expect((await rowForCode('EXPIRED')).textContent).toContain('已过期'); + expect((await rowForCode('ACTIVE')).textContent).toContain('有效'); + expect((await rowForCode('DISABLED')).textContent).toContain('停用'); +}); + +test('点击兑换码回填本地日期输入', async () => { + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('button', { name: 'ACTIVE' })); + + expect((screen.getByLabelText('开始时间') as HTMLInputElement).value).toBe( + toLocalInputValue('2000-01-01T00:00:00Z'), + ); + expect((screen.getByLabelText('截止时间') as HTMLInputElement).value).toBe( + toLocalInputValue('2999-01-01T00:00:00Z'), + ); +}); + +test('兑换码拒绝截止时间不晚于开始时间的配置', async () => { + render(); + await screen.findByRole('button', { name: 'LONG-LIVED' }); + + fireEvent.change(screen.getByLabelText('Code'), { + target: { value: 'INVALID' }, + }); + fireEvent.change(screen.getByLabelText('开始时间'), { + target: { value: '2026-07-13T10:00' }, + }); + fireEvent.change(screen.getByLabelText('截止时间'), { + target: { value: '2026-07-13T10:00' }, + }); + + expect(screen.getByText('截止时间必须晚于开始时间')).toBeTruthy(); + expect( + (screen.getByRole('button', { name: '保存' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect(upsertProfileRedeemCode).not.toHaveBeenCalled(); +}); + +test('兑换码保存时把本地时间转换为 ISO 并保留空边界', async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole('button', { name: 'LONG-LIVED' }); + + fireEvent.change(screen.getByLabelText('Code'), { + target: { value: 'WINDOWED' }, + }); + fireEvent.change(screen.getByLabelText('开始时间'), { + target: { value: '2026-07-13T10:30' }, + }); + await user.click(screen.getByRole('button', { name: '保存' })); + await user.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(upsertProfileRedeemCode).toHaveBeenCalledWith( + 'admin-token', + expect.objectContaining({ + code: 'WINDOWED', + startsAt: new Date('2026-07-13T10:30').toISOString(), + expiresAt: null, + }), + ); + }); +}); + +async function rowForCode(code: string) { + const button = await screen.findByRole('button', { name: code }); + const row = button.closest('tr'); + if (!row) { + throw new Error(`未找到兑换码 ${code} 所在行`); + } + return row; +} + +function toLocalInputValue(value: string) { + const date = new Date(value); + const offsetMs = date.getTimezoneOffset() * 60 * 1000; + return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16); +} diff --git a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx index f1df9c315..6afe195d5 100644 --- a/apps/admin-web/src/pages/AdminRedeemCodePage.tsx +++ b/apps/admin-web/src/pages/AdminRedeemCodePage.tsx @@ -34,6 +34,8 @@ export function AdminRedeemCodePage({ const [rewardPoints, setRewardPoints] = useState('100'); const [maxUses, setMaxUses] = useState('1'); const [enabled, setEnabled] = useState(true); + const [startsAt, setStartsAt] = useState(''); + const [expiresAt, setExpiresAt] = useState(''); const [allowedUserIds, setAllowedUserIds] = useState(''); const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState(''); const [disableCode, setDisableCode] = useState(''); @@ -73,6 +75,12 @@ export function AdminRedeemCodePage({ } setErrorMessage(''); + const validityError = validateValidityWindow(startsAt, expiresAt); + if (validityError) { + setErrorMessage(validityError); + return; + } + const confirmed = await confirmWrite({ action: '保存兑换码', target: code.trim(), @@ -92,6 +100,8 @@ export function AdminRedeemCodePage({ allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [], allowedPublicUserCodes: mode === 'private' ? splitLines(allowedPublicUserCodes) : [], + startsAt: startsAt ? toIsoDateTime(startsAt) : null, + expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null, }); fillForm(response); await refreshRedeemCodes(); @@ -137,11 +147,15 @@ export function AdminRedeemCodePage({ setRewardPoints(String(entry.rewardPoints)); setMaxUses(String(entry.maxUses)); setEnabled(entry.enabled); + setStartsAt(toDateTimeLocalValue(entry.startsAt)); + setExpiresAt(toDateTimeLocalValue(entry.expiresAt)); setAllowedUserIds(entry.allowedUserIds.join('\n')); setAllowedPublicUserCodes(''); setDisableCode(entry.code); } + const validityError = validateValidityWindow(startsAt, expiresAt); + return (
@@ -222,6 +236,25 @@ export function AdminRedeemCodePage({
+
+ + +
+ {mode === 'private' ? (
) : null} + {validityError && validityError !== errorMessage ? ( +
+ {validityError} +
+ ) : null}