master #14

Merged
kdletters merged 226 commits from master into release 2026-05-13 13:23:09 +08:00
77 changed files with 5997 additions and 110 deletions
Showing only changes of commit 34aecdddf1 - Show all commits

View File

@@ -0,0 +1,4 @@
interface:
display_name: "新增玩法入口"
short_description: "把新增玩法入口的文档、配置、路由和验证流程一次收口"
default_prompt: "Use $genarrative-gameplay-entry-type to add a new gameplay entry type end to end in Genarrative."

View File

@@ -44,7 +44,8 @@ npm run dev
补充说明:
- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server` Vite 前端,适合完整联调。
- `npm run dev` 会启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 与后台 Vite适合完整联调。
- 主站默认地址是 `http://127.0.0.1:3000`,后台可从 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`
- 如果只想单独启动前端页面,可使用 `npm run dev:web`,默认代理到本地 Rust `api-server`
构建生产包:

View File

@@ -2,16 +2,22 @@ import type {
AdminDebugHttpRequest,
AdminDebugHttpResponse,
AdminDisableProfileRedeemCodeRequest,
AdminDisableProfileTaskConfigRequest,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
ProfileInviteCodeAdminListResponse,
ProfileInviteCodeAdminResponse,
ProfileRedeemCodeAdminListResponse,
ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminListResponse,
ProfileTaskConfigAdminResponse,
} from './adminApiTypes';
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
@@ -129,6 +135,13 @@ export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
});
}
export function listProfileRedeemCodes(token: string) {
return request<ProfileRedeemCodeAdminListResponse>(
'/admin/api/profile/redeem-codes',
{token},
);
}
export function upsertProfileRedeemCode(
token: string,
payload: AdminUpsertProfileRedeemCodeRequest,
@@ -143,6 +156,13 @@ export function upsertProfileRedeemCode(
);
}
export function listProfileInviteCodes(token: string) {
return request<ProfileInviteCodeAdminListResponse>(
'/admin/api/profile/invite-codes',
{token},
);
}
export function upsertProfileInviteCode(
token: string,
payload: AdminUpsertProfileInviteCodeRequest,
@@ -171,6 +191,38 @@ export function disableProfileRedeemCode(
);
}
export function listProfileTaskConfigs(token: string) {
return request<ProfileTaskConfigAdminListResponse>(
'/admin/api/profile/tasks',
{token},
);
}
export function upsertProfileTaskConfig(
token: string,
payload: AdminUpsertProfileTaskConfigRequest,
) {
return request<ProfileTaskConfigAdminResponse>('/admin/api/profile/tasks', {
method: 'POST',
token,
body: payload,
});
}
export function disableProfileTaskConfig(
token: string,
payload: AdminDisableProfileTaskConfigRequest,
) {
return request<ProfileTaskConfigAdminResponse>(
'/admin/api/profile/tasks/disable',
{
method: 'POST',
token,
body: payload,
},
);
}
function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}

View File

@@ -106,6 +106,8 @@ export interface AdminDebugHttpResponse {
}
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
@@ -126,6 +128,23 @@ export interface AdminDisableProfileRedeemCodeRequest {
code: string;
}
export interface AdminUpsertProfileTaskConfigRequest {
taskId: string;
title: string;
description?: string | null;
eventKey: string;
cycle: ProfileTaskCycle;
scopeKind: TrackingScopeKind;
threshold: number;
rewardPoints: number;
enabled: boolean;
sortOrder: number;
}
export interface AdminDisableProfileTaskConfigRequest {
taskId: string;
}
export interface ProfileRedeemCodeAdminResponse {
code: string;
mode: ProfileRedeemCodeMode;
@@ -139,6 +158,10 @@ export interface ProfileRedeemCodeAdminResponse {
updatedAt: string;
}
export interface ProfileRedeemCodeAdminListResponse {
entries: ProfileRedeemCodeAdminResponse[];
}
export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
@@ -146,3 +169,28 @@ export interface ProfileInviteCodeAdminResponse {
createdAt: string;
updatedAt: string;
}
export interface ProfileInviteCodeAdminListResponse {
entries: ProfileInviteCodeAdminResponse[];
}
export interface ProfileTaskConfigAdminResponse {
taskId: string;
title: string;
description: string;
eventKey: string;
cycle: ProfileTaskCycle;
scopeKind: TrackingScopeKind;
threshold: number;
rewardPoints: number;
enabled: boolean;
sortOrder: number;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
}
export interface ProfileTaskConfigAdminListResponse {
entries: ProfileTaskConfigAdminResponse[];
}

View File

@@ -10,6 +10,7 @@ import type {
AdminSessionPayload,
ProfileInviteCodeAdminResponse,
ProfileRedeemCodeAdminResponse,
ProfileTaskConfigAdminResponse,
} from '../api/adminApiTypes';
import {
clearStoredAdminToken,
@@ -21,6 +22,7 @@ import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
import {AdminShell} from './AdminShell';
import type {AdminRouteId} from './adminRoutes';
import {resolveAdminRoute, routeHash} from './adminRoutes';
@@ -40,6 +42,8 @@ export function AdminApp() {
useState<ProfileRedeemCodeAdminResponse | null>(null);
const [inviteResult, setInviteResult] =
useState<ProfileInviteCodeAdminResponse | null>(null);
const [taskConfigResult, setTaskConfigResult] =
useState<ProfileTaskConfigAdminResponse | null>(null);
const clearSession = useCallback((message = '') => {
clearStoredAdminToken();
@@ -47,6 +51,7 @@ export function AdminApp() {
setAdmin(null);
setRedeemResult(null);
setInviteResult(null);
setTaskConfigResult(null);
setStatus('guest');
setLoginNotice(message);
}, []);
@@ -115,6 +120,7 @@ export function AdminApp() {
setAdmin(response.admin);
setRedeemResult(null);
setInviteResult(null);
setTaskConfigResult(null);
setLoginNotice('');
setStatus('authenticated');
}, []);
@@ -172,6 +178,14 @@ export function AdminApp() {
onResultChange={setInviteResult}
/>
) : null}
{routeId === 'tasks' ? (
<AdminTaskConfigPage
result={taskConfigResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setTaskConfigResult}
/>
) : null}
</AdminShell>
);
}

View File

@@ -3,6 +3,7 @@ import {
LayoutDashboard,
LogOut,
ShieldCheck,
ListChecks,
TicketCheck,
TicketPercent,
} from 'lucide-react';
@@ -25,6 +26,7 @@ const routeIcons = {
debug: Bug,
redeem: TicketPercent,
invite: TicketCheck,
tasks: ListChecks,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({

View File

@@ -1,4 +1,4 @@
export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite';
export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite' | 'tasks';
export interface AdminRouteDefinition {
id: AdminRouteId;
@@ -11,6 +11,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'debug', label: 'API 调试', hash: '#debug'},
{id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
];
export function resolveAdminRoute(hash: string): AdminRouteId {

View File

@@ -0,0 +1,45 @@
import type {TrackingScopeKind} from '../api/adminApiTypes';
export interface AdminTrackingEventDefinition {
key: string;
title: string;
scopeKind: TrackingScopeKind;
remark: string;
}
export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
{
key: 'daily_login',
title: '每日登录',
scopeKind: 'user',
remark: '用户打开任务中心时由后端幂等记录,用于每日登录任务进度校验。',
},
];
export function findAdminTrackingEventDefinition(eventKey: string) {
const normalizedEventKey = eventKey.trim();
return (
adminTrackingEventDefinitions.find(
(definition) => definition.key === normalizedEventKey,
) ?? null
);
}
export function filterAdminTrackingEventDefinitions(query: string) {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) {
return adminTrackingEventDefinitions;
}
return adminTrackingEventDefinitions.filter((definition) => {
const haystack = [
definition.key,
definition.title,
definition.scopeKind,
definition.remark,
]
.join(' ')
.toLowerCase();
return haystack.includes(normalizedQuery);
});
}

View File

@@ -1,7 +1,10 @@
import {Save} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import {upsertProfileInviteCode} from '../api/adminApiClient';
import {
listProfileInviteCodes,
upsertProfileInviteCode,
} from '../api/adminApiClient';
import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
@@ -21,7 +24,28 @@ export function AdminInviteCodePage({
const [inviteCode, setInviteCode] = useState('');
const [metadataText, setMetadataText] = useState('{}');
const [errorMessage, setErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileInviteCodeAdminResponse[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
void refreshInviteCodes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshInviteCodes() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileInviteCodes(token);
setEntries(response.entries);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -37,6 +61,8 @@ export function AdminInviteCodePage({
metadata: parseMetadata(metadataText),
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
@@ -44,6 +70,28 @@ export function AdminInviteCodePage({
}
}
function upsertEntry(next: ProfileInviteCodeAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.inviteCode !== next.inviteCode);
return [...rest, next].sort((left, right) => {
const leftUpdatedAt = Date.parse(left.updatedAt);
const rightUpdatedAt = Date.parse(right.updatedAt);
if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) {
const updatedCompare = rightUpdatedAt - leftUpdatedAt;
if (updatedCompare !== 0) {
return updatedCompare;
}
}
return left.inviteCode.localeCompare(right.inviteCode);
});
});
}
function fillForm(entry: ProfileInviteCodeAdminResponse) {
setInviteCode(entry.inviteCode);
setMetadataText(JSON.stringify(entry.metadata, null, 2));
}
return (
<section className="admin-page">
<div className="admin-page-heading">
@@ -51,8 +99,23 @@ export function AdminInviteCodePage({
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshInviteCodes}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<label className="admin-field">
@@ -90,42 +153,81 @@ export function AdminInviteCodePage({
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.inviteCode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>User ID</dt>
<dd>{result.userId}</dd>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.inviteCode}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.inviteCode}
</button>
</td>
<td>{entry.createdAt}</td>
<td>{entry.updatedAt}</td>
</tr>
))}
</tbody>
</table>
</div>
<div>
<dt></dt>
<dd>{result.inviteCode}</dd>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无邀请码'}
</div>
<div>
<dt></dt>
<dd>{result.createdAt}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
<div>
<dt>Metadata</dt>
<dd>
<pre className="admin-code-block">
{JSON.stringify(result.metadata, null, 2)}
</pre>
</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
)}
</section>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.inviteCode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt></dt>
<dd>{result.inviteCode}</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdAt}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
<div>
<dt>Metadata</dt>
<dd>
<pre className="admin-code-block">
{JSON.stringify(result.metadata, null, 2)}
</pre>
</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</div>
</section>
);

View File

@@ -1,8 +1,9 @@
import {PowerOff, Save} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {PowerOff, RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import {
disableProfileRedeemCode,
listProfileRedeemCodes,
upsertProfileRedeemCode,
} from '../api/adminApiClient';
import type {
@@ -40,8 +41,29 @@ export function AdminRedeemCodePage({
const [disableCode, setDisableCode] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileRedeemCodeAdminResponse[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
void refreshRedeemCodes();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshRedeemCodes() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileRedeemCodes(token);
setEntries(response.entries);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -63,6 +85,8 @@ export function AdminRedeemCodePage({
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
@@ -83,6 +107,8 @@ export function AdminRedeemCodePage({
code: disableCode.trim(),
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally {
@@ -90,6 +116,34 @@ export function AdminRedeemCodePage({
}
}
function upsertEntry(next: ProfileRedeemCodeAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.code !== next.code);
return [...rest, next].sort((left, right) => {
const leftUpdatedAt = Date.parse(left.updatedAt);
const rightUpdatedAt = Date.parse(right.updatedAt);
if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) {
const updatedCompare = rightUpdatedAt - leftUpdatedAt;
if (updatedCompare !== 0) {
return updatedCompare;
}
}
return left.code.localeCompare(right.code);
});
});
}
function fillForm(entry: ProfileRedeemCodeAdminResponse) {
setCode(entry.code);
setMode(entry.mode);
setRewardPoints(String(entry.rewardPoints));
setMaxUses(String(entry.maxUses));
setEnabled(entry.enabled);
setAllowedUserIds(entry.allowedUserIds.join('\n'));
setAllowedPublicUserCodes('');
setDisableCode(entry.code);
}
return (
<section className="admin-page">
<div className="admin-page-heading">
@@ -97,8 +151,23 @@ export function AdminRedeemCodePage({
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshRedeemCodes}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row">
@@ -200,6 +269,48 @@ export function AdminRedeemCodePage({
</form>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>Code</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.code}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.code}
</button>
<small>{redeemModeLabel(entry.mode)}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无兑换码'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleDisable}>
<label className="admin-field">
<span> Code</span>
@@ -273,3 +384,7 @@ function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function redeemModeLabel(value: ProfileRedeemCodeMode) {
return redeemModes.find((item) => item.value === value)?.label ?? value;
}

View File

@@ -0,0 +1,545 @@
import {ChevronDown, PowerOff, RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useMemo, useState} from 'react';
import {
disableProfileTaskConfig,
listProfileTaskConfigs,
upsertProfileTaskConfig,
} from '../api/adminApiClient';
import type {
ProfileTaskConfigAdminResponse,
ProfileTaskCycle,
TrackingScopeKind,
} from '../api/adminApiTypes';
import {
filterAdminTrackingEventDefinitions,
findAdminTrackingEventDefinition,
} from '../config/trackingEventDefinitions';
import {handlePageError} from './pageUtils';
interface AdminTaskConfigPageProps {
token: string;
result: ProfileTaskConfigAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileTaskConfigAdminResponse) => void;
}
const taskCycles: Array<{value: ProfileTaskCycle; label: string}> = [
{value: 'daily', label: '每日'},
];
const scopeKinds: Array<{value: TrackingScopeKind; label: string}> = [
{value: 'user', label: '用户'},
{value: 'site', label: '整站'},
{value: 'work', label: '作品'},
{value: 'module', label: '模块'},
];
export function AdminTaskConfigPage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminTaskConfigPageProps) {
const [entries, setEntries] = useState<ProfileTaskConfigAdminResponse[]>([]);
const [taskId, setTaskId] = useState('daily_login');
const [title, setTitle] = useState('每日登录');
const [description, setDescription] = useState('');
const [eventKey, setEventKey] = useState('daily_login');
const [eventKeySearch, setEventKeySearch] = useState('每日登录');
const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false);
const [cycle, setCycle] = useState<ProfileTaskCycle>('daily');
const [scopeKind, setScopeKind] = useState<TrackingScopeKind>('user');
const [threshold, setThreshold] = useState('1');
const [rewardPoints, setRewardPoints] = useState('10');
const [sortOrder, setSortOrder] = useState('10');
const [enabled, setEnabled] = useState(true);
const [disableTaskId, setDisableTaskId] = useState('daily_login');
const [errorMessage, setErrorMessage] = useState('');
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
void refreshTaskConfigs();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const selectedEventDefinition = useMemo(
() => findAdminTrackingEventDefinition(eventKey),
[eventKey],
);
const filteredEventDefinitions = useMemo(
() => filterAdminTrackingEventDefinitions(eventKeySearch),
[eventKeySearch],
);
async function refreshTaskConfigs() {
setIsLoading(true);
setListErrorMessage('');
try {
const response = await listProfileTaskConfigs(token);
setEntries(response.entries);
const dailyLogin = response.entries.find(
(entry) => entry.taskId === 'daily_login',
);
if (dailyLogin) {
fillForm(dailyLogin);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
setIsSaving(true);
try {
const response = await upsertProfileTaskConfig(token, {
taskId: taskId.trim(),
title: title.trim(),
description,
eventKey: eventKey.trim(),
cycle,
scopeKind,
threshold: parsePositiveInteger(threshold),
rewardPoints: parsePositiveInteger(rewardPoints),
enabled,
sortOrder: parseInteger(sortOrder),
});
onResultChange(response);
upsertEntry(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
async function handleDisable(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isDisabling) {
return;
}
setDisableErrorMessage('');
setIsDisabling(true);
try {
const response = await disableProfileTaskConfig(token, {
taskId: disableTaskId.trim(),
});
onResultChange(response);
upsertEntry(response);
fillForm(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally {
setIsDisabling(false);
}
}
function upsertEntry(next: ProfileTaskConfigAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.taskId !== next.taskId);
return [...rest, next].sort((left, right) => {
if (left.sortOrder !== right.sortOrder) {
return left.sortOrder - right.sortOrder;
}
return left.taskId.localeCompare(right.taskId);
});
});
}
function fillForm(entry: ProfileTaskConfigAdminResponse) {
setTaskId(entry.taskId);
setTitle(entry.title);
setDescription(entry.description);
setEventKey(entry.eventKey);
setCycle(entry.cycle);
setScopeKind(entry.scopeKind);
setThreshold(String(entry.threshold));
setRewardPoints(String(entry.rewardPoints));
setSortOrder(String(entry.sortOrder));
setEnabled(entry.enabled);
setDisableTaskId(entry.taskId);
const nextDefinition = findAdminTrackingEventDefinition(entry.eventKey);
setEventKeySearch(nextDefinition?.title ?? entry.eventKey);
setIsEventKeyPickerOpen(false);
}
function selectEventKey(nextEventKey: string) {
const nextDefinition = findAdminTrackingEventDefinition(nextEventKey);
setEventKey(nextEventKey);
if (nextDefinition) {
setEventKeySearch(nextDefinition.title);
setScopeKind(nextDefinition.scopeKind);
} else {
setEventKeySearch(nextEventKey);
}
setIsEventKeyPickerOpen(false);
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshTaskConfigs}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-form-row">
<label className="admin-field admin-field-fill">
<span>Task ID</span>
<input
value={taskId}
onChange={(event) => setTaskId(event.target.value)}
/>
</label>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</label>
<label className="admin-field">
<span>Event Key</span>
<div
className="admin-combobox"
onBlur={(event) => {
const nextTarget = event.relatedTarget;
if (
!(nextTarget instanceof Node) ||
!event.currentTarget.contains(nextTarget)
) {
setIsEventKeyPickerOpen(false);
}
}}
>
<div className="admin-combobox-control">
<input
aria-label="Event Key"
value={eventKeySearch || eventKey}
onChange={(event) => {
const nextValue = event.target.value;
setEventKeySearch(nextValue);
setIsEventKeyPickerOpen(true);
}}
onFocus={() => setIsEventKeyPickerOpen(true)}
/>
<button
aria-label="展开 Event Key"
className="admin-combobox-toggle"
type="button"
onClick={() =>
setIsEventKeyPickerOpen((current) => !current)
}
>
<ChevronDown size={16} aria-hidden="true" />
</button>
</div>
{isEventKeyPickerOpen ? (
<div className="admin-combobox-menu" role="listbox">
{filteredEventDefinitions.length ? (
filteredEventDefinitions.map((definition) => (
<button
key={definition.key}
className="admin-combobox-option"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => selectEventKey(definition.key)}
>
<strong>{definition.title}</strong>
<span>{definition.key}</span>
<small>{definition.remark}</small>
</button>
))
) : (
<div className="admin-combobox-empty">
{eventKeySearch.trim() ? (
<button
className="admin-text-button"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() =>
selectEventKey(eventKeySearch.trim())
}
>
使 key
</button>
) : null}
</div>
)}
</div>
) : null}
</div>
{selectedEventDefinition ? (
<small className="admin-field-note">
{selectedEventDefinition.remark}
</small>
) : (
<small className="admin-field-note">
key
</small>
)}
</label>
</div>
<label className="admin-field">
<span></span>
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<select
value={cycle}
onChange={(event) =>
setCycle(event.target.value as ProfileTaskCycle)
}
>
{taskCycles.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="admin-field">
<span></span>
<select
value={scopeKind}
onChange={(event) =>
setScopeKind(event.target.value as TrackingScopeKind)
}
>
{scopeKinds.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={threshold}
onChange={(event) => setThreshold(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={rewardPoints}
onChange={(event) => setRewardPoints(event.target.value)}
/>
</label>
</div>
<label className="admin-field admin-field-compact">
<span></span>
<input
step={1}
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={
isSaving ||
!taskId.trim() ||
!title.trim() ||
!eventKey.trim() ||
!parsePositiveInteger(threshold) ||
!parsePositiveInteger(rewardPoints)
}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{entries.length}</span>
</div>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.taskId}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.title || entry.taskId}
</button>
<small>{entry.taskId}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无配置'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleDisable}>
<label className="admin-field">
<span> Task ID</span>
<input
value={disableTaskId}
onChange={(event) => setDisableTaskId(event.target.value)}
/>
</label>
{disableErrorMessage ? (
<div className="admin-alert" role="status">
{disableErrorMessage}
</div>
) : null}
<button
className="admin-danger-button"
disabled={isDisabling || !disableTaskId.trim()}
type="submit"
>
<PowerOff size={17} aria-hidden="true" />
<span>{isDisabling ? '停用中' : '停用'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result?.taskId ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>Task ID</dt>
<dd>{result.taskId}</dd>
</div>
<div>
<dt>Event Key</dt>
<dd>{result.eventKey}</dd>
</div>
<div>
<dt></dt>
<dd>{result.rewardPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.threshold}</dd>
</div>
<div>
<dt></dt>
<dd>{result.enabled ? '启用' : '停用'}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedBy}</dd>
</div>
<div>
<dt></dt>
<dd>{result.updatedAt}</dd>
</div>
</dl>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</div>
</section>
);
}
function parsePositiveInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
function parseInteger(value: string) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
}

View File

@@ -321,6 +321,13 @@ button:disabled {
outline: none;
}
.admin-field-note {
color: #667682;
font-size: 12px;
font-weight: 500;
line-height: 1.45;
}
.admin-field textarea {
min-height: 112px;
resize: vertical;
@@ -333,6 +340,96 @@ button:disabled {
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
}
.admin-combobox {
position: relative;
min-width: 0;
}
.admin-combobox-control {
display: grid;
grid-template-columns: minmax(0, 1fr) 38px;
}
.admin-combobox-control input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.admin-combobox-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
border: 1px solid #cbd8e0;
border-left: 0;
border-radius: 0 8px 8px 0;
color: #52616d;
background: #fbfdfe;
}
.admin-combobox:focus-within .admin-combobox-toggle {
border-color: #126e82;
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
}
.admin-combobox-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 30;
display: grid;
max-height: 260px;
overflow: auto;
border: 1px solid #cbd8e0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 16px 40px rgba(23, 33, 43, 0.14);
padding: 6px;
}
.admin-combobox-option {
display: grid;
gap: 3px;
width: 100%;
border: 0;
border-radius: 7px;
color: #17212b;
background: transparent;
padding: 9px 10px;
text-align: left;
}
.admin-combobox-option:hover,
.admin-combobox-option:focus-visible {
background: #e7f3f5;
outline: none;
}
.admin-combobox-option span {
color: #0f5666;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
font-weight: 700;
}
.admin-combobox-option small,
.admin-combobox-empty {
color: #667682;
font-size: 12px;
font-weight: 500;
line-height: 1.45;
}
.admin-combobox-empty {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px;
}
.admin-switch-field {
display: inline-flex;
align-items: center;
@@ -384,6 +481,16 @@ button:disabled {
background: #eef3f6;
}
.admin-text-button {
display: inline;
border: 0;
color: #0f5666;
background: transparent;
padding: 0;
text-align: left;
font-weight: 700;
}
.admin-alert {
border: 1px solid #efc0bd;
border-radius: 8px;
@@ -443,6 +550,17 @@ button:disabled {
font-size: 12px;
}
.admin-table td small {
display: block;
margin-top: 3px;
color: #667682;
font-size: 12px;
}
.admin-table-compact {
min-width: 360px;
}
.admin-status {
display: inline-flex;
max-width: 460px;
@@ -608,7 +726,7 @@ button:disabled {
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
border-top: 1px solid #d8e2e8;
background: rgba(255, 255, 255, 0.94);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));

View File

@@ -15,10 +15,12 @@ export default defineConfig(({mode}) => {
env.ADMIN_API_TARGET ||
env.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
const base = env.ADMIN_WEB_BASE || '/admin/';
return {
root: adminWebRoot,
envDir: repoRoot,
base,
plugins: [react()],
server: {
proxy: {

View File

@@ -11,6 +11,8 @@
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
- [参考目录](./reference/README.md):脚本/Function 速查入口。
重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [埋点查询](./tracking/README.md):埋点原始事件与聚合投影的本地 SQL 查询。
- [运营查询](./operations/README.md):任务、领奖、钱包对账等后台核查查询。
- [PRD](./prd):产品需求与阶段计划;后台管理独立前端工程见 [ADMIN_WEB_CONSOLE_PRD_2026-04-30.md](./prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),新增 RPG 开场动画方案见 [AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md](./prd/AI_NATIVE_RPG_OPENING_ANIMATION_PRD_2026-04-25.md),新增抓大鹅 Match3D 玩法方案见 [AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md](./prd/AI_NATIVE_MATCH3D_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-30.md)。
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
@@ -33,3 +35,5 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段
- `technical/`:偏技术选型、实现路线、竞品/产品形态拆解。
- `planning/`:偏阶段优先级与推进顺序。
- `reference/`:偏目录、速查、检索辅助。
- `tracking/`:偏埋点原始事实和聚合投影查询,不放任务进度或钱包对账。
- `operations/`:偏后台运营核查、对账和排障查询。

View File

@@ -0,0 +1,33 @@
# 个人任务运营查询手册
更新时间:`2026-05-03`
## 任务配置
```powershell
spacetime sql <db> "SELECT * FROM profile_task_config ORDER BY updated_at DESC"
spacetime sql <db> "SELECT * FROM profile_task_config WHERE task_id = 'daily_login'"
```
## 任务进度
```powershell
spacetime sql <db> "SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' ORDER BY updated_at DESC"
spacetime sql <db> "SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' AND task_id = 'daily_login' ORDER BY day_key DESC"
```
## 领奖记录
```powershell
spacetime sql <db> "SELECT * FROM profile_task_reward_claim WHERE user_id = '<user_id>' ORDER BY claimed_at DESC"
spacetime sql <db> "SELECT * FROM profile_task_reward_claim WHERE claim_id = '<user_id>:daily_login:<day_key>'"
```
## 钱包流水对账
```powershell
spacetime sql <db> "SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' AND source_type = 'DailyTaskReward' ORDER BY created_at DESC"
spacetime sql <db> "SELECT * FROM profile_dashboard_state WHERE user_id = '<user_id>'"
```
每日登录奖励流水 ID 为 `task-reward:<user_id>:daily_login:<day_key>`,领奖记录中的 `wallet_ledger_id` 必须能在 `profile_wallet_ledger` 中查到。

View File

@@ -0,0 +1,5 @@
# 运营查询
本目录存放后台运营核查、对账和排障查询,不承载埋点系统本身的查询手册。
- [PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md](./PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md):个人任务配置、进度、领奖记录与钱包流水对账查询。

View File

@@ -22,15 +22,16 @@
-`2026-04-19` 起,“最近游玩 / 历史浏览”已从“我的”页迁出,改为平台一级主 Tab“存档”。
- 对应母文档见 [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)。
当前“我的”页保留以下 `7` 个独立功能:
当前“我的”页保留以下 `8` 个独立功能:
1. 账号资料与身份卡
2. 会员中心与充值
3. 我的数据看板
4. 邀请好友
5. 填邀请码
6. 玩家社区
7. 设置与账号安全
6. 每日任务
7. 玩家社区
8. 设置与账号安全
---
@@ -42,8 +43,9 @@
4. [PLATFORM_SAVE_TAB_PRD_2026-04-19.md](/E:/Repos/Genarrative/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md)
5. [MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_FRIENDS_PRD_2026-04-16.md)
6. [MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_INVITE_CODE_REDEMPTION_PRD_2026-04-16.md)
7. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md)
8. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md)
7. [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](../technical/PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md)
8. [MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_PLAYER_COMMUNITY_PRD_2026-04-16.md)
9. [MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md](/E:/Repos/Genarrative/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md)
---
@@ -58,7 +60,8 @@
5. 会员中心与充值
6. 邀请好友
7. 填邀请码
8. 玩家社区
8. 每日任务
9. 玩家社区
原因:
@@ -66,6 +69,7 @@
- `3 + 4` 直接增强账号资产与回流体验,短期收益高
- `5 + 6` 涉及商业化和关系绑定,依赖结算与奖励台账
- `7` 最适合放在平台内容层能力稳定后再做
- `8` 依赖埋点聚合、任务配置和钱包流水,首版只接每日登录
---
@@ -76,6 +80,7 @@
- `PlatformHomeView` 继续作为“我的”Tab 首屏承载层
- 优先采用现有面板、抽屉、弹窗,不新建独立大系统
- 页面只展示后端返回的状态,不自行计算结论型业务状态
- 每日任务入口放在“常用功能”,点击后弹出独立任务面板
### 4.2 后端边界

View File

@@ -96,8 +96,10 @@ export interface ApiErrorEnvelope {
| 当前管理员 | `GET /admin/api/me` | 管理员 Bearer |
| 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer |
| 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer |
| 读取兑换码列表 | `GET /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer |
| 读取后台邀请码列表 | `GET /admin/api/profile/invite-codes` | 管理员 Bearer |
| 创建/更新注册邀请码 | `POST /admin/api/profile/invite-codes` | 管理员 Bearer |
### 4.3 前端类型命名
@@ -206,6 +208,10 @@ export interface ProfileRedeemCodeAdminResponse {
updatedAt: string;
}
export interface ProfileRedeemCodeAdminListResponse {
entries: ProfileRedeemCodeAdminResponse[];
}
export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
@@ -213,6 +219,10 @@ export interface ProfileInviteCodeAdminResponse {
createdAt: string;
updatedAt: string;
}
export interface ProfileInviteCodeAdminListResponse {
entries: ProfileInviteCodeAdminResponse[];
}
```
### 4.4 登录 contract
@@ -284,6 +294,31 @@ export interface ProfileInviteCodeAdminResponse {
### 4.7 兑换码管理 contract
列表请求:
`GET /admin/api/profile/redeem-codes`
成功返回:
```json
{
"entries": [
{
"code": "WELCOME2026",
"mode": "public",
"rewardPoints": 100,
"maxUses": 1,
"globalUsedCount": 0,
"enabled": true,
"allowedUserIds": [],
"createdBy": "admin:root",
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
]
}
```
创建/更新请求:
```json
@@ -300,8 +335,6 @@ export interface ProfileInviteCodeAdminResponse {
停用请求:
兑换码管理页的最近一次接口返回记录由 `AdminApp` 维护为管理端会话态,并传入 `AdminRedeemCodePage` 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 `useState` 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。
```json
{
"code": "WELCOME2026"
@@ -325,10 +358,36 @@ export interface ProfileInviteCodeAdminResponse {
}
```
兑换码管理页进入时必须通过 `GET /admin/api/profile/redeem-codes` 加载数据库已有记录。最近一次接口返回记录仍由 `AdminApp` 维护为管理端会话态,用于展示当前操作结果;历史列表不得依赖该会话态,刷新页面后必须从后端列表接口恢复。列表项点击后回填表单,继续通过同一个 `POST /admin/api/profile/redeem-codes` 修改原记录。
前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。
### 4.8 邀请码管理 contract
列表请求:
`GET /admin/api/profile/invite-codes`
成功返回:
```json
{
"entries": [
{
"userId": "admin:root:SPRING2026",
"inviteCode": "SPRING2026",
"metadata": {
"batch": "spring"
},
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
]
}
```
后台邀请码列表只返回后台运营预置码。后端按 `profile_invite_code.user_id``admin:` 前缀过滤,普通用户在邀请中心生成的个人邀请码不得展示在后台列表中。
创建/更新请求:
```json
@@ -372,19 +431,22 @@ export interface ProfileInviteCodeAdminResponse {
3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors`
4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]`
5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。
6. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata不在前端复制后端邀请码规则
7. 所有按钮的 loading 状态必须锁定重复提交
8. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动
6. 兑换码页和邀请码页进入时加载数据库列表,保存后合并返回记录,点击列表项回填表单进入编辑态
7. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata不在前端复制后端邀请码规则
8. 所有按钮的 loading 状态必须锁定重复提交
9. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
## 7. 部署与联调
### 7.1 本地联调
1. 启动后端:`npm run api-server`
2. 启动后台前端:在 `apps/admin-web` 执行 `npm run dev`
3. 后台 dev server 通过 Vite proxy 转发 `/admin/api``ADMIN_API_TARGET`;未配置时默认 `http://127.0.0.1:3100`
4. 若使用非 3100 端口,在仓库根目录 `.env.local` 设置 `ADMIN_API_TARGET=http://127.0.0.1:<api-server-port>`,并重启后台前端 dev server
5. `GENARRATIVE_API_PORT` 控制 Rust `api-server` 监听端口;`ADMIN_API_TARGET` 只控制后台前端 dev proxy 目标,二者需要指向同一个端口
1. 完整本地栈直接在仓库根目录执行 `npm run dev`
2. `npm run dev` 默认启动 SpacetimeDB standalone、Rust `api-server`、主站 Vite 和后台 Vite
3. 主站默认地址为 `http://127.0.0.1:3000`,后台可从主站 `http://127.0.0.1:3000/admin/` 进入,也可直连 `http://127.0.0.1:3102`
4. 主站 Vite 会把 `/admin/` 转发到后台 dev server贴近生产同域 `/admin/` 入口
5. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到当前 Rust API 地址;`--api-port` 改动时脚本会同步注入 `ADMIN_API_TARGET`
6. 如需单独启动后台前端,可继续执行根脚本 `npm run admin-web:dev`,或在 `apps/admin-web` 执行 `npm run dev`;单独启动时未配置 `ADMIN_API_TARGET` 会默认代理到 `http://127.0.0.1:3100`
7. 后台 dev 端口可用 `npm run dev -- --admin-web-port <port>` 覆盖。
### 7.2 构建部署
@@ -430,8 +492,8 @@ export interface ProfileInviteCodeAdminResponse {
- token 恢复、过期清理、退出登录。
- 总览页正常数据、部分表统计失败、整体请求失败。
- API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。
- 兑换码 public/unique/private 表单提交和停用。
- 邀请码表单提交、metadata JSON 对象校验和结果展示。
- 兑换码数据库列表加载、列表点击回填、public/unique/private 表单提交和停用。
- 邀请码数据库列表加载、普通用户邀请码不展示、列表点击回填、metadata JSON 对象校验和结果展示。
2. 根工程:
- `npm run check:encoding`
- 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。

View File

@@ -0,0 +1,78 @@
# 个人任务与埋点系统技术方案
更新时间:`2026-05-03`
## 1. 目标
本轮新增一套可配置的个人任务系统,并补齐任务依赖的埋点统计能力。首个任务为“每日登录”,奖励 `10` 光点,入口放在“我的”页签;后台可修改任务配置。
## 2. 核心边界
- 埋点原始事实写入 `tracking_event`,这是实际存在的 SpacetimeDB 表。
- 聚合投影写入 `tracking_daily_stat`,这也是后端维护的真实表,不是 view。
- 任务配置写入 `profile_task_config`,默认配置包含 `daily_login`,后台修改后不得被默认初始化覆盖。
- 任务进度写入 `profile_task_progress`,用于任务中心快速读取状态。
- 领奖记录写入 `profile_task_reward_claim`,与钱包流水 `profile_wallet_ledger` 同事务写入。
- “星光”奖励复用现有“光点”钱包,不新增第二种货币。
## 3. 埋点分层
| 层级 | scope_kind | scope_id 口径 |
| --- | --- | --- |
| 整站 | `site` | 固定为 `site` 或站点分区 key |
| 作品 | `work` | 作品 profile_id / work_id |
| 模块 | `module` | 模块 key例如 `profile``puzzle` |
| 用户 | `user` | 用户 id |
每条埋点可同时记录 `user_id``owner_user_id``profile_id``module_key``metadata_json`。任务首版只依赖用户层 `daily_login`,表结构先保留四层统计能力。
## 4. 日期桶
任务统计使用北京时间自然日:`day_key = floor((occurred_at_micros + 8h) / 1d)`
这样存储仍是 UTC 时间戳,日切规则固定为业务口径,不依赖服务器本地时区。`tracking_event.occurred_at` 保存精确发生时间,`tracking_daily_stat.day_key` 只承担聚合桶职责。
## 5. 首版任务
| 字段 | 默认值 |
| --- | --- |
| task_id | `daily_login` |
| title | `每日登录` |
| event_key | `daily_login` |
| cycle | `daily` |
| threshold | `1` |
| reward_points | `10` |
| enabled | `true` |
用户打开任务中心时,后端会幂等记录当日 `daily_login` 埋点并刷新任务进度。用户点击领取时,后端校验当日进度、领奖记录和配置状态,然后同事务写入领奖记录与钱包流水。
后台任务配置页的 `Event Key` 使用可搜索下拉控件,选项来自前端后台的埋点定义注册表。当前注册表默认包含 `daily_login`,展示中文名称和备注;后续新增任务依赖的埋点时,应先补充注册表,再开放运营配置。
## 6. 接口
### 用户侧
- `GET /api/profile/tasks`:读取任务中心,同时记录当日登录埋点。
- `POST /api/profile/tasks/{task_id}/claim`:领取任务奖励。
### 后台侧
- `GET /admin/api/profile/tasks`:读取任务配置列表。
- `POST /admin/api/profile/tasks`:新增或更新任务配置。
- `POST /admin/api/profile/tasks/disable`:停用任务配置。
后台任务配置页进入时从 `profile_task_config` 对应的列表接口读取已有配置,点击列表项回填表单后仍通过同一个 upsert 接口修改原配置。最近一次保存结果可以保留为会话态提示,但不得作为任务配置列表的唯一来源。
## 7. 查询文档边界
- `docs/tracking/` 只存放具体埋点与埋点聚合查询,例如 `tracking_event``tracking_daily_stat` 的站点/作品/模块/用户查询。
- `docs/operations/` 存放运营核查查询,例如任务进度、领奖记录、钱包流水对账。
不要把任务进度、领奖记录或钱包对账查询塞进 `docs/tracking/`,它们不是埋点系统本身。
## 8. 验收
1. `profile_task_config` 默认存在 `daily_login`,后台可修改奖励、阈值、标题和启用状态。
2. “我的”页可以打开每日任务面板,登录后任务可领取 `10` 光点。
3. 重复打开任务中心不会重复增加领取资格,重复领奖不会重复发放。
4. 表目录、迁移白名单、Rust/TypeScript 契约和前端入口同步更新。

View File

@@ -4,6 +4,7 @@
## 文档列表
- [PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md](./PROFILE_TASK_AND_TRACKING_SYSTEM_2026-05-03.md):冻结个人任务与埋点系统首版方案,明确 `tracking_event``tracking_daily_stat``profile_task_config`、任务进度、领奖记录和光点钱包流水的边界。
- [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md):冻结单机生产部署目标,从旧一体化启动脚本切到 Nginx、systemd 托管 SpacetimeDB 与 Rust `api-server`,并记录生产 Jenkins 流水线拆分计划和首批部署骨架。
- [PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md](./PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。
- [RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md](./RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md):记录 `agent-foundation-*-dossier-batch-*` 无搜索 Responses 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。

View File

@@ -24,7 +24,7 @@ spacetime sql <db> "SELECT * FROM custom_world_gallery_entry"
| --- | --- |
| 运维迁移 | `database_migration_operator`, `database_migration_import_chunk` |
| 认证 | `auth_store_snapshot`, `user_account`, `auth_identity`, `refresh_session` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_save_archive` |
| 运行时档案 | `runtime_setting`, `runtime_snapshot`, `user_browse_history`, `profile_dashboard_state`, `profile_wallet_ledger`, `tracking_event`, `tracking_daily_stat`, `profile_task_config`, `profile_task_progress`, `profile_task_reward_claim`, `profile_redeem_code`, `profile_redeem_code_usage`, `profile_invite_code`, `profile_referral_relation`, `profile_played_world`, `profile_membership`, `profile_recharge_order`, `profile_save_archive` |
| RPG 运行时 | `story_session`, `story_event`, `npc_state`, `inventory_slot`, `battle_state`, `treasure_record`, `quest_record`, `quest_log`, `player_progression`, `chapter_progression` |
| 世界创作 | `custom_world_profile`, `custom_world_session`, `custom_world_agent_session`, `custom_world_agent_message`, `custom_world_agent_operation`, `custom_world_draft_card`, `custom_world_gallery_entry` |
| 拼图 | `puzzle_agent_session`, `puzzle_agent_message`, `puzzle_work_profile`, `puzzle_event`, `puzzle_runtime_run`, `puzzle_leaderboard_entry` |
@@ -158,14 +158,72 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>';
SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created_at DESC;
```
### `tracking_event`
- 作用:埋点原始事件表,保存整站、作品、模块和用户层的原始事实。
- 结构:`event_id PK: String`, `event_key: String`, `scope_kind: RuntimeTrackingScopeKind`, `scope_id: String`, `day_key: i64`, `user_id: Option<String>`, `owner_user_id: Option<String>`, `profile_id: Option<String>`, `module_key: Option<String>`, `metadata_json: String`, `occurred_at: Timestamp`
- 索引:`event_key`, `(scope_kind, scope_id)`, `(user_id, occurred_at)`
```sql
SELECT * FROM tracking_event WHERE event_id = '<event_id>';
SELECT * FROM tracking_event WHERE event_key = '<event_key>' ORDER BY occurred_at DESC;
SELECT * FROM tracking_event WHERE scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY occurred_at DESC;
```
### `tracking_daily_stat`
- 作用:埋点按北京时间自然日聚合后的真实表,供任务统计和快速查询使用。
- 结构:`stat_id PK: String`, `event_key: String`, `scope_kind: RuntimeTrackingScopeKind`, `scope_id: String`, `day_key: i64`, `count: u32`, `first_occurred_at: Timestamp`, `last_occurred_at: Timestamp`, `updated_at: Timestamp`
- 索引:`(event_key, day_key)`, `(scope_kind, scope_id, day_key)`
```sql
SELECT * FROM tracking_daily_stat WHERE stat_id = '<stat_id>';
SELECT * FROM tracking_daily_stat WHERE scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY day_key DESC;
```
### `profile_task_config`
- 作用:个人任务配置表,后台可修改每日登录等任务的奖励、阈值、启用状态和排序。
- 结构:`task_id PK: String`, `title: String`, `description: String`, `event_key: String`, `cycle: RuntimeProfileTaskCycle`, `scope_kind: RuntimeTrackingScopeKind`, `threshold: u32`, `reward_points: u64`, `enabled: bool`, `sort_order: i32`, `created_by: String`, `created_at: Timestamp`, `updated_by: String`, `updated_at: Timestamp`
- 索引:主键 `task_id`
```sql
SELECT * FROM profile_task_config WHERE task_id = 'daily_login';
SELECT * FROM profile_task_config ORDER BY updated_at DESC;
```
### `profile_task_progress`
- 作用:个人任务进度表,保存用户在某个自然日的任务进度和状态快照。
- 结构:`progress_id PK: String`, `user_id: String`, `task_id: String`, `day_key: i64`, `progress_count: u32`, `threshold: u32`, `status: RuntimeProfileTaskStatus`, `updated_at: Timestamp`
- 索引:`user_id`, `(user_id, task_id)`
```sql
SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' ORDER BY updated_at DESC;
SELECT * FROM profile_task_progress WHERE user_id = '<user_id>' AND task_id = 'daily_login' ORDER BY day_key DESC;
```
### `profile_task_reward_claim`
- 作用:个人任务领奖记录表,记录用户、任务、自然日、奖励和对应钱包流水。
- 结构:`claim_id PK: String`, `user_id: String`, `task_id: String`, `day_key: i64`, `reward_points: u64`, `wallet_ledger_id: String`, `claimed_at: Timestamp`
- 索引:`user_id`, `(user_id, task_id)`
```sql
SELECT * FROM profile_task_reward_claim WHERE user_id = '<user_id>' ORDER BY claimed_at DESC;
SELECT * FROM profile_task_reward_claim WHERE claim_id = '<user_id>:daily_login:<day_key>';
```
### `profile_redeem_code`
- 作用:运营发放的光点兑换码,支持公共码、唯一码和私有码。
- 结构:`code PK: String`, `mode: RuntimeProfileRedeemCodeMode`, `reward_points: u64`, `max_uses: u32`, `global_used_count: u32`, `enabled: bool`, `allowed_user_ids: Vec<String>`, `created_by: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `code`
- 后台读取:`GET /admin/api/profile/redeem-codes` 从该表返回已有兑换码,后台列表点击后通过 upsert 修改同一条记录。
```sql
SELECT * FROM profile_redeem_code WHERE code = '<CODE>';
SELECT * FROM profile_redeem_code ORDER BY updated_at DESC;
```
### `profile_redeem_code_usage`
@@ -181,13 +239,15 @@ SELECT * FROM profile_redeem_code_usage WHERE user_id = '<user_id>';
### `profile_invite_code`
- 作用:用户邀请中心的邀请码主表,保存用户当前稳定邀请码。
- 结构:`user_id PK: String`, `invite_code: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 作用:用户邀请中心的邀请码主表,也承载后台运营预置邀请码。
- 结构:`user_id PK: String`, `invite_code: String`, `metadata_json: String`, `created_at: Timestamp`, `updated_at: Timestamp`
- 索引:主键 `user_id`,唯一索引 `invite_code`
- 后台读取:`GET /admin/api/profile/invite-codes` 只返回 `user_id``admin:` 开头的后台预置码;普通用户自己的邀请码不得进入后台运营列表。
```sql
SELECT * FROM profile_invite_code WHERE user_id = '<user_id>';
SELECT * FROM profile_invite_code WHERE invite_code = '<invite_code>';
SELECT * FROM profile_invite_code WHERE user_id LIKE 'admin:%' ORDER BY updated_at DESC;
```
### `profile_referral_relation`

7
docs/tracking/README.md Normal file
View File

@@ -0,0 +1,7 @@
# 埋点查询
本目录只存放埋点原始事件和埋点聚合投影的本地查询手册。
- [TRACKING_QUERY_PLAYBOOK_2026-05-03.md](./TRACKING_QUERY_PLAYBOOK_2026-05-03.md)`tracking_event``tracking_daily_stat` 的整站、作品、模块、用户维度查询。
任务配置、任务进度、领奖记录和钱包对账查询放在 `docs/operations/`

View File

@@ -0,0 +1,50 @@
# 埋点查询手册
更新时间:`2026-05-03`
占位符说明:
- `<db>`SpacetimeDB 数据库名。
- `<event_key>`:埋点 key例如 `daily_login`
- `<day_key>`:北京时间自然日桶,按 `floor((occurred_at_micros + 8h) / 1d)` 计算。
## 原始事件
```powershell
spacetime sql <db> "SELECT * FROM tracking_event ORDER BY occurred_at DESC LIMIT 50"
spacetime sql <db> "SELECT * FROM tracking_event WHERE event_key = '<event_key>' ORDER BY occurred_at DESC LIMIT 50"
```
## 整站维度
```powershell
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE scope_kind = 'Site' AND scope_id = 'site' ORDER BY day_key DESC"
```
## 作品维度
```powershell
spacetime sql <db> "SELECT * FROM tracking_event WHERE scope_kind = 'Work' AND scope_id = '<profile_id>' ORDER BY occurred_at DESC LIMIT 50"
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE scope_kind = 'Work' AND scope_id = '<profile_id>' ORDER BY day_key DESC"
```
## 模块维度
```powershell
spacetime sql <db> "SELECT * FROM tracking_event WHERE scope_kind = 'Module' AND scope_id = '<module_key>' ORDER BY occurred_at DESC LIMIT 50"
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE scope_kind = 'Module' AND scope_id = '<module_key>' ORDER BY day_key DESC"
```
## 用户维度
```powershell
spacetime sql <db> "SELECT * FROM tracking_event WHERE scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY occurred_at DESC LIMIT 50"
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY day_key DESC"
```
## 每日登录埋点
```powershell
spacetime sql <db> "SELECT * FROM tracking_event WHERE event_key = 'daily_login' AND user_id = '<user_id>' ORDER BY occurred_at DESC LIMIT 20"
spacetime sql <db> "SELECT * FROM tracking_daily_stat WHERE event_key = 'daily_login' AND scope_kind = 'User' AND scope_id = '<user_id>' ORDER BY day_key DESC"
```

View File

@@ -66,7 +66,8 @@ export type ProfileWalletLedgerEntry = {
| 'asset_operation_consume'
| 'asset_operation_refund'
| 'redeem_code_reward'
| 'puzzle_author_incentive_claim';
| 'puzzle_author_incentive_claim'
| 'daily_task_reward';
createdAt: string;
};
@@ -186,6 +187,116 @@ export type RedeemProfileRewardCodeResponse = {
ledgerEntry: ProfileWalletLedgerEntry;
};
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export type ProfileTaskStatus =
| 'incomplete'
| 'claimable'
| 'claimed'
| 'disabled';
export type ProfileTaskItem = {
taskId: string;
title: string;
description: string;
eventKey: string;
cycle: ProfileTaskCycle;
threshold: number;
progressCount: number;
rewardPoints: number;
status: ProfileTaskStatus;
dayKey: number;
claimedAt: string | null;
updatedAt: string;
};
export type ProfileTaskCenterResponse = {
dayKey: number;
walletBalance: number;
tasks: ProfileTaskItem[];
updatedAt: string;
};
export type ClaimProfileTaskRewardResponse = {
taskId: string;
dayKey: number;
rewardPoints: number;
walletBalance: number;
ledgerEntry: ProfileWalletLedgerEntry;
center: ProfileTaskCenterResponse;
};
export type ProfileTaskConfigAdminResponse = {
taskId: string;
title: string;
description: string;
eventKey: string;
cycle: ProfileTaskCycle;
scopeKind: TrackingScopeKind;
threshold: number;
rewardPoints: number;
enabled: boolean;
sortOrder: number;
createdBy: string;
createdAt: string;
updatedBy: string;
updatedAt: string;
};
export type ProfileTaskConfigAdminListResponse = {
entries: ProfileTaskConfigAdminResponse[];
};
export type AdminUpsertProfileTaskConfigRequest = {
taskId: string;
title: string;
description?: string | null;
eventKey: string;
cycle: ProfileTaskCycle;
scopeKind: TrackingScopeKind;
threshold: number;
rewardPoints: number;
enabled?: boolean;
sortOrder?: number;
};
export type AdminDisableProfileTaskConfigRequest = {
taskId: string;
};
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export type ProfileRedeemCodeAdminResponse = {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
globalUsedCount: number;
enabled: boolean;
allowedUserIds: string[];
createdBy: string;
createdAt: string;
updatedAt: string;
};
export type ProfileRedeemCodeAdminListResponse = {
entries: ProfileRedeemCodeAdminResponse[];
};
export type AdminUpsertProfileRedeemCodeRequest = {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
enabled?: boolean;
allowedUserIds?: string[];
allowedPublicUserCodes?: string[];
};
export type AdminDisableProfileRedeemCodeRequest = {
code: string;
};
export type AdminUpsertProfileInviteCodeRequest = {
inviteCode: string;
metadata?: Record<string, unknown> | null;
@@ -199,6 +310,10 @@ export type ProfileInviteCodeAdminResponse = {
updatedAt: string;
};
export type ProfileInviteCodeAdminListResponse = {
entries: ProfileInviteCodeAdminResponse[];
};
export type ProfilePlayedWorkSummary = {
worldKey: string;
ownerUserId: string | null;

View File

@@ -7,6 +7,7 @@ usage() {
用法:
npm run dev:rust
./scripts/dev-rust-stack.sh --api-port 8090 --spacetime-port 3110
./scripts/dev-rust-stack.sh --admin-web-port 3102
./scripts/dev-rust-stack.sh --api-timeout-seconds 600
./scripts/dev-rust-stack.sh --skip-spacetime --skip-publish
./scripts/dev-rust-stack.sh --preserve-database
@@ -14,7 +15,7 @@ usage() {
npm run dev:rust:logs -- --follow
说明:
1. 默认同时启动 SpacetimeDB standalone、Rust api-server Vite 前端
1. 默认同时启动 SpacetimeDB standalone、Rust api-server、主站 Vite 与后台 Vite。
2. 当前开发阶段默认 publish server-rs/crates/spacetime-module 时追加 -c=on-conflict 在结构冲突时清理旧模块数据。
3. 只有显式传入 --preserve-database 时,才会跳过 -c=on-conflict。
4. SpacetimeDB 默认使用 server-rs/.spacetimedb/local 作为本地数据与日志目录。
@@ -296,11 +297,14 @@ SERVER_RS_DIR="${REPO_ROOT}/server-rs"
MANIFEST_PATH="${SERVER_RS_DIR}/Cargo.toml"
MODULE_PATH="${SERVER_RS_DIR}/crates/spacetime-module"
VITE_CLI_PATH="${REPO_ROOT}/scripts/vite-cli.mjs"
ADMIN_WEB_DIR="${REPO_ROOT}/apps/admin-web"
API_HOST="127.0.0.1"
API_PORT="8082"
WEB_HOST="0.0.0.0"
WEB_PORT="3000"
ADMIN_WEB_HOST="127.0.0.1"
ADMIN_WEB_PORT="3102"
SPACETIME_HOST="127.0.0.1"
SPACETIME_PORT="3101"
SPACETIME_ROOT_DIR="${SERVER_RS_DIR}/.spacetimedb/local"
@@ -359,6 +363,14 @@ while [[ $# -gt 0 ]]; do
WEB_PORT="${2:?缺少 --web-port 的值}"
shift 2
;;
--admin-web-host)
ADMIN_WEB_HOST="${2:?缺少 --admin-web-host 的值}"
shift 2
;;
--admin-web-port)
ADMIN_WEB_PORT="${2:?缺少 --admin-web-port 的值}"
shift 2
;;
--spacetime-host)
SPACETIME_HOST="${2:?缺少 --spacetime-host 的值}"
shift 2
@@ -444,6 +456,11 @@ if [[ ! -f "${VITE_CLI_PATH}" ]]; then
exit 1
fi
if [[ ! -f "${ADMIN_WEB_DIR}/package.json" ]]; then
echo "[dev:rust] 未找到 ${ADMIN_WEB_DIR}/package.json无法启动后台前端。" >&2
exit 1
fi
require_command cargo
require_command node
@@ -454,11 +471,13 @@ fi
SPACETIME_SERVER="http://${SPACETIME_HOST}:${SPACETIME_PORT}"
API_TARGET_HOST="$(resolve_client_host "${API_HOST}")"
RUST_SERVER_TARGET="http://${API_TARGET_HOST}:${API_PORT}"
ADMIN_WEB_TARGET_HOST="$(resolve_client_host "${ADMIN_WEB_HOST}")"
trap cleanup EXIT INT TERM
echo "[dev:rust] repo: ${REPO_ROOT}"
echo "[dev:rust] web: http://127.0.0.1:${WEB_PORT}"
echo "[dev:rust] admin web: http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}"
echo "[dev:rust] rust api: ${RUST_SERVER_TARGET}"
echo "[dev:rust] spacetime: ${SPACETIME_SERVER}"
echo "[dev:rust] database: ${DATABASE}"
@@ -537,12 +556,25 @@ echo "[dev:rust] 启动 vite"
cd "${REPO_ROOT}"
RUST_SERVER_TARGET="${RUST_SERVER_TARGET}" \
GENARRATIVE_RUNTIME_SERVER_TARGET="${RUST_SERVER_TARGET}" \
ADMIN_WEB_TARGET="http://${ADMIN_WEB_TARGET_HOST}:${ADMIN_WEB_PORT}" \
ADMIN_WEB_PORT="${ADMIN_WEB_PORT}" \
VITE_DEV_HOST="${WEB_HOST}" \
exec node "${VITE_CLI_PATH}" "--port=${WEB_PORT}" "--host=${WEB_HOST}"
) &
PIDS+=("$!")
NAMES+=("vite")
echo "[dev:rust] 启动 admin vite"
(
cd "${ADMIN_WEB_DIR}"
ADMIN_API_TARGET="${RUST_SERVER_TARGET}" \
GENARRATIVE_API_TARGET="${RUST_SERVER_TARGET}" \
GENARRATIVE_API_PORT="${API_PORT}" \
exec node "${VITE_CLI_PATH}" "--host=${ADMIN_WEB_HOST}" "--port=${ADMIN_WEB_PORT}"
) &
PIDS+=("$!")
NAMES+=("admin-vite")
echo "[dev:rust] 本地 Rust 栈已启动。按 Ctrl+C 停止全部子进程。"
set +e

View File

@@ -105,10 +105,14 @@ use crate::{
},
runtime_inventory::get_runtime_inventory_state,
runtime_profile::{
admin_disable_profile_redeem_code, admin_upsert_profile_invite_code,
admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard,
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
admin_list_profile_invite_codes, admin_list_profile_redeem_codes,
admin_list_profile_task_configs, admin_upsert_profile_invite_code,
admin_upsert_profile_redeem_code, admin_upsert_profile_task_config,
claim_profile_task_reward, create_profile_recharge_order, get_profile_dashboard,
get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code,
get_profile_task_center, get_profile_wallet_ledger, redeem_profile_referral_invite_code,
redeem_profile_reward_code,
},
runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -157,10 +161,12 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/admin/api/profile/redeem-codes",
post(admin_upsert_profile_redeem_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
get(admin_list_profile_redeem_codes)
.post(admin_upsert_profile_redeem_code)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/redeem-codes/disable",
@@ -171,7 +177,25 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/admin/api/profile/invite-codes",
post(admin_upsert_profile_invite_code).route_layer(middleware::from_fn_with_state(
get(admin_list_profile_invite_codes)
.post(admin_upsert_profile_invite_code)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/tasks",
get(admin_list_profile_task_configs)
.post(admin_upsert_profile_task_config)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route(
"/admin/api/profile/tasks/disable",
post(admin_disable_profile_task_config).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
@@ -1050,6 +1074,20 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/profile/tasks",
get(get_profile_task_center).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/tasks/{task_id}/claim",
post(claim_profile_task_reward).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/save-archives",
get(list_profile_save_archives).route_layer(middleware::from_fn_with_state(

View File

@@ -1,6 +1,6 @@
use axum::{
Json,
extract::{Extension, State},
extract::{Extension, Path, State},
http::StatusCode,
response::Response,
};
@@ -9,15 +9,22 @@ use module_runtime::{
RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord,
RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord,
RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle,
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
};
use serde_json::{Value, json};
use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest,
CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
AdminDisableProfileRedeemCodeRequest, AdminDisableProfileTaskConfigRequest,
AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest, ClaimProfileTaskRewardResponse,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse,
PROFILE_TASK_CYCLE_DAILY, PROFILE_TASK_STATUS_CLAIMABLE, PROFILE_TASK_STATUS_CLAIMED,
PROFILE_TASK_STATUS_DISABLED, PROFILE_TASK_STATUS_INCOMPLETE,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD,
@@ -25,13 +32,17 @@ use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse,
ProfileInviteCodeAdminListResponse, ProfileInviteCodeAdminResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse,
ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse,
ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse,
ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse,
ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse,
ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, TRACKING_SCOPE_KIND_MODULE,
TRACKING_SCOPE_KIND_SITE, TRACKING_SCOPE_KIND_USER, TRACKING_SCOPE_KIND_WORK,
};
use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime;
@@ -91,14 +102,7 @@ pub async fn get_profile_wallet_ledger(
ProfileWalletLedgerResponse {
entries: entries
.into_iter()
.map(|entry| ProfileWalletLedgerEntryResponse {
id: entry.wallet_ledger_id,
amount_delta: entry.amount_delta,
balance_after: entry.balance_after,
source_type: format_profile_wallet_ledger_source_type(entry.source_type)
.to_string(),
created_at: entry.created_at,
})
.map(build_profile_wallet_ledger_entry_response)
.collect(),
},
))
@@ -135,6 +139,9 @@ fn format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
}
RuntimeProfileWalletLedgerSourceType::DailyTaskReward => {
PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
}
}
}
@@ -270,6 +277,184 @@ pub async fn redeem_profile_reward_code(
))
}
pub async fn get_profile_task_center(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.get_profile_task_center(user_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_center_response(record),
))
}
pub async fn claim_profile_task_reward(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(task_id): Path<String>,
) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string();
let record = state
.spacetime_client()
.claim_profile_task_reward(user_id, task_id)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_claim_profile_task_reward_response(record),
))
}
pub async fn admin_list_profile_task_configs(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_task_configs(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileTaskConfigAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_task_config_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_task_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileTaskConfigRequest>,
) -> Result<Json<Value>, Response> {
let cycle = parse_profile_task_cycle(&payload.cycle).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let scope_kind = parse_tracking_scope_kind(&payload.scope_kind).map_err(|error| {
runtime_profile_error_response(
&request_context,
AppError::from_status(StatusCode::BAD_REQUEST).with_message(error),
)
})?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_upsert_profile_task_config(
admin.session().subject.clone(),
payload.task_id,
payload.title,
payload.description.unwrap_or_default(),
payload.event_key,
cycle,
scope_kind,
payload.threshold,
payload.reward_points,
payload.enabled,
payload.sort_order.unwrap_or(10),
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_config_admin_response(record),
))
}
pub async fn admin_disable_profile_task_config(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminDisableProfileTaskConfigRequest>,
) -> Result<Json<Value>, Response> {
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_disable_profile_task_config(
admin.session().subject.clone(),
payload.task_id,
updated_at_micros as i64,
)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_profile_task_config_admin_response(record),
))
}
pub async fn admin_list_profile_redeem_codes(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_redeem_codes(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileRedeemCodeAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_redeem_code_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_redeem_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -338,6 +523,33 @@ pub async fn admin_disable_profile_redeem_code(
))
}
pub async fn admin_list_profile_invite_codes(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
) -> Result<Json<Value>, Response> {
let entries = state
.spacetime_client()
.admin_list_profile_invite_codes(admin.session().subject.clone())
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context,
map_runtime_profile_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
ProfileInviteCodeAdminListResponse {
entries: entries
.into_iter()
.map(build_profile_invite_code_admin_response)
.collect(),
},
))
}
pub async fn admin_upsert_profile_invite_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -553,14 +765,87 @@ fn build_redeem_profile_reward_code_response(
RedeemProfileRewardCodeResponse {
wallet_balance: record.wallet_balance,
amount_granted: record.amount_granted,
ledger_entry: ProfileWalletLedgerEntryResponse {
id: record.ledger_entry.wallet_ledger_id,
amount_delta: record.ledger_entry.amount_delta,
balance_after: record.ledger_entry.balance_after,
source_type: format_profile_wallet_ledger_source_type(record.ledger_entry.source_type)
.to_string(),
created_at: record.ledger_entry.created_at,
},
ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
}
}
fn build_profile_wallet_ledger_entry_response(
record: module_runtime::RuntimeProfileWalletLedgerEntryRecord,
) -> ProfileWalletLedgerEntryResponse {
ProfileWalletLedgerEntryResponse {
id: record.wallet_ledger_id,
amount_delta: record.amount_delta,
balance_after: record.balance_after,
source_type: format_profile_wallet_ledger_source_type(record.source_type).to_string(),
created_at: record.created_at,
}
}
fn build_profile_task_center_response(
record: RuntimeProfileTaskCenterRecord,
) -> ProfileTaskCenterResponse {
ProfileTaskCenterResponse {
day_key: record.day_key,
wallet_balance: record.wallet_balance,
tasks: record
.tasks
.into_iter()
.map(build_profile_task_item_response)
.collect(),
updated_at: record.updated_at,
}
}
fn build_profile_task_item_response(
record: RuntimeProfileTaskItemRecord,
) -> ProfileTaskItemResponse {
ProfileTaskItemResponse {
task_id: record.task_id,
title: record.title,
description: record.description,
event_key: record.event_key,
cycle: format_profile_task_cycle(record.cycle).to_string(),
threshold: record.threshold,
progress_count: record.progress_count,
reward_points: record.reward_points,
status: format_profile_task_status(record.status).to_string(),
day_key: record.day_key,
claimed_at: record.claimed_at,
updated_at: record.updated_at,
}
}
fn build_claim_profile_task_reward_response(
record: RuntimeProfileTaskClaimRecord,
) -> ClaimProfileTaskRewardResponse {
ClaimProfileTaskRewardResponse {
task_id: record.task_id,
day_key: record.day_key,
reward_points: record.reward_points,
wallet_balance: record.wallet_balance,
ledger_entry: build_profile_wallet_ledger_entry_response(record.ledger_entry),
center: build_profile_task_center_response(record.center),
}
}
fn build_profile_task_config_admin_response(
record: RuntimeProfileTaskConfigRecord,
) -> ProfileTaskConfigAdminResponse {
ProfileTaskConfigAdminResponse {
task_id: record.task_id,
title: record.title,
description: record.description,
event_key: record.event_key,
cycle: format_profile_task_cycle(record.cycle).to_string(),
scope_kind: format_tracking_scope_kind(record.scope_kind).to_string(),
threshold: record.threshold,
reward_points: record.reward_points,
enabled: record.enabled,
sort_order: record.sort_order,
created_by: record.created_by,
created_at: record.created_at,
updated_by: record.updated_by,
updated_at: record.updated_at,
}
}
@@ -597,6 +882,47 @@ fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeM
}
}
fn parse_profile_task_cycle(raw: &str) -> Result<RuntimeProfileTaskCycle, String> {
match raw.trim().to_ascii_lowercase().as_str() {
PROFILE_TASK_CYCLE_DAILY => Ok(RuntimeProfileTaskCycle::Daily),
_ => Err("任务周期无效".to_string()),
}
}
fn parse_tracking_scope_kind(raw: &str) -> Result<RuntimeTrackingScopeKind, String> {
match raw.trim().to_ascii_lowercase().as_str() {
TRACKING_SCOPE_KIND_SITE => Ok(RuntimeTrackingScopeKind::Site),
TRACKING_SCOPE_KIND_WORK => Ok(RuntimeTrackingScopeKind::Work),
TRACKING_SCOPE_KIND_MODULE => Ok(RuntimeTrackingScopeKind::Module),
TRACKING_SCOPE_KIND_USER => Ok(RuntimeTrackingScopeKind::User),
_ => Err("埋点范围无效".to_string()),
}
}
fn format_profile_task_cycle(cycle: RuntimeProfileTaskCycle) -> &'static str {
match cycle {
RuntimeProfileTaskCycle::Daily => PROFILE_TASK_CYCLE_DAILY,
}
}
fn format_profile_task_status(status: RuntimeProfileTaskStatus) -> &'static str {
match status {
RuntimeProfileTaskStatus::Incomplete => PROFILE_TASK_STATUS_INCOMPLETE,
RuntimeProfileTaskStatus::Claimable => PROFILE_TASK_STATUS_CLAIMABLE,
RuntimeProfileTaskStatus::Claimed => PROFILE_TASK_STATUS_CLAIMED,
RuntimeProfileTaskStatus::Disabled => PROFILE_TASK_STATUS_DISABLED,
}
}
fn format_tracking_scope_kind(scope_kind: RuntimeTrackingScopeKind) -> &'static str {
match scope_kind {
RuntimeTrackingScopeKind::Site => TRACKING_SCOPE_KIND_SITE,
RuntimeTrackingScopeKind::Work => TRACKING_SCOPE_KIND_WORK,
RuntimeTrackingScopeKind::Module => TRACKING_SCOPE_KIND_MODULE,
RuntimeTrackingScopeKind::User => TRACKING_SCOPE_KIND_USER,
}
}
fn build_profile_invite_code_admin_response(
record: RuntimeProfileInviteCodeRecord,
) -> ProfileInviteCodeAdminResponse {
@@ -675,6 +1001,12 @@ mod tests {
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM
);
assert_eq!(
format_profile_wallet_ledger_source_type(
RuntimeProfileWalletLedgerSourceType::DailyTaskReward
),
shared_contracts::runtime::PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD
);
}
#[tokio::test]
@@ -713,6 +1045,36 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_tasks_require_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/api/profile/tasks")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let claim_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/tasks/daily_login/claim")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(claim_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn profile_play_stats_requires_authentication() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
@@ -892,6 +1254,78 @@ mod tests {
}
}
#[tokio::test]
async fn admin_profile_task_routes_require_admin_authentication() {
let app = build_router(
AppState::new(admin_enabled_test_config()).expect("state should build"),
);
let list_response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri("/admin/api/profile/tasks")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
let upsert_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/profile/tasks")
.header("content-type", "application/json")
.body(Body::from(r#"{"taskId":"daily_login"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
let disable_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/admin/api/profile/tasks/disable")
.header("content-type", "application/json")
.body(Body::from(r#"{"taskId":"daily_login"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(list_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(upsert_response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(disable_response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn admin_profile_code_list_routes_require_admin_authentication() {
let app = build_router(
AppState::new(admin_enabled_test_config()).expect("state should build"),
);
for uri in [
"/admin/api/profile/redeem-codes",
"/admin/api/profile/invite-codes",
] {
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
.uri(uri)
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}");
}
}
async fn seed_authenticated_state() -> AppState {
let state = AppState::new(fast_spacetime_timeout_config()).expect("state should build");
state
@@ -908,6 +1342,14 @@ mod tests {
}
}
fn admin_enabled_test_config() -> AppConfig {
AppConfig {
admin_username: Some("root".to_string()),
admin_password: Some("secret123".to_string()),
..AppConfig::default()
}
}
fn issue_access_token(state: &AppState) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {

View File

@@ -217,6 +217,183 @@ pub fn build_runtime_profile_reward_code_redeem_record(
}
}
pub fn runtime_profile_beijing_day_key(now_micros: i64) -> i64 {
now_micros
.saturating_add(PROFILE_TASK_BEIJING_OFFSET_MICROS)
.div_euclid(PROFILE_RUNTIME_DAY_MICROS)
}
pub fn build_default_runtime_profile_task_config(
updated_at_micros: i64,
updated_by: String,
) -> RuntimeProfileTaskConfigSnapshot {
RuntimeProfileTaskConfigSnapshot {
task_id: PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
title: PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN.to_string(),
description: String::new(),
event_key: PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
cycle: RuntimeProfileTaskCycle::Daily,
scope_kind: RuntimeTrackingScopeKind::User,
threshold: PROFILE_TASK_DEFAULT_THRESHOLD,
reward_points: PROFILE_TASK_DEFAULT_REWARD_POINTS,
enabled: true,
sort_order: 10,
created_by: updated_by.clone(),
created_at_micros: updated_at_micros,
updated_by,
updated_at_micros,
}
}
pub fn resolve_runtime_profile_task_status(
enabled: bool,
progress_count: u32,
threshold: u32,
claimed: bool,
) -> RuntimeProfileTaskStatus {
if !enabled {
return RuntimeProfileTaskStatus::Disabled;
}
if claimed {
return RuntimeProfileTaskStatus::Claimed;
}
if progress_count >= threshold {
RuntimeProfileTaskStatus::Claimable
} else {
RuntimeProfileTaskStatus::Incomplete
}
}
pub fn build_runtime_profile_task_progress_id(
user_id: &str,
task_id: &str,
day_key: i64,
) -> String {
format!("{}:{}:{}", user_id.trim(), task_id.trim(), day_key)
}
pub fn build_runtime_profile_task_claim_id(user_id: &str, task_id: &str, day_key: i64) -> String {
build_runtime_profile_task_progress_id(user_id, task_id, day_key)
}
pub fn build_runtime_profile_task_reward_ledger_id(
user_id: &str,
task_id: &str,
day_key: i64,
) -> String {
format!(
"task-reward:{}:{}:{}",
user_id.trim(),
task_id.trim(),
day_key
)
}
pub fn build_runtime_tracking_event_id(
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
occurred_at_micros: i64,
) -> String {
format!(
"tracking:{}:{}:{}:{}",
event_key.trim(),
scope_kind.as_str(),
scope_id.trim(),
occurred_at_micros
)
}
pub fn build_runtime_tracking_daily_stat_id(
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
day_key: i64,
) -> String {
format!(
"tracking-stat:{}:{}:{}:{}",
event_key.trim(),
scope_kind.as_str(),
scope_id.trim(),
day_key
)
}
pub fn build_runtime_profile_task_config_record(
snapshot: RuntimeProfileTaskConfigSnapshot,
) -> RuntimeProfileTaskConfigRecord {
RuntimeProfileTaskConfigRecord {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: snapshot.cycle,
scope_kind: snapshot.scope_kind,
threshold: snapshot.threshold,
reward_points: snapshot.reward_points,
enabled: snapshot.enabled,
sort_order: snapshot.sort_order,
created_by: snapshot.created_by,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_by: snapshot.updated_by,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_task_item_record(
snapshot: RuntimeProfileTaskItemSnapshot,
) -> RuntimeProfileTaskItemRecord {
RuntimeProfileTaskItemRecord {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: snapshot.cycle,
threshold: snapshot.threshold,
progress_count: snapshot.progress_count,
reward_points: snapshot.reward_points,
status: snapshot.status,
day_key: snapshot.day_key,
claimed_at: snapshot.claimed_at_micros.map(format_utc_micros),
claimed_at_micros: snapshot.claimed_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_task_center_record(
snapshot: RuntimeProfileTaskCenterSnapshot,
) -> RuntimeProfileTaskCenterRecord {
RuntimeProfileTaskCenterRecord {
user_id: snapshot.user_id,
day_key: snapshot.day_key,
wallet_balance: snapshot.wallet_balance,
tasks: snapshot
.tasks
.into_iter()
.map(build_runtime_profile_task_item_record)
.collect(),
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_task_claim_record(
snapshot: RuntimeProfileTaskClaimSnapshot,
) -> RuntimeProfileTaskClaimRecord {
RuntimeProfileTaskClaimRecord {
user_id: snapshot.user_id,
task_id: snapshot.task_id,
day_key: snapshot.day_key,
reward_points: snapshot.reward_points,
wallet_balance: snapshot.wallet_balance,
ledger_entry: build_runtime_profile_wallet_ledger_entry_record(snapshot.ledger_entry),
center: build_runtime_profile_task_center_record(snapshot.center),
}
}
pub fn build_runtime_profile_redeem_code_record(
snapshot: RuntimeProfileRedeemCodeSnapshot,
) -> RuntimeProfileRedeemCodeRecord {

View File

@@ -75,6 +75,121 @@ pub fn build_runtime_profile_wallet_ledger_list_input(
Ok(RuntimeProfileWalletLedgerListInput { user_id })
}
pub fn build_runtime_tracking_event_input(
event_id: String,
event_key: String,
scope_kind: RuntimeTrackingScopeKind,
scope_id: String,
user_id: Option<String>,
owner_user_id: Option<String>,
profile_id: Option<String>,
module_key: Option<String>,
metadata_json: String,
occurred_at_micros: i64,
) -> Result<RuntimeTrackingEventInput, RuntimeProfileFieldError> {
let event_id = normalize_required_string(event_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingEventId)?;
let event_key =
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
let scope_id = normalize_required_string(scope_id)
.ok_or(RuntimeProfileFieldError::MissingTrackingScopeId)?;
let metadata_json = normalize_tracking_metadata_json(metadata_json)?;
Ok(RuntimeTrackingEventInput {
event_id,
event_key,
scope_kind,
scope_id,
user_id: normalize_optional_string(user_id),
owner_user_id: normalize_optional_string(owner_user_id),
profile_id: normalize_optional_string(profile_id),
module_key: normalize_optional_string(module_key),
metadata_json,
occurred_at_micros,
})
}
pub fn build_runtime_profile_task_center_get_input(
user_id: String,
) -> Result<RuntimeProfileTaskCenterGetInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
Ok(RuntimeProfileTaskCenterGetInput { user_id })
}
pub fn build_runtime_profile_task_claim_input(
user_id: String,
task_id: String,
) -> Result<RuntimeProfileTaskClaimInput, RuntimeProfileFieldError> {
let user_id = normalize_runtime_profile_user_id(user_id)?;
let task_id = normalize_profile_task_id(task_id)?;
Ok(RuntimeProfileTaskClaimInput { user_id, task_id })
}
pub fn build_runtime_profile_task_config_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileTaskConfigAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileTaskConfigAdminListInput { admin_user_id })
}
#[allow(clippy::too_many_arguments)]
pub fn build_runtime_profile_task_config_admin_upsert_input(
admin_user_id: String,
task_id: String,
title: String,
description: String,
event_key: String,
cycle: RuntimeProfileTaskCycle,
scope_kind: RuntimeTrackingScopeKind,
threshold: u32,
reward_points: u64,
enabled: bool,
sort_order: i32,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let task_id = normalize_profile_task_id(task_id)?;
let title =
normalize_required_string(title).ok_or(RuntimeProfileFieldError::MissingTaskTitle)?;
let event_key =
normalize_required_string(event_key).ok_or(RuntimeProfileFieldError::MissingTaskEventKey)?;
if threshold == 0 {
return Err(RuntimeProfileFieldError::InvalidTaskThreshold);
}
if reward_points == 0 || reward_points > i64::MAX as u64 {
return Err(RuntimeProfileFieldError::InvalidTaskReward);
}
Ok(RuntimeProfileTaskConfigAdminUpsertInput {
admin_user_id,
task_id,
title,
description: normalize_optional_string(Some(description)).unwrap_or_default(),
event_key,
cycle,
scope_kind,
threshold,
reward_points,
enabled,
sort_order,
updated_at_micros,
})
}
pub fn build_runtime_profile_task_config_admin_disable_input(
admin_user_id: String,
task_id: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigAdminDisableInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let task_id = normalize_profile_task_id(task_id)?;
Ok(RuntimeProfileTaskConfigAdminDisableInput {
admin_user_id,
task_id,
updated_at_micros,
})
}
pub fn build_runtime_profile_wallet_adjustment_input(
user_id: String,
amount: u64,
@@ -200,6 +315,13 @@ pub fn build_runtime_profile_redeem_code_admin_upsert_input(
})
}
pub fn build_runtime_profile_redeem_code_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileRedeemCodeAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileRedeemCodeAdminListInput { admin_user_id })
}
pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id: String,
invite_code: String,
@@ -219,6 +341,13 @@ pub fn build_runtime_profile_invite_code_admin_upsert_input(
})
}
pub fn build_runtime_profile_invite_code_admin_list_input(
admin_user_id: String,
) -> Result<RuntimeProfileInviteCodeAdminListInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
Ok(RuntimeProfileInviteCodeAdminListInput { admin_user_id })
}
pub fn build_runtime_profile_redeem_code_admin_disable_input(
admin_user_id: String,
code: String,
@@ -509,3 +638,22 @@ pub fn normalize_invite_code_metadata_json(
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
}
fn normalize_tracking_metadata_json(value: String) -> Result<String, RuntimeProfileFieldError> {
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string());
}
let parsed = serde_json::from_str::<Value>(trimmed)
.map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)?;
if !parsed.is_object() {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
serde_json::to_string(&parsed).map_err(|_| RuntimeProfileFieldError::InvalidInviteCodeMetadata)
}
fn normalize_profile_task_id(value: String) -> Result<String, RuntimeProfileFieldError> {
normalize_required_string(value).ok_or(RuntimeProfileFieldError::MissingTaskId)
}

View File

@@ -20,6 +20,12 @@ pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10;
pub const PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}";
pub const PROFILE_INVITE_CODE_METADATA_MAX_BYTES: usize = 4096;
pub const PROFILE_RUNTIME_DAY_MICROS: i64 = 86_400_000_000;
pub const PROFILE_TASK_BEIJING_OFFSET_MICROS: i64 = 28_800_000_000;
pub const PROFILE_TASK_ID_DAILY_LOGIN: &str = "daily_login";
pub const PROFILE_TASK_EVENT_KEY_DAILY_LOGIN: &str = "daily_login";
pub const PROFILE_TASK_DEFAULT_TITLE_DAILY_LOGIN: &str = "每日登录";
pub const PROFILE_TASK_DEFAULT_REWARD_POINTS: u64 = 10;
pub const PROFILE_TASK_DEFAULT_THRESHOLD: u32 = 1;
pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
@@ -334,6 +340,226 @@ pub struct RuntimeProfileDashboardGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeTrackingScopeKind {
Site,
Work,
Module,
User,
}
impl RuntimeTrackingScopeKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Site => "site",
Self::Work => "work",
Self::Module => "module",
Self::User => "user",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"site" => Some(Self::Site),
"work" => Some(Self::Work),
"module" => Some(Self::Module),
"user" => Some(Self::User),
_ => None,
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileTaskCycle {
Daily,
}
impl RuntimeProfileTaskCycle {
pub fn as_str(&self) -> &'static str {
match self {
Self::Daily => "daily",
}
}
pub fn from_client_str(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"daily" => Some(Self::Daily),
_ => None,
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileTaskStatus {
Incomplete,
Claimable,
Claimed,
Disabled,
}
impl RuntimeProfileTaskStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Incomplete => "incomplete",
Self::Claimable => "claimable",
Self::Claimed => "claimed",
Self::Disabled => "disabled",
}
}
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeTrackingEventInput {
pub event_id: String,
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub user_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub module_key: Option<String>,
pub metadata_json: String,
pub occurred_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigSnapshot {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at_micros: i64,
pub updated_by: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskItemSnapshot {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub threshold: u32,
pub progress_count: u32,
pub reward_points: u64,
pub status: RuntimeProfileTaskStatus,
pub day_key: i64,
pub claimed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskCenterSnapshot {
pub user_id: String,
pub day_key: i64,
pub wallet_balance: u64,
pub tasks: Vec<RuntimeProfileTaskItemSnapshot>,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskCenterProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskCenterSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskClaimSnapshot {
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_balance: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
pub center: RuntimeProfileTaskCenterSnapshot,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskClaimProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskClaimSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskCenterGetInput {
pub user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskClaimInput {
pub user_id: String,
pub task_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminListInput {
pub admin_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminUpsertInput {
pub admin_user_id: String,
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminDisableInput {
pub admin_user_id: String,
pub task_id: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileTaskConfigSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileTaskConfigAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskConfigSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeProfileWalletLedgerSourceType {
@@ -346,6 +572,7 @@ pub enum RuntimeProfileWalletLedgerSourceType {
AssetOperationRefund,
RedeemCodeReward,
PuzzleAuthorIncentiveClaim,
DailyTaskReward,
}
impl RuntimeProfileWalletLedgerSourceType {
@@ -360,6 +587,7 @@ impl RuntimeProfileWalletLedgerSourceType {
Self::AssetOperationRefund => "asset_operation_refund",
Self::RedeemCodeReward => "redeem_code_reward",
Self::PuzzleAuthorIncentiveClaim => "puzzle_author_incentive_claim",
Self::DailyTaskReward => "daily_task_reward",
}
}
}
@@ -633,6 +861,12 @@ pub struct RuntimeProfileRedeemCodeAdminDisableInput {
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeAdminListInput {
pub admin_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeSnapshot {
@@ -656,6 +890,14 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRedeemCodeAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileRedeemCodeSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeAdminUpsertInput {
@@ -665,6 +907,12 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput {
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeAdminListInput {
pub admin_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeSnapshot {
@@ -683,6 +931,14 @@ pub struct RuntimeProfileInviteCodeAdminProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileInviteCodeSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInvitedUserSnapshot {
@@ -953,6 +1209,65 @@ pub struct RuntimeProfileRewardCodeRedeemRecord {
pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileTaskConfigRecord {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at: String,
pub created_at_micros: i64,
pub updated_by: String,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileTaskItemRecord {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub threshold: u32,
pub progress_count: u32,
pub reward_points: u64,
pub status: RuntimeProfileTaskStatus,
pub day_key: i64,
pub claimed_at: Option<String>,
pub claimed_at_micros: Option<i64>,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileTaskCenterRecord {
pub user_id: String,
pub day_key: i64,
pub wallet_balance: u64,
pub tasks: Vec<RuntimeProfileTaskItemRecord>,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileTaskClaimRecord {
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_balance: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntryRecord,
pub center: RuntimeProfileTaskCenterRecord,
}
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileRedeemCodeRecord {
pub code: String,

View File

@@ -52,6 +52,18 @@ pub enum RuntimeProfileFieldError {
InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses,
InvalidInviteCodeMetadata,
MissingTaskId,
MissingTaskTitle,
MissingTaskEventKey,
MissingTrackingEventId,
MissingTrackingScopeId,
InvalidTaskCycle,
InvalidTaskScopeKind,
InvalidTaskThreshold,
InvalidTaskReward,
TaskDisabled,
TaskNotClaimable,
TaskAlreadyClaimed,
MissingProductId,
MissingWorldKey,
MissingBottomTab,
@@ -86,6 +98,18 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::InvalidInviteCodeMetadata => {
f.write_str("邀请码 metadata 必须是合法 JSON object")
}
Self::MissingTaskId => f.write_str("profile_task.task_id 不能为空"),
Self::MissingTaskTitle => f.write_str("profile_task.title 不能为空"),
Self::MissingTaskEventKey => f.write_str("profile_task.event_key 不能为空"),
Self::MissingTrackingEventId => f.write_str("tracking_event.event_id 不能为空"),
Self::MissingTrackingScopeId => f.write_str("tracking_event.scope_id 不能为空"),
Self::InvalidTaskCycle => f.write_str("profile_task.cycle 无效"),
Self::InvalidTaskScopeKind => f.write_str("profile_task.scope_kind 无效"),
Self::InvalidTaskThreshold => f.write_str("profile_task.threshold 必须大于 0"),
Self::InvalidTaskReward => f.write_str("profile_task.reward_points 必须大于 0"),
Self::TaskDisabled => f.write_str("任务已停用"),
Self::TaskNotClaimable => f.write_str("任务尚未达成"),
Self::TaskAlreadyClaimed => f.write_str("任务奖励已领取"),
Self::MissingProductId => f.write_str("recharge.product_id 不能为空"),
Self::MissingWorldKey => f.write_str("profile.world_key 不能为空"),
Self::MissingBottomTab => f.write_str("runtime_snapshot.bottom_tab 不能为空"),

View File

@@ -448,6 +448,81 @@ mod tests {
RuntimeProfileWalletLedgerSourceType::AssetOperationRefund.as_str(),
"asset_operation_refund"
);
assert_eq!(
RuntimeProfileWalletLedgerSourceType::DailyTaskReward.as_str(),
"daily_task_reward"
);
}
#[test]
fn runtime_profile_beijing_day_key_uses_business_day_boundary() {
let before_beijing_midnight = 1_714_927_999_999_999;
let after_beijing_midnight = 1_714_928_000_000_000;
assert_eq!(
runtime_profile_beijing_day_key(before_beijing_midnight),
runtime_profile_beijing_day_key(after_beijing_midnight) - 1
);
}
#[test]
fn runtime_profile_task_status_matches_progress_and_claim() {
assert_eq!(
resolve_runtime_profile_task_status(false, 1, 1, false),
RuntimeProfileTaskStatus::Disabled
);
assert_eq!(
resolve_runtime_profile_task_status(true, 0, 1, false),
RuntimeProfileTaskStatus::Incomplete
);
assert_eq!(
resolve_runtime_profile_task_status(true, 1, 1, false),
RuntimeProfileTaskStatus::Claimable
);
assert_eq!(
resolve_runtime_profile_task_status(true, 1, 1, true),
RuntimeProfileTaskStatus::Claimed
);
}
#[test]
fn build_task_config_input_rejects_invalid_reward_and_threshold() {
assert_eq!(
build_runtime_profile_task_config_admin_upsert_input(
"admin".to_string(),
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
"每日登录".to_string(),
"".to_string(),
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
RuntimeProfileTaskCycle::Daily,
RuntimeTrackingScopeKind::User,
0,
10,
true,
10,
1,
)
.expect_err("zero threshold should fail"),
RuntimeProfileFieldError::InvalidTaskThreshold
);
assert_eq!(
build_runtime_profile_task_config_admin_upsert_input(
"admin".to_string(),
PROFILE_TASK_ID_DAILY_LOGIN.to_string(),
"每日登录".to_string(),
"".to_string(),
PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
RuntimeProfileTaskCycle::Daily,
RuntimeTrackingScopeKind::User,
1,
0,
true,
10,
1,
)
.expect_err("zero reward should fail"),
RuntimeProfileFieldError::InvalidTaskReward
);
}
#[test]

View File

@@ -15,6 +15,16 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND: &str = "asse
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD: &str = "redeem_code_reward";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM: &str =
"puzzle_author_incentive_claim";
pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD: &str = "daily_task_reward";
pub const PROFILE_TASK_CYCLE_DAILY: &str = "daily";
pub const PROFILE_TASK_STATUS_INCOMPLETE: &str = "incomplete";
pub const PROFILE_TASK_STATUS_CLAIMABLE: &str = "claimable";
pub const PROFILE_TASK_STATUS_CLAIMED: &str = "claimed";
pub const PROFILE_TASK_STATUS_DISABLED: &str = "disabled";
pub const TRACKING_SCOPE_KIND_SITE: &str = "site";
pub const TRACKING_SCOPE_KIND_WORK: &str = "work";
pub const TRACKING_SCOPE_KIND_MODULE: &str = "module";
pub const TRACKING_SCOPE_KIND_USER: &str = "user";
pub const BROWSE_HISTORY_THEME_MODE_MARTIAL: &str = "martial";
pub const BROWSE_HISTORY_THEME_MODE_ARCANE: &str = "arcane";
pub const BROWSE_HISTORY_THEME_MODE_MACHINA: &str = "machina";
@@ -295,6 +305,92 @@ pub struct RedeemProfileRewardCodeResponse {
pub ledger_entry: ProfileWalletLedgerEntryResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileTaskItemResponse {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: String,
pub threshold: u32,
pub progress_count: u32,
pub reward_points: u64,
pub status: String,
pub day_key: i64,
pub claimed_at: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileTaskCenterResponse {
pub day_key: i64,
pub wallet_balance: u64,
pub tasks: Vec<ProfileTaskItemResponse>,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ClaimProfileTaskRewardResponse {
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_balance: u64,
pub ledger_entry: ProfileWalletLedgerEntryResponse,
pub center: ProfileTaskCenterResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileTaskConfigAdminResponse {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: String,
pub scope_kind: String,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at: String,
pub updated_by: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileTaskConfigAdminListResponse {
pub entries: Vec<ProfileTaskConfigAdminResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileTaskConfigRequest {
pub task_id: String,
pub title: String,
#[serde(default)]
pub description: Option<String>,
pub event_key: String,
pub cycle: String,
pub scope_kind: String,
pub threshold: u32,
pub reward_points: u64,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub sort_order: Option<i32>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminDisableProfileTaskConfigRequest {
pub task_id: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileRedeemCodeRequest {
@@ -339,6 +435,12 @@ pub struct ProfileRedeemCodeAdminResponse {
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileRedeemCodeAdminListResponse {
pub entries: Vec<ProfileRedeemCodeAdminResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInviteCodeAdminResponse {
@@ -349,6 +451,12 @@ pub struct ProfileInviteCodeAdminResponse {
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInviteCodeAdminListResponse {
pub entries: Vec<ProfileInviteCodeAdminResponse>,
}
fn default_true() -> bool {
true
}
@@ -958,6 +1066,13 @@ mod tests {
.to_string(),
created_at: "2026-04-22T10:06:00Z".to_string(),
},
ProfileWalletLedgerEntryResponse {
id: "ledger-9".to_string(),
amount_delta: 10,
balance_after: 212,
source_type: PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD.to_string(),
created_at: "2026-04-22T10:07:00Z".to_string(),
},
],
})
.expect("payload should serialize");
@@ -996,12 +1111,66 @@ mod tests {
payload["entries"][7]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM)
);
assert_eq!(
payload["entries"][8]["sourceType"],
json!(PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_TASK_REWARD)
);
assert_eq!(
payload["entries"][0]["createdAt"],
json!("2026-04-22T09:59:00Z")
);
}
#[test]
fn profile_task_center_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfileTaskCenterResponse {
day_key: 20576,
wallet_balance: 18,
tasks: vec![ProfileTaskItemResponse {
task_id: "daily_login".to_string(),
title: "每日登录".to_string(),
description: "".to_string(),
event_key: "daily_login".to_string(),
cycle: PROFILE_TASK_CYCLE_DAILY.to_string(),
threshold: 1,
progress_count: 1,
reward_points: 10,
status: PROFILE_TASK_STATUS_CLAIMABLE.to_string(),
day_key: 20576,
claimed_at: None,
updated_at: "2026-05-03T00:00:00Z".to_string(),
}],
updated_at: "2026-05-03T00:00:00Z".to_string(),
})
.expect("payload should serialize");
assert_eq!(payload["walletBalance"], json!(18));
assert_eq!(payload["tasks"][0]["taskId"], json!("daily_login"));
assert_eq!(payload["tasks"][0]["rewardPoints"], json!(10));
assert_eq!(
payload["tasks"][0]["status"],
json!(PROFILE_TASK_STATUS_CLAIMABLE)
);
}
#[test]
fn admin_task_config_request_accepts_defaults() {
let payload: AdminUpsertProfileTaskConfigRequest = serde_json::from_value(json!({
"taskId": "daily_login",
"title": "每日登录",
"eventKey": "daily_login",
"cycle": "daily",
"scopeKind": "user",
"threshold": 1,
"rewardPoints": 10
}))
.expect("request should deserialize");
assert_eq!(payload.description, None);
assert_eq!(payload.enabled, true);
assert_eq!(payload.sort_order, None);
}
#[test]
fn profile_recharge_center_response_uses_camel_case_fields() {
let payload = serde_json::to_value(ProfileRechargeCenterResponse {

View File

@@ -139,21 +139,31 @@ use module_runtime::{
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
RuntimeProfileSaveArchiveRecord, RuntimeProfileWalletLedgerEntryRecord,
RuntimeReferralInviteCenterRecord, RuntimeReferralRedeemRecord, RuntimeSettingsRecord,
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
build_runtime_browse_history_list_input, build_runtime_browse_history_record,
build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input,
build_runtime_profile_dashboard_record, build_runtime_profile_invite_code_admin_upsert_input,
build_runtime_profile_invite_code_record, build_runtime_profile_play_stats_get_input,
build_runtime_profile_play_stats_record, build_runtime_profile_recharge_center_get_input,
build_runtime_profile_recharge_center_record,
RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord,
RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle as DomainRuntimeProfileTaskCycle,
RuntimeProfileTaskStatus as DomainRuntimeProfileTaskStatus,
RuntimeProfileWalletLedgerEntryRecord, RuntimeReferralInviteCenterRecord,
RuntimeReferralRedeemRecord, RuntimeSettingsRecord, RuntimeSnapshotRecord,
RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind,
build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input,
build_runtime_browse_history_record, build_runtime_browse_history_sync_input,
build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record,
build_runtime_profile_invite_code_admin_list_input,
build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record,
build_runtime_profile_play_stats_get_input, build_runtime_profile_play_stats_record,
build_runtime_profile_recharge_center_get_input, build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input,
build_runtime_profile_redeem_code_admin_disable_input,
build_runtime_profile_redeem_code_admin_list_input,
build_runtime_profile_redeem_code_admin_upsert_input, build_runtime_profile_redeem_code_record,
build_runtime_profile_reward_code_redeem_input,
build_runtime_profile_reward_code_redeem_record, build_runtime_profile_save_archive_list_input,
build_runtime_profile_save_archive_record, build_runtime_profile_save_archive_resume_input,
build_runtime_profile_task_center_get_input, build_runtime_profile_task_center_record,
build_runtime_profile_task_claim_input, build_runtime_profile_task_claim_record,
build_runtime_profile_task_config_admin_disable_input,
build_runtime_profile_task_config_admin_list_input,
build_runtime_profile_task_config_admin_upsert_input, build_runtime_profile_task_config_record,
build_runtime_profile_wallet_adjustment_input,
build_runtime_profile_wallet_ledger_entry_record,
build_runtime_profile_wallet_ledger_list_input, build_runtime_referral_invite_center_get_input,

View File

@@ -173,6 +173,66 @@ impl From<module_runtime::RuntimeProfileRewardCodeRedeemInput>
}
}
impl From<module_runtime::RuntimeProfileTaskCenterGetInput> for RuntimeProfileTaskCenterGetInput {
fn from(input: module_runtime::RuntimeProfileTaskCenterGetInput) -> Self {
Self {
user_id: input.user_id,
}
}
}
impl From<module_runtime::RuntimeProfileTaskClaimInput> for RuntimeProfileTaskClaimInput {
fn from(input: module_runtime::RuntimeProfileTaskClaimInput) -> Self {
Self {
user_id: input.user_id,
task_id: input.task_id,
}
}
}
impl From<module_runtime::RuntimeProfileTaskConfigAdminListInput>
for RuntimeProfileTaskConfigAdminListInput
{
fn from(input: module_runtime::RuntimeProfileTaskConfigAdminListInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
}
}
}
impl From<module_runtime::RuntimeProfileTaskConfigAdminUpsertInput>
for RuntimeProfileTaskConfigAdminUpsertInput
{
fn from(input: module_runtime::RuntimeProfileTaskConfigAdminUpsertInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
task_id: input.task_id,
title: input.title,
description: input.description,
event_key: input.event_key,
cycle: map_runtime_profile_task_cycle(input.cycle),
scope_kind: map_runtime_tracking_scope_kind(input.scope_kind),
threshold: input.threshold,
reward_points: input.reward_points,
enabled: input.enabled,
sort_order: input.sort_order,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfileTaskConfigAdminDisableInput>
for RuntimeProfileTaskConfigAdminDisableInput
{
fn from(input: module_runtime::RuntimeProfileTaskConfigAdminDisableInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
task_id: input.task_id,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput>
for RuntimeProfileRedeemCodeAdminUpsertInput
{
@@ -203,6 +263,16 @@ impl From<module_runtime::RuntimeProfileRedeemCodeAdminDisableInput>
}
}
impl From<module_runtime::RuntimeProfileRedeemCodeAdminListInput>
for RuntimeProfileRedeemCodeAdminListInput
{
fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminListInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
}
}
}
impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
for RuntimeProfileInviteCodeAdminUpsertInput
{
@@ -216,6 +286,16 @@ impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
}
}
impl From<module_runtime::RuntimeProfileInviteCodeAdminListInput>
for RuntimeProfileInviteCodeAdminListInput
{
fn from(input: module_runtime::RuntimeProfileInviteCodeAdminListInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
}
}
}
impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
for RuntimeReferralInviteCenterGetInput
{
@@ -801,6 +881,72 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result(
))
}
pub(crate) fn map_runtime_profile_task_center_procedure_result(
result: RuntimeProfileTaskCenterProcedureResult,
) -> Result<RuntimeProfileTaskCenterRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let snapshot = result
.record
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task center 快照"))?;
Ok(build_runtime_profile_task_center_record(
map_runtime_profile_task_center_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_task_claim_procedure_result(
result: RuntimeProfileTaskClaimProcedureResult,
) -> Result<RuntimeProfileTaskClaimRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let snapshot = result
.record
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task claim 快照"))?;
Ok(build_runtime_profile_task_claim_record(
map_runtime_profile_task_claim_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_task_config_admin_list_procedure_result(
result: RuntimeProfileTaskConfigAdminListProcedureResult,
) -> Result<Vec<RuntimeProfileTaskConfigRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.entries
.into_iter()
.map(|snapshot| {
build_runtime_profile_task_config_record(map_runtime_profile_task_config_snapshot(
snapshot,
))
})
.collect())
}
pub(crate) fn map_runtime_profile_task_config_admin_procedure_result(
result: RuntimeProfileTaskConfigAdminProcedureResult,
) -> Result<RuntimeProfileTaskConfigRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let snapshot = result
.record
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task config 快照"))?;
Ok(build_runtime_profile_task_config_record(
map_runtime_profile_task_config_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
result: RuntimeProfileRedeemCodeAdminProcedureResult,
) -> Result<RuntimeProfileRedeemCodeRecord, SpacetimeClientError> {
@@ -817,6 +963,24 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
))
}
pub(crate) fn map_runtime_profile_redeem_code_admin_list_procedure_result(
result: RuntimeProfileRedeemCodeAdminListProcedureResult,
) -> Result<Vec<RuntimeProfileRedeemCodeRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.entries
.into_iter()
.map(|snapshot| {
build_runtime_profile_redeem_code_record(map_runtime_profile_redeem_code_snapshot(
snapshot,
))
})
.collect())
}
pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result(
result: RuntimeProfileInviteCodeAdminProcedureResult,
) -> Result<RuntimeProfileInviteCodeRecord, SpacetimeClientError> {
@@ -837,6 +1001,24 @@ pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result(
))
}
pub(crate) fn map_runtime_profile_invite_code_admin_list_procedure_result(
result: RuntimeProfileInviteCodeAdminListProcedureResult,
) -> Result<Vec<RuntimeProfileInviteCodeRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.entries
.into_iter()
.map(|snapshot| {
build_runtime_profile_invite_code_record(map_runtime_profile_invite_code_snapshot(
snapshot,
))
})
.collect())
}
pub(crate) fn map_runtime_profile_play_stats_procedure_result(
result: RuntimeProfilePlayStatsProcedureResult,
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
@@ -1721,6 +1903,76 @@ pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot(
}
}
pub(crate) fn map_runtime_profile_task_config_snapshot(
snapshot: RuntimeProfileTaskConfigSnapshot,
) -> module_runtime::RuntimeProfileTaskConfigSnapshot {
module_runtime::RuntimeProfileTaskConfigSnapshot {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: map_runtime_profile_task_cycle_back(snapshot.cycle),
scope_kind: map_runtime_tracking_scope_kind_back(snapshot.scope_kind),
threshold: snapshot.threshold,
reward_points: snapshot.reward_points,
enabled: snapshot.enabled,
sort_order: snapshot.sort_order,
created_by: snapshot.created_by,
created_at_micros: snapshot.created_at_micros,
updated_by: snapshot.updated_by,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_task_item_snapshot(
snapshot: RuntimeProfileTaskItemSnapshot,
) -> module_runtime::RuntimeProfileTaskItemSnapshot {
module_runtime::RuntimeProfileTaskItemSnapshot {
task_id: snapshot.task_id,
title: snapshot.title,
description: snapshot.description,
event_key: snapshot.event_key,
cycle: map_runtime_profile_task_cycle_back(snapshot.cycle),
threshold: snapshot.threshold,
progress_count: snapshot.progress_count,
reward_points: snapshot.reward_points,
status: map_runtime_profile_task_status_back(snapshot.status),
day_key: snapshot.day_key,
claimed_at_micros: snapshot.claimed_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_task_center_snapshot(
snapshot: RuntimeProfileTaskCenterSnapshot,
) -> module_runtime::RuntimeProfileTaskCenterSnapshot {
module_runtime::RuntimeProfileTaskCenterSnapshot {
user_id: snapshot.user_id,
day_key: snapshot.day_key,
wallet_balance: snapshot.wallet_balance,
tasks: snapshot
.tasks
.into_iter()
.map(map_runtime_profile_task_item_snapshot)
.collect(),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_task_claim_snapshot(
snapshot: RuntimeProfileTaskClaimSnapshot,
) -> module_runtime::RuntimeProfileTaskClaimSnapshot {
module_runtime::RuntimeProfileTaskClaimSnapshot {
user_id: snapshot.user_id,
task_id: snapshot.task_id,
day_key: snapshot.day_key,
reward_points: snapshot.reward_points,
wallet_balance: snapshot.wallet_balance,
ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry),
center: map_runtime_profile_task_center_snapshot(snapshot.center),
}
}
pub(crate) fn map_runtime_profile_redeem_code_snapshot(
snapshot: RuntimeProfileRedeemCodeSnapshot,
) -> module_runtime::RuntimeProfileRedeemCodeSnapshot {
@@ -3750,6 +4002,86 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back(
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => {
module_runtime::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim
}
crate::module_bindings::RuntimeProfileWalletLedgerSourceType::DailyTaskReward => {
module_runtime::RuntimeProfileWalletLedgerSourceType::DailyTaskReward
}
}
}
pub(crate) fn map_runtime_tracking_scope_kind(
value: DomainRuntimeTrackingScopeKind,
) -> crate::module_bindings::RuntimeTrackingScopeKind {
match value {
DomainRuntimeTrackingScopeKind::Site => {
crate::module_bindings::RuntimeTrackingScopeKind::Site
}
DomainRuntimeTrackingScopeKind::Work => {
crate::module_bindings::RuntimeTrackingScopeKind::Work
}
DomainRuntimeTrackingScopeKind::Module => {
crate::module_bindings::RuntimeTrackingScopeKind::Module
}
DomainRuntimeTrackingScopeKind::User => {
crate::module_bindings::RuntimeTrackingScopeKind::User
}
}
}
pub(crate) fn map_runtime_tracking_scope_kind_back(
value: crate::module_bindings::RuntimeTrackingScopeKind,
) -> DomainRuntimeTrackingScopeKind {
match value {
crate::module_bindings::RuntimeTrackingScopeKind::Site => {
DomainRuntimeTrackingScopeKind::Site
}
crate::module_bindings::RuntimeTrackingScopeKind::Work => {
DomainRuntimeTrackingScopeKind::Work
}
crate::module_bindings::RuntimeTrackingScopeKind::Module => {
DomainRuntimeTrackingScopeKind::Module
}
crate::module_bindings::RuntimeTrackingScopeKind::User => {
DomainRuntimeTrackingScopeKind::User
}
}
}
pub(crate) fn map_runtime_profile_task_cycle(
value: DomainRuntimeProfileTaskCycle,
) -> crate::module_bindings::RuntimeProfileTaskCycle {
match value {
DomainRuntimeProfileTaskCycle::Daily => {
crate::module_bindings::RuntimeProfileTaskCycle::Daily
}
}
}
pub(crate) fn map_runtime_profile_task_cycle_back(
value: crate::module_bindings::RuntimeProfileTaskCycle,
) -> DomainRuntimeProfileTaskCycle {
match value {
crate::module_bindings::RuntimeProfileTaskCycle::Daily => {
DomainRuntimeProfileTaskCycle::Daily
}
}
}
pub(crate) fn map_runtime_profile_task_status_back(
value: crate::module_bindings::RuntimeProfileTaskStatus,
) -> DomainRuntimeProfileTaskStatus {
match value {
crate::module_bindings::RuntimeProfileTaskStatus::Incomplete => {
DomainRuntimeProfileTaskStatus::Incomplete
}
crate::module_bindings::RuntimeProfileTaskStatus::Claimable => {
DomainRuntimeProfileTaskStatus::Claimable
}
crate::module_bindings::RuntimeProfileTaskStatus::Claimed => {
DomainRuntimeProfileTaskStatus::Claimed
}
crate::module_bindings::RuntimeProfileTaskStatus::Disabled => {
DomainRuntimeProfileTaskStatus::Disabled
}
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_admin_disable_input_type::RuntimeProfileTaskConfigAdminDisableInput;
use super::runtime_profile_task_config_admin_procedure_result_type::RuntimeProfileTaskConfigAdminProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminDisableProfileTaskConfigArgs {
pub input: RuntimeProfileTaskConfigAdminDisableInput,
}
impl __sdk::InModule for AdminDisableProfileTaskConfigArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_disable_profile_task_config`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_disable_profile_task_config {
fn admin_disable_profile_task_config(&self, input: RuntimeProfileTaskConfigAdminDisableInput) {
self.admin_disable_profile_task_config_then(input, |_, _| {});
}
fn admin_disable_profile_task_config_then(
&self,
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_disable_profile_task_config for super::RemoteProcedures {
fn admin_disable_profile_task_config_then(
&self,
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
"admin_disable_profile_task_config",
AdminDisableProfileTaskConfigArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_invite_code_admin_list_input_type::RuntimeProfileInviteCodeAdminListInput;
use super::runtime_profile_invite_code_admin_list_procedure_result_type::RuntimeProfileInviteCodeAdminListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminListProfileInviteCodesArgs {
pub input: RuntimeProfileInviteCodeAdminListInput,
}
impl __sdk::InModule for AdminListProfileInviteCodesArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_list_profile_invite_codes`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_list_profile_invite_codes {
fn admin_list_profile_invite_codes(&self, input: RuntimeProfileInviteCodeAdminListInput) {
self.admin_list_profile_invite_codes_then(input, |_, _| {});
}
fn admin_list_profile_invite_codes_then(
&self,
input: RuntimeProfileInviteCodeAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_list_profile_invite_codes for super::RemoteProcedures {
fn admin_list_profile_invite_codes_then(
&self,
input: RuntimeProfileInviteCodeAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileInviteCodeAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminListProcedureResult>(
"admin_list_profile_invite_codes",
AdminListProfileInviteCodesArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_redeem_code_admin_list_input_type::RuntimeProfileRedeemCodeAdminListInput;
use super::runtime_profile_redeem_code_admin_list_procedure_result_type::RuntimeProfileRedeemCodeAdminListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminListProfileRedeemCodesArgs {
pub input: RuntimeProfileRedeemCodeAdminListInput,
}
impl __sdk::InModule for AdminListProfileRedeemCodesArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_list_profile_redeem_codes`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_list_profile_redeem_codes {
fn admin_list_profile_redeem_codes(&self, input: RuntimeProfileRedeemCodeAdminListInput) {
self.admin_list_profile_redeem_codes_then(input, |_, _| {});
}
fn admin_list_profile_redeem_codes_then(
&self,
input: RuntimeProfileRedeemCodeAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_list_profile_redeem_codes for super::RemoteProcedures {
fn admin_list_profile_redeem_codes_then(
&self,
input: RuntimeProfileRedeemCodeAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminListProcedureResult>(
"admin_list_profile_redeem_codes",
AdminListProfileRedeemCodesArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_admin_list_input_type::RuntimeProfileTaskConfigAdminListInput;
use super::runtime_profile_task_config_admin_list_procedure_result_type::RuntimeProfileTaskConfigAdminListProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminListProfileTaskConfigsArgs {
pub input: RuntimeProfileTaskConfigAdminListInput,
}
impl __sdk::InModule for AdminListProfileTaskConfigsArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_list_profile_task_configs`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_list_profile_task_configs {
fn admin_list_profile_task_configs(&self, input: RuntimeProfileTaskConfigAdminListInput) {
self.admin_list_profile_task_configs_then(input, |_, _| {});
}
fn admin_list_profile_task_configs_then(
&self,
input: RuntimeProfileTaskConfigAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_list_profile_task_configs for super::RemoteProcedures {
fn admin_list_profile_task_configs_then(
&self,
input: RuntimeProfileTaskConfigAdminListInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminListProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminListProcedureResult>(
"admin_list_profile_task_configs",
AdminListProfileTaskConfigsArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_admin_procedure_result_type::RuntimeProfileTaskConfigAdminProcedureResult;
use super::runtime_profile_task_config_admin_upsert_input_type::RuntimeProfileTaskConfigAdminUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminUpsertProfileTaskConfigArgs {
pub input: RuntimeProfileTaskConfigAdminUpsertInput,
}
impl __sdk::InModule for AdminUpsertProfileTaskConfigArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_upsert_profile_task_config`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_upsert_profile_task_config {
fn admin_upsert_profile_task_config(&self, input: RuntimeProfileTaskConfigAdminUpsertInput) {
self.admin_upsert_profile_task_config_then(input, |_, _| {});
}
fn admin_upsert_profile_task_config_then(
&self,
input: RuntimeProfileTaskConfigAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_upsert_profile_task_config for super::RemoteProcedures {
fn admin_upsert_profile_task_config_then(
&self,
input: RuntimeProfileTaskConfigAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(
"admin_upsert_profile_task_config",
AdminUpsertProfileTaskConfigArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_claim_input_type::RuntimeProfileTaskClaimInput;
use super::runtime_profile_task_claim_procedure_result_type::RuntimeProfileTaskClaimProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct ClaimProfileTaskRewardAndReturnArgs {
pub input: RuntimeProfileTaskClaimInput,
}
impl __sdk::InModule for ClaimProfileTaskRewardAndReturnArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `claim_profile_task_reward_and_return`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait claim_profile_task_reward_and_return {
fn claim_profile_task_reward_and_return(&self, input: RuntimeProfileTaskClaimInput) {
self.claim_profile_task_reward_and_return_then(input, |_, _| {});
}
fn claim_profile_task_reward_and_return_then(
&self,
input: RuntimeProfileTaskClaimInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl claim_profile_task_reward_and_return for super::RemoteProcedures {
fn claim_profile_task_reward_and_return_then(
&self,
input: RuntimeProfileTaskClaimInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskClaimProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskClaimProcedureResult>(
"claim_profile_task_reward_and_return",
ClaimProfileTaskRewardAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -0,0 +1,59 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_center_get_input_type::RuntimeProfileTaskCenterGetInput;
use super::runtime_profile_task_center_procedure_result_type::RuntimeProfileTaskCenterProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct GetProfileTaskCenterArgs {
pub input: RuntimeProfileTaskCenterGetInput,
}
impl __sdk::InModule for GetProfileTaskCenterArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `get_profile_task_center`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait get_profile_task_center {
fn get_profile_task_center(&self, input: RuntimeProfileTaskCenterGetInput) {
self.get_profile_task_center_then(input, |_, _| {});
}
fn get_profile_task_center_then(
&self,
input: RuntimeProfileTaskCenterGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskCenterProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl get_profile_task_center for super::RemoteProcedures {
fn get_profile_task_center_then(
&self,
input: RuntimeProfileTaskCenterGetInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskCenterProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskCenterProcedureResult>(
"get_profile_task_center",
GetProfileTaskCenterArgs { input },
__callback,
);
}
}

View File

@@ -9,8 +9,13 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
pub mod accept_quest_reducer;
pub mod acknowledge_quest_completion_reducer;
pub mod admin_disable_profile_redeem_code_procedure;
pub mod admin_disable_profile_task_config_procedure;
pub mod admin_list_profile_invite_codes_procedure;
pub mod admin_list_profile_redeem_codes_procedure;
pub mod admin_list_profile_task_configs_procedure;
pub mod admin_upsert_profile_invite_code_procedure;
pub mod admin_upsert_profile_redeem_code_procedure;
pub mod admin_upsert_profile_task_config_procedure;
pub mod advance_puzzle_next_level_procedure;
pub mod ai_result_reference_input_type;
pub mod ai_result_reference_kind_type;
@@ -129,6 +134,7 @@ pub mod chapter_progression_ledger_input_type;
pub mod chapter_progression_procedure_result_type;
pub mod chapter_progression_snapshot_type;
pub mod chapter_progression_type;
pub mod claim_profile_task_reward_and_return_procedure;
pub mod claim_puzzle_work_point_incentive_procedure;
pub mod clear_database_migration_import_chunks_procedure;
pub mod clear_platform_browse_history_and_return_procedure;
@@ -260,6 +266,7 @@ pub mod get_profile_dashboard_procedure;
pub mod get_profile_play_stats_procedure;
pub mod get_profile_recharge_center_procedure;
pub mod get_profile_referral_invite_center_procedure;
pub mod get_profile_task_center_procedure;
pub mod get_puzzle_agent_session_procedure;
pub mod get_puzzle_gallery_detail_procedure;
pub mod get_puzzle_run_procedure;
@@ -351,6 +358,9 @@ pub mod profile_redeem_code_type;
pub mod profile_redeem_code_usage_type;
pub mod profile_referral_relation_type;
pub mod profile_save_archive_type;
pub mod profile_task_config_type;
pub mod profile_task_progress_type;
pub mod profile_task_reward_claim_type;
pub mod profile_wallet_ledger_type;
pub mod public_work_like_type;
pub mod public_work_play_daily_stat_type;
@@ -482,6 +492,8 @@ pub mod runtime_platform_theme_type;
pub mod runtime_profile_dashboard_get_input_type;
pub mod runtime_profile_dashboard_procedure_result_type;
pub mod runtime_profile_dashboard_snapshot_type;
pub mod runtime_profile_invite_code_admin_list_input_type;
pub mod runtime_profile_invite_code_admin_list_procedure_result_type;
pub mod runtime_profile_invite_code_admin_procedure_result_type;
pub mod runtime_profile_invite_code_admin_upsert_input_type;
pub mod runtime_profile_invite_code_snapshot_type;
@@ -502,6 +514,8 @@ pub mod runtime_profile_recharge_order_status_type;
pub mod runtime_profile_recharge_product_kind_type;
pub mod runtime_profile_recharge_product_snapshot_type;
pub mod runtime_profile_redeem_code_admin_disable_input_type;
pub mod runtime_profile_redeem_code_admin_list_input_type;
pub mod runtime_profile_redeem_code_admin_list_procedure_result_type;
pub mod runtime_profile_redeem_code_admin_procedure_result_type;
pub mod runtime_profile_redeem_code_admin_upsert_input_type;
pub mod runtime_profile_redeem_code_mode_type;
@@ -513,6 +527,21 @@ pub mod runtime_profile_save_archive_list_input_type;
pub mod runtime_profile_save_archive_procedure_result_type;
pub mod runtime_profile_save_archive_resume_input_type;
pub mod runtime_profile_save_archive_snapshot_type;
pub mod runtime_profile_task_center_get_input_type;
pub mod runtime_profile_task_center_procedure_result_type;
pub mod runtime_profile_task_center_snapshot_type;
pub mod runtime_profile_task_claim_input_type;
pub mod runtime_profile_task_claim_procedure_result_type;
pub mod runtime_profile_task_claim_snapshot_type;
pub mod runtime_profile_task_config_admin_disable_input_type;
pub mod runtime_profile_task_config_admin_list_input_type;
pub mod runtime_profile_task_config_admin_list_procedure_result_type;
pub mod runtime_profile_task_config_admin_procedure_result_type;
pub mod runtime_profile_task_config_admin_upsert_input_type;
pub mod runtime_profile_task_config_snapshot_type;
pub mod runtime_profile_task_cycle_type;
pub mod runtime_profile_task_item_snapshot_type;
pub mod runtime_profile_task_status_type;
pub mod runtime_profile_wallet_adjustment_input_type;
pub mod runtime_profile_wallet_adjustment_procedure_result_type;
pub mod runtime_profile_wallet_ledger_entry_snapshot_type;
@@ -537,6 +566,7 @@ pub mod runtime_snapshot_procedure_result_type;
pub mod runtime_snapshot_row_type;
pub mod runtime_snapshot_type;
pub mod runtime_snapshot_upsert_input_type;
pub mod runtime_tracking_scope_kind_type;
pub mod save_puzzle_form_draft_procedure;
pub mod save_puzzle_generated_images_procedure;
pub mod select_puzzle_cover_image_procedure;
@@ -564,6 +594,8 @@ pub mod submit_match_3_d_agent_message_procedure;
pub mod submit_puzzle_agent_message_procedure;
pub mod submit_puzzle_leaderboard_entry_procedure;
pub mod swap_puzzle_pieces_procedure;
pub mod tracking_daily_stat_type;
pub mod tracking_event_type;
pub mod treasure_interaction_action_type;
pub mod treasure_record_procedure_result_type;
pub mod treasure_record_snapshot_type;
@@ -594,8 +626,13 @@ pub mod user_browse_history_type;
pub use accept_quest_reducer::accept_quest;
pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion;
pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code;
pub use admin_disable_profile_task_config_procedure::admin_disable_profile_task_config;
pub use admin_list_profile_invite_codes_procedure::admin_list_profile_invite_codes;
pub use admin_list_profile_redeem_codes_procedure::admin_list_profile_redeem_codes;
pub use admin_list_profile_task_configs_procedure::admin_list_profile_task_configs;
pub use admin_upsert_profile_invite_code_procedure::admin_upsert_profile_invite_code;
pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code;
pub use admin_upsert_profile_task_config_procedure::admin_upsert_profile_task_config;
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
pub use ai_result_reference_input_type::AiResultReferenceInput;
pub use ai_result_reference_kind_type::AiResultReferenceKind;
@@ -714,6 +751,7 @@ pub use chapter_progression_ledger_input_type::ChapterProgressionLedgerInput;
pub use chapter_progression_procedure_result_type::ChapterProgressionProcedureResult;
pub use chapter_progression_snapshot_type::ChapterProgressionSnapshot;
pub use chapter_progression_type::ChapterProgression;
pub use claim_profile_task_reward_and_return_procedure::claim_profile_task_reward_and_return;
pub use claim_puzzle_work_point_incentive_procedure::claim_puzzle_work_point_incentive;
pub use clear_database_migration_import_chunks_procedure::clear_database_migration_import_chunks;
pub use clear_platform_browse_history_and_return_procedure::clear_platform_browse_history_and_return;
@@ -845,6 +883,7 @@ pub use get_profile_dashboard_procedure::get_profile_dashboard;
pub use get_profile_play_stats_procedure::get_profile_play_stats;
pub use get_profile_recharge_center_procedure::get_profile_recharge_center;
pub use get_profile_referral_invite_center_procedure::get_profile_referral_invite_center;
pub use get_profile_task_center_procedure::get_profile_task_center;
pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session;
pub use get_puzzle_gallery_detail_procedure::get_puzzle_gallery_detail;
pub use get_puzzle_run_procedure::get_puzzle_run;
@@ -936,6 +975,9 @@ pub use profile_redeem_code_type::ProfileRedeemCode;
pub use profile_redeem_code_usage_type::ProfileRedeemCodeUsage;
pub use profile_referral_relation_type::ProfileReferralRelation;
pub use profile_save_archive_type::ProfileSaveArchive;
pub use profile_task_config_type::ProfileTaskConfig;
pub use profile_task_progress_type::ProfileTaskProgress;
pub use profile_task_reward_claim_type::ProfileTaskRewardClaim;
pub use profile_wallet_ledger_type::ProfileWalletLedger;
pub use public_work_like_type::PublicWorkLike;
pub use public_work_play_daily_stat_type::PublicWorkPlayDailyStat;
@@ -1067,6 +1109,8 @@ pub use runtime_platform_theme_type::RuntimePlatformTheme;
pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput;
pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult;
pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot;
pub use runtime_profile_invite_code_admin_list_input_type::RuntimeProfileInviteCodeAdminListInput;
pub use runtime_profile_invite_code_admin_list_procedure_result_type::RuntimeProfileInviteCodeAdminListProcedureResult;
pub use runtime_profile_invite_code_admin_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult;
pub use runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput;
pub use runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot;
@@ -1087,6 +1131,8 @@ pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderS
pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind;
pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot;
pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput;
pub use runtime_profile_redeem_code_admin_list_input_type::RuntimeProfileRedeemCodeAdminListInput;
pub use runtime_profile_redeem_code_admin_list_procedure_result_type::RuntimeProfileRedeemCodeAdminListProcedureResult;
pub use runtime_profile_redeem_code_admin_procedure_result_type::RuntimeProfileRedeemCodeAdminProcedureResult;
pub use runtime_profile_redeem_code_admin_upsert_input_type::RuntimeProfileRedeemCodeAdminUpsertInput;
pub use runtime_profile_redeem_code_mode_type::RuntimeProfileRedeemCodeMode;
@@ -1098,6 +1144,21 @@ pub use runtime_profile_save_archive_list_input_type::RuntimeProfileSaveArchiveL
pub use runtime_profile_save_archive_procedure_result_type::RuntimeProfileSaveArchiveProcedureResult;
pub use runtime_profile_save_archive_resume_input_type::RuntimeProfileSaveArchiveResumeInput;
pub use runtime_profile_save_archive_snapshot_type::RuntimeProfileSaveArchiveSnapshot;
pub use runtime_profile_task_center_get_input_type::RuntimeProfileTaskCenterGetInput;
pub use runtime_profile_task_center_procedure_result_type::RuntimeProfileTaskCenterProcedureResult;
pub use runtime_profile_task_center_snapshot_type::RuntimeProfileTaskCenterSnapshot;
pub use runtime_profile_task_claim_input_type::RuntimeProfileTaskClaimInput;
pub use runtime_profile_task_claim_procedure_result_type::RuntimeProfileTaskClaimProcedureResult;
pub use runtime_profile_task_claim_snapshot_type::RuntimeProfileTaskClaimSnapshot;
pub use runtime_profile_task_config_admin_disable_input_type::RuntimeProfileTaskConfigAdminDisableInput;
pub use runtime_profile_task_config_admin_list_input_type::RuntimeProfileTaskConfigAdminListInput;
pub use runtime_profile_task_config_admin_list_procedure_result_type::RuntimeProfileTaskConfigAdminListProcedureResult;
pub use runtime_profile_task_config_admin_procedure_result_type::RuntimeProfileTaskConfigAdminProcedureResult;
pub use runtime_profile_task_config_admin_upsert_input_type::RuntimeProfileTaskConfigAdminUpsertInput;
pub use runtime_profile_task_config_snapshot_type::RuntimeProfileTaskConfigSnapshot;
pub use runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
pub use runtime_profile_task_item_snapshot_type::RuntimeProfileTaskItemSnapshot;
pub use runtime_profile_task_status_type::RuntimeProfileTaskStatus;
pub use runtime_profile_wallet_adjustment_input_type::RuntimeProfileWalletAdjustmentInput;
pub use runtime_profile_wallet_adjustment_procedure_result_type::RuntimeProfileWalletAdjustmentProcedureResult;
pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
@@ -1122,6 +1183,7 @@ pub use runtime_snapshot_procedure_result_type::RuntimeSnapshotProcedureResult;
pub use runtime_snapshot_row_type::RuntimeSnapshotRow;
pub use runtime_snapshot_type::RuntimeSnapshot;
pub use runtime_snapshot_upsert_input_type::RuntimeSnapshotUpsertInput;
pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft;
pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images;
pub use select_puzzle_cover_image_procedure::select_puzzle_cover_image;
@@ -1149,6 +1211,8 @@ pub use submit_match_3_d_agent_message_procedure::submit_match_3_d_agent_message
pub use submit_puzzle_agent_message_procedure::submit_puzzle_agent_message;
pub use submit_puzzle_leaderboard_entry_procedure::submit_puzzle_leaderboard_entry;
pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces;
pub use tracking_daily_stat_type::TrackingDailyStat;
pub use tracking_event_type::TrackingEvent;
pub use treasure_interaction_action_type::TreasureInteractionAction;
pub use treasure_record_procedure_result_type::TreasureRecordProcedureResult;
pub use treasure_record_snapshot_type::TreasureRecordSnapshot;

View File

@@ -0,0 +1,91 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileTaskConfig {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at: __sdk::Timestamp,
pub updated_by: String,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileTaskConfig {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileTaskConfig`.
///
/// Provides typed access to columns for query building.
pub struct ProfileTaskConfigCols {
pub task_id: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub title: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub description: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub event_key: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub cycle: __sdk::__query_builder::Col<ProfileTaskConfig, RuntimeProfileTaskCycle>,
pub scope_kind: __sdk::__query_builder::Col<ProfileTaskConfig, RuntimeTrackingScopeKind>,
pub threshold: __sdk::__query_builder::Col<ProfileTaskConfig, u32>,
pub reward_points: __sdk::__query_builder::Col<ProfileTaskConfig, u64>,
pub enabled: __sdk::__query_builder::Col<ProfileTaskConfig, bool>,
pub sort_order: __sdk::__query_builder::Col<ProfileTaskConfig, i32>,
pub created_by: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub created_at: __sdk::__query_builder::Col<ProfileTaskConfig, __sdk::Timestamp>,
pub updated_by: __sdk::__query_builder::Col<ProfileTaskConfig, String>,
pub updated_at: __sdk::__query_builder::Col<ProfileTaskConfig, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileTaskConfig {
type Cols = ProfileTaskConfigCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileTaskConfigCols {
task_id: __sdk::__query_builder::Col::new(table_name, "task_id"),
title: __sdk::__query_builder::Col::new(table_name, "title"),
description: __sdk::__query_builder::Col::new(table_name, "description"),
event_key: __sdk::__query_builder::Col::new(table_name, "event_key"),
cycle: __sdk::__query_builder::Col::new(table_name, "cycle"),
scope_kind: __sdk::__query_builder::Col::new(table_name, "scope_kind"),
threshold: __sdk::__query_builder::Col::new(table_name, "threshold"),
reward_points: __sdk::__query_builder::Col::new(table_name, "reward_points"),
enabled: __sdk::__query_builder::Col::new(table_name, "enabled"),
sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"),
created_by: __sdk::__query_builder::Col::new(table_name, "created_by"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_by: __sdk::__query_builder::Col::new(table_name, "updated_by"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileTaskConfig`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileTaskConfigIxCols {
pub task_id: __sdk::__query_builder::IxCol<ProfileTaskConfig, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileTaskConfig {
type IxCols = ProfileTaskConfigIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileTaskConfigIxCols {
task_id: __sdk::__query_builder::IxCol::new(table_name, "task_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileTaskConfig {}

View File

@@ -0,0 +1,74 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_status_type::RuntimeProfileTaskStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileTaskProgress {
pub progress_id: String,
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub progress_count: u32,
pub threshold: u32,
pub status: RuntimeProfileTaskStatus,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileTaskProgress {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileTaskProgress`.
///
/// Provides typed access to columns for query building.
pub struct ProfileTaskProgressCols {
pub progress_id: __sdk::__query_builder::Col<ProfileTaskProgress, String>,
pub user_id: __sdk::__query_builder::Col<ProfileTaskProgress, String>,
pub task_id: __sdk::__query_builder::Col<ProfileTaskProgress, String>,
pub day_key: __sdk::__query_builder::Col<ProfileTaskProgress, i64>,
pub progress_count: __sdk::__query_builder::Col<ProfileTaskProgress, u32>,
pub threshold: __sdk::__query_builder::Col<ProfileTaskProgress, u32>,
pub status: __sdk::__query_builder::Col<ProfileTaskProgress, RuntimeProfileTaskStatus>,
pub updated_at: __sdk::__query_builder::Col<ProfileTaskProgress, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileTaskProgress {
type Cols = ProfileTaskProgressCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileTaskProgressCols {
progress_id: __sdk::__query_builder::Col::new(table_name, "progress_id"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
task_id: __sdk::__query_builder::Col::new(table_name, "task_id"),
day_key: __sdk::__query_builder::Col::new(table_name, "day_key"),
progress_count: __sdk::__query_builder::Col::new(table_name, "progress_count"),
threshold: __sdk::__query_builder::Col::new(table_name, "threshold"),
status: __sdk::__query_builder::Col::new(table_name, "status"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileTaskProgress`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileTaskProgressIxCols {
pub progress_id: __sdk::__query_builder::IxCol<ProfileTaskProgress, String>,
pub user_id: __sdk::__query_builder::IxCol<ProfileTaskProgress, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileTaskProgress {
type IxCols = ProfileTaskProgressIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileTaskProgressIxCols {
progress_id: __sdk::__query_builder::IxCol::new(table_name, "progress_id"),
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileTaskProgress {}

View File

@@ -0,0 +1,69 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct ProfileTaskRewardClaim {
pub claim_id: String,
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_ledger_id: String,
pub claimed_at: __sdk::Timestamp,
}
impl __sdk::InModule for ProfileTaskRewardClaim {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `ProfileTaskRewardClaim`.
///
/// Provides typed access to columns for query building.
pub struct ProfileTaskRewardClaimCols {
pub claim_id: __sdk::__query_builder::Col<ProfileTaskRewardClaim, String>,
pub user_id: __sdk::__query_builder::Col<ProfileTaskRewardClaim, String>,
pub task_id: __sdk::__query_builder::Col<ProfileTaskRewardClaim, String>,
pub day_key: __sdk::__query_builder::Col<ProfileTaskRewardClaim, i64>,
pub reward_points: __sdk::__query_builder::Col<ProfileTaskRewardClaim, u64>,
pub wallet_ledger_id: __sdk::__query_builder::Col<ProfileTaskRewardClaim, String>,
pub claimed_at: __sdk::__query_builder::Col<ProfileTaskRewardClaim, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for ProfileTaskRewardClaim {
type Cols = ProfileTaskRewardClaimCols;
fn cols(table_name: &'static str) -> Self::Cols {
ProfileTaskRewardClaimCols {
claim_id: __sdk::__query_builder::Col::new(table_name, "claim_id"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
task_id: __sdk::__query_builder::Col::new(table_name, "task_id"),
day_key: __sdk::__query_builder::Col::new(table_name, "day_key"),
reward_points: __sdk::__query_builder::Col::new(table_name, "reward_points"),
wallet_ledger_id: __sdk::__query_builder::Col::new(table_name, "wallet_ledger_id"),
claimed_at: __sdk::__query_builder::Col::new(table_name, "claimed_at"),
}
}
}
/// Indexed column accessor struct for the table `ProfileTaskRewardClaim`.
///
/// Provides typed access to indexed columns for query building.
pub struct ProfileTaskRewardClaimIxCols {
pub claim_id: __sdk::__query_builder::IxCol<ProfileTaskRewardClaim, String>,
pub user_id: __sdk::__query_builder::IxCol<ProfileTaskRewardClaim, String>,
}
impl __sdk::__query_builder::HasIxCols for ProfileTaskRewardClaim {
type IxCols = ProfileTaskRewardClaimIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
ProfileTaskRewardClaimIxCols {
claim_id: __sdk::__query_builder::IxCol::new(table_name, "claim_id"),
user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for ProfileTaskRewardClaim {}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileInviteCodeAdminListInput {
pub admin_user_id: String,
}
impl __sdk::InModule for RuntimeProfileInviteCodeAdminListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_invite_code_snapshot_type::RuntimeProfileInviteCodeSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileInviteCodeAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileInviteCodeSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileInviteCodeAdminListProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRedeemCodeAdminListInput {
pub admin_user_id: String,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_redeem_code_snapshot_type::RuntimeProfileRedeemCodeSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileRedeemCodeAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileRedeemCodeSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileRedeemCodeAdminListProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskCenterGetInput {
pub user_id: String,
}
impl __sdk::InModule for RuntimeProfileTaskCenterGetInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_center_snapshot_type::RuntimeProfileTaskCenterSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskCenterProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskCenterSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileTaskCenterProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,21 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_item_snapshot_type::RuntimeProfileTaskItemSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskCenterSnapshot {
pub user_id: String,
pub day_key: i64,
pub wallet_balance: u64,
pub tasks: Vec<RuntimeProfileTaskItemSnapshot>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskCenterSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskClaimInput {
pub user_id: String,
pub task_id: String,
}
impl __sdk::InModule for RuntimeProfileTaskClaimInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_claim_snapshot_type::RuntimeProfileTaskClaimSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskClaimProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskClaimSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileTaskClaimProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,24 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_center_snapshot_type::RuntimeProfileTaskCenterSnapshot;
use super::runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletLedgerEntrySnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskClaimSnapshot {
pub user_id: String,
pub task_id: String,
pub day_key: i64,
pub reward_points: u64,
pub wallet_balance: u64,
pub ledger_entry: RuntimeProfileWalletLedgerEntrySnapshot,
pub center: RuntimeProfileTaskCenterSnapshot,
}
impl __sdk::InModule for RuntimeProfileTaskClaimSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,17 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminDisableInput {
pub admin_user_id: String,
pub task_id: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminDisableInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminListInput {
pub admin_user_id: String,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminListInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_snapshot_type::RuntimeProfileTaskConfigSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileTaskConfigSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminListProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,19 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_config_snapshot_type::RuntimeProfileTaskConfigSnapshot;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileTaskConfigSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,29 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigAdminUpsertInput {
pub admin_user_id: String,
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskConfigAdminUpsertInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,31 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskConfigSnapshot {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub scope_kind: RuntimeTrackingScopeKind,
pub threshold: u32,
pub reward_points: u64,
pub enabled: bool,
pub sort_order: i32,
pub created_by: String,
pub created_at_micros: i64,
pub updated_by: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskConfigSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,16 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileTaskCycle {
Daily,
}
impl __sdk::InModule for RuntimeProfileTaskCycle {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,29 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_profile_task_cycle_type::RuntimeProfileTaskCycle;
use super::runtime_profile_task_status_type::RuntimeProfileTaskStatus;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileTaskItemSnapshot {
pub task_id: String,
pub title: String,
pub description: String,
pub event_key: String,
pub cycle: RuntimeProfileTaskCycle,
pub threshold: u32,
pub progress_count: u32,
pub reward_points: u64,
pub status: RuntimeProfileTaskStatus,
pub day_key: i64,
pub claimed_at_micros: Option<i64>,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileTaskItemSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeProfileTaskStatus {
Incomplete,
Claimable,
Claimed,
Disabled,
}
impl __sdk::InModule for RuntimeProfileTaskStatus {
type Module = super::RemoteModule;
}

View File

@@ -25,6 +25,8 @@ pub enum RuntimeProfileWalletLedgerSourceType {
RedeemCodeReward,
PuzzleAuthorIncentiveClaim,
DailyTaskReward,
}
impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType {

View File

@@ -0,0 +1,22 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
#[derive(Copy, Eq, Hash)]
pub enum RuntimeTrackingScopeKind {
Site,
Work,
Module,
User,
}
impl __sdk::InModule for RuntimeTrackingScopeKind {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,75 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct TrackingDailyStat {
pub stat_id: String,
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub day_key: i64,
pub count: u32,
pub first_occurred_at: __sdk::Timestamp,
pub last_occurred_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp,
}
impl __sdk::InModule for TrackingDailyStat {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `TrackingDailyStat`.
///
/// Provides typed access to columns for query building.
pub struct TrackingDailyStatCols {
pub stat_id: __sdk::__query_builder::Col<TrackingDailyStat, String>,
pub event_key: __sdk::__query_builder::Col<TrackingDailyStat, String>,
pub scope_kind: __sdk::__query_builder::Col<TrackingDailyStat, RuntimeTrackingScopeKind>,
pub scope_id: __sdk::__query_builder::Col<TrackingDailyStat, String>,
pub day_key: __sdk::__query_builder::Col<TrackingDailyStat, i64>,
pub count: __sdk::__query_builder::Col<TrackingDailyStat, u32>,
pub first_occurred_at: __sdk::__query_builder::Col<TrackingDailyStat, __sdk::Timestamp>,
pub last_occurred_at: __sdk::__query_builder::Col<TrackingDailyStat, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<TrackingDailyStat, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for TrackingDailyStat {
type Cols = TrackingDailyStatCols;
fn cols(table_name: &'static str) -> Self::Cols {
TrackingDailyStatCols {
stat_id: __sdk::__query_builder::Col::new(table_name, "stat_id"),
event_key: __sdk::__query_builder::Col::new(table_name, "event_key"),
scope_kind: __sdk::__query_builder::Col::new(table_name, "scope_kind"),
scope_id: __sdk::__query_builder::Col::new(table_name, "scope_id"),
day_key: __sdk::__query_builder::Col::new(table_name, "day_key"),
count: __sdk::__query_builder::Col::new(table_name, "count"),
first_occurred_at: __sdk::__query_builder::Col::new(table_name, "first_occurred_at"),
last_occurred_at: __sdk::__query_builder::Col::new(table_name, "last_occurred_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),
}
}
}
/// Indexed column accessor struct for the table `TrackingDailyStat`.
///
/// Provides typed access to indexed columns for query building.
pub struct TrackingDailyStatIxCols {
pub stat_id: __sdk::__query_builder::IxCol<TrackingDailyStat, String>,
}
impl __sdk::__query_builder::HasIxCols for TrackingDailyStat {
type IxCols = TrackingDailyStatIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
TrackingDailyStatIxCols {
stat_id: __sdk::__query_builder::IxCol::new(table_name, "stat_id"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for TrackingDailyStat {}

View File

@@ -0,0 +1,83 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
use super::runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct TrackingEvent {
pub event_id: String,
pub event_key: String,
pub scope_kind: RuntimeTrackingScopeKind,
pub scope_id: String,
pub day_key: i64,
pub user_id: Option<String>,
pub owner_user_id: Option<String>,
pub profile_id: Option<String>,
pub module_key: Option<String>,
pub metadata_json: String,
pub occurred_at: __sdk::Timestamp,
}
impl __sdk::InModule for TrackingEvent {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `TrackingEvent`.
///
/// Provides typed access to columns for query building.
pub struct TrackingEventCols {
pub event_id: __sdk::__query_builder::Col<TrackingEvent, String>,
pub event_key: __sdk::__query_builder::Col<TrackingEvent, String>,
pub scope_kind: __sdk::__query_builder::Col<TrackingEvent, RuntimeTrackingScopeKind>,
pub scope_id: __sdk::__query_builder::Col<TrackingEvent, String>,
pub day_key: __sdk::__query_builder::Col<TrackingEvent, i64>,
pub user_id: __sdk::__query_builder::Col<TrackingEvent, Option<String>>,
pub owner_user_id: __sdk::__query_builder::Col<TrackingEvent, Option<String>>,
pub profile_id: __sdk::__query_builder::Col<TrackingEvent, Option<String>>,
pub module_key: __sdk::__query_builder::Col<TrackingEvent, Option<String>>,
pub metadata_json: __sdk::__query_builder::Col<TrackingEvent, String>,
pub occurred_at: __sdk::__query_builder::Col<TrackingEvent, __sdk::Timestamp>,
}
impl __sdk::__query_builder::HasCols for TrackingEvent {
type Cols = TrackingEventCols;
fn cols(table_name: &'static str) -> Self::Cols {
TrackingEventCols {
event_id: __sdk::__query_builder::Col::new(table_name, "event_id"),
event_key: __sdk::__query_builder::Col::new(table_name, "event_key"),
scope_kind: __sdk::__query_builder::Col::new(table_name, "scope_kind"),
scope_id: __sdk::__query_builder::Col::new(table_name, "scope_id"),
day_key: __sdk::__query_builder::Col::new(table_name, "day_key"),
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
profile_id: __sdk::__query_builder::Col::new(table_name, "profile_id"),
module_key: __sdk::__query_builder::Col::new(table_name, "module_key"),
metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"),
occurred_at: __sdk::__query_builder::Col::new(table_name, "occurred_at"),
}
}
}
/// Indexed column accessor struct for the table `TrackingEvent`.
///
/// Provides typed access to indexed columns for query building.
pub struct TrackingEventIxCols {
pub event_id: __sdk::__query_builder::IxCol<TrackingEvent, String>,
pub event_key: __sdk::__query_builder::IxCol<TrackingEvent, String>,
}
impl __sdk::__query_builder::HasIxCols for TrackingEvent {
type IxCols = TrackingEventIxCols;
fn ix_cols(table_name: &'static str) -> Self::IxCols {
TrackingEventIxCols {
event_id: __sdk::__query_builder::IxCol::new(table_name, "event_id"),
event_key: __sdk::__query_builder::IxCol::new(table_name, "event_key"),
}
}
}
impl __sdk::__query_builder::CanBeLookupTable for TrackingEvent {}

View File

@@ -304,6 +304,143 @@ impl SpacetimeClient {
.await
}
pub async fn get_profile_task_center(
&self,
user_id: String,
) -> Result<RuntimeProfileTaskCenterRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_center_get_input(user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection.procedures().get_profile_task_center_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_center_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn claim_profile_task_reward(
&self,
user_id: String,
task_id: String,
) -> Result<RuntimeProfileTaskClaimRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_claim_input(user_id, task_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.claim_profile_task_reward_and_return_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_claim_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_list_profile_task_configs(
&self,
admin_user_id: String,
) -> Result<Vec<RuntimeProfileTaskConfigRecord>, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_config_admin_list_input(admin_user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_list_profile_task_configs_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_config_admin_list_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_upsert_profile_task_config(
&self,
admin_user_id: String,
task_id: String,
title: String,
description: String,
event_key: String,
cycle: DomainRuntimeProfileTaskCycle,
scope_kind: DomainRuntimeTrackingScopeKind,
threshold: u32,
reward_points: u64,
enabled: bool,
sort_order: i32,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_config_admin_upsert_input(
admin_user_id,
task_id,
title,
description,
event_key,
cycle,
scope_kind,
threshold,
reward_points,
enabled,
sort_order,
updated_at_micros,
)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_upsert_profile_task_config_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_config_admin_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_disable_profile_task_config(
&self,
admin_user_id: String,
task_id: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileTaskConfigRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_task_config_admin_disable_input(
admin_user_id,
task_id,
updated_at_micros,
)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_disable_profile_task_config_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_task_config_admin_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_upsert_profile_redeem_code(
&self,
admin_user_id: String,
@@ -343,6 +480,27 @@ impl SpacetimeClient {
.await
}
pub async fn admin_list_profile_redeem_codes(
&self,
admin_user_id: String,
) -> Result<Vec<RuntimeProfileRedeemCodeRecord>, SpacetimeClientError> {
let procedure_input = build_runtime_profile_redeem_code_admin_list_input(admin_user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_list_profile_redeem_codes_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_redeem_code_admin_list_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn admin_disable_profile_redeem_code(
&self,
admin_user_id: String,
@@ -399,6 +557,27 @@ impl SpacetimeClient {
.await
}
pub async fn admin_list_profile_invite_codes(
&self,
admin_user_id: String,
) -> Result<Vec<RuntimeProfileInviteCodeRecord>, SpacetimeClientError> {
let procedure_input = build_runtime_profile_invite_code_admin_list_input(admin_user_id)
.map_err(SpacetimeClientError::validation_failed)?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_list_profile_invite_codes_then(procedure_input, move |_, result| {
let mapped = result
.map_err(SpacetimeClientError::from_sdk_error)
.and_then(map_runtime_profile_invite_code_admin_list_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_profile_play_stats(
&self,
user_id: String,

View File

@@ -161,6 +161,11 @@ macro_rules! migration_tables {
user_browse_history,
profile_dashboard_state,
profile_wallet_ledger,
tracking_event,
tracking_daily_stat,
profile_task_config,
profile_task_progress,
profile_task_reward_claim,
profile_redeem_code,
profile_redeem_code_usage,
profile_invite_code,

View File

@@ -4,6 +4,10 @@ const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000;
const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7;
const PROFILE_REFERRAL_INVITED_USERS_LIMIT: usize = 20;
const PROFILE_NEW_USER_REGISTRATION_LEDGER_PREFIX: &str = "new-user-registration";
const PROFILE_TASK_SYSTEM_USER_ID: &str = "system:profile-task";
const PROFILE_TASK_LOGIN_EVENT_ID_PREFIX: &str = "daily-login";
const PROFILE_TRACKING_SITE_SCOPE_ID: &str = "site";
const PROFILE_TRACKING_PROFILE_MODULE_KEY: &str = "profile";
#[spacetimedb::table(accessor = profile_dashboard_state)]
pub struct ProfileDashboardState {
@@ -33,6 +37,115 @@ pub struct ProfileWalletLedger {
pub(crate) created_at: Timestamp,
}
#[spacetimedb::table(
accessor = tracking_event,
index(accessor = by_tracking_event_event_key, btree(columns = [event_key])),
index(
accessor = by_tracking_event_scope,
btree(columns = [scope_kind, scope_id])
),
index(
accessor = by_tracking_event_user,
btree(columns = [user_id, occurred_at])
)
)]
pub struct TrackingEvent {
#[primary_key]
pub(crate) event_id: String,
pub(crate) event_key: String,
pub(crate) scope_kind: RuntimeTrackingScopeKind,
pub(crate) scope_id: String,
pub(crate) day_key: i64,
pub(crate) user_id: Option<String>,
pub(crate) owner_user_id: Option<String>,
pub(crate) profile_id: Option<String>,
pub(crate) module_key: Option<String>,
pub(crate) metadata_json: String,
pub(crate) occurred_at: Timestamp,
}
#[spacetimedb::table(
accessor = tracking_daily_stat,
index(
accessor = by_tracking_daily_stat_event_day,
btree(columns = [event_key, day_key])
),
index(
accessor = by_tracking_daily_stat_scope_day,
btree(columns = [scope_kind, scope_id, day_key])
)
)]
pub struct TrackingDailyStat {
#[primary_key]
pub(crate) stat_id: String,
pub(crate) event_key: String,
pub(crate) scope_kind: RuntimeTrackingScopeKind,
pub(crate) scope_id: String,
pub(crate) day_key: i64,
pub(crate) count: u32,
pub(crate) first_occurred_at: Timestamp,
pub(crate) last_occurred_at: Timestamp,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_task_config)]
pub struct ProfileTaskConfig {
#[primary_key]
pub(crate) task_id: String,
pub(crate) title: String,
pub(crate) description: String,
pub(crate) event_key: String,
pub(crate) cycle: RuntimeProfileTaskCycle,
pub(crate) scope_kind: RuntimeTrackingScopeKind,
pub(crate) threshold: u32,
pub(crate) reward_points: u64,
pub(crate) enabled: bool,
pub(crate) sort_order: i32,
pub(crate) created_by: String,
pub(crate) created_at: Timestamp,
pub(crate) updated_by: String,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_task_progress,
index(accessor = by_profile_task_progress_user, btree(columns = [user_id])),
index(
accessor = by_profile_task_progress_user_task,
btree(columns = [user_id, task_id])
)
)]
pub struct ProfileTaskProgress {
#[primary_key]
pub(crate) progress_id: String,
pub(crate) user_id: String,
pub(crate) task_id: String,
pub(crate) day_key: i64,
pub(crate) progress_count: u32,
pub(crate) threshold: u32,
pub(crate) status: RuntimeProfileTaskStatus,
pub(crate) updated_at: Timestamp,
}
#[spacetimedb::table(
accessor = profile_task_reward_claim,
index(accessor = by_profile_task_claim_user, btree(columns = [user_id])),
index(
accessor = by_profile_task_claim_user_task,
btree(columns = [user_id, task_id])
)
)]
pub struct ProfileTaskRewardClaim {
#[primary_key]
pub(crate) claim_id: String,
pub(crate) user_id: String,
pub(crate) task_id: String,
pub(crate) day_key: i64,
pub(crate) reward_points: u64,
pub(crate) wallet_ledger_id: String,
pub(crate) claimed_at: Timestamp,
}
#[spacetimedb::table(accessor = profile_redeem_code)]
pub struct ProfileRedeemCode {
#[primary_key]
@@ -355,6 +468,103 @@ pub fn list_profile_wallet_ledger(
}
}
// 任务中心读取会顺手记录当日登录埋点,确保“每日登录”只依赖后端事实。
#[spacetimedb::procedure]
pub fn get_profile_task_center(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskCenterGetInput,
) -> RuntimeProfileTaskCenterProcedureResult {
match ctx.try_with_tx(|tx| get_profile_task_center_snapshot(tx, input.clone(), true)) {
Ok(record) => RuntimeProfileTaskCenterProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskCenterProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 领奖记录与光点流水在同一事务内写入,避免任务状态和钱包余额漂移。
#[spacetimedb::procedure]
pub fn claim_profile_task_reward_and_return(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskClaimInput,
) -> RuntimeProfileTaskClaimProcedureResult {
match ctx.try_with_tx(|tx| claim_profile_task_reward_record(tx, input.clone())) {
Ok(record) => RuntimeProfileTaskClaimProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskClaimProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_list_profile_task_configs(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskConfigAdminListInput,
) -> RuntimeProfileTaskConfigAdminListProcedureResult {
match ctx.try_with_tx(|tx| list_profile_task_config_snapshots(tx, input.clone())) {
Ok(entries) => RuntimeProfileTaskConfigAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileTaskConfigAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_task_config(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskConfigAdminUpsertInput,
) -> RuntimeProfileTaskConfigAdminProcedureResult {
match ctx.try_with_tx(|tx| upsert_profile_task_config_record(tx, input.clone())) {
Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_disable_profile_task_config(
ctx: &mut ProcedureContext,
input: RuntimeProfileTaskConfigAdminDisableInput,
) -> RuntimeProfileTaskConfigAdminProcedureResult {
match ctx.try_with_tx(|tx| disable_profile_task_config_record(tx, input.clone())) {
Ok(record) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileTaskConfigAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
// 新用户注册赠送由后端注册链路调用;流水 ID 固定,保证重试不重复发放。
#[spacetimedb::procedure]
pub fn grant_new_user_registration_wallet_reward(
@@ -591,6 +801,25 @@ pub fn admin_disable_profile_redeem_code(
}
}
#[spacetimedb::procedure]
pub fn admin_list_profile_redeem_codes(
ctx: &mut ProcedureContext,
input: RuntimeProfileRedeemCodeAdminListInput,
) -> RuntimeProfileRedeemCodeAdminListProcedureResult {
match ctx.try_with_tx(|tx| admin_list_profile_redeem_code_records(tx, input.clone())) {
Ok(entries) => RuntimeProfileRedeemCodeAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileRedeemCodeAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn admin_upsert_profile_invite_code(
ctx: &mut ProcedureContext,
@@ -610,6 +839,25 @@ pub fn admin_upsert_profile_invite_code(
}
}
#[spacetimedb::procedure]
pub fn admin_list_profile_invite_codes(
ctx: &mut ProcedureContext,
input: RuntimeProfileInviteCodeAdminListInput,
) -> RuntimeProfileInviteCodeAdminListProcedureResult {
match ctx.try_with_tx(|tx| admin_list_profile_invite_code_records(tx, input.clone())) {
Ok(entries) => RuntimeProfileInviteCodeAdminListProcedureResult {
ok: true,
entries,
error_message: None,
},
Err(message) => RuntimeProfileInviteCodeAdminListProcedureResult {
ok: false,
entries: Vec::new(),
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput,
@@ -2136,6 +2384,533 @@ fn build_profile_recharge_center_snapshot(
}
}
fn get_profile_task_center_snapshot(
ctx: &ReducerContext,
input: RuntimeProfileTaskCenterGetInput,
record_login_event: bool,
) -> Result<RuntimeProfileTaskCenterSnapshot, String> {
let validated_input = build_runtime_profile_task_center_get_input(input.user_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(ctx);
if record_login_event {
record_daily_login_tracking_event(ctx, &validated_input.user_id)?;
}
Ok(build_profile_task_center_snapshot(
ctx,
&validated_input.user_id,
ctx.timestamp,
))
}
fn claim_profile_task_reward_record(
ctx: &ReducerContext,
input: RuntimeProfileTaskClaimInput,
) -> Result<RuntimeProfileTaskClaimSnapshot, String> {
let validated_input = build_runtime_profile_task_claim_input(input.user_id, input.task_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(ctx);
let config = ctx
.db
.profile_task_config()
.task_id()
.find(&validated_input.task_id)
.ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?;
if !config.enabled {
return Err(RuntimeProfileFieldError::TaskDisabled.to_string());
}
if is_daily_login_task_config(&config) {
record_daily_login_tracking_event(ctx, &validated_input.user_id)?;
}
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let claim_id =
build_runtime_profile_task_claim_id(&validated_input.user_id, &config.task_id, day_key);
if ctx
.db
.profile_task_reward_claim()
.claim_id()
.find(&claim_id)
.is_some()
{
return Err(RuntimeProfileFieldError::TaskAlreadyClaimed.to_string());
}
let progress_count = profile_task_progress_count(ctx, &validated_input.user_id, &config);
if progress_count < config.threshold {
return Err(RuntimeProfileFieldError::TaskNotClaimable.to_string());
}
let ledger_id = build_runtime_profile_task_reward_ledger_id(
&validated_input.user_id,
&config.task_id,
day_key,
);
let wallet_balance = grant_profile_wallet_points(
ctx,
&validated_input.user_id,
config.reward_points,
RuntimeProfileWalletLedgerSourceType::DailyTaskReward,
&ledger_id,
ctx.timestamp,
)?;
let claim = ctx
.db
.profile_task_reward_claim()
.insert(ProfileTaskRewardClaim {
claim_id: claim_id.clone(),
user_id: validated_input.user_id.clone(),
task_id: config.task_id.clone(),
day_key,
reward_points: config.reward_points,
wallet_ledger_id: ledger_id.clone(),
claimed_at: ctx.timestamp,
});
refresh_profile_task_progress(ctx, &validated_input.user_id, &config, day_key);
let ledger_entry = ctx
.db
.profile_wallet_ledger()
.wallet_ledger_id()
.find(&ledger_id)
.ok_or_else(|| "任务奖励钱包流水写入失败".to_string())?;
Ok(RuntimeProfileTaskClaimSnapshot {
user_id: validated_input.user_id.clone(),
task_id: config.task_id.clone(),
day_key,
reward_points: claim.reward_points,
wallet_balance,
ledger_entry: build_profile_wallet_ledger_snapshot_from_row(&ledger_entry),
center: build_profile_task_center_snapshot(ctx, &validated_input.user_id, ctx.timestamp),
})
}
fn list_profile_task_config_snapshots(
ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminListInput,
) -> Result<Vec<RuntimeProfileTaskConfigSnapshot>, String> {
let _validated_input = build_runtime_profile_task_config_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
ensure_default_profile_task_config(ctx);
let mut entries = ctx
.db
.profile_task_config()
.iter()
.map(|row| build_profile_task_config_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.task_id.cmp(&right.task_id))
});
Ok(entries)
}
fn admin_list_profile_redeem_code_records(
ctx: &ReducerContext,
input: RuntimeProfileRedeemCodeAdminListInput,
) -> Result<Vec<RuntimeProfileRedeemCodeSnapshot>, String> {
let _validated_input = build_runtime_profile_redeem_code_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.profile_redeem_code()
.iter()
.map(|row| build_profile_redeem_code_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.code.cmp(&right.code))
});
Ok(entries)
}
fn admin_list_profile_invite_code_records(
ctx: &ReducerContext,
input: RuntimeProfileInviteCodeAdminListInput,
) -> Result<Vec<RuntimeProfileInviteCodeSnapshot>, String> {
let _validated_input = build_runtime_profile_invite_code_admin_list_input(input.admin_user_id)
.map_err(|error| error.to_string())?;
let mut entries = ctx
.db
.profile_invite_code()
.iter()
.filter(|row| is_admin_profile_invite_code_user_id(&row.user_id))
.map(|row| build_profile_invite_code_snapshot_from_row(&row))
.collect::<Vec<_>>();
entries.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.invite_code.cmp(&right.invite_code))
});
Ok(entries)
}
fn upsert_profile_task_config_record(
ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminUpsertInput,
) -> Result<RuntimeProfileTaskConfigSnapshot, String> {
let validated_input = build_runtime_profile_task_config_admin_upsert_input(
input.admin_user_id,
input.task_id,
input.title,
input.description,
input.event_key,
input.cycle,
input.scope_kind,
input.threshold,
input.reward_points,
input.enabled,
input.sort_order,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
let existing = ctx
.db
.profile_task_config()
.task_id()
.find(&validated_input.task_id);
if let Some(row) = existing.as_ref() {
ctx.db.profile_task_config().task_id().delete(&row.task_id);
}
let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig {
task_id: validated_input.task_id,
title: validated_input.title,
description: validated_input.description,
event_key: validated_input.event_key,
cycle: validated_input.cycle,
scope_kind: validated_input.scope_kind,
threshold: validated_input.threshold,
reward_points: validated_input.reward_points,
enabled: validated_input.enabled,
sort_order: validated_input.sort_order,
created_by: existing
.as_ref()
.map(|row| row.created_by.clone())
.unwrap_or_else(|| validated_input.admin_user_id.clone()),
created_at: existing
.as_ref()
.map(|row| row.created_at)
.unwrap_or(updated_at),
updated_by: validated_input.admin_user_id,
updated_at,
});
Ok(build_profile_task_config_snapshot_from_row(&inserted))
}
fn disable_profile_task_config_record(
ctx: &ReducerContext,
input: RuntimeProfileTaskConfigAdminDisableInput,
) -> Result<RuntimeProfileTaskConfigSnapshot, String> {
let validated_input = build_runtime_profile_task_config_admin_disable_input(
input.admin_user_id,
input.task_id,
input.updated_at_micros,
)
.map_err(|error| error.to_string())?;
let row = ctx
.db
.profile_task_config()
.task_id()
.find(&validated_input.task_id)
.ok_or_else(|| RuntimeProfileFieldError::MissingTaskId.to_string())?;
let updated_at = Timestamp::from_micros_since_unix_epoch(validated_input.updated_at_micros);
ctx.db.profile_task_config().task_id().delete(&row.task_id);
let inserted = ctx.db.profile_task_config().insert(ProfileTaskConfig {
enabled: false,
updated_by: validated_input.admin_user_id,
updated_at,
..row
});
Ok(build_profile_task_config_snapshot_from_row(&inserted))
}
fn build_profile_task_center_snapshot(
ctx: &ReducerContext,
user_id: &str,
updated_at: Timestamp,
) -> RuntimeProfileTaskCenterSnapshot {
ensure_default_profile_task_config(ctx);
let day_key = runtime_profile_beijing_day_key(updated_at.to_micros_since_unix_epoch());
let mut configs = ctx.db.profile_task_config().iter().collect::<Vec<_>>();
configs.sort_by(|left, right| {
left.sort_order
.cmp(&right.sort_order)
.then_with(|| left.task_id.cmp(&right.task_id))
});
let tasks = configs
.into_iter()
.map(|config| {
let progress_count = profile_task_progress_count(ctx, user_id, &config);
refresh_profile_task_progress(ctx, user_id, &config, day_key);
let claim = ctx.db.profile_task_reward_claim().claim_id().find(
&build_runtime_profile_task_claim_id(user_id, &config.task_id, day_key),
);
RuntimeProfileTaskItemSnapshot {
task_id: config.task_id,
title: config.title,
description: config.description,
event_key: config.event_key,
cycle: config.cycle,
threshold: config.threshold,
progress_count,
reward_points: config.reward_points,
status: resolve_runtime_profile_task_status(
config.enabled,
progress_count,
config.threshold,
claim.is_some(),
),
day_key,
claimed_at_micros: claim.map(|row| row.claimed_at.to_micros_since_unix_epoch()),
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
}
})
.collect();
RuntimeProfileTaskCenterSnapshot {
user_id: user_id.to_string(),
day_key,
wallet_balance: profile_wallet_balance(ctx, user_id),
tasks,
updated_at_micros: updated_at.to_micros_since_unix_epoch(),
}
}
fn refresh_profile_task_progress(
ctx: &ReducerContext,
user_id: &str,
config: &ProfileTaskConfig,
day_key: i64,
) -> ProfileTaskProgress {
let progress_id = build_runtime_profile_task_progress_id(user_id, &config.task_id, day_key);
if let Some(existing) = ctx
.db
.profile_task_progress()
.progress_id()
.find(&progress_id)
{
ctx.db
.profile_task_progress()
.progress_id()
.delete(&existing.progress_id);
}
let progress_count = profile_task_progress_count(ctx, user_id, config);
let claimed = ctx
.db
.profile_task_reward_claim()
.claim_id()
.find(&build_runtime_profile_task_claim_id(
user_id,
&config.task_id,
day_key,
))
.is_some();
ctx.db.profile_task_progress().insert(ProfileTaskProgress {
progress_id,
user_id: user_id.to_string(),
task_id: config.task_id.clone(),
day_key,
progress_count,
threshold: config.threshold,
status: resolve_runtime_profile_task_status(
config.enabled,
progress_count,
config.threshold,
claimed,
),
updated_at: ctx.timestamp,
})
}
fn profile_task_progress_count(
ctx: &ReducerContext,
user_id: &str,
config: &ProfileTaskConfig,
) -> u32 {
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let scope_id = profile_task_tracking_scope_id(user_id, config);
ctx.db
.tracking_daily_stat()
.stat_id()
.find(&build_runtime_tracking_daily_stat_id(
&config.event_key,
config.scope_kind,
&scope_id,
day_key,
))
.map(|row| row.count)
.unwrap_or(0)
}
fn profile_task_tracking_scope_id(user_id: &str, config: &ProfileTaskConfig) -> String {
match config.scope_kind {
RuntimeTrackingScopeKind::Site => PROFILE_TRACKING_SITE_SCOPE_ID.to_string(),
RuntimeTrackingScopeKind::Module => PROFILE_TRACKING_PROFILE_MODULE_KEY.to_string(),
RuntimeTrackingScopeKind::User => user_id.to_string(),
RuntimeTrackingScopeKind::Work => user_id.to_string(),
}
}
fn is_daily_login_task_config(config: &ProfileTaskConfig) -> bool {
config.task_id == PROFILE_TASK_ID_DAILY_LOGIN
&& config.event_key == PROFILE_TASK_EVENT_KEY_DAILY_LOGIN
&& config.scope_kind == RuntimeTrackingScopeKind::User
}
fn record_daily_login_tracking_event(ctx: &ReducerContext, user_id: &str) -> Result<(), String> {
let day_key = runtime_profile_beijing_day_key(ctx.timestamp.to_micros_since_unix_epoch());
let event_id = format!(
"{}:{}:{}",
PROFILE_TASK_LOGIN_EVENT_ID_PREFIX,
user_id.trim(),
day_key
);
if ctx.db.tracking_event().event_id().find(&event_id).is_some() {
return Ok(());
}
record_tracking_event(
ctx,
RuntimeTrackingEventInput {
event_id,
event_key: PROFILE_TASK_EVENT_KEY_DAILY_LOGIN.to_string(),
scope_kind: RuntimeTrackingScopeKind::User,
scope_id: user_id.to_string(),
user_id: Some(user_id.to_string()),
owner_user_id: None,
profile_id: None,
module_key: Some(PROFILE_TRACKING_PROFILE_MODULE_KEY.to_string()),
metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
occurred_at_micros: ctx.timestamp.to_micros_since_unix_epoch(),
},
)
}
fn record_tracking_event(
ctx: &ReducerContext,
input: RuntimeTrackingEventInput,
) -> Result<(), String> {
let validated_input = build_runtime_tracking_event_input(
input.event_id,
input.event_key,
input.scope_kind,
input.scope_id,
input.user_id,
input.owner_user_id,
input.profile_id,
input.module_key,
input.metadata_json,
input.occurred_at_micros,
)
.map_err(|error| error.to_string())?;
let occurred_at = Timestamp::from_micros_since_unix_epoch(validated_input.occurred_at_micros);
let day_key = runtime_profile_beijing_day_key(validated_input.occurred_at_micros);
ctx.db.tracking_event().insert(TrackingEvent {
event_id: validated_input.event_id,
event_key: validated_input.event_key.clone(),
scope_kind: validated_input.scope_kind,
scope_id: validated_input.scope_id.clone(),
day_key,
user_id: validated_input.user_id,
owner_user_id: validated_input.owner_user_id,
profile_id: validated_input.profile_id,
module_key: validated_input.module_key,
metadata_json: validated_input.metadata_json,
occurred_at,
});
upsert_tracking_daily_stat(
ctx,
&validated_input.event_key,
validated_input.scope_kind,
&validated_input.scope_id,
day_key,
occurred_at,
);
Ok(())
}
fn upsert_tracking_daily_stat(
ctx: &ReducerContext,
event_key: &str,
scope_kind: RuntimeTrackingScopeKind,
scope_id: &str,
day_key: i64,
occurred_at: Timestamp,
) {
let stat_id = build_runtime_tracking_daily_stat_id(event_key, scope_kind, scope_id, day_key);
let existing = ctx.db.tracking_daily_stat().stat_id().find(&stat_id);
if let Some(row) = existing {
ctx.db.tracking_daily_stat().stat_id().delete(&row.stat_id);
ctx.db.tracking_daily_stat().insert(TrackingDailyStat {
stat_id,
event_key: row.event_key,
scope_kind: row.scope_kind,
scope_id: row.scope_id,
day_key: row.day_key,
count: row.count.saturating_add(1),
first_occurred_at: row.first_occurred_at,
last_occurred_at: occurred_at,
updated_at: occurred_at,
});
} else {
ctx.db.tracking_daily_stat().insert(TrackingDailyStat {
stat_id,
event_key: event_key.to_string(),
scope_kind,
scope_id: scope_id.to_string(),
day_key,
count: 1,
first_occurred_at: occurred_at,
last_occurred_at: occurred_at,
updated_at: occurred_at,
});
}
}
fn ensure_default_profile_task_config(ctx: &ReducerContext) -> ProfileTaskConfig {
if let Some(row) = ctx
.db
.profile_task_config()
.task_id()
.find(&PROFILE_TASK_ID_DAILY_LOGIN.to_string())
{
return row;
}
let default_config = build_default_runtime_profile_task_config(
ctx.timestamp.to_micros_since_unix_epoch(),
PROFILE_TASK_SYSTEM_USER_ID.to_string(),
);
ctx.db.profile_task_config().insert(ProfileTaskConfig {
task_id: default_config.task_id,
title: default_config.title,
description: default_config.description,
event_key: default_config.event_key,
cycle: default_config.cycle,
scope_kind: default_config.scope_kind,
threshold: default_config.threshold,
reward_points: default_config.reward_points,
enabled: default_config.enabled,
sort_order: default_config.sort_order,
created_by: default_config.created_by,
created_at: ctx.timestamp,
updated_by: default_config.updated_by,
updated_at: ctx.timestamp,
})
}
fn build_profile_membership_snapshot(
ctx: &ReducerContext,
user_id: &str,
@@ -2485,6 +3260,27 @@ fn build_profile_wallet_ledger_snapshot_from_row(
}
}
fn build_profile_task_config_snapshot_from_row(
row: &ProfileTaskConfig,
) -> RuntimeProfileTaskConfigSnapshot {
RuntimeProfileTaskConfigSnapshot {
task_id: row.task_id.clone(),
title: row.title.clone(),
description: row.description.clone(),
event_key: row.event_key.clone(),
cycle: row.cycle,
scope_kind: row.scope_kind,
threshold: row.threshold,
reward_points: row.reward_points,
enabled: row.enabled,
sort_order: row.sort_order,
created_by: row.created_by.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_by: row.updated_by.clone(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_recharge_order_snapshot_from_row(
row: &ProfileRechargeOrder,
) -> RuntimeProfileRechargeOrderSnapshot {

View File

@@ -9,7 +9,10 @@ import type {
AuthUser,
PublicUserSummary,
} from '../../../packages/shared/src/contracts/auth';
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
import type {
ProfileReferralInviteCenterResponse,
ProfileTaskCenterResponse,
} from '../../../packages/shared/src/contracts/runtime';
import { AuthUiContext } from '../auth/AuthUiContext';
import {
RpgEntryHomeView,
@@ -19,7 +22,10 @@ import type { PlatformPublicGalleryCard } from './rpgEntryWorldPresentation';
const {
mockBuildReferralCenter,
mockBuildTaskCenter,
mockClaimRpgProfileTaskReward,
mockGetRpgProfileReferralInviteCenter,
mockGetRpgProfileTasks,
mockGetRpgProfileWalletLedger,
mockRedeemRpgProfileReferralInviteCode,
} = vi.hoisted(() => {
@@ -47,12 +53,73 @@ const {
updatedAt: '2026-05-01T08:00:00Z',
...overrides,
});
const buildTaskCenter = (
overrides: Partial<ProfileTaskCenterResponse> = {},
): ProfileTaskCenterResponse => ({
dayKey: 20260503,
walletBalance: 0,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimable',
dayKey: 20260503,
claimedAt: null,
updatedAt: '2026-05-03T08:00:00Z',
},
],
updatedAt: '2026-05-03T08:00:00Z',
...overrides,
});
const buildClaimedTaskCenter = () =>
buildTaskCenter({
walletBalance: 10,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimed',
dayKey: 20260503,
claimedAt: '2026-05-03T08:01:00Z',
updatedAt: '2026-05-03T08:01:00Z',
},
],
updatedAt: '2026-05-03T08:01:00Z',
});
return {
mockBuildReferralCenter: buildReferralCenter,
mockBuildTaskCenter: buildTaskCenter,
mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
buildReferralCenter(),
),
mockGetRpgProfileTasks: vi.fn(async () => buildTaskCenter()),
mockClaimRpgProfileTaskReward: vi.fn(async () => ({
taskId: 'daily_login',
dayKey: 20260503,
rewardPoints: 10,
walletBalance: 10,
ledgerEntry: {
id: 'ledger-daily-login',
amountDelta: 10,
balanceAfter: 10,
sourceType: 'daily_task_reward',
createdAt: '2026-05-03T08:01:00Z',
},
center: buildClaimedTaskCenter(),
})),
mockRedeemRpgProfileReferralInviteCode: vi.fn(async () => ({
center: buildReferralCenter({
invitedUsers: [],
@@ -131,7 +198,9 @@ mockUpdateAuthProfile.mockResolvedValue({
vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileReferralInviteCenter: mockGetRpgProfileReferralInviteCenter,
getRpgProfileTasks: mockGetRpgProfileTasks,
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
getRpgProfileRechargeCenter: vi.fn(async () => ({
walletBalance: 0,
@@ -558,6 +627,40 @@ afterEach(() => {
mockGetRpgProfileReferralInviteCenter.mockResolvedValue(
mockBuildReferralCenter(),
);
mockGetRpgProfileTasks.mockResolvedValue(mockBuildTaskCenter());
mockClaimRpgProfileTaskReward.mockResolvedValue({
taskId: 'daily_login',
dayKey: 20260503,
rewardPoints: 10,
walletBalance: 10,
ledgerEntry: {
id: 'ledger-daily-login',
amountDelta: 10,
balanceAfter: 10,
sourceType: 'daily_task_reward',
createdAt: '2026-05-03T08:01:00Z',
},
center: mockBuildTaskCenter({
walletBalance: 10,
tasks: [
{
taskId: 'daily_login',
title: '每日登录',
description: '',
eventKey: 'profile.login.daily',
cycle: 'daily',
threshold: 1,
progressCount: 1,
rewardPoints: 10,
status: 'claimed',
dayKey: 20260503,
claimedAt: '2026-05-03T08:01:00Z',
updatedAt: '2026-05-03T08:01:00Z',
},
],
updatedAt: '2026-05-03T08:01:00Z',
}),
});
mockUpdateAuthProfile.mockResolvedValue({
id: 'user-1',
publicUserCode: '100001',
@@ -605,6 +708,31 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('+30')).toBeTruthy();
});
test('profile daily task shortcut opens task center and claims reward', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText('每日登录')).toBeTruthy();
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
expect(screen.getByText('1/1')).toBeTruthy();
expect(screen.getByText('+10')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '领取' }));
await waitFor(() => {
expect(mockClaimRpgProfileTaskReward).toHaveBeenCalledWith('daily_login');
});
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已领取 10 光点')).toBeTruthy();
expect(
(screen.getByRole('button', { name: '已领取' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
test('profile total play time card always uses hours', () => {
renderProfileView(vi.fn(), {
totalPlayTimeMs: 90 * 60 * 1000,

View File

@@ -48,6 +48,8 @@ import type {
ProfilePlayStatsResponse,
ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary,
ProfileTaskCenterResponse,
ProfileTaskItem,
ProfileWalletLedgerResponse,
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
@@ -61,7 +63,9 @@ import {
import { copyTextToClipboard } from '../../services/clipboard';
import {
getRpgProfileReferralInviteCenter,
getRpgProfileTasks,
getRpgProfileWalletLedger,
claimRpgProfileTaskReward,
redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient';
@@ -2004,6 +2008,7 @@ const WALLET_LEDGER_SOURCE_LABELS: Record<string, string> = {
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
redeem_code_reward: '兑换码奖励',
daily_task_reward: '每日任务奖励',
};
function formatWalletLedgerAmount(amountDelta: number) {
@@ -2119,6 +2124,142 @@ function WalletLedgerModal({
);
}
const PROFILE_TASK_STATUS_LABELS: Record<ProfileTaskItem['status'], string> = {
incomplete: '未完成',
claimable: '可领取',
claimed: '已领取',
disabled: '已停用',
};
function ProfileTaskCenterModal({
center,
isLoading,
error,
success,
claimingTaskId,
fallbackBalance,
onClose,
onRetry,
onClaim,
}: {
center: ProfileTaskCenterResponse | null;
isLoading: boolean;
error: string | null;
success: string | null;
claimingTaskId: string | null;
fallbackBalance: number;
onClose: () => void;
onRetry: () => void;
onClaim: (taskId: string) => void;
}) {
const tasks = center?.tasks ?? [];
const walletBalance = center?.walletBalance ?? fallbackBalance;
return (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div className="platform-recharge-modal w-full max-w-md overflow-hidden rounded-[1.4rem]">
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
<div>
<div className="text-base font-black"></div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{walletBalance}
</div>
</div>
<button
type="button"
aria-label="关闭每日任务"
onClick={onClose}
className="platform-modal-close flex h-9 w-9 items-center justify-center rounded-full"
>
×
</button>
</div>
<div className="space-y-3 px-5 py-5">
{error ? (
<div className="platform-profile-error rounded-2xl px-3 py-2 text-xs font-semibold">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="platform-primary-button mt-3 rounded-2xl px-4 py-2 text-xs font-black"
>
</button>
</div>
) : null}
{success ? (
<div className="platform-profile-success rounded-2xl px-3 py-2 text-xs font-semibold">
{success}
</div>
) : null}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, index) => (
<div
key={index}
className="h-20 animate-pulse rounded-2xl bg-white/10"
/>
))}
</div>
) : tasks.length === 0 ? (
<div className="platform-subpanel rounded-2xl px-4 py-8 text-center text-sm font-semibold text-[var(--platform-text-soft)]">
</div>
) : (
<div className="space-y-3">
{tasks.map((task) => {
const isClaimable = task.status === 'claimable';
const isClaiming = claimingTaskId === task.taskId;
const progressLabel = `${Math.min(task.progressCount, task.threshold)}/${task.threshold}`;
return (
<div
key={task.taskId}
className="platform-subpanel rounded-2xl px-4 py-4"
>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-base font-black text-[var(--platform-text-strong)]">
{task.title}
</div>
<div className="mt-1 text-xs font-semibold text-[var(--platform-text-soft)]">
{progressLabel}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
+{task.rewardPoints}
</div>
<div className="mt-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
{PROFILE_TASK_STATUS_LABELS[task.status]}
</div>
</div>
</div>
<button
type="button"
disabled={!isClaimable || Boolean(claimingTaskId)}
onClick={() => onClaim(task.taskId)}
className="platform-primary-button mt-3 w-full rounded-2xl px-4 py-2.5 text-sm font-black disabled:cursor-not-allowed disabled:opacity-50"
>
{isClaiming
? '领取中'
: task.status === 'claimed'
? '已领取'
: isClaimable
? '领取'
: '未完成'}
</button>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function RewardCodeRedeemModal({
value,
isSubmitting,
@@ -2528,6 +2669,14 @@ export function RpgEntryHomeView({
null,
);
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false);
const [taskCenter, setTaskCenter] = useState<ProfileTaskCenterResponse | null>(
null,
);
const [taskCenterError, setTaskCenterError] = useState<string | null>(null);
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
@@ -2961,6 +3110,24 @@ export function RpgEntryHomeView({
setIsWalletLedgerOpen(true);
loadWalletLedger();
};
const loadTaskCenter = () => {
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
void getRpgProfileTasks()
.then(setTaskCenter)
.catch((error: unknown) => {
setTaskCenter(null);
setTaskCenterError(
error instanceof Error ? error.message : '读取每日任务失败',
);
})
.finally(() => setIsLoadingTaskCenter(false));
};
const openTaskCenterPanel = () => {
setIsTaskCenterOpen(true);
setTaskClaimSuccess(null);
loadTaskCenter();
};
const loadReferralCenter = useCallback(() => {
setIsLoadingReferral(true);
setIsReferralCenterInitialized(false);
@@ -3070,6 +3237,27 @@ export function RpgEntryHomeView({
})
.finally(() => setIsSubmittingRewardCode(false));
};
const claimTaskReward = (taskId: string) => {
if (claimingTaskId) {
return;
}
setClaimingTaskId(taskId);
setTaskCenterError(null);
setTaskClaimSuccess(null);
void claimRpgProfileTaskReward(taskId)
.then((response) => {
setTaskCenter(response.center);
setTaskClaimSuccess(`已领取 ${response.rewardPoints} 光点`);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setTaskCenterError(
error instanceof Error ? error.message : '领取任务奖励失败',
);
})
.finally(() => setClaimingTaskId(null));
};
const clearWorkSearch = () => {
setActiveWorkSearchKeyword('');
setDesktopSearchKeyword('');
@@ -3714,6 +3902,17 @@ export function RpgEntryHomeView({
aria-label="常用功能"
>
<div className="grid grid-cols-2 gap-3">
<ProfileShortcutButton
label="每日任务"
subLabel={
<>
<span>10</span>
<Coins className="h-3 w-3" />
</>
}
icon={Star}
onClick={openTaskCenterPanel}
/>
<ProfileShortcutButton
label="邀请好友"
subLabel={
@@ -4197,6 +4396,19 @@ export function RpgEntryHomeView({
/>
) : null}
{rewardCodeModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}
isLoading={isLoadingTaskCenter}
error={taskCenterError}
success={taskClaimSuccess}
claimingTaskId={claimingTaskId}
fallbackBalance={remainingNarrativeCoins}
onClose={() => setIsTaskCenterOpen(false)}
onRetry={loadTaskCenter}
onClaim={claimTaskReward}
/>
) : null}
{isProfilePlayStatsOpen ? (
<ProfilePlayedWorksModal
stats={profilePlayStats}
@@ -4303,6 +4515,19 @@ export function RpgEntryHomeView({
</div>
</div>
{rewardCodeModal}
{isTaskCenterOpen ? (
<ProfileTaskCenterModal
center={taskCenter}
isLoading={isLoadingTaskCenter}
error={taskCenterError}
success={taskClaimSuccess}
claimingTaskId={claimingTaskId}
fallbackBalance={remainingNarrativeCoins}
onClose={() => setIsTaskCenterOpen(false)}
onRetry={loadTaskCenter}
onClaim={claimTaskReward}
/>
) : null}
{profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}

View File

@@ -21,4 +21,22 @@ describe('vite dev api proxy', () => {
}),
);
});
it('forwards the admin route to the admin dev server', async () => {
const resolvedConfig =
typeof viteConfig === 'function'
? await viteConfig({ command: 'serve', mode: 'test' })
: viteConfig;
// 中文注释:本地完整栈需要能从主站 `/admin/` 进入后台,贴近生产同域部署形态。
expect(resolvedConfig.server?.proxy).toEqual(
expect.objectContaining({
'/admin/': expect.objectContaining({
target: expect.stringContaining(':3102'),
changeOrigin: true,
secure: false,
}),
}),
);
});
});

View File

@@ -1,5 +1,6 @@
import type {
CreateProfileRechargeOrderResponse,
ClaimProfileTaskRewardResponse,
PlatformBrowseHistoryBatchSyncRequest,
PlatformBrowseHistoryResponse,
PlatformBrowseHistoryWriteEntry,
@@ -9,6 +10,7 @@ import type {
ProfileRechargeCenterResponse,
ProfileSaveArchiveListResponse,
ProfileSaveArchiveResumeResponse,
ProfileTaskCenterResponse,
ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse,
@@ -142,6 +144,27 @@ export function redeemRpgProfileRewardCode(
);
}
export function getRpgProfileTasks(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfileTaskCenterResponse>(
'/profile/tasks',
{ method: 'GET' },
'读取每日任务失败',
options,
);
}
export function claimRpgProfileTaskReward(
taskId: string,
options: RuntimeRequestOptions = {},
) {
return requestRpgRuntimeJson<ClaimProfileTaskRewardResponse>(
`/profile/tasks/${encodeURIComponent(taskId)}/claim`,
{ method: 'POST' },
'领取任务奖励失败',
options,
);
}
export function getRpgProfilePlayStats(options: RuntimeRequestOptions = {}) {
return requestRpgRuntimeJson<ProfilePlayStatsResponse>(
'/profile/play-stats',
@@ -255,6 +278,8 @@ export const rpgProfileClient = {
createRechargeOrder: createRpgProfileRechargeOrder,
getReferralInviteCenter: getRpgProfileReferralInviteCenter,
redeemReferralInviteCode: redeemRpgProfileReferralInviteCode,
getTasks: getRpgProfileTasks,
claimTaskReward: claimRpgProfileTaskReward,
getSettings: getRpgProfileSettings,
putSettings: putRpgProfileSettings,
listSaveArchives: listRpgProfileSaveArchives,

View File

@@ -33,6 +33,9 @@ export default defineConfig(({mode}) => {
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
const runtimeServerTarget =
env.GENARRATIVE_RUNTIME_SERVER_TARGET || rustServerTarget;
const adminWebTarget =
env.ADMIN_WEB_TARGET ||
`http://127.0.0.1:${env.ADMIN_WEB_PORT || '3102'}`;
return {
root: __dirname,
@@ -62,6 +65,11 @@ export default defineConfig(({mode}) => {
// Do not modify; file watching is disabled to prevent flickering during agent edits.
hmr: process.env.DISABLE_HMR !== 'true',
proxy: {
'/admin/': {
target: adminWebTarget,
changeOrigin: true,
secure: false,
},
'/api/auth': {
target: runtimeServerTarget,
changeOrigin: true,