Add skill for gameplay entry type workflows

This commit is contained in:
2026-05-04 02:32:38 +08:00
parent 49aad7311c
commit 34aecdddf1
77 changed files with 5997 additions and 110 deletions
@@ -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."
+2 -1
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`
构建生产包:
+52
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(/\/+$/, '');
}
+48
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[];
}
+14
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>
);
}
+2
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({
+2 -1
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 {
@@ -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);
});
}
+139 -37
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>
);
@@ -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;
}
File diff suppressed because it is too large Load Diff
+119 -1
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));
+2
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: {
+4
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/`:偏后台运营核查、对账和排障查询。
@@ -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` 中查到。
+5
View File
@@ -0,0 +1,5 @@
# 运营查询
本目录存放后台运营核查、对账和排障查询,不承载埋点系统本身的查询手册。
- [PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md](./PROFILE_TASK_QUERY_PLAYBOOK_2026-05-03.md):个人任务配置、进度、领奖记录与钱包流水对账查询。
@@ -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 后端边界
@@ -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 脚本。
@@ -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 契约和前端入口同步更新。
+1
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 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。
+63 -3
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
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/`
@@ -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"
```
+116 -1
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;
+33 -1
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

Some files were not shown because too many files have changed in this diff Show More