This commit is contained in:
2026-05-01 01:53:14 +08:00
69 changed files with 7346 additions and 759 deletions

View File

@@ -50,6 +50,15 @@ ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS"
RUST_SERVER_TARGET="http://127.0.0.1:3100" RUST_SERVER_TARGET="http://127.0.0.1:3100"
GENARRATIVE_API_TARGET="http://127.0.0.1:3100" GENARRATIVE_API_TARGET="http://127.0.0.1:3100"
GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101"
GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr"
GENARRATIVE_SPACETIME_TOKEN=""
GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com" GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com"
GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr" GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr"
GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUtQRzVXQlhTTjVCRDAyTjBNSlNONFJCNyIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NjUxMjAyMiwiZXhwIjoxODM5NTg0MDIyfQ.UguSQDajalekrqs9oiUqLZiWjWK7VTgMQfdLVOhBQZpKX0VYUhNMSok9oBMJ4X655_NxV5TUUXZ4ON4HSJZrMMPc9aZyhS1b3i36vqI_zMPwLrAgfb1MqY5o0wNFl6Y0m0UQ3nsu7ZYxmxxgzF4My7So0Pv75QfXFS3-Uq1-QyO7lCxxgQ6vySbP_PEr7FZJsdPkNvAfP7mTaUh0yaV6SI7jXBsZ_mfdcWtElCNuvR9J3hvfAbx1qyeTgCJtgH4kNhiEOEIAYEFMEQkXd4rdLszmEgtlFubYZPsbMgqeZKx73feU6eGxlYhyPiRHF4AdosIfk3x2MAm_WzOd3efXDQ" GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUsyN05YUjBaQkRUVEVCNlFQQjFXNzU2MiIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NzU1NTQ1NiwiZXhwIjoxODQwNjI3NDU2fQ.iy5qN-3lGPQnkya-wsABtqEgRk1VM2XGxTfxuLV5-eTMfX8cR20sWSx7pnoZcLEwYOkz6cEOb4krhMJmTeBax9Z114o_iwISau3wjjHbeKL9or-039zfYfKb3TtJo3_DZaJSu-ECcMZNl4P1zLmtoRSwl-_AMET4sGzPw0_qR-e49_QGDJz1EEhr7aphybl1xCejCebM8XiJjaRz48vL7-lkwBl90uP-0h7Xx8ToTT2h1egmlcYAvaJalVLHIQqzyYxPUT_Zw9TW7VYExZLhJWdGpQzEm0aXZ2fbch9qVrKpZP2xQ9YjppuLxUFFJeQwhmFf6yc67s6J7LqNvL2-ZA"
# admin
GENARRATIVE_ADMIN_USERNAME=admin
GENARRATIVE_ADMIN_PASSWORD=123456
ADMIN_API_TARGET=http://127.0.0.1:8082

12
apps/admin-web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>陶泥后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,24 @@
{
"name": "@genarrative/admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@vitejs/plugin-react": "^5.0.4",
"lucide-react": "^0.546.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"vite": "^6.2.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "~5.8.2"
}
}

View File

@@ -0,0 +1,238 @@
import type {
AdminDebugHttpRequest,
AdminDebugHttpResponse,
AdminDisableProfileRedeemCodeRequest,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRedeemCodeRequest,
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
ProfileInviteCodeAdminResponse,
ProfileRedeemCodeAdminResponse,
} from './adminApiTypes';
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
const ADMIN_API_BASE_URL = normalizeBaseUrl(
import.meta.env.VITE_ADMIN_API_BASE_URL ?? '',
);
interface AdminRequestOptions {
method?: string;
token?: string;
body?: unknown;
headers?: Record<string, string>;
signal?: AbortSignal;
}
export class AdminApiError extends Error {
status: number;
code: string;
details: Record<string, unknown> | null;
meta: ApiMeta | null;
responseText: string;
constructor(params: {
message: string;
status: number;
code?: string;
details?: Record<string, unknown> | null;
meta?: ApiMeta | null;
responseText?: string;
}) {
super(params.message);
this.name = 'AdminApiError';
this.status = params.status;
this.code = params.code ?? 'ADMIN_API_ERROR';
this.details = params.details ?? null;
this.meta = params.meta ?? null;
this.responseText = params.responseText ?? '';
}
}
export function isAdminApiError(error: unknown): error is AdminApiError {
return error instanceof AdminApiError;
}
export function formatAdminApiError(error: unknown) {
if (isAdminApiError(error)) {
return error.message;
}
if (error instanceof Error && error.message.trim()) {
return error.message;
}
return '请求失败';
}
export async function request<T>(
path: string,
options: AdminRequestOptions = {},
): Promise<T> {
const method = options.method ?? 'GET';
const headers: Record<string, string> = {
Accept: 'application/json',
[API_RESPONSE_ENVELOPE_HEADER]: 'v1',
...(options.headers ?? {}),
};
const token = options.token?.trim();
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const init: RequestInit = {
method,
headers,
signal: options.signal,
};
if (typeof options.body !== 'undefined') {
headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(options.body);
}
const response = await fetch(buildRequestUrl(path), init);
const responseText = await response.text();
const payload = parseJsonResponse(responseText);
if (!response.ok) {
throw buildAdminApiError(response, payload, responseText);
}
return unwrapSuccessPayload<T>(payload);
}
export function loginAdmin(username: string, password: string) {
return request<AdminLoginResponse>('/admin/api/login', {
method: 'POST',
body: {username, password},
});
}
export function getAdminMe(token: string) {
return request<AdminMeResponse>('/admin/api/me', {token});
}
export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', {token});
}
export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
return request<AdminDebugHttpResponse>('/admin/api/debug/http', {
method: 'POST',
token,
body: payload,
});
}
export function upsertProfileRedeemCode(
token: string,
payload: AdminUpsertProfileRedeemCodeRequest,
) {
return request<ProfileRedeemCodeAdminResponse>(
'/admin/api/profile/redeem-codes',
{
method: 'POST',
token,
body: payload,
},
);
}
export function upsertProfileInviteCode(
token: string,
payload: AdminUpsertProfileInviteCodeRequest,
) {
return request<ProfileInviteCodeAdminResponse>(
'/admin/api/profile/invite-codes',
{
method: 'POST',
token,
body: payload,
},
);
}
export function disableProfileRedeemCode(
token: string,
payload: AdminDisableProfileRedeemCodeRequest,
) {
return request<ProfileRedeemCodeAdminResponse>(
'/admin/api/profile/redeem-codes/disable',
{
method: 'POST',
token,
body: payload,
},
);
}
function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
function buildRequestUrl(path: string) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${ADMIN_API_BASE_URL}${normalizedPath}`;
}
function parseJsonResponse(responseText: string): unknown {
if (!responseText.trim()) {
return null;
}
try {
return JSON.parse(responseText) as unknown;
} catch {
return null;
}
}
function unwrapSuccessPayload<T>(payload: unknown): T {
if (isRecord(payload) && 'data' in payload) {
return (payload as ApiSuccessEnvelope<T>).data as T;
}
return payload as T;
}
function buildAdminApiError(
response: Response,
payload: unknown,
responseText: string,
) {
const envelope = isRecord(payload) ? (payload as ApiErrorEnvelope) : null;
const errorPayload = envelope?.error;
const details = isRecord(errorPayload?.details)
? errorPayload.details
: null;
const detailsMessage =
typeof details?.message === 'string' ? details.message.trim() : '';
const payloadMessage =
typeof errorPayload?.message === 'string' ? errorPayload.message.trim() : '';
const topLevelMessage =
typeof envelope?.message === 'string' ? envelope.message.trim() : '';
const message =
detailsMessage ||
payloadMessage ||
topLevelMessage ||
response.statusText ||
`HTTP ${response.status}`;
return new AdminApiError({
message,
status: response.status,
code: errorPayload?.code,
details,
meta: envelope?.meta ?? null,
responseText,
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,148 @@
export interface ApiMeta {
apiVersion?: string;
requestId?: string;
routeVersion?: string;
operation?: string;
latencyMs?: number;
timestamp?: string;
}
export interface ApiErrorPayload {
code?: string;
message?: string;
details?: {
message?: string;
[key: string]: unknown;
} | null;
}
export interface ApiSuccessEnvelope<T> {
ok?: boolean;
data?: T;
error?: ApiErrorPayload | null;
meta?: ApiMeta;
}
export interface ApiErrorEnvelope {
ok?: boolean;
data?: unknown;
error?: ApiErrorPayload;
meta?: ApiMeta;
message?: string;
}
export interface AdminSessionPayload {
subject: string;
username: string;
displayName: string;
roles: string[];
issuedAt: string;
expiresAt: string;
}
export interface AdminLoginResponse {
token: string;
admin: AdminSessionPayload;
}
export interface AdminMeResponse {
admin: AdminSessionPayload;
}
export interface AdminOverviewResponse {
service: AdminServiceOverviewPayload;
database: AdminDatabaseOverviewPayload;
}
export interface AdminServiceOverviewPayload {
bindHost: string;
bindPort: number;
jwtIssuer: string;
adminEnabled: boolean;
spacetimeServerUrl: string;
spacetimeDatabase: string;
}
export interface AdminDatabaseOverviewPayload {
databaseIdentity: string | null;
ownerIdentity: string | null;
hostType: string | null;
schemaTableNames: string[];
tableStats: AdminDatabaseTableStatPayload[];
fetchErrors: string[];
}
export interface AdminDatabaseTableStatPayload {
tableName: string;
rowCount: number | null;
errorMessage: string | null;
}
export interface AdminDebugHeaderInput {
name: string;
value: string;
}
export type AdminDebugHttpMethod =
| 'GET'
| 'POST'
| 'PUT'
| 'PATCH'
| 'DELETE';
export interface AdminDebugHttpRequest {
method: AdminDebugHttpMethod;
path: string;
headers?: AdminDebugHeaderInput[];
body?: string;
}
export interface AdminDebugHttpResponse {
status: number;
statusText: string;
headers: AdminDebugHeaderInput[];
bodyText: string;
bodyJson: unknown | null;
}
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
enabled: boolean;
allowedUserIds: string[];
allowedPublicUserCodes: string[];
}
export interface AdminUpsertProfileInviteCodeRequest {
inviteCode: string;
metadata?: Record<string, unknown>;
}
export interface AdminDisableProfileRedeemCodeRequest {
code: string;
}
export interface ProfileRedeemCodeAdminResponse {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
globalUsedCount: number;
enabled: boolean;
allowedUserIds: string[];
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,177 @@
import {useCallback, useEffect, useState} from 'react';
import {
formatAdminApiError,
getAdminMe,
isAdminApiError,
loginAdmin,
} from '../api/adminApiClient';
import type {
AdminSessionPayload,
ProfileInviteCodeAdminResponse,
ProfileRedeemCodeAdminResponse,
} from '../api/adminApiTypes';
import {
clearStoredAdminToken,
getStoredAdminToken,
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminShell} from './AdminShell';
import type {AdminRouteId} from './adminRoutes';
import {resolveAdminRoute, routeHash} from './adminRoutes';
type SessionStatus = 'checking' | 'guest' | 'authenticated';
export function AdminApp() {
const [status, setStatus] = useState<SessionStatus>('checking');
const [admin, setAdmin] = useState<AdminSessionPayload | null>(null);
const [token, setToken] = useState('');
const [routeId, setRouteId] = useState<AdminRouteId>(() =>
resolveAdminRoute(window.location.hash),
);
const [loginNotice, setLoginNotice] = useState('');
// 兑换码页会随页签切换卸载,最近操作记录需要放在会话层保留。
const [redeemResult, setRedeemResult] =
useState<ProfileRedeemCodeAdminResponse | null>(null);
const [inviteResult, setInviteResult] =
useState<ProfileInviteCodeAdminResponse | null>(null);
const clearSession = useCallback((message = '') => {
clearStoredAdminToken();
setToken('');
setAdmin(null);
setRedeemResult(null);
setInviteResult(null);
setStatus('guest');
setLoginNotice(message);
}, []);
useEffect(() => {
let isMounted = true;
const storedToken = getStoredAdminToken();
if (!storedToken) {
setStatus('guest');
return;
}
void getAdminMe(storedToken)
.then((response) => {
if (!isMounted) {
return;
}
setToken(storedToken);
setAdmin(response.admin);
setStatus('authenticated');
})
.catch((error: unknown) => {
if (!isMounted) {
return;
}
clearStoredAdminToken();
setToken('');
setAdmin(null);
setStatus('guest');
setLoginNotice(
isAdminApiError(error) && error.status === 401
? '登录状态已失效'
: formatAdminApiError(error),
);
});
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
const handleHashChange = () => {
setRouteId(resolveAdminRoute(window.location.hash));
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
const handleRouteChange = useCallback((nextRouteId: AdminRouteId) => {
setRouteId(nextRouteId);
const nextHash = routeHash(nextRouteId);
if (window.location.hash !== nextHash) {
window.location.hash = nextHash;
}
}, []);
const handleLogin = useCallback(async (username: string, password: string) => {
const response = await loginAdmin(username, password);
setStoredAdminToken(response.token);
setToken(response.token);
setAdmin(response.admin);
setRedeemResult(null);
setInviteResult(null);
setLoginNotice('');
setStatus('authenticated');
}, []);
const handleUnauthorized = useCallback(
(message = '登录状态已失效') => {
clearSession(message);
},
[clearSession],
);
const handleLogout = useCallback(() => {
clearSession('');
}, [clearSession]);
if (status === 'checking') {
return (
<main className="admin-loading-screen">
<div className="admin-loading-mark" />
<span></span>
</main>
);
}
if (status === 'guest' || !admin || !token) {
return <AdminLoginPage notice={loginNotice} onLogin={handleLogin} />;
}
return (
<AdminShell
admin={admin}
routeId={routeId}
onLogout={handleLogout}
onRouteChange={handleRouteChange}
>
{routeId === 'overview' ? (
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'debug' ? (
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'redeem' ? (
<AdminRedeemCodePage
result={redeemResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setRedeemResult}
/>
) : null}
{routeId === 'invite' ? (
<AdminInviteCodePage
result={inviteResult}
token={token}
onUnauthorized={handleUnauthorized}
onResultChange={setInviteResult}
/>
) : null}
</AdminShell>
);
}

View File

@@ -0,0 +1,110 @@
import {
Bug,
LayoutDashboard,
LogOut,
ShieldCheck,
TicketCheck,
TicketPercent,
} from 'lucide-react';
import type {ReactNode} from 'react';
import type {AdminSessionPayload} from '../api/adminApiTypes';
import type {AdminRouteId} from './adminRoutes';
import {adminRoutes} from './adminRoutes';
interface AdminShellProps {
admin: AdminSessionPayload;
routeId: AdminRouteId;
children: ReactNode;
onRouteChange: (routeId: AdminRouteId) => void;
onLogout: () => void;
}
const routeIcons = {
overview: LayoutDashboard,
debug: Bug,
redeem: TicketPercent,
invite: TicketCheck,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
admin,
routeId,
children,
onRouteChange,
onLogout,
}: AdminShellProps) {
return (
<div className="admin-shell">
<aside className="admin-sidebar">
<div className="admin-brand">
<div className="admin-brand-icon">
<ShieldCheck size={20} aria-hidden="true" />
</div>
<div>
<strong></strong>
<span>Admin</span>
</div>
</div>
<nav className="admin-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
className="admin-nav-button"
data-active={route.id === routeId}
key={route.id}
title={route.label}
type="button"
onClick={() => onRouteChange(route.id)}
>
<Icon size={18} aria-hidden="true" />
<span>{route.label}</span>
</button>
);
})}
</nav>
</aside>
<div className="admin-main">
<header className="admin-topbar">
<div className="admin-user">
<span>{admin.displayName || admin.username}</span>
<small>{admin.roles.join(' / ')}</small>
</div>
<button
className="admin-icon-button"
title="退出登录"
type="button"
onClick={onLogout}
>
<LogOut size={18} aria-hidden="true" />
<span>退</span>
</button>
</header>
<main className="admin-content">{children}</main>
</div>
<nav className="admin-bottom-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
className="admin-bottom-nav-button"
data-active={route.id === routeId}
key={route.id}
title={route.label}
type="button"
onClick={() => onRouteChange(route.id)}
>
<Icon size={19} aria-hidden="true" />
<span>{route.label}</span>
</button>
);
})}
</nav>
</div>
);
}

View File

@@ -0,0 +1,30 @@
export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite';
export interface AdminRouteDefinition {
id: AdminRouteId;
label: string;
hash: string;
}
export const adminRoutes: AdminRouteDefinition[] = [
{id: 'overview', label: '总览', hash: '#overview'},
{id: 'debug', label: 'API 调试', hash: '#debug'},
{id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'},
];
export function resolveAdminRoute(hash: string): AdminRouteId {
const normalizedHash = hash.trim().toLowerCase();
return (
adminRoutes.find((route) => route.hash === normalizedHash)?.id ??
'overview'
);
}
export function routeHash(routeId: AdminRouteId) {
return (
adminRoutes.find((route) => route.id === routeId)?.hash ??
adminRoutes[0]?.hash ??
'#overview'
);
}

View File

@@ -0,0 +1,34 @@
export const ADMIN_TOKEN_STORAGE_KEY = 'genarrative_admin_token';
// 管理员 token 与玩家 token 分开保存,避免后台请求误复用玩家登录态。
export function getStoredAdminToken() {
if (!canUseLocalStorage()) {
return '';
}
return window.localStorage.getItem(ADMIN_TOKEN_STORAGE_KEY)?.trim() || '';
}
export function setStoredAdminToken(token: string) {
if (!canUseLocalStorage()) {
return;
}
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ADMIN_TOKEN_STORAGE_KEY, nextToken);
return;
}
window.localStorage.removeItem(ADMIN_TOKEN_STORAGE_KEY);
}
export function clearStoredAdminToken() {
setStoredAdminToken('');
}
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}

View File

@@ -0,0 +1,18 @@
import './styles/admin.css';
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {AdminApp} from './app/AdminApp';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Missing #root container');
}
createRoot(rootElement).render(
<StrictMode>
<AdminApp />
</StrictMode>,
);

View File

@@ -0,0 +1,214 @@
import {Plus, Send, Trash2} from 'lucide-react';
import {FormEvent, useMemo, useState} from 'react';
import {debugAdminHttp} from '../api/adminApiClient';
import type {
AdminDebugHeaderInput,
AdminDebugHttpMethod,
AdminDebugHttpResponse,
} from '../api/adminApiTypes';
import {formatUnknownJson, handlePageError} from './pageUtils';
interface AdminDebugHttpPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const httpMethods: AdminDebugHttpMethod[] = [
'GET',
'POST',
'PUT',
'PATCH',
'DELETE',
];
export function AdminDebugHttpPage({
token,
onUnauthorized,
}: AdminDebugHttpPageProps) {
const [method, setMethod] = useState<AdminDebugHttpMethod>('GET');
const [path, setPath] = useState('/healthz');
const [body, setBody] = useState('');
const [headers, setHeaders] = useState<AdminDebugHeaderInput[]>([]);
const [result, setResult] = useState<AdminDebugHttpResponse | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const jsonPreview = useMemo(
() => formatUnknownJson(result?.bodyJson),
[result?.bodyJson],
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSubmitting) {
return;
}
setErrorMessage('');
setIsSubmitting(true);
try {
const response = await debugAdminHttp(token, {
method,
path: path.trim(),
headers: headers.filter((header) => header.name.trim()),
body,
});
setResult(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSubmitting(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2>API </h2>
<p></p>
</div>
</div>
<div className="admin-two-column">
<form className="admin-panel admin-form" onSubmit={handleSubmit}>
<div className="admin-form-row">
<label className="admin-field admin-field-compact">
<span>Method</span>
<select
value={method}
onChange={(event) =>
setMethod(event.target.value as AdminDebugHttpMethod)
}
>
{httpMethods.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</label>
<label className="admin-field admin-field-fill">
<span>Path</span>
<input
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
</div>
<section className="admin-subsection">
<div className="admin-subsection-heading">
<span>Headers</span>
<button
className="admin-ghost-button"
type="button"
onClick={() =>
setHeaders((current) => [
...current,
{name: '', value: ''},
])
}
>
<Plus size={16} aria-hidden="true" />
</button>
</div>
<div className="admin-header-editor">
{headers.map((header, index) => (
<div className="admin-header-row" key={index}>
<input
value={header.name}
onChange={(event) =>
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? {...item, name: event.target.value}
: item,
),
)
}
/>
<input
value={header.value}
onChange={(event) =>
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? {...item, value: event.target.value}
: item,
),
)
}
/>
<button
className="admin-ghost-button"
title="移除"
type="button"
onClick={() =>
setHeaders((current) =>
current.filter((_, itemIndex) => itemIndex !== index),
)
}
>
<Trash2 size={16} aria-hidden="true" />
</button>
</div>
))}
</div>
</section>
<label className="admin-field">
<span>Body</span>
<textarea
rows={9}
value={body}
onChange={(event) => setBody(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSubmitting || !path.trim().startsWith('/')}
type="submit"
>
<Send size={17} aria-hidden="true" />
<span>{isSubmitting ? '发送中' : '发送'}</span>
</button>
</form>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{result ? `${result.status} ${result.statusText}` : '-'}</span>
</div>
{result ? (
<>
<dl className="admin-info-list">
<div>
<dt>Status</dt>
<dd>{result.status}</dd>
</div>
<div>
<dt>Headers</dt>
<dd>{result.headers.length}</dd>
</div>
</dl>
<pre className="admin-code-block">
{jsonPreview || result.bodyText || '(empty)'}
</pre>
</>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</div>
</section>
);
}

View File

@@ -0,0 +1,158 @@
import {Save} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {upsertProfileInviteCode} from '../api/adminApiClient';
import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
interface AdminInviteCodePageProps {
token: string;
result: ProfileInviteCodeAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileInviteCodeAdminResponse) => void;
}
export function AdminInviteCodePage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminInviteCodePageProps) {
const [inviteCode, setInviteCode] = useState('');
const [metadataText, setMetadataText] = useState('{}');
const [errorMessage, setErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
setIsSaving(true);
try {
const response = await upsertProfileInviteCode(token, {
inviteCode: inviteCode.trim(),
metadata: parseMetadata(metadataText),
});
onResultChange(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
</div>
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<label className="admin-field">
<span>Invite Code</span>
<input
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
/>
</label>
<label className="admin-field">
<span>Metadata JSON</span>
<textarea
rows={10}
spellCheck={false}
value={metadataText}
onChange={(event) => setMetadataText(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !inviteCode.trim() || !isMetadataReady(metadataText)}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</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>
<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>
</section>
);
}
function parseMetadata(value: string): Record<string, unknown> {
const trimmed = value.trim();
if (!trimmed) {
return {};
}
const parsed = JSON.parse(trimmed) as unknown;
if (!isRecord(parsed)) {
throw new Error('Metadata 必须是 JSON 对象');
}
return parsed;
}
function isMetadataReady(value: string) {
try {
parseMetadata(value);
return true;
} catch {
return false;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View File

@@ -0,0 +1,83 @@
import {LockKeyhole, ShieldCheck} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {formatAdminApiError} from '../api/adminApiClient';
interface AdminLoginPageProps {
notice: string;
onLogin: (username: string, password: string) => Promise<void>;
}
export function AdminLoginPage({notice, onLogin}: AdminLoginPageProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSubmitting) {
return;
}
setErrorMessage('');
setIsSubmitting(true);
try {
await onLogin(username.trim(), password);
} catch (error: unknown) {
setErrorMessage(formatAdminApiError(error));
} finally {
setIsSubmitting(false);
}
}
return (
<main className="admin-login-screen">
<form className="admin-login-panel" onSubmit={handleSubmit}>
<div className="admin-login-brand">
<div className="admin-brand-icon admin-brand-icon-large">
<ShieldCheck size={26} aria-hidden="true" />
</div>
<div>
<h1></h1>
<span>Admin Console</span>
</div>
</div>
<label className="admin-field">
<span></span>
<input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
{notice || errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage || notice}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSubmitting || !username.trim() || !password}
type="submit"
>
<LockKeyhole size={18} aria-hidden="true" />
<span>{isSubmitting ? '登录中' : '登录'}</span>
</button>
</form>
</main>
);
}

View File

@@ -0,0 +1,171 @@
import {RefreshCw} from 'lucide-react';
import {useCallback, useEffect, useState} from 'react';
import {getAdminOverview} from '../api/adminApiClient';
import type {
AdminDatabaseTableStatPayload,
AdminOverviewResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
interface AdminOverviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
export function AdminOverviewPage({
token,
onUnauthorized,
}: AdminOverviewPageProps) {
const [overview, setOverview] = useState<AdminOverviewResponse | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const loadOverview = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminOverview(token);
setOverview(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [onUnauthorized, token]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
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={() => void loadOverview()}
>
<RefreshCw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-overview-grid">
<InfoPanel
title="服务"
rows={[
['监听', overview ? `${overview.service.bindHost}:${overview.service.bindPort}` : '-'],
['JWT issuer', overview?.service.jwtIssuer ?? '-'],
['后台', overview?.service.adminEnabled ? '已启用' : '未启用'],
]}
/>
<InfoPanel
title="SpacetimeDB"
rows={[
['Server', overview?.service.spacetimeServerUrl ?? '-'],
['Database', overview?.service.spacetimeDatabase ?? '-'],
['Identity', overview?.database.databaseIdentity ?? '-'],
['Owner', overview?.database.ownerIdentity ?? '-'],
['Host', overview?.database.hostType ?? '-'],
]}
/>
</div>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{overview?.database.schemaTableNames.length ?? 0} tables</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{(overview?.database.tableStats ?? []).map((stat) => (
<TableStatRow key={stat.tableName} stat={stat} />
))}
{overview && overview.database.tableStats.length === 0 ? (
<tr>
<td colSpan={3}></td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
{overview?.database.fetchErrors.length ? (
<section className="admin-panel admin-panel-warning">
<div className="admin-panel-heading">
<h3></h3>
<span>{overview.database.fetchErrors.length}</span>
</div>
<ul className="admin-error-list">
{overview.database.fetchErrors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</section>
) : null}
</section>
);
}
function InfoPanel({
title,
rows,
}: {
title: string;
rows: Array<[string, string]>;
}) {
return (
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>{title}</h3>
</div>
<dl className="admin-info-list">
{rows.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</section>
);
}
function TableStatRow({stat}: {stat: AdminDatabaseTableStatPayload}) {
return (
<tr>
<td>{stat.tableName}</td>
<td>{typeof stat.rowCount === 'number' ? stat.rowCount : '-'}</td>
<td>
{stat.errorMessage ? (
<span className="admin-status admin-status-error">
{stat.errorMessage}
</span>
) : (
<span className="admin-status admin-status-ok">OK</span>
)}
</td>
</tr>
);
}

View File

@@ -0,0 +1,275 @@
import {PowerOff, Save} from 'lucide-react';
import {FormEvent, useState} from 'react';
import {
disableProfileRedeemCode,
upsertProfileRedeemCode,
} from '../api/adminApiClient';
import type {
ProfileRedeemCodeAdminResponse,
ProfileRedeemCodeMode,
} from '../api/adminApiTypes';
import {handlePageError, splitLines} from './pageUtils';
interface AdminRedeemCodePageProps {
token: string;
result: ProfileRedeemCodeAdminResponse | null;
onUnauthorized: (message?: string) => void;
onResultChange: (result: ProfileRedeemCodeAdminResponse) => void;
}
const redeemModes: Array<{value: ProfileRedeemCodeMode; label: string}> = [
{value: 'public', label: '公共码'},
{value: 'unique', label: '唯一码'},
{value: 'private', label: '私有码'},
];
export function AdminRedeemCodePage({
token,
result,
onUnauthorized,
onResultChange,
}: AdminRedeemCodePageProps) {
const [code, setCode] = useState('');
const [mode, setMode] = useState<ProfileRedeemCodeMode>('public');
const [rewardPoints, setRewardPoints] = useState('100');
const [maxUses, setMaxUses] = useState('1');
const [enabled, setEnabled] = useState(true);
const [allowedUserIds, setAllowedUserIds] = useState('');
const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState('');
const [disableCode, setDisableCode] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
setErrorMessage('');
setIsSaving(true);
try {
const response = await upsertProfileRedeemCode(token, {
code: code.trim(),
mode,
rewardPoints: parsePositiveInteger(rewardPoints),
maxUses: parsePositiveInteger(maxUses),
enabled,
allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [],
allowedPublicUserCodes:
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
});
onResultChange(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 disableProfileRedeemCode(token, {
code: disableCode.trim(),
});
onResultChange(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setDisableErrorMessage);
} finally {
setIsDisabling(false);
}
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
</div>
<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>Code</span>
<input
value={code}
onChange={(event) => setCode(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-segmented-control" role="tablist">
{redeemModes.map((item) => (
<button
data-active={mode === item.value}
key={item.value}
type="button"
onClick={() => setMode(item.value)}
>
{item.label}
</button>
))}
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={rewardPoints}
onChange={(event) => setRewardPoints(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={maxUses}
onChange={(event) => setMaxUses(event.target.value)}
/>
</label>
</div>
{mode === 'private' ? (
<div className="admin-form-row">
<label className="admin-field">
<span> userId</span>
<textarea
rows={6}
value={allowedUserIds}
onChange={(event) => setAllowedUserIds(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<textarea
rows={6}
value={allowedPublicUserCodes}
onChange={(event) =>
setAllowedPublicUserCodes(event.target.value)
}
/>
</label>
</div>
) : null}
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={
isSaving ||
!code.trim() ||
!parsePositiveInteger(rewardPoints) ||
!parsePositiveInteger(maxUses)
}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
<div className="admin-stack">
<form className="admin-panel admin-form" onSubmit={handleDisable}>
<label className="admin-field">
<span> Code</span>
<input
value={disableCode}
onChange={(event) => setDisableCode(event.target.value)}
/>
</label>
{disableErrorMessage ? (
<div className="admin-alert" role="status">
{disableErrorMessage}
</div>
) : null}
<button
className="admin-danger-button"
disabled={isDisabling || !disableCode.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?.mode ?? '-'}</span>
</div>
{result ? (
<dl className="admin-info-list">
<div>
<dt>Code</dt>
<dd>{result.code}</dd>
</div>
<div>
<dt></dt>
<dd>{result.rewardPoints}</dd>
</div>
<div>
<dt></dt>
<dd>{result.maxUses}</dd>
</div>
<div>
<dt></dt>
<dd>{result.globalUsedCount}</dd>
</div>
<div>
<dt></dt>
<dd>{result.enabled ? '启用' : '停用'}</dd>
</div>
<div>
<dt></dt>
<dd>{result.createdBy}</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;
}

View File

@@ -0,0 +1,33 @@
import {formatAdminApiError, isAdminApiError} from '../api/adminApiClient';
export function handlePageError(
error: unknown,
onUnauthorized: (message?: string) => void,
setError: (message: string) => void,
) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
return;
}
setError(formatAdminApiError(error));
}
export function splitLines(value: string) {
return value
.split(/\r?\n|,/)
.map((item) => item.trim())
.filter(Boolean);
}
export function formatUnknownJson(value: unknown) {
if (value === null || typeof value === 'undefined') {
return '';
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View File

@@ -0,0 +1,661 @@
:root {
color: #17212b;
background: #eef3f6;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* {
box-sizing: border-box;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background: #eef3f6;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
opacity: 0.62;
}
#root {
min-height: 100vh;
}
.admin-loading-screen,
.admin-login-screen {
display: grid;
min-height: 100dvh;
place-items: center;
padding: 20px;
}
.admin-loading-screen {
gap: 12px;
color: #5c6b77;
}
.admin-loading-mark {
width: 24px;
height: 24px;
border: 3px solid #d1dde6;
border-top-color: #126e82;
border-radius: 50%;
animation: admin-spin 0.8s linear infinite;
}
.admin-login-screen {
background:
linear-gradient(145deg, rgba(18, 110, 130, 0.12), transparent 36%),
linear-gradient(315deg, rgba(165, 94, 54, 0.12), transparent 34%),
#eef3f6;
}
.admin-login-panel,
.admin-panel {
border: 1px solid #d8e2e8;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 36px rgba(23, 33, 43, 0.08);
}
.admin-login-panel {
display: grid;
width: min(100%, 420px);
gap: 18px;
padding: 28px;
}
.admin-login-brand,
.admin-brand {
display: flex;
align-items: center;
gap: 12px;
}
.admin-login-brand h1 {
margin: 0;
color: #17212b;
font-size: 26px;
line-height: 1.16;
}
.admin-login-brand span,
.admin-brand span {
color: #667682;
font-size: 12px;
}
.admin-brand-icon {
display: grid;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid #bcd2db;
border-radius: 8px;
color: #126e82;
background: #e7f3f5;
}
.admin-brand-icon-large {
width: 48px;
height: 48px;
}
.admin-shell {
display: grid;
min-height: 100dvh;
grid-template-columns: 232px minmax(0, 1fr);
}
.admin-sidebar {
display: flex;
flex-direction: column;
gap: 24px;
border-right: 1px solid #d8e2e8;
background: #ffffff;
padding: 22px 18px;
}
.admin-brand strong {
display: block;
font-size: 17px;
}
.admin-nav {
display: grid;
gap: 8px;
}
.admin-nav-button,
.admin-bottom-nav-button,
.admin-icon-button,
.admin-primary-button,
.admin-secondary-button,
.admin-danger-button,
.admin-ghost-button {
display: inline-flex;
align-items: center;
justify-content: center;
border: 0;
border-radius: 8px;
white-space: nowrap;
}
.admin-nav-button {
justify-content: flex-start;
gap: 10px;
min-height: 42px;
padding: 0 12px;
color: #52616d;
background: transparent;
}
.admin-nav-button[data-active="true"] {
color: #0f5666;
background: #e7f3f5;
}
.admin-main {
display: grid;
min-width: 0;
grid-template-rows: 64px minmax(0, 1fr);
}
.admin-topbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
border-bottom: 1px solid #d8e2e8;
background: rgba(255, 255, 255, 0.86);
padding: 0 24px;
}
.admin-user {
display: grid;
justify-items: end;
gap: 2px;
}
.admin-user span {
font-weight: 650;
}
.admin-user small {
color: #667682;
}
.admin-content {
min-width: 0;
padding: 24px;
overflow: auto;
}
.admin-page {
display: grid;
gap: 18px;
max-width: 1180px;
margin: 0 auto;
}
.admin-page-heading,
.admin-panel-heading,
.admin-subsection-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.admin-page-heading h2,
.admin-panel-heading h3 {
margin: 0;
color: #17212b;
}
.admin-page-heading h2 {
font-size: 25px;
}
.admin-page-heading p {
margin: 3px 0 0;
color: #667682;
}
.admin-panel {
display: grid;
gap: 16px;
min-width: 0;
padding: 18px;
}
.admin-panel-heading h3 {
font-size: 16px;
}
.admin-panel-heading span {
color: #667682;
font-size: 13px;
}
.admin-panel-warning {
border-color: #efc894;
background: #fffaf3;
}
.admin-overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.admin-two-column {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
gap: 16px;
align-items: start;
}
.admin-two-column-wide {
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
}
.admin-stack,
.admin-form {
display: grid;
gap: 16px;
}
.admin-form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: end;
}
.admin-field {
display: grid;
min-width: 0;
gap: 7px;
color: #4c5c68;
font-size: 13px;
font-weight: 650;
}
.admin-field-fill {
min-width: 0;
}
.admin-field-compact {
max-width: 160px;
}
.admin-field input,
.admin-field select,
.admin-field textarea {
width: 100%;
min-height: 42px;
border: 1px solid #cbd8e0;
border-radius: 8px;
color: #17212b;
background: #fbfdfe;
padding: 9px 11px;
outline: none;
}
.admin-field textarea {
min-height: 112px;
resize: vertical;
}
.admin-field input:focus,
.admin-field select:focus,
.admin-field textarea:focus {
border-color: #126e82;
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
}
.admin-switch-field {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-height: 42px;
color: #4c5c68;
font-size: 13px;
font-weight: 650;
}
.admin-switch-field input {
width: 18px;
height: 18px;
accent-color: #126e82;
}
.admin-primary-button,
.admin-secondary-button,
.admin-danger-button,
.admin-icon-button {
gap: 8px;
min-height: 40px;
padding: 0 14px;
font-weight: 700;
}
.admin-primary-button {
color: #ffffff;
background: #126e82;
}
.admin-secondary-button,
.admin-icon-button {
border: 1px solid #cbd8e0;
color: #2f4550;
background: #ffffff;
}
.admin-danger-button {
color: #ffffff;
background: #a44242;
}
.admin-ghost-button {
width: 34px;
height: 34px;
color: #52616d;
background: #eef3f6;
}
.admin-alert {
border: 1px solid #efc0bd;
border-radius: 8px;
color: #8a2f2f;
background: #fff4f3;
padding: 10px 12px;
font-size: 13px;
}
.admin-info-list {
display: grid;
gap: 10px;
margin: 0;
}
.admin-info-list div {
display: grid;
grid-template-columns: minmax(90px, 0.34fr) minmax(0, 1fr);
gap: 12px;
align-items: baseline;
}
.admin-info-list dt {
color: #667682;
font-size: 12px;
}
.admin-info-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #17212b;
font-size: 13px;
font-weight: 650;
}
.admin-table-wrap {
overflow: auto;
}
.admin-table {
width: 100%;
min-width: 620px;
border-collapse: collapse;
}
.admin-table th,
.admin-table td {
border-bottom: 1px solid #e4edf2;
padding: 10px;
text-align: left;
vertical-align: top;
}
.admin-table th {
color: #667682;
font-size: 12px;
}
.admin-status {
display: inline-flex;
max-width: 460px;
border-radius: 999px;
padding: 3px 8px;
font-size: 12px;
overflow-wrap: anywhere;
}
.admin-status-ok {
color: #17623c;
background: #e6f5ed;
}
.admin-status-error {
color: #8a2f2f;
background: #fff1ef;
}
.admin-error-list {
display: grid;
gap: 8px;
margin: 0;
padding-left: 18px;
color: #8a5a1b;
overflow-wrap: anywhere;
}
.admin-subsection {
display: grid;
gap: 10px;
}
.admin-subsection-heading {
color: #4c5c68;
font-size: 13px;
font-weight: 650;
}
.admin-header-editor {
display: grid;
gap: 8px;
}
.admin-header-row {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1fr) 34px;
gap: 8px;
align-items: center;
}
.admin-header-row input {
min-width: 0;
min-height: 38px;
border: 1px solid #cbd8e0;
border-radius: 8px;
padding: 8px 10px;
}
.admin-result-panel {
min-height: 260px;
}
.admin-code-block {
max-height: 520px;
margin: 0;
overflow: auto;
border: 1px solid #dce6ec;
border-radius: 8px;
background: #17212b;
color: #e9f2f4;
padding: 14px;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.admin-empty-state {
display: grid;
min-height: 140px;
place-items: center;
border: 1px dashed #cbd8e0;
border-radius: 8px;
color: #667682;
background: #fbfdfe;
}
.admin-segmented-control {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
border: 1px solid #d8e2e8;
border-radius: 8px;
background: #eef3f6;
padding: 4px;
}
.admin-segmented-control button {
min-height: 36px;
border: 0;
border-radius: 6px;
color: #52616d;
background: transparent;
font-weight: 700;
}
.admin-segmented-control button[data-active="true"] {
color: #0f5666;
background: #ffffff;
box-shadow: 0 2px 8px rgba(23, 33, 43, 0.08);
}
.admin-bottom-nav {
display: none;
}
@keyframes admin-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-sidebar {
display: none;
}
.admin-main {
grid-template-rows: 58px minmax(0, 1fr);
}
.admin-topbar {
justify-content: space-between;
padding: 0 14px;
}
.admin-content {
padding: 16px 14px 86px;
}
.admin-overview-grid,
.admin-two-column,
.admin-two-column-wide,
.admin-form-row {
grid-template-columns: 1fr;
}
.admin-field-compact {
max-width: none;
}
.admin-bottom-nav {
position: fixed;
right: 0;
bottom: 0;
left: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid #d8e2e8;
background: rgba(255, 255, 255, 0.94);
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
backdrop-filter: blur(10px);
}
.admin-bottom-nav-button {
display: grid;
gap: 4px;
min-height: 48px;
color: #667682;
background: transparent;
font-size: 11px;
font-weight: 700;
}
.admin-bottom-nav-button[data-active="true"] {
color: #0f5666;
background: #e7f3f5;
}
}
@media (max-width: 560px) {
.admin-login-panel,
.admin-panel {
padding: 16px;
}
.admin-login-brand h1,
.admin-page-heading h2 {
font-size: 22px;
}
.admin-info-list div {
grid-template-columns: 1fr;
gap: 3px;
}
.admin-header-row {
grid-template-columns: 1fr;
}
.admin-header-row .admin-ghost-button {
width: 100%;
}
.admin-icon-button span {
display: none;
}
}

9
apps/admin-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ADMIN_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": false,
"strict": true,
"noUncheckedIndexedAccess": true,
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,43 @@
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import react from '@vitejs/plugin-react';
import {defineConfig, loadEnv} from 'vite';
const adminWebRoot = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(adminWebRoot, '../..');
export default defineConfig(({mode}) => {
const repoEnv = loadEnv(mode, repoRoot, '');
const appEnv = loadEnv(mode, adminWebRoot, '');
const env = {...repoEnv, ...appEnv};
const apiTarget =
env.ADMIN_API_TARGET ||
env.GENARRATIVE_API_TARGET ||
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
return {
root: adminWebRoot,
envDir: repoRoot,
plugins: [react()],
server: {
proxy: {
'/admin/api': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
'/healthz': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
chunkSizeWarningLimit: 600,
},
};
});

View File

@@ -11,9 +11,9 @@
- [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。 - [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级。
- [参考目录](./reference/README.md):脚本/Function 速查入口。 - [参考目录](./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)。 重点补充RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。
- [PRD](./prd):产品需求与阶段计划;新增 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)。 - [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)。
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和 Jenkins 数据库迁移流水线见 [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)。 SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)private 表迁移 JSON 导入导出、HTTP 413 分片导入和 Jenkins 数据库迁移流水线见 [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)
## 推荐阅读顺序 ## 推荐阅读顺序

View File

@@ -0,0 +1,168 @@
# 后台管理独立前端工程 PRD
日期:`2026-04-30`
## 1. 目标
本 PRD 定义当前项目的后台管理独立前端工程 v1。目标不是重做一套后端也不是把玩家端前端改造成后台而是在 monorepo 内新增一个独立管理端应用,正式承接已经从 `api-server` 内嵌页面移出的后台 UI。
首版目标:
1. 后台 UI 独立落在 `apps/admin-web`,不再写入 Rust 源码字符串。
2. 管理数据、业务规则、权限校验和写操作继续统一走 `server-rs/crates/api-server`
3. v1 只接管已有管理能力:管理员登录、当前管理员信息、服务/数据库概览、受控 API 调试、兑换码管理、注册邀请码管理。
4. 保持管理端清爽、可扫读、移动端可用,不在界面堆大段规则说明。
## 2. 用户与使用场景
### 2.1 目标用户
1. 项目开发者:用于本地或测试环境快速查看 `api-server` 与 SpacetimeDB 连接状态。
2. 运维/运营人员:用于创建、更新、停用兑换码,预置注册邀请码,以及确认后台接口是否可用。
3. 后续后台功能开发者:以本工程作为后续用户、作品、资产、审核等管理模块的承载壳。
### 2.2 首版高频场景
1. 管理员打开后台登录页,输入环境变量配置的管理员用户名和密码。
2. 登录后进入总览页确认当前服务监听、JWT issuer、SpacetimeDB server/database、schema 表数量和关键表统计。
3. 在 API 调试页对当前 `api-server` 的同源相对路径发起受控请求,排查接口状态。
4. 在兑换码管理页创建或更新公共码、唯一码、私有码,并在需要时停用兑换码。
5. 在邀请码管理页创建或更新注册链路使用的邀请码与 metadata。
## 3. 功能范围
### 3.1 包含
1. 独立前端工程骨架:`apps/admin-web`
2. 登录页:
- 调用 `POST /admin/api/login`
- 登录成功后保存管理员 Bearer token。
- 登录失败直接展示后端返回的错误消息。
3. 管理端 Shell
- 顶部显示当前管理员、登录状态和退出按钮。
- 侧边或底部导航包含“总览”“API 调试”“兑换码”“邀请码”。
- 移动端导航改为底部或顶部紧凑标签。
4. 总览页:
- 调用 `GET /admin/api/me` 恢复管理员会话。
- 调用 `GET /admin/api/overview` 展示服务和数据库概览。
- 表统计允许部分失败,并直接展示失败项。
5. API 调试页:
- 调用 `POST /admin/api/debug/http`
- 只允许填写同源相对路径,不提供绝对 URL 输入暗示。
- 调试结果在独立结果面板展示状态码、响应头、响应文本和 JSON 预览。
6. 兑换码管理页:
- 调用 `POST /admin/api/profile/redeem-codes` 创建/更新兑换码。
- 调用 `POST /admin/api/profile/redeem-codes/disable` 停用兑换码。
- 支持 `public``unique``private` 三种模式。
- 私有码支持输入内部 `userId` 和公开陶泥号,提交给后端统一解析。
7. 邀请码管理页:
- 调用 `POST /admin/api/profile/invite-codes` 创建/更新注册邀请码。
- 支持输入 JSON 对象 metadata提交前做基础 JSON 对象校验,最终校验以服务端为准。
### 3.2 不包含
1. 不新建后台专用后端服务、BFF、Express 服务或 PostgreSQL 数据面。
2. 不让管理端直接连接 SpacetimeDB TypeScript SDK。
3. 不新增 SpacetimeDB 表结构。
4. 不实现完整用户管理、作品审核、资产审核、充值订单后台。
5. 不实现多角色权限体系、管理员 refresh session、多端会话管理。
6. 不保留 `GET /admin` 同源内嵌页面作为正式后台入口。
## 4. 页面与交互要求
### 4.1 登录页
登录页只保留必要输入和状态反馈:
1. 用户名输入框。
2. 密码输入框。
3. 登录按钮。
4. 登录失败状态。
5. 后台未启用状态。
后台未启用时,前端根据 `POST /admin/api/login``503` 响应展示不可登录状态,不额外引导用户在页面配置环境变量。
### 4.2 总览页
总览页必须能快速扫读:
1. 服务状态监听地址、端口、JWT issuer。
2. SpacetimeDB 配置server URL、database。
3. 数据库元信息database identity、owner identity、host type。
4. 表清单与表统计:展示成功计数和失败原因。
总览页不展示任意 SQL 输入框。
### 4.3 API 调试页
API 调试页是受控接口调试台,不是通用代理工具:
1. method 使用选择控件。
2. path 只接受 `/xxx` 形式。
3. headers 使用结构化键值编辑。
4. body 使用文本框。
5. 结果展示在固定结果面板,不在按钮下方临时插入内容。
### 4.4 兑换码管理页
兑换码管理页首版采用一个创建/更新表单和一个停用表单:
1. code提交前可在前端 trim但最终标准化以服务端为准。
2. mode`public``unique``private`
3. rewardPoints必须为正整数最终校验以服务端为准。
4. maxUses必须为正整数最终校验以服务端为准。
5. enabled创建/更新时可切换。
6. allowedUserIds私有码允许的内部用户 ID 列表。
7. allowedPublicUserCodes私有码允许的公开陶泥号列表。
提交成功后展示后端返回的兑换码记录;失败时展示后端错误消息。
### 4.5 邀请码管理页
邀请码管理页首版采用一个创建/更新表单和一个结果面板:
1. inviteCode提交前可在前端 trim最终标准化以服务端为准。
2. metadata必须是 JSON 对象;空值按 `{}` 提交。
3. 提交成功后展示后端返回的 userId、inviteCode、createdAt、updatedAt 和 metadata。
4. 失败时展示后端错误消息。
## 5. 数据与权限边界
1. 管理端所有请求必须经过 `/admin/api/*`
2. 管理端持有的管理员 token 只用于后台接口,不复用玩家登录 token。
3. 普通玩家 token 访问管理接口应被后端拒绝。
4. 管理端不持久化密码。
5. token 首版可保存在 localStorage退出登录时立即清除。
6. SpacetimeDB 表、reducer、procedure、迁移和权限判断均不由管理端直接处理。
## 6. 验收标准
1. `apps/admin-web` 可以独立启动和构建。
2. 登录页可完成管理员登录,错误和未启用状态展示清楚。
3. 登录后刷新页面可通过 `GET /admin/api/me` 恢复会话。
4. 总览页可展示 `GET /admin/api/overview` 的服务与数据库数据。
5. API 调试页可通过后端调试接口访问 `/healthz`
6. 兑换码管理页可创建/更新、停用兑换码,并展示返回记录。
7. 邀请码管理页可创建/更新注册邀请码,并展示返回记录。
8. `GET /admin` 保持 404不恢复旧内嵌页面。
9. `npm run check:encoding` 通过。
## 7. 首版任务拆解
首版编码按以下模块收口,避免独立后台工程继续扩散成未定义功能:
1. 工程骨架:创建 `apps/admin-web`,补齐 Vite、TypeScript、React 入口和独立构建脚本。
2. API client只封装 `/admin/api/*`,统一处理 Bearer token、统一 envelope 和后端错误消息。
3. 登录与会话:实现登录、启动恢复、`401` 清理、退出登录四个状态流。
4. 页面 Shell实现桌面侧栏/顶部栏与移动端紧凑导航,不在未登录状态渲染后台导航。
5. 总览页:展示 `service``database``tableStats``fetchErrors`,部分失败不阻断整页。
6. API 调试页:实现 method、path、headers、body 输入和独立结果面板。
7. 兑换码页:实现创建/更新、停用、三种 mode 切换和私有码用户列表输入。
8. 邀请码页:实现注册邀请码创建/更新、metadata JSON 对象输入和最近记录展示。
9. 验证:完成后台工程 typecheck/build、根工程编码检查以及 `/admin` 仍为 404 的后端验证。
## 8. 交互修正记录
### 8.1 兑换码记录页签保持
兑换码管理页的“记录”面板展示最近一次创建、更新或停用接口返回的兑换码记录。该记录在管理端会话内切换“总览”“API 调试”“兑换码”页签时必须保留,避免运营人员切页核对信息后丢失刚返回的结果。退出登录、登录新会话或刷新浏览器页面时可以清空该前端会话态;持久化查询能力后续由新的后端查询接口承接,不在首版用前端伪造历史列表。

View File

@@ -2,6 +2,8 @@
更新时间:`2026-04-16` 更新时间:`2026-04-16`
> 2026-04-30 更新:用户侧邀请码填写入口已迁到注册环节,当前落地以 `docs/technical/PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md` 为准;“我的 Tab”不再保留填邀请码入口。
## 0. 目标 ## 0. 目标
把“填邀请码”做成用户激活早期的一次性绑定动作,完成: 把“填邀请码”做成用户激活早期的一次性绑定动作,完成:

View File

@@ -2,6 +2,10 @@
日期:`2026-04-23` 日期:`2026-04-23`
更新:`2026-04-30`
> 状态说明:本文件中的管理员鉴权、`/admin/api/*` 管理接口、数据库概览与受控 API 调试设计继续有效;同源内嵌 HTML/CSS/JS 后台页面已废弃。后续后台 UI 迁移到独立前端工程,当前 `api-server` 不再挂载 `GET /admin` 页面入口。独立后台前端的产品边界见 [`../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md`](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md),技术方案见 [`ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md`](./ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
## 1. 目标 ## 1. 目标
为当前 Rust `api-server` 增加一套同源后台管理服务,满足以下首版目标: 为当前 Rust `api-server` 增加一套同源后台管理服务,满足以下首版目标:
@@ -10,7 +14,7 @@
2. 支持独立的管理员鉴权,不允许普通玩家 JWT 越权访问。 2. 支持独立的管理员鉴权,不允许普通玩家 JWT 越权访问。
3. 支持在后台查看当前服务与数据库概览信息。 3. 支持在后台查看当前服务与数据库概览信息。
4. 支持在后台测试当前 `api-server` 已挂载接口。 4. 支持在后台测试当前 `api-server` 已挂载接口。
5. 保持首版工程足够轻量,不新建额外独立服务进程,不引入第二套前端工程。 5. 保持管理能力继续收口在 `server-rs`,管理 UI 由独立后台前端工程承接
## 2. 背景与约束 ## 2. 背景与约束
@@ -24,19 +28,20 @@
1. 后端统一落在 `server-rs`,不回退到 `server-node` 1. 后端统一落在 `server-rs`,不回退到 `server-node`
2. 不额外新起独立管理服务进程。 2. 不额外新起独立管理服务进程。
3. 首版以“一个受保护管理域 + 一个同源后台页面”为落地形态 3. 管理 API 继续作为受保护管理域挂载在 `api-server`
4. 数据库信息必须尽量读取真实数据库侧信息,不能只展示硬编码假数据。 4. 数据库信息必须尽量读取真实数据库侧信息,不能只展示硬编码假数据。
## 3. 首版范围 ## 3. 首版范围
### 3.1 包含 ### 3.1 包含
1. `GET /admin`:后台管理页面入口 1. `POST /admin/api/login`:管理员用户名密码登录
2. `POST /admin/api/login`:管理员用户名密码登录 2. `GET /admin/api/me`当前管理员会话信息
3. `GET /admin/api/me`:当前管理员会话信息 3. `GET /admin/api/overview`:服务与数据库概览
4. `GET /admin/api/overview`:服务与数据库概览 4. `POST /admin/api/debug/http`:受控 HTTP 接口调试
5. `POST /admin/api/debug/http`:受控 HTTP 接口调试 5. `POST /admin/api/profile/redeem-codes`:兑换码创建/更新
6. 基于 Bearer JWT 的管理员鉴权中间件 6. `POST /admin/api/profile/redeem-codes/disable`:兑换码停用
7. 基于 Bearer JWT 的管理员鉴权中间件。
### 3.2 不包含 ### 3.2 不包含
@@ -44,7 +49,7 @@
2. 管理员 refresh cookie / 多端会话管理。 2. 管理员 refresh cookie / 多端会话管理。
3. 后台直接写库、删库、执行 reducer。 3. 后台直接写库、删库、执行 reducer。
4. 任意 SQL 执行器。 4. 任意 SQL 执行器。
5. 新建独立 React/Vite 管理端工程 5. `api-server` 内嵌 HTML/CSS/JS 后台页面
## 4. 总体方案 ## 4. 总体方案
@@ -60,13 +65,13 @@
### 4.2 页面形态 ### 4.2 页面形态
后台管理页面采用 `api-server` 直接返回一份内嵌 HTML/CSS/JS 的管理页 后台管理页面不再由 `api-server` 直接返回内嵌 HTML/CSS/JS`api-server` 仅保留管理 API页面由独立后台前端工程调用这些接口
原因: 原因:
1. 首版目标是“可用的后台能力”,不是新建一套复杂前端基建 1. 管理 UI 需要独立演进,不应继续堆在 Rust 源码字符串中
2. 管理页面交互相对简单,直接内嵌更易随服务端一起部署 2. `server-rs` 继续负责鉴权、聚合和写操作,符合前端只做表现的工程约束
3. 可以避免新增构建链和静态资源发布路径 3. 删除 `GET /admin` 后,当前服务访问该路径应返回 `404`
### 4.3 数据库信息来源 ### 4.3 数据库信息来源
@@ -129,7 +134,7 @@ claims 设计:
## 6. 后台页面设计 ## 6. 后台页面设计
首版页面包含三个主区域: 本节已由独立后台前端工程方案接管。历史同源页面包含三个主区域:
1. 登录卡片。 1. 登录卡片。
2. 数据库概览面板。 2. 数据库概览面板。
@@ -223,7 +228,7 @@ claims 设计:
默认策略: 默认策略:
1. 若未配置用户名或密码,则后台登录接口返回 `503`后台页面显示“后台未启用 1. 若未配置用户名或密码,则后台登录接口返回 `503`独立后台前端自行展示未启用状态
2. 默认管理员 token TTL 为 `4` 小时。 2. 默认管理员 token TTL 为 `4` 小时。
## 10. 测试要求 ## 10. 测试要求
@@ -240,21 +245,27 @@ claims 设计:
## 11. 路由清单 ## 11. 路由清单
首版新增路由: 当前保留的管理 API 路由:
1. `GET /admin` 1. `POST /admin/api/login`
2. `POST /admin/api/login` 2. `GET /admin/api/me`
3. `GET /admin/api/me` 3. `GET /admin/api/overview`
4. `GET /admin/api/overview` 4. `POST /admin/api/debug/http`
5. `POST /admin/api/debug/http` 5. `POST /admin/api/profile/redeem-codes`
6. `POST /admin/api/profile/redeem-codes/disable`
`GET /admin` 已取消挂载,后续由独立后台前端工程承接页面入口。
## 12. 完成定义 ## 12. 完成定义
满足以下条件时,本任务视为完成: 当前管理 API 保留与内嵌页面移除满足以下条件时,本任务视为完成:
1. `api-server` 内存在受保护后台管理域。 1. `api-server` 内存在受保护后台管理域。
2. 管理员用户名密码可登录。 2. 管理员用户名密码可登录。
3. 普通用户 token 无法访问后台接口。 3. 普通用户 token 无法访问后台接口。
4. 后台能看到服务和数据库真实概览。 4. 后台能看到服务和数据库真实概览。
5. 后台能调试当前服务 HTTP 接口。 5. 后台能调试当前服务 HTTP 接口。
6. 路由索引与技术文档已同步更新。 6. 兑换码管理 API 可由管理员 token 调用。
7. `GET /admin` 不再挂载,访问返回 `404`
8. 独立后台前端 PRD 与技术方案已补齐。
9. 路由索引与技术文档已同步更新。

View File

@@ -0,0 +1,446 @@
# 后台管理独立前端工程技术方案
日期:`2026-04-30`
对应 PRD[后台管理独立前端工程 PRD](../prd/ADMIN_WEB_CONSOLE_PRD_2026-04-30.md)
落地状态:`2026-04-30` 已创建 `apps/admin-web` 独立前端工程包含登录、总览、API 调试、兑换码管理和注册邀请码管理首版页面;根工程已补 `admin-web:*` 转发脚本。
## 1. 结论
后台管理端采用独立前端工程,路径固定为 `apps/admin-web`。它只负责 UI 表现、输入采集、请求发起和结果渲染所有鉴权、聚合、写操作、SpacetimeDB 访问和业务校验继续收口在 `server-rs/crates/api-server`
本方案接管旧 `api-server` 内嵌 HTML/CSS/JS 页面,旧 `GET /admin` 不再挂载。后续后台入口由独立前端工程部署产物承接。
## 2. 工程结构
建议首版结构:
```text
apps/
└─ admin-web/
├─ index.html
├─ package.json
├─ tsconfig.json
├─ vite.config.ts
└─ src/
├─ main.tsx
├─ app/
│ ├─ AdminApp.tsx
│ ├─ AdminShell.tsx
│ └─ adminRoutes.ts
├─ api/
│ ├─ adminApiClient.ts
│ └─ adminApiTypes.ts
├─ auth/
│ └─ adminAuthStore.ts
├─ pages/
│ ├─ AdminLoginPage.tsx
│ ├─ AdminOverviewPage.tsx
│ ├─ AdminDebugHttpPage.tsx
│ ├─ AdminRedeemCodePage.tsx
│ └─ AdminInviteCodePage.tsx
└─ styles/
└─ admin.css
```
首版可使用独立 `package.json`,不要求立刻把根工程改成 npm workspace。后续如果根工程统一 workspace再把 `apps/admin-web` 纳入统一脚本。
## 3. 技术栈
1. React + TypeScript + Vite。
2. 图标使用 `lucide-react`
3. 样式首版使用普通 CSS 或 CSS Modules不引入新的大型 UI 组件库。
4. 请求使用浏览器 `fetch` 封装,不新增状态管理库。
5. 不引入 SpacetimeDB TypeScript SDK管理端不直连 SpacetimeDB。
## 4. API 边界
### 4.1 基础约定
所有管理端请求使用同一个 `adminApiClient`
1. base URL 由 `VITE_ADMIN_API_BASE_URL` 配置。
2. 未配置时默认同源空前缀。
3. 有 token 时附加 `Authorization: Bearer <token>`
4. 后端统一响应 envelope 时,前端读取 `data`;错误优先读取 `error.details.message`,再读 `error.message`,最后回退到 HTTP 状态。
前端统一按以下响应形状解析,不在页面组件里重复拆 envelope
```ts
export interface ApiSuccessEnvelope<T> {
data: T;
meta?: unknown;
}
export interface ApiErrorEnvelope {
error?: {
code?: string;
message?: string;
details?: {
message?: string;
[key: string]: unknown;
} | null;
};
meta?: unknown;
}
```
`adminApiClient` 暴露 `request<T>()``get<T>()``post<T>()` 三层即可。页面只拿到成功数据或抛出的中文错误消息,不直接处理 `Response`
### 4.2 已有管理接口
| 功能 | 方法与路径 | 鉴权 |
| --- | --- | --- |
| 管理员登录 | `POST /admin/api/login` | 无 |
| 当前管理员 | `GET /admin/api/me` | 管理员 Bearer |
| 服务与数据库概览 | `GET /admin/api/overview` | 管理员 Bearer |
| 受控 HTTP 调试 | `POST /admin/api/debug/http` | 管理员 Bearer |
| 创建/更新兑换码 | `POST /admin/api/profile/redeem-codes` | 管理员 Bearer |
| 停用兑换码 | `POST /admin/api/profile/redeem-codes/disable` | 管理员 Bearer |
| 创建/更新注册邀请码 | `POST /admin/api/profile/invite-codes` | 管理员 Bearer |
### 4.3 前端类型命名
后台前端首版不引入自动生成 contract。为了避免字段漂移`apps/admin-web/src/api/adminApiTypes.ts` 必须按 `shared-contracts` 的 camelCase JSON 字段命名:
```ts
export interface AdminSessionPayload {
subject: string;
username: string;
displayName: string;
roles: string[];
issuedAt: string;
expiresAt: string;
}
export interface AdminLoginResponse {
token: string;
admin: AdminSessionPayload;
}
export interface AdminOverviewResponse {
service: AdminServiceOverviewPayload;
database: AdminDatabaseOverviewPayload;
}
export interface AdminServiceOverviewPayload {
bindHost: string;
bindPort: number;
jwtIssuer: string;
adminEnabled: boolean;
spacetimeServerUrl: string;
spacetimeDatabase: string;
}
export interface AdminDatabaseOverviewPayload {
databaseIdentity: string | null;
ownerIdentity: string | null;
hostType: string | null;
schemaTableNames: string[];
tableStats: AdminDatabaseTableStatPayload[];
fetchErrors: string[];
}
export interface AdminDatabaseTableStatPayload {
tableName: string;
rowCount: number | null;
errorMessage: string | null;
}
export interface AdminDebugHeaderInput {
name: string;
value: string;
}
export interface AdminDebugHttpRequest {
method: string;
path: string;
headers?: AdminDebugHeaderInput[];
body?: string;
}
export interface AdminDebugHttpResponse {
status: number;
statusText: string;
headers: AdminDebugHeaderInput[];
bodyText: string;
bodyJson: unknown | null;
}
```
兑换码类型同样保持 camelCase
```ts
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export interface AdminUpsertProfileRedeemCodeRequest {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
enabled: boolean;
allowedUserIds: string[];
allowedPublicUserCodes: string[];
}
export interface AdminDisableProfileRedeemCodeRequest {
code: string;
}
export interface AdminUpsertProfileInviteCodeRequest {
inviteCode: string;
metadata?: Record<string, unknown>;
}
export interface ProfileRedeemCodeAdminResponse {
code: string;
mode: ProfileRedeemCodeMode;
rewardPoints: number;
maxUses: number;
globalUsedCount: number;
enabled: boolean;
allowedUserIds: string[];
createdBy: string;
createdAt: string;
updatedAt: string;
}
export interface ProfileInviteCodeAdminResponse {
userId: string;
inviteCode: string;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
```
### 4.4 登录 contract
请求:
```json
{
"username": "root",
"password": "secret123"
}
```
成功数据:
```json
{
"token": "<admin bearer token>",
"admin": {
"subject": "admin:root",
"username": "root",
"displayName": "root",
"roles": ["admin"],
"issuedAt": "2026-04-30T00:00:00Z",
"expiresAt": "2026-04-30T04:00:00Z"
}
}
```
`503` 表示后台未启用;`401` 表示用户名或密码错误。
### 4.5 总览 contract
`GET /admin/api/overview` 返回:
1. `service``bindHost``bindPort``jwtIssuer``adminEnabled``spacetimeServerUrl``spacetimeDatabase`
2. `database``databaseIdentity``ownerIdentity``hostType``schemaTableNames``tableStats``fetchErrors`
后端读取 SpacetimeDB schema 时必须请求 `/v1/database/{database}/schema?version=9`。SpacetimeDB 2.x schema HTTP API 缺少 `version` query 会返回 `400 missing field version`,后台页面只能展示读取异常,不能拿到真实表名。
后端读取表行数时必须按 SpacetimeDB 2.x `/sql` 响应解析:接口返回 statement result 数组,单条结果内的 `schema.elements` 描述列名,`rows` 是按列顺序排列的数组行,例如 `rows: [[0]]`。后台服务不能再假设响应是 `{ rows: [{ row_count: 0 }] }` 的对象行形状;为了兼容小版本差异,可保留对象行兜底解析。
`tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。
### 4.6 API 调试 contract
请求:
```json
{
"method": "GET",
"path": "/healthz",
"headers": [],
"body": ""
}
```
限制由后端执行:
1. `path` 只允许同源相对路径。
2. 禁止绝对 URL。
3. 禁止调试 `/admin/api/login`
4. 禁止覆盖危险请求头。
5. 请求体大小和超时由后端收口。
### 4.7 兑换码管理 contract
创建/更新请求:
```json
{
"code": "WELCOME2026",
"mode": "public",
"rewardPoints": 100,
"maxUses": 1,
"enabled": true,
"allowedUserIds": [],
"allowedPublicUserCodes": []
}
```
停用请求:
兑换码管理页的最近一次接口返回记录由 `AdminApp` 维护为管理端会话态,并传入 `AdminRedeemCodePage` 渲染。页面页签通过 hash 切换时子页面会卸载,不能把最近记录只放在兑换码页面内部 `useState` 中,否则切换到其他页签再返回会展示“暂无记录”。该会话态只用于保留当前操作结果,不作为兑换码历史列表;退出登录或重新登录时清空。
```json
{
"code": "WELCOME2026"
}
```
成功返回兑换码记录:
```json
{
"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"
}
```
前端只做基础输入约束,最终标准化、私有码用户解析、次数和奖励合法性以 `server-rs` 为准。
### 4.8 邀请码管理 contract
创建/更新请求:
```json
{
"inviteCode": "SPRING2026",
"metadata": {
"batch": "spring"
}
}
```
成功返回邀请码记录:
```json
{
"userId": "admin",
"inviteCode": "SPRING2026",
"metadata": {
"batch": "spring"
},
"createdAt": "2026-04-30T00:00:00Z",
"updatedAt": "2026-04-30T00:00:00Z"
}
```
邀请码页的 metadata 输入必须先在前端解析为 JSON 对象;空字符串按 `{}` 处理,数组、字符串、数字等非对象值直接提示错误。最终标准化、长度限制和邀请码合法性以 `server-rs` 为准。
## 5. 鉴权与会话
1. token key 固定为 `genarrative_admin_token`
2. token 首版存 localStorage。
3. 应用启动时如果存在 token先调用 `GET /admin/api/me`
4. `401` 时清空 token 并回到登录页。
5. `403` 时展示无权限状态,不自动重试。
6. 退出登录只清理本地 token首版没有后台 refresh session 和服务端会话吊销。
## 6. 页面实现要点
1. `AdminShell` 承载导航、当前管理员、退出按钮和页面容器。
2. 登录页不进入 `AdminShell`,避免未登录时展示后台导航。
3. 总览页加载失败时展示后端错误,不吞掉 `fetchErrors`
4. API 调试页的 headers 使用键值行编辑,提交前转为 `[{ name, value }]`
5. 兑换码页的 `mode=private` 时展示允许用户输入区;其他模式提交空数组。
6. 邀请码页只提交 `inviteCode` 与 JSON 对象 metadata不在前端复制后端邀请码规则。
7. 所有按钮的 loading 状态必须锁定重复提交。
8. 移动端优先:表单单列,导航紧凑,结果面板可横向/纵向滚动。
## 7. 部署与联调
### 7.1 本地联调
1. 启动后端:`npm run api-server:maincloud`
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 目标,二者需要指向同一个端口。
### 7.2 构建部署
首版构建产物由独立后台工程输出到 `apps/admin-web/dist`。部署可以选择:
1. 独立静态站点域名,例如 `https://admin.example.com`
2. 与主站同域不同路径,由网关把后台静态资源和 `/admin/api/*` 分别路由到正确目标。
无论哪种方式,`server-rs` 仍然是唯一管理 API 后端。
### 7.3 后台工程脚本
`apps/admin-web/package.json` 首版至少提供以下脚本:
```json
{
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview --host 127.0.0.1"
}
}
```
如果后续接入根 npm workspace再在根 `package.json` 增加转发脚本;本轮不要为了后台工程强行重排现有前端脚本。
当前根工程同步提供以下转发脚本:
1. `npm run admin-web:dev`
2. `npm run admin-web:typecheck`
3. `npm run admin-web:build`
4. `npm run admin-web:preview`
## 8. 测试计划
1. `apps/admin-web`
- 登录成功和失败。
- token 恢复、过期清理、退出登录。
- 总览页正常数据、部分表统计失败、整体请求失败。
- API 调试成功访问 `/healthz`,绝对 URL 被后端拒绝。
- 兑换码 public/unique/private 表单提交和停用。
- 邀请码表单提交、metadata JSON 对象校验和结果展示。
2. 根工程:
- `npm run check:encoding`
- 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。
3. 后端:
- 继续保留 `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`
- 修改后端管理 API 后必须运行 `npm run api-server:maincloud` 并手动验证 `/admin` 为 404、`/admin/api/login` 可用。
## 9. 后续扩展边界
后续新增用户管理、作品审核、资产审核、订单/充值管理时,必须先补对应 PRD 和技术方案,并在 `server-rs` 增加受保护管理 API。不要让 `apps/admin-web` 直接读取 SpacetimeDB 或复制业务规则。
## 10. 实施顺序
1. 先创建 `apps/admin-web` 工程骨架,确保空应用可 `dev/build`
2. 再实现 `adminApiTypes``adminApiClient`,用 `/admin/api/login` 做第一条真实链路。
3. 接入 `adminAuthStore` 和启动恢复逻辑,确认 `401` 会清理本地 token。
4. 完成 `AdminShell` 与四页路由再分别接入总览、API 调试、兑换码和邀请码接口。
5. 最后补测试、运行 `npm run check:encoding`,并确认 `GET /admin` 仍由 `api-server` 返回 `404`
当前实现已完成第 1 至第 4 步。验证以实际命令输出为准。

View File

@@ -17,16 +17,17 @@
1. 构建产物目录统一使用 `build/<版本号>/` 1. 构建产物目录统一使用 `build/<版本号>/`
2. 默认使用 Jenkins `BUILD_NUMBER` 作为版本号,避免依赖时间戳;如有需要也允许显式传 `BUILD_VERSION` 2. 默认使用 Jenkins `BUILD_NUMBER` 作为版本号,避免依赖时间戳;如有需要也允许显式传 `BUILD_VERSION`
3. `构建``构建并部署``checkout scm` 后、实际构建前必须执行 `git reset --hard HEAD``git clean -fd`,避免固定源码目录内的 Git 变更和未跟踪文件影响发布包;不使用 `-x`,避免删除 `node_modules/` 等忽略目录后与 `RUN_NPM_CI=false` 冲突。 3. `构建``构建并部署``checkout scm` 后、实际构建前必须执行 `git reset --hard HEAD``git clean -fd`,避免固定源码目录内的 Git 变更和未跟踪文件影响发布包;不使用 `-x`,避免删除 `node_modules/` 等忽略目录后与 `RUN_NPM_CI=false` 冲突。
4. `部署` 流水线允许人工启动;没有上游触发 cause 时按人工部署处理,不再直接失败 4. `构建并部署` 可选填写 `COMMIT_HASH`。留空时使用 Jenkins SCM 当前检出的提交;填写时只能是 7 到 40 位十六进制 commit hash流水线会先按 SCM checkout 得到仓库,再尽量拉取 `origin` 全部分支引用、解析该 hash 并 detached checkout 到对应 commit 后构建
5. `部署` 流水线仅在存在上游触发 cause 时校验上游作业名与传入的 `EXPECTED_UPSTREAM_JOB` 一致;如配置了环境变量 `GENARRATIVE_ALLOWED_UPSTREAM_JOB`,还必须与该值一致 5. `部署` 流水线允许人工启动;没有上游触发 cause 时按人工部署处理,不再直接失败
6. `构建并部署` 在触发 `部署` 前先释放自己的构建节点,避免单执行器节点出现死锁 6. `部署` 流水线仅在存在上游触发 cause 时校验上游作业名与传入的 `EXPECTED_UPSTREAM_JOB` 一致;如配置了环境变量 `GENARRATIVE_ALLOWED_UPSTREAM_JOB`,还必须与该值一致
7. `部署` 不重新构建,不重新上传,不从 Jenkins 插件仓库复制产物,直接使用上游构建节点的本地 `build/<版本号>/` 目录 7. `构建并部署` 在触发 `部署` 前先释放自己的构建节点,避免单执行器节点出现死锁
8. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses(...)` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截 8. `部署` 不重新构建,不重新上传,不从 Jenkins 插件仓库复制产物,直接使用上游构建节点的本地 `build/<版本号>/` 目录
9. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行 9. `部署` 流水线读取触发原因时必须使用 `currentBuild.getBuildCauses(...)` 这类白名单方法,不能直接访问 `currentBuild.rawBuild`,否则会被 Jenkins Script Security 拦截
10. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限 10. 由于 Jenkins Pipeline 的 `build` 步骤触发下游时,原因类型通常是 `org.jenkinsci.plugins.workflow.support.steps.build.BuildUpstreamCause`,实现上需要同时兼容它和经典的 `hudson.model.Cause$UpstreamCause`,否则会把真实的上游触发误判成人工执行
11. `WEB_PORT` 必须在 `构建并部署``部署` 两条流水线之间使用同名参数传递;部署脚本会把最终端口写入固定部署目录 `.env.local``GENARRATIVE_WEB_PORT`,避免 `sudo` 启动 hook 时环境变量被清理导致端口回退 11. 如果线上进程的启停必须经过 `sudo`,只允许 `start.sh` / `stop.sh` 这两个 hook 使用 `sudo -n` 执行,部署目录清空与文件覆盖仍保持普通权限
12. `DATABASE` 必须匹配 SpacetimeDB CLI 数据库名规则 `^[a-z0-9]+(-[a-z0-9]+)*$`:只能使用小写字母、数字,并用单个短横线分隔;大写字母、点号、下划线、首尾短横线和连续短横线都会被拒绝,否则 `spacetime publish` 会报 `invalid characters in database name` 12. `WEB_PORT` 必须在 `构建并部署``部署` 两条流水线之间使用同名参数传递;部署脚本会把最终端口写入固定部署目录 `.env.local` `GENARRATIVE_WEB_PORT`,避免 `sudo` 启动 hook 时环境变量被清理导致端口回退
13. Jenkins 日志必须能看到构建参数中的 SpacetimeDB 发布数据库,以及 `start.sh` 最终加载环境文件后的运行时数据库、server 和 root-dir避免 `.env.local` 覆盖默认值后无法判断实际发布目标 13. `DATABASE` 必须匹配 SpacetimeDB CLI 数据库名规则 `^[a-z0-9]+(-[a-z0-9]+)*$`:只能使用小写字母、数字,并用单个短横线分隔;大写字母、点号、下划线、首尾短横线和连续短横线都会被拒绝,否则 `spacetime publish` 会报 `invalid characters in database name`
14. Jenkins 日志必须能看到构建参数中的 SpacetimeDB 发布数据库,以及 `start.sh` 最终加载环境文件后的运行时数据库、server 和 root-dir避免 `.env.local` 覆盖默认值后无法判断实际发布目标。
## 3. 节点与工作区要求 ## 3. 节点与工作区要求
@@ -119,13 +120,14 @@ jenkins/Jenkinsfile.build-and-deploy
核心流程: 核心流程:
1. `checkout scm`执行 `git reset --hard HEAD``git clean -fd` 清理工作区 1. `checkout scm`,如果 `COMMIT_HASH` 非空,则先拉取远端分支和 tag解析该 hash 指向的 commit并 detached checkout 到该提交
2. 复用与 `构建` 相同的构建命令生成 `build/<BUILD_VERSION>/` 2. 执行 `git reset --hard HEAD``git clean -fd` 清理工作区
3. 归档 `build/<BUILD_VERSION>/**` 3. 复用与 `构建` 相同的构建命令生成 `build/<BUILD_VERSION>/`
4. 记录当前 `NODE_NAME`、源码根目录、版本号 4. 归档 `build/<BUILD_VERSION>/**`
5. 构建时额外透传 `--web-port <WEB_PORT>`,默认生成监听 `25001` 的发布包 5. 记录当前 `NODE_NAME`、源码根目录、版本号与实际构建 commit
6. 构建日志会输出 `SpacetimeDB 发布数据库: <DATABASE>`,发布包启动日志会输出最终 `database/server/root-dir` 6. 构建时额外透传 `--web-port <WEB_PORT>`,默认生成监听 `25001` 的发布包
7. 触发 `部署` 流水线,并传递: 7. 构建日志会输出 `SpacetimeDB 发布数据库: <DATABASE>``构建 commit: <COMMIT>`,发布包启动日志会输出最终 `database/server/root-dir`
8. 触发 `部署` 流水线,并传递:
- `BUILD_VERSION` - `BUILD_VERSION`
- `SOURCE_WORKSPACE_ROOT` - `SOURCE_WORKSPACE_ROOT`
- `SOURCE_NODE_NAME` - `SOURCE_NODE_NAME`
@@ -180,6 +182,7 @@ jenkins/Jenkinsfile.build-and-deploy
7. `MIGRATION_EXPORT_TOKEN` 7. `MIGRATION_EXPORT_TOKEN`
8. `MIGRATION_IMPORT_TOKEN` 8. `MIGRATION_IMPORT_TOKEN`
9. `DATABASE`:发布包默认数据库名,默认 `genarrative-pipeline-local-test`,必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$` 9. `DATABASE`:发布包默认数据库名,默认 `genarrative-pipeline-local-test`,必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`
10. `COMMIT_HASH`:可选指定构建提交;如果目标 commit 不在 Jenkins 当前浅克隆历史中,流水线会尝试 unshallow仍找不到时构建失败。
如果你选择启用 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,推荐提前在服务器上增加一份最小 sudoers 配置,例如: 如果你选择启用 `RUN_DEPLOY_HOOKS_WITH_SUDO=true`,推荐提前在服务器上增加一份最小 sudoers 配置,例如:

View File

@@ -0,0 +1,126 @@
# 抓大鹅 Match3D B4+B5 spacetime-client 与 api-server facade 落地记录
日期:`2026-04-30`
## 1. 本阶段目标
本文件记录 B4+B5 的技术落地范围:把 B3 已生成的 Match3D SpacetimeDB procedure 接到 `spacetime-client`,再通过 `api-server` 暴露给前端使用的 HTTP facade。
本阶段不改 SpacetimeDB 表结构,不新增 migration不接入真实题材素材生成也不改前端即时反馈实现。
## 2. 已落地范围
### 2.1 SpacetimeDB bindings
使用仓库封装脚本重新生成 bindings
```powershell
npm run spacetime:generate
```
Windows 下 SpacetimeDB CLI 可能在 Rust bindings 已生成后输出 `Could not format generated files: 文件名或扩展名太长。 (os error 206)`。脚本已调整为:当 CLI 退出码为 `0` 且只是格式化警告时继续同步生成文件。
生成文件仍视为机器产物,禁止手写修改。
### 2.2 spacetime-client facade
新增:
```text
server-rs/crates/spacetime-client/src/match3d.rs
```
并在 `spacetime-client/src/lib.rs` 导出 `match3d` 模块与 Match3D Record 类型。
已覆盖:
1. 创作会话create / get / submit message / finalize / compile draft。
2. 作品list / get detail / update / publish / delete / public gallery list。
3. 运行态start / get / click / stop / restart / time-up。
`mapper.rs` 负责把 procedure 返回的 JSON 字符串解析为稳定 Record不把 generated bindings 泄露到 `api-server`
### 2.3 api-server HTTP facade
新增:
```text
server-rs/crates/api-server/src/match3d.rs
```
并在 `main.rs` 注册模块,在 `app.rs` 挂载路由。
已挂载路由:
```text
POST /api/creation/match3d/sessions
GET /api/creation/match3d/sessions/{session_id}
POST /api/creation/match3d/sessions/{session_id}/messages
POST /api/creation/match3d/sessions/{session_id}/messages/stream
POST /api/creation/match3d/sessions/{session_id}/actions
POST /api/creation/match3d/sessions/{session_id}/compile
GET /api/creation/match3d/works
GET /api/creation/match3d/works/{profile_id}
PATCH /api/creation/match3d/works/{profile_id}
PUT /api/creation/match3d/works/{profile_id}
DELETE /api/creation/match3d/works/{profile_id}
POST /api/creation/match3d/works/{profile_id}/publish
GET /api/runtime/match3d/gallery
POST /api/runtime/match3d/works/{profile_id}/runs
GET /api/runtime/match3d/runs/{run_id}
POST /api/runtime/match3d/runs/{run_id}/click
POST /api/runtime/match3d/runs/{run_id}/stop
POST /api/runtime/match3d/runs/{run_id}/restart
POST /api/runtime/match3d/runs/{run_id}/time-up
```
`api-server` 返回 `shared-contracts` 中的 Match3D DTO前端不需要感知 SpacetimeDB 内部 JSON 快照结构。
## 3. 创作 Agent 当前口径
B5 首版先采用确定性配置抽取,不在本阶段新增真实 LLM prompt
1. 创建会话时可从 `themeText / seedText / clearCount / difficulty / referenceImageSrc` 形成配置。
2. 发送消息时根据用户文本或 `quickFillRequested` 更新题材、需要消除次数和难度。
3. `match3d_compile_draft` 动作调用 SpacetimeDB `compile_match3d_draft`,生成 draft work profile。
后续若要接真实 LLM turn应复用现有创作 Agent 公共编排,并保持 submit/finalize 两阶段职责不变。
## 4. 运行态确认协议
B5 保持 PRD 调整后的边界:
1. 前端负责点击、飞入、入槽、三消、腾格、胜负等即时表现。
2. 后端通过 `click_match3d_item``finish_match3d_time_up` 等 procedure 做权威确认。
3. HTTP response 会把 SpacetimeDB `Accepted / VersionConflict / RunFinished` 等状态归一到前端 shared contract 可消费的 `accepted / rejectReason / run` 结构。
4. `snapshotVersion` 继续作为前端即时反馈和后端确认之间的版本校验字段。
## 5. shared contract 对齐
本阶段补齐 Rust shared contract 与 TypeScript contract 的已知差异:
1. `Match3DAgentSessionSnapshotResponse` 增加 `anchorPack`
2. `Match3DResultDraftResponse` 增加 `profileId / summaryText / totalItemCount`,同时保留 `summary` 兼容结果页读取。
3. `PutMatch3DWorkRequest` 增加可选 `themeText`,结果页可编辑题材;旧请求缺省时 API 会沿用已有作品题材。
## 6. 验收命令
本阶段至少执行:
```powershell
cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml
cargo check -p api-server --manifest-path server-rs\Cargo.toml
cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml
npm run check:encoding
npm run api-server:maincloud
```
`api-server:maincloud` 是修改后端后的必跑项;如果本地缺少 Maincloud 环境或 SpacetimeDB 发布态不一致,需要在最终结果里明确说明。
## 7. 后续接入点
1. F1/F2/F3 可把 mock client 替换到上述 HTTP facade。
2. F4 平台分发可先读取 `/api/runtime/match3d/gallery` 的已发布作品列表。
3. 若后续要记录排行榜或作品播放统计,需要补 Match3D 成绩表或 play record procedure并同步更新 migration。

View File

@@ -0,0 +1,55 @@
# 注册环节邀请码与管理员邀请码方案
更新时间:`2026-04-30`
## 背景
旧版“我的 Tab 填邀请码”设计把邀请码绑定放在登录后的个人面板中,容易让老账号重复发现入口,也不利于承接带邀请码的分享链接。本方案将邀请码填写收口到注册链路:未登录用户打开带 `inviteCode``invite_code` 查询参数的链接时,前端自动打开注册弹窗并预填邀请码。
## 落地边界
1. 注册入口复用当前手机号验证码登录自动建号能力,不新增独立注册系统。
2. 已登录用户不自动弹注册弹窗;登录后的“我的 Tab”只保留“邀请好友”不再提供“填邀请码”入口。
3. 邀请码只在本次手机号验证码登录创建新账号时尝试绑定。老账号登录时即使请求体带邀请码,也不会绑定。
4. 链接邀请码无效或不可用时不阻断注册,登录响应返回短错误提示,由前端展示;不写邀请关系、不发邀请奖励。
5. 普通登录态下的 `/api/profile/referrals/redeem-code` 不再允许手动填码,统一返回“邀请码仅注册时填写”。
## 数据与接口
`profile_invite_code` 增加 `metadata_json` 字段,默认 `{}`,用于保存渠道、活动、批次等元数据。旧迁移导入数据缺失该字段时由 `migration.rs``{}`
新增管理员接口:
- `POST /admin/api/profile/invite-codes`
请求:
```json
{
"inviteCode": "SPRING2026",
"metadata": {
"campaign": "spring"
}
}
```
管理员邀请码写入 SpacetimeDB 时使用虚拟主体:
```text
admin:{管理员用户名}:{邀请码}
```
管理员码只做归因和被邀请人奖励,不给虚拟主体写邀请人钱包流水。
手机号登录响应新增:
- `created`:本次登录是否创建新账号。
- `referral`:注册邀请码绑定结果;仅当本次提交了邀请码时返回。
## 验收标准
1. 未登录用户访问 `/?inviteCode=ABC123` 自动打开注册弹窗并预填 `ABC123`
2. 有效邀请码注册成功后,被邀请人获得陶泥币奖励,邀请关系落库。
3. 无效邀请码注册成功但不绑定,并返回短提示。
4. 管理员可添加邀请码并写入 metadata重复提交同管理员同码更新 metadata。
5. 管理员邀请码被使用时不产生 `admin:*` 虚拟主体的钱包流水。

View File

@@ -7,13 +7,16 @@
- [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。
- [SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md](./SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md):记录本地 standalone 启动时报 `mismatched database identity` 的 root-dir/replica 数据残留根因、备份重建步骤和脚本诊断口径。 - [SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md](./SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md):记录本地 standalone 启动时报 `mismatched database identity` 的 root-dir/replica 数据残留根因、备份重建步骤和脚本诊断口径。
- [LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md](./LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md):冻结 RPG 运行时剧情推理使用 `doubao-seed-character-251128``/chat/completions`,以及所有模板创作大模型推理使用 `deepseek-v3-2-251201``/responses` - [LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md](./LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md):冻结 RPG 运行时剧情推理使用 `doubao-seed-character-251128``/chat/completions`,以及所有模板创作大模型推理使用 `deepseek-v3-2-251201``/responses`
- [PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md](./PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md):冻结邀请码从“我的 Tab 填写”迁到注册环节的前后端边界、`profile_invite_code.metadata_json` 表结构扩展、管理员邀请码虚拟主体和奖励规则。
- [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。 - [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。
- [MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md](./MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md):冻结抓大鹅 Match3D B1+B2 的纯领域规则 crate、Rust/TypeScript shared contracts以及 Stage1 不触碰 SpacetimeDB 表和 api-server 的边界。 - [MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md](./MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md):冻结抓大鹅 Match3D B1+B2 的纯领域规则 crate、Rust/TypeScript shared contracts以及 Stage1 不触碰 SpacetimeDB 表和 api-server 的边界。
- [MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md](./MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md):记录抓大鹅 F1 创作入口、Agent 工作区、参考图入口、本地 mock client 与后续 B5 HTTP facade 替换点。 - [MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md](./MATCH3D_F1_CREATION_ENTRY_AND_AGENT_UI_2026-04-30.md):记录抓大鹅 F1 创作入口、Agent 工作区、参考图入口、本地 mock client 与后续 B5 HTTP facade 替换点。
- [MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md](./MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md):冻结抓大鹅 F2 结果页、基础信息编辑、发布前试玩入口、发布门槛、自动保存和已发布作品二次编辑恢复口径。 - [MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md](./MATCH3D_F2_RESULT_AND_PUBLISH_2026-04-30.md):冻结抓大鹅 F2 结果页、基础信息编辑、发布前试玩入口、发布门槛、自动保存和已发布作品二次编辑恢复口径。
- [MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md](./MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md):记录抓大鹅 B4+B5 已落地的 SpacetimeDB bindings、`spacetime-client` facade、`api-server` HTTP 路由、shared contract 对齐和验收命令。
- [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。 - [PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md](./PLATFORM_MOBILE_BOTTOM_DOCK_VIEWPORT_FIX_2026-04-30.md):记录平台首页底部 dock 在手机浏览器地址栏展开时脱离可见区域的根因,以及 `100dvh`、固定底部锚点和安全区占位的修复口径。
- [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。 - [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md):记录 SpacetimeDB private 表迁移 JSON 导出/导入 procedure、迁移操作员授权、HTTP 413 分片导入、Jenkins 自动迁移回灌和导入脚本参数。
- [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。 - [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md):记录 `Genarrative-Database-Export` / `Genarrative-Database-Import` 两条 SCM-backed 数据库迁移流水线参数、默认 dry-run、token 边界和 `CHUNK_SIZE` 413 规避参数。
- [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md):冻结后台管理独立前端工程 `apps/admin-web` 的技术方案,明确管理端只做表现、全部数据和写操作走 `server-rs``/admin/api/*`,并接管旧 `GET /admin` 内嵌页面的 UI 职责。
- [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs` - [RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md](./RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md):冻结 RPG 提示词禁止存在前端的边界,明确前端只保留 API client角色私聊/NPC 对话/剧情续写等 prompt 统一收口到 `server-rs`
- [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。 - [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。
- [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs``/api/runtime/custom-world/profile` 生成世界底稿。 - [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs``/api/runtime/custom-world/profile` 生成世界底稿。
@@ -61,7 +64,7 @@
- [CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md](./CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md):记录结果页 profile 归一化回写丢失顶层 `worldHook / playerPremise` 导致 publish gate 继续误报结构 blocker 的根因,并冻结前端归一化保留发布字段的修复口径。 - [CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md](./CREATION_AGENT_PUBLISH_GATE_NORMALIZE_WRITEBACK_FIX_2026-04-24.md):记录结果页 profile 归一化回写丢失顶层 `worldHook / playerPremise` 导致 publish gate 继续误报结构 blocker 的根因,并冻结前端归一化保留发布字段的修复口径。
- [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。 - [CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md](./CUSTOM_WORLD_RESULT_ENTITY_GENERATION_FIX_2026-04-24.md):记录世界结果页在 Agent 草稿模式下新增场景、新增 NPC 生成成功但结果页字段不可用的根因,并冻结 `api-server` 生成归一化层补齐 profile 字段的修复口径。
- [ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md](./ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md):冻结 Rust `api-server` 内后台管理服务首版方案,明确管理员用户名密码登录、管理员 JWT 鉴权、数据库概览、受控 API 调试台与同源管理页面的落地边界 - [ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md](./ADMIN_CONSOLE_SERVICE_DESIGN_2026-04-23.md):冻结 Rust `api-server` 内后台管理 API 首版方案;同源内嵌页面已取消,管理员登录、管理员 JWT 鉴权、数据库概览、受控 API 调试台和兑换码管理 API 保留给独立后台前端工程复用
- [SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md](./SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md):冻结 `server-rs/crates/spacetime-module/src/lib.rs` 的模块地图、二级落位点与迁移顺序,要求后续 SpacetimeDB 主工程改动按对应模块落位,不再继续堆回单大文件。 - [SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md](./SPACETIME_MODULE_LIB_RS_SPLIT_EXECUTION_2026-04-23.md):冻结 `server-rs/crates/spacetime-module/src/lib.rs` 的模块地图、二级落位点与迁移顺序,要求后续 SpacetimeDB 主工程改动按对应模块落位,不再继续堆回单大文件。
- [CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md](./CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md):冻结 `draft_foundation` 从 SpacetimeDB 内部规则编译迁到 `api-server + platform-llm` 的边界,明确草稿必须由 `api-server` 真实调 LLM 生成SpacetimeDB 只负责落库。 - [CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md](./CUSTOM_WORLD_DRAFT_FOUNDATION_API_SERVER_LLM_MIGRATION_2026-04-23.md):冻结 `draft_foundation` 从 SpacetimeDB 内部规则编译迁到 `api-server + platform-llm` 的边界,明确草稿必须由 `api-server` 真实调 LLM 生成SpacetimeDB 只负责落库。
- [CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md](./CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md):恢复 Custom World Agent 聊天必须走大模型推理的 Rust 落地方案,冻结 submit/finalize 两阶段职责、旧 Node 提示词原样搬运、SSE 流式回复与 session 回写边界。 - [CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md](./CUSTOM_WORLD_AGENT_LLM_REPLY_RESTORE_2026-04-22.md):恢复 Custom World Agent 聊天必须走大模型推理的 Rust 落地方案,冻结 submit/finalize 两阶段职责、旧 Node 提示词原样搬运、SSE 流式回复与 session 回写边界。
@@ -78,7 +81,7 @@
- [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本、发布包覆盖策略,以及部署阶段 SpacetimeDB schema 冲突自动导出、清库发布、导入回灌能力。 - [JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md](./JENKINS_RUST_BUILD_DEPLOY_PIPELINES_2026-04-23.md):冻结 Jenkins `构建 / 部署 / 构建并部署` 三条流水线的职责、版本号传递、上游触发门禁、本地目录部署脚本、发布包覆盖策略,以及部署阶段 SpacetimeDB schema 冲突自动导出、清库发布、导入回灌能力。
- [JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md](./JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md):记录 Jenkins 部署时 `.env.local` 首行 UTF-8 BOM 导致 `start.sh` 加载失败的根因,并冻结发布包构建、部署脚本和启动脚本的环境文件净化规则。 - [JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md](./JENKINS_DEPLOY_ENV_BOM_FIX_2026-04-25.md):记录 Jenkins 部署时 `.env.local` 首行 UTF-8 BOM 导致 `start.sh` 加载失败的根因,并冻结发布包构建、部署脚本和启动脚本的环境文件净化规则。
- [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust``npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传、安全清库开关,以及发布包内 schema 冲突自动迁移脚本。 - [RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md](./RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md):冻结 Rust 本地一键联调脚本与 Ubuntu 发布包构建脚本的执行口径,覆盖 `npm run dev:rust``npm run build:rust:ubuntu`、Vite release、Linux `api-server`、SpacetimeDB wasm、启动停止脚本、默认 scp 上传、安全清库开关,以及发布包内 schema 冲突自动迁移脚本。
- [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 101 条 Axum 路由,并补充管理后台入口与管理接口索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。 - [RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md](./RUST_API_SERVER_ROUTE_INDEX_2026-04-22.md):记录当前 Rust `api-server` 已挂载的 Axum 路由,并补充管理 API 索引,按 auth、assets、runtime、custom world、story、generated path 等挂载面归类,用于对照 Node 能力基线与切流 smoke 清单。
- [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。 - [BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md](./BACKEND_REWRITE_CROSS_CUTTING_GOVERNANCE_2026-04-22.md):冻结后端重写收口阶段的横向治理规则,覆盖 TypeScript contract 到 Rust DTO 映射、SpacetimeDB schema 演进、大对象 / workflow cache 存储边界和文档维护门禁。
- [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md)`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。 - [PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md](./PLATFORM_LLM_TEXT_GATEWAY_DESIGN_2026-04-21.md)`platform-llm` 文本模型网关首版设计,冻结 OpenAI 兼容 `/chat/completions`、SSE 增量解析、错误模型与重试边界。
- [API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md](./API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md)`api-server` 接入 `platform-llm` 的最小代理设计,冻结 `/api/llm/chat/completions` 的配置、状态注入与首版非流式兼容边界。 - [API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md](./API_SERVER_PLATFORM_LLM_PROXY_DESIGN_2026-04-21.md)`api-server` 接入 `platform-llm` 的最小代理设计,冻结 `/api/llm/chat/completions` 的配置、状态注入与首版非流式兼容边界。

View File

@@ -1,6 +1,6 @@
# Rust API Server 路由索引2026-04-23 # Rust API Server 路由索引2026-04-23
更新时间:`2026-04-23` 更新时间:`2026-04-30`
## 1. 文档目标 ## 1. 文档目标
@@ -10,9 +10,9 @@
## 2. 当前统计 ## 2. 当前统计
当前 Rust `api-server``app.rs` 可抽取到 `101` 条路由: 当前 Rust `api-server``app.rs` 可抽取到 `102` 条路由:
1. 管理后台接口`5` 条。 1. 管理后台 API`6` 条。
2. 内部鉴权调试接口:`2` 条。 2. 内部鉴权调试接口:`2` 条。
3. AI task 接口:`9` 条。 3. AI task 接口:`9` 条。
4. assets / OSS 接口:`15` 条。 4. assets / OSS 接口:`15` 条。
@@ -26,13 +26,16 @@
## 3. 路由清单 ## 3. 路由清单
### 3.1 管理后台 ### 3.1 管理后台 API
1. `GET /admin` 1. `POST /admin/api/login`
2. `POST /admin/api/login` 2. `GET /admin/api/me`
3. `GET /admin/api/me` 3. `GET /admin/api/overview`
4. `GET /admin/api/overview` 4. `POST /admin/api/debug/http`
5. `POST /admin/api/debug/http` 5. `POST /admin/api/profile/redeem-codes`
6. `POST /admin/api/profile/redeem-codes/disable`
`GET /admin` 同源内嵌页面入口已取消挂载,后台 UI 后续由独立前端工程承接。
### 3.2 内部鉴权调试 ### 3.2 内部鉴权调试

View File

@@ -10,6 +10,7 @@ pipeline {
string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签') string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '构建节点标签')
string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区')
string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER') string(name: 'BUILD_VERSION', defaultValue: '', description: '发布版本号,留空则使用 Jenkins BUILD_NUMBER')
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定要构建的 Git commit hash留空则使用 SCM 当前检出的提交')
string(name: 'DATABASE', defaultValue: 'genarrative-pipeline-local-test', description: '发布包默认 SpacetimeDB database') string(name: 'DATABASE', defaultValue: 'genarrative-pipeline-local-test', description: '发布包默认 SpacetimeDB database')
string(name: 'API_PORT', defaultValue: '8082', description: '发布包内 api-server 端口') string(name: 'API_PORT', defaultValue: '8082', description: '发布包内 api-server 端口')
string(name: 'WEB_PORT', defaultValue: '25001', description: '发布包内静态网站端口,默认 25001') string(name: 'WEB_PORT', defaultValue: '25001', description: '发布包内静态网站端口,默认 25001')
@@ -37,6 +38,11 @@ pipeline {
env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER env.EFFECTIVE_BUILD_VERSION = params.BUILD_VERSION?.trim() ? params.BUILD_VERSION.trim() : env.BUILD_NUMBER
// 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。 // 允许 Jenkins Job 直接指定固定源码目录,未指定时回退到当前工作区。
env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd() env.WORKSPACE_ROOT = params.GENARRATIVE_WORKSPACE_ROOT?.trim() ? params.GENARRATIVE_WORKSPACE_ROOT.trim() : pwd()
def commitHash = params.COMMIT_HASH?.trim()
if (commitHash && !(commitHash ==~ /^[0-9a-fA-F]{7,40}$/)) {
error('COMMIT_HASH 只能填写 7 到 40 位十六进制 Git commit hash当前值: ' + commitHash)
}
env.COMMIT_HASH = commitHash ?: ''
def database = params.DATABASE?.trim() def database = params.DATABASE?.trim()
if (!database) { if (!database) {
error('DATABASE 不能为空。') error('DATABASE 不能为空。')
@@ -102,14 +108,35 @@ pipeline {
sh ''' sh '''
bash -lc ' bash -lc '
set -euo pipefail set -euo pipefail
requested_commit="${COMMIT_HASH:-}"
if [[ -n "${requested_commit}" ]]; then
# Jenkins 先按 SCM 配置完成 checkout如指定 commit再拉取远端引用并切到该提交构建。
git fetch --tags --prune origin "+refs/heads/*:refs/remotes/origin/*" || git fetch --all --tags --prune
if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then
git fetch --unshallow --tags || true
fi
git cat-file -e "${requested_commit}^{commit}"
resolved_commit="$(git rev-parse "${requested_commit}^{commit}")"
git checkout --detach "${resolved_commit}"
echo "[build-and-deploy] 使用指定 commit 构建: ${resolved_commit}"
else
resolved_commit="$(git rev-parse HEAD)"
echo "[build-and-deploy] 使用 SCM checkout commit 构建: ${resolved_commit}"
fi
# 构建前清理工作区内的 Git 变更和未跟踪文件,避免复用固定源码目录时受到上次构建残留影响。 # 构建前清理工作区内的 Git 变更和未跟踪文件,避免复用固定源码目录时受到上次构建残留影响。
# 这里不使用 -x避免删除 node_modules 等忽略目录后与 RUN_NPM_CI=false 的配置冲突。 # 这里不使用 -x避免删除 node_modules 等忽略目录后与 RUN_NPM_CI=false 的配置冲突。
git reset --hard HEAD git reset --hard HEAD
git clean -fd git clean -fd
echo "${resolved_commit}" > "build-and-deploy-commit.txt"
rm -rf "build/${EFFECTIVE_BUILD_VERSION}" rm -rf "build/${EFFECTIVE_BUILD_VERSION}"
' '
''' '''
script {
env.EFFECTIVE_COMMIT_HASH = readFile('build-and-deploy-commit.txt').trim()
echo "构建 commit: ${env.EFFECTIVE_COMMIT_HASH}"
}
script { script {
// 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。 // 是否重装依赖交给流水线参数决定,避免每次构建都重复执行 npm ci。
if (params.RUN_NPM_CI) { if (params.RUN_NPM_CI) {

View File

@@ -8,6 +8,10 @@
"dev:rust": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh", "dev:rust": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh",
"dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", "dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh",
"dev:web": "node scripts/dev-web-rust.mjs", "dev:web": "node scripts/dev-web-rust.mjs",
"admin-web:dev": "npm --prefix apps/admin-web run dev --",
"admin-web:build": "npm --prefix apps/admin-web run build --",
"admin-web:typecheck": "npm --prefix apps/admin-web run typecheck --",
"admin-web:preview": "npm --prefix apps/admin-web run preview --",
"spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh", "spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh",
"spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs",
"api-server:maincloud": "node scripts/api-server-maincloud.mjs", "api-server:maincloud": "node scripts/api-server-maincloud.mjs",

View File

@@ -65,7 +65,7 @@ export type AuthPasswordResetResponse = {
export type AuthPhoneSendCodeRequest = { export type AuthPhoneSendCodeRequest = {
phone: string; phone: string;
scene?: 'login' | 'bind_phone' | 'change_phone'; scene?: 'login' | 'bind_phone' | 'change_phone' | 'reset_password';
captchaChallengeId?: string; captchaChallengeId?: string;
captchaAnswer?: string; captchaAnswer?: string;
}; };
@@ -80,11 +80,23 @@ export type AuthPhoneSendCodeResponse = {
export type AuthPhoneLoginRequest = { export type AuthPhoneLoginRequest = {
phone: string; phone: string;
code: string; code: string;
inviteCode?: string;
}; };
export type AuthPhoneLoginResponse = { export type AuthPhoneLoginResponse = {
token: string; token: string;
user: AuthUser; user: AuthUser;
created: boolean;
referral: AuthPhoneLoginReferral | null;
};
export type AuthPhoneLoginReferral = {
ok: boolean;
message: string | null;
inviteeRewardGranted: boolean;
inviterRewardGranted: boolean;
inviteeBalanceAfter: number | null;
inviterBalanceAfter: number | null;
}; };
export type AuthMeResponse = { export type AuthMeResponse = {

View File

@@ -6,6 +6,7 @@ export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
export interface PutMatch3DWorkRequest { export interface PutMatch3DWorkRequest {
gameName: string; gameName: string;
themeText?: string;
summary: string; summary: string;
tags: string[]; tags: string[];
coverImageSrc?: string | null; coverImageSrc?: string | null;

View File

@@ -177,6 +177,19 @@ export type RedeemProfileRewardCodeResponse = {
ledgerEntry: ProfileWalletLedgerEntry; ledgerEntry: ProfileWalletLedgerEntry;
}; };
export type AdminUpsertProfileInviteCodeRequest = {
inviteCode: string;
metadata?: Record<string, unknown> | null;
};
export type ProfileInviteCodeAdminResponse = {
userId: string;
inviteCode: string;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
};
export type ProfilePlayedWorkSummary = { export type ProfilePlayedWorkSummary = {
worldKey: string; worldKey: string;
ownerUserId: string | null; ownerUserId: string | null;

View File

@@ -180,9 +180,9 @@ function run(command, commandArgs) {
if (code === 0) { if (code === 0) {
if (output.includes('Could not format generated files')) { if (output.includes('Could not format generated files')) {
console.warn( // 中文注释Windows 下 Rust 绑定文件很多时SpacetimeDB CLI 可能已生成成功但 rustfmt 启动失败。
`[spacetime:generate] ${command} 已生成 bindings但 formatter 受限未执行;继续同步生成文件。`, // 这里保留后续文件数量校验,避免把格式化警告误判成绑定生成失败。
); console.warn(`[spacetime:generate] ${command} 生成后格式化失败,继续校验并同步生成文件。`);
} }
resolve(); resolve();
return; return;

2
server-rs/Cargo.lock generated
View File

@@ -84,6 +84,7 @@ dependencies = [
"module-combat", "module-combat",
"module-custom-world", "module-custom-world",
"module-inventory", "module-inventory",
"module-match3d",
"module-npc", "module-npc",
"module-puzzle", "module-puzzle",
"module-runtime", "module-runtime",
@@ -2668,6 +2669,7 @@ dependencies = [
"module-combat", "module-combat",
"module-custom-world", "module-custom-world",
"module-inventory", "module-inventory",
"module-match3d",
"module-npc", "module-npc",
"module-puzzle", "module-puzzle",
"module-runtime", "module-runtime",

View File

@@ -19,6 +19,7 @@ module-big-fish = { path = "../module-big-fish" }
module-combat = { path = "../module-combat" } module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" } module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" } module-inventory = { path = "../module-inventory" }
module-match3d = { path = "../module-match3d" }
module-npc = { path = "../module-npc" } module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" } module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" } module-runtime = { path = "../module-runtime" }

View File

@@ -11,7 +11,7 @@ use axum::{
header::{AUTHORIZATION, CONTENT_TYPE}, header::{AUTHORIZATION, CONTENT_TYPE},
}, },
middleware::Next, middleware::Next,
response::{Html, Response}, response::Response,
}; };
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
@@ -76,6 +76,9 @@ const DATABASE_OVERVIEW_TABLES: &[&str] = &[
"asset_object", "asset_object",
"asset_entity_binding", "asset_entity_binding",
]; ];
// SpacetimeDB 2.x 的 schema HTTP API 要求显式传入 BSATN JSON 版本。
// 后台总览只读取表名,固定使用当前 CLI 2.1.0 兼容的版本参数即可。
const SPACETIME_SCHEMA_VERSION_QUERY: &str = "version=9";
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct AuthenticatedAdmin { pub struct AuthenticatedAdmin {
@@ -100,17 +103,6 @@ struct SpacetimeSchemaTable {
name: Option<String>, name: Option<String>,
} }
#[derive(Debug, Deserialize)]
struct SpacetimeSqlRow {
#[serde(flatten)]
columns: serde_json::Map<String, Value>,
}
#[derive(Debug, Deserialize)]
struct SpacetimeSqlResponse {
rows: Option<Vec<SpacetimeSqlRow>>,
}
impl AuthenticatedAdmin { impl AuthenticatedAdmin {
pub fn new(session: AdminSessionPayload) -> Self { pub fn new(session: AdminSessionPayload) -> Self {
Self { session } Self { session }
@@ -121,10 +113,6 @@ impl AuthenticatedAdmin {
} }
} }
pub async fn admin_console_page() -> Html<&'static str> {
Html(ADMIN_CONSOLE_HTML)
}
pub async fn admin_login( pub async fn admin_login(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -287,7 +275,7 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
let schema = fetch_spacetime_json::<SpacetimeSchemaResponse>( let schema = fetch_spacetime_json::<SpacetimeSchemaResponse>(
&client, &client,
&format!("{server_root}/v1/database/{database}/schema"), &build_spacetime_schema_url(server_root, database),
token, token,
) )
.await .await
@@ -353,6 +341,10 @@ async fn fetch_database_overview(state: &AppState) -> AdminDatabaseOverviewPaylo
} }
} }
fn build_spacetime_schema_url(server_root: &str, database: &str) -> String {
format!("{server_root}/v1/database/{database}/schema?{SPACETIME_SCHEMA_VERSION_QUERY}")
}
async fn fetch_spacetime_json<T>( async fn fetch_spacetime_json<T>(
client: &Client, client: &Client,
url: &str, url: &str,
@@ -409,17 +401,63 @@ async fn fetch_spacetime_sql_count(
} }
let payload = response let payload = response
.json::<SpacetimeSqlResponse>() .json::<Value>()
.await .await
.map_err(|error| format!("SQL 响应解析失败:{error}"))?; .map_err(|error| format!("SQL 响应解析失败:{error}"))?;
let row = payload parse_spacetime_sql_count_response(payload)
.rows
.and_then(|rows| rows.into_iter().next())
.ok_or_else(|| "SQL 结果为空".to_string())?;
extract_sql_count(row.columns)
} }
fn extract_sql_count(columns: serde_json::Map<String, Value>) -> Result<u64, String> { fn parse_spacetime_sql_count_response(payload: Value) -> Result<u64, String> {
match payload {
// SpacetimeDB 2.x /sql 返回 statement result 数组,每个 result 内含 schema 与 rows。
Value::Array(statements) => {
let statement = statements
.into_iter()
.next()
.ok_or_else(|| "SQL 结果为空".to_string())?;
extract_sql_count_from_statement(statement)
}
// 保留兼容旧对象形状,便于本地/远端 API 小版本差异时仍能读取计数。
Value::Object(statement) => extract_sql_count_from_statement(Value::Object(statement)),
_ => Err("SQL 响应格式非法".to_string()),
}
}
fn extract_sql_count_from_statement(statement: Value) -> Result<u64, String> {
let Value::Object(mut statement) = statement else {
return Err("SQL statement 结果格式非法".to_string());
};
let schema = statement.remove("schema");
let rows = statement
.remove("rows")
.ok_or_else(|| "SQL 响应缺少 rows 字段".to_string())?;
extract_sql_count_from_rows(rows, schema.as_ref())
}
fn extract_sql_count_from_rows(rows: Value, schema: Option<&Value>) -> Result<u64, String> {
let Value::Array(rows) = rows else {
return Err("SQL rows 字段格式非法".to_string());
};
let row = rows.first().ok_or_else(|| "SQL 结果为空".to_string())?;
extract_sql_count_from_row(row, schema)
}
fn extract_sql_count_from_row(row: &Value, schema: Option<&Value>) -> Result<u64, String> {
match row {
Value::Object(columns) => extract_sql_count(columns),
Value::Array(values) => {
let count_index = schema.and_then(find_sql_count_column_index).unwrap_or(0);
values
.get(count_index)
.ok_or_else(|| "SQL 结果缺少 count 字段".to_string())
.and_then(parse_count_value)
}
value => parse_count_value(value),
}
}
fn extract_sql_count(columns: &serde_json::Map<String, Value>) -> Result<u64, String> {
for key in ["row_count", "count", "COUNT(*)"] { for key in ["row_count", "count", "COUNT(*)"] {
if let Some(value) = columns.get(key) { if let Some(value) = columns.get(key) {
return parse_count_value(value); return parse_count_value(value);
@@ -432,6 +470,25 @@ fn extract_sql_count(columns: serde_json::Map<String, Value>) -> Result<u64, Str
.and_then(parse_count_value) .and_then(parse_count_value)
} }
fn find_sql_count_column_index(schema: &Value) -> Option<usize> {
let elements = schema.get("elements")?.as_array()?;
elements.iter().position(|element| {
element
.get("name")
.and_then(extract_sql_schema_name)
.map(|name| matches!(name, "row_count" | "count" | "COUNT(*)"))
.unwrap_or(false)
})
}
fn extract_sql_schema_name(value: &Value) -> Option<&str> {
match value {
Value::String(text) => Some(text.as_str()),
Value::Object(object) => object.get("some").and_then(Value::as_str),
_ => None,
}
}
fn parse_count_value(value: &Value) -> Result<u64, String> { fn parse_count_value(value: &Value) -> Result<u64, String> {
match value { match value {
Value::Number(number) => number Value::Number(number) => number
@@ -602,520 +659,14 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
} }
} }
// 首版后台页面内嵌在 api-server避免新增独立前端工程与静态资源发布链。
static ADMIN_CONSOLE_HTML: &str = r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Genarrative 管理后台</title>
<style>
:root {
--bg: linear-gradient(180deg, #f4efe5 0%, #e7ddd0 100%);
--panel: rgba(255, 249, 240, 0.92);
--panel-strong: #fffaf2;
--line: rgba(90, 61, 41, 0.14);
--text: #2f241d;
--muted: #7b6657;
--accent: #b45a2f;
--accent-strong: #8f431f;
--ok: #2c7a54;
--danger: #a63f2f;
--shadow: 0 20px 50px rgba(70, 41, 19, 0.12);
--radius: 20px;
--font: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font);
color: var(--text);
background: var(--bg);
min-height: 100vh;
}
.shell {
max-width: 1180px;
margin: 0 auto;
padding: 20px 16px 40px;
}
.hero {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 18px;
}
.hero h1 {
margin: 0;
font-size: 28px;
line-height: 1.1;
letter-spacing: 0.02em;
}
.hero p {
margin: 10px 0 0;
color: var(--muted);
font-size: 14px;
}
.status-chip {
padding: 10px 14px;
border-radius: 999px;
background: rgba(180, 90, 47, 0.1);
color: var(--accent-strong);
font-size: 13px;
white-space: nowrap;
}
.grid {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 16px;
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.panel-head {
padding: 18px 18px 0;
}
.panel-head h2 {
margin: 0;
font-size: 18px;
}
.panel-head p {
margin: 8px 0 0;
color: var(--muted);
font-size: 13px;
}
.panel-body {
padding: 18px;
}
.form {
display: grid;
gap: 12px;
}
label {
display: grid;
gap: 6px;
font-size: 13px;
color: var(--muted);
}
input, textarea, select {
width: 100%;
border: 1px solid rgba(78, 53, 37, 0.12);
border-radius: 14px;
background: var(--panel-strong);
color: var(--text);
font: inherit;
padding: 12px 14px;
outline: none;
}
textarea {
min-height: 140px;
resize: vertical;
}
.btn-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 14px;
padding: 11px 16px;
background: var(--accent);
color: #fff8f2;
font: inherit;
cursor: pointer;
}
button.secondary {
background: rgba(180, 90, 47, 0.14);
color: var(--accent-strong);
}
button:disabled {
opacity: 0.6;
cursor: wait;
}
.stack {
display: grid;
gap: 16px;
}
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.metric {
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.45);
}
.metric .k {
font-size: 12px;
color: var(--muted);
}
.metric .v {
margin-top: 8px;
font-size: 18px;
font-weight: 700;
}
.data-grid {
display: grid;
gap: 10px;
}
.row {
display: grid;
grid-template-columns: 160px 1fr;
gap: 10px;
font-size: 13px;
align-items: start;
padding: 10px 0;
border-bottom: 1px solid rgba(78, 53, 37, 0.08);
}
.row:last-child {
border-bottom: none;
}
.row .k {
color: var(--muted);
}
.table-list {
display: grid;
gap: 10px;
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
.table-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255,255,255,0.52);
border: 1px solid rgba(78, 53, 37, 0.08);
align-items: center;
}
.table-item small {
color: var(--muted);
display: block;
margin-top: 4px;
}
.count {
font-weight: 700;
white-space: nowrap;
}
.count.err {
color: var(--danger);
font-weight: 600;
}
.result-panel {
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.55);
padding: 14px;
display: grid;
gap: 10px;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.5;
background: rgba(47, 36, 29, 0.06);
border-radius: 14px;
padding: 12px;
overflow: auto;
}
.hint {
font-size: 12px;
color: var(--muted);
}
.err-text { color: var(--danger); }
.ok-text { color: var(--ok); }
@media (max-width: 900px) {
.grid { grid-template-columns: 1fr; }
.hero { flex-direction: column; }
.row { grid-template-columns: 1fr; gap: 4px; }
}
</style>
</head>
<body>
<div class="shell">
<div class="hero">
<div>
<h1>Genarrative 管理后台</h1>
<p>查看服务状态、数据库概览,并对当前 API 做受控调试。</p>
</div>
<div id="admin-status" class="status-chip">未登录</div>
</div>
<div class="grid">
<div class="stack">
<section class="panel">
<div class="panel-head">
<h2>管理员登录</h2>
<p>使用配置的后台账号进入管理域。</p>
</div>
<div class="panel-body">
<form id="login-form" class="form">
<label>用户名
<input id="login-username" name="username" autocomplete="username" />
</label>
<label>密码
<input id="login-password" name="password" type="password" autocomplete="current-password" />
</label>
<div class="btn-row">
<button id="login-submit" type="submit">登录后台</button>
<button id="login-clear" class="secondary" type="button">清空令牌</button>
</div>
<div id="login-message" class="hint"></div>
</form>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>API 调试</h2>
<p>对当前服务做同源受控请求。</p>
</div>
<div class="panel-body">
<form id="debug-form" class="form">
<label>方法
<select id="debug-method">
<option>GET</option>
<option>POST</option>
<option>PUT</option>
<option>DELETE</option>
<option>PATCH</option>
</select>
</label>
<label>路径
<input id="debug-path" value="/healthz" />
</label>
<label>附加请求头JSON 数组)
<textarea id="debug-headers">[]</textarea>
</label>
<label>请求体
<textarea id="debug-body"></textarea>
</label>
<div class="btn-row">
<button id="debug-submit" type="submit">发送调试请求</button>
</div>
</form>
</div>
</section>
</div>
<div class="stack">
<section class="panel">
<div class="panel-head">
<h2>数据库概览</h2>
<p>读取当前服务配置和 SpacetimeDB 数据库真相。</p>
</div>
<div class="panel-body">
<div class="btn-row" style="margin-bottom:14px;">
<button id="refresh-overview" type="button">刷新概览</button>
</div>
<div id="overview-metrics" class="metrics"></div>
<div id="overview-detail" class="data-grid" style="margin-top:14px;"></div>
<div id="overview-errors" class="hint err-text" style="margin-top:10px;"></div>
<div id="overview-tables" class="table-list" style="margin-top:14px;"></div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>调试结果</h2>
<p>返回状态、响应头和内容预览。</p>
</div>
<div class="panel-body">
<div id="debug-result" class="result-panel">
<div class="hint">尚未执行调试请求。</div>
</div>
</div>
</section>
</div>
</div>
</div>
<script>
const TOKEN_KEY = 'genarrative_admin_token';
const statusEl = document.getElementById('admin-status');
const loginMessageEl = document.getElementById('login-message');
const overviewMetricsEl = document.getElementById('overview-metrics');
const overviewDetailEl = document.getElementById('overview-detail');
const overviewTablesEl = document.getElementById('overview-tables');
const overviewErrorsEl = document.getElementById('overview-errors');
const debugResultEl = document.getElementById('debug-result');
function getToken() {
return window.localStorage.getItem(TOKEN_KEY) || '';
}
function setToken(token) {
if (!token) {
window.localStorage.removeItem(TOKEN_KEY);
return;
}
window.localStorage.setItem(TOKEN_KEY, token);
}
function setStatus(text, ok) {
statusEl.textContent = text;
statusEl.style.background = ok ? 'rgba(44,122,84,0.12)' : 'rgba(180,90,47,0.1)';
statusEl.style.color = ok ? '#2c7a54' : '#8f431f';
}
async function request(path, options = {}) {
const headers = new Headers(options.headers || {});
const token = getToken();
if (token) {
headers.set('authorization', `Bearer ${token}`);
}
if (options.json !== undefined) {
headers.set('content-type', 'application/json');
options.body = JSON.stringify(options.json);
}
const response = await fetch(path, { ...options, headers });
const text = await response.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) {}
if (!response.ok) {
const message = data?.error?.message || data?.message || text || `HTTP ${response.status}`;
throw new Error(message);
}
return data?.data ?? data;
}
function renderOverview(overview) {
const service = overview.service || {};
const database = overview.database || {};
const stats = Array.isArray(database.tableStats) ? database.tableStats : [];
overviewMetricsEl.innerHTML = `
<div class="metric"><div class="k">后台状态</div><div class="v">${service.adminEnabled ? '已启用' : '未启用'}</div></div>
<div class="metric"><div class="k">服务监听</div><div class="v">${service.bindHost || '-'}:${service.bindPort || '-'}</div></div>
<div class="metric"><div class="k">SpacetimeDB</div><div class="v">${service.spacetimeDatabase || '-'}</div></div>
<div class="metric"><div class="k">统计表数</div><div class="v">${stats.length}</div></div>
`;
overviewDetailEl.innerHTML = `
<div class="row"><div class="k">JWT Issuer</div><div>${service.jwtIssuer || '-'}</div></div>
<div class="row"><div class="k">Spacetime 服务</div><div>${service.spacetimeServerUrl || '-'}</div></div>
<div class="row"><div class="k">数据库 Identity</div><div>${database.databaseIdentity || '-'}</div></div>
<div class="row"><div class="k">Owner Identity</div><div>${database.ownerIdentity || '-'}</div></div>
<div class="row"><div class="k">Host Type</div><div>${database.hostType || '-'}</div></div>
<div class="row"><div class="k">Schema 表数量</div><div>${(database.schemaTableNames || []).length}</div></div>
`;
overviewTablesEl.innerHTML = stats.map((item) => `
<div class="table-item">
<div>
<strong>${item.tableName}</strong>
${item.errorMessage ? `<small class="err-text">${item.errorMessage}</small>` : ''}
</div>
<div class="count ${item.errorMessage ? 'err' : ''}">${item.rowCount ?? '失败'}</div>
</div>
`).join('');
overviewErrorsEl.textContent = (database.fetchErrors || []).join(' | ');
}
function renderDebugResult(result) {
const headerText = (result.headers || []).map((item) => `${item.name}: ${item.value}`).join('\n');
debugResultEl.innerHTML = `
<div><strong>状态:</strong><span class="${result.status < 400 ? 'ok-text' : 'err-text'}">${result.status} ${result.statusText}</span></div>
<div><strong>响应头</strong><pre>${headerText || '(无)'}</pre></div>
<div><strong>响应体预览</strong><pre>${result.bodyText || '(空)'}</pre></div>
<div><strong>响应 JSON</strong><pre>${result.bodyJson ? JSON.stringify(result.bodyJson, null, 2) : '(不是 JSON)'}</pre></div>
`;
}
async function loadMe() {
const token = getToken();
if (!token) {
setStatus('未登录', false);
return;
}
try {
const result = await request('/admin/api/me');
setStatus(`管理员:${result.admin.displayName}`, true);
} catch (error) {
setToken('');
setStatus('未登录', false);
}
}
async function loadOverview() {
try {
const overview = await request('/admin/api/overview');
renderOverview(overview);
} catch (error) {
overviewMetricsEl.innerHTML = '';
overviewDetailEl.innerHTML = '';
overviewTablesEl.innerHTML = '';
overviewErrorsEl.textContent = error.message;
}
}
document.getElementById('login-form').addEventListener('submit', async (event) => {
event.preventDefault();
loginMessageEl.textContent = '正在登录...';
try {
const result = await request('/admin/api/login', {
method: 'POST',
json: {
username: document.getElementById('login-username').value,
password: document.getElementById('login-password').value,
},
});
setToken(result.token);
loginMessageEl.textContent = '登录成功';
await loadMe();
await loadOverview();
} catch (error) {
loginMessageEl.textContent = error.message;
}
});
document.getElementById('login-clear').addEventListener('click', () => {
setToken('');
setStatus('未登录', false);
loginMessageEl.textContent = '已清空本地令牌';
});
document.getElementById('refresh-overview').addEventListener('click', async () => {
await loadOverview();
});
document.getElementById('debug-form').addEventListener('submit', async (event) => {
event.preventDefault();
debugResultEl.innerHTML = '<div class="hint">正在请求...</div>';
try {
const headers = JSON.parse(document.getElementById('debug-headers').value || '[]');
const result = await request('/admin/api/debug/http', {
method: 'POST',
json: {
method: document.getElementById('debug-method').value,
path: document.getElementById('debug-path').value,
headers,
body: document.getElementById('debug-body').value,
},
});
renderDebugResult(result);
} catch (error) {
debugResultEl.innerHTML = `<div class="err-text">${error.message}</div>`;
}
});
loadMe().then(loadOverview);
</script>
</body>
</html>"#;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{build_body_preview, build_debug_base_url, normalize_debug_path, trim_preview}; use super::{
build_body_preview, build_debug_base_url, build_spacetime_schema_url, normalize_debug_path,
parse_spacetime_sql_count_response, trim_preview,
};
use axum::{http::StatusCode, response::IntoResponse}; use axum::{http::StatusCode, response::IntoResponse};
use serde_json::json;
#[test] #[test]
fn normalize_debug_path_rejects_absolute_url() { fn normalize_debug_path_rejects_absolute_url() {
@@ -1161,6 +712,91 @@ mod tests {
assert_eq!(trim_preview(&text).chars().count(), 4000); assert_eq!(trim_preview(&text).chars().count(), 4000);
} }
#[test]
fn build_spacetime_schema_url_includes_required_version_query() {
let url = build_spacetime_schema_url("http://127.0.0.1:3101", "xushi-p4wfr");
assert_eq!(
url,
"http://127.0.0.1:3101/v1/database/xushi-p4wfr/schema?version=9"
);
}
#[test]
fn parse_spacetime_sql_count_response_accepts_statement_array_rows() {
let payload = json!([
{
"schema": {
"elements": [
{
"name": {
"some": "row_count"
},
"algebraic_type": {
"U64": []
}
}
]
},
"rows": [[7]],
"total_duration_micros": 116,
"stats": {
"rows_inserted": 0,
"rows_deleted": 0,
"rows_updated": 0
}
}
]);
let count =
parse_spacetime_sql_count_response(payload).expect("statement array should parse");
assert_eq!(count, 7);
}
#[test]
fn parse_spacetime_sql_count_response_uses_schema_column_index() {
let payload = json!([
{
"schema": {
"elements": [
{
"name": {
"some": "table_name"
}
},
{
"name": {
"some": "row_count"
}
}
]
},
"rows": [["runtime_setting", "12"]]
}
]);
let count =
parse_spacetime_sql_count_response(payload).expect("schema column index should parse");
assert_eq!(count, 12);
}
#[test]
fn parse_spacetime_sql_count_response_keeps_object_row_compatibility() {
let payload = json!({
"rows": [
{
"row_count": "3"
}
]
});
let count = parse_spacetime_sql_count_response(payload).expect("object row should parse");
assert_eq!(count, 3);
}
#[test] #[test]
fn build_body_preview_handles_utf8() { fn build_body_preview_handles_utf8() {
let preview = build_body_preview("后台测试".as_bytes()); let preview = build_body_preview("后台测试".as_bytes());

View File

@@ -13,10 +13,7 @@ use tower_http::{
use tracing::{Level, Span, error, info, info_span, warn}; use tracing::{Level, Span, error, info, info_span, warn};
use crate::{ use crate::{
admin::{ admin::{admin_debug_http, admin_login, admin_me, admin_overview, require_admin_auth},
admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview,
require_admin_auth,
},
ai_tasks::{ ai_tasks::{
append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage, append_ai_text_chunk, attach_ai_result_reference, cancel_ai_task, complete_ai_stage,
complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage, complete_ai_task, create_ai_task, fail_ai_task, start_ai_task, start_ai_task_stage,
@@ -78,6 +75,13 @@ use crate::{
login_options::auth_login_options, login_options::auth_login_options,
logout::logout, logout::logout,
logout_all::logout_all, logout_all::logout_all,
match3d::{
click_match3d_item, create_match3d_agent_session, delete_match3d_work,
execute_match3d_agent_action, finish_match3d_time_up, get_match3d_agent_session,
get_match3d_run, get_match3d_work_detail, get_match3d_works, list_match3d_gallery,
publish_match3d_work, put_match3d_work, restart_match3d_run, start_match3d_run,
stop_match3d_run, stream_match3d_agent_message, submit_match3d_agent_message,
},
password_entry::password_entry, password_entry::password_entry,
password_management::{change_password, reset_password}, password_management::{change_password, reset_password},
phone_auth::{phone_login, send_phone_code}, phone_auth::{phone_login, send_phone_code},
@@ -105,10 +109,10 @@ use crate::{
}, },
runtime_inventory::get_runtime_inventory_state, runtime_inventory::get_runtime_inventory_state,
runtime_profile::{ runtime_profile::{
admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code, admin_disable_profile_redeem_code, admin_upsert_profile_invite_code,
create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats, admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard,
get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger, get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center,
redeem_profile_referral_invite_code, redeem_profile_reward_code, get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code,
}, },
runtime_save::{ runtime_save::{
delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives, delete_runtime_snapshot, get_runtime_snapshot, list_profile_save_archives,
@@ -135,7 +139,6 @@ pub fn build_router(state: AppState) -> Router {
let slow_request_threshold_ms = state.config.slow_request_threshold_ms; let slow_request_threshold_ms = state.config.slow_request_threshold_ms;
Router::new() Router::new()
.route("/admin", get(admin_console_page))
.route("/admin/api/login", post(admin_login)) .route("/admin/api/login", post(admin_login))
.route( .route(
"/admin/api/me", "/admin/api/me",
@@ -172,6 +175,13 @@ pub fn build_router(state: AppState) -> Router {
require_admin_auth, require_admin_auth,
)), )),
) )
.route(
"/admin/api/profile/invite-codes",
post(admin_upsert_profile_invite_code).route_layer(middleware::from_fn_with_state(
state.clone(),
require_admin_auth,
)),
)
.route( .route(
"/healthz", "/healthz",
get(|Extension(request_context): Extension<_>| async move { get(|Extension(request_context): Extension<_>| async move {
@@ -702,6 +712,116 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.route(
"/api/creation/match3d/sessions",
post(create_match3d_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}",
get(get_match3d_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}/messages",
post(submit_match3d_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}/messages/stream",
post(stream_match3d_agent_message).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}/actions",
post(execute_match3d_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/sessions/{session_id}/compile",
post(execute_match3d_agent_action).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works",
get(get_match3d_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}",
get(get_match3d_work_detail)
.patch(put_match3d_work)
.put(put_match3d_work)
.delete(delete_match3d_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/match3d/works/{profile_id}/publish",
post(publish_match3d_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route("/api/runtime/match3d/gallery", get(list_match3d_gallery))
.route(
"/api/runtime/match3d/works/{profile_id}/runs",
post(start_match3d_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}",
get(get_match3d_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}/click",
post(click_match3d_item).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}/stop",
post(stop_match3d_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}/restart",
post(restart_match3d_run).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/match3d/runs/{run_id}/time-up",
post(finish_match3d_time_up).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route( .route(
"/api/runtime/puzzle/agent/sessions", "/api/runtime/puzzle/agent/sessions",
post(create_puzzle_agent_session) post(create_puzzle_agent_session)
@@ -1993,6 +2113,8 @@ mod tests {
payload["user"]["phoneNumberMasked"], payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string()) Value::String("138****8000".to_string())
); );
assert_eq!(payload["created"], Value::Bool(true));
assert!(payload["referral"].is_null());
} }
#[tokio::test] #[tokio::test]
@@ -2099,6 +2221,175 @@ mod tests {
serde_json::from_slice(&second_body).expect("second login payload should be json"); serde_json::from_slice(&second_body).expect("second login payload should be json");
assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]); assert_eq!(first_payload["user"]["id"], second_payload["user"]["id"]);
assert_eq!(first_payload["created"], Value::Bool(true));
assert_eq!(second_payload["created"], Value::Bool(false));
assert!(second_payload["referral"].is_null());
}
#[tokio::test]
async fn phone_login_invite_code_failure_does_not_block_created_user() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13600136000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(send_code_response.status(), StatusCode::OK);
let login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13600136000",
"code": "123456",
"inviteCode": "SPRING2026"
})
.to_string(),
))
.expect("login request should build"),
)
.await
.expect("login request should succeed");
assert_eq!(login_response.status(), StatusCode::OK);
let body = login_response
.into_body()
.collect()
.await
.expect("login body should collect")
.to_bytes();
let payload: Value = serde_json::from_slice(&body).expect("login payload should be json");
assert!(payload["token"].as_str().is_some());
assert_eq!(payload["created"], Value::Bool(true));
assert_eq!(payload["referral"]["ok"], Value::Bool(false));
assert_eq!(
payload["referral"]["message"],
Value::String("邀请码无效,已继续注册".to_string())
);
}
#[tokio::test]
async fn phone_login_existing_user_ignores_invite_code() {
let config = AppConfig {
sms_auth_enabled: true,
..AppConfig::default()
};
let app = build_router(AppState::new(config).expect("state should build"));
let first_send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13500135000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(first_send_code_response.status(), StatusCode::OK);
let first_login_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13500135000",
"code": "123456"
})
.to_string(),
))
.expect("first login request should build"),
)
.await
.expect("first login request should succeed");
assert_eq!(first_login_response.status(), StatusCode::OK);
let second_send_code_response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/send-code")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13500135000",
"scene": "login"
})
.to_string(),
))
.expect("send code request should build"),
)
.await
.expect("send code request should succeed");
assert_eq!(second_send_code_response.status(), StatusCode::OK);
let second_login_response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/auth/phone/login")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"phone": "13500135000",
"code": "123456",
"inviteCode": "SPRING2026"
})
.to_string(),
))
.expect("second login request should build"),
)
.await
.expect("second login request should succeed");
assert_eq!(second_login_response.status(), StatusCode::OK);
let body = second_login_response
.into_body()
.collect()
.await
.expect("second login body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("second login payload should be json");
assert_eq!(payload["created"], Value::Bool(false));
assert!(payload["referral"].is_null());
} }
#[tokio::test] #[tokio::test]
@@ -3314,6 +3605,23 @@ mod tests {
); );
} }
#[tokio::test]
async fn admin_page_route_is_not_mounted() {
let app = build_router(AppState::new(AppConfig::default()).expect("state should build"));
let response = app
.oneshot(
Request::builder()
.uri("/admin")
.body(Body::empty())
.expect("admin page request should build"),
)
.await
.expect("admin page request should succeed");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test] #[tokio::test]
async fn admin_login_returns_token_when_configured() { async fn admin_login_returns_token_when_configured() {
let mut config = AppConfig::default(); let mut config = AppConfig::default();

View File

@@ -40,6 +40,7 @@ mod llm_model_routing;
mod login_options; mod login_options;
mod logout; mod logout;
mod logout_all; mod logout_all;
mod match3d;
mod password_entry; mod password_entry;
mod password_management; mod password_management;
mod phone_auth; mod phone_auth;

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,8 @@ use module_auth::{
}; };
use serde_json::json; use serde_json::json;
use shared_contracts::auth::{ use shared_contracts::auth::{
PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse, PhoneLoginReferralResponse, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest,
PhoneSendCodeResponse,
}; };
use time::OffsetDateTime; use time::OffsetDateTime;
use tracing::{info, warn}; use tracing::{info, warn};
@@ -110,6 +111,7 @@ pub async fn phone_login(
AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用") AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用")
); );
} }
let invite_code = payload.invite_code.clone();
let result = match state let result = match state
.phone_auth_service() .phone_auth_service()
.login( .login(
@@ -146,6 +148,18 @@ pub async fn phone_login(
return Err(map_phone_auth_error(error)); return Err(map_phone_auth_error(error));
} }
}; };
let created = result.created;
let referral = if created {
bind_referral_invite_code_on_registration(
&state,
&request_context,
result.user.id.clone(),
invite_code,
)
.await
} else {
None
};
let session_client = resolve_session_client_context(&headers); let session_client = resolve_session_client_context(&headers);
let signed_session = create_auth_session( let signed_session = create_auth_session(
&state, &state,
@@ -174,11 +188,55 @@ pub async fn phone_login(
PhoneLoginResponse { PhoneLoginResponse {
token: signed_session.access_token, token: signed_session.access_token,
user: map_auth_user_payload(result.user), user: map_auth_user_payload(result.user),
created,
referral,
}, },
), ),
)) ))
} }
async fn bind_referral_invite_code_on_registration(
state: &AppState,
request_context: &RequestContext,
user_id: String,
invite_code: Option<String>,
) -> Option<PhoneLoginReferralResponse> {
let invite_code = invite_code
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
match state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, invite_code, updated_at_micros as i64)
.await
{
Ok(record) => Some(PhoneLoginReferralResponse {
ok: true,
message: Some("邀请码已绑定".to_string()),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: Some(record.invitee_balance_after),
inviter_balance_after: Some(record.inviter_balance_after),
}),
Err(error) => {
warn!(
request_id = request_context.request_id(),
operation = request_context.operation(),
error = %error,
"注册邀请码绑定失败,登录流程继续"
);
Some(PhoneLoginReferralResponse {
ok: false,
message: Some("邀请码无效,已继续注册".to_string()),
invitee_reward_granted: false,
inviter_reward_granted: false,
invitee_balance_after: None,
inviter_balance_after: None,
})
}
}
}
fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppError> { fn map_phone_auth_scene(raw_scene: Option<&str>) -> Result<PhoneAuthScene, AppError> {
match raw_scene.unwrap_or("login").trim() { match raw_scene.unwrap_or("login").trim() {
"login" => Ok(PhoneAuthScene::Login), "login" => Ok(PhoneAuthScene::Login),

View File

@@ -5,18 +5,18 @@ use axum::{
response::Response, response::Response,
}; };
use module_runtime::{ use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord,
RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord,
RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralRedeemRecord, RuntimeReferralInviteCenterRecord,
}; };
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::runtime::{ use shared_contracts::runtime::{
AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest, AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileInviteCodeRequest,
CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD,
@@ -24,13 +24,12 @@ use shared_contracts::runtime::{
PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD,
PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse,
ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse,
ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse,
ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse,
ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse, ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse,
ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest,
RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse,
RedeemProfileRewardCodeResponse,
}; };
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -217,27 +216,14 @@ pub async fn get_profile_referral_invite_center(
} }
pub async fn redeem_profile_referral_invite_code( pub async fn redeem_profile_referral_invite_code(
State(state): State<AppState>, State(_state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>, Extension(_authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<RedeemProfileReferralInviteCodeRequest>, Json(_payload): Json<RedeemProfileReferralInviteCodeRequest>,
) -> Result<Json<Value>, Response> { ) -> Result<Json<Value>, Response> {
let user_id = authenticated.claims().user_id().to_string(); Err(runtime_profile_error_response(
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.redeem_profile_referral_invite_code(user_id, payload.invite_code, updated_at_micros as i64)
.await
.map_err(|error| {
runtime_profile_error_response(
&request_context, &request_context,
map_runtime_profile_client_error(error), AppError::from_status(StatusCode::BAD_REQUEST).with_message("邀请码仅注册时填写"),
)
})?;
Ok(json_success_body(
Some(&request_context),
build_redeem_profile_referral_invite_code_response(record),
)) ))
} }
@@ -334,6 +320,37 @@ pub async fn admin_disable_profile_redeem_code(
)) ))
} }
pub async fn admin_upsert_profile_invite_code(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(admin): Extension<AuthenticatedAdmin>,
Json(payload): Json<AdminUpsertProfileInviteCodeRequest>,
) -> Result<Json<Value>, Response> {
let metadata_json = normalize_admin_invite_code_metadata(payload.metadata)
.map_err(|error| runtime_profile_error_response(&request_context, error))?;
let updated_at_micros = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000;
let record = state
.spacetime_client()
.admin_upsert_profile_invite_code(
admin.session().username.clone(),
payload.invite_code,
metadata_json,
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_invite_code_admin_response(record),
))
}
pub async fn get_profile_play_stats( pub async fn get_profile_play_stats(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -490,18 +507,6 @@ fn build_profile_referral_invite_center_response(
} }
} }
fn build_redeem_profile_referral_invite_code_response(
record: RuntimeReferralRedeemRecord,
) -> RedeemProfileReferralInviteCodeResponse {
RedeemProfileReferralInviteCodeResponse {
center: build_profile_referral_invite_center_response(record.center),
invitee_reward_granted: record.invitee_reward_granted,
inviter_reward_granted: record.inviter_reward_granted,
invitee_balance_after: record.invitee_balance_after,
inviter_balance_after: record.inviter_balance_after,
}
}
fn build_redeem_profile_reward_code_response( fn build_redeem_profile_reward_code_response(
record: RuntimeProfileRewardCodeRedeemRecord, record: RuntimeProfileRewardCodeRedeemRecord,
) -> RedeemProfileRewardCodeResponse { ) -> RedeemProfileRewardCodeResponse {
@@ -519,6 +524,30 @@ fn build_redeem_profile_reward_code_response(
} }
} }
fn normalize_admin_invite_code_metadata(metadata: Option<Value>) -> Result<String, AppError> {
let metadata = match metadata {
Some(Value::Null) | None => json!({}),
Some(value) if value.is_object() => value,
Some(_) => {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("邀请码 metadata 必须是 JSON 对象")
.with_details(json!({ "field": "metadata" })));
}
};
let metadata_json = serde_json::to_string(&metadata).map_err(|error| {
AppError::from_status(StatusCode::BAD_REQUEST)
.with_message(format!("邀请码 metadata 序列化失败:{error}"))
.with_details(json!({ "field": "metadata" }))
})?;
if metadata_json.len() > 4096 {
return Err(AppError::from_status(StatusCode::BAD_REQUEST)
.with_message("邀请码 metadata 不能超过 4096 bytes")
.with_details(json!({ "field": "metadata" })));
}
Ok(metadata_json)
}
fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> { fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeMode, String> {
match raw.trim().to_ascii_lowercase().as_str() { match raw.trim().to_ascii_lowercase().as_str() {
"public" => Ok(RuntimeProfileRedeemCodeMode::Public), "public" => Ok(RuntimeProfileRedeemCodeMode::Public),
@@ -528,6 +557,20 @@ fn parse_profile_redeem_code_mode(raw: &str) -> Result<RuntimeProfileRedeemCodeM
} }
} }
fn build_profile_invite_code_admin_response(
record: RuntimeProfileInviteCodeRecord,
) -> ProfileInviteCodeAdminResponse {
let metadata =
serde_json::from_str::<Value>(&record.metadata_json).unwrap_or_else(|_| json!({}));
ProfileInviteCodeAdminResponse {
user_id: record.user_id,
invite_code: record.invite_code,
metadata,
created_at: record.created_at,
updated_at: record.updated_at,
}
}
fn build_profile_redeem_code_admin_response( fn build_profile_redeem_code_admin_response(
record: RuntimeProfileRedeemCodeRecord, record: RuntimeProfileRedeemCodeRecord,
) -> ProfileRedeemCodeAdminResponse { ) -> ProfileRedeemCodeAdminResponse {
@@ -549,7 +592,7 @@ fn build_profile_redeem_code_admin_response(
mod tests { mod tests {
use module_runtime::RuntimeProfileWalletLedgerSourceType; use module_runtime::RuntimeProfileWalletLedgerSourceType;
use super::format_profile_wallet_ledger_source_type; use super::{format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata};
use axum::{ use axum::{
body::Body, body::Body,
@@ -715,6 +758,60 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
#[tokio::test]
async fn profile_referral_redeem_code_rejects_authenticated_manual_fill() {
let state = seed_authenticated_state().await;
let token = issue_access_token(&state);
let app = build_router(state);
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/profile/referrals/redeem-code")
.header("authorization", format!("Bearer {token}"))
.header("content-type", "application/json")
.body(Body::from(r#"{"inviteCode":"SY12345678"}"#))
.expect("request should build"),
)
.await
.expect("request should succeed");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response
.into_body()
.collect()
.await
.expect("body should collect")
.to_bytes();
let payload: Value =
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(
payload["error"]["message"],
Value::String("邀请码仅注册时填写".to_string())
);
}
#[test]
fn admin_invite_code_metadata_accepts_only_json_object() {
assert_eq!(
normalize_admin_invite_code_metadata(None).expect("empty metadata should default"),
"{}"
);
assert_eq!(
normalize_admin_invite_code_metadata(Some(serde_json::json!({
"channel": "spring",
"source": "banner"
})))
.expect("object metadata should serialize"),
r#"{"channel":"spring","source":"banner"}"#
);
let error = normalize_admin_invite_code_metadata(Some(serde_json::json!("spring")))
.expect_err("non-object metadata should reject");
assert_eq!(error.message(), "邀请码 metadata 必须是 JSON 对象");
}
#[tokio::test] #[tokio::test]
async fn profile_dashboard_compat_route_matches_main_route_error_shape() { async fn profile_dashboard_compat_route_matches_main_route_error_shape() {
assert_compat_route_matches_main_route_error_shape( assert_compat_route_matches_main_route_error_shape(

View File

@@ -17,6 +17,8 @@ pub const MAX_BROWSE_HISTORY_BATCH_SIZE: usize = 100;
pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50; pub const PROFILE_WALLET_LEDGER_LIST_LIMIT: usize = 50;
pub const PROFILE_REFERRAL_REWARD_POINTS: u64 = 30; pub const PROFILE_REFERRAL_REWARD_POINTS: u64 = 30;
pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10; pub const PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT: u32 = 10;
pub const PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON: &str = "{}";
const PROFILE_INVITE_CODE_METADATA_MAX_BYTES: usize = 4096;
pub const SAVE_SNAPSHOT_VERSION: u32 = 2; pub const SAVE_SNAPSHOT_VERSION: u32 = 2;
pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。";
pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock";
@@ -503,6 +505,33 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult {
pub error_message: Option<String>, pub error_message: Option<String>,
} }
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeAdminUpsertInput {
pub admin_user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeSnapshot {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileInviteCodeAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileInviteCodeSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeReferralInviteCenterSnapshot { pub struct RuntimeReferralInviteCenterSnapshot {
@@ -616,6 +645,7 @@ pub enum RuntimeProfileFieldError {
MissingLedgerId, MissingLedgerId,
InvalidWalletAmount, InvalidWalletAmount,
MissingInviteCode, MissingInviteCode,
InvalidInviteCodeMetadata,
MissingRedeemCode, MissingRedeemCode,
InvalidRedeemCodeReward, InvalidRedeemCodeReward,
InvalidRedeemCodeMaxUses, InvalidRedeemCodeMaxUses,
@@ -917,6 +947,17 @@ pub struct RuntimeProfileRedeemCodeRecord {
pub updated_at_micros: i64, pub updated_at_micros: i64,
} }
#[derive(Clone, Debug, PartialEq)]
pub struct RuntimeProfileInviteCodeRecord {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub created_at: String,
pub created_at_micros: i64,
pub updated_at: String,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
pub struct RuntimeReferralInviteCenterRecord { pub struct RuntimeReferralInviteCenterRecord {
pub user_id: String, pub user_id: String,
@@ -1142,6 +1183,25 @@ pub fn build_runtime_profile_redeem_code_admin_disable_input(
}) })
} }
pub fn build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id: String,
invite_code: String,
metadata_json: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileInviteCodeAdminUpsertInput, RuntimeProfileFieldError> {
let admin_user_id = normalize_runtime_profile_user_id(admin_user_id)?;
let invite_code =
normalize_invite_code(invite_code).ok_or(RuntimeProfileFieldError::MissingInviteCode)?;
let metadata_json = normalize_invite_code_metadata_json(metadata_json)?;
Ok(RuntimeProfileInviteCodeAdminUpsertInput {
admin_user_id,
invite_code,
metadata_json,
updated_at_micros,
})
}
pub fn build_runtime_profile_play_stats_get_input( pub fn build_runtime_profile_play_stats_get_input(
user_id: String, user_id: String,
) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> { ) -> Result<RuntimeProfilePlayStatsGetInput, RuntimeProfileFieldError> {
@@ -1524,6 +1584,20 @@ pub fn build_runtime_profile_redeem_code_record(
} }
} }
pub fn build_runtime_profile_invite_code_record(
snapshot: RuntimeProfileInviteCodeSnapshot,
) -> RuntimeProfileInviteCodeRecord {
RuntimeProfileInviteCodeRecord {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
created_at: format_utc_micros(snapshot.created_at_micros),
created_at_micros: snapshot.created_at_micros,
updated_at: format_utc_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
}
}
pub fn build_runtime_profile_played_world_record( pub fn build_runtime_profile_played_world_record(
snapshot: RuntimeProfilePlayedWorldSnapshot, snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> RuntimeProfilePlayedWorldRecord { ) -> RuntimeProfilePlayedWorldRecord {
@@ -1949,6 +2023,25 @@ pub fn normalize_invite_code(value: String) -> Option<String> {
} }
} }
pub fn normalize_invite_code_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());
}
if trimmed.len() > PROFILE_INVITE_CODE_METADATA_MAX_BYTES {
return Err(RuntimeProfileFieldError::InvalidInviteCodeMetadata);
}
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)
}
pub fn normalize_redeem_code(value: String) -> Option<String> { pub fn normalize_redeem_code(value: String) -> Option<String> {
normalize_invite_code(value) normalize_invite_code(value)
} }
@@ -1960,6 +2053,9 @@ impl std::fmt::Display for RuntimeProfileFieldError {
Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"), Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"),
Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"), Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"),
Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"), Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"),
Self::InvalidInviteCodeMetadata => {
f.write_str("邀请码 metadata 必须是 JSON 对象且不超过 4096 bytes")
}
Self::MissingRedeemCode => f.write_str("兑换码不能为空"), Self::MissingRedeemCode => f.write_str("兑换码不能为空"),
Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"), Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"),
Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"), Self::InvalidRedeemCodeMaxUses => f.write_str("兑换次数必须大于 0"),
@@ -2204,6 +2300,41 @@ mod tests {
); );
} }
#[test]
fn invite_code_metadata_defaults_to_empty_object() {
assert_eq!(
normalize_invite_code_metadata_json(" ".to_string()).expect("blank metadata defaults"),
"{}"
);
}
#[test]
fn invite_code_metadata_requires_json_object() {
assert_eq!(
normalize_invite_code_metadata_json("[]".to_string()).expect_err("array rejects"),
RuntimeProfileFieldError::InvalidInviteCodeMetadata
);
assert_eq!(
normalize_invite_code_metadata_json("{bad".to_string()).expect_err("bad json rejects"),
RuntimeProfileFieldError::InvalidInviteCodeMetadata
);
}
#[test]
fn build_admin_invite_code_input_normalizes_code_and_compacts_metadata() {
let input = build_runtime_profile_invite_code_admin_upsert_input(
" admin-user ".to_string(),
" spring-2026 ".to_string(),
r#"{ "channel": "spring", "batch": 1 }"#.to_string(),
1_776_000_000_000_000,
)
.expect("admin invite input should build");
assert_eq!(input.admin_user_id, "admin-user");
assert_eq!(input.invite_code, "SPRING2026");
assert_eq!(input.metadata_json, r#"{"batch":1,"channel":"spring"}"#);
}
#[test] #[test]
fn profile_dashboard_record_formats_optional_timestamp() { fn profile_dashboard_record_formats_optional_timestamp() {
let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot { let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot {

View File

@@ -164,6 +164,8 @@ pub struct PhoneSendCodeResponse {
pub struct PhoneLoginRequest { pub struct PhoneLoginRequest {
pub phone: String, pub phone: String,
pub code: String, pub code: String,
#[serde(default)]
pub invite_code: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@@ -171,6 +173,19 @@ pub struct PhoneLoginRequest {
pub struct PhoneLoginResponse { pub struct PhoneLoginResponse {
pub token: String, pub token: String,
pub user: AuthUserPayload, pub user: AuthUserPayload,
pub created: bool,
pub referral: Option<PhoneLoginReferralResponse>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PhoneLoginReferralResponse {
pub ok: bool,
pub message: Option<String>,
pub invitee_reward_granted: bool,
pub inviter_reward_granted: bool,
pub invitee_balance_after: Option<u64>,
pub inviter_balance_after: Option<u64>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]

View File

@@ -55,8 +55,11 @@ pub struct Match3DCreatorConfigResponse {
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Match3DResultDraftResponse { pub struct Match3DResultDraftResponse {
pub profile_id: String,
pub game_name: String, pub game_name: String,
pub theme_text: String, pub theme_text: String,
#[serde(default)]
pub summary_text: Option<String>,
pub summary: String, pub summary: String,
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)] #[serde(default)]
@@ -65,10 +68,28 @@ pub struct Match3DResultDraftResponse {
pub reference_image_src: Option<String>, pub reference_image_src: Option<String>,
pub clear_count: u32, pub clear_count: u32,
pub difficulty: u32, pub difficulty: u32,
pub total_item_count: u32,
pub publish_ready: bool, pub publish_ready: bool,
pub blockers: Vec<String>, pub blockers: Vec<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DAnchorItemResponse {
pub key: String,
pub label: String,
pub value: String,
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Match3DAnchorPackResponse {
pub theme: Match3DAnchorItemResponse,
pub clear_count: Match3DAnchorItemResponse,
pub difficulty: Match3DAnchorItemResponse,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Match3DAgentMessageResponse { pub struct Match3DAgentMessageResponse {
@@ -86,6 +107,7 @@ pub struct Match3DAgentSessionSnapshotResponse {
pub current_turn: u32, pub current_turn: u32,
pub progress_percent: u32, pub progress_percent: u32,
pub stage: String, pub stage: String,
pub anchor_pack: Match3DAnchorPackResponse,
#[serde(default)] #[serde(default)]
pub config: Option<Match3DCreatorConfigResponse>, pub config: Option<Match3DCreatorConfigResponse>,
#[serde(default)] #[serde(default)]

View File

@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PutMatch3DWorkRequest { pub struct PutMatch3DWorkRequest {
pub game_name: String, pub game_name: String,
#[serde(default)]
pub theme_text: Option<String>,
pub summary: String, pub summary: String,
pub tags: Vec<String>, pub tags: Vec<String>,
#[serde(default)] #[serde(default)]
@@ -74,6 +76,7 @@ mod tests {
fn match3d_work_request_uses_camel_case() { fn match3d_work_request_uses_camel_case() {
let payload = serde_json::to_value(PutMatch3DWorkRequest { let payload = serde_json::to_value(PutMatch3DWorkRequest {
game_name: "水果抓大鹅".to_string(), game_name: "水果抓大鹅".to_string(),
theme_text: Some("水果".to_string()),
summary: "水果主题".to_string(), summary: "水果主题".to_string(),
tags: vec!["水果".to_string()], tags: vec!["水果".to_string()],
cover_image_src: None, cover_image_src: None,

View File

@@ -298,6 +298,14 @@ pub struct AdminUpsertProfileRedeemCodeRequest {
pub allowed_public_user_codes: Vec<String>, pub allowed_public_user_codes: Vec<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct AdminUpsertProfileInviteCodeRequest {
pub invite_code: String,
#[serde(default)]
pub metadata: Option<serde_json::Value>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AdminDisableProfileRedeemCodeRequest { pub struct AdminDisableProfileRedeemCodeRequest {
@@ -319,6 +327,16 @@ pub struct ProfileRedeemCodeAdminResponse {
pub updated_at: String, pub updated_at: String,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ProfileInviteCodeAdminResponse {
pub user_id: String,
pub invite_code: String,
pub metadata: serde_json::Value,
pub created_at: String,
pub updated_at: String,
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }

View File

@@ -11,6 +11,7 @@ module-big-fish = { path = "../module-big-fish" }
module-combat = { path = "../module-combat" } module-combat = { path = "../module-combat" }
module-custom-world = { path = "../module-custom-world" } module-custom-world = { path = "../module-custom-world" }
module-inventory = { path = "../module-inventory" } module-inventory = { path = "../module-inventory" }
module-match3d = { path = "../module-match3d" }
module-npc = { path = "../module-npc" } module-npc = { path = "../module-npc" }
module-puzzle = { path = "../module-puzzle" } module-puzzle = { path = "../module-puzzle" }
module-runtime = { path = "../module-runtime" } module-runtime = { path = "../module-runtime" }

View File

@@ -32,6 +32,14 @@ pub use mapper::{
PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord,
PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, PuzzleFormDraftSaveRecordInput,
Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord,
Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput,
Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord,
Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, Match3DCreatorConfigRecord,
Match3DItemSnapshotRecord, Match3DResultDraftRecord, Match3DRunClickRecordInput,
Match3DRunRecord, Match3DRunRestartRecordInput, Match3DRunStartRecordInput,
Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, Match3DTraySlotRecord,
Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput, PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord,
@@ -51,6 +59,7 @@ pub mod big_fish;
pub mod combat; pub mod combat;
pub mod custom_world; pub mod custom_world;
pub mod inventory; pub mod inventory;
pub mod match3d;
pub mod npc; pub mod npc;
pub mod puzzle; pub mod puzzle;
pub mod runtime; pub mod runtime;
@@ -124,7 +133,7 @@ use module_puzzle::{
}; };
use module_runtime::{ use module_runtime::{
RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme,
RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord,
RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord,
RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode,
RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord,
@@ -133,7 +142,8 @@ use module_runtime::{
RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, RuntimeSnapshotRecord, build_runtime_browse_history_clear_input,
build_runtime_browse_history_list_input, build_runtime_browse_history_record, 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_browse_history_sync_input, build_runtime_profile_dashboard_get_input,
build_runtime_profile_dashboard_record, build_runtime_profile_play_stats_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_play_stats_record, build_runtime_profile_recharge_center_get_input,
build_runtime_profile_recharge_center_record, build_runtime_profile_recharge_center_record,
build_runtime_profile_recharge_order_create_input, build_runtime_profile_recharge_order_create_input,

View File

@@ -203,6 +203,19 @@ impl From<module_runtime::RuntimeProfileRedeemCodeAdminDisableInput>
} }
} }
impl From<module_runtime::RuntimeProfileInviteCodeAdminUpsertInput>
for RuntimeProfileInviteCodeAdminUpsertInput
{
fn from(input: module_runtime::RuntimeProfileInviteCodeAdminUpsertInput) -> Self {
Self {
admin_user_id: input.admin_user_id,
invite_code: input.invite_code,
metadata_json: input.metadata_json,
updated_at_micros: input.updated_at_micros,
}
}
}
impl From<module_runtime::RuntimeReferralInviteCenterGetInput> impl From<module_runtime::RuntimeReferralInviteCenterGetInput>
for RuntimeReferralInviteCenterGetInput for RuntimeReferralInviteCenterGetInput
{ {
@@ -886,6 +899,26 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result(
)) ))
} }
pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result(
result: RuntimeProfileInviteCodeAdminProcedureResult,
) -> Result<RuntimeProfileInviteCodeRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let snapshot = result.record.ok_or_else(|| {
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 invite code 快照".to_string())
})?;
Ok(build_runtime_profile_invite_code_record(
map_runtime_profile_invite_code_snapshot(snapshot),
))
}
pub(crate) fn map_runtime_profile_play_stats_procedure_result( pub(crate) fn map_runtime_profile_play_stats_procedure_result(
result: RuntimeProfilePlayStatsProcedureResult, result: RuntimeProfilePlayStatsProcedureResult,
) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> { ) -> Result<RuntimeProfilePlayStatsRecord, SpacetimeClientError> {
@@ -1388,6 +1421,132 @@ pub(crate) fn map_big_fish_works_procedure_result(
.collect()) .collect())
} }
pub(crate) fn map_match3d_agent_session_procedure_result(
result: Match3DAgentSessionProcedureResult,
) -> Result<Match3DAgentSessionRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let session_json = result.session_json.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 match3d agent session 快照".to_string(),
)
})?;
let session =
serde_json::from_str::<Match3DAgentSessionJsonRecord>(&session_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("match3d session_json 非法: {error}"))
})?;
Ok(map_match3d_agent_session_snapshot(session))
}
pub(crate) fn map_match3d_work_procedure_result(
result: Match3DWorkProcedureResult,
) -> Result<Match3DWorkProfileRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let work_json = result.work_json.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 match3d work 快照".to_string(),
)
})?;
let work = serde_json::from_str::<Match3DWorkJsonRecord>(&work_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("match3d work_json 非法: {error}"))
})?;
Ok(map_match3d_work_snapshot(work))
}
pub(crate) fn map_match3d_works_procedure_result(
result: Match3DWorksProcedureResult,
) -> Result<Vec<Match3DWorkProfileRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let items_json = result.items_json.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 match3d works 快照".to_string(),
)
})?;
let items =
serde_json::from_str::<Vec<Match3DWorkJsonRecord>>(&items_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("match3d works items_json 非法: {error}"))
})?;
Ok(items.into_iter().map(map_match3d_work_snapshot).collect())
}
pub(crate) fn map_match3d_run_procedure_result(
result: Match3DRunProcedureResult,
) -> Result<Match3DRunRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let run_json = result.run_json.ok_or_else(|| {
SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 match3d run 快照".to_string())
})?;
map_match3d_run_json(run_json)
}
pub(crate) fn map_match3d_click_item_procedure_result(
result: Match3DClickItemProcedureResult,
) -> Result<Match3DClickConfirmationRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::Procedure(
result
.error_message
.unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()),
));
}
let run_json = result.run_json.ok_or_else(|| {
SpacetimeClientError::Procedure(
"SpacetimeDB procedure 未返回 match3d click run 快照".to_string(),
)
})?;
let run = map_match3d_run_json(run_json)?;
let accepted = result.status == "Accepted";
let accepted_item_instance_id = result.accepted_item_instance_id.clone();
let entered_slot_index = accepted_item_instance_id.as_deref().and_then(|item_id| {
run.items
.iter()
.find(|item| item.item_instance_id == item_id)
.and_then(|item| item.tray_slot_index)
});
Ok(Match3DClickConfirmationRecord {
status: result.status.clone(),
accepted,
reject_reason: if accepted { None } else { Some(result.status) },
accepted_item_instance_id,
entered_slot_index,
cleared_item_instance_ids: result.cleared_item_instance_ids,
failure_reason: result.failure_reason,
run,
})
}
pub(crate) fn map_story_session_procedure_result( pub(crate) fn map_story_session_procedure_result(
result: StorySessionProcedureResult, result: StorySessionProcedureResult,
) -> Result<StorySessionResultRecord, SpacetimeClientError> { ) -> Result<StorySessionResultRecord, SpacetimeClientError> {
@@ -1784,6 +1943,18 @@ pub(crate) fn map_runtime_profile_redeem_code_snapshot(
} }
} }
pub(crate) fn map_runtime_profile_invite_code_snapshot(
snapshot: RuntimeProfileInviteCodeSnapshot,
) -> module_runtime::RuntimeProfileInviteCodeSnapshot {
module_runtime::RuntimeProfileInviteCodeSnapshot {
user_id: snapshot.user_id,
invite_code: snapshot.invite_code,
metadata_json: snapshot.metadata_json,
created_at_micros: snapshot.created_at_micros,
updated_at_micros: snapshot.updated_at_micros,
}
}
pub(crate) fn map_runtime_profile_played_world_snapshot( pub(crate) fn map_runtime_profile_played_world_snapshot(
snapshot: RuntimeProfilePlayedWorldSnapshot, snapshot: RuntimeProfilePlayedWorldSnapshot,
) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { ) -> module_runtime::RuntimeProfilePlayedWorldSnapshot {
@@ -2363,6 +2534,236 @@ pub(crate) fn map_puzzle_agent_message_snapshot(
} }
} }
fn map_match3d_agent_session_snapshot(
snapshot: Match3DAgentSessionJsonRecord,
) -> Match3DAgentSessionRecord {
let config = map_match3d_creator_config(snapshot.config);
Match3DAgentSessionRecord {
session_id: snapshot.session_id,
current_turn: snapshot.current_turn,
progress_percent: snapshot.progress_percent,
stage: normalize_match3d_stage(&snapshot.stage).to_string(),
anchor_pack: build_match3d_anchor_pack(&config),
draft: snapshot
.draft
.map(|draft| map_match3d_result_draft(draft, config.reference_image_src.clone())),
config: Some(config),
messages: snapshot
.messages
.into_iter()
.map(map_match3d_agent_message_snapshot)
.collect(),
last_assistant_reply: empty_string_to_none(snapshot.last_assistant_reply),
published_profile_id: snapshot.published_profile_id,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
}
}
fn map_match3d_creator_config(
snapshot: Match3DCreatorConfigJsonRecord,
) -> Match3DCreatorConfigRecord {
Match3DCreatorConfigRecord {
theme_text: snapshot.theme_text,
reference_image_src: snapshot.reference_image_src,
clear_count: snapshot.clear_count,
difficulty: snapshot.difficulty,
}
}
fn map_match3d_result_draft(
snapshot: Match3DDraftJsonRecord,
reference_image_src: Option<String>,
) -> Match3DResultDraftRecord {
Match3DResultDraftRecord {
profile_id: snapshot.profile_id,
game_name: snapshot.game_name,
theme_text: snapshot.theme_text,
summary_text: snapshot.summary_text,
tags: snapshot.tags,
cover_image_src: None,
reference_image_src,
clear_count: snapshot.clear_count,
difficulty: snapshot.difficulty,
total_item_count: snapshot.clear_count.saturating_mul(3),
publish_ready: false,
blockers: Vec::new(),
}
}
fn map_match3d_agent_message_snapshot(
snapshot: Match3DAgentMessageJsonRecord,
) -> Match3DAgentMessageRecord {
Match3DAgentMessageRecord {
message_id: snapshot.message_id,
role: snapshot.role,
kind: normalize_match3d_message_kind(&snapshot.kind).to_string(),
text: snapshot.text,
created_at: format_timestamp_micros(snapshot.created_at_micros),
}
}
fn map_match3d_work_snapshot(snapshot: Match3DWorkJsonRecord) -> Match3DWorkProfileRecord {
let config = map_match3d_creator_config(snapshot.config);
Match3DWorkProfileRecord {
work_id: snapshot.profile_id.clone(),
profile_id: snapshot.profile_id,
owner_user_id: snapshot.owner_user_id,
source_session_id: empty_string_to_none(snapshot.source_session_id),
author_display_name: snapshot.author_display_name,
game_name: snapshot.game_name,
theme_text: snapshot.theme_text,
summary: snapshot.summary_text,
tags: snapshot.tags,
cover_image_src: empty_string_to_none(snapshot.cover_image_src),
cover_asset_id: empty_string_to_none(snapshot.cover_asset_id),
reference_image_src: config.reference_image_src,
clear_count: snapshot.clear_count,
difficulty: snapshot.difficulty,
publication_status: normalize_match3d_publication_status(&snapshot.publication_status)
.to_string(),
play_count: snapshot.play_count,
updated_at: format_timestamp_micros(snapshot.updated_at_micros),
published_at: snapshot.published_at_micros.map(format_timestamp_micros),
publish_ready: snapshot.publish_ready,
}
}
fn map_match3d_run_json(run_json: String) -> Result<Match3DRunRecord, SpacetimeClientError> {
let run = serde_json::from_str::<Match3DRunJsonRecord>(&run_json).map_err(|error| {
SpacetimeClientError::Runtime(format!("match3d run_json 非法: {error}"))
})?;
Ok(map_match3d_run_snapshot(run))
}
fn map_match3d_run_snapshot(snapshot: Match3DRunJsonRecord) -> Match3DRunRecord {
let tray_slots = snapshot
.tray_slots
.into_iter()
.map(map_match3d_tray_slot_snapshot)
.collect::<Vec<_>>();
let items = snapshot
.items
.into_iter()
.map(|item| {
let tray_slot_index = tray_slots
.iter()
.find(|slot| {
slot.item_instance_id.as_deref() == Some(item.item_instance_id.as_str())
})
.map(|slot| slot.slot_index);
map_match3d_item_snapshot(item, tray_slot_index)
})
.collect();
Match3DRunRecord {
run_id: snapshot.run_id,
profile_id: snapshot.profile_id,
owner_user_id: String::new(),
status: snapshot.status,
snapshot_version: u64::from(snapshot.snapshot_version),
started_at_ms: i64_to_u64_ms(snapshot.started_at_ms),
duration_limit_ms: i64_to_u64_ms(snapshot.duration_limit_ms),
server_now_ms: Some(i64_to_u64_ms(snapshot.server_now_ms)),
remaining_ms: i64_to_u64_ms(snapshot.remaining_ms),
clear_count: snapshot.clear_count,
total_item_count: snapshot.total_item_count,
cleared_item_count: snapshot.cleared_item_count,
items,
tray_slots,
failure_reason: snapshot.failure_reason,
last_confirmed_action_id: None,
}
}
fn map_match3d_item_snapshot(
snapshot: Match3DItemJsonRecord,
tray_slot_index: Option<u32>,
) -> Match3DItemSnapshotRecord {
Match3DItemSnapshotRecord {
item_instance_id: snapshot.item_instance_id,
item_type_id: snapshot.item_type_id,
visual_key: snapshot.visual_key,
x: snapshot.x,
y: snapshot.y,
radius: snapshot.radius,
layer: snapshot.layer,
state: snapshot.state,
clickable: snapshot.clickable,
tray_slot_index,
}
}
fn map_match3d_tray_slot_snapshot(snapshot: Match3DTraySlotJsonRecord) -> Match3DTraySlotRecord {
Match3DTraySlotRecord {
slot_index: snapshot.slot_index,
item_instance_id: snapshot.item_instance_id,
item_type_id: snapshot.item_type_id,
visual_key: snapshot.visual_key,
}
}
fn build_match3d_anchor_pack(config: &Match3DCreatorConfigRecord) -> Match3DAnchorPackRecord {
let clear_count = config.clear_count.to_string();
let difficulty = config.difficulty.to_string();
Match3DAnchorPackRecord {
theme: build_match3d_anchor_item("theme", "题材主题", config.theme_text.as_str()),
clear_count: build_match3d_anchor_item("clearCount", "需要消除次数", clear_count.as_str()),
difficulty: build_match3d_anchor_item("difficulty", "难度", difficulty.as_str()),
}
}
fn build_match3d_anchor_item(key: &str, label: &str, value: &str) -> Match3DAnchorItemRecord {
Match3DAnchorItemRecord {
key: key.to_string(),
label: label.to_string(),
value: value.to_string(),
status: if value.trim().is_empty() {
"missing"
} else {
"confirmed"
}
.to_string(),
}
}
fn normalize_match3d_stage(value: &str) -> &str {
match value {
"Collecting" | "collecting" | "collecting_config" => "collecting_config",
"ReadyToCompile" | "ready_to_compile" => "ready_to_compile",
"DraftCompiled" | "draft_compiled" | "draft_ready" => "draft_ready",
"Published" | "published" => "published",
_ => value,
}
}
fn normalize_match3d_publication_status(value: &str) -> &str {
match value {
"Draft" | "draft" => "draft",
"Published" | "published" => "published",
_ => value,
}
}
fn normalize_match3d_message_kind(value: &str) -> &str {
match value {
"text" => "chat",
_ => value,
}
}
fn empty_string_to_none(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn i64_to_u64_ms(value: i64) -> u64 {
value.max(0) as u64
}
pub(crate) fn map_puzzle_suggested_action( pub(crate) fn map_puzzle_suggested_action(
snapshot: DomainPuzzleAgentSuggestedAction, snapshot: DomainPuzzleAgentSuggestedAction,
) -> PuzzleAgentSuggestedActionRecord { ) -> PuzzleAgentSuggestedActionRecord {
@@ -2493,9 +2894,9 @@ fn map_puzzle_recommended_next_work(
pub(crate) fn map_puzzle_runtime_level_snapshot( pub(crate) fn map_puzzle_runtime_level_snapshot(
snapshot: DomainPuzzleRuntimeLevelSnapshot, snapshot: DomainPuzzleRuntimeLevelSnapshot,
) -> PuzzleRuntimeLevelRecord { ) -> PuzzleRuntimeLevelRecord {
// 中文注释:历史 run_json 可能缺 started_at_ms领域 serde 会回填为 0API 层继续补成 1避免前端计时器拿到无效开局时间。
let started_at_ms = if snapshot.started_at_ms == 0 { let started_at_ms = if snapshot.started_at_ms == 0 {
1 // 中文注释:旧 run_json 没有计时字段时只补一个可用开始时间,其余限时字段保持旧默认值。
current_unix_millis_for_legacy_puzzle_snapshot()
} else { } else {
snapshot.started_at_ms snapshot.started_at_ms
}; };
@@ -2530,6 +2931,13 @@ pub(crate) fn map_puzzle_runtime_level_snapshot(
} }
} }
fn current_unix_millis_for_legacy_puzzle_snapshot() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64)
.unwrap_or(1)
}
pub(crate) fn map_puzzle_leaderboard_entry( pub(crate) fn map_puzzle_leaderboard_entry(
snapshot: module_puzzle::PuzzleLeaderboardEntry, snapshot: module_puzzle::PuzzleLeaderboardEntry,
) -> PuzzleLeaderboardEntryRecord { ) -> PuzzleLeaderboardEntryRecord {
@@ -4574,6 +4982,367 @@ pub struct BigFishWorkRemixRecordInput {
pub remixed_at_micros: i64, pub remixed_at_micros: i64,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DAgentSessionCreateRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub seed_text: String,
pub welcome_message_id: String,
pub welcome_message_text: String,
pub config_json: Option<String>,
pub created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DAgentMessageSubmitRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub user_message_id: String,
pub user_message_text: String,
pub submitted_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DAgentMessageFinalizeRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub assistant_message_id: Option<String>,
pub assistant_reply_text: Option<String>,
pub config_json: Option<String>,
pub progress_percent: u32,
pub stage: String,
pub updated_at_micros: i64,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DCompileDraftRecordInput {
pub session_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub author_display_name: String,
pub game_name: Option<String>,
pub summary_text: Option<String>,
pub tags_json: Option<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub compiled_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DWorkUpdateRecordInput {
pub profile_id: String,
pub owner_user_id: String,
pub game_name: String,
pub theme_text: String,
pub summary_text: String,
pub tags_json: String,
pub cover_image_src: String,
pub cover_asset_id: String,
pub clear_count: u32,
pub difficulty: u32,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DRunStartRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub profile_id: String,
pub started_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DRunClickRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub item_instance_id: String,
pub client_snapshot_version: u32,
pub client_event_id: String,
pub clicked_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DRunStopRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub stopped_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DRunRestartRecordInput {
pub source_run_id: String,
pub next_run_id: String,
pub owner_user_id: String,
pub restarted_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DRunTimeUpRecordInput {
pub run_id: String,
pub owner_user_id: String,
pub finished_at_ms: i64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DAnchorItemRecord {
pub key: String,
pub label: String,
pub value: String,
pub status: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DAnchorPackRecord {
pub theme: Match3DAnchorItemRecord,
pub clear_count: Match3DAnchorItemRecord,
pub difficulty: Match3DAnchorItemRecord,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DCreatorConfigRecord {
pub theme_text: String,
pub reference_image_src: Option<String>,
pub clear_count: u32,
pub difficulty: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DResultDraftRecord {
pub profile_id: String,
pub game_name: String,
pub theme_text: String,
pub summary_text: String,
pub tags: Vec<String>,
pub cover_image_src: Option<String>,
pub reference_image_src: Option<String>,
pub clear_count: u32,
pub difficulty: u32,
pub total_item_count: u32,
pub publish_ready: bool,
pub blockers: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DAgentMessageRecord {
pub message_id: String,
pub role: String,
pub kind: String,
pub text: String,
pub created_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DAgentSessionRecord {
pub session_id: String,
pub current_turn: u32,
pub progress_percent: u32,
pub stage: String,
pub anchor_pack: Match3DAnchorPackRecord,
pub config: Option<Match3DCreatorConfigRecord>,
pub draft: Option<Match3DResultDraftRecord>,
pub messages: Vec<Match3DAgentMessageRecord>,
pub last_assistant_reply: Option<String>,
pub published_profile_id: Option<String>,
pub updated_at: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DWorkProfileRecord {
pub work_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub source_session_id: Option<String>,
pub author_display_name: String,
pub game_name: String,
pub theme_text: String,
pub summary: String,
pub tags: Vec<String>,
pub cover_image_src: Option<String>,
pub cover_asset_id: Option<String>,
pub reference_image_src: Option<String>,
pub clear_count: u32,
pub difficulty: u32,
pub publication_status: String,
pub play_count: u32,
pub updated_at: String,
pub published_at: Option<String>,
pub publish_ready: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Match3DItemSnapshotRecord {
pub item_instance_id: String,
pub item_type_id: String,
pub visual_key: String,
pub x: f32,
pub y: f32,
pub radius: f32,
pub layer: u32,
pub state: String,
pub clickable: bool,
pub tray_slot_index: Option<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Match3DTraySlotRecord {
pub slot_index: u32,
pub item_instance_id: Option<String>,
pub item_type_id: Option<String>,
pub visual_key: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Match3DRunRecord {
pub run_id: String,
pub profile_id: String,
pub owner_user_id: String,
pub status: String,
pub snapshot_version: u64,
pub started_at_ms: u64,
pub duration_limit_ms: u64,
pub server_now_ms: Option<u64>,
pub remaining_ms: u64,
pub clear_count: u32,
pub total_item_count: u32,
pub cleared_item_count: u32,
pub items: Vec<Match3DItemSnapshotRecord>,
pub tray_slots: Vec<Match3DTraySlotRecord>,
pub failure_reason: Option<String>,
pub last_confirmed_action_id: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Match3DClickConfirmationRecord {
pub status: String,
pub accepted: bool,
pub reject_reason: Option<String>,
pub accepted_item_instance_id: Option<String>,
pub entered_slot_index: Option<u32>,
pub cleared_item_instance_ids: Vec<String>,
pub failure_reason: Option<String>,
pub run: Match3DRunRecord,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DCreatorConfigJsonRecord {
theme_text: String,
reference_image_src: Option<String>,
clear_count: u32,
difficulty: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DAgentMessageJsonRecord {
message_id: String,
#[allow(dead_code)]
session_id: String,
role: String,
kind: String,
text: String,
created_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DDraftJsonRecord {
profile_id: String,
game_name: String,
theme_text: String,
summary_text: String,
tags: Vec<String>,
clear_count: u32,
difficulty: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DAgentSessionJsonRecord {
session_id: String,
#[allow(dead_code)]
owner_user_id: String,
#[allow(dead_code)]
seed_text: String,
current_turn: u32,
progress_percent: u32,
stage: String,
config: Match3DCreatorConfigJsonRecord,
draft: Option<Match3DDraftJsonRecord>,
messages: Vec<Match3DAgentMessageJsonRecord>,
last_assistant_reply: String,
published_profile_id: Option<String>,
#[allow(dead_code)]
created_at_micros: i64,
updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DWorkJsonRecord {
profile_id: String,
owner_user_id: String,
source_session_id: String,
author_display_name: String,
game_name: String,
theme_text: String,
summary_text: String,
tags: Vec<String>,
cover_image_src: String,
cover_asset_id: String,
clear_count: u32,
difficulty: u32,
config: Match3DCreatorConfigJsonRecord,
publication_status: String,
publish_ready: bool,
play_count: u32,
updated_at_micros: i64,
published_at_micros: Option<i64>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DItemJsonRecord {
item_instance_id: String,
item_type_id: String,
visual_key: String,
x: f32,
y: f32,
radius: f32,
layer: u32,
state: String,
clickable: bool,
}
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DTraySlotJsonRecord {
slot_index: u32,
item_instance_id: Option<String>,
item_type_id: Option<String>,
visual_key: Option<String>,
}
#[derive(Clone, Debug, PartialEq, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct Match3DRunJsonRecord {
run_id: String,
profile_id: String,
status: String,
snapshot_version: u32,
started_at_ms: i64,
duration_limit_ms: i64,
server_now_ms: i64,
remaining_ms: i64,
clear_count: u32,
total_item_count: u32,
cleared_item_count: u32,
tray_slots: Vec<Match3DTraySlotJsonRecord>,
items: Vec<Match3DItemJsonRecord>,
failure_reason: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct PuzzleAnchorItemRecord { pub struct PuzzleAnchorItemRecord {
pub key: String, pub key: String,

View File

@@ -0,0 +1,466 @@
use super::*;
use crate::mapper::*;
impl SpacetimeClient {
pub async fn create_match3d_agent_session(
&self,
input: Match3DAgentSessionCreateRecordInput,
) -> Result<Match3DAgentSessionRecord, SpacetimeClientError> {
let procedure_input = Match3DAgentSessionCreateInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
seed_text: input.seed_text,
welcome_message_id: input.welcome_message_id,
welcome_message_text: input.welcome_message_text,
config_json: input.config_json,
created_at_micros: input.created_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().create_match_3_d_agent_session_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn get_match3d_agent_session(
&self,
session_id: String,
owner_user_id: String,
) -> Result<Match3DAgentSessionRecord, SpacetimeClientError> {
let procedure_input = Match3DAgentSessionGetInput {
session_id,
owner_user_id,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().get_match_3_d_agent_session_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn submit_match3d_agent_message(
&self,
input: Match3DAgentMessageSubmitRecordInput,
) -> Result<Match3DAgentSessionRecord, SpacetimeClientError> {
let procedure_input = Match3DAgentMessageSubmitInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
user_message_id: input.user_message_id,
user_message_text: input.user_message_text,
submitted_at_micros: input.submitted_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().submit_match_3_d_agent_message_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn finalize_match3d_agent_message(
&self,
input: Match3DAgentMessageFinalizeRecordInput,
) -> Result<Match3DAgentSessionRecord, SpacetimeClientError> {
let procedure_input = Match3DAgentMessageFinalizeInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
assistant_message_id: input.assistant_message_id,
assistant_reply_text: input.assistant_reply_text,
config_json: input.config_json,
progress_percent: input.progress_percent,
stage: input.stage,
updated_at_micros: input.updated_at_micros,
error_message: input.error_message,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.finalize_match_3_d_agent_message_turn_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_agent_session_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn compile_match3d_draft(
&self,
input: Match3DCompileDraftRecordInput,
) -> Result<Match3DAgentSessionRecord, SpacetimeClientError> {
let procedure_input = Match3DDraftCompileInput {
session_id: input.session_id,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
author_display_name: input.author_display_name,
game_name: input.game_name,
summary_text: input.summary_text,
tags_json: input.tags_json,
cover_image_src: input.cover_image_src,
cover_asset_id: input.cover_asset_id,
compiled_at_micros: input.compiled_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().compile_match_3_d_draft_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_agent_session_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn update_match3d_work(
&self,
input: Match3DWorkUpdateRecordInput,
) -> Result<Match3DWorkProfileRecord, SpacetimeClientError> {
let procedure_input = Match3DWorkUpdateInput {
profile_id: input.profile_id,
owner_user_id: input.owner_user_id,
game_name: input.game_name,
theme_text: input.theme_text,
summary_text: input.summary_text,
tags_json: input.tags_json,
cover_image_src: input.cover_image_src,
cover_asset_id: input.cover_asset_id,
clear_count: input.clear_count,
difficulty: input.difficulty,
updated_at_micros: input.updated_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().update_match_3_d_work_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_work_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn publish_match3d_work(
&self,
profile_id: String,
owner_user_id: String,
published_at_micros: i64,
) -> Result<Match3DWorkProfileRecord, SpacetimeClientError> {
let procedure_input = Match3DWorkPublishInput {
profile_id,
owner_user_id,
published_at_micros,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().publish_match_3_d_work_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_work_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn list_match3d_works(
&self,
owner_user_id: String,
) -> Result<Vec<Match3DWorkProfileRecord>, SpacetimeClientError> {
self.list_match3d_works_with_input(Match3DWorksListInput {
owner_user_id,
published_only: false,
})
.await
}
pub async fn list_match3d_gallery(
&self,
) -> Result<Vec<Match3DWorkProfileRecord>, SpacetimeClientError> {
self.list_match3d_works_with_input(Match3DWorksListInput {
// 中文注释:公开广场读取只依赖 published_onlyowner_user_id 保持非空便于兼容校验。
owner_user_id: "match3d-public-gallery".to_string(),
published_only: true,
})
.await
}
async fn list_match3d_works_with_input(
&self,
procedure_input: Match3DWorksListInput,
) -> Result<Vec<Match3DWorkProfileRecord>, SpacetimeClientError> {
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.list_match_3_d_works_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_works_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_match3d_work_detail(
&self,
profile_id: String,
owner_user_id: String,
) -> Result<Match3DWorkProfileRecord, SpacetimeClientError> {
let procedure_input = Match3DWorkGetInput {
profile_id,
owner_user_id,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().get_match_3_d_work_detail_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_work_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn delete_match3d_work(
&self,
profile_id: String,
owner_user_id: String,
) -> Result<Vec<Match3DWorkProfileRecord>, SpacetimeClientError> {
let procedure_input = Match3DWorkDeleteInput {
profile_id,
owner_user_id,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().delete_match_3_d_work_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_works_procedure_result);
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn start_match3d_run(
&self,
input: Match3DRunStartRecordInput,
) -> Result<Match3DRunRecord, SpacetimeClientError> {
let owner_user_id = input.owner_user_id.clone();
let procedure_input = Match3DRunStartInput {
run_id: input.run_id,
owner_user_id: input.owner_user_id,
profile_id: input.profile_id,
started_at_ms: input.started_at_ms,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.start_match_3_d_run_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_run_procedure_result)
.map(|mut run| {
run.owner_user_id = owner_user_id;
run
});
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_match3d_run(
&self,
run_id: String,
owner_user_id: String,
) -> Result<Match3DRunRecord, SpacetimeClientError> {
let procedure_owner_user_id = owner_user_id.clone();
let procedure_input = Match3DRunGetInput {
run_id,
owner_user_id,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.get_match_3_d_run_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_run_procedure_result)
.map(|mut run| {
run.owner_user_id = procedure_owner_user_id;
run
});
send_once(&sender, mapped);
});
})
.await
}
pub async fn click_match3d_item(
&self,
input: Match3DRunClickRecordInput,
) -> Result<Match3DClickConfirmationRecord, SpacetimeClientError> {
let owner_user_id = input.owner_user_id.clone();
let client_event_id = input.client_event_id.clone();
let procedure_input = Match3DRunClickInput {
run_id: input.run_id,
owner_user_id: input.owner_user_id,
item_instance_id: input.item_instance_id,
client_snapshot_version: input.client_snapshot_version,
client_event_id: input.client_event_id,
clicked_at_ms: input.clicked_at_ms,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.click_match_3_d_item_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_click_item_procedure_result)
.map(|mut confirmation| {
confirmation.run.owner_user_id = owner_user_id;
if confirmation.accepted {
confirmation.run.last_confirmed_action_id = Some(client_event_id);
}
confirmation
});
send_once(&sender, mapped);
});
})
.await
}
pub async fn stop_match3d_run(
&self,
input: Match3DRunStopRecordInput,
) -> Result<Match3DRunRecord, SpacetimeClientError> {
let owner_user_id = input.owner_user_id.clone();
let procedure_input = Match3DRunStopInput {
run_id: input.run_id,
owner_user_id: input.owner_user_id,
stopped_at_ms: input.stopped_at_ms,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.stop_match_3_d_run_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_run_procedure_result)
.map(|mut run| {
run.owner_user_id = owner_user_id;
run
});
send_once(&sender, mapped);
});
})
.await
}
pub async fn restart_match3d_run(
&self,
input: Match3DRunRestartRecordInput,
) -> Result<Match3DRunRecord, SpacetimeClientError> {
let owner_user_id = input.owner_user_id.clone();
let procedure_input = Match3DRunRestartInput {
source_run_id: input.source_run_id,
next_run_id: input.next_run_id,
owner_user_id: input.owner_user_id,
restarted_at_ms: input.restarted_at_ms,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().restart_match_3_d_run_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_run_procedure_result)
.map(|mut run| {
run.owner_user_id = owner_user_id;
run
});
send_once(&sender, mapped);
},
);
})
.await
}
pub async fn finish_match3d_time_up(
&self,
input: Match3DRunTimeUpRecordInput,
) -> Result<Match3DRunRecord, SpacetimeClientError> {
let owner_user_id = input.owner_user_id.clone();
let procedure_input = Match3DRunTimeUpInput {
run_id: input.run_id,
owner_user_id: input.owner_user_id,
finished_at_ms: input.finished_at_ms,
};
self.call_after_connect(move |connection, sender| {
connection.procedures().finish_match_3_d_time_up_then(
procedure_input,
move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_match3d_run_procedure_result)
.map(|mut run| {
run.owner_user_id = owner_user_id;
run
});
send_once(&sender, mapped);
},
);
})
.await
}
}

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_procedure_result_type::RuntimeProfileInviteCodeAdminProcedureResult;
use super::runtime_profile_invite_code_admin_upsert_input_type::RuntimeProfileInviteCodeAdminUpsertInput;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct AdminUpsertProfileInviteCodeArgs {
pub input: RuntimeProfileInviteCodeAdminUpsertInput,
}
impl __sdk::InModule for AdminUpsertProfileInviteCodeArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `admin_upsert_profile_invite_code`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait admin_upsert_profile_invite_code {
fn admin_upsert_profile_invite_code(&self, input: RuntimeProfileInviteCodeAdminUpsertInput) {
self.admin_upsert_profile_invite_code_then(input, |_, _| {});
}
fn admin_upsert_profile_invite_code_then(
&self,
input: RuntimeProfileInviteCodeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
impl admin_upsert_profile_invite_code for super::RemoteProcedures {
fn admin_upsert_profile_invite_code_then(
&self,
input: RuntimeProfileInviteCodeAdminUpsertInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileInviteCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>(
"admin_upsert_profile_invite_code",
AdminUpsertProfileInviteCodeArgs { input },
__callback,
);
}
}

View File

@@ -335,6 +335,9 @@ pub mod runtime_platform_theme_type;
pub mod runtime_profile_dashboard_get_input_type; pub mod runtime_profile_dashboard_get_input_type;
pub mod runtime_profile_dashboard_procedure_result_type; pub mod runtime_profile_dashboard_procedure_result_type;
pub mod runtime_profile_dashboard_snapshot_type; pub mod runtime_profile_dashboard_snapshot_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;
pub mod runtime_profile_membership_benefit_snapshot_type; pub mod runtime_profile_membership_benefit_snapshot_type;
pub mod runtime_profile_membership_snapshot_type; pub mod runtime_profile_membership_snapshot_type;
pub mod runtime_profile_membership_status_type; pub mod runtime_profile_membership_status_type;
@@ -431,6 +434,7 @@ pub mod upsert_custom_world_profile_reducer;
pub mod upsert_npc_state_reducer; pub mod upsert_npc_state_reducer;
pub mod custom_world_gallery_entry_table; pub mod custom_world_gallery_entry_table;
pub mod admin_disable_profile_redeem_code_procedure; pub mod admin_disable_profile_redeem_code_procedure;
pub mod admin_upsert_profile_invite_code_procedure;
pub mod admin_upsert_profile_redeem_code_procedure; pub mod admin_upsert_profile_redeem_code_procedure;
pub mod advance_puzzle_next_level_procedure; pub mod advance_puzzle_next_level_procedure;
pub mod append_ai_text_chunk_and_return_procedure; pub mod append_ai_text_chunk_and_return_procedure;
@@ -895,6 +899,9 @@ pub use runtime_platform_theme_type::RuntimePlatformTheme;
pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput;
pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult; pub use runtime_profile_dashboard_procedure_result_type::RuntimeProfileDashboardProcedureResult;
pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot; pub use runtime_profile_dashboard_snapshot_type::RuntimeProfileDashboardSnapshot;
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;
pub use runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot; pub use runtime_profile_membership_benefit_snapshot_type::RuntimeProfileMembershipBenefitSnapshot;
pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot; pub use runtime_profile_membership_snapshot_type::RuntimeProfileMembershipSnapshot;
pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus; pub use runtime_profile_membership_status_type::RuntimeProfileMembershipStatus;
@@ -991,6 +998,7 @@ pub use upsert_chapter_progression_reducer::upsert_chapter_progression;
pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile; pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile;
pub use upsert_npc_state_reducer::upsert_npc_state; pub use upsert_npc_state_reducer::upsert_npc_state;
pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code; pub use admin_disable_profile_redeem_code_procedure::admin_disable_profile_redeem_code;
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_redeem_code_procedure::admin_upsert_profile_redeem_code;
pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level; pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level;
pub use append_ai_text_chunk_and_return_procedure::append_ai_text_chunk_and_return; pub use append_ai_text_chunk_and_return_procedure::append_ai_text_chunk_and_return;

View File

@@ -15,6 +15,7 @@ use spacetimedb_sdk::__codegen::{
pub struct ProfileInviteCode { pub struct ProfileInviteCode {
pub user_id: String, pub user_id: String,
pub invite_code: String, pub invite_code: String,
pub metadata_json: String,
pub created_at: __sdk::Timestamp, pub created_at: __sdk::Timestamp,
pub updated_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp,
} }
@@ -31,6 +32,7 @@ impl __sdk::InModule for ProfileInviteCode {
pub struct ProfileInviteCodeCols { pub struct ProfileInviteCodeCols {
pub user_id: __sdk::__query_builder::Col<ProfileInviteCode, String>, pub user_id: __sdk::__query_builder::Col<ProfileInviteCode, String>,
pub invite_code: __sdk::__query_builder::Col<ProfileInviteCode, String>, pub invite_code: __sdk::__query_builder::Col<ProfileInviteCode, String>,
pub metadata_json: __sdk::__query_builder::Col<ProfileInviteCode, String>,
pub created_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>, pub created_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
pub updated_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>, pub updated_at: __sdk::__query_builder::Col<ProfileInviteCode, __sdk::Timestamp>,
} }
@@ -41,6 +43,7 @@ impl __sdk::__query_builder::HasCols for ProfileInviteCode {
ProfileInviteCodeCols { ProfileInviteCodeCols {
user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), user_id: __sdk::__query_builder::Col::new(table_name, "user_id"),
invite_code: __sdk::__query_builder::Col::new(table_name, "invite_code"), invite_code: __sdk::__query_builder::Col::new(table_name, "invite_code"),
metadata_json: __sdk::__query_builder::Col::new(table_name, "metadata_json"),
created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"),
updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"),

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 RuntimeProfileInviteCodeAdminProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileInviteCodeSnapshot>,
pub error_message: Option<String>,
}
impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,18 @@
// 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 RuntimeProfileInviteCodeAdminUpsertInput {
pub admin_user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileInviteCodeAdminUpsertInput {
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};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct RuntimeProfileInviteCodeSnapshot {
pub user_id: String,
pub invite_code: String,
pub metadata_json: String,
pub created_at_micros: i64,
pub updated_at_micros: i64,
}
impl __sdk::InModule for RuntimeProfileInviteCodeSnapshot {
type Module = super::RemoteModule;
}

View File

@@ -346,6 +346,35 @@ impl SpacetimeClient {
.await .await
} }
pub async fn admin_upsert_profile_invite_code(
&self,
admin_user_id: String,
invite_code: String,
metadata_json: String,
updated_at_micros: i64,
) -> Result<RuntimeProfileInviteCodeRecord, SpacetimeClientError> {
let procedure_input = build_runtime_profile_invite_code_admin_upsert_input(
admin_user_id,
invite_code,
metadata_json,
updated_at_micros,
)
.map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?
.into();
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.admin_upsert_profile_invite_code_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_runtime_profile_invite_code_admin_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_profile_play_stats( pub async fn get_profile_play_stats(
&self, &self,
user_id: String, user_id: String,

View File

@@ -1102,6 +1102,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde
.or_insert(serde_json::Value::Null); .or_insert(serde_json::Value::Null);
} }
} }
if table_name == "profile_invite_code" {
if let Some(object) = next_value.as_object_mut() {
// 中文注释:邀请码 metadata 晚于邀请表加入,旧迁移包按空对象兼容。
object
.entry("metadata_json".to_string())
.or_insert_with(|| serde_json::Value::String("{}".to_string()));
}
}
if table_name == "big_fish_creation_session" { if table_name == "big_fish_creation_session" {
if let Some(object) = next_value.as_object_mut() { if let Some(object) = next_value.as_object_mut() {
// 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。

View File

@@ -70,6 +70,7 @@ pub struct ProfileInviteCode {
pub(crate) user_id: String, pub(crate) user_id: String,
#[unique] #[unique]
pub(crate) invite_code: String, pub(crate) invite_code: String,
pub(crate) metadata_json: String,
pub(crate) created_at: Timestamp, pub(crate) created_at: Timestamp,
pub(crate) updated_at: Timestamp, pub(crate) updated_at: Timestamp,
} }
@@ -568,6 +569,25 @@ pub fn admin_disable_profile_redeem_code(
} }
} }
#[spacetimedb::procedure]
pub fn admin_upsert_profile_invite_code(
ctx: &mut ProcedureContext,
input: RuntimeProfileInviteCodeAdminUpsertInput,
) -> RuntimeProfileInviteCodeAdminProcedureResult {
match ctx.try_with_tx(|tx| admin_upsert_profile_invite_code_record(tx, input.clone())) {
Ok(record) => RuntimeProfileInviteCodeAdminProcedureResult {
ok: true,
record: Some(record),
error_message: None,
},
Err(message) => RuntimeProfileInviteCodeAdminProcedureResult {
ok: false,
record: None,
error_message: Some(message),
},
}
}
pub(crate) fn list_profile_save_archive_rows( pub(crate) fn list_profile_save_archive_rows(
ctx: &ReducerContext, ctx: &ReducerContext,
input: RuntimeProfileSaveArchiveListInput, input: RuntimeProfileSaveArchiveListInput,
@@ -1658,10 +1678,14 @@ fn redeem_profile_referral_invite_code_record(
), ),
bound_at, bound_at,
)?; )?;
let today_inviter_reward_count = let is_admin_invite_code = is_admin_profile_invite_code_user_id(&inviter_code.user_id);
count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at); let today_inviter_reward_count = if is_admin_invite_code {
let inviter_reward_granted = 0
today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT; } else {
count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at)
};
let inviter_reward_granted = !is_admin_invite_code
&& today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT;
let inviter_balance_after = if inviter_reward_granted { let inviter_balance_after = if inviter_reward_granted {
apply_profile_wallet_delta( apply_profile_wallet_delta(
ctx, ctx,
@@ -1877,6 +1901,56 @@ fn admin_disable_profile_redeem_code_record(
Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) Ok(build_profile_redeem_code_snapshot_from_row(&inserted))
} }
fn admin_upsert_profile_invite_code_record(
ctx: &ReducerContext,
input: RuntimeProfileInviteCodeAdminUpsertInput,
) -> Result<RuntimeProfileInviteCodeSnapshot, String> {
let validated_input = build_runtime_profile_invite_code_admin_upsert_input(
input.admin_user_id,
input.invite_code,
input.metadata_json,
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 user_id = build_admin_profile_invite_code_user_id(
&validated_input.admin_user_id,
&validated_input.invite_code,
);
if let Some(existing) = ctx
.db
.profile_invite_code()
.invite_code()
.find(&validated_input.invite_code)
{
if existing.user_id != user_id {
return Err("邀请码已被其他用户占用".to_string());
}
ctx.db
.profile_invite_code()
.user_id()
.delete(&existing.user_id);
let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id,
invite_code: validated_input.invite_code,
metadata_json: validated_input.metadata_json,
created_at: existing.created_at,
updated_at,
});
return Ok(build_profile_invite_code_snapshot_from_row(&inserted));
}
let inserted = ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id,
invite_code: validated_input.invite_code,
metadata_json: validated_input.metadata_json,
created_at: updated_at,
updated_at,
});
Ok(build_profile_invite_code_snapshot_from_row(&inserted))
}
fn build_profile_referral_invite_center_snapshot( fn build_profile_referral_invite_center_snapshot(
ctx: &ReducerContext, ctx: &ReducerContext,
user_id: &str, user_id: &str,
@@ -1949,6 +2023,7 @@ fn ensure_profile_invite_code(ctx: &ReducerContext, user_id: &str) -> ProfileInv
ctx.db.profile_invite_code().insert(ProfileInviteCode { ctx.db.profile_invite_code().insert(ProfileInviteCode {
user_id: user_id.to_string(), user_id: user_id.to_string(),
invite_code, invite_code,
metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(),
created_at: ctx.timestamp, created_at: ctx.timestamp,
updated_at: ctx.timestamp, updated_at: ctx.timestamp,
}) })
@@ -1980,6 +2055,14 @@ fn count_today_profile_referral_inviter_rewards(
.count() as u32 .count() as u32
} }
fn is_admin_profile_invite_code_user_id(user_id: &str) -> bool {
user_id.starts_with("admin:")
}
fn build_admin_profile_invite_code_user_id(admin_user_id: &str, invite_code: &str) -> String {
format!("admin:{}:{}", admin_user_id, invite_code)
}
fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 { fn profile_wallet_balance(ctx: &ReducerContext, user_id: &str) -> u64 {
ctx.db ctx.db
.profile_dashboard_state() .profile_dashboard_state()
@@ -2348,6 +2431,18 @@ fn build_profile_redeem_code_snapshot_from_row(
} }
} }
fn build_profile_invite_code_snapshot_from_row(
row: &ProfileInviteCode,
) -> RuntimeProfileInviteCodeSnapshot {
RuntimeProfileInviteCodeSnapshot {
user_id: row.user_id.clone(),
invite_code: row.invite_code.clone(),
metadata_json: row.metadata_json.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
}
}
fn build_profile_wallet_ledger_snapshot_from_row( fn build_profile_wallet_ledger_snapshot_from_row(
row: &ProfileWalletLedger, row: &ProfileWalletLedger,
) -> RuntimeProfileWalletLedgerEntrySnapshot { ) -> RuntimeProfileWalletLedgerEntrySnapshot {

View File

@@ -1,4 +1,4 @@
{ {
"server": "http://127.0.0.1:3101", "server": "local",
"module-path": "./server-rs/crates/spacetime-module" "module-path": "./server-rs/crates/spacetime-module"
} }

View File

@@ -88,13 +88,19 @@ const mockUser: AuthUser = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
window.history.replaceState(null, '', '/');
authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.consumeAuthCallbackResult.mockReturnValue(null);
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
authMocks.getCurrentAuthUser.mockResolvedValue({ authMocks.getCurrentAuthUser.mockResolvedValue({
user: null, user: null,
availableLoginMethods: ['phone'], availableLoginMethods: ['phone'],
}); });
authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); authMocks.loginWithPhoneCode.mockResolvedValue({
token: 'jwt-phone',
user: mockUser,
created: false,
referral: null,
});
authMocks.authEntry.mockResolvedValue(mockUser); authMocks.authEntry.mockResolvedValue(mockUser);
authMocks.changePassword.mockResolvedValue(mockUser); authMocks.changePassword.mockResolvedValue(mockUser);
authMocks.logoutAllAuthSessions.mockResolvedValue(undefined); authMocks.logoutAllAuthSessions.mockResolvedValue(undefined);
@@ -287,6 +293,7 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith( expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000', '13800000000',
'123456', '123456',
undefined,
); );
expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1); expect(authMocks.getCurrentAuthUser).toHaveBeenCalledTimes(1);
expect(onAuthenticated).toHaveBeenCalledTimes(1); expect(onAuthenticated).toHaveBeenCalledTimes(1);
@@ -295,6 +302,44 @@ test('auth gate opens a login modal for protected actions and resumes after logi
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull(); expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
}); });
test('auth gate opens register tab and preloads invite code from url', async () => {
const user = userEvent.setup();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
authMocks.getAuthLoginOptions.mockResolvedValue({
availableLoginMethods: ['phone'],
});
render(
<AuthGate>
<div></div>
</AuthGate>,
);
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
await waitFor(() => {
expect(
within(dialog)
.getByRole('tab', { name: '注册' })
.getAttribute('aria-selected'),
).toBe('true');
});
expect(
(within(dialog).getByLabelText('邀请码') as HTMLInputElement).value,
).toBe('SPRING2026');
await user.type(within(dialog).getByLabelText('手机号'), '13800000000');
await user.type(within(dialog).getByLabelText('验证码'), '123456');
await user.click(within(dialog).getByRole('button', { name: '注册' }));
await waitFor(() => {
expect(authMocks.loginWithPhoneCode).toHaveBeenCalledWith(
'13800000000',
'123456',
'SPRING2026',
);
});
});
test('auth state refresh keeps mounted platform content and local tab state', async () => { test('auth state refresh keeps mounted platform content and local tab state', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
authMocks.getCurrentAuthUser.mockResolvedValue({ authMocks.getCurrentAuthUser.mockResolvedValue({

View File

@@ -59,6 +59,14 @@ type AuthStatus =
const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password']; const FALLBACK_LOGIN_METHODS: AuthLoginMethod[] = ['password'];
function readInviteCodeFromLocation(): string {
const params = new URLSearchParams(window.location.search || '');
return (params.get('inviteCode') || params.get('invite_code') || '')
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
}
function normalizeAvailableLoginMethods( function normalizeAvailableLoginMethods(
methods: AuthLoginMethod[] | null | undefined, methods: AuthLoginMethod[] | null | undefined,
): AuthLoginMethod[] { ): AuthLoginMethod[] {
@@ -83,6 +91,10 @@ export function AuthGate({ children }: AuthGateProps) {
const [bindingPhone, setBindingPhone] = useState(false); const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false); const [wechatLoading, setWechatLoading] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false);
const [loginInitialMode, setLoginInitialMode] = useState<
'login' | 'register'
>('login');
const [pendingInviteCode, setPendingInviteCode] = useState('');
const [showSettingsModal, setShowSettingsModal] = useState(false); const [showSettingsModal, setShowSettingsModal] = useState(false);
const [settingsEntryMode, setSettingsEntryMode] = useState< const [settingsEntryMode, setSettingsEntryMode] = useState<
'settings' | 'account' 'settings' | 'account'
@@ -102,6 +114,7 @@ export function AuthGate({ children }: AuthGateProps) {
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] = const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null); useState<AuthCaptchaChallenge | null>(null);
const pendingProtectedActionRef = useRef<(() => void) | null>(null); const pendingProtectedActionRef = useRef<(() => void) | null>(null);
const autoOpenedInviteCodeRef = useRef<string | null>(null);
const hasRenderedPlatformContentRef = useRef(false); const hasRenderedPlatformContentRef = useRef(false);
const canKeepPlatformContentMounted = const canKeepPlatformContentMounted =
hasRenderedPlatformContentRef.current && hasRenderedPlatformContentRef.current &&
@@ -169,6 +182,8 @@ export function AuthGate({ children }: AuthGateProps) {
const closeLoginModal = useCallback(() => { const closeLoginModal = useCallback(() => {
pendingProtectedActionRef.current = null; pendingProtectedActionRef.current = null;
setShowLoginModal(false); setShowLoginModal(false);
setLoginInitialMode('login');
setPendingInviteCode('');
setLoginCaptchaChallenge(null); setLoginCaptchaChallenge(null);
setError(''); setError('');
}, []); }, []);
@@ -187,6 +202,8 @@ export function AuthGate({ children }: AuthGateProps) {
} }
pendingProtectedActionRef.current = postLoginAction ?? null; pendingProtectedActionRef.current = postLoginAction ?? null;
setLoginInitialMode('login');
setPendingInviteCode('');
setShowLoginModal(true); setShowLoginModal(true);
}, },
[readyUser], [readyUser],
@@ -224,6 +241,24 @@ export function AuthGate({ children }: AuthGateProps) {
openLoginModal(); openLoginModal();
}, [openLoginModal, readyUser]); }, [openLoginModal, readyUser]);
useEffect(() => {
if (status !== 'unauthenticated' || readyUser || showLoginModal) {
return;
}
const inviteCode = readInviteCodeFromLocation();
if (!inviteCode) {
return;
}
if (autoOpenedInviteCodeRef.current === inviteCode) {
return;
}
autoOpenedInviteCodeRef.current = inviteCode;
pendingProtectedActionRef.current = null;
setPendingInviteCode(inviteCode);
setLoginInitialMode('register');
setShowLoginModal(true);
}, [readyUser, showLoginModal, status]);
useEffect(() => { useEffect(() => {
let isActive = true; let isActive = true;
@@ -703,6 +738,8 @@ export function AuthGate({ children }: AuthGateProps) {
wechatLoading={wechatLoading} wechatLoading={wechatLoading}
error={error} error={error}
captchaChallenge={loginCaptchaChallenge} captchaChallenge={loginCaptchaChallenge}
initialMode={loginInitialMode}
initialInviteCode={pendingInviteCode}
onClose={closeLoginModal} onClose={closeLoginModal}
onSendCode={async (phone, scene, captcha) => { onSendCode={async (phone, scene, captcha) => {
setSendingCode(true); setSendingCode(true);
@@ -727,14 +764,21 @@ export function AuthGate({ children }: AuthGateProps) {
setSendingCode(false); setSendingCode(false);
} }
}} }}
onPhoneSubmit={async (phone, code) => { onPhoneSubmit={async (phone, code, inviteCode) => {
setLoggingIn(true); setLoggingIn(true);
setError(''); setError('');
try { try {
const nextUser = await loginWithPhoneCode(phone, code); const response = await loginWithPhoneCode(
phone,
code,
inviteCode,
);
setStoredLastLoginPhone(phone); setStoredLastLoginPhone(phone);
setLoginCaptchaChallenge(null); setLoginCaptchaChallenge(null);
activateReadyUser(nextUser); if (response.referral && !response.referral.ok) {
setError(response.referral.message || '邀请码未绑定');
}
activateReadyUser(response.user);
} catch (loginError) { } catch (loginError) {
setError( setError(
loginError instanceof Error loginError instanceof Error

View File

@@ -10,7 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField'; import { CaptchaChallengeField } from './CaptchaChallengeField';
type SmsScene = 'login' | 'reset_password'; type SmsScene = 'login' | 'reset_password';
type LoginTab = 'phone' | 'password'; type LoginTab = 'phone' | 'password' | 'register';
type LoginScreenProps = { type LoginScreenProps = {
isOpen: boolean; isOpen: boolean;
@@ -21,6 +21,8 @@ type LoginScreenProps = {
wechatLoading: boolean; wechatLoading: boolean;
error: string; error: string;
captchaChallenge: AuthCaptchaChallenge | null; captchaChallenge: AuthCaptchaChallenge | null;
initialMode?: 'login' | 'register';
initialInviteCode?: string;
onClose: () => void; onClose: () => void;
onSendCode: ( onSendCode: (
phone: string, phone: string,
@@ -33,7 +35,11 @@ type LoginScreenProps = {
cooldownSeconds: number; cooldownSeconds: number;
expiresInSeconds: number; expiresInSeconds: number;
}>; }>;
onPhoneSubmit: (phone: string, code: string) => Promise<void>; onPhoneSubmit: (
phone: string,
code: string,
inviteCode?: string,
) => Promise<void>;
onPasswordSubmit: (phone: string, password: string) => Promise<void>; onPasswordSubmit: (phone: string, password: string) => Promise<void>;
onResetPassword: ( onResetPassword: (
phone: string, phone: string,
@@ -52,6 +58,8 @@ export function LoginScreen({
wechatLoading, wechatLoading,
error, error,
captchaChallenge, captchaChallenge,
initialMode = 'login',
initialInviteCode = '',
onClose, onClose,
onSendCode, onSendCode,
onPhoneSubmit, onPhoneSubmit,
@@ -66,6 +74,7 @@ export function LoginScreen({
const [resetPhone, setResetPhone] = useState(''); const [resetPhone, setResetPhone] = useState('');
const [resetCode, setResetCode] = useState(''); const [resetCode, setResetCode] = useState('');
const [resetPasswordValue, setResetPasswordValue] = useState(''); const [resetPasswordValue, setResetPasswordValue] = useState('');
const [inviteCode, setInviteCode] = useState(initialInviteCode);
const [captchaAnswer, setCaptchaAnswer] = useState(''); const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0); const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0); const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0);
@@ -88,16 +97,23 @@ export function LoginScreen({
setResetPhone(''); setResetPhone('');
setResetCode(''); setResetCode('');
setResetPasswordValue(''); setResetPasswordValue('');
setInviteCode(initialInviteCode);
setCaptchaAnswer(''); setCaptchaAnswer('');
setCooldownSeconds(0); setCooldownSeconds(0);
setResetCooldownSeconds(0); setResetCooldownSeconds(0);
setHint(''); setHint('');
setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password'); setActiveLoginTab(
}, [isOpen, phoneLoginEnabled]); initialMode === 'register' && phoneLoginEnabled
? 'register'
: phoneLoginEnabled
? 'phone'
: 'password',
);
}, [initialInviteCode, initialMode, isOpen, phoneLoginEnabled]);
useEffect(() => { useEffect(() => {
if ( if (
activeLoginTab === 'phone' && (activeLoginTab === 'phone' || activeLoginTab === 'register') &&
!phoneLoginEnabled && !phoneLoginEnabled &&
passwordLoginEnabled passwordLoginEnabled
) { ) {
@@ -196,9 +212,11 @@ export function LoginScreen({
/> />
) : ( ) : (
<div className="flex flex-col gap-5 px-5 py-5"> <div className="flex flex-col gap-5 px-5 py-5">
{phoneLoginEnabled && passwordLoginEnabled ? ( {phoneLoginEnabled ? (
<div <div
className="grid grid-cols-2 gap-2" className={`grid gap-2 ${
passwordLoginEnabled ? 'grid-cols-3' : 'grid-cols-2'
}`}
role="tablist" role="tablist"
aria-label="登录方式" aria-label="登录方式"
> >
@@ -208,12 +226,20 @@ export function LoginScreen({
> >
</LoginTabButton> </LoginTabButton>
{passwordLoginEnabled ? (
<LoginTabButton <LoginTabButton
active={activeLoginTab === 'password'} active={activeLoginTab === 'password'}
onClick={() => setActiveLoginTab('password')} onClick={() => setActiveLoginTab('password')}
> >
</LoginTabButton> </LoginTabButton>
) : null}
<LoginTabButton
active={activeLoginTab === 'register'}
onClick={() => setActiveLoginTab('register')}
>
</LoginTabButton>
</div> </div>
) : null} ) : null}
@@ -312,6 +338,42 @@ export function LoginScreen({
/> />
) : null} ) : null}
{phoneLoginEnabled && activeLoginTab === 'register' ? (
<PhoneCodeForm
phone={phone}
code={code}
inviteCode={inviteCode}
captchaAnswer={captchaAnswer}
captchaChallenge={captchaChallenge}
cooldownSeconds={cooldownSeconds}
sendingCode={sendingCode}
loggingIn={loggingIn}
error={error}
hint={hint}
submitLabel="注册"
enabled={phoneLoginEnabled}
showPhoneField
showInviteCodeField
onPhoneChange={setPhone}
onCodeChange={setCode}
onInviteCodeChange={setInviteCode}
onCaptchaAnswerChange={setCaptchaAnswer}
onSendCode={async () => {
setHint('');
const result = await onSendCode(phone, 'login', {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`短信请求已提交,验证码有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
}}
onSubmit={() => onPhoneSubmit(phone, code, inviteCode)}
/>
) : null}
{!passwordLoginEnabled && {!passwordLoginEnabled &&
!phoneLoginEnabled && !phoneLoginEnabled &&
!wechatLoginEnabled ? ( !wechatLoginEnabled ? (
@@ -358,6 +420,7 @@ function LoginTabButton({
function PhoneCodeForm({ function PhoneCodeForm({
phone, phone,
code, code,
inviteCode = '',
captchaAnswer, captchaAnswer,
captchaChallenge, captchaChallenge,
cooldownSeconds, cooldownSeconds,
@@ -368,14 +431,17 @@ function PhoneCodeForm({
submitLabel, submitLabel,
enabled, enabled,
showPhoneField, showPhoneField,
showInviteCodeField = false,
onPhoneChange, onPhoneChange,
onCodeChange, onCodeChange,
onInviteCodeChange,
onCaptchaAnswerChange, onCaptchaAnswerChange,
onSendCode, onSendCode,
onSubmit, onSubmit,
}: { }: {
phone: string; phone: string;
code: string; code: string;
inviteCode?: string;
captchaAnswer: string; captchaAnswer: string;
captchaChallenge: AuthCaptchaChallenge | null; captchaChallenge: AuthCaptchaChallenge | null;
cooldownSeconds: number; cooldownSeconds: number;
@@ -386,8 +452,10 @@ function PhoneCodeForm({
submitLabel: string; submitLabel: string;
enabled: boolean; enabled: boolean;
showPhoneField: boolean; showPhoneField: boolean;
showInviteCodeField?: boolean;
onPhoneChange: (value: string) => void; onPhoneChange: (value: string) => void;
onCodeChange: (value: string) => void; onCodeChange: (value: string) => void;
onInviteCodeChange?: (value: string) => void;
onCaptchaAnswerChange: (value: string) => void; onCaptchaAnswerChange: (value: string) => void;
onSendCode: () => Promise<void>; onSendCode: () => Promise<void>;
onSubmit: () => Promise<void>; onSubmit: () => Promise<void>;
@@ -418,6 +486,19 @@ function PhoneCodeForm({
</label> </label>
) : null} ) : null}
{showInviteCodeField ? (
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span>
<input
className="platform-input"
autoComplete="off"
value={inviteCode}
onChange={(event) => onInviteCodeChange?.(event.target.value)}
placeholder="邀请码"
/>
</label>
) : null}
<label className="grid gap-2 text-sm text-[var(--platform-text-base)]"> <label className="grid gap-2 text-sm text-[var(--platform-text-base)]">
<span></span> <span></span>
<div className="flex gap-3"> <div className="flex gap-3">

View File

@@ -48,7 +48,6 @@ import type {
ProfileReferralInviteCenterResponse, ProfileReferralInviteCenterResponse,
ProfileSaveArchiveSummary, ProfileSaveArchiveSummary,
ProfileWalletLedgerResponse, ProfileWalletLedgerResponse,
RedeemProfileReferralInviteCodeResponse,
RedeemProfileRewardCodeResponse, RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime'; } from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
@@ -58,7 +57,6 @@ import { copyTextToClipboard } from '../../services/clipboard';
import { import {
getRpgProfileReferralInviteCenter, getRpgProfileReferralInviteCenter,
getRpgProfileWalletLedger, getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode, redeemRpgProfileRewardCode,
} from '../../services/rpg-entry/rpgProfileClient'; } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
@@ -1934,34 +1932,21 @@ function RewardCodeRedeemModal({
function ProfileReferralModal({ function ProfileReferralModal({
panel, panel,
center, center,
inviteCodeInput,
isLoading, isLoading,
isSubmitting,
error, error,
success, success,
onClose, onClose,
onInputChange,
onCopyInvite, onCopyInvite,
onSubmitRedeem,
}: { }: {
panel: ProfilePopupPanel; panel: ProfilePopupPanel;
center: ProfileReferralInviteCenterResponse | null; center: ProfileReferralInviteCenterResponse | null;
inviteCodeInput: string;
isLoading: boolean; isLoading: boolean;
isSubmitting: boolean;
error: string | null; error: string | null;
success: string | null; success: string | null;
onClose: () => void; onClose: () => void;
onInputChange: (value: string) => void;
onCopyInvite: () => void; onCopyInvite: () => void;
onSubmitRedeem: () => void;
}) { }) {
const title = const title = panel === 'invite' ? '邀请好友' : '玩家社区';
panel === 'invite'
? '邀请好友'
: panel === 'redeem'
? '填邀请码'
: '玩家社区';
return ( return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5"> <div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/42 px-3 py-5">
@@ -2004,7 +1989,7 @@ function ProfileReferralModal({
<div className="h-20 animate-pulse rounded-xl bg-zinc-100" /> <div className="h-20 animate-pulse rounded-xl bg-zinc-100" />
<div className="h-10 animate-pulse rounded-xl bg-zinc-100" /> <div className="h-10 animate-pulse rounded-xl bg-zinc-100" />
</div> </div>
) : panel === 'invite' ? ( ) : (
<div className="mt-5 space-y-3"> <div className="mt-5 space-y-3">
<div className="rounded-xl bg-zinc-50 px-4 py-4 text-center"> <div className="rounded-xl bg-zinc-50 px-4 py-4 text-center">
<div className="text-[11px] font-bold text-zinc-500"> <div className="text-[11px] font-bold text-zinc-500">
@@ -2044,31 +2029,6 @@ function ProfileReferralModal({
</div> </div>
</div> </div>
</div> </div>
) : (
<div className="mt-5 space-y-3">
{center?.hasRedeemedCode ? (
<div className="rounded-xl bg-emerald-50 px-4 py-4 text-center text-sm font-bold text-emerald-700">
</div>
) : (
<>
<input
value={inviteCodeInput}
onChange={(event) => onInputChange(event.target.value)}
placeholder="输入邀请码"
className="w-full rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-3 text-center text-base font-black tracking-[0.14em] outline-none focus:border-[#ff4056]"
/>
<button
type="button"
onClick={onSubmitRedeem}
disabled={isSubmitting || !inviteCodeInput.trim()}
className="w-full rounded-xl bg-[#ff4056] px-4 py-3 text-sm font-black text-white disabled:opacity-60"
>
{isSubmitting ? '提交中' : '确认填写'}
</button>
</>
)}
</div>
)} )}
{error ? ( {error ? (
@@ -2246,10 +2206,8 @@ export function RpgEntryHomeView({
const [referralCenter, setReferralCenter] = const [referralCenter, setReferralCenter] =
useState<ProfileReferralInviteCenterResponse | null>(null); useState<ProfileReferralInviteCenterResponse | null>(null);
const [isLoadingReferral, setIsLoadingReferral] = useState(false); const [isLoadingReferral, setIsLoadingReferral] = useState(false);
const [isSubmittingReferral, setIsSubmittingReferral] = useState(false);
const [referralError, setReferralError] = useState<string | null>(null); const [referralError, setReferralError] = useState<string | null>(null);
const [referralSuccess, setReferralSuccess] = useState<string | null>(null); const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
const [inviteCodeInput, setInviteCodeInput] = useState('');
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>( const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null, null,
); );
@@ -2616,30 +2574,6 @@ export function RpgEntryHomeView({
}, },
); );
}; };
const submitReferralInviteCode = () => {
if (isSubmittingReferral || !inviteCodeInput.trim()) {
return;
}
setIsSubmittingReferral(true);
setReferralError(null);
setReferralSuccess(null);
void redeemRpgProfileReferralInviteCode(inviteCodeInput)
.then((response: RedeemProfileReferralInviteCodeResponse) => {
setReferralCenter(response.center);
setInviteCodeInput('');
setReferralSuccess(
response.inviteeRewardGranted ? '已获得30陶泥币' : '填写成功',
);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setReferralError(
error instanceof Error ? error.message : '填写邀请码失败',
);
})
.finally(() => setIsSubmittingReferral(false));
};
const openRewardCodeModal = () => { const openRewardCodeModal = () => {
setIsRewardCodeOpen(true); setIsRewardCodeOpen(true);
setRewardCodeError(null); setRewardCodeError(null);
@@ -3251,17 +3185,12 @@ export function RpgEntryHomeView({
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}> <section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<SectionHeader title="常用功能" detail="快捷入口" /> <SectionHeader title="常用功能" detail="快捷入口" />
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-2 gap-3">
<ProfileShortcutButton <ProfileShortcutButton
label="邀请好友" label="邀请好友"
icon={UserPlus} icon={UserPlus}
onClick={() => openProfilePopupPanel('invite')} onClick={() => openProfilePopupPanel('invite')}
/> />
<ProfileShortcutButton
label="填邀请码"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
<ProfileShortcutButton <ProfileShortcutButton
label="玩家社区" label="玩家社区"
icon={MessageCircle} icon={MessageCircle}
@@ -3700,15 +3629,11 @@ export function RpgEntryHomeView({
<ProfileReferralModal <ProfileReferralModal
panel={profilePopupPanel} panel={profilePopupPanel}
center={referralCenter} center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral} isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError} error={referralError}
success={referralSuccess} success={referralSuccess}
onClose={() => setProfilePopupPanel(null)} onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo} onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/> />
) : null} ) : null}
{rewardCodeModal} {rewardCodeModal}
@@ -3822,15 +3747,11 @@ export function RpgEntryHomeView({
<ProfileReferralModal <ProfileReferralModal
panel={profilePopupPanel} panel={profilePopupPanel}
center={referralCenter} center={referralCenter}
inviteCodeInput={inviteCodeInput}
isLoading={isLoadingReferral} isLoading={isLoadingReferral}
isSubmitting={isSubmittingReferral}
error={referralError} error={referralError}
success={referralSuccess} success={referralSuccess}
onClose={() => setProfilePopupPanel(null)} onClose={() => setProfilePopupPanel(null)}
onInputChange={setInviteCodeInput}
onCopyInvite={copyInviteInfo} onCopyInvite={copyInviteInfo}
onSubmitRedeem={submitReferralInviteCode}
/> />
) : null} ) : null}
{isProfilePlayStatsOpen ? ( {isProfilePlayStatsOpen ? (

View File

@@ -219,15 +219,20 @@ describe('authService', () => {
}, },
}); });
const user = await loginWithPhoneCode('13800138000', '123456'); const response = await loginWithPhoneCode(
'13800138000',
'123456',
'spring-2026',
);
expect(user.username).toBe('138****8000'); expect(response.user.username).toBe('138****8000');
expect(apiClientMocks.requestJson).toHaveBeenCalledWith( expect(apiClientMocks.requestJson).toHaveBeenCalledWith(
'/api/auth/phone/login', '/api/auth/phone/login',
expect.objectContaining({ expect.objectContaining({
body: JSON.stringify({ body: JSON.stringify({
phone: '13800138000', phone: '13800138000',
code: '123456', code: '123456',
inviteCode: 'SPRING2026',
}), }),
}), }),
'登录失败', '登录失败',

View File

@@ -65,6 +65,13 @@ export function normalizePhoneInput(phoneInput: string) {
return phoneInput.replace(/[^\d+]/gu, '').trim(); return phoneInput.replace(/[^\d+]/gu, '').trim();
} }
export function normalizeInviteCodeInput(inviteCode: string | undefined) {
return (inviteCode ?? '')
.trim()
.replace(/[^0-9a-z]/gi, '')
.toUpperCase();
}
export function getStoredLastLoginPhone() { export function getStoredLastLoginPhone() {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return ''; return '';
@@ -145,7 +152,12 @@ export async function sendPhoneLoginCode(
return response; return response;
} }
export async function loginWithPhoneCode(phone: string, code: string) { export async function loginWithPhoneCode(
phone: string,
code: string,
inviteCode?: string,
) {
const normalizedInviteCode = normalizeInviteCodeInput(inviteCode);
const response = await requestJson<AuthPhoneLoginResponse>( const response = await requestJson<AuthPhoneLoginResponse>(
'/api/auth/phone/login', '/api/auth/phone/login',
{ {
@@ -154,6 +166,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
body: JSON.stringify({ body: JSON.stringify({
phone: normalizePhoneInput(phone), phone: normalizePhoneInput(phone),
code: code.trim(), code: code.trim(),
...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}),
}), }),
}, },
'登录失败', '登录失败',
@@ -161,7 +174,7 @@ export async function loginWithPhoneCode(phone: string, code: string) {
); );
setStoredAccessToken(response.token, { emit: false }); setStoredAccessToken(response.token, { emit: false });
return response.user; return response;
} }
export async function bindWechatPhone(phone: string, code: string) { export async function bindWechatPhone(phone: string, code: string) {