diff --git a/.env.local b/.env.local index 95fecc04..ef534f5b 100644 --- a/.env.local +++ b/.env.local @@ -50,6 +50,15 @@ ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS" RUST_SERVER_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_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 \ No newline at end of file diff --git a/apps/admin-web/index.html b/apps/admin-web/index.html new file mode 100644 index 00000000..1af47ad5 --- /dev/null +++ b/apps/admin-web/index.html @@ -0,0 +1,12 @@ + + + + + + 陶泥后台 + + +
+ + + diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json new file mode 100644 index 00000000..7704e562 --- /dev/null +++ b/apps/admin-web/package.json @@ -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" + } +} diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts new file mode 100644 index 00000000..c4203bfd --- /dev/null +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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; + signal?: AbortSignal; +} + +export class AdminApiError extends Error { + status: number; + code: string; + details: Record | null; + meta: ApiMeta | null; + responseText: string; + + constructor(params: { + message: string; + status: number; + code?: string; + details?: Record | 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( + path: string, + options: AdminRequestOptions = {}, +): Promise { + const method = options.method ?? 'GET'; + const headers: Record = { + 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(payload); +} + +export function loginAdmin(username: string, password: string) { + return request('/admin/api/login', { + method: 'POST', + body: {username, password}, + }); +} + +export function getAdminMe(token: string) { + return request('/admin/api/me', {token}); +} + +export function getAdminOverview(token: string) { + return request('/admin/api/overview', {token}); +} + +export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) { + return request('/admin/api/debug/http', { + method: 'POST', + token, + body: payload, + }); +} + +export function upsertProfileRedeemCode( + token: string, + payload: AdminUpsertProfileRedeemCodeRequest, +) { + return request( + '/admin/api/profile/redeem-codes', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +export function upsertProfileInviteCode( + token: string, + payload: AdminUpsertProfileInviteCodeRequest, +) { + return request( + '/admin/api/profile/invite-codes', + { + method: 'POST', + token, + body: payload, + }, + ); +} + +export function disableProfileRedeemCode( + token: string, + payload: AdminDisableProfileRedeemCodeRequest, +) { + return request( + '/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(payload: unknown): T { + if (isRecord(payload) && 'data' in payload) { + return (payload as ApiSuccessEnvelope).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 { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts new file mode 100644 index 00000000..a9201f87 --- /dev/null +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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 { + 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; +} + +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; + createdAt: string; + updatedAt: string; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx new file mode 100644 index 00000000..498db96f --- /dev/null +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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('checking'); + const [admin, setAdmin] = useState(null); + const [token, setToken] = useState(''); + const [routeId, setRouteId] = useState(() => + resolveAdminRoute(window.location.hash), + ); + const [loginNotice, setLoginNotice] = useState(''); + // 兑换码页会随页签切换卸载,最近操作记录需要放在会话层保留。 + const [redeemResult, setRedeemResult] = + useState(null); + const [inviteResult, setInviteResult] = + useState(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 ( +
+
+ 正在校验会话 +
+ ); + } + + if (status === 'guest' || !admin || !token) { + return ; + } + + return ( + + {routeId === 'overview' ? ( + + ) : null} + {routeId === 'debug' ? ( + + ) : null} + {routeId === 'redeem' ? ( + + ) : null} + {routeId === 'invite' ? ( + + ) : null} + + ); +} diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx new file mode 100644 index 00000000..fa2da055 --- /dev/null +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -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; + +export function AdminShell({ + admin, + routeId, + children, + onRouteChange, + onLogout, +}: AdminShellProps) { + return ( +
+ + +
+
+
+ {admin.displayName || admin.username} + {admin.roles.join(' / ')} +
+ +
+ +
{children}
+
+ + +
+ ); +} diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts new file mode 100644 index 00000000..ee9ba760 --- /dev/null +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -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' + ); +} diff --git a/apps/admin-web/src/auth/adminAuthStore.ts b/apps/admin-web/src/auth/adminAuthStore.ts new file mode 100644 index 00000000..f7e02d4d --- /dev/null +++ b/apps/admin-web/src/auth/adminAuthStore.ts @@ -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' + ); +} diff --git a/apps/admin-web/src/main.tsx b/apps/admin-web/src/main.tsx new file mode 100644 index 00000000..339dc360 --- /dev/null +++ b/apps/admin-web/src/main.tsx @@ -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( + + + , +); diff --git a/apps/admin-web/src/pages/AdminDebugHttpPage.tsx b/apps/admin-web/src/pages/AdminDebugHttpPage.tsx new file mode 100644 index 00000000..a5ac8ad1 --- /dev/null +++ b/apps/admin-web/src/pages/AdminDebugHttpPage.tsx @@ -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('GET'); + const [path, setPath] = useState('/healthz'); + const [body, setBody] = useState(''); + const [headers, setHeaders] = useState([]); + const [result, setResult] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const jsonPreview = useMemo( + () => formatUnknownJson(result?.bodyJson), + [result?.bodyJson], + ); + + async function handleSubmit(event: FormEvent) { + 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 ( +
+
+
+

API 调试

+

受控同源请求

+
+
+ +
+
+
+ + +
+ +
+
+ Headers + +
+
+ {headers.map((header, index) => ( +
+ + setHeaders((current) => + current.map((item, itemIndex) => + itemIndex === index + ? {...item, name: event.target.value} + : item, + ), + ) + } + /> + + setHeaders((current) => + current.map((item, itemIndex) => + itemIndex === index + ? {...item, value: event.target.value} + : item, + ), + ) + } + /> + +
+ ))} +
+
+ + - -
- -
-
-
-
- - -
-
-
-

数据库概览

-

读取当前服务配置和 SpacetimeDB 数据库真相。

-
-
-
- -
-
-
-
-
-
-
- -
-
-

调试结果

-

返回状态、响应头和内容预览。

-
-
-
-
尚未执行调试请求。
-
-
-
-
- - - - - -"#; - #[cfg(test)] 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 serde_json::json; #[test] fn normalize_debug_path_rejects_absolute_url() { @@ -1161,6 +712,91 @@ mod tests { 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] fn build_body_preview_handles_utf8() { let preview = build_body_preview("后台测试".as_bytes()); diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index c62a37d7..41551490 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -13,10 +13,7 @@ use tower_http::{ use tracing::{Level, Span, error, info, info_span, warn}; use crate::{ - admin::{ - admin_console_page, admin_debug_http, admin_login, admin_me, admin_overview, - require_admin_auth, - }, + admin::{admin_debug_http, admin_login, admin_me, admin_overview, require_admin_auth}, ai_tasks::{ 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, @@ -78,6 +75,13 @@ use crate::{ login_options::auth_login_options, logout::logout, 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_management::{change_password, reset_password}, phone_auth::{phone_login, send_phone_code}, @@ -105,10 +109,10 @@ use crate::{ }, runtime_inventory::get_runtime_inventory_state, runtime_profile::{ - admin_disable_profile_redeem_code, admin_upsert_profile_redeem_code, - create_profile_recharge_order, get_profile_dashboard, get_profile_play_stats, - get_profile_recharge_center, get_profile_referral_invite_center, get_profile_wallet_ledger, - redeem_profile_referral_invite_code, redeem_profile_reward_code, + admin_disable_profile_redeem_code, admin_upsert_profile_invite_code, + admin_upsert_profile_redeem_code, create_profile_recharge_order, get_profile_dashboard, + get_profile_play_stats, get_profile_recharge_center, get_profile_referral_invite_center, + get_profile_wallet_ledger, redeem_profile_referral_invite_code, redeem_profile_reward_code, }, runtime_save::{ 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; Router::new() - .route("/admin", get(admin_console_page)) .route("/admin/api/login", post(admin_login)) .route( "/admin/api/me", @@ -172,6 +175,13 @@ pub fn build_router(state: AppState) -> Router { 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( "/healthz", get(|Extension(request_context): Extension<_>| async move { @@ -702,6 +712,116 @@ pub fn build_router(state: AppState) -> Router { 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( "/api/runtime/puzzle/agent/sessions", post(create_puzzle_agent_session) @@ -1993,6 +2113,8 @@ mod tests { payload["user"]["phoneNumberMasked"], Value::String("138****8000".to_string()) ); + assert_eq!(payload["created"], Value::Bool(true)); + assert!(payload["referral"].is_null()); } #[tokio::test] @@ -2099,6 +2221,175 @@ mod tests { 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["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] @@ -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] async fn admin_login_returns_token_when_configured() { let mut config = AppConfig::default(); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 3388298d..cd6fdece 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -40,6 +40,7 @@ mod llm_model_routing; mod login_options; mod logout; mod logout_all; +mod match3d; mod password_entry; mod password_management; mod phone_auth; diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs new file mode 100644 index 00000000..cf6762e3 --- /dev/null +++ b/server-rs/crates/api-server/src/match3d.rs @@ -0,0 +1,1342 @@ +use std::{ + convert::Infallible, + time::{SystemTime, UNIX_EPOCH}, +}; + +use axum::{ + Json, + extract::{Extension, Path, State, rejection::JsonRejection}, + http::{HeaderName, StatusCode, header}, + response::{ + IntoResponse, Response, + sse::{Event, Sse}, + }, +}; +use module_match3d::{ + MATCH3D_MESSAGE_ID_PREFIX, MATCH3D_PROFILE_ID_PREFIX, MATCH3D_RUN_ID_PREFIX, + MATCH3D_SESSION_ID_PREFIX, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use shared_contracts::{ + match3d_agent::{ + CreateMatch3DAgentSessionRequest, ExecuteMatch3DAgentActionRequest, + Match3DAgentActionResponse, Match3DAgentMessageResponse, Match3DAgentSessionResponse, + Match3DAgentSessionSnapshotResponse, Match3DAnchorItemResponse, Match3DAnchorPackResponse, + Match3DCreatorConfigResponse, Match3DResultDraftResponse, SendMatch3DAgentMessageRequest, + }, + match3d_runtime::{ + ClickMatch3DItemRequest, Match3DClickConfirmationResponse, Match3DClickResponse, + Match3DItemSnapshotResponse, Match3DRunResponse, Match3DRunSnapshotResponse, + Match3DTraySlotResponse, StartMatch3DRunRequest, StopMatch3DRunRequest, + }, + match3d_works::{ + Match3DWorkDetailResponse, Match3DWorkMutationResponse, Match3DWorkProfileResponse, + Match3DWorkSummaryResponse, Match3DWorksResponse, PutMatch3DWorkRequest, + }, +}; +use shared_kernel::build_prefixed_uuid_id; +use spacetime_client::{ + Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord, + Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput, + Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord, + Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, Match3DCreatorConfigRecord, + Match3DItemSnapshotRecord, Match3DResultDraftRecord, Match3DRunClickRecordInput, + Match3DRunRecord, Match3DRunRestartRecordInput, Match3DRunStartRecordInput, + Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, Match3DTraySlotRecord, + Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, SpacetimeClientError, +}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +const MATCH3D_AGENT_PROVIDER: &str = "match3d-agent"; +const MATCH3D_WORKS_PROVIDER: &str = "match3d-works"; +const MATCH3D_RUNTIME_PROVIDER: &str = "match3d-runtime"; +const MATCH3D_DEFAULT_THEME: &str = "缤纷玩具"; +const MATCH3D_DEFAULT_CLEAR_COUNT: u32 = 12; +const MATCH3D_DEFAULT_DIFFICULTY: u32 = 4; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DConfigJson { + theme_text: String, + reference_image_src: Option, + clear_count: u32, + difficulty: u32, +} + +pub async fn create_match3d_agent_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + let config = build_config_from_create_request(&payload); + let seed_text = build_seed_text(&payload, &config); + let welcome_message_text = build_match3d_assistant_reply(&config); + + let session = state + .spacetime_client() + .create_match3d_agent_session(Match3DAgentSessionCreateRecordInput { + session_id: build_prefixed_uuid_id(MATCH3D_SESSION_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + seed_text, + welcome_message_id: build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX), + welcome_message_text, + config_json: serialize_match3d_config(&config), + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentSessionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn get_match3d_agent_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + let session = state + .spacetime_client() + .get_match3d_agent_session(session_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentSessionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn submit_match3d_agent_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + let session = submit_and_finalize_match3d_message( + &state, + &request_context, + authenticated.claims().user_id(), + session_id, + payload, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentSessionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn stream_match3d_agent_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let request_context_for_stream = request_context.clone(); + let stream = async_stream::stream! { + let result = submit_and_finalize_match3d_message( + &state, + &request_context_for_stream, + owner_user_id.as_str(), + session_id, + payload, + ) + .await; + + match result { + Ok(session) => { + let session_response = map_match3d_agent_session_response(session); + if let Some(reply) = session_response.last_assistant_reply.clone() { + yield Ok::(match3d_sse_json_event_or_error( + "reply_delta", + json!({ "text": reply }), + )); + } + yield Ok::(match3d_sse_json_event_or_error( + "session", + json!({ "session": session_response }), + )); + yield Ok::(match3d_sse_json_event_or_error( + "done", + json!({ "ok": true }), + )); + } + Err(response) => { + yield Ok::(match3d_sse_json_event_or_error( + "error", + json!({ "message": response.status().to_string() }), + )); + } + } + }; + + Ok(Sse::new(stream).into_response()) +} + +pub async fn execute_match3d_agent_action( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + if payload.action.trim() != "match3d_compile_draft" { + return Err(match3d_bad_request( + &request_context, + MATCH3D_AGENT_PROVIDER, + "unknown match3d action", + )); + } + + let owner_user_id = authenticated.claims().user_id().to_string(); + let session = state + .spacetime_client() + .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let config = resolve_config_or_default(session.config.as_ref()); + let tags_json = payload + .tags + .as_ref() + .map(|tags| serde_json::to_string(&normalize_tags(tags.clone())).unwrap_or_default()); + let session = state + .spacetime_client() + .compile_match3d_draft(Match3DCompileDraftRecordInput { + session_id, + owner_user_id, + profile_id: build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX), + author_display_name: resolve_author_display_name(&state, &authenticated), + game_name: payload + .game_name + .or_else(|| Some(format!("{}抓大鹅", config.theme_text))), + summary_text: payload.summary, + tags_json, + cover_image_src: payload.cover_image_src, + cover_asset_id: None, + compiled_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentActionResponse { + session: map_match3d_agent_session_response(session), + }, + )) +} + +pub async fn get_match3d_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_match3d_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorksResponse { + items: items + .into_iter() + .map(map_match3d_work_summary_response) + .collect(), + }, + )) +} + +pub async fn list_match3d_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_match3d_gallery() + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorksResponse { + items: items + .into_iter() + .map(map_match3d_work_summary_response) + .collect(), + }, + )) +} + +pub async fn get_match3d_work_detail( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .get_match3d_work_detail(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorkDetailResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn put_match3d_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let existing = state + .spacetime_client() + .get_match3d_work_detail( + profile_id.clone(), + authenticated.claims().user_id().to_string(), + ) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let theme_text = payload + .theme_text + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(existing.theme_text); + let item = state + .spacetime_client() + .update_match3d_work(Match3DWorkUpdateRecordInput { + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + game_name: payload.game_name, + theme_text, + summary_text: payload.summary, + tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(), + cover_image_src: payload.cover_image_src.unwrap_or_default(), + cover_asset_id: String::new(), + clear_count: payload.clear_count, + difficulty: payload.difficulty, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorkMutationResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn publish_match3d_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .publish_match3d_work( + profile_id, + authenticated.claims().user_id().to_string(), + current_utc_micros(), + ) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorkMutationResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn delete_match3d_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let items = state + .spacetime_client() + .delete_match3d_work(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorksResponse { + items: items + .into_iter() + .map(map_match3d_work_summary_response) + .collect(), + }, + )) +} + +pub async fn start_match3d_run( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let maybe_payload = payload.ok().map(|Json(payload)| payload); + let profile_id = maybe_payload + .map(|payload| payload.profile_id) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(profile_id); + ensure_non_empty( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + &profile_id, + "profileId", + )?; + + let run = state + .spacetime_client() + .start_match3d_run(Match3DRunStartRecordInput { + run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + profile_id, + started_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn get_match3d_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .get_match3d_run(run_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn click_match3d_item( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?; + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + &payload.item_instance_id, + "itemInstanceId", + )?; + ensure_non_empty( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + &payload.client_event_id, + "clientEventId", + )?; + + let confirmation = state + .spacetime_client() + .click_match3d_item(Match3DRunClickRecordInput { + run_id: payload.run_id.unwrap_or(run_id), + owner_user_id: authenticated.claims().user_id().to_string(), + item_instance_id: payload.item_instance_id, + client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32, + client_event_id: payload.client_event_id, + clicked_at_ms: payload.clicked_at_ms.min(i64::MAX as u64) as i64, + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DClickResponse { + confirmation: map_match3d_click_confirmation_response(confirmation), + }, + )) +} + +pub async fn stop_match3d_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let _ = payload.ok(); + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .stop_match3d_run(Match3DRunStopRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + stopped_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn restart_match3d_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .restart_match3d_run(Match3DRunRestartRecordInput { + source_run_id: run_id, + next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + restarted_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn finish_match3d_time_up( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .finish_match3d_time_up(Match3DRunTimeUpRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + finished_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +async fn submit_and_finalize_match3d_message( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + session_id: String, + payload: SendMatch3DAgentMessageRequest, +) -> Result { + ensure_non_empty( + request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + ensure_non_empty( + request_context, + MATCH3D_AGENT_PROVIDER, + &payload.client_message_id, + "clientMessageId", + )?; + ensure_non_empty( + request_context, + MATCH3D_AGENT_PROVIDER, + &payload.text, + "text", + )?; + + let submitted = state + .spacetime_client() + .submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.to_string(), + user_message_id: payload.client_message_id.clone(), + user_message_text: payload.text.clone(), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let next_config = build_config_from_message(&submitted, &payload); + let assistant_reply = build_match3d_assistant_reply(&next_config); + let progress_percent = resolve_progress_percent(&next_config); + let stage = if progress_percent >= 100 { + "ReadyToCompile" + } else { + "Collecting" + } + .to_string(); + + state + .spacetime_client() + .finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput { + session_id, + owner_user_id: owner_user_id.to_string(), + assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)), + assistant_reply_text: Some(assistant_reply), + config_json: serialize_match3d_config(&next_config), + progress_percent, + stage, + updated_at_micros: current_utc_micros(), + error_message: None, + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + }) +} + +fn map_match3d_agent_session_response( + session: Match3DAgentSessionRecord, +) -> Match3DAgentSessionSnapshotResponse { + Match3DAgentSessionSnapshotResponse { + session_id: session.session_id, + current_turn: session.current_turn, + progress_percent: session.progress_percent, + stage: session.stage, + anchor_pack: map_match3d_anchor_pack_response(session.anchor_pack), + config: session.config.map(map_match3d_config_response), + draft: session.draft.map(map_match3d_draft_response), + messages: session + .messages + .into_iter() + .map(map_match3d_message_response) + .collect(), + last_assistant_reply: session.last_assistant_reply, + published_profile_id: session.published_profile_id, + updated_at: session.updated_at, + } +} + +fn map_match3d_anchor_pack_response(anchor: Match3DAnchorPackRecord) -> Match3DAnchorPackResponse { + Match3DAnchorPackResponse { + theme: map_match3d_anchor_item_response(anchor.theme), + clear_count: map_match3d_anchor_item_response(anchor.clear_count), + difficulty: map_match3d_anchor_item_response(anchor.difficulty), + } +} + +fn map_match3d_anchor_item_response(anchor: Match3DAnchorItemRecord) -> Match3DAnchorItemResponse { + Match3DAnchorItemResponse { + key: anchor.key, + label: anchor.label, + value: anchor.value, + status: anchor.status, + } +} + +fn map_match3d_config_response(config: Match3DCreatorConfigRecord) -> Match3DCreatorConfigResponse { + Match3DCreatorConfigResponse { + theme_text: config.theme_text, + reference_image_src: config.reference_image_src, + clear_count: config.clear_count, + difficulty: config.difficulty, + } +} + +fn map_match3d_draft_response(draft: Match3DResultDraftRecord) -> Match3DResultDraftResponse { + Match3DResultDraftResponse { + profile_id: draft.profile_id, + game_name: draft.game_name, + theme_text: draft.theme_text, + summary_text: Some(draft.summary_text.clone()), + summary: draft.summary_text, + tags: draft.tags, + cover_image_src: draft.cover_image_src, + reference_image_src: draft.reference_image_src, + clear_count: draft.clear_count, + difficulty: draft.difficulty, + total_item_count: draft.total_item_count, + publish_ready: draft.publish_ready, + blockers: draft.blockers, + } +} + +fn map_match3d_message_response(message: Match3DAgentMessageRecord) -> Match3DAgentMessageResponse { + Match3DAgentMessageResponse { + id: message.message_id, + role: message.role, + kind: message.kind, + text: message.text, + created_at: message.created_at, + } +} + +fn map_match3d_work_summary_response(item: Match3DWorkProfileRecord) -> Match3DWorkSummaryResponse { + Match3DWorkSummaryResponse { + work_id: item.work_id, + profile_id: item.profile_id, + owner_user_id: item.owner_user_id, + source_session_id: item.source_session_id, + game_name: item.game_name, + theme_text: item.theme_text, + summary: item.summary, + tags: item.tags, + cover_image_src: item.cover_image_src, + reference_image_src: item.reference_image_src, + clear_count: item.clear_count, + difficulty: item.difficulty, + publication_status: item.publication_status, + play_count: item.play_count, + updated_at: item.updated_at, + published_at: item.published_at, + publish_ready: item.publish_ready, + } +} + +fn map_match3d_work_profile_response(item: Match3DWorkProfileRecord) -> Match3DWorkProfileResponse { + Match3DWorkProfileResponse { + summary: map_match3d_work_summary_response(item), + } +} + +fn map_match3d_run_response(run: Match3DRunRecord) -> Match3DRunSnapshotResponse { + Match3DRunSnapshotResponse { + run_id: run.run_id, + profile_id: run.profile_id, + owner_user_id: run.owner_user_id, + status: normalize_match3d_run_status(run.status.as_str()).to_string(), + snapshot_version: run.snapshot_version, + started_at_ms: run.started_at_ms, + duration_limit_ms: run.duration_limit_ms, + server_now_ms: run.server_now_ms, + remaining_ms: run.remaining_ms, + clear_count: run.clear_count, + total_item_count: run.total_item_count, + cleared_item_count: run.cleared_item_count, + items: run + .items + .into_iter() + .map(map_match3d_item_response) + .collect(), + tray_slots: run + .tray_slots + .into_iter() + .map(map_match3d_tray_slot_response) + .collect(), + failure_reason: run + .failure_reason + .map(|reason| normalize_match3d_failure_reason(reason.as_str()).to_string()), + last_confirmed_action_id: run.last_confirmed_action_id, + } +} + +fn map_match3d_item_response(item: Match3DItemSnapshotRecord) -> Match3DItemSnapshotResponse { + Match3DItemSnapshotResponse { + item_instance_id: item.item_instance_id, + item_type_id: item.item_type_id, + visual_key: item.visual_key, + x: item.x, + y: item.y, + radius: item.radius, + layer: item.layer, + state: normalize_match3d_item_state(item.state.as_str()).to_string(), + clickable: item.clickable, + tray_slot_index: item.tray_slot_index, + } +} + +fn map_match3d_tray_slot_response(slot: Match3DTraySlotRecord) -> Match3DTraySlotResponse { + Match3DTraySlotResponse { + slot_index: slot.slot_index, + item_instance_id: slot.item_instance_id, + item_type_id: slot.item_type_id, + visual_key: slot.visual_key, + } +} + +fn map_match3d_click_confirmation_response( + confirmation: Match3DClickConfirmationRecord, +) -> Match3DClickConfirmationResponse { + Match3DClickConfirmationResponse { + accepted: confirmation.accepted, + reject_reason: confirmation + .reject_reason + .map(|reason| normalize_match3d_click_reject_reason(reason.as_str()).to_string()), + entered_slot_index: confirmation.entered_slot_index, + cleared_item_instance_ids: confirmation.cleared_item_instance_ids, + run: map_match3d_run_response(confirmation.run), + } +} + +fn build_config_from_create_request( + payload: &CreateMatch3DAgentSessionRequest, +) -> Match3DConfigJson { + Match3DConfigJson { + theme_text: payload + .theme_text + .as_deref() + .or(payload.seed_text.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(MATCH3D_DEFAULT_THEME) + .to_string(), + reference_image_src: payload.reference_image_src.clone(), + clear_count: payload + .clear_count + .unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT) + .max(1), + difficulty: payload + .difficulty + .unwrap_or(MATCH3D_DEFAULT_DIFFICULTY) + .clamp(1, 10), + } +} + +fn build_config_from_message( + session: &Match3DAgentSessionRecord, + payload: &SendMatch3DAgentMessageRequest, +) -> Match3DConfigJson { + let current = resolve_config_or_default(session.config.as_ref()); + if payload.quick_fill_requested.unwrap_or(false) || payload.text.contains("自动配置") { + return Match3DConfigJson { + theme_text: if current.theme_text.trim().is_empty() { + MATCH3D_DEFAULT_THEME.to_string() + } else { + current.theme_text + }, + reference_image_src: current.reference_image_src, + clear_count: current.clear_count.max(1), + difficulty: current.difficulty.clamp(1, 10), + }; + } + + let text = payload.text.trim(); + let theme_text = parse_theme_from_text(text).unwrap_or(current.theme_text); + let clear_count = parse_number_after_keywords(text, &["消除", "次数", "clearCount"]) + .unwrap_or(current.clear_count) + .max(1); + let difficulty = parse_number_after_keywords(text, &["难度", "difficulty"]) + .unwrap_or(current.difficulty) + .clamp(1, 10); + + Match3DConfigJson { + theme_text, + reference_image_src: current.reference_image_src, + clear_count, + difficulty, + } +} + +fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson { + config + .map(|config| Match3DConfigJson { + theme_text: config.theme_text.clone(), + reference_image_src: config.reference_image_src.clone(), + clear_count: config.clear_count.max(1), + difficulty: config.difficulty.clamp(1, 10), + }) + .unwrap_or_else(|| Match3DConfigJson { + theme_text: MATCH3D_DEFAULT_THEME.to_string(), + reference_image_src: None, + clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, + difficulty: MATCH3D_DEFAULT_DIFFICULTY, + }) +} + +fn serialize_match3d_config(config: &Match3DConfigJson) -> Option { + serde_json::to_string(config).ok() +} + +fn build_seed_text( + payload: &CreateMatch3DAgentSessionRequest, + config: &Match3DConfigJson, +) -> String { + payload + .seed_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + format!( + "{}题材,消除{}次,难度{}", + config.theme_text, config.clear_count, config.difficulty + ) + }) +} + +fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String { + format!( + "已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。", + config.theme_text, + config.clear_count, + config.clear_count.saturating_mul(3), + config.difficulty + ) +} + +fn resolve_progress_percent(config: &Match3DConfigJson) -> u32 { + let completed = [ + !config.theme_text.trim().is_empty(), + config.clear_count > 0, + (1..=10).contains(&config.difficulty), + ] + .into_iter() + .filter(|done| *done) + .count(); + ((completed as u32) * 100) / 3 +} + +fn parse_theme_from_text(text: &str) -> Option { + for marker in ["题材", "主题"] { + if let Some((_, value)) = text.split_once(marker) { + let normalized = value + .trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace()) + .split_whitespace() + .next() + .unwrap_or_default() + .trim_matches(['。', ',', ',', ';', ';']) + .to_string(); + if !normalized.is_empty() { + return Some(normalized); + } + } + } + let trimmed = text.trim(); + if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit()) + { + return Some(trimmed.to_string()); + } + None +} + +fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option { + for keyword in keywords { + if let Some(index) = text.find(keyword) { + let suffix = &text[index + keyword.len()..]; + if let Some(value) = first_positive_integer(suffix) { + return Some(value); + } + } + } + first_positive_integer(text) +} + +fn first_positive_integer(text: &str) -> Option { + let mut digits = String::new(); + for ch in text.chars() { + if ch.is_ascii_digit() { + digits.push(ch); + } else if !digits.is_empty() { + break; + } + } + digits.parse::().ok().filter(|value| *value > 0) +} + +fn normalize_tags(tags: Vec) -> Vec { + let mut result = Vec::new(); + for tag in tags { + let trimmed = tag.trim(); + if !trimmed.is_empty() && !result.iter().any(|value| value == trimmed) { + result.push(trimmed.to_string()); + } + if result.len() >= 6 { + break; + } + } + result +} + +fn resolve_author_display_name( + state: &AppState, + authenticated: &AuthenticatedAccessToken, +) -> String { + state + .auth_user_service() + .get_user_by_id(authenticated.claims().user_id()) + .ok() + .flatten() + .map(|user| user.display_name) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "玩家".to_string()) +} + +fn normalize_match3d_run_status(value: &str) -> &str { + match value { + "Running" => "running", + "Won" => "won", + "Failed" => "failed", + "Stopped" => "stopped", + _ => value, + } +} + +fn normalize_match3d_item_state(value: &str) -> &str { + match value { + "InBoard" => "in_board", + "InTray" => "in_tray", + "Cleared" => "cleared", + _ => value, + } +} + +fn normalize_match3d_failure_reason(value: &str) -> &str { + match value { + "TimeUp" => "time_up", + "TrayFull" => "tray_full", + _ => value, + } +} + +fn normalize_match3d_click_reject_reason(value: &str) -> &str { + match value { + "RejectedNotClickable" => "item_not_clickable", + "RejectedAlreadyMoved" => "item_not_in_board", + "RejectedTrayFull" => "tray_full", + "VersionConflict" => "snapshot_version_mismatch", + "RunFinished" => "run_not_active", + _ => value, + } +} + +fn ensure_non_empty( + request_context: &RequestContext, + provider: &str, + value: &str, + field_name: &str, +) -> Result<(), Response> { + if value.trim().is_empty() { + return Err(match3d_bad_request( + request_context, + provider, + format!("{field_name} is required").as_str(), + )); + } + Ok(()) +} + +fn match3d_json( + payload: Result, JsonRejection>, + request_context: &RequestContext, + provider: &str, +) -> Result, Response> { + payload.map_err(|error| { + match3d_error_response( + request_context, + provider, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "message": error.body_text(), + })), + ) + }) +} + +fn match3d_bad_request( + request_context: &RequestContext, + provider: &str, + message: &str, +) -> Response { + match3d_error_response( + request_context, + provider, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": provider, + "message": message, + })), + ) +} + +fn map_match3d_client_error(error: SpacetimeClientError) -> AppError { + let status = match &error { + SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, + SpacetimeClientError::Procedure(message) + if message.contains("不存在") + || message.contains("not found") + || message.contains("does not exist") => + { + StatusCode::NOT_FOUND + } + SpacetimeClientError::Procedure(message) + if message.contains("发布需要") + || message.contains("不能为空") + || message.contains("必须") => + { + StatusCode::BAD_REQUEST + } + _ => StatusCode::BAD_GATEWAY, + }; + + AppError::from_status(status).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn match3d_error_response( + request_context: &RequestContext, + provider: &str, + error: AppError, +) -> Response { + let mut response = error.into_response_with_context(Some(request_context)); + response.headers_mut().insert( + HeaderName::from_static("x-genarrative-provider"), + header::HeaderValue::from_str(provider) + .unwrap_or_else(|_| header::HeaderValue::from_static("match3d")), + ); + response +} + +fn match3d_sse_json_event(event_name: &str, payload: Value) -> Result { + Event::default() + .event(event_name) + .json_data(payload) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "sse", + "message": format!("SSE payload 序列化失败:{error}"), + })) + }) +} + +fn match3d_sse_json_event_or_error(event_name: &str, payload: Value) -> Event { + match match3d_sse_json_event(event_name, payload) { + Ok(event) => event, + Err(error) => Event::default().event("error").data(format!("{error:?}")), + } +} + +fn current_utc_micros() -> i64 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + (duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros()) +} + +fn current_utc_ms() -> i64 { + current_utc_micros().saturating_div(1000) +} diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 39cb8f2b..524d50e8 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -9,7 +9,8 @@ use module_auth::{ }; use serde_json::json; use shared_contracts::auth::{ - PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, PhoneSendCodeResponse, + PhoneLoginReferralResponse, PhoneLoginRequest, PhoneLoginResponse, PhoneSendCodeRequest, + PhoneSendCodeResponse, }; use time::OffsetDateTime; use tracing::{info, warn}; @@ -110,6 +111,7 @@ pub async fn phone_login( AppError::from_status(StatusCode::BAD_REQUEST).with_message("手机号登录暂未启用") ); } + let invite_code = payload.invite_code.clone(); let result = match state .phone_auth_service() .login( @@ -146,6 +148,18 @@ pub async fn phone_login( 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 signed_session = create_auth_session( &state, @@ -174,11 +188,55 @@ pub async fn phone_login( PhoneLoginResponse { token: signed_session.access_token, 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, +) -> Option { + 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 { match raw_scene.unwrap_or("login").trim() { "login" => Ok(PhoneAuthScene::Login), diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index a41abdae..a14a550b 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -5,18 +5,18 @@ use axum::{ response::Response, }; use module_runtime::{ - PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileMembershipBenefitRecord, - RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, - RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, - RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, - RuntimeReferralRedeemRecord, + PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK, RuntimeProfileInviteCodeRecord, + RuntimeProfileMembershipBenefitRecord, RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, + RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileWalletLedgerSourceType, + RuntimeReferralInviteCenterRecord, }; use serde_json::{Value, json}; use shared_contracts::runtime::{ - AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileRedeemCodeRequest, - CreateProfileRechargeOrderRequest, CreateProfileRechargeOrderResponse, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, + AdminDisableProfileRedeemCodeRequest, AdminUpsertProfileInviteCodeRequest, + AdminUpsertProfileRedeemCodeRequest, CreateProfileRechargeOrderRequest, + CreateProfileRechargeOrderResponse, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME, PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_REFUND, PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_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_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, - ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, - ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, - ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, + ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, + ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, + ProfileRechargeOrderResponse, ProfileRechargeProductResponse, ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse, ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, - RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, - RedeemProfileRewardCodeResponse, + RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, }; use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; @@ -217,27 +216,14 @@ pub async fn get_profile_referral_invite_center( } pub async fn redeem_profile_referral_invite_code( - State(state): State, + State(_state): State, Extension(request_context): Extension, - Extension(authenticated): Extension, - Json(payload): Json, + Extension(_authenticated): Extension, + Json(_payload): Json, ) -> Result, Response> { - let user_id = authenticated.claims().user_id().to_string(); - 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, - map_runtime_profile_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - build_redeem_profile_referral_invite_code_response(record), + Err(runtime_profile_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message("邀请码仅注册时填写"), )) } @@ -334,6 +320,37 @@ pub async fn admin_disable_profile_redeem_code( )) } +pub async fn admin_upsert_profile_invite_code( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, 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( State(state): State, Extension(request_context): Extension, @@ -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( record: RuntimeProfileRewardCodeRedeemRecord, ) -> RedeemProfileRewardCodeResponse { @@ -519,6 +524,30 @@ fn build_redeem_profile_reward_code_response( } } +fn normalize_admin_invite_code_metadata(metadata: Option) -> Result { + 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 { match raw.trim().to_ascii_lowercase().as_str() { "public" => Ok(RuntimeProfileRedeemCodeMode::Public), @@ -528,6 +557,20 @@ fn parse_profile_redeem_code_mode(raw: &str) -> Result ProfileInviteCodeAdminResponse { + let metadata = + serde_json::from_str::(&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( record: RuntimeProfileRedeemCodeRecord, ) -> ProfileRedeemCodeAdminResponse { @@ -549,7 +592,7 @@ fn build_profile_redeem_code_admin_response( mod tests { 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::{ body::Body, @@ -715,6 +758,60 @@ mod tests { 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] async fn profile_dashboard_compat_route_matches_main_route_error_shape() { assert_compat_route_matches_main_route_error_shape( diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 7e2f0d50..127e2051 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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_REFERRAL_REWARD_POINTS: u64 = 30; 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 DEFAULT_SAVE_ARCHIVE_SUMMARY_TEXT: &str = "继续推进上一次保存的故事。"; pub const PROFILE_RECHARGE_PAYMENT_CHANNEL_MOCK: &str = "mock"; @@ -503,6 +505,33 @@ pub struct RuntimeProfileRedeemCodeAdminProcedureResult { pub error_message: Option, } +#[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, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeReferralInviteCenterSnapshot { @@ -616,6 +645,7 @@ pub enum RuntimeProfileFieldError { MissingLedgerId, InvalidWalletAmount, MissingInviteCode, + InvalidInviteCodeMetadata, MissingRedeemCode, InvalidRedeemCodeReward, InvalidRedeemCodeMaxUses, @@ -917,6 +947,17 @@ pub struct RuntimeProfileRedeemCodeRecord { 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)] pub struct RuntimeReferralInviteCenterRecord { 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 { + 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( user_id: String, ) -> Result { @@ -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( snapshot: RuntimeProfilePlayedWorldSnapshot, ) -> RuntimeProfilePlayedWorldRecord { @@ -1949,6 +2023,25 @@ pub fn normalize_invite_code(value: String) -> Option { } } +pub fn normalize_invite_code_metadata_json( + value: String, +) -> Result { + 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::(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 { normalize_invite_code(value) } @@ -1960,6 +2053,9 @@ impl std::fmt::Display for RuntimeProfileFieldError { Self::MissingLedgerId => f.write_str("profile.wallet_ledger_id 不能为空"), Self::InvalidWalletAmount => f.write_str("profile.wallet_amount 必须大于 0"), Self::MissingInviteCode => f.write_str("referral.invite_code 不能为空"), + Self::InvalidInviteCodeMetadata => { + f.write_str("邀请码 metadata 必须是 JSON 对象且不超过 4096 bytes") + } Self::MissingRedeemCode => f.write_str("兑换码不能为空"), Self::InvalidRedeemCodeReward => f.write_str("兑换码奖励无效"), 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] fn profile_dashboard_record_formats_optional_timestamp() { let record = build_runtime_profile_dashboard_record(RuntimeProfileDashboardSnapshot { diff --git a/server-rs/crates/shared-contracts/src/auth.rs b/server-rs/crates/shared-contracts/src/auth.rs index 07ec16d0..14da7fe5 100644 --- a/server-rs/crates/shared-contracts/src/auth.rs +++ b/server-rs/crates/shared-contracts/src/auth.rs @@ -164,6 +164,8 @@ pub struct PhoneSendCodeResponse { pub struct PhoneLoginRequest { pub phone: String, pub code: String, + #[serde(default)] + pub invite_code: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -171,6 +173,19 @@ pub struct PhoneLoginRequest { pub struct PhoneLoginResponse { pub token: String, pub user: AuthUserPayload, + pub created: bool, + pub referral: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PhoneLoginReferralResponse { + pub ok: bool, + pub message: Option, + pub invitee_reward_granted: bool, + pub inviter_reward_granted: bool, + pub invitee_balance_after: Option, + pub inviter_balance_after: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/server-rs/crates/shared-contracts/src/match3d_agent.rs b/server-rs/crates/shared-contracts/src/match3d_agent.rs index dd44c097..0b74357f 100644 --- a/server-rs/crates/shared-contracts/src/match3d_agent.rs +++ b/server-rs/crates/shared-contracts/src/match3d_agent.rs @@ -55,8 +55,11 @@ pub struct Match3DCreatorConfigResponse { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Match3DResultDraftResponse { + pub profile_id: String, pub game_name: String, pub theme_text: String, + #[serde(default)] + pub summary_text: Option, pub summary: String, pub tags: Vec, #[serde(default)] @@ -65,10 +68,28 @@ pub struct Match3DResultDraftResponse { pub reference_image_src: Option, pub clear_count: u32, pub difficulty: u32, + pub total_item_count: u32, pub publish_ready: bool, pub blockers: Vec, } +#[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)] #[serde(rename_all = "camelCase")] pub struct Match3DAgentMessageResponse { @@ -86,6 +107,7 @@ pub struct Match3DAgentSessionSnapshotResponse { pub current_turn: u32, pub progress_percent: u32, pub stage: String, + pub anchor_pack: Match3DAnchorPackResponse, #[serde(default)] pub config: Option, #[serde(default)] diff --git a/server-rs/crates/shared-contracts/src/match3d_works.rs b/server-rs/crates/shared-contracts/src/match3d_works.rs index a55d1f84..3bf85e55 100644 --- a/server-rs/crates/shared-contracts/src/match3d_works.rs +++ b/server-rs/crates/shared-contracts/src/match3d_works.rs @@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] pub struct PutMatch3DWorkRequest { pub game_name: String, + #[serde(default)] + pub theme_text: Option, pub summary: String, pub tags: Vec, #[serde(default)] @@ -74,6 +76,7 @@ mod tests { fn match3d_work_request_uses_camel_case() { let payload = serde_json::to_value(PutMatch3DWorkRequest { game_name: "水果抓大鹅".to_string(), + theme_text: Some("水果".to_string()), summary: "水果主题".to_string(), tags: vec!["水果".to_string()], cover_image_src: None, diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 5d70bc2c..087142ce 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -298,6 +298,14 @@ pub struct AdminUpsertProfileRedeemCodeRequest { pub allowed_public_user_codes: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUpsertProfileInviteCodeRequest { + pub invite_code: String, + #[serde(default)] + pub metadata: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct AdminDisableProfileRedeemCodeRequest { @@ -319,6 +327,16 @@ pub struct ProfileRedeemCodeAdminResponse { 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 { true } diff --git a/server-rs/crates/spacetime-client/Cargo.toml b/server-rs/crates/spacetime-client/Cargo.toml index b700beaf..b631e777 100644 --- a/server-rs/crates/spacetime-client/Cargo.toml +++ b/server-rs/crates/spacetime-client/Cargo.toml @@ -11,6 +11,7 @@ module-big-fish = { path = "../module-big-fish" } module-combat = { path = "../module-combat" } module-custom-world = { path = "../module-custom-world" } module-inventory = { path = "../module-inventory" } +module-match3d = { path = "../module-match3d" } module-npc = { path = "../module-npc" } module-puzzle = { path = "../module-puzzle" } module-runtime = { path = "../module-runtime" } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index bb49112f..104aa88e 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -32,6 +32,14 @@ pub use mapper::{ PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord, 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, PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, @@ -51,6 +59,7 @@ pub mod big_fish; pub mod combat; pub mod custom_world; pub mod inventory; +pub mod match3d; pub mod npc; pub mod puzzle; pub mod runtime; @@ -124,7 +133,7 @@ use module_puzzle::{ }; use module_runtime::{ RuntimeBrowseHistoryRecord, RuntimePlatformTheme as DomainRuntimePlatformTheme, - RuntimeProfileDashboardRecord, RuntimeProfilePlayStatsRecord, + RuntimeProfileDashboardRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, @@ -133,7 +142,8 @@ use module_runtime::{ RuntimeSnapshotRecord, build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, build_runtime_browse_history_record, build_runtime_browse_history_sync_input, build_runtime_profile_dashboard_get_input, - build_runtime_profile_dashboard_record, build_runtime_profile_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_recharge_center_record, build_runtime_profile_recharge_order_create_input, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 60fc4aa2..7b111d4a 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -203,6 +203,19 @@ impl From } } +impl From + 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 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 { + 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( result: RuntimeProfilePlayStatsProcedureResult, ) -> Result { @@ -1388,6 +1421,132 @@ pub(crate) fn map_big_fish_works_procedure_result( .collect()) } +pub(crate) fn map_match3d_agent_session_procedure_result( + result: Match3DAgentSessionProcedureResult, +) -> Result { + 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::(&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 { + 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::(&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, 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::>(&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 { + 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 { + 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( result: StorySessionProcedureResult, ) -> Result { @@ -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( snapshot: 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, +) -> 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 { + let run = serde_json::from_str::(&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::>(); + 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, +) -> 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 { + 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( snapshot: DomainPuzzleAgentSuggestedAction, ) -> PuzzleAgentSuggestedActionRecord { @@ -2493,9 +2894,9 @@ fn map_puzzle_recommended_next_work( pub(crate) fn map_puzzle_runtime_level_snapshot( snapshot: DomainPuzzleRuntimeLevelSnapshot, ) -> PuzzleRuntimeLevelRecord { - // 中文注释:历史 run_json 可能缺 started_at_ms,领域 serde 会回填为 0;API 层继续补成 1,避免前端计时器拿到无效开局时间。 let started_at_ms = if snapshot.started_at_ms == 0 { - 1 + // 中文注释:旧 run_json 没有计时字段时只补一个可用开始时间,其余限时字段保持旧默认值。 + current_unix_millis_for_legacy_puzzle_snapshot() } else { 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( snapshot: module_puzzle::PuzzleLeaderboardEntry, ) -> PuzzleLeaderboardEntryRecord { @@ -4574,6 +4982,367 @@ pub struct BigFishWorkRemixRecordInput { 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, + 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, + pub assistant_reply_text: Option, + pub config_json: Option, + pub progress_percent: u32, + pub stage: String, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[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, + pub summary_text: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + 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, + 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, + pub cover_image_src: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub total_item_count: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[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, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: Option, + pub published_profile_id: Option, + 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, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + pub published_at: Option, + 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, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DTraySlotRecord { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +#[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, + pub remaining_ms: u64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub items: Vec, + pub tray_slots: Vec, + pub failure_reason: Option, + pub last_confirmed_action_id: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Match3DClickConfirmationRecord { + pub status: String, + pub accepted: bool, + pub reject_reason: Option, + pub accepted_item_instance_id: Option, + pub entered_slot_index: Option, + pub cleared_item_instance_ids: Vec, + pub failure_reason: Option, + pub run: Match3DRunRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct Match3DCreatorConfigJsonRecord { + theme_text: String, + reference_image_src: Option, + 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, + 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, + messages: Vec, + last_assistant_reply: String, + published_profile_id: Option, + #[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, + 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, +} + +#[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, + item_type_id: Option, + visual_key: Option, +} + +#[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, + items: Vec, + failure_reason: Option, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct PuzzleAnchorItemRecord { pub key: String, diff --git a/server-rs/crates/spacetime-client/src/match3d.rs b/server-rs/crates/spacetime-client/src/match3d.rs new file mode 100644 index 00000000..32db7478 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/match3d.rs @@ -0,0 +1,466 @@ +use super::*; +use crate::mapper::*; + +impl SpacetimeClient { + pub async fn create_match3d_agent_session( + &self, + input: Match3DAgentSessionCreateRecordInput, + ) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, SpacetimeClientError> { + self.list_match3d_works_with_input(Match3DWorksListInput { + owner_user_id, + published_only: false, + }) + .await + } + + pub async fn list_match3d_gallery( + &self, + ) -> Result, SpacetimeClientError> { + self.list_match3d_works_with_input(Match3DWorksListInput { + // 中文注释:公开广场读取只依赖 published_only,owner_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, 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 { + 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs new file mode 100644 index 00000000..3601be97 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_invite_code_procedure.rs @@ -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, + ) + 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, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileInviteCodeAdminProcedureResult>( + "admin_upsert_profile_invite_code", + AdminUpsertProfileInviteCodeArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index 62bb65b8..67d0bf21 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -335,6 +335,9 @@ pub mod runtime_platform_theme_type; pub mod runtime_profile_dashboard_get_input_type; pub mod runtime_profile_dashboard_procedure_result_type; pub mod runtime_profile_dashboard_snapshot_type; +pub mod runtime_profile_invite_code_admin_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_snapshot_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 custom_world_gallery_entry_table; 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 advance_puzzle_next_level_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_procedure_result_type::RuntimeProfileDashboardProcedureResult; 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_snapshot_type::RuntimeProfileMembershipSnapshot; 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_npc_state_reducer::upsert_npc_state; 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 advance_puzzle_next_level_procedure::advance_puzzle_next_level; pub use append_ai_text_chunk_and_return_procedure::append_ai_text_chunk_and_return; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs index 1a3ba152..be2e1ecb 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs @@ -15,6 +15,7 @@ use spacetimedb_sdk::__codegen::{ pub struct ProfileInviteCode { pub user_id: String, pub invite_code: String, + pub metadata_json: String, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, } @@ -31,6 +32,7 @@ impl __sdk::InModule for ProfileInviteCode { pub struct ProfileInviteCodeCols { pub user_id: __sdk::__query_builder::Col, pub invite_code: __sdk::__query_builder::Col, + pub metadata_json: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, } @@ -41,6 +43,7 @@ impl __sdk::__query_builder::HasCols for ProfileInviteCode { ProfileInviteCodeCols { user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), 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"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs new file mode 100644 index 00000000..70f54300 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_procedure_result_type.rs @@ -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, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileInviteCodeAdminProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs new file mode 100644 index 00000000..77daf412 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs new file mode 100644 index 00000000..36ea09ee --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs @@ -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; +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index f95407cf..49f79325 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -346,6 +346,35 @@ impl SpacetimeClient { .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 { + 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( &self, user_id: String, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 7cd22b3d..93af5b00 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -1102,6 +1102,14 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .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 let Some(object) = next_value.as_object_mut() { // 中文注释:旧迁移包没有公开游玩次数字段,导入时按新建作品默认 0 兼容。 diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index f99494fc..834b7778 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -70,6 +70,7 @@ pub struct ProfileInviteCode { pub(crate) user_id: String, #[unique] pub(crate) invite_code: String, + pub(crate) metadata_json: String, pub(crate) created_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( ctx: &ReducerContext, input: RuntimeProfileSaveArchiveListInput, @@ -1658,10 +1678,14 @@ fn redeem_profile_referral_invite_code_record( ), bound_at, )?; - let today_inviter_reward_count = - count_today_profile_referral_inviter_rewards(ctx, &inviter_code.user_id, bound_at); - let inviter_reward_granted = - today_inviter_reward_count < PROFILE_REFERRAL_DAILY_INVITER_REWARD_LIMIT; + let is_admin_invite_code = is_admin_profile_invite_code_user_id(&inviter_code.user_id); + let today_inviter_reward_count = if is_admin_invite_code { + 0 + } 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 { apply_profile_wallet_delta( ctx, @@ -1877,6 +1901,56 @@ fn admin_disable_profile_redeem_code_record( Ok(build_profile_redeem_code_snapshot_from_row(&inserted)) } +fn admin_upsert_profile_invite_code_record( + ctx: &ReducerContext, + input: RuntimeProfileInviteCodeAdminUpsertInput, +) -> Result { + 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( ctx: &ReducerContext, 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 { user_id: user_id.to_string(), invite_code, + metadata_json: PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON.to_string(), created_at: ctx.timestamp, updated_at: ctx.timestamp, }) @@ -1980,6 +2055,14 @@ fn count_today_profile_referral_inviter_rewards( .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 { ctx.db .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( row: &ProfileWalletLedger, ) -> RuntimeProfileWalletLedgerEntrySnapshot { diff --git a/spacetime.json b/spacetime.json index fc6b20a0..cb013ee4 100644 --- a/spacetime.json +++ b/spacetime.json @@ -1,4 +1,4 @@ { - "server": "http://127.0.0.1:3101", + "server": "local", "module-path": "./server-rs/crates/spacetime-module" } diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 52484249..c3feca1a 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -88,13 +88,19 @@ const mockUser: AuthUser = { beforeEach(() => { vi.clearAllMocks(); + window.history.replaceState(null, '', '/'); authMocks.consumeAuthCallbackResult.mockReturnValue(null); authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token'); authMocks.getCurrentAuthUser.mockResolvedValue({ user: null, availableLoginMethods: ['phone'], }); - authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); + authMocks.loginWithPhoneCode.mockResolvedValue({ + token: 'jwt-phone', + user: mockUser, + created: false, + referral: null, + }); authMocks.authEntry.mockResolvedValue(mockUser); authMocks.changePassword.mockResolvedValue(mockUser); 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( '13800000000', '123456', + undefined, ); expect(authMocks.getCurrentAuthUser).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(); }); +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( + +
公开内容
+
, + ); + + 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 () => { const user = userEvent.setup(); authMocks.getCurrentAuthUser.mockResolvedValue({ diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index ed6e2b26..1a60c3d2 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -59,6 +59,14 @@ type AuthStatus = 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( methods: AuthLoginMethod[] | null | undefined, ): AuthLoginMethod[] { @@ -83,6 +91,10 @@ export function AuthGate({ children }: AuthGateProps) { const [bindingPhone, setBindingPhone] = useState(false); const [wechatLoading, setWechatLoading] = useState(false); const [showLoginModal, setShowLoginModal] = useState(false); + const [loginInitialMode, setLoginInitialMode] = useState< + 'login' | 'register' + >('login'); + const [pendingInviteCode, setPendingInviteCode] = useState(''); const [showSettingsModal, setShowSettingsModal] = useState(false); const [settingsEntryMode, setSettingsEntryMode] = useState< 'settings' | 'account' @@ -102,6 +114,7 @@ export function AuthGate({ children }: AuthGateProps) { const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] = useState(null); const pendingProtectedActionRef = useRef<(() => void) | null>(null); + const autoOpenedInviteCodeRef = useRef(null); const hasRenderedPlatformContentRef = useRef(false); const canKeepPlatformContentMounted = hasRenderedPlatformContentRef.current && @@ -169,6 +182,8 @@ export function AuthGate({ children }: AuthGateProps) { const closeLoginModal = useCallback(() => { pendingProtectedActionRef.current = null; setShowLoginModal(false); + setLoginInitialMode('login'); + setPendingInviteCode(''); setLoginCaptchaChallenge(null); setError(''); }, []); @@ -187,6 +202,8 @@ export function AuthGate({ children }: AuthGateProps) { } pendingProtectedActionRef.current = postLoginAction ?? null; + setLoginInitialMode('login'); + setPendingInviteCode(''); setShowLoginModal(true); }, [readyUser], @@ -224,6 +241,24 @@ export function AuthGate({ children }: AuthGateProps) { openLoginModal(); }, [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(() => { let isActive = true; @@ -703,6 +738,8 @@ export function AuthGate({ children }: AuthGateProps) { wechatLoading={wechatLoading} error={error} captchaChallenge={loginCaptchaChallenge} + initialMode={loginInitialMode} + initialInviteCode={pendingInviteCode} onClose={closeLoginModal} onSendCode={async (phone, scene, captcha) => { setSendingCode(true); @@ -727,14 +764,21 @@ export function AuthGate({ children }: AuthGateProps) { setSendingCode(false); } }} - onPhoneSubmit={async (phone, code) => { + onPhoneSubmit={async (phone, code, inviteCode) => { setLoggingIn(true); setError(''); try { - const nextUser = await loginWithPhoneCode(phone, code); + const response = await loginWithPhoneCode( + phone, + code, + inviteCode, + ); setStoredLastLoginPhone(phone); setLoginCaptchaChallenge(null); - activateReadyUser(nextUser); + if (response.referral && !response.referral.ok) { + setError(response.referral.message || '邀请码未绑定'); + } + activateReadyUser(response.user); } catch (loginError) { setError( loginError instanceof Error diff --git a/src/components/auth/LoginScreen.tsx b/src/components/auth/LoginScreen.tsx index 3c7577b8..1629bdcc 100644 --- a/src/components/auth/LoginScreen.tsx +++ b/src/components/auth/LoginScreen.tsx @@ -10,7 +10,7 @@ import { getStoredLastLoginPhone } from '../../services/authService'; import { CaptchaChallengeField } from './CaptchaChallengeField'; type SmsScene = 'login' | 'reset_password'; -type LoginTab = 'phone' | 'password'; +type LoginTab = 'phone' | 'password' | 'register'; type LoginScreenProps = { isOpen: boolean; @@ -21,6 +21,8 @@ type LoginScreenProps = { wechatLoading: boolean; error: string; captchaChallenge: AuthCaptchaChallenge | null; + initialMode?: 'login' | 'register'; + initialInviteCode?: string; onClose: () => void; onSendCode: ( phone: string, @@ -33,7 +35,11 @@ type LoginScreenProps = { cooldownSeconds: number; expiresInSeconds: number; }>; - onPhoneSubmit: (phone: string, code: string) => Promise; + onPhoneSubmit: ( + phone: string, + code: string, + inviteCode?: string, + ) => Promise; onPasswordSubmit: (phone: string, password: string) => Promise; onResetPassword: ( phone: string, @@ -52,6 +58,8 @@ export function LoginScreen({ wechatLoading, error, captchaChallenge, + initialMode = 'login', + initialInviteCode = '', onClose, onSendCode, onPhoneSubmit, @@ -66,6 +74,7 @@ export function LoginScreen({ const [resetPhone, setResetPhone] = useState(''); const [resetCode, setResetCode] = useState(''); const [resetPasswordValue, setResetPasswordValue] = useState(''); + const [inviteCode, setInviteCode] = useState(initialInviteCode); const [captchaAnswer, setCaptchaAnswer] = useState(''); const [cooldownSeconds, setCooldownSeconds] = useState(0); const [resetCooldownSeconds, setResetCooldownSeconds] = useState(0); @@ -88,16 +97,23 @@ export function LoginScreen({ setResetPhone(''); setResetCode(''); setResetPasswordValue(''); + setInviteCode(initialInviteCode); setCaptchaAnswer(''); setCooldownSeconds(0); setResetCooldownSeconds(0); setHint(''); - setActiveLoginTab(phoneLoginEnabled ? 'phone' : 'password'); - }, [isOpen, phoneLoginEnabled]); + setActiveLoginTab( + initialMode === 'register' && phoneLoginEnabled + ? 'register' + : phoneLoginEnabled + ? 'phone' + : 'password', + ); + }, [initialInviteCode, initialMode, isOpen, phoneLoginEnabled]); useEffect(() => { if ( - activeLoginTab === 'phone' && + (activeLoginTab === 'phone' || activeLoginTab === 'register') && !phoneLoginEnabled && passwordLoginEnabled ) { @@ -196,9 +212,11 @@ export function LoginScreen({ /> ) : (
- {phoneLoginEnabled && passwordLoginEnabled ? ( + {phoneLoginEnabled ? (
@@ -208,11 +226,19 @@ export function LoginScreen({ > 短信登录 + {passwordLoginEnabled ? ( + setActiveLoginTab('password')} + > + 密码登录 + + ) : null} setActiveLoginTab('password')} + active={activeLoginTab === 'register'} + onClick={() => setActiveLoginTab('register')} > - 密码登录 + 注册
) : null} @@ -312,6 +338,42 @@ export function LoginScreen({ /> ) : null} + {phoneLoginEnabled && activeLoginTab === 'register' ? ( + { + 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 && !phoneLoginEnabled && !wechatLoginEnabled ? ( @@ -358,6 +420,7 @@ function LoginTabButton({ function PhoneCodeForm({ phone, code, + inviteCode = '', captchaAnswer, captchaChallenge, cooldownSeconds, @@ -368,14 +431,17 @@ function PhoneCodeForm({ submitLabel, enabled, showPhoneField, + showInviteCodeField = false, onPhoneChange, onCodeChange, + onInviteCodeChange, onCaptchaAnswerChange, onSendCode, onSubmit, }: { phone: string; code: string; + inviteCode?: string; captchaAnswer: string; captchaChallenge: AuthCaptchaChallenge | null; cooldownSeconds: number; @@ -386,8 +452,10 @@ function PhoneCodeForm({ submitLabel: string; enabled: boolean; showPhoneField: boolean; + showInviteCodeField?: boolean; onPhoneChange: (value: string) => void; onCodeChange: (value: string) => void; + onInviteCodeChange?: (value: string) => void; onCaptchaAnswerChange: (value: string) => void; onSendCode: () => Promise; onSubmit: () => Promise; @@ -418,6 +486,19 @@ function PhoneCodeForm({ ) : null} + {showInviteCodeField ? ( + + ) : null} +