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({ + + + 开始时间 + setStartsAt(event.target.value)} + /> + + + 截止时间 + setExpiresAt(event.target.value)} + /> + + + {mode === 'private' ? ( @@ -250,6 +283,11 @@ export function AdminRedeemCodePage({ {errorMessage} ) : null} + {validityError && validityError !== errorMessage ? ( + + {validityError} + + ) : null} @@ -280,6 +319,7 @@ export function AdminRedeemCodePage({ Code 奖励 状态 + 有效期 @@ -296,7 +336,16 @@ export function AdminRedeemCodePage({ {redeemModeLabel(entry.mode)} {entry.rewardPoints} - {entry.enabled ? '启用' : '停用'} + + + {redeemValidityLabel(entry)} + + + + {formatValidityWindow(entry)} + ))} @@ -400,3 +449,76 @@ function formatDateTime(value: string) { } return date.toLocaleString('zh-CN', {hour12: false}); } + +function validateValidityWindow(startsAt: string, expiresAt: string) { + if (!startsAt || !expiresAt) { + return ''; + } + + const startsAtTime = Date.parse(toIsoDateTime(startsAt)); + const expiresAtTime = Date.parse(toIsoDateTime(expiresAt)); + if (!Number.isFinite(startsAtTime) || !Number.isFinite(expiresAtTime)) { + return '有效期时间无效'; + } + + return startsAtTime < expiresAtTime ? '' : '截止时间必须晚于开始时间'; +} + +function toIsoDateTime(value: string) { + const time = Date.parse(value); + if (!Number.isFinite(time)) { + throw new Error('有效期时间无效'); + } + return new Date(time).toISOString(); +} + +function toDateTimeLocalValue(value?: string | null) { + if (!value) { + return ''; + } + + const date = new Date(value); + if (!Number.isFinite(date.getTime())) { + return ''; + } + + const offsetMs = date.getTimezoneOffset() * 60 * 1000; + return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16); +} + +function redeemValidityLabel(entry: ProfileRedeemCodeAdminResponse) { + if (!entry.enabled) { + return '停用'; + } + + const now = Date.now(); + const startsAtTime = entry.startsAt ? Date.parse(entry.startsAt) : null; + const expiresAtTime = entry.expiresAt ? Date.parse(entry.expiresAt) : null; + if (startsAtTime !== null && Number.isFinite(startsAtTime) && now < startsAtTime) { + return '未生效'; + } + if (expiresAtTime !== null && Number.isFinite(expiresAtTime) && now >= expiresAtTime) { + return '已过期'; + } + if (entry.startsAt || entry.expiresAt) { + return '有效'; + } + return '长期有效'; +} + +function redeemValidityClass(entry: ProfileRedeemCodeAdminResponse) { + const label = redeemValidityLabel(entry); + if (label === '停用' || label === '已过期') { + return 'admin-status-error'; + } + if (label === '未生效') { + return 'admin-status-pending'; + } + return 'admin-status-ok'; +} + +function formatValidityWindow(entry: ProfileRedeemCodeAdminResponse) { + const startsAt = entry.startsAt ? formatDateTime(entry.startsAt) : '立即'; + const expiresAt = entry.expiresAt ? formatDateTime(entry.expiresAt) : '长期'; + return `${startsAt} / ${expiresAt}`; +} diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index f71fc107b..bbee95ac6 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -121,17 +121,21 @@ button:disabled { .admin-shell { display: grid; + height: 100dvh; min-height: 100dvh; grid-template-columns: 232px minmax(0, 1fr); + overflow: hidden; } .admin-sidebar { display: flex; + min-height: 0; flex-direction: column; gap: 24px; border-right: 1px solid #e1ccbb; background: #ffffff; padding: 22px 18px; + overflow-y: auto; } .admin-brand strong { @@ -176,7 +180,9 @@ button:disabled { .admin-main { display: grid; min-width: 0; + min-height: 0; grid-template-rows: 64px minmax(0, 1fr); + overflow: hidden; } .admin-topbar { @@ -205,6 +211,7 @@ button:disabled { .admin-content { min-width: 0; + min-height: 0; padding: 24px; overflow: auto; } diff --git a/apps/admin-web/src/styles/admin.test.ts b/apps/admin-web/src/styles/admin.test.ts new file mode 100644 index 000000000..a03443925 --- /dev/null +++ b/apps/admin-web/src/styles/admin.test.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, test } from 'vitest'; + +const stylesheet = fs.readFileSync( + path.resolve(process.cwd(), 'apps/admin-web/src/styles/admin.css'), + 'utf8', +); + +describe('admin shell scrolling contract', () => { + test('desktop shell keeps sidebar separate from the scrollable content', () => { + expect(ruleFor('.admin-shell')).toContain('\n height: 100dvh;'); + expect(ruleFor('.admin-shell')).toContain('overflow: hidden'); + expect(ruleFor('.admin-sidebar')).toContain('min-height: 0'); + expect(ruleFor('.admin-sidebar')).toContain('overflow-y: auto'); + expect(ruleFor('.admin-main')).toContain('min-height: 0'); + expect(ruleFor('.admin-main')).toContain('overflow: hidden'); + expect(ruleFor('.admin-content')).toContain('min-height: 0'); + expect(ruleFor('.admin-content')).toContain('overflow: auto'); + }); +}); + +function ruleFor(selector: string) { + const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = stylesheet.match( + new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`), + ); + if (!match) { + throw new Error(`Missing CSS rule for ${selector}`); + } + return match[1]; +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f5b1ca82a..c9d78e429 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4022,3 +4022,17 @@ - 生命周期:新维护窗口未提供 `--page-file` 时清理 marker 外残留公告;同一窗口内 Stdb / API 发布重复调用 `maintenance-on.sh` 时保留已安装公告;`maintenance-off.sh` 同时清理 marker 和公告页。Web Deploy 不再拥有临时公告事实源。 - 影响范围:默认维护页、维护开关脚本、Nginx snippet、Pingora 配置与 smoke、生产 Web 发布包门禁和生产运维文档。 - 验证方式:`npm run check:maintenance-page`、`npm run check:nginx-spa-routes`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`。 + +## 2026-07-13 兑换码生效日期范围对齐邀请码 + +- 背景:后台邀请码已支持可选开始时间和截止时间,但兑换码只有启用状态,运营无法预设活动时间窗口,用户兑换也没有后端时间边界校验。 +- 决策:`profile_redeem_code` 在已有字段末尾追加可空 `starts_at` / `expires_at`,默认均为空;后台请求和响应使用可空 `startsAt` / `expiresAt`。时间窗口与邀请码一致:空边界合法,双边界必须严格满足开始早于截止,有效区间为 `[starts_at, expires_at)`。 +- 兑换边界:时间是后端事实,`redeem_profile_reward_code` 必须用经后端构造的 `redeemed_at_micros` 拒绝未生效或已过期的兑换码;前端的状态标签只用于运营展示,不代替后端校验。 +- 影响范围:`module-runtime` 兑换码命令与校验、`spacetime-module` schema / migration / procedure、生成 bindings、`spacetime-client`、`shared-contracts` / `packages/shared`、`api-server` 和 `apps/admin-web` 兑换码页。 + +## 2026-07-13 后台侧边栏与主内容独立滚动 + +- 背景:后台壳层只给 `.admin-shell` 设置 `min-height: 100dvh`,长页面会撑高 document;滚动时侧边栏与主内容一起移出视口,`.admin-content` 上的 `overflow: auto` 没有成为真正滚动容器。 +- 决策:后台壳层固定为 `height: 100dvh` 并隐藏壳层 overflow;桌面侧边栏使用独立 `overflow-y: auto`,`.admin-main` 通过 `min-height: 0` 和 `overflow: hidden` 约束网格,`.admin-content` 使用 `min-height: 0; overflow: auto` 独立滚动。 +- 响应式边界:小于等于 `980px` 时仍隐藏桌面侧边栏,主内容在视口高度内滚动,底部导航继续固定。 +- 验证方式:`apps/admin-web/src/styles/admin.test.ts` 锁定壳层滚动契约;桌面浏览器滚动后应保持 `window.scrollY = 0`、侧边栏 `top = 0`,只改变 `.admin-content.scrollTop`;移动视口继续由 `.admin-content` 滚动。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 42818aef7..5e5c68170 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -709,6 +709,7 @@ npm run check:server-rs-ddd - Rust 结构体:`ProfileInviteCode` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 生效时间:`starts_at` / `expires_at` 均可为空;两者同时存在时必须满足 `starts_at < expires_at`。开始时刻计入有效区间,截止时刻不计入有效区间。 ### `profile_code_operation` @@ -756,6 +757,8 @@ npm run check:server-rs-ddd - Rust 结构体:`ProfileRedeemCode` - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 生效时间:复用邀请码时间窗口语义,`starts_at` / `expires_at` 均可为空;两者同时存在时必须满足 `starts_at < expires_at`。开始时刻计入有效区间,截止时刻不计入有效区间。用户兑换时以后端接收的 `redeemed_at_micros` 判定:未到开始时间拒绝为“兑换码未生效”,到达或超过截止时间拒绝为“兑换码已过期”。 +- 后台契约:`AdminUpsertProfileRedeemCodeRequest` 通过可空 `startsAt` / `expiresAt` 接收 RFC3339 时间,列表与保存响应同步返回这两个字段;后台页只负责输入、回填和显示,真正兑换判定留在后端事务路径。 ### `profile_redeem_code_usage` diff --git a/packages/shared/src/contracts/runtime.ts b/packages/shared/src/contracts/runtime.ts index a01f70547..7f731aa9a 100644 --- a/packages/shared/src/contracts/runtime.ts +++ b/packages/shared/src/contracts/runtime.ts @@ -457,6 +457,8 @@ export type ProfileRedeemCodeAdminResponse = { globalUsedCount: number; enabled: boolean; allowedUserIds: string[]; + startsAt?: string | null; + expiresAt?: string | null; createdBy: string; createdAt: string; updatedAt: string; @@ -484,6 +486,8 @@ export type AdminUpsertProfileRedeemCodeRequest = { enabled?: boolean; allowedUserIds?: string[]; allowedPublicUserCodes?: string[]; + startsAt?: string | null; + expiresAt?: string | null; }; export type AdminDisableProfileRedeemCodeRequest = { diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 726d0f533..0d936e15e 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -1127,6 +1127,12 @@ pub async fn admin_upsert_profile_redeem_code( AppError::from_status(StatusCode::BAD_REQUEST).with_message(error), ) })?; + let starts_at_micros = + parse_admin_profile_code_time_field("兑换码", "startsAt", payload.starts_at) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; + let expires_at_micros = + parse_admin_profile_code_time_field("兑换码", "expiresAt", payload.expires_at) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; let record = state .spacetime_client() .admin_upsert_profile_redeem_code( @@ -1138,6 +1144,8 @@ pub async fn admin_upsert_profile_redeem_code( payload.enabled, payload.allowed_user_ids, payload.allowed_public_user_codes, + starts_at_micros, + expires_at_micros, updated_at_micros as i64, ) .await @@ -1223,10 +1231,12 @@ pub async fn admin_upsert_profile_invite_code( ) -> Result, Response> { let metadata_json = normalize_admin_invite_code_metadata(payload.metadata) .map_err(|error| runtime_profile_error_response(&request_context, error))?; - let starts_at_micros = parse_admin_invite_code_time_field("startsAt", payload.starts_at) - .map_err(|error| runtime_profile_error_response(&request_context, error))?; - let expires_at_micros = parse_admin_invite_code_time_field("expiresAt", payload.expires_at) - .map_err(|error| runtime_profile_error_response(&request_context, error))?; + let starts_at_micros = + parse_admin_profile_code_time_field("邀请码", "startsAt", payload.starts_at) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; + let expires_at_micros = + parse_admin_profile_code_time_field("邀请码", "expiresAt", payload.expires_at) + .map_err(|error| runtime_profile_error_response(&request_context, error))?; let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000; let record = state .spacetime_client() @@ -1294,15 +1304,40 @@ pub async fn get_profile_play_stats( } fn map_runtime_profile_client_error(error: SpacetimeClientError) -> AppError { - let (status, provider) = match error { + let is_redeem_code_domain_error = matches!( + &error, + SpacetimeClientError::Procedure(message) + if is_runtime_profile_redeem_code_domain_error(message) + ); + let (status, provider) = match &error { SpacetimeClientError::Runtime(_) => (StatusCode::BAD_REQUEST, "runtime-profile"), + SpacetimeClientError::Procedure(_) if is_redeem_code_domain_error => { + (StatusCode::BAD_REQUEST, "runtime-profile") + } _ => (StatusCode::BAD_GATEWAY, "spacetimedb"), }; - AppError::from_status(status).with_details(json!({ + let app_error = AppError::from_status(status).with_details(json!({ "provider": provider, "message": error.to_string(), - })) + })); + if is_redeem_code_domain_error { + return app_error.with_message(error.to_string()); + } + app_error +} + +fn is_runtime_profile_redeem_code_domain_error(message: &str) -> bool { + matches!( + message, + "兑换码不存在" + | "兑换码已停用" + | "兑换码未生效" + | "兑换码已过期" + | "兑换次数已用完" + | "该兑换码不适用于当前账号" + | "兑换码奖励无效" + ) } fn runtime_profile_error_response(request_context: &RequestContext, error: AppError) -> Response { @@ -2112,7 +2147,8 @@ fn normalize_admin_invite_code_metadata(metadata: Option) -> Result, ) -> Result, AppError> { @@ -2126,7 +2162,7 @@ fn parse_admin_invite_code_time_field( let parsed = parse_rfc3339(value).map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST) - .with_message(format!("邀请码 {field} 必须是 RFC3339 时间字符串")) + .with_message(format!("{code_label} {field} 必须是 RFC3339 时间字符串")) .with_details(json!({ "field": field, "message": error })) })?; @@ -2271,6 +2307,8 @@ fn build_profile_redeem_code_admin_response( created_by: record.created_by, created_at: record.created_at, updated_at: record.updated_at, + starts_at: record.starts_at, + expires_at: record.expires_at, } } @@ -2305,9 +2343,67 @@ mod tests { calc_wechat_virtual_payment_user_signature_with_key, format_profile_wallet_ledger_source_type, is_wechat_profile_recharge_order_terminal_for_confirmation, - normalize_admin_invite_code_metadata, should_notify_virtual_payment_goods_delivery, + map_runtime_profile_client_error, normalize_admin_invite_code_metadata, + parse_admin_profile_code_time_field, should_notify_virtual_payment_goods_delivery, }; + #[test] + fn redeem_code_domain_errors_map_to_client_errors_without_hiding_infrastructure_failures() { + for message in ["兑换码未生效", "兑换码已过期"] { + let error = map_runtime_profile_client_error(SpacetimeClientError::Procedure( + message.to_string(), + )); + + assert_eq!(error.status_code(), StatusCode::BAD_REQUEST); + assert_eq!(error.code(), "BAD_REQUEST"); + assert_eq!(error.message(), message); + assert_eq!( + error + .details() + .and_then(|details| details.get("provider")) + .and_then(Value::as_str), + Some("runtime-profile") + ); + } + + let infrastructure_error = map_runtime_profile_client_error( + SpacetimeClientError::Procedure("No such procedure: redeem_profile_reward_code".into()), + ); + assert_eq!(infrastructure_error.status_code(), StatusCode::BAD_GATEWAY); + assert_eq!( + infrastructure_error + .details() + .and_then(|details| details.get("provider")) + .and_then(Value::as_str), + Some("spacetimedb") + ); + } + + #[test] + fn admin_profile_code_time_parser_accepts_optional_rfc3339_values() { + assert_eq!( + parse_admin_profile_code_time_field("兑换码", "startsAt", None) + .expect("missing boundary should pass"), + None + ); + assert_eq!( + parse_admin_profile_code_time_field( + "兑换码", + "startsAt", + Some("1970-01-01T00:00:01Z".to_string()), + ) + .expect("RFC3339 boundary should pass"), + Some(1_000_000) + ); + let error = parse_admin_profile_code_time_field( + "兑换码", + "startsAt", + Some("2026-07-13 00:00:00".to_string()), + ) + .expect_err("non-RFC3339 boundary should fail"); + assert!(error.message().contains("兑换码 startsAt")); + } + use axum::{ body::Body, http::{Request, StatusCode}, @@ -2317,6 +2413,7 @@ mod tests { AccessTokenClaims, AccessTokenClaimsInput, AuthProvider, BindingStatus, sign_access_token, }; use serde_json::Value; + use spacetime_client::SpacetimeClientError; use std::time::Duration; use time::OffsetDateTime; use tower::ServiceExt; diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index a123970e4..fc804bc82 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -1723,6 +1723,10 @@ pub fn build_runtime_profile_redeem_code_record( created_at_micros: snapshot.created_at_micros, updated_at: format_utc_micros(snapshot.updated_at_micros), updated_at_micros: snapshot.updated_at_micros, + starts_at: snapshot.starts_at_micros.map(format_utc_micros), + starts_at_micros: snapshot.starts_at_micros, + expires_at: snapshot.expires_at_micros.map(format_utc_micros), + expires_at_micros: snapshot.expires_at_micros, } } @@ -2040,6 +2044,7 @@ pub fn validate_runtime_profile_redeem_code_usage( code: &RuntimeProfileRedeemCodeSnapshot, user_id: &str, user_used_count: u32, + redeemed_at_micros: i64, ) -> Result<(), RuntimeProfileFieldError> { if !code.enabled { return Err(RuntimeProfileFieldError::RedeemCodeDisabled); @@ -2047,6 +2052,11 @@ pub fn validate_runtime_profile_redeem_code_usage( if code.reward_points == 0 { return Err(RuntimeProfileFieldError::InvalidRedeemCodeReward); } + crate::commands::validate_runtime_profile_redeem_code_redeem_time( + code.starts_at_micros, + code.expires_at_micros, + redeemed_at_micros, + )?; match code.mode { RuntimeProfileRedeemCodeMode::Public if user_used_count >= code.max_uses => { diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index d5d866d88..6de8a1c63 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -593,6 +593,8 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input( enabled: bool, allowed_user_ids: Vec, allowed_public_user_codes: Vec, + starts_at_micros: Option, + expires_at_micros: Option, updated_at_micros: i64, ) -> Result { let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?; @@ -603,6 +605,7 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input( if max_uses == 0 { return Err(RuntimeProfileFieldError::InvalidRedeemCodeMaxUses); } + validate_runtime_profile_redeem_code_validity_window(starts_at_micros, expires_at_micros)?; Ok(RuntimeProfileRedeemCodeAdminUpsertInput { admin_user_id, @@ -619,6 +622,8 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input( .into_iter() .filter_map(|value| normalize_optional_string(Some(value))) .collect(), + starts_at_micros, + expires_at_micros, updated_at_micros, }) } @@ -1047,14 +1052,53 @@ pub fn validate_runtime_profile_invite_code_validity_window( starts_at_micros: Option, expires_at_micros: Option, ) -> Result<(), RuntimeProfileFieldError> { - if matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at >= expires_at) - { + if is_invalid_runtime_profile_code_validity_window(starts_at_micros, expires_at_micros) { return Err(RuntimeProfileFieldError::InvalidInviteCodeValidityWindow); } Ok(()) } +pub fn validate_runtime_profile_redeem_code_validity_window( + starts_at_micros: Option, + expires_at_micros: Option, +) -> Result<(), RuntimeProfileFieldError> { + if is_invalid_runtime_profile_code_validity_window(starts_at_micros, expires_at_micros) { + return Err(RuntimeProfileFieldError::InvalidRedeemCodeValidityWindow); + } + + Ok(()) +} + +pub fn validate_runtime_profile_redeem_code_redeem_time( + starts_at_micros: Option, + expires_at_micros: Option, + now_micros: i64, +) -> Result<(), RuntimeProfileFieldError> { + if starts_at_micros + .map(|starts_at| now_micros < starts_at) + .unwrap_or(false) + { + return Err(RuntimeProfileFieldError::RedeemCodeNotStarted); + } + + if expires_at_micros + .map(|expires_at| now_micros >= expires_at) + .unwrap_or(false) + { + return Err(RuntimeProfileFieldError::RedeemCodeExpired); + } + + Ok(()) +} + +fn is_invalid_runtime_profile_code_validity_window( + starts_at_micros: Option, + expires_at_micros: Option, +) -> bool { + matches!((starts_at_micros, expires_at_micros), (Some(starts_at), Some(expires_at)) if starts_at >= expires_at) +} + pub fn resolve_runtime_profile_invite_code_status( starts_at_micros: Option, expires_at_micros: Option, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 215b80ea1..5be01a58a 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1575,6 +1575,8 @@ pub struct RuntimeProfileRedeemCodeAdminUpsertInput { pub enabled: bool, pub allowed_user_ids: Vec, pub allowed_public_user_codes: Vec, + pub starts_at_micros: Option, + pub expires_at_micros: Option, pub updated_at_micros: i64, } @@ -1605,6 +1607,8 @@ pub struct RuntimeProfileRedeemCodeSnapshot { pub created_by: String, pub created_at_micros: i64, pub updated_at_micros: i64, + pub starts_at_micros: Option, + pub expires_at_micros: Option, } #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] @@ -2098,6 +2102,10 @@ pub struct RuntimeProfileRedeemCodeRecord { pub created_at_micros: i64, pub updated_at: String, pub updated_at_micros: i64, + pub starts_at: Option, + pub starts_at_micros: Option, + pub expires_at: Option, + pub expires_at_micros: Option, } #[derive(Clone, Debug, PartialEq)] diff --git a/server-rs/crates/module-runtime/src/errors.rs b/server-rs/crates/module-runtime/src/errors.rs index c98e43eb5..695f7f304 100644 --- a/server-rs/crates/module-runtime/src/errors.rs +++ b/server-rs/crates/module-runtime/src/errors.rs @@ -58,6 +58,9 @@ pub enum RuntimeProfileFieldError { RedeemCodeNotAllowedForUser, InvalidRedeemCodeReward, InvalidRedeemCodeMaxUses, + InvalidRedeemCodeValidityWindow, + RedeemCodeNotStarted, + RedeemCodeExpired, InvalidInviteCodeMetadata, InvalidUserTag, InvalidInviteCodeValidityWindow, @@ -130,6 +133,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::RedeemCodeNotAllowedForUser => f.write_str("该兑换码不适用于当前账号"), Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"), Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"), + Self::InvalidRedeemCodeValidityWindow => f.write_str("兑换码开始时间不能晚于截止时间"), + Self::RedeemCodeNotStarted => f.write_str("兑换码未生效"), + Self::RedeemCodeExpired => f.write_str("兑换码已过期"), Self::InvalidInviteCodeMetadata => { f.write_str("邀请码 metadata 必须是合法 JSON object") } diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 409bf2356..2def15c25 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1265,12 +1265,14 @@ mod tests { created_by: "admin".to_string(), created_at_micros: 1, updated_at_micros: 1, + starts_at_micros: None, + expires_at_micros: None, }; - validate_runtime_profile_redeem_code_usage(&base, "user-1", 1) + validate_runtime_profile_redeem_code_usage(&base, "user-1", 1, 1) .expect("public code under per-user limit should pass"); assert_eq!( - validate_runtime_profile_redeem_code_usage(&base, "user-1", 2) + validate_runtime_profile_redeem_code_usage(&base, "user-1", 2, 1) .expect_err("public code over per-user limit should fail"), RuntimeProfileFieldError::RedeemCodeUsesExhausted ); @@ -1280,10 +1282,10 @@ mod tests { global_used_count: 9, ..base.clone() }; - validate_runtime_profile_redeem_code_usage(&unique, "user-1", 0) + validate_runtime_profile_redeem_code_usage(&unique, "user-1", 0, 1) .expect("unique code should allow a user that has not redeemed it"); assert_eq!( - validate_runtime_profile_redeem_code_usage(&unique, "user-1", 1) + validate_runtime_profile_redeem_code_usage(&unique, "user-1", 1, 1) .expect_err("unique code should reject the same user twice"), RuntimeProfileFieldError::RedeemCodeUsesExhausted ); @@ -1295,14 +1297,14 @@ mod tests { ..base.clone() }; assert_eq!( - validate_runtime_profile_redeem_code_usage(&private, "user-1", 0) + validate_runtime_profile_redeem_code_usage(&private, "user-1", 0, 1) .expect_err("private code should check allow list"), RuntimeProfileFieldError::RedeemCodeNotAllowedForUser ); - validate_runtime_profile_redeem_code_usage(&private, "user-2", 0) + validate_runtime_profile_redeem_code_usage(&private, "user-2", 0, 1) .expect("private code should allow an allowed user that has not redeemed it"); assert_eq!( - validate_runtime_profile_redeem_code_usage(&private, "user-2", 1) + validate_runtime_profile_redeem_code_usage(&private, "user-2", 1, 1) .expect_err("private code should reject the same allowed user twice"), RuntimeProfileFieldError::RedeemCodeUsesExhausted ); @@ -1312,7 +1314,7 @@ mod tests { ..base }; assert_eq!( - validate_runtime_profile_redeem_code_usage(&disabled, "user-1", 0) + validate_runtime_profile_redeem_code_usage(&disabled, "user-1", 0, 1) .expect_err("disabled code should fail"), RuntimeProfileFieldError::RedeemCodeDisabled ); diff --git a/server-rs/crates/module-runtime/tests/redeem_code_validity.rs b/server-rs/crates/module-runtime/tests/redeem_code_validity.rs new file mode 100644 index 000000000..a430e90ce --- /dev/null +++ b/server-rs/crates/module-runtime/tests/redeem_code_validity.rs @@ -0,0 +1,89 @@ +use module_runtime::{ + RuntimeProfileFieldError, RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeSnapshot, + build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record, + validate_runtime_profile_redeem_code_usage, + validate_runtime_profile_redeem_code_validity_window, +}; + +#[test] +fn redeem_code_validity_window_allows_open_ended_and_requires_strict_order() { + assert!(validate_runtime_profile_redeem_code_validity_window(None, None).is_ok()); + assert!(validate_runtime_profile_redeem_code_validity_window(Some(10), None).is_ok()); + assert!(validate_runtime_profile_redeem_code_validity_window(None, Some(10)).is_ok()); + assert_eq!( + validate_runtime_profile_redeem_code_validity_window(Some(10), Some(10)), + Err(RuntimeProfileFieldError::InvalidRedeemCodeValidityWindow) + ); + assert_eq!( + validate_runtime_profile_redeem_code_validity_window(Some(20), Some(10)), + Err(RuntimeProfileFieldError::InvalidRedeemCodeValidityWindow) + ); +} + +#[test] +fn redeem_code_admin_upsert_carries_optional_validity_window() { + let input = build_runtime_profile_redeem_code_admin_upsert_input( + "admin-1".to_string(), + "GIFT".to_string(), + RuntimeProfileRedeemCodeMode::Public, + 30, + 1, + true, + Vec::new(), + Vec::new(), + Some(10), + Some(20), + 5, + ) + .expect("valid window should pass"); + + assert_eq!(input.starts_at_micros, Some(10)); + assert_eq!(input.expires_at_micros, Some(20)); +} + +#[test] +fn redeem_code_usage_uses_inclusive_start_and_exclusive_expire_boundary() { + let code = redeem_code_snapshot(Some(20), Some(30)); + + assert_eq!( + validate_runtime_profile_redeem_code_usage(&code, "user-1", 0, 19), + Err(RuntimeProfileFieldError::RedeemCodeNotStarted) + ); + validate_runtime_profile_redeem_code_usage(&code, "user-1", 0, 20) + .expect("start boundary should be valid"); + validate_runtime_profile_redeem_code_usage(&code, "user-1", 0, 29) + .expect("time before expire boundary should be valid"); + assert_eq!( + validate_runtime_profile_redeem_code_usage(&code, "user-1", 0, 30), + Err(RuntimeProfileFieldError::RedeemCodeExpired) + ); +} + +#[test] +fn redeem_code_record_formats_optional_validity_window() { + let record = + build_runtime_profile_redeem_code_record(redeem_code_snapshot(Some(0), Some(1_000_000))); + + assert_eq!(record.starts_at.as_deref(), Some("1970-01-01T00:00:00Z")); + assert_eq!(record.expires_at.as_deref(), Some("1970-01-01T00:00:01Z")); +} + +fn redeem_code_snapshot( + starts_at_micros: Option, + expires_at_micros: Option, +) -> RuntimeProfileRedeemCodeSnapshot { + RuntimeProfileRedeemCodeSnapshot { + code: "GIFT".to_string(), + mode: RuntimeProfileRedeemCodeMode::Public, + reward_points: 30, + max_uses: 1, + global_used_count: 0, + enabled: true, + allowed_user_ids: Vec::new(), + created_by: "admin".to_string(), + created_at_micros: 1, + updated_at_micros: 1, + starts_at_micros, + expires_at_micros, + } +} diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 98674d92b..67afc5fa0 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -680,6 +680,10 @@ pub struct AdminUpsertProfileRedeemCodeRequest { pub allowed_user_ids: Vec, #[serde(default)] pub allowed_public_user_codes: Vec, + #[serde(default)] + pub starts_at: Option, + #[serde(default)] + pub expires_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -713,6 +717,8 @@ pub struct ProfileRedeemCodeAdminResponse { pub created_by: String, pub created_at: String, pub updated_at: String, + pub starts_at: Option, + pub expires_at: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -1219,6 +1225,39 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn redeem_code_admin_contract_uses_optional_camel_case_validity_window() { + let request: AdminUpsertProfileRedeemCodeRequest = serde_json::from_value(json!({ + "code": "GIFT", + "mode": "public", + "rewardPoints": 30, + "maxUses": 1, + "startsAt": "2026-07-13T00:00:00Z", + "expiresAt": null + })) + .expect("redeem code request should accept validity window"); + assert_eq!(request.starts_at.as_deref(), Some("2026-07-13T00:00:00Z")); + assert_eq!(request.expires_at, None); + + let response = ProfileRedeemCodeAdminResponse { + code: "GIFT".to_string(), + mode: "public".to_string(), + reward_points: 30, + max_uses: 1, + global_used_count: 0, + enabled: true, + allowed_user_ids: Vec::new(), + created_by: "admin".to_string(), + created_at: "2026-07-13T00:00:00Z".to_string(), + updated_at: "2026-07-13T00:00:00Z".to_string(), + starts_at: Some("2026-07-13T00:00:00Z".to_string()), + expires_at: None, + }; + let payload = serde_json::to_value(response).expect("response should serialize"); + assert_eq!(payload["startsAt"], json!("2026-07-13T00:00:00Z")); + assert_eq!(payload["expiresAt"], json!(null)); + } + fn empty_mud_point_balance() -> ProfileMudPointBalanceResponse { ProfileMudPointBalanceResponse { total_points: 0, diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs index 74f898096..1294e945f 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs @@ -315,6 +315,8 @@ impl From enabled: input.enabled, allowed_user_ids: input.allowed_user_ids, allowed_public_user_codes: input.allowed_public_user_codes, + starts_at_micros: input.starts_at_micros, + expires_at_micros: input.expires_at_micros, updated_at_micros: input.updated_at_micros, } } @@ -1286,6 +1288,8 @@ pub(crate) fn map_runtime_profile_redeem_code_snapshot( created_by: snapshot.created_by, created_at_micros: snapshot.created_at_micros, updated_at_micros: snapshot.updated_at_micros, + starts_at_micros: snapshot.starts_at_micros, + expires_at_micros: snapshot.expires_at_micros, } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs index 9be48e6d9..a880d3ceb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_redeem_code_type.rs @@ -19,6 +19,8 @@ pub struct ProfileRedeemCode { pub created_by: String, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub starts_at: Option<__sdk::Timestamp>, + pub expires_at: Option<__sdk::Timestamp>, } impl __sdk::InModule for ProfileRedeemCode { @@ -39,6 +41,8 @@ pub struct ProfileRedeemCodeCols { pub created_by: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub starts_at: __sdk::__query_builder::Col>, + pub expires_at: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ProfileRedeemCode { @@ -55,6 +59,8 @@ impl __sdk::__query_builder::HasCols for ProfileRedeemCode { created_by: __sdk::__query_builder::Col::new(table_name, "created_by"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + starts_at: __sdk::__query_builder::Col::new(table_name, "starts_at"), + expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs index 5f18a8751..1baeb5cbe 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_admin_upsert_input_type.rs @@ -17,6 +17,8 @@ pub struct RuntimeProfileRedeemCodeAdminUpsertInput { pub enabled: bool, pub allowed_user_ids: Vec, pub allowed_public_user_codes: Vec, + pub starts_at_micros: Option, + pub expires_at_micros: Option, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs index aea09f25b..3580d911a 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_redeem_code_snapshot_type.rs @@ -19,6 +19,8 @@ pub struct RuntimeProfileRedeemCodeSnapshot { pub created_by: String, pub created_at_micros: i64, pub updated_at_micros: i64, + pub starts_at_micros: Option, + pub expires_at_micros: Option, } impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot { diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 2b158c0f9..561bc8f31 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -1351,6 +1351,8 @@ impl SpacetimeClient { enabled: bool, allowed_user_ids: Vec, allowed_public_user_codes: Vec, + starts_at_micros: Option, + expires_at_micros: Option, updated_at_micros: i64, ) -> Result { let procedure_input = build_runtime_profile_redeem_code_admin_upsert_input( @@ -1362,6 +1364,8 @@ impl SpacetimeClient { enabled, allowed_user_ids, allowed_public_user_codes, + starts_at_micros, + expires_at_micros, updated_at_micros, ) .map_err(SpacetimeClientError::validation_failed)? diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 0dc48da85..a61c395a6 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1308,6 +1308,17 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert_with(|| serde_json::Value::String("{}".to_string())); } } + if table_name == "profile_redeem_code" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:兑换码生效时间晚于兑换码表加入,旧迁移包按无时间边界兼容。 + object + .entry("starts_at".to_string()) + .or_insert(serde_json::Value::Null); + object + .entry("expires_at".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "profile_recharge_order" { if let Some(object) = next_value.as_object_mut() { // 中文注释:真实微信支付接入后才有平台交易号,旧迁移包按未回填处理。 @@ -1616,6 +1627,17 @@ fn is_supported_migration_table(table_name: &str) -> bool { mod migration_bootstrap_secret_tests { use super::*; + #[test] + fn old_profile_redeem_code_rows_default_to_open_validity_window() { + let normalized = normalize_migration_row( + "profile_redeem_code", + &serde_json::json!({ "code": "GIFT" }), + ); + + assert_eq!(normalized["starts_at"], serde_json::Value::Null); + assert_eq!(normalized["expires_at"], serde_json::Value::Null); + } + #[test] fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() { let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 7dd00c720..4146a9c44 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -202,6 +202,10 @@ pub struct ProfileRedeemCode { pub(crate) created_by: String, pub(crate) created_at: Timestamp, pub(crate) updated_at: Timestamp, + #[default(None::)] + pub(crate) starts_at: Option, + #[default(None::)] + pub(crate) expires_at: Option, } #[spacetimedb::table( @@ -3753,6 +3757,7 @@ fn redeem_profile_reward_code_record( &build_profile_redeem_code_snapshot_from_row(&redeem_code), &user_id, user_used_count, + validated_input.redeemed_at_micros, ) .map_err(|error| error.to_string())?; @@ -3817,6 +3822,8 @@ fn admin_upsert_profile_redeem_code_record( input.enabled, input.allowed_user_ids, input.allowed_public_user_codes, + input.starts_at_micros, + input.expires_at_micros, input.updated_at_micros, ) .map_err(|error| error.to_string())?; @@ -3858,6 +3865,12 @@ fn admin_upsert_profile_redeem_code_record( created_by: validated_input.admin_user_id, created_at, updated_at, + starts_at: validated_input + .starts_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + expires_at: validated_input + .expires_at_micros + .map(Timestamp::from_micros_since_unix_epoch), }; let inserted = ctx.db.profile_redeem_code().insert(row); insert_profile_code_operation( @@ -7193,6 +7206,12 @@ fn build_profile_redeem_code_snapshot_from_row( created_by: row.created_by.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + starts_at_micros: row + .starts_at + .map(|value| value.to_micros_since_unix_epoch()), + expires_at_micros: row + .expires_at + .map(|value| value.to_micros_since_unix_epoch()), } }