补齐后台兑换码有效期与独立滚动
为兑换码增加可选生效和截止时间,并按左闭右开区间校验 同步 SpacetimeDB 迁移、生成绑定、共享契约和后台管理界面 让后台侧边栏与主内容区域独立滚动 修正兑换业务拒绝的 HTTP 状态映射并补充回归测试和文档
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user