补齐后台兑换码有效期与独立滚动

为兑换码增加可选生效和截止时间,并按左闭右开区间校验
同步 SpacetimeDB 迁移、生成绑定、共享契约和后台管理界面
让后台侧边栏与主内容区域独立滚动
修正兑换业务拒绝的 HTTP 状态映射并补充回归测试和文档
This commit is contained in:
2026-07-13 17:25:58 +08:00
parent f4153ebfa7
commit 55471e15de
23 changed files with 729 additions and 22 deletions
+4
View File
@@ -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;
@@ -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(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
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(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
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(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
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(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
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);
}
@@ -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 (
<section className="admin-page">
<div className="admin-page-heading">
@@ -222,6 +236,25 @@ export function AdminRedeemCodePage({
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
type="datetime-local"
value={startsAt}
onChange={(event) => setStartsAt(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
type="datetime-local"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
/>
</label>
</div>
{mode === 'private' ? (
<div className="admin-form-row">
<label className="admin-field">
@@ -250,6 +283,11 @@ export function AdminRedeemCodePage({
{errorMessage}
</div>
) : null}
{validityError && validityError !== errorMessage ? (
<div className="admin-alert" role="status">
{validityError}
</div>
) : null}
<button
className="admin-primary-button"
@@ -257,7 +295,8 @@ export function AdminRedeemCodePage({
isSaving ||
!code.trim() ||
!parsePositiveInteger(rewardPoints) ||
!parsePositiveInteger(maxUses)
!parsePositiveInteger(maxUses) ||
Boolean(validityError)
}
type="submit"
>
@@ -280,6 +319,7 @@ export function AdminRedeemCodePage({
<th>Code</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@@ -296,7 +336,16 @@ export function AdminRedeemCodePage({
<small>{redeemModeLabel(entry.mode)}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
<td>
<span
className={`admin-status ${redeemValidityClass(entry)}`}
>
{redeemValidityLabel(entry)}
</span>
</td>
<td>
<small>{formatValidityWindow(entry)}</small>
</td>
</tr>
))}
</tbody>
@@ -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}`;
}
+7
View File
@@ -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;
}
+33
View File
@@ -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];
}
@@ -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` 滚动。
@@ -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`
+4
View File
@@ -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 = {
@@ -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<Json<Value>, 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<Value>) -> Result<Strin
Ok(metadata_json)
}
fn parse_admin_invite_code_time_field(
fn parse_admin_profile_code_time_field(
code_label: &'static str,
field: &'static str,
value: Option<String>,
) -> Result<Option<i64>, 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;
@@ -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 => {
@@ -593,6 +593,8 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input(
enabled: bool,
allowed_user_ids: Vec<String>,
allowed_public_user_codes: Vec<String>,
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeAdminUpsertInput, RuntimeProfileFieldError> {
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<i64>,
expires_at_micros: Option<i64>,
) -> 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<i64>,
expires_at_micros: Option<i64>,
) -> 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<i64>,
expires_at_micros: Option<i64>,
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<i64>,
expires_at_micros: Option<i64>,
) -> 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<i64>,
expires_at_micros: Option<i64>,
@@ -1575,6 +1575,8 @@ pub struct RuntimeProfileRedeemCodeAdminUpsertInput {
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub allowed_public_user_codes: Vec<String>,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
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<i64>,
pub expires_at_micros: Option<i64>,
}
#[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<String>,
pub starts_at_micros: Option<i64>,
pub expires_at: Option<String>,
pub expires_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -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")
}
+10 -8
View File
@@ -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
);
@@ -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<i64>,
expires_at_micros: Option<i64>,
) -> 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,
}
}
@@ -680,6 +680,10 @@ pub struct AdminUpsertProfileRedeemCodeRequest {
pub allowed_user_ids: Vec<String>,
#[serde(default)]
pub allowed_public_user_codes: Vec<String>,
#[serde(default)]
pub starts_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
}
#[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<String>,
pub expires_at: Option<String>,
}
#[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,
@@ -315,6 +315,8 @@ impl From<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput>
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,
}
}
@@ -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<ProfileRedeemCode, String>,
pub created_at: __sdk::__query_builder::Col<ProfileRedeemCode, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<ProfileRedeemCode, __sdk::Timestamp>,
pub starts_at: __sdk::__query_builder::Col<ProfileRedeemCode, Option<__sdk::Timestamp>>,
pub expires_at: __sdk::__query_builder::Col<ProfileRedeemCode, Option<__sdk::Timestamp>>,
}
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"),
}
}
}
@@ -17,6 +17,8 @@ pub struct RuntimeProfileRedeemCodeAdminUpsertInput {
pub enabled: bool,
pub allowed_user_ids: Vec<String>,
pub allowed_public_user_codes: Vec<String>,
pub starts_at_micros: Option<i64>,
pub expires_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
@@ -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<i64>,
pub expires_at_micros: Option<i64>,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeSnapshot {
@@ -1351,6 +1351,8 @@ impl SpacetimeClient {
enabled: bool,
allowed_user_ids: Vec<String>,
allowed_public_user_codes: Vec<String>,
starts_at_micros: Option<i64>,
expires_at_micros: Option<i64>,
updated_at_micros: i64,
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
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)?
@@ -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";
@@ -202,6 +202,10 @@ pub struct ProfileRedeemCode {
pub(crate) created_by: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp,
#[default(None::<Timestamp>)]
pub(crate) starts_at: Option<Timestamp>,
#[default(None::<Timestamp>)]
pub(crate) expires_at: Option<Timestamp>,
}
#[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()),
}
}