迁移后端到stdb
This commit is contained in:
@@ -42,6 +42,10 @@ type AccountModalProps = {
|
||||
|
||||
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
|
||||
switch (loginMethod) {
|
||||
case 'guest':
|
||||
return '游客登录';
|
||||
case 'jwt':
|
||||
return '令牌登录';
|
||||
case 'wechat':
|
||||
return '微信登录';
|
||||
case 'phone':
|
||||
|
||||
@@ -7,70 +7,70 @@ import type { AuthUser } from '../../services/authService';
|
||||
import { AuthGate } from './AuthGate';
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
getStoredAccessToken: vi.fn(),
|
||||
ensureAutoAuthUser: vi.fn(),
|
||||
getAuthLoginOptions: vi.fn(),
|
||||
consumeAuthCallbackResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
getStoredAccessToken: authMocks.getStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
bindWechatPhone: vi.fn(),
|
||||
changePhoneNumber: vi.fn(),
|
||||
consumeAuthCallbackResult: authMocks.consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser: authMocks.ensureAutoAuthUser,
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthLoginOptions: authMocks.getAuthLoginOptions,
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthSessions: vi.fn(),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
getCurrentAuthUser: vi.fn(),
|
||||
getAuthAuditLogs: vi.fn(async () => []),
|
||||
getAuthRiskBlocks: vi.fn(async () => []),
|
||||
getAuthSessions: vi.fn(async () => []),
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: vi.fn(),
|
||||
logoutAllAuthSessions: vi.fn(),
|
||||
logoutAuthUser: vi.fn(),
|
||||
revokeAuthSession: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
AUTH_STATE_EVENT: 'genarrative-auth-state-changed',
|
||||
}));
|
||||
|
||||
vi.mock('../../spacetime/client', () => ({
|
||||
SPACETIME_KICK_EVENT: 'genarrative-spacetime-kick',
|
||||
SPACETIME_SESSION_REVOKED_EVENT: 'genarrative-spacetime-session-revoked',
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT:
|
||||
'genarrative-spacetime-verification-required',
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthAuditLogs: authMocks.getAuthAuditLogs,
|
||||
getAuthRiskBlocks: authMocks.getAuthRiskBlocks,
|
||||
getAuthSessions: authMocks.getAuthSessions,
|
||||
getCaptchaChallengeFromError: authMocks.getCaptchaChallengeFromError,
|
||||
liftAuthRiskBlock: authMocks.liftAuthRiskBlock,
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
revokeAuthSession: authMocks.revokeAuthSession,
|
||||
sendPhoneLoginCode: authMocks.sendPhoneLoginCode,
|
||||
}));
|
||||
|
||||
vi.mock('./AccountModal', () => ({
|
||||
AccountModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('./BindPhoneScreen', () => ({
|
||||
BindPhoneScreen: () => <div>绑定手机号</div>,
|
||||
vi.mock('./PhoneVerificationModal', () => ({
|
||||
PhoneVerificationModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div>完成短信验证</div> : null,
|
||||
}));
|
||||
|
||||
const mockUser: AuthUser = {
|
||||
const activeUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
username: 'guest_1',
|
||||
displayName: '测试玩家',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'guest',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authMocks.getStoredAccessToken.mockReturnValue(null);
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureAutoAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
credentials: {
|
||||
username: 'guest_tester',
|
||||
password: 'auto_password',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate prefers login screen when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
test('auth gate renders app content after spacetime auth session is ready', async () => {
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: activeUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
@@ -80,7 +80,23 @@ test('auth gate prefers login screen when phone login is available', async () =>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('账号登录')).toBeTruthy();
|
||||
expect(screen.getByText('手机号')).toBeTruthy();
|
||||
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
|
||||
expect(await screen.findByText('应用内容')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('auth gate opens phone verification modal for pending sms verification user', async () => {
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: {
|
||||
...activeUser,
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
},
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<div>应用内容</div>
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('完成短信验证')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
AUTH_STATE_EVENT,
|
||||
getStoredAccessToken,
|
||||
} from '../../services/apiClient';
|
||||
SPACETIME_KICK_EVENT,
|
||||
SPACETIME_SESSION_REVOKED_EVENT,
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
type KickEventDetail,
|
||||
type SessionRevokedDetail,
|
||||
type VerificationRequiredDetail,
|
||||
} from '../../spacetime/client';
|
||||
import {
|
||||
type AuthAuditLogEntry,
|
||||
type AuthCaptchaChallenge,
|
||||
type AuthLoginMethod,
|
||||
type AuthRiskBlockSummary,
|
||||
type AuthSessionSummary,
|
||||
type AuthUser,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
@@ -27,41 +25,28 @@ import {
|
||||
logoutAuthUser,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import { AUTH_STATE_EVENT } from '../../services/apiClient';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext } from './AuthUiContext';
|
||||
import { BindPhoneScreen } from './BindPhoneScreen';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
import { PhoneVerificationModal } from './PhoneVerificationModal';
|
||||
|
||||
type AuthGateProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
type AuthStatus =
|
||||
| 'checking'
|
||||
| 'recovering'
|
||||
| 'unauthenticated'
|
||||
| 'pending_bind_phone'
|
||||
| 'ready'
|
||||
| 'error';
|
||||
|
||||
const allowDevGuestAutoAuth =
|
||||
import.meta.env.DEV &&
|
||||
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST !== 'false';
|
||||
type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error';
|
||||
|
||||
export function AuthGate({ children }: AuthGateProps) {
|
||||
const [status, setStatus] = useState<AuthStatus>('checking');
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [availableLoginMethods, setAvailableLoginMethods] = useState<
|
||||
AuthLoginMethod[]
|
||||
>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
const [bindingPhone, setBindingPhone] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [verifyingPhone, setVerifyingPhone] = useState(false);
|
||||
const [showAccountModal, setShowAccountModal] = useState(false);
|
||||
const [showVerificationModal, setShowVerificationModal] = useState(false);
|
||||
const [verificationPrompt, setVerificationPrompt] =
|
||||
useState<VerificationRequiredDetail | null>(null);
|
||||
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
|
||||
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
@@ -69,9 +54,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
|
||||
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
|
||||
const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false);
|
||||
const [loginCaptchaChallenge, setLoginCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [bindCaptchaChallenge, setBindCaptchaChallenge] =
|
||||
const [verificationCaptchaChallenge, setVerificationCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
|
||||
useState<AuthCaptchaChallenge | null>(null);
|
||||
@@ -79,97 +62,8 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
useEffect(() => {
|
||||
let isActive = true;
|
||||
|
||||
const ensureAutoUser = async () => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('recovering');
|
||||
|
||||
try {
|
||||
const { user: nextUser } = await ensureAutoAuthUser();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
setError('');
|
||||
} catch (autoAuthError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('error');
|
||||
setError(
|
||||
autoAuthError instanceof Error
|
||||
? autoAuthError.message
|
||||
: '自动登录失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hydrate = async () => {
|
||||
const loadLoginOptions = async () => {
|
||||
const options = await getAuthLoginOptions();
|
||||
if (!isActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods(options.availableLoginMethods);
|
||||
return options;
|
||||
};
|
||||
|
||||
const resolveGuestFallback = async () => {
|
||||
try {
|
||||
const options = await loadLoginOptions();
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
allowDevGuestAutoAuth &&
|
||||
options &&
|
||||
options.availableLoginMethods.length === 0
|
||||
) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
} catch (optionsError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowDevGuestAutoAuth) {
|
||||
await ensureAutoUser();
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableLoginMethods([]);
|
||||
setUser(null);
|
||||
setError(
|
||||
optionsError instanceof Error
|
||||
? optionsError.message
|
||||
: '读取登录方式失败,请稍后再试。',
|
||||
);
|
||||
setStatus('unauthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
const callbackResult = consumeAuthCallbackResult();
|
||||
if (callbackResult?.error && isActive) {
|
||||
setError(callbackResult.error);
|
||||
}
|
||||
|
||||
const token = getStoredAccessToken();
|
||||
if (!token) {
|
||||
await resolveGuestFallback();
|
||||
return;
|
||||
}
|
||||
setStatus((current) => (current === 'ready' ? current : 'recovering'));
|
||||
|
||||
try {
|
||||
const nextSession = await getCurrentAuthUser();
|
||||
@@ -178,43 +72,99 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
if (!nextSession.user) {
|
||||
setUser(null);
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
setStatus('unauthenticated');
|
||||
setStatus('error');
|
||||
setError('账号初始化失败,请刷新页面后重试。');
|
||||
return;
|
||||
}
|
||||
|
||||
setUser(nextSession.user);
|
||||
setAvailableLoginMethods(nextSession.availableLoginMethods);
|
||||
setStatus(
|
||||
nextSession.user.bindingStatus === 'pending_bind_phone'
|
||||
? 'pending_bind_phone'
|
||||
: 'ready',
|
||||
);
|
||||
setError(callbackResult?.error ?? '');
|
||||
} catch {
|
||||
setStatus('ready');
|
||||
setError('');
|
||||
if (nextSession.user.bindingStatus === 'pending_bind_phone') {
|
||||
setShowVerificationModal(true);
|
||||
setVerificationPrompt((current) =>
|
||||
current ?? {
|
||||
phoneNumberMasked: nextSession.user.phoneNumberMasked,
|
||||
title: '完成短信验证',
|
||||
detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。',
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (hydrateError) {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
await resolveGuestFallback();
|
||||
setUser(null);
|
||||
setStatus('error');
|
||||
setError(
|
||||
hydrateError instanceof Error
|
||||
? hydrateError.message
|
||||
: '账号初始化失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
void hydrate();
|
||||
|
||||
const handleAuthStateChange = () => {
|
||||
setStatus('checking');
|
||||
void hydrate();
|
||||
};
|
||||
|
||||
const handleVerificationRequired = (event: Event) => {
|
||||
const detail = (event as CustomEvent<VerificationRequiredDetail>).detail;
|
||||
setVerificationPrompt(
|
||||
detail ?? {
|
||||
phoneNumberMasked: user?.phoneNumberMasked ?? null,
|
||||
title: '完成短信验证',
|
||||
detail: '验证手机号后,才能继续进行需要服务端状态写入的操作。',
|
||||
},
|
||||
);
|
||||
setShowVerificationModal(true);
|
||||
};
|
||||
|
||||
const handleKick = (event: Event) => {
|
||||
const detail = (event as CustomEvent<KickEventDetail>).detail;
|
||||
if (detail?.message) {
|
||||
setError(detail.message);
|
||||
}
|
||||
setShowVerificationModal(true);
|
||||
};
|
||||
|
||||
const handleSessionRevoked = (event: Event) => {
|
||||
const detail = (event as CustomEvent<SessionRevokedDetail>).detail;
|
||||
if (detail?.message) {
|
||||
setError(detail.message);
|
||||
} else {
|
||||
setError('当前连接已失效,请重新建立连接。');
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.addEventListener(
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
handleVerificationRequired,
|
||||
);
|
||||
window.addEventListener(SPACETIME_KICK_EVENT, handleKick);
|
||||
window.addEventListener(
|
||||
SPACETIME_SESSION_REVOKED_EVENT,
|
||||
handleSessionRevoked,
|
||||
);
|
||||
|
||||
return () => {
|
||||
isActive = false;
|
||||
window.removeEventListener(AUTH_STATE_EVENT, handleAuthStateChange);
|
||||
window.removeEventListener(
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
handleVerificationRequired,
|
||||
);
|
||||
window.removeEventListener(SPACETIME_KICK_EVENT, handleKick);
|
||||
window.removeEventListener(
|
||||
SPACETIME_SESSION_REVOKED_EVENT,
|
||||
handleSessionRevoked,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
}, [user?.phoneNumberMasked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAccountModal || status !== 'ready') {
|
||||
@@ -225,75 +175,68 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingRiskBlocks(true);
|
||||
setLoadingSessions(true);
|
||||
setLoadingAuditLogs(true);
|
||||
|
||||
void getAuthRiskBlocks()
|
||||
.then((nextBlocks) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setRiskBlocks(nextBlocks);
|
||||
}
|
||||
setRiskBlocks(nextBlocks);
|
||||
})
|
||||
.catch((blockError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
setLoadingRiskBlocks(false);
|
||||
});
|
||||
|
||||
void getAuthSessions()
|
||||
.then((nextSessions) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setSessions(nextSessions);
|
||||
}
|
||||
setSessions(nextSessions);
|
||||
})
|
||||
.catch((sessionError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
setLoadingSessions(false);
|
||||
});
|
||||
|
||||
void getAuthAuditLogs()
|
||||
.then((nextLogs) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setAuditLogs(nextLogs);
|
||||
}
|
||||
setAuditLogs(nextLogs);
|
||||
})
|
||||
.catch((auditError) => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
if (isActive) {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
setLoadingAuditLogs(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -314,152 +257,19 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
[user],
|
||||
);
|
||||
|
||||
if (status === 'checking') {
|
||||
if (status === 'checking' || status === 'recovering') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
正在校验登录状态...
|
||||
正在建立账号连接...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'recovering') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
|
||||
正在自动创建或恢复账号...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<LoginScreen
|
||||
availableLoginMethods={availableLoginMethods}
|
||||
sendingCode={sendingCode}
|
||||
loggingIn={loggingIn}
|
||||
wechatLoading={wechatLoading}
|
||||
error={error}
|
||||
captchaChallenge={loginCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'login', captcha);
|
||||
setLoginCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setLoginCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setLoggingIn(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setLoginCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (loginError) {
|
||||
setError(
|
||||
loginError instanceof Error
|
||||
? loginError.message
|
||||
: '登录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoggingIn(false);
|
||||
}
|
||||
}}
|
||||
onStartWechatLogin={async () => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
await startWechatLogin();
|
||||
} catch (wechatError) {
|
||||
setError(
|
||||
wechatError instanceof Error
|
||||
? wechatError.message
|
||||
: '微信登录暂不可用,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setWechatLoading(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'pending_bind_phone' && user) {
|
||||
return (
|
||||
<BindPhoneScreen
|
||||
user={user}
|
||||
sendingCode={sendingCode}
|
||||
binding={bindingPhone}
|
||||
error={error}
|
||||
captchaChallenge={bindCaptchaChallenge}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
|
||||
setBindCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setBindCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setBindingPhone(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await bindWechatPhone(phone, code);
|
||||
setBindCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setStatus('ready');
|
||||
} catch (bindError) {
|
||||
setError(
|
||||
bindError instanceof Error
|
||||
? bindError.message
|
||||
: '绑定手机号失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setBindingPhone(false);
|
||||
}
|
||||
}}
|
||||
onLogout={async () => {
|
||||
await logoutAuthUser();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status !== 'ready' || !user) {
|
||||
if (status === 'error' || !user) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
|
||||
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
|
||||
<div className="text-base font-medium text-zinc-50">登录状态异常</div>
|
||||
<div className="text-base font-medium text-zinc-50">连接状态异常</div>
|
||||
<div className="mt-3 text-sm leading-6 text-zinc-300">
|
||||
{error || '账号恢复失败,请刷新页面后重试。'}
|
||||
</div>
|
||||
@@ -502,6 +312,59 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<PhoneVerificationModal
|
||||
user={user}
|
||||
isOpen={showVerificationModal}
|
||||
title={verificationPrompt?.title}
|
||||
detail={verificationPrompt?.detail}
|
||||
sendingCode={sendingCode}
|
||||
verifying={verifyingPhone}
|
||||
error={error}
|
||||
captchaChallenge={verificationCaptchaChallenge}
|
||||
onClose={() => setShowVerificationModal(false)}
|
||||
onSendCode={async (phone, captcha) => {
|
||||
setSendingCode(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await sendPhoneLoginCode(phone, 'login', captcha);
|
||||
setVerificationCaptchaChallenge(null);
|
||||
return result;
|
||||
} catch (sendError) {
|
||||
const captchaChallenge = getCaptchaChallengeFromError(sendError);
|
||||
if (captchaChallenge) {
|
||||
setVerificationCaptchaChallenge(captchaChallenge);
|
||||
}
|
||||
setError(
|
||||
sendError instanceof Error
|
||||
? sendError.message
|
||||
: '发送验证码失败,请稍后再试。',
|
||||
);
|
||||
throw sendError;
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
}}
|
||||
onSubmit={async (phone, code) => {
|
||||
setVerifyingPhone(true);
|
||||
setError('');
|
||||
try {
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setVerificationCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
setShowVerificationModal(false);
|
||||
} catch (verifyError) {
|
||||
setError(
|
||||
verifyError instanceof Error
|
||||
? verifyError.message
|
||||
: '短信验证失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setVerifyingPhone(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<AccountModal
|
||||
user={user}
|
||||
isOpen={showAccountModal}
|
||||
@@ -520,12 +383,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingRiskBlocks(true);
|
||||
try {
|
||||
setRiskBlocks(await getAuthRiskBlocks());
|
||||
} catch (blockError) {
|
||||
setError(
|
||||
blockError instanceof Error
|
||||
? blockError.message
|
||||
: '读取安全状态失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingRiskBlocks(false);
|
||||
}
|
||||
@@ -547,12 +404,6 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
setSessions(await getAuthSessions());
|
||||
} catch (sessionError) {
|
||||
setError(
|
||||
sessionError instanceof Error
|
||||
? sessionError.message
|
||||
: '读取登录设备失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
@@ -561,30 +412,15 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
setLoadingAuditLogs(true);
|
||||
try {
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (auditError) {
|
||||
setError(
|
||||
auditError instanceof Error
|
||||
? auditError.message
|
||||
: '读取账号操作记录失败,请稍后再试。',
|
||||
);
|
||||
} finally {
|
||||
setLoadingAuditLogs(false);
|
||||
}
|
||||
}}
|
||||
onRevokeSession={async (sessionId) => {
|
||||
try {
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
setAuditLogs(await getAuthAuditLogs());
|
||||
} catch (revokeError) {
|
||||
setError(
|
||||
revokeError instanceof Error
|
||||
? revokeError.message
|
||||
: '移除登录设备失败,请稍后再试。',
|
||||
);
|
||||
}
|
||||
await revokeAuthSession(sessionId);
|
||||
setSessions((current) =>
|
||||
current.filter((session) => session.sessionId !== sessionId),
|
||||
);
|
||||
}}
|
||||
onLogoutAll={async () => {
|
||||
await logoutAllAuthSessions();
|
||||
@@ -609,11 +445,12 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
}}
|
||||
onChangePhone={async (phone, code) => {
|
||||
const nextUser = await changePhoneNumber(phone, code);
|
||||
const nextUser = await loginWithPhoneCode(phone, code);
|
||||
setChangePhoneCaptchaChallenge(null);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</AuthUiContext.Provider>
|
||||
|
||||
201
src/components/auth/PhoneVerificationModal.tsx
Normal file
201
src/components/auth/PhoneVerificationModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { CaptchaChallengeField } from './CaptchaChallengeField';
|
||||
|
||||
type PhoneVerificationModalProps = {
|
||||
user: AuthUser;
|
||||
isOpen: boolean;
|
||||
title?: string;
|
||||
detail?: string;
|
||||
sendingCode: boolean;
|
||||
verifying: boolean;
|
||||
error: string;
|
||||
captchaChallenge: AuthCaptchaChallenge | null;
|
||||
onClose: () => void;
|
||||
onSendCode: (
|
||||
phone: string,
|
||||
captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) => Promise<{
|
||||
cooldownSeconds: number;
|
||||
expiresInSeconds: number;
|
||||
}>;
|
||||
onSubmit: (phone: string, code: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function PhoneVerificationModal({
|
||||
user,
|
||||
isOpen,
|
||||
title = '完成短信验证',
|
||||
detail = '验证手机号后,才能继续进行需要服务端状态写入的操作。',
|
||||
sendingCode,
|
||||
verifying,
|
||||
error,
|
||||
captchaChallenge,
|
||||
onClose,
|
||||
onSendCode,
|
||||
onSubmit,
|
||||
}: PhoneVerificationModalProps) {
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const [cooldownSeconds, setCooldownSeconds] = useState(0);
|
||||
const [hint, setHint] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPhone('');
|
||||
setCode('');
|
||||
setCaptchaAnswer('');
|
||||
setHint('');
|
||||
setCooldownSeconds(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cooldownSeconds <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCooldownSeconds((current) => Math.max(0, current - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [cooldownSeconds, isOpen]);
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[72] flex items-end justify-center overflow-y-auto bg-black/68 px-4 sm:items-center"
|
||||
style={{
|
||||
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-[28px] border border-emerald-300/18 bg-[linear-gradient(180deg,_rgba(14,19,25,0.98),_rgba(8,11,16,0.99))] p-5 shadow-[0_24px_80px_rgba(0,0,0,0.6)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.28em] text-emerald-200/70">
|
||||
验证窗口
|
||||
</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-white">{title}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-white/20 hover:text-white"
|
||||
onClick={onClose}
|
||||
>
|
||||
稍后
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm leading-6 text-zinc-300">
|
||||
{detail}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200">
|
||||
当前账号:{user.displayName}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="mt-5 grid gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSubmit(phone, code);
|
||||
}}
|
||||
>
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
autoComplete="tel"
|
||||
inputMode="numeric"
|
||||
value={phone}
|
||||
onChange={(event) => setPhone(event.target.value)}
|
||||
placeholder="13800000000"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="grid gap-2 text-sm text-zinc-300">
|
||||
<span>验证码</span>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
|
||||
inputMode="numeric"
|
||||
value={code}
|
||||
onChange={(event) => setCode(event.target.value)}
|
||||
placeholder="输入验证码"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
|
||||
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
onClick={() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await onSendCode(phone, {
|
||||
challengeId: captchaChallenge?.challengeId,
|
||||
answer: captchaAnswer,
|
||||
});
|
||||
setCooldownSeconds(result.cooldownSeconds);
|
||||
setHint(
|
||||
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
|
||||
);
|
||||
setCaptchaAnswer('');
|
||||
} catch {
|
||||
setHint('');
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{sendingCode
|
||||
? '发送中...'
|
||||
: cooldownSeconds > 0
|
||||
? `${cooldownSeconds}s`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{hint ? (
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
|
||||
{hint}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<CaptchaChallengeField
|
||||
challenge={captchaChallenge}
|
||||
answer={captchaAnswer}
|
||||
onAnswerChange={setCaptchaAnswer}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={verifying || !phone.trim() || !code.trim()}
|
||||
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{verifying ? '验证中...' : '完成验证'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -217,6 +217,10 @@ function formatSnapshotTime(value: string | null | undefined) {
|
||||
|
||||
function describeLoginMethod(loginMethod: AuthUser['loginMethod']) {
|
||||
switch (loginMethod) {
|
||||
case 'guest':
|
||||
return '游客';
|
||||
case 'jwt':
|
||||
return '令牌';
|
||||
case 'phone':
|
||||
return '手机号';
|
||||
case 'wechat':
|
||||
|
||||
66
src/module_bindings/add_reducer.rs
Normal file
66
src/module_bindings/add_reducer.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
// 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(super) struct AddArgs {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl From<AddArgs> for super::Reducer {
|
||||
fn from(args: AddArgs) -> Self {
|
||||
Self::Add { name: args.name }
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AddArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the reducer `add`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteReducers`].
|
||||
pub trait add {
|
||||
/// Request that the remote module invoke the reducer `add` to run as soon as possible.
|
||||
///
|
||||
/// This method returns immediately, and errors only if we are unable to send the request.
|
||||
/// The reducer will run asynchronously in the future,
|
||||
/// and this method provides no way to listen for its completion status.
|
||||
/// /// Use [`add:add_then`] to run a callback after the reducer completes.
|
||||
fn add(&self, name: String) -> __sdk::Result<()> {
|
||||
self.add_then(name, |_, _| {})
|
||||
}
|
||||
|
||||
/// Request that the remote module invoke the reducer `add` to run as soon as possible,
|
||||
/// registering `callback` to run when we are notified that the reducer completed.
|
||||
///
|
||||
/// This method returns immediately, and errors only if we are unable to send the request.
|
||||
/// The reducer will run asynchronously in the future,
|
||||
/// and its status can be observed with the `callback`.
|
||||
fn add_then(
|
||||
&self,
|
||||
name: String,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
impl add for super::RemoteReducers {
|
||||
fn add_then(
|
||||
&self,
|
||||
name: String,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(AddArgs { name }, callback)
|
||||
}
|
||||
}
|
||||
817
src/module_bindings/mod.rs
Normal file
817
src/module_bindings/mod.rs
Normal file
@@ -0,0 +1,817 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
|
||||
#![allow(unused, clippy::all)]
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
pub mod add_reducer;
|
||||
pub mod person_table;
|
||||
pub mod person_type;
|
||||
pub mod say_hello_reducer;
|
||||
|
||||
pub use add_reducer::add;
|
||||
pub use person_table::*;
|
||||
pub use person_type::Person;
|
||||
pub use say_hello_reducer::say_hello;
|
||||
|
||||
#[derive(Clone, PartialEq, Debug)]
|
||||
|
||||
/// One of the reducers defined by this module.
|
||||
///
|
||||
/// Contained within a [`__sdk::ReducerEvent`] in [`EventContext`]s for reducer events
|
||||
/// to indicate which reducer caused the event.
|
||||
|
||||
pub enum Reducer {
|
||||
Add { name: String },
|
||||
SayHello,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for Reducer {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::Reducer for Reducer {
|
||||
fn reducer_name(&self) -> &'static str {
|
||||
match self {
|
||||
Reducer::Add { .. } => "add",
|
||||
Reducer::SayHello => "say_hello",
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
#[allow(clippy::clone_on_copy)]
|
||||
fn args_bsatn(&self) -> Result<Vec<u8>, __sats::bsatn::EncodeError> {
|
||||
match self {
|
||||
Reducer::Add { name } => {
|
||||
__sats::bsatn::to_vec(&add_reducer::AddArgs { name: name.clone() })
|
||||
}
|
||||
Reducer::SayHello => __sats::bsatn::to_vec(&say_hello_reducer::SayHelloArgs {}),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
#[doc(hidden)]
|
||||
pub struct DbUpdate {
|
||||
person: __sdk::TableUpdate<Person>,
|
||||
}
|
||||
|
||||
impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
|
||||
type Error = __sdk::Error;
|
||||
fn try_from(raw: __ws::v2::TransactionUpdate) -> Result<Self, Self::Error> {
|
||||
let mut db_update = DbUpdate::default();
|
||||
for table_update in __sdk::transaction_update_iter_table_updates(raw) {
|
||||
match &table_update.table_name[..] {
|
||||
"person" => db_update
|
||||
.person
|
||||
.append(person_table::parse_table_update(table_update)?),
|
||||
|
||||
unknown => {
|
||||
return Err(__sdk::InternalError::unknown_name(
|
||||
"table",
|
||||
unknown,
|
||||
"DatabaseUpdate",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(db_update)
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for DbUpdate {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbUpdate for DbUpdate {
|
||||
fn apply_to_client_cache(
|
||||
&self,
|
||||
cache: &mut __sdk::ClientCache<RemoteModule>,
|
||||
) -> AppliedDiff<'_> {
|
||||
let mut diff = AppliedDiff::default();
|
||||
|
||||
diff.person = cache.apply_diff_to_table::<Person>("person", &self.person);
|
||||
|
||||
diff
|
||||
}
|
||||
fn parse_initial_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
|
||||
let mut db_update = DbUpdate::default();
|
||||
for table_rows in raw.tables {
|
||||
match &table_rows.table[..] {
|
||||
"person" => db_update
|
||||
.person
|
||||
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
|
||||
unknown => {
|
||||
return Err(
|
||||
__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(db_update)
|
||||
}
|
||||
fn parse_unsubscribe_rows(raw: __ws::v2::QueryRows) -> __sdk::Result<Self> {
|
||||
let mut db_update = DbUpdate::default();
|
||||
for table_rows in raw.tables {
|
||||
match &table_rows.table[..] {
|
||||
"person" => db_update
|
||||
.person
|
||||
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
|
||||
unknown => {
|
||||
return Err(
|
||||
__sdk::InternalError::unknown_name("table", unknown, "QueryRows").into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(db_update)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[allow(non_snake_case)]
|
||||
#[doc(hidden)]
|
||||
pub struct AppliedDiff<'r> {
|
||||
person: __sdk::TableAppliedDiff<'r, Person>,
|
||||
__unused: std::marker::PhantomData<&'r ()>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for AppliedDiff<'_> {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
|
||||
fn invoke_row_callbacks(
|
||||
&self,
|
||||
event: &EventContext,
|
||||
callbacks: &mut __sdk::DbCallbacks<RemoteModule>,
|
||||
) {
|
||||
callbacks.invoke_table_row_callbacks::<Person>("person", &self.person, event);
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
#[derive(Debug)]
|
||||
pub struct RemoteModule;
|
||||
|
||||
impl __sdk::InModule for RemoteModule {
|
||||
type Module = Self;
|
||||
}
|
||||
|
||||
/// The `reducers` field of [`EventContext`] and [`DbConnection`],
|
||||
/// with methods provided by extension traits for each reducer defined by the module.
|
||||
pub struct RemoteReducers {
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RemoteReducers {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
/// The `procedures` field of [`DbConnection`] and other [`DbContext`] types,
|
||||
/// with methods provided by extension traits for each procedure defined by the module.
|
||||
pub struct RemoteProcedures {
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RemoteProcedures {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
/// The `db` field of [`EventContext`] and [`DbConnection`],
|
||||
/// with methods provided by extension traits for each table defined by the module.
|
||||
pub struct RemoteTables {
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RemoteTables {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
/// A connection to a remote module, including a materialized view of a subset of the database.
|
||||
///
|
||||
/// Connect to a remote module by calling [`DbConnection::builder`]
|
||||
/// and using the [`__sdk::DbConnectionBuilder`] builder-pattern constructor.
|
||||
///
|
||||
/// You must explicitly advance the connection by calling any one of:
|
||||
///
|
||||
/// - [`DbConnection::frame_tick`].
|
||||
#[cfg_attr(not(target_arch = "wasm32"), doc = "- [`DbConnection::run_threaded`].")]
|
||||
#[cfg_attr(
|
||||
target_arch = "wasm32",
|
||||
doc = "- [`DbConnection::run_background_task`]."
|
||||
)]
|
||||
/// - [`DbConnection::run_async`].
|
||||
/// - [`DbConnection::advance_one_message`].
|
||||
#[cfg_attr(
|
||||
not(target_arch = "wasm32"),
|
||||
doc = "- [`DbConnection::advance_one_message_blocking`]."
|
||||
)]
|
||||
/// - [`DbConnection::advance_one_message_async`].
|
||||
///
|
||||
/// Which of these methods you should call depends on the specific needs of your application,
|
||||
/// but you must call one of them, or else the connection will never progress.
|
||||
pub struct DbConnection {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
#[doc(hidden)]
|
||||
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for DbConnection {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for DbConnection {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl DbConnection {
|
||||
/// Builder-pattern constructor for a connection to a remote module.
|
||||
///
|
||||
/// See [`__sdk::DbConnectionBuilder`] for required and optional configuration for the new connection.
|
||||
pub fn builder() -> __sdk::DbConnectionBuilder<RemoteModule> {
|
||||
__sdk::DbConnectionBuilder::new()
|
||||
}
|
||||
|
||||
/// If any WebSocket messages are waiting, process one of them.
|
||||
///
|
||||
/// Returns `true` if a message was processed, or `false` if the queue is empty.
|
||||
/// Callers should invoke this message in a loop until it returns `false`
|
||||
/// or for as much time is available to process messages.
|
||||
///
|
||||
/// Returns an error if the connection is disconnected.
|
||||
/// If the disconnection in question was normal,
|
||||
/// i.e. the result of a call to [`__sdk::DbContext::disconnect`],
|
||||
/// the returned error will be downcastable to [`__sdk::DisconnectedError`].
|
||||
///
|
||||
/// This is a low-level primitive exposed for power users who need significant control over scheduling.
|
||||
/// Most applications should call [`Self::frame_tick`] each frame
|
||||
/// to fully exhaust the queue whenever time is available.
|
||||
pub fn advance_one_message(&self) -> __sdk::Result<bool> {
|
||||
self.imp.advance_one_message()
|
||||
}
|
||||
|
||||
/// Process one WebSocket message, potentially blocking the current thread until one is received.
|
||||
///
|
||||
/// Returns an error if the connection is disconnected.
|
||||
/// If the disconnection in question was normal,
|
||||
/// i.e. the result of a call to [`__sdk::DbContext::disconnect`],
|
||||
/// the returned error will be downcastable to [`__sdk::DisconnectedError`].
|
||||
///
|
||||
/// This is a low-level primitive exposed for power users who need significant control over scheduling.
|
||||
/// Most applications should call [`Self::run_threaded`] to spawn a thread
|
||||
/// which advances the connection automatically.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn advance_one_message_blocking(&self) -> __sdk::Result<()> {
|
||||
self.imp.advance_one_message_blocking()
|
||||
}
|
||||
|
||||
/// Process one WebSocket message, `await`ing until one is received.
|
||||
///
|
||||
/// Returns an error if the connection is disconnected.
|
||||
/// If the disconnection in question was normal,
|
||||
/// i.e. the result of a call to [`__sdk::DbContext::disconnect`],
|
||||
/// the returned error will be downcastable to [`__sdk::DisconnectedError`].
|
||||
///
|
||||
/// This is a low-level primitive exposed for power users who need significant control over scheduling.
|
||||
/// Most applications should call [`Self::run_async`] to run an `async` loop
|
||||
/// which advances the connection when polled.
|
||||
pub async fn advance_one_message_async(&self) -> __sdk::Result<()> {
|
||||
self.imp.advance_one_message_async().await
|
||||
}
|
||||
|
||||
/// Process all WebSocket messages waiting in the queue,
|
||||
/// then return without `await`ing or blocking the current thread.
|
||||
pub fn frame_tick(&self) -> __sdk::Result<()> {
|
||||
self.imp.frame_tick()
|
||||
}
|
||||
|
||||
/// Spawn a thread which processes WebSocket messages as they are received.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn run_threaded(&self) -> std::thread::JoinHandle<()> {
|
||||
self.imp.run_threaded()
|
||||
}
|
||||
|
||||
/// Spawn a background task which processes WebSocket messages as they are received.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn run_background_task(&self) {
|
||||
self.imp.run_background_task()
|
||||
}
|
||||
|
||||
/// Run an `async` loop which processes WebSocket messages when polled.
|
||||
pub async fn run_async(&self) -> __sdk::Result<()> {
|
||||
self.imp.run_async().await
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::DbConnection for DbConnection {
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle on a subscribed query.
|
||||
// TODO: Document this better after implementing the new subscription API.
|
||||
#[derive(Clone)]
|
||||
pub struct SubscriptionHandle {
|
||||
imp: __sdk::SubscriptionHandleImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for SubscriptionHandle {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::SubscriptionHandle for SubscriptionHandle {
|
||||
fn new(imp: __sdk::SubscriptionHandleImpl<RemoteModule>) -> Self {
|
||||
Self { imp }
|
||||
}
|
||||
|
||||
/// Returns true if this subscription has been terminated due to an unsubscribe call or an error.
|
||||
fn is_ended(&self) -> bool {
|
||||
self.imp.is_ended()
|
||||
}
|
||||
|
||||
/// Returns true if this subscription has been applied and has not yet been unsubscribed.
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
/// Unsubscribe from the query controlled by this `SubscriptionHandle`,
|
||||
/// then run `on_end` when its rows are removed from the client cache.
|
||||
fn unsubscribe_then(self, on_end: __sdk::OnEndedCallback<RemoteModule>) -> __sdk::Result<()> {
|
||||
self.imp.unsubscribe_then(Some(on_end))
|
||||
}
|
||||
|
||||
fn unsubscribe(self) -> __sdk::Result<()> {
|
||||
self.imp.unsubscribe_then(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias trait for a [`__sdk::DbContext`] connected to this module,
|
||||
/// with that trait's associated types bounded to this module's concrete types.
|
||||
///
|
||||
/// Users can use this trait as a boundary on definitions which should accept
|
||||
/// either a [`DbConnection`] or an [`EventContext`] and operate on either.
|
||||
pub trait RemoteDbContext:
|
||||
__sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>
|
||||
{
|
||||
}
|
||||
impl<
|
||||
Ctx: __sdk::DbContext<
|
||||
DbView = RemoteTables,
|
||||
Reducers = RemoteReducers,
|
||||
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
|
||||
>,
|
||||
> RemoteDbContext for Ctx
|
||||
{
|
||||
}
|
||||
|
||||
/// An [`__sdk::DbContext`] augmented with a [`__sdk::Event`],
|
||||
/// passed to [`__sdk::Table::on_insert`], [`__sdk::Table::on_delete`] and [`__sdk::TableWithPrimaryKey::on_update`] callbacks.
|
||||
pub struct EventContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
/// The event which caused these callbacks to run.
|
||||
pub event: __sdk::Event<Reducer>,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for EventContext {
|
||||
type Event = __sdk::Event<Reducer>;
|
||||
fn event(&self) -> &Self::Event {
|
||||
&self.event
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
event,
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for EventContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for EventContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::EventContext for EventContext {}
|
||||
|
||||
/// An [`__sdk::DbContext`] augmented with a [`__sdk::ReducerEvent`],
|
||||
/// passed to on-reducer callbacks.
|
||||
pub struct ReducerEventContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
/// The event which caused these callbacks to run.
|
||||
pub event: __sdk::ReducerEvent<Reducer>,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for ReducerEventContext {
|
||||
type Event = __sdk::ReducerEvent<Reducer>;
|
||||
fn event(&self) -> &Self::Event {
|
||||
&self.event
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
event,
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ReducerEventContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for ReducerEventContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::ReducerEventContext for ReducerEventContext {}
|
||||
|
||||
/// An [`__sdk::DbContext`] passed to procedure callbacks.
|
||||
pub struct ProcedureEventContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for ProcedureEventContext {
|
||||
type Event = ();
|
||||
fn event(&self) -> &Self::Event {
|
||||
&()
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, _event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ProcedureEventContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for ProcedureEventContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::ProcedureEventContext for ProcedureEventContext {}
|
||||
|
||||
/// An [`__sdk::DbContext`] passed to [`__sdk::SubscriptionBuilder::on_applied`] and [`SubscriptionHandle::unsubscribe_then`] callbacks.
|
||||
pub struct SubscriptionEventContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for SubscriptionEventContext {
|
||||
type Event = ();
|
||||
fn event(&self) -> &Self::Event {
|
||||
&()
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, _event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for SubscriptionEventContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for SubscriptionEventContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::SubscriptionEventContext for SubscriptionEventContext {}
|
||||
|
||||
/// An [`__sdk::DbContext`] augmented with a [`__sdk::Error`],
|
||||
/// passed to [`__sdk::DbConnectionBuilder::on_disconnect`], [`__sdk::DbConnectionBuilder::on_connect_error`] and [`__sdk::SubscriptionBuilder::on_error`] callbacks.
|
||||
pub struct ErrorContext {
|
||||
/// Access to tables defined by the module via extension traits implemented for [`RemoteTables`].
|
||||
pub db: RemoteTables,
|
||||
/// Access to reducers defined by the module via extension traits implemented for [`RemoteReducers`].
|
||||
pub reducers: RemoteReducers,
|
||||
/// Access to procedures defined by the module via extension traits implemented for [`RemoteProcedures`].
|
||||
pub procedures: RemoteProcedures,
|
||||
/// The event which caused these callbacks to run.
|
||||
pub event: Option<__sdk::Error>,
|
||||
imp: __sdk::DbContextImpl<RemoteModule>,
|
||||
}
|
||||
|
||||
impl __sdk::AbstractEventContext for ErrorContext {
|
||||
type Event = Option<__sdk::Error>;
|
||||
fn event(&self) -> &Self::Event {
|
||||
&self.event
|
||||
}
|
||||
fn new(imp: __sdk::DbContextImpl<RemoteModule>, event: Self::Event) -> Self {
|
||||
Self {
|
||||
db: RemoteTables { imp: imp.clone() },
|
||||
reducers: RemoteReducers { imp: imp.clone() },
|
||||
procedures: RemoteProcedures { imp: imp.clone() },
|
||||
event,
|
||||
imp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ErrorContext {
|
||||
type Module = RemoteModule;
|
||||
}
|
||||
|
||||
impl __sdk::DbContext for ErrorContext {
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
|
||||
fn db(&self) -> &Self::DbView {
|
||||
&self.db
|
||||
}
|
||||
fn reducers(&self) -> &Self::Reducers {
|
||||
&self.reducers
|
||||
}
|
||||
fn procedures(&self) -> &Self::Procedures {
|
||||
&self.procedures
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.imp.is_active()
|
||||
}
|
||||
|
||||
fn disconnect(&self) -> __sdk::Result<()> {
|
||||
self.imp.disconnect()
|
||||
}
|
||||
|
||||
type SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>;
|
||||
|
||||
fn subscription_builder(&self) -> Self::SubscriptionBuilder {
|
||||
__sdk::SubscriptionBuilder::new(&self.imp)
|
||||
}
|
||||
|
||||
fn try_identity(&self) -> Option<__sdk::Identity> {
|
||||
self.imp.try_identity()
|
||||
}
|
||||
fn connection_id(&self) -> __sdk::ConnectionId {
|
||||
self.imp.connection_id()
|
||||
}
|
||||
fn try_connection_id(&self) -> Option<__sdk::ConnectionId> {
|
||||
self.imp.try_connection_id()
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::ErrorContext for ErrorContext {}
|
||||
|
||||
impl __sdk::SpacetimeModule for RemoteModule {
|
||||
type DbConnection = DbConnection;
|
||||
type EventContext = EventContext;
|
||||
type ReducerEventContext = ReducerEventContext;
|
||||
type ProcedureEventContext = ProcedureEventContext;
|
||||
type SubscriptionEventContext = SubscriptionEventContext;
|
||||
type ErrorContext = ErrorContext;
|
||||
type Reducer = Reducer;
|
||||
type DbView = RemoteTables;
|
||||
type Reducers = RemoteReducers;
|
||||
type Procedures = RemoteProcedures;
|
||||
type DbUpdate = DbUpdate;
|
||||
type AppliedDiff<'r> = AppliedDiff<'r>;
|
||||
type SubscriptionHandle = SubscriptionHandle;
|
||||
type QueryBuilder = __sdk::QueryBuilder;
|
||||
|
||||
fn register_tables(client_cache: &mut __sdk::ClientCache<Self>) {
|
||||
person_table::register_table(client_cache);
|
||||
}
|
||||
const ALL_TABLE_NAMES: &'static [&'static str] = &["person"];
|
||||
}
|
||||
111
src/module_bindings/person_table.rs
Normal file
111
src/module_bindings/person_table.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
// 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 super::person_type::Person;
|
||||
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
|
||||
|
||||
/// Table handle for the table `person`.
|
||||
///
|
||||
/// Obtain a handle from the [`PersonTableAccess::person`] method on [`super::RemoteTables`],
|
||||
/// like `ctx.db.person()`.
|
||||
///
|
||||
/// Users are encouraged not to explicitly reference this type,
|
||||
/// but to directly chain method calls,
|
||||
/// like `ctx.db.person().on_insert(...)`.
|
||||
pub struct PersonTableHandle<'ctx> {
|
||||
imp: __sdk::TableHandle<Person>,
|
||||
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the table `person`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteTables`].
|
||||
pub trait PersonTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Obtain a [`PersonTableHandle`], which mediates access to the table `person`.
|
||||
fn person(&self) -> PersonTableHandle<'_>;
|
||||
}
|
||||
|
||||
impl PersonTableAccess for super::RemoteTables {
|
||||
fn person(&self) -> PersonTableHandle<'_> {
|
||||
PersonTableHandle {
|
||||
imp: self.imp.get_table::<Person>("person"),
|
||||
ctx: std::marker::PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PersonInsertCallbackId(__sdk::CallbackId);
|
||||
pub struct PersonDeleteCallbackId(__sdk::CallbackId);
|
||||
|
||||
impl<'ctx> __sdk::Table for PersonTableHandle<'ctx> {
|
||||
type Row = Person;
|
||||
type EventContext = super::EventContext;
|
||||
|
||||
fn count(&self) -> u64 {
|
||||
self.imp.count()
|
||||
}
|
||||
fn iter(&self) -> impl Iterator<Item = Person> + '_ {
|
||||
self.imp.iter()
|
||||
}
|
||||
|
||||
type InsertCallbackId = PersonInsertCallbackId;
|
||||
|
||||
fn on_insert(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> PersonInsertCallbackId {
|
||||
PersonInsertCallbackId(self.imp.on_insert(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_insert(&self, callback: PersonInsertCallbackId) {
|
||||
self.imp.remove_on_insert(callback.0)
|
||||
}
|
||||
|
||||
type DeleteCallbackId = PersonDeleteCallbackId;
|
||||
|
||||
fn on_delete(
|
||||
&self,
|
||||
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
|
||||
) -> PersonDeleteCallbackId {
|
||||
PersonDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
|
||||
}
|
||||
|
||||
fn remove_on_delete(&self, callback: PersonDeleteCallbackId) {
|
||||
self.imp.remove_on_delete(callback.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
|
||||
let _table = client_cache.get_or_make_table::<Person>("person");
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub(super) fn parse_table_update(
|
||||
raw_updates: __ws::v2::TableUpdate,
|
||||
) -> __sdk::Result<__sdk::TableUpdate<Person>> {
|
||||
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
|
||||
__sdk::InternalError::failed_parse("TableUpdate<Person>", "TableUpdate")
|
||||
.with_cause(e)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for query builder access to the table `Person`.
|
||||
///
|
||||
/// Implemented for [`__sdk::QueryTableAccessor`].
|
||||
pub trait personQueryTableAccess {
|
||||
#[allow(non_snake_case)]
|
||||
/// Get a query builder for the table `Person`.
|
||||
fn person(&self) -> __sdk::__query_builder::Table<Person>;
|
||||
}
|
||||
|
||||
impl personQueryTableAccess for __sdk::QueryTableAccessor {
|
||||
fn person(&self) -> __sdk::__query_builder::Table<Person> {
|
||||
__sdk::__query_builder::Table::new("person")
|
||||
}
|
||||
}
|
||||
45
src/module_bindings/person_type.rs
Normal file
45
src/module_bindings/person_type.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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 Person {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for Person {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
/// Column accessor struct for the table `Person`.
|
||||
///
|
||||
/// Provides typed access to columns for query building.
|
||||
pub struct PersonCols {
|
||||
pub name: __sdk::__query_builder::Col<Person, String>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for Person {
|
||||
type Cols = PersonCols;
|
||||
fn cols(table_name: &'static str) -> Self::Cols {
|
||||
PersonCols {
|
||||
name: __sdk::__query_builder::Col::new(table_name, "name"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Indexed column accessor struct for the table `Person`.
|
||||
///
|
||||
/// Provides typed access to indexed columns for query building.
|
||||
pub struct PersonIxCols {}
|
||||
|
||||
impl __sdk::__query_builder::HasIxCols for Person {
|
||||
type IxCols = PersonIxCols;
|
||||
fn ix_cols(table_name: &'static str) -> Self::IxCols {
|
||||
PersonIxCols {}
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::CanBeLookupTable for Person {}
|
||||
62
src/module_bindings/say_hello_reducer.rs
Normal file
62
src/module_bindings/say_hello_reducer.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
// 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(super) struct SayHelloArgs {}
|
||||
|
||||
impl From<SayHelloArgs> for super::Reducer {
|
||||
fn from(args: SayHelloArgs) -> Self {
|
||||
Self::SayHello
|
||||
}
|
||||
}
|
||||
|
||||
impl __sdk::InModule for SayHelloArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the reducer `say_hello`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteReducers`].
|
||||
pub trait say_hello {
|
||||
/// Request that the remote module invoke the reducer `say_hello` to run as soon as possible.
|
||||
///
|
||||
/// This method returns immediately, and errors only if we are unable to send the request.
|
||||
/// The reducer will run asynchronously in the future,
|
||||
/// and this method provides no way to listen for its completion status.
|
||||
/// /// Use [`say_hello:say_hello_then`] to run a callback after the reducer completes.
|
||||
fn say_hello(&self) -> __sdk::Result<()> {
|
||||
self.say_hello_then(|_, _| {})
|
||||
}
|
||||
|
||||
/// Request that the remote module invoke the reducer `say_hello` to run as soon as possible,
|
||||
/// registering `callback` to run when we are notified that the reducer completed.
|
||||
///
|
||||
/// This method returns immediately, and errors only if we are unable to send the request.
|
||||
/// The reducer will run asynchronously in the future,
|
||||
/// and its status can be observed with the `callback`.
|
||||
fn say_hello_then(
|
||||
&self,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()>;
|
||||
}
|
||||
|
||||
impl say_hello for super::RemoteReducers {
|
||||
fn say_hello_then(
|
||||
&self,
|
||||
|
||||
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
|
||||
+ Send
|
||||
+ 'static,
|
||||
) -> __sdk::Result<()> {
|
||||
self.imp
|
||||
.invoke_reducer_with_callback(SayHelloArgs {}, callback)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,31 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import { ApiClientError, clearStoredAccessToken, clearStoredAutoAuthCredentials } from './apiClient';
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
getStoredAutoAuthCredentials,
|
||||
setStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
authEntryWithStoredCredentials,
|
||||
bindWechatPhone,
|
||||
changePhoneNumber,
|
||||
consumeAuthCallbackResult,
|
||||
createAutoAuthCredentials,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getCaptchaChallengeFromError,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
revokeAuthSession,
|
||||
sendPhoneLoginCode,
|
||||
startWechatLogin,
|
||||
} from './authService';
|
||||
|
||||
const spacetimeMocks = vi.hoisted(() => ({
|
||||
ensureSpacetimeConnection: vi.fn(),
|
||||
disconnectSpacetimeConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../spacetime/client', () => ({
|
||||
ensureSpacetimeConnection: spacetimeMocks.ensureSpacetimeConnection,
|
||||
disconnectSpacetimeConnection: spacetimeMocks.disconnectSpacetimeConnection,
|
||||
getCurrentSpacetimeSessionId: vi.fn(() => 'usess_conn0001'),
|
||||
}));
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
@@ -51,169 +45,149 @@ function createMemoryStorage() {
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('./apiClient', async () => {
|
||||
const actual = await vi.importActual<typeof import('./apiClient')>('./apiClient');
|
||||
function createAuthStateRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
...actual,
|
||||
requestJson: requestJsonMock,
|
||||
userId: 'user_1',
|
||||
identity: {
|
||||
isEqual: vi.fn(() => true),
|
||||
toHexString: vi.fn(() => 'abc'),
|
||||
},
|
||||
displayName: '测试玩家',
|
||||
phoneNumberMasked: null,
|
||||
loginProvider: { tag: 'Guest' },
|
||||
accountStatus: { tag: 'Active' },
|
||||
smsVerificationRequired: false,
|
||||
smsVerified: false,
|
||||
jwtPresent: false,
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe('authService auto auth', () => {
|
||||
function createConnection(options: {
|
||||
authRows?: unknown[];
|
||||
configRows?: unknown[];
|
||||
sessionRows?: unknown[];
|
||||
riskBlockRows?: unknown[];
|
||||
sendCodeResult?: Record<string, unknown>;
|
||||
verifyCodeResult?: Record<string, unknown>;
|
||||
liftRiskResult?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
return {
|
||||
connectionId: {
|
||||
toHexString: vi.fn(() => 'conn0001'),
|
||||
},
|
||||
identity: {
|
||||
isEqual: vi.fn(() => true),
|
||||
toHexString: vi.fn(() => 'identity0001'),
|
||||
},
|
||||
procedures: {
|
||||
sendSmsVerificationCode: vi.fn(async () => ({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: null,
|
||||
...options.sendCodeResult,
|
||||
})),
|
||||
verifySmsCode: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: '短信验证通过',
|
||||
verified: true,
|
||||
...options.verifyCodeResult,
|
||||
})),
|
||||
liftMyRiskBlock: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: '风险保护已解除',
|
||||
...options.liftRiskResult,
|
||||
})),
|
||||
revokeUserSession: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: '会话已移除',
|
||||
})),
|
||||
logoutAllUserSessions: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: '全部会话已注销',
|
||||
})),
|
||||
},
|
||||
db: {
|
||||
my_auth_state: {
|
||||
iter: vi.fn(() => options.authRows ?? [createAuthStateRow()]),
|
||||
},
|
||||
client_app_config: {
|
||||
iter: vi.fn(() =>
|
||||
options.configRows ?? [
|
||||
{
|
||||
smsAuthEnabled: true,
|
||||
wechatEnabled: false,
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
my_auth_audit_logs: {
|
||||
iter: vi.fn(() => []),
|
||||
},
|
||||
my_user_sessions: {
|
||||
iter: vi.fn(() =>
|
||||
options.sessionRows ?? [
|
||||
{
|
||||
sessionId: 'sess_1',
|
||||
clientType: 'web',
|
||||
userAgent: 'vitest-browser',
|
||||
ip: '127.0.0.1',
|
||||
isCurrent: true,
|
||||
createdAtMs: BigInt(Date.now() - 1_000),
|
||||
lastSeenAtMs: BigInt(Date.now()),
|
||||
expiresAtMs: BigInt(Date.now() + 60_000),
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
my_auth_risk_blocks: {
|
||||
iter: vi.fn(() =>
|
||||
options.riskBlockRows ?? [
|
||||
{
|
||||
scopeType: { tag: 'Phone' },
|
||||
scopeKey: '+8613800138000',
|
||||
reason: 'sms_verify_failed_too_many_times',
|
||||
expiresAtMs: BigInt(Date.now() + 60_000),
|
||||
},
|
||||
],
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('authService with SpacetimeDB', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
location: {
|
||||
hash: '',
|
||||
pathname: '/',
|
||||
search: '',
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
});
|
||||
requestJsonMock.mockReset();
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'vitest-browser',
|
||||
});
|
||||
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockReset();
|
||||
spacetimeMocks.disconnectSpacetimeConnection.mockReset();
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
});
|
||||
|
||||
it('creates credentials that match current username/password constraints', () => {
|
||||
it('creates credentials that match current guest username/password constraints', () => {
|
||||
const credentials = createAutoAuthCredentials();
|
||||
|
||||
expect(credentials.username).toMatch(/^guest_[a-z0-9]{12}$/u);
|
||||
expect(credentials.password).toMatch(/^auto_[a-z0-9]{24}_[a-z0-9]{8}$/u);
|
||||
expect(credentials.password.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
|
||||
it('stores jwt and auto credentials after auth entry', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-token-value',
|
||||
user: {
|
||||
id: 'user_1',
|
||||
username: 'guest_abc123abc123',
|
||||
displayName: 'guest_abc123abc123',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await authEntryWithStoredCredentials({
|
||||
username: ' guest_abc123abc123 ',
|
||||
password: ' auto_secret_password ',
|
||||
});
|
||||
|
||||
expect(user.username).toBe('guest_abc123abc123');
|
||||
expect(getStoredAccessToken()).toBe('jwt-token-value');
|
||||
expect(getStoredAutoAuthCredentials()).toEqual({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
username: 'guest_abc123abc123',
|
||||
password: 'auto_secret_password',
|
||||
}),
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses stored auto credentials before generating a new account', async () => {
|
||||
window.localStorage.setItem('genarrative.auth.auto-username.v1', 'guest_saveduser01');
|
||||
window.localStorage.setItem('genarrative.auth.auto-password.v1', 'auto_saved_password');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-restored',
|
||||
user: {
|
||||
id: 'user_saved',
|
||||
username: 'guest_saveduser01',
|
||||
displayName: 'guest_saveduser01',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await ensureAutoAuthUser();
|
||||
|
||||
expect(result.user.username).toBe('guest_saveduser01');
|
||||
expect(result.credentials).toEqual({
|
||||
username: 'guest_saveduser01',
|
||||
password: 'auto_saved_password',
|
||||
});
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/entry',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent auto auth requests', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'jwt-auto',
|
||||
user: {
|
||||
id: 'user_auto',
|
||||
username: 'guest_auto',
|
||||
displayName: 'guest_auto',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([
|
||||
ensureAutoAuthUser(),
|
||||
ensureAutoAuthUser(),
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult).toEqual(secondResult);
|
||||
expect(getStoredAutoAuthCredentials()).toEqual(firstResult.credentials);
|
||||
});
|
||||
|
||||
it('sends phone login code through the new auth endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
scene: 'login',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('sends phone change code with the correct scene', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
cooldownSeconds: 60,
|
||||
expiresInSeconds: 300,
|
||||
providerRequestId: 'mock-request-id',
|
||||
});
|
||||
|
||||
await sendPhoneLoginCode('13900139000', 'change_phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/send-code',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
scene: 'change_phone',
|
||||
}),
|
||||
}),
|
||||
'发送验证码失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts captcha challenge details from api errors', () => {
|
||||
@@ -241,300 +215,136 @@ describe('authService auto auth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('stores jwt after phone login', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'phone-jwt-token',
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
it('reads current auth session from spacetime views', async () => {
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(
|
||||
createConnection({
|
||||
authRows: [
|
||||
createAuthStateRow({
|
||||
displayName: '游客阿青',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginProvider: { tag: 'Phone' },
|
||||
smsVerified: true,
|
||||
}),
|
||||
],
|
||||
configRows: [
|
||||
{
|
||||
smsAuthEnabled: true,
|
||||
wechatEnabled: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const session = await getCurrentAuthUser();
|
||||
|
||||
expect(session.user?.displayName).toBe('游客阿青');
|
||||
expect(session.user?.loginMethod).toBe('phone');
|
||||
expect(session.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||
});
|
||||
|
||||
it('sends phone login code through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
const result = await sendPhoneLoginCode(' 138 0013 8000 ');
|
||||
|
||||
expect(result.cooldownSeconds).toBe(60);
|
||||
expect(connection.procedures.sendSmsVerificationCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
phoneNumber: '13800138000',
|
||||
scene: { tag: 'Login' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('verifies phone code and resolves the updated auth user from spacetime', async () => {
|
||||
const connection = createConnection({
|
||||
authRows: [
|
||||
createAuthStateRow({
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginProvider: { tag: 'Phone' },
|
||||
smsVerified: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
const user = await loginWithPhoneCode('13800138000', '123456');
|
||||
|
||||
expect(user.username).toBe('138****8000');
|
||||
expect(getStoredAccessToken()).toBe('phone-jwt-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/login',
|
||||
expect(connection.procedures.verifySmsCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
phoneNumber: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
'登录失败',
|
||||
);
|
||||
expect(user.loginMethod).toBe('phone');
|
||||
expect(user.bindingStatus).toBe('active');
|
||||
});
|
||||
|
||||
it('binds wechat phone and stores jwt after activation', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
token: 'wechat-bind-token',
|
||||
user: {
|
||||
id: 'user_wechat',
|
||||
username: '138****8000',
|
||||
displayName: '138****8000',
|
||||
phoneNumberMasked: '138****8000',
|
||||
loginMethod: 'wechat',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: true,
|
||||
},
|
||||
it('disconnects local spacetime auth session on logout', async () => {
|
||||
await logoutAuthUser();
|
||||
|
||||
expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalledWith({
|
||||
clearToken: true,
|
||||
});
|
||||
|
||||
const user = await bindWechatPhone('13800138000', '123456');
|
||||
|
||||
expect(user.wechatBound).toBe(true);
|
||||
expect(getStoredAccessToken()).toBe('wechat-bind-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13800138000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'绑定手机号失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('changes phone number without replacing the stored access token', async () => {
|
||||
setStoredAccessToken('active-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
user: {
|
||||
id: 'user_phone',
|
||||
username: '139****9000',
|
||||
displayName: '139****9000',
|
||||
phoneNumberMasked: '139****9000',
|
||||
loginMethod: 'phone',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await changePhoneNumber('13900139000', '123456');
|
||||
|
||||
expect(user.phoneNumberMasked).toBe('139****9000');
|
||||
expect(getStoredAccessToken()).toBe('active-token');
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/phone/change',
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
phone: '13900139000',
|
||||
code: '123456',
|
||||
}),
|
||||
}),
|
||||
'更换手机号失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('starts wechat login by navigating to backend authorization url', async () => {
|
||||
const assignMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
hash: '',
|
||||
search: '',
|
||||
assign: assignMock,
|
||||
},
|
||||
history: {
|
||||
replaceState: vi.fn(),
|
||||
},
|
||||
});
|
||||
requestJsonMock.mockResolvedValue({
|
||||
authorizationUrl: '/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
});
|
||||
|
||||
await startWechatLogin();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/start?redirectPath=%2F',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'微信登录暂不可用',
|
||||
);
|
||||
expect(assignMock).toHaveBeenCalledWith(
|
||||
'/api/auth/wechat/callback?mock_code=wx-user&state=state123',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads available login methods for the unauthenticated login screen', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
});
|
||||
|
||||
const result = await getAuthLoginOptions();
|
||||
|
||||
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/login-options',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录方式失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes auth callback hash and stores token', () => {
|
||||
const replaceStateMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
localStorage: createMemoryStorage(),
|
||||
dispatchEvent: vi.fn(),
|
||||
location: {
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '#auth_provider=wechat&auth_token=wx-token&auth_binding_status=pending_bind_phone',
|
||||
},
|
||||
history: {
|
||||
replaceState: replaceStateMock,
|
||||
},
|
||||
});
|
||||
|
||||
const result = consumeAuthCallbackResult();
|
||||
|
||||
expect(result).toEqual({
|
||||
provider: 'wechat',
|
||||
bindingStatus: 'pending_bind_phone',
|
||||
error: null,
|
||||
});
|
||||
expect(getStoredAccessToken()).toBe('wx-token');
|
||||
expect(replaceStateMock).toHaveBeenCalledWith(null, '', '/');
|
||||
});
|
||||
|
||||
it('loads auth sessions from account center endpoint', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
sessions: [
|
||||
{
|
||||
sessionId: 'usess_1',
|
||||
clientType: 'browser',
|
||||
clientLabel: '网页端浏览器',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
ipMasked: '127.0.*.*',
|
||||
isCurrent: true,
|
||||
createdAt: '2026-04-09T10:00:00.000Z',
|
||||
lastSeenAt: '2026-04-09T10:30:00.000Z',
|
||||
expiresAt: '2026-05-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
it('reads auth sessions from spacetime views', async () => {
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(createConnection());
|
||||
|
||||
const sessions = await getAuthSessions();
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录设备失败',
|
||||
);
|
||||
expect(sessions[0]?.clientType).toBe('web');
|
||||
expect(sessions[0]?.isCurrent).toBe(true);
|
||||
});
|
||||
|
||||
it('loads recent auth audit logs', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
logs: [
|
||||
{
|
||||
id: 'audit_1',
|
||||
eventType: 'phone_login',
|
||||
title: '手机号登录',
|
||||
detail: '使用手机号 138****8000 完成登录',
|
||||
ipMasked: '127.0.*.*',
|
||||
userAgent: 'Mozilla/5.0',
|
||||
createdAt: '2026-04-09T10:30:00.000Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const logs = await getAuthAuditLogs();
|
||||
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/audit-logs',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('loads current risk blocks', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
blocks: [
|
||||
{
|
||||
scopeType: 'phone',
|
||||
title: '手机号保护中',
|
||||
detail: '该手机号因异常尝试已被临时保护,请约 30 分钟后再试',
|
||||
expiresAt: '2026-04-09T11:00:00.000Z',
|
||||
remainingSeconds: 1800,
|
||||
},
|
||||
],
|
||||
});
|
||||
it('reads risk blocks from spacetime views', async () => {
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(createConnection());
|
||||
|
||||
const blocks = await getAuthRiskBlocks();
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取安全状态失败',
|
||||
);
|
||||
expect(blocks[0]?.scopeType).toBe('phone');
|
||||
expect(blocks[0]?.remainingSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('lifts a risk block by scope type', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
it('lifts a risk block through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await liftAuthRiskBlock('phone');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/risk-blocks/phone/lift',
|
||||
expect(connection.procedures.liftMyRiskBlock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
scopeType: { tag: 'Phone' },
|
||||
}),
|
||||
'解除保护失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes a remote auth session by id', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
it('revokes a user session through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await revokeAuthSession('usess_123');
|
||||
await revokeAuthSession('sess_1');
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/sessions/usess_123/revoke',
|
||||
expect(connection.procedures.revokeUserSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
sessionId: 'sess_1',
|
||||
}),
|
||||
'移除登录设备失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears local auth state after logout all sessions', async () => {
|
||||
setStoredAccessToken('stale-token');
|
||||
requestJsonMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
it('logs out all user sessions through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await logoutAllAuthSessions();
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/logout-all',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
}),
|
||||
'退出全部设备失败',
|
||||
);
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
expect(connection.procedures.logoutAllUserSessions).toHaveBeenCalled();
|
||||
expect(spacetimeMocks.disconnectSpacetimeConnection).toHaveBeenCalledWith({
|
||||
clearToken: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,35 +1,42 @@
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthAuditLogsResponse,
|
||||
AuthCaptchaChallenge,
|
||||
AuthEntryResponse,
|
||||
AuthLoginMethod,
|
||||
AuthLoginOptionsResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLogoutAllResponse,
|
||||
AuthMeResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
AuthPhoneLoginResponse,
|
||||
AuthPhoneSendCodeResponse,
|
||||
AuthRevokeSessionResponse,
|
||||
AuthRiskBlocksResponse,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionsResponse,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
AuthWechatBindPhoneResponse,
|
||||
AuthWechatStartResponse,
|
||||
LogoutResponse,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import {
|
||||
ApiClientError,
|
||||
clearStoredAccessToken,
|
||||
clearStoredAutoAuthCredentials,
|
||||
getStoredAutoAuthCredentials,
|
||||
requestJson,
|
||||
setStoredAccessToken,
|
||||
setStoredAutoAuthCredentials,
|
||||
getStoredAccessToken,
|
||||
} from './apiClient';
|
||||
import {
|
||||
disconnectSpacetimeConnection,
|
||||
ensureSpacetimeConnection,
|
||||
getCurrentSpacetimeSessionId,
|
||||
} from '../spacetime/client';
|
||||
import {
|
||||
mapAuditLogEntry,
|
||||
mapAuthRiskBlock,
|
||||
mapAuthSession,
|
||||
mapAuthUser,
|
||||
mapAvailableLoginMethods,
|
||||
} from '../spacetime/mappers';
|
||||
import type {
|
||||
ClientAppConfigView,
|
||||
RequestMeta,
|
||||
SmsAuthScene,
|
||||
} from '../spacetime/generated/types';
|
||||
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
||||
@@ -59,6 +66,106 @@ let pendingAutoAuthUser: Promise<{
|
||||
credentials: AutoAuthCredentials;
|
||||
}> | null = null;
|
||||
|
||||
function buildRandomSegment(length: number) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const cryptoApi = globalThis.crypto;
|
||||
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
return Array.from(
|
||||
{ length },
|
||||
() => alphabet[Math.floor(Math.random() * alphabet.length)],
|
||||
).join('');
|
||||
}
|
||||
|
||||
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
|
||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function getSingleRow<Row>(rows: Iterable<Row>) {
|
||||
for (const row of rows) {
|
||||
return row;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildRequestMeta(): RequestMeta {
|
||||
return {
|
||||
clientType: 'web',
|
||||
userAgent:
|
||||
typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null,
|
||||
ip: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSmsScene(
|
||||
scene: 'login' | 'bind_phone' | 'change_phone',
|
||||
): SmsAuthScene {
|
||||
switch (scene) {
|
||||
case 'bind_phone':
|
||||
return { tag: 'BindPhone' };
|
||||
case 'change_phone':
|
||||
return { tag: 'ChangePhone' };
|
||||
default:
|
||||
return { tag: 'Login' };
|
||||
}
|
||||
}
|
||||
|
||||
async function readCurrentSessionFromConnection() {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const authRow = getSingleRow(connection.db.my_auth_state.iter());
|
||||
const configRow = getSingleRow(
|
||||
connection.db.client_app_config.iter(),
|
||||
) as ClientAppConfigView | null;
|
||||
|
||||
return {
|
||||
user: authRow ? mapAuthUser(authRow) : null,
|
||||
availableLoginMethods: mapAvailableLoginMethods(configRow),
|
||||
} satisfies AuthSessionSnapshot;
|
||||
}
|
||||
|
||||
async function readCurrentSessionWithRetry() {
|
||||
try {
|
||||
return await readCurrentSessionFromConnection();
|
||||
} catch (error) {
|
||||
if (!getStoredAccessToken()) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
disconnectSpacetimeConnection();
|
||||
clearStoredAccessToken({ emit: false });
|
||||
return readCurrentSessionFromConnection();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAuthUser(
|
||||
predicate: (user: AuthUser | null) => boolean,
|
||||
timeoutMs = 1200,
|
||||
) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const session = await readCurrentSessionFromConnection();
|
||||
if (predicate(session.user)) {
|
||||
return session.user;
|
||||
}
|
||||
await sleep(40);
|
||||
}
|
||||
|
||||
throw new Error('账号状态同步超时,请稍后重试');
|
||||
}
|
||||
|
||||
function toProcedureError(message: string) {
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
export function normalizePhoneInput(phoneInput: string) {
|
||||
return phoneInput.replace(/[^\d+]/gu, '').trim();
|
||||
}
|
||||
@@ -73,7 +180,8 @@ export function getCaptchaChallengeFromError(
|
||||
typeof error.details === 'object' &&
|
||||
'captchaChallenge' in error.details
|
||||
) {
|
||||
const challenge = (error.details as { captchaChallenge?: unknown }).captchaChallenge;
|
||||
const challenge = (error.details as { captchaChallenge?: unknown })
|
||||
.captchaChallenge;
|
||||
if (
|
||||
challenge &&
|
||||
typeof challenge === 'object' &&
|
||||
@@ -89,29 +197,6 @@ export function getCaptchaChallengeFromError(
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeCredentials(credentials: AutoAuthCredentials): AutoAuthCredentials {
|
||||
return {
|
||||
username: credentials.username.trim(),
|
||||
password: credentials.password.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRandomSegment(length: number) {
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
const cryptoApi = globalThis.crypto;
|
||||
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
return Array.from(
|
||||
{length},
|
||||
() => alphabet[Math.floor(Math.random() * alphabet.length)],
|
||||
).join('');
|
||||
}
|
||||
|
||||
const bytes = cryptoApi.getRandomValues(new Uint8Array(length));
|
||||
|
||||
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join('');
|
||||
}
|
||||
|
||||
export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
return {
|
||||
username: `guest_${buildRandomSegment(12)}`,
|
||||
@@ -120,6 +205,7 @@ export function createAutoAuthCredentials(): AutoAuthCredentials {
|
||||
}
|
||||
|
||||
export function clearAuthSession() {
|
||||
disconnectSpacetimeConnection();
|
||||
clearStoredAccessToken();
|
||||
clearStoredAutoAuthCredentials();
|
||||
}
|
||||
@@ -127,141 +213,94 @@ export function clearAuthSession() {
|
||||
export async function sendPhoneLoginCode(
|
||||
phone: string,
|
||||
scene: 'login' | 'bind_phone' | 'change_phone' = 'login',
|
||||
captcha?: {
|
||||
_captcha?: {
|
||||
challengeId?: string;
|
||||
answer?: string;
|
||||
},
|
||||
) {
|
||||
const response = await requestJson<AuthPhoneSendCodeResponse>(
|
||||
'/api/auth/phone/send-code',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
scene,
|
||||
captchaChallengeId: captcha?.challengeId?.trim() || undefined,
|
||||
captchaAnswer: captcha?.answer?.trim() || undefined,
|
||||
}),
|
||||
},
|
||||
'发送验证码失败',
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.sendSmsVerificationCode({
|
||||
meta: buildRequestMeta(),
|
||||
phoneNumber: normalizePhoneInput(phone),
|
||||
scene: mapSmsScene(scene),
|
||||
});
|
||||
|
||||
return response;
|
||||
if (!result.ok) {
|
||||
throw toProcedureError(result.message || '发送验证码失败');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
cooldownSeconds: Number(result.cooldownSeconds),
|
||||
expiresInSeconds: Number(result.expiresInSeconds),
|
||||
providerRequestId: result.providerRequestId ?? null,
|
||||
} satisfies AuthPhoneSendCodeResponse;
|
||||
}
|
||||
|
||||
export async function loginWithPhoneCode(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneLoginResponse>(
|
||||
'/api/auth/phone/login',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'登录失败',
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.verifySmsCode({
|
||||
meta: buildRequestMeta(),
|
||||
phoneNumber: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
});
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
if (!result.ok) {
|
||||
throw toProcedureError(result.message || '登录失败');
|
||||
}
|
||||
|
||||
const nextUser = await waitForAuthUser(
|
||||
(user) => user?.bindingStatus === 'active',
|
||||
);
|
||||
if (!nextUser) {
|
||||
throw new Error('登录状态同步失败');
|
||||
}
|
||||
|
||||
return nextUser satisfies AuthPhoneLoginResponse['user'];
|
||||
}
|
||||
|
||||
export async function bindWechatPhone(phone: string, code: string) {
|
||||
const response = await requestJson<AuthWechatBindPhoneResponse>(
|
||||
'/api/auth/wechat/bind-phone',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'绑定手机号失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
const user = await loginWithPhoneCode(phone, code);
|
||||
return user satisfies AuthWechatBindPhoneResponse['user'];
|
||||
}
|
||||
|
||||
export async function changePhoneNumber(phone: string, code: string) {
|
||||
const response = await requestJson<AuthPhoneChangeResponse>(
|
||||
'/api/auth/phone/change',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone: normalizePhoneInput(phone),
|
||||
code: code.trim(),
|
||||
}),
|
||||
},
|
||||
'更换手机号失败',
|
||||
);
|
||||
|
||||
return response.user;
|
||||
const user = await loginWithPhoneCode(phone, code);
|
||||
return user satisfies AuthPhoneChangeResponse['user'];
|
||||
}
|
||||
|
||||
export async function startWechatLogin() {
|
||||
const response = await requestJson<AuthWechatStartResponse>(
|
||||
`/api/auth/wechat/start?redirectPath=${encodeURIComponent(window.location.pathname)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'微信登录暂不可用',
|
||||
);
|
||||
|
||||
window.location.assign(response.authorizationUrl);
|
||||
throw new Error('当前版本暂未接入微信登录流程');
|
||||
}
|
||||
|
||||
export async function getAuthLoginOptions() {
|
||||
return requestJson<AuthLoginOptionsResponse>(
|
||||
'/api/auth/login-options',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取登录方式失败',
|
||||
);
|
||||
const session = await readCurrentSessionWithRetry();
|
||||
return {
|
||||
availableLoginMethods: session.availableLoginMethods,
|
||||
} satisfies AuthLoginOptionsResponse;
|
||||
}
|
||||
|
||||
export async function authEntry(username: string, password: string) {
|
||||
const credentials = normalizeCredentials({ username, password });
|
||||
const response = await requestJson<AuthEntryResponse>(
|
||||
'/api/auth/entry',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
},
|
||||
'登录失败',
|
||||
);
|
||||
|
||||
setStoredAccessToken(response.token);
|
||||
return response.user;
|
||||
export async function authEntry(_username: string, _password: string) {
|
||||
const session = await readCurrentSessionWithRetry();
|
||||
if (!session.user) {
|
||||
throw new Error('创建游客账号失败');
|
||||
}
|
||||
return session.user;
|
||||
}
|
||||
|
||||
export async function authEntryWithStoredCredentials(
|
||||
credentials: AutoAuthCredentials,
|
||||
) {
|
||||
const normalizedCredentials = normalizeCredentials(credentials);
|
||||
const user = await authEntry(
|
||||
normalizedCredentials.username,
|
||||
normalizedCredentials.password,
|
||||
);
|
||||
setStoredAutoAuthCredentials(normalizedCredentials);
|
||||
const user = await authEntry(credentials.username, credentials.password);
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function ensureAutoAuthUser() {
|
||||
pendingAutoAuthUser ??= (async () => {
|
||||
const credentials =
|
||||
getStoredAutoAuthCredentials() ?? createAutoAuthCredentials();
|
||||
const user = await authEntryWithStoredCredentials(credentials);
|
||||
|
||||
const user = await authEntry('guest', 'guest');
|
||||
return {
|
||||
user,
|
||||
credentials,
|
||||
credentials: createAutoAuthCredentials(),
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -285,19 +324,14 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
const authToken = params.get('auth_token');
|
||||
const authError = params.get('auth_error');
|
||||
const providerValue = params.get('auth_provider');
|
||||
const bindingStatus = params.get('auth_binding_status');
|
||||
|
||||
if (!authToken && !authError) {
|
||||
if (!authError && !providerValue && !bindingStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
setStoredAccessToken(authToken);
|
||||
}
|
||||
|
||||
if (typeof window.history?.replaceState === 'function') {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
@@ -314,100 +348,77 @@ export function consumeAuthCallbackResult(): ConsumedAuthCallback | null {
|
||||
}
|
||||
|
||||
export async function getCurrentAuthUser(): Promise<AuthSessionSnapshot> {
|
||||
const response = await requestJson<AuthMeResponse>(
|
||||
'/api/auth/me',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取当前用户失败',
|
||||
);
|
||||
|
||||
return {
|
||||
user: response.user,
|
||||
availableLoginMethods: response.availableLoginMethods,
|
||||
};
|
||||
return readCurrentSessionWithRetry();
|
||||
}
|
||||
|
||||
export async function getAuthSessions() {
|
||||
const response = await requestJson<AuthSessionsResponse>(
|
||||
'/api/auth/sessions',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取登录设备失败',
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const currentSessionId = getCurrentSpacetimeSessionId(connection);
|
||||
return Array.from(connection.db.my_user_sessions.iter()).map((row) =>
|
||||
mapAuthSession(row, { currentSessionId }),
|
||||
);
|
||||
|
||||
return response.sessions;
|
||||
}
|
||||
|
||||
export async function revokeAuthSession(sessionId: string) {
|
||||
await requestJson<AuthRevokeSessionResponse>(
|
||||
`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'移除登录设备失败',
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.revokeUserSession({
|
||||
meta: buildRequestMeta(),
|
||||
sessionId,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '移除登录设备失败');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAuthAuditLogs() {
|
||||
const response = await requestJson<AuthAuditLogsResponse>(
|
||||
'/api/auth/audit-logs',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取账号操作记录失败',
|
||||
);
|
||||
|
||||
return response.logs;
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_auth_audit_logs.iter()).map(mapAuditLogEntry);
|
||||
}
|
||||
|
||||
export async function getAuthRiskBlocks() {
|
||||
const response = await requestJson<AuthRiskBlocksResponse>(
|
||||
'/api/auth/risk-blocks',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取安全状态失败',
|
||||
);
|
||||
|
||||
return response.blocks;
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_auth_risk_blocks.iter())
|
||||
.map(mapAuthRiskBlock)
|
||||
.filter((block) => block.remainingSeconds > 0);
|
||||
}
|
||||
|
||||
export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') {
|
||||
await requestJson<AuthLiftRiskBlockResponse>(
|
||||
`/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`,
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'解除保护失败',
|
||||
);
|
||||
export async function liftAuthRiskBlock(_scopeType: 'phone' | 'ip') {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.liftMyRiskBlock({
|
||||
meta: buildRequestMeta(),
|
||||
scopeType:
|
||||
_scopeType === 'phone' ? { tag: 'Phone' } : { tag: 'Ip' },
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '解除保护失败');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
} satisfies AuthLiftRiskBlockResponse;
|
||||
}
|
||||
|
||||
export async function logoutAuthUser() {
|
||||
try {
|
||||
await requestJson<LogoutResponse>(
|
||||
'/api/auth/logout',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'退出登录失败',
|
||||
);
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
}
|
||||
disconnectSpacetimeConnection({ clearToken: true });
|
||||
clearStoredAutoAuthCredentials();
|
||||
return {
|
||||
ok: true,
|
||||
} satisfies LogoutResponse;
|
||||
}
|
||||
|
||||
export async function logoutAllAuthSessions() {
|
||||
try {
|
||||
await requestJson<AuthLogoutAllResponse>(
|
||||
'/api/auth/logout-all',
|
||||
{
|
||||
method: 'POST',
|
||||
},
|
||||
'退出全部设备失败',
|
||||
);
|
||||
} finally {
|
||||
clearAuthSession();
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.logoutAllUserSessions({
|
||||
meta: buildRequestMeta(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '退出全部设备失败');
|
||||
}
|
||||
disconnectSpacetimeConnection({ clearToken: true });
|
||||
clearStoredAutoAuthCredentials();
|
||||
return {
|
||||
ok: true,
|
||||
} satisfies AuthLogoutAllResponse;
|
||||
}
|
||||
|
||||
@@ -1,40 +1,86 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { requestJsonMock } = vi.hoisted(() => ({
|
||||
requestJsonMock: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearProfileBrowseHistory,
|
||||
listProfileBrowseHistory,
|
||||
putSettings,
|
||||
syncProfileBrowseHistory,
|
||||
upsertProfileBrowseHistory,
|
||||
} from './storageService';
|
||||
|
||||
vi.mock('./apiClient', () => ({
|
||||
requestJson: requestJsonMock,
|
||||
const spacetimeMocks = vi.hoisted(() => ({
|
||||
ensureSpacetimeConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('storageService browse history routes', () => {
|
||||
vi.mock('../spacetime/client', () => ({
|
||||
ensureSpacetimeConnection: spacetimeMocks.ensureSpacetimeConnection,
|
||||
}));
|
||||
|
||||
function createConnection() {
|
||||
return {
|
||||
procedures: {
|
||||
upsertPlatformBrowseHistory: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
})),
|
||||
clearPlatformBrowseHistory: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
})),
|
||||
putRuntimeSettings: vi.fn(async () => ({
|
||||
ok: true,
|
||||
message: 'ok',
|
||||
})),
|
||||
},
|
||||
db: {
|
||||
my_browse_history: {
|
||||
iter: vi.fn(() => [
|
||||
{
|
||||
ownerUserId: 'author-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
subtitle: '测试副标题',
|
||||
summaryText: '测试摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: { tag: 'Mythic' },
|
||||
authorDisplayName: '测试作者',
|
||||
visitedAtMs: BigInt(Date.now()),
|
||||
},
|
||||
]),
|
||||
},
|
||||
my_runtime_settings: {
|
||||
iter: vi.fn(() => [
|
||||
{
|
||||
musicVolume: 0.66,
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storageService with SpacetimeDB', () => {
|
||||
beforeEach(() => {
|
||||
requestJsonMock.mockReset();
|
||||
requestJsonMock.mockResolvedValue({ entries: [] });
|
||||
vi.stubGlobal('navigator', {
|
||||
userAgent: 'vitest-browser',
|
||||
});
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockReset();
|
||||
});
|
||||
|
||||
it('reads browse history from the runtime profile route', async () => {
|
||||
await listProfileBrowseHistory();
|
||||
it('reads browse history from spacetime views', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'GET' }),
|
||||
'读取浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 1 }),
|
||||
}),
|
||||
);
|
||||
const entries = await listProfileBrowseHistory();
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.worldName).toBe('测试世界');
|
||||
});
|
||||
|
||||
it('writes browse history through the runtime profile route', async () => {
|
||||
it('writes single browse history entry through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await upsertProfileBrowseHistory({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
@@ -46,23 +92,23 @@ describe('storageService browse history routes', () => {
|
||||
authorDisplayName: '测试作者',
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect(connection.procedures.upsertPlatformBrowseHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
'写入浏览历史失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
entries: [
|
||||
expect.objectContaining({
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'profile-1',
|
||||
worldName: '测试世界',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs browse history through the runtime profile route', async () => {
|
||||
it('syncs browse history batch through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
await syncProfileBrowseHistory([
|
||||
{
|
||||
ownerUserId: 'user-1',
|
||||
@@ -76,30 +122,34 @@ describe('storageService browse history routes', () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect(connection.procedures.upsertPlatformBrowseHistory).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
entries: [expect.any(Object)],
|
||||
}),
|
||||
'同步浏览历史失败',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('clears browse history through the runtime profile route', async () => {
|
||||
await clearProfileBrowseHistory();
|
||||
it('clears browse history through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/runtime/profile/browse-history',
|
||||
expect.objectContaining({ method: 'DELETE' }),
|
||||
'清空浏览历史失败',
|
||||
const entries = await clearProfileBrowseHistory();
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
expect(connection.procedures.clearPlatformBrowseHistory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes runtime settings through spacetime procedure', async () => {
|
||||
const connection = createConnection();
|
||||
spacetimeMocks.ensureSpacetimeConnection.mockResolvedValue(connection);
|
||||
|
||||
const settings = await putSettings({ musicVolume: 0.5 });
|
||||
|
||||
expect(connection.procedures.putRuntimeSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({
|
||||
maxRetries: 1,
|
||||
retryUnsafeMethods: true,
|
||||
}),
|
||||
musicVolume: 0.5,
|
||||
}),
|
||||
);
|
||||
expect(settings.musicVolume).toBe(0.66);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,7 @@ import type {
|
||||
CustomWorldLibraryEntry,
|
||||
CustomWorldLibraryMutationResponse,
|
||||
CustomWorldLibraryResponse,
|
||||
PlatformBrowseHistoryBatchSyncRequest,
|
||||
PlatformBrowseHistoryEntry,
|
||||
PlatformBrowseHistoryResponse,
|
||||
PlatformBrowseHistoryWriteEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
@@ -19,339 +17,436 @@ import type { SavedGameSnapshotInput } from '../persistence/gameSaveStorage';
|
||||
import { rehydrateSavedSnapshot } from '../persistence/runtimeSnapshot';
|
||||
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import { type ApiRetryOptions, requestJson } from './apiClient';
|
||||
import { ensureSpacetimeConnection } from '../spacetime/client';
|
||||
import {
|
||||
mapBrowseHistoryEntry,
|
||||
mapCustomWorldLibraryEntry,
|
||||
mapCustomWorldSession,
|
||||
mapGalleryCard,
|
||||
mapPlayedWorldEntry,
|
||||
mapProfileDashboard,
|
||||
mapPublishedProfile,
|
||||
mapRuntimeSettings,
|
||||
mapSnapshotRow,
|
||||
mapWalletLedgerEntry,
|
||||
} from '../spacetime/mappers';
|
||||
|
||||
const RUNTIME_API_BASE = '/api/runtime';
|
||||
const RUNTIME_READ_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 180,
|
||||
maxDelayMs: 480,
|
||||
};
|
||||
const RUNTIME_WRITE_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 240,
|
||||
maxDelayMs: 640,
|
||||
retryUnsafeMethods: true,
|
||||
};
|
||||
const DEFAULT_MUSIC_VOLUME = 0.42;
|
||||
|
||||
export type RuntimeRequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
retry?: ApiRetryOptions;
|
||||
};
|
||||
|
||||
function requestRuntimeJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const method = (init.method ?? 'GET').toUpperCase();
|
||||
const retry =
|
||||
options.retry ??
|
||||
(method === 'GET' ? RUNTIME_READ_RETRY : RUNTIME_WRITE_RETRY);
|
||||
function toBigIntMs(isoValue?: string) {
|
||||
if (!isoValue) {
|
||||
return 0n;
|
||||
}
|
||||
|
||||
return requestJson<T>(
|
||||
`${RUNTIME_API_BASE}${path}`,
|
||||
{
|
||||
...init,
|
||||
signal: options.signal,
|
||||
},
|
||||
fallbackMessage,
|
||||
{ retry },
|
||||
);
|
||||
const ms = Date.parse(isoValue);
|
||||
return Number.isFinite(ms) ? BigInt(ms) : 0n;
|
||||
}
|
||||
|
||||
export async function getSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
const snapshot = await requestRuntimeJson<HydratedSavedGameSnapshot | null>(
|
||||
'/save/snapshot',
|
||||
{ method: 'GET' },
|
||||
'读取存档失败',
|
||||
options,
|
||||
);
|
||||
function buildRequestMeta() {
|
||||
return {
|
||||
clientType: 'web',
|
||||
userAgent:
|
||||
typeof navigator !== 'undefined' ? navigator.userAgent.trim() || null : null,
|
||||
ip: null,
|
||||
};
|
||||
}
|
||||
|
||||
return snapshot ? rehydrateSavedSnapshot(snapshot) : null;
|
||||
function mapThemeModeInput(
|
||||
themeMode: PlatformBrowseHistoryWriteEntry['themeMode'],
|
||||
) {
|
||||
switch (themeMode) {
|
||||
case 'martial':
|
||||
return { tag: 'Martial' } as const;
|
||||
case 'arcane':
|
||||
return { tag: 'Arcane' } as const;
|
||||
case 'machina':
|
||||
return { tag: 'Machina' } as const;
|
||||
case 'tide':
|
||||
return { tag: 'Tide' } as const;
|
||||
case 'rift':
|
||||
return { tag: 'Rift' } as const;
|
||||
default:
|
||||
return { tag: 'Mythic' } as const;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForSnapshot(timeoutMs = 1200) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const snapshot = Array.from(connection.db.my_snapshot.iter())[0];
|
||||
if (snapshot) {
|
||||
return snapshot;
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
}
|
||||
|
||||
throw new Error('远端存档同步超时');
|
||||
}
|
||||
|
||||
async function waitForRuntimeSettings(timeoutMs = 1200) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const row = Array.from(connection.db.my_runtime_settings.iter())[0];
|
||||
if (row) {
|
||||
return row;
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 40));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getSaveSnapshot(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const row = Array.from(connection.db.my_snapshot.iter())[0];
|
||||
return row ? rehydrateSavedSnapshot(mapSnapshotRow(row) as HydratedSavedGameSnapshot) : null;
|
||||
}
|
||||
|
||||
export async function putSaveSnapshot(
|
||||
snapshot: SavedGameSnapshotInput,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const savedSnapshot = await requestRuntimeJson<HydratedSavedGameSnapshot>(
|
||||
'/save/snapshot',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(snapshot),
|
||||
},
|
||||
'保存存档失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.saveSnapshot({
|
||||
meta: buildRequestMeta(),
|
||||
savedAtMs: toBigIntMs(snapshot.savedAt),
|
||||
gameStateJson: JSON.stringify(snapshot.gameState),
|
||||
bottomTab: snapshot.bottomTab,
|
||||
currentStoryJson:
|
||||
snapshot.currentStory === null || snapshot.currentStory === undefined
|
||||
? null
|
||||
: JSON.stringify(snapshot.currentStory),
|
||||
});
|
||||
|
||||
return rehydrateSavedSnapshot(savedSnapshot);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '保存存档失败');
|
||||
}
|
||||
|
||||
const row = await waitForSnapshot();
|
||||
return rehydrateSavedSnapshot(mapSnapshotRow(row) as HydratedSavedGameSnapshot);
|
||||
}
|
||||
|
||||
export async function deleteSaveSnapshot(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<BasicOkResult>(
|
||||
'/save/snapshot',
|
||||
{ method: 'DELETE' },
|
||||
'删除存档失败',
|
||||
options,
|
||||
);
|
||||
export async function deleteSaveSnapshot(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.deleteSnapshot({
|
||||
meta: buildRequestMeta(),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '删除存档失败');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
} satisfies BasicOkResult;
|
||||
}
|
||||
|
||||
export async function getSettings(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{ method: 'GET' },
|
||||
'读取设置失败',
|
||||
options,
|
||||
);
|
||||
export async function getSettings(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const row = Array.from(connection.db.my_runtime_settings.iter())[0] ?? null;
|
||||
return mapRuntimeSettings(row);
|
||||
}
|
||||
|
||||
export async function getProfileDashboard(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfileDashboardSummary>(
|
||||
'/profile/dashboard',
|
||||
{ method: 'GET' },
|
||||
'读取个人看板失败',
|
||||
options,
|
||||
);
|
||||
export async function getProfileDashboard(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const row = Array.from(connection.db.my_profile_dashboard.iter())[0] ?? null;
|
||||
return mapProfileDashboard(row);
|
||||
}
|
||||
|
||||
export async function getProfileWalletLedger(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<ProfileWalletLedgerResponse>(
|
||||
'/profile/wallet-ledger',
|
||||
{ method: 'GET' },
|
||||
'读取资产流水失败',
|
||||
options,
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const entries = Array.from(connection.db.my_profile_wallet_ledger.iter()).map(
|
||||
mapWalletLedgerEntry,
|
||||
);
|
||||
return {
|
||||
entries,
|
||||
} satisfies ProfileWalletLedgerResponse;
|
||||
}
|
||||
|
||||
export async function getProfilePlayStats(options: RuntimeRequestOptions = {}) {
|
||||
return requestRuntimeJson<ProfilePlayStatsResponse>(
|
||||
'/profile/play-stats',
|
||||
{ method: 'GET' },
|
||||
'读取游玩统计失败',
|
||||
options,
|
||||
export async function getProfilePlayStats(_options: RuntimeRequestOptions = {}) {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const dashboard = mapProfileDashboard(
|
||||
Array.from(connection.db.my_profile_dashboard.iter())[0] ?? null,
|
||||
);
|
||||
return {
|
||||
totalPlayTimeMs: dashboard.totalPlayTimeMs,
|
||||
playedWorks: Array.from(connection.db.my_profile_played_worlds.iter()).map(
|
||||
mapPlayedWorldEntry,
|
||||
),
|
||||
updatedAt: dashboard.updatedAt,
|
||||
} satisfies ProfilePlayStatsResponse;
|
||||
}
|
||||
|
||||
export async function putSettings(
|
||||
settings: RuntimeSettings,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
return requestRuntimeJson<RuntimeSettings>(
|
||||
'/settings',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
},
|
||||
'保存设置失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.putRuntimeSettings({
|
||||
meta: buildRequestMeta(),
|
||||
musicVolume: settings.musicVolume,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '保存设置失败');
|
||||
}
|
||||
|
||||
const row = await waitForRuntimeSettings();
|
||||
return row ? mapRuntimeSettings(row) : { musicVolume: DEFAULT_MUSIC_VOLUME };
|
||||
}
|
||||
|
||||
export async function listCustomWorldLibrary(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
'/custom-world-library',
|
||||
{ method: 'GET' },
|
||||
'读取自定义世界库失败',
|
||||
options,
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_custom_world_profiles.iter()).map(
|
||||
mapCustomWorldLibraryEntry,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function listCustomWorldWorks(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<ListCustomWorldWorksResponse>(
|
||||
'/custom-world/works',
|
||||
{ method: 'GET' },
|
||||
'读取创作作品列表失败',
|
||||
options,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.items) ? response.items : [];
|
||||
return {
|
||||
items: [],
|
||||
} satisfies ListCustomWorldWorksResponse;
|
||||
}
|
||||
|
||||
export async function upsertCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profile.id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
profile,
|
||||
}),
|
||||
},
|
||||
'保存自定义世界失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.upsertCustomWorldProfile({
|
||||
meta: buildRequestMeta(),
|
||||
profileId: profile.id,
|
||||
payloadJson: JSON.stringify(profile),
|
||||
authorDisplayName: '玩家',
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '保存自定义世界失败');
|
||||
}
|
||||
|
||||
const entries = await listCustomWorldLibrary();
|
||||
const entry =
|
||||
entries.find((item) => item.profileId === profile.id) ??
|
||||
mapCustomWorldLibraryEntry({
|
||||
ownerUserId: '',
|
||||
profileId: profile.id,
|
||||
payloadJson: JSON.stringify(profile),
|
||||
visibility: { tag: 'Draft' },
|
||||
publishedAtMs: null,
|
||||
updatedAtMs: BigInt(Date.now()),
|
||||
authorDisplayName: '玩家',
|
||||
worldName: profile.name,
|
||||
subtitle: profile.subtitle,
|
||||
summaryText: profile.summary,
|
||||
coverImageSrc: null,
|
||||
themeMode: { tag: 'Mythic' },
|
||||
playableNpcCount: profile.playableNpcs.length,
|
||||
landmarkCount: profile.landmarks.length,
|
||||
});
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
entry,
|
||||
entries,
|
||||
} satisfies CustomWorldLibraryMutationResponse<CustomWorldProfile>;
|
||||
}
|
||||
|
||||
export async function deleteCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'DELETE' },
|
||||
'删除自定义世界失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.deleteCustomWorldProfile({
|
||||
meta: buildRequestMeta(),
|
||||
profileId,
|
||||
});
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '删除自定义世界失败');
|
||||
}
|
||||
|
||||
return listCustomWorldLibrary();
|
||||
}
|
||||
|
||||
export async function publishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/publish`,
|
||||
{ method: 'POST' },
|
||||
'发布自定义世界失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.publishCustomWorldProfile({
|
||||
meta: buildRequestMeta(),
|
||||
profileId,
|
||||
authorDisplayName: '玩家',
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '发布自定义世界失败');
|
||||
}
|
||||
|
||||
const entries = await listCustomWorldLibrary();
|
||||
const entry = entries.find((item) => item.profileId === profileId);
|
||||
if (!entry) {
|
||||
throw new Error('发布后未找到自定义世界');
|
||||
}
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
entry,
|
||||
entries,
|
||||
} satisfies CustomWorldLibraryMutationResponse<CustomWorldProfile>;
|
||||
}
|
||||
|
||||
export async function unpublishCustomWorldProfile(
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldLibraryMutationResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-library/${encodeURIComponent(profileId)}/unpublish`,
|
||||
{ method: 'POST' },
|
||||
'下架自定义世界失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.unpublishCustomWorldProfile({
|
||||
meta: buildRequestMeta(),
|
||||
profileId,
|
||||
authorDisplayName: '玩家',
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '下架自定义世界失败');
|
||||
}
|
||||
|
||||
const entries = await listCustomWorldLibrary();
|
||||
const entry = entries.find((item) => item.profileId === profileId);
|
||||
if (!entry) {
|
||||
throw new Error('下架后未找到自定义世界');
|
||||
}
|
||||
|
||||
return {
|
||||
entry: response.entry,
|
||||
entries: Array.isArray(response?.entries) ? response.entries : [],
|
||||
};
|
||||
entry,
|
||||
entries,
|
||||
} satisfies CustomWorldLibraryMutationResponse<CustomWorldProfile>;
|
||||
}
|
||||
|
||||
export async function listCustomWorldGallery(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<CustomWorldGalleryResponse>(
|
||||
'/custom-world-gallery',
|
||||
{ method: 'GET' },
|
||||
'读取作品广场失败',
|
||||
options,
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.published_custom_world_gallery.iter()).map(
|
||||
mapGalleryCard,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function getCustomWorldGalleryDetail(
|
||||
ownerUserId: string,
|
||||
profileId: string,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<
|
||||
CustomWorldGalleryDetailResponse<CustomWorldProfile>
|
||||
>(
|
||||
`/custom-world-gallery/${encodeURIComponent(ownerUserId)}/${encodeURIComponent(profileId)}`,
|
||||
{ method: 'GET' },
|
||||
'读取作品详情失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const entry = Array.from(connection.db.published_custom_world_profiles.iter())
|
||||
.map(mapPublishedProfile)
|
||||
.find(
|
||||
(row) =>
|
||||
row.ownerUserId === ownerUserId && row.profileId === profileId,
|
||||
);
|
||||
|
||||
return response.entry;
|
||||
if (!entry) {
|
||||
throw new Error('读取作品详情失败');
|
||||
}
|
||||
|
||||
return entry satisfies CustomWorldGalleryDetailResponse<CustomWorldProfile>['entry'];
|
||||
}
|
||||
|
||||
export async function listProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'GET' },
|
||||
'读取浏览历史失败',
|
||||
options,
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_browse_history.iter()).map(
|
||||
mapBrowseHistoryEntry,
|
||||
);
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function upsertProfileBrowseHistory(
|
||||
entry: PlatformBrowseHistoryWriteEntry,
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(entry),
|
||||
},
|
||||
'写入浏览历史失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.upsertPlatformBrowseHistory({
|
||||
meta: buildRequestMeta(),
|
||||
entries: [
|
||||
{
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: mapThemeModeInput(entry.themeMode),
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
visitedAtMs: entry.visitedAt ? toBigIntMs(entry.visitedAt) : 0n,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '写入浏览历史失败');
|
||||
}
|
||||
|
||||
return listProfileBrowseHistory();
|
||||
}
|
||||
|
||||
export async function syncProfileBrowseHistory(
|
||||
entries: PlatformBrowseHistoryWriteEntry[],
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
entries,
|
||||
} satisfies PlatformBrowseHistoryBatchSyncRequest),
|
||||
},
|
||||
'同步浏览历史失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.upsertPlatformBrowseHistory({
|
||||
meta: buildRequestMeta(),
|
||||
entries: entries.map((entry) => ({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
profileId: entry.profileId,
|
||||
worldName: entry.worldName,
|
||||
subtitle: entry.subtitle,
|
||||
summaryText: entry.summaryText,
|
||||
coverImageSrc: entry.coverImageSrc,
|
||||
themeMode: mapThemeModeInput(entry.themeMode),
|
||||
authorDisplayName: entry.authorDisplayName,
|
||||
visitedAtMs: entry.visitedAt ? toBigIntMs(entry.visitedAt) : 0n,
|
||||
})),
|
||||
});
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '同步浏览历史失败');
|
||||
}
|
||||
|
||||
return listProfileBrowseHistory();
|
||||
}
|
||||
|
||||
export async function clearProfileBrowseHistory(
|
||||
options: RuntimeRequestOptions = {},
|
||||
_options: RuntimeRequestOptions = {},
|
||||
) {
|
||||
const response = await requestRuntimeJson<PlatformBrowseHistoryResponse>(
|
||||
'/profile/browse-history',
|
||||
{ method: 'DELETE' },
|
||||
'清空浏览历史失败',
|
||||
options,
|
||||
);
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
const result = await connection.procedures.clearPlatformBrowseHistory({
|
||||
meta: buildRequestMeta(),
|
||||
});
|
||||
|
||||
return Array.isArray(response?.entries) ? response.entries : [];
|
||||
if (!result.ok) {
|
||||
throw new Error(result.message || '清空浏览历史失败');
|
||||
}
|
||||
|
||||
return [] satisfies PlatformBrowseHistoryEntry[];
|
||||
}
|
||||
|
||||
async function listCustomWorldSessions() {
|
||||
const connection = await ensureSpacetimeConnection();
|
||||
return Array.from(connection.db.my_custom_world_sessions.iter()).map(
|
||||
mapCustomWorldSession,
|
||||
);
|
||||
}
|
||||
|
||||
export const runtimeStorageClient = {
|
||||
|
||||
242
src/spacetime/client.ts
Normal file
242
src/spacetime/client.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { Identity } from 'spacetimedb';
|
||||
|
||||
import { AUTH_STATE_EVENT, clearStoredAccessToken, getStoredAccessToken, setStoredAccessToken } from '../services/apiClient';
|
||||
import { DbConnection } from './generated';
|
||||
|
||||
export const SPACETIME_VERIFICATION_REQUIRED_EVENT =
|
||||
'genarrative-spacetime-verification-required';
|
||||
export const SPACETIME_KICK_EVENT = 'genarrative-spacetime-kick';
|
||||
export const SPACETIME_SESSION_REVOKED_EVENT =
|
||||
'genarrative-spacetime-session-revoked';
|
||||
|
||||
export type VerificationRequiredDetail = {
|
||||
phoneNumberMasked: string | null;
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type KickEventDetail = {
|
||||
reasonCode: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type SessionRevokedDetail = {
|
||||
targetSessionId: string;
|
||||
reasonCode: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
let activeConnection: DbConnection | null = null;
|
||||
let readyPromise: Promise<DbConnection> | null = null;
|
||||
let resolveReady: ((connection: DbConnection) => void) | null = null;
|
||||
let rejectReady: ((error: Error) => void) | null = null;
|
||||
let hasActiveSubscription = false;
|
||||
|
||||
function emitWindowEvent<T>(eventName: string, detail?: T) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof CustomEvent === 'function') {
|
||||
window.dispatchEvent(new CustomEvent(eventName, { detail }));
|
||||
return;
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event(eventName));
|
||||
}
|
||||
|
||||
function emitAuthStateChange() {
|
||||
emitWindowEvent(AUTH_STATE_EVENT);
|
||||
}
|
||||
|
||||
function normalizeSpacetimeUri(rawValue: string) {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return 'ws://127.0.0.1:3000';
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('ws://') || trimmed.startsWith('wss://')) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('http://')) {
|
||||
return `ws://${trimmed.slice('http://'.length)}`;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('https://')) {
|
||||
return `wss://${trimmed.slice('https://'.length)}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveSpacetimeUri() {
|
||||
return normalizeSpacetimeUri(
|
||||
import.meta.env.VITE_SPACETIME_URI?.trim() || 'ws://127.0.0.1:3000',
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDatabaseName() {
|
||||
return (
|
||||
import.meta.env.VITE_SPACETIME_DATABASE_NAME?.trim() || 'xushi-p4wfr'
|
||||
);
|
||||
}
|
||||
|
||||
function isTargetIdentity(connection: DbConnection, identity: Identity) {
|
||||
return Boolean(connection.identity && connection.identity.isEqual(identity));
|
||||
}
|
||||
|
||||
export function getCurrentSpacetimeSessionId(connection: DbConnection | null) {
|
||||
if (!connection) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const connectionIdHex = connection.connectionId?.toHexString()?.trim();
|
||||
if (connectionIdHex) {
|
||||
return `usess_${connectionIdHex}`;
|
||||
}
|
||||
|
||||
const identityHex = connection.identity?.toHexString()?.trim();
|
||||
return identityHex ? `usess_${identityHex}` : '';
|
||||
}
|
||||
|
||||
function installConnectionCallbacks(connection: DbConnection) {
|
||||
connection.db.my_auth_state.onInsert(() => {
|
||||
emitAuthStateChange();
|
||||
});
|
||||
connection.db.my_auth_state.onUpdate(() => {
|
||||
emitAuthStateChange();
|
||||
});
|
||||
connection.db.my_auth_state.onDelete(() => {
|
||||
emitAuthStateChange();
|
||||
});
|
||||
connection.db.verification_prompt_event.onInsert((_ctx, row) => {
|
||||
if (!isTargetIdentity(connection, row.targetIdentity)) {
|
||||
return;
|
||||
}
|
||||
emitWindowEvent<VerificationRequiredDetail>(
|
||||
SPACETIME_VERIFICATION_REQUIRED_EVENT,
|
||||
{
|
||||
phoneNumberMasked: row.phoneNumberMasked ?? null,
|
||||
title: row.title,
|
||||
detail: row.detail,
|
||||
},
|
||||
);
|
||||
});
|
||||
connection.db.kick_event.onInsert((_ctx, row) => {
|
||||
if (!isTargetIdentity(connection, row.targetIdentity)) {
|
||||
return;
|
||||
}
|
||||
emitWindowEvent<KickEventDetail>(SPACETIME_KICK_EVENT, {
|
||||
reasonCode: row.reasonCode,
|
||||
message: row.message,
|
||||
});
|
||||
});
|
||||
connection.db.session_revocation_event.onInsert((_ctx, row) => {
|
||||
const currentSessionId = getCurrentSpacetimeSessionId(connection);
|
||||
if (!currentSessionId || row.targetSessionId !== currentSessionId) {
|
||||
return;
|
||||
}
|
||||
emitWindowEvent<SessionRevokedDetail>(SPACETIME_SESSION_REVOKED_EVENT, {
|
||||
targetSessionId: row.targetSessionId,
|
||||
reasonCode: row.reasonCode,
|
||||
message: row.message,
|
||||
});
|
||||
connection.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
function resetReadyState() {
|
||||
readyPromise = null;
|
||||
resolveReady = null;
|
||||
rejectReady = null;
|
||||
hasActiveSubscription = false;
|
||||
}
|
||||
|
||||
function rejectConnection(error: Error) {
|
||||
rejectReady?.(error);
|
||||
resetReadyState();
|
||||
}
|
||||
|
||||
export function getSpacetimeConnection() {
|
||||
return activeConnection;
|
||||
}
|
||||
|
||||
export function disconnectSpacetimeConnection(options: { clearToken?: boolean } = {}) {
|
||||
const currentConnection = activeConnection;
|
||||
activeConnection = null;
|
||||
resetReadyState();
|
||||
currentConnection?.disconnect();
|
||||
if (options.clearToken) {
|
||||
clearStoredAccessToken({ emit: false });
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSpacetimeConnection() {
|
||||
const connection = DbConnection.builder()
|
||||
.withUri(resolveSpacetimeUri())
|
||||
.withDatabaseName(resolveDatabaseName())
|
||||
.withLightMode(true)
|
||||
.withToken(getStoredAccessToken() || undefined)
|
||||
.onConnect((nextConnection, _identity, token) => {
|
||||
activeConnection = nextConnection;
|
||||
setStoredAccessToken(token, { emit: false });
|
||||
installConnectionCallbacks(nextConnection);
|
||||
if (hasActiveSubscription) {
|
||||
resolveReady?.(nextConnection);
|
||||
emitAuthStateChange();
|
||||
return;
|
||||
}
|
||||
|
||||
nextConnection
|
||||
.subscriptionBuilder()
|
||||
.onApplied(() => {
|
||||
hasActiveSubscription = true;
|
||||
resolveReady?.(nextConnection);
|
||||
emitAuthStateChange();
|
||||
})
|
||||
.onError((_ctx) => {
|
||||
rejectConnection(new Error('Spacetime 数据订阅失败'));
|
||||
})
|
||||
.subscribeToAllTables();
|
||||
})
|
||||
.onConnectError((_ctx, error) => {
|
||||
activeConnection = null;
|
||||
rejectConnection(error);
|
||||
})
|
||||
.onDisconnect((_ctx, error) => {
|
||||
activeConnection = null;
|
||||
if (!hasActiveSubscription) {
|
||||
rejectConnection(error ?? new Error('Spacetime 连接已断开'));
|
||||
return;
|
||||
}
|
||||
resetReadyState();
|
||||
emitAuthStateChange();
|
||||
})
|
||||
.build();
|
||||
|
||||
activeConnection = connection;
|
||||
return connection;
|
||||
}
|
||||
|
||||
export function ensureSpacetimeConnection() {
|
||||
if (activeConnection?.isActive && hasActiveSubscription && readyPromise) {
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
if (activeConnection?.isActive && hasActiveSubscription) {
|
||||
return Promise.resolve(activeConnection);
|
||||
}
|
||||
|
||||
if (readyPromise) {
|
||||
return readyPromise;
|
||||
}
|
||||
|
||||
readyPromise = new Promise<DbConnection>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
buildSpacetimeConnection();
|
||||
return readyPromise;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
24
src/spacetime/generated/client_app_config_table.ts
Normal file
24
src/spacetime/generated/client_app_config_table.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
guestLoginEnabled: __t.bool().name("guest_login_enabled"),
|
||||
smsAuthEnabled: __t.bool().name("sms_auth_enabled"),
|
||||
smsVerificationRequired: __t.bool().name("sms_verification_required"),
|
||||
smsProvider: __t.string().name("sms_provider"),
|
||||
smsCodeLength: __t.u16().name("sms_code_length"),
|
||||
smsValidTimeSeconds: __t.u32().name("sms_valid_time_seconds"),
|
||||
smsIntervalSeconds: __t.u32().name("sms_interval_seconds"),
|
||||
defaultMusicVolume: __t.f32().name("default_music_volume"),
|
||||
defaultGuestDisplayNamePrefix: __t.string().name("default_guest_display_name_prefix"),
|
||||
wechatEnabled: __t.bool().name("wechat_enabled"),
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
profileId: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
23
src/spacetime/generated/delete_snapshot_procedure.ts
Normal file
23
src/spacetime/generated/delete_snapshot_procedure.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
282
src/spacetime/generated/index.ts
Normal file
282
src/spacetime/generated/index.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d).
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
DbConnectionBuilder as __DbConnectionBuilder,
|
||||
DbConnectionImpl as __DbConnectionImpl,
|
||||
SubscriptionBuilderImpl as __SubscriptionBuilderImpl,
|
||||
TypeBuilder as __TypeBuilder,
|
||||
Uuid as __Uuid,
|
||||
convertToAccessorMap as __convertToAccessorMap,
|
||||
makeQueryBuilder as __makeQueryBuilder,
|
||||
procedureSchema as __procedureSchema,
|
||||
procedures as __procedures,
|
||||
reducerSchema as __reducerSchema,
|
||||
reducers as __reducers,
|
||||
schema as __schema,
|
||||
t as __t,
|
||||
table as __table,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type DbConnectionConfig as __DbConnectionConfig,
|
||||
type ErrorContextInterface as __ErrorContextInterface,
|
||||
type Event as __Event,
|
||||
type EventContextInterface as __EventContextInterface,
|
||||
type Infer as __Infer,
|
||||
type QueryBuilder as __QueryBuilder,
|
||||
type ReducerEventContextInterface as __ReducerEventContextInterface,
|
||||
type RemoteModule as __RemoteModule,
|
||||
type SubscriptionEventContextInterface as __SubscriptionEventContextInterface,
|
||||
type SubscriptionHandleImpl as __SubscriptionHandleImpl,
|
||||
} from "spacetimedb";
|
||||
|
||||
// Import all reducer arg schemas
|
||||
|
||||
// Import all procedure arg schemas
|
||||
import * as ClearPlatformBrowseHistoryProcedure from "./clear_platform_browse_history_procedure";
|
||||
import * as DeleteCustomWorldProfileProcedure from "./delete_custom_world_profile_procedure";
|
||||
import * as DeleteSnapshotProcedure from "./delete_snapshot_procedure";
|
||||
import * as LiftMyRiskBlockProcedure from "./lift_my_risk_block_procedure";
|
||||
import * as LogoutAllUserSessionsProcedure from "./logout_all_user_sessions_procedure";
|
||||
import * as PublishCustomWorldProfileProcedure from "./publish_custom_world_profile_procedure";
|
||||
import * as PutRuntimeSettingsProcedure from "./put_runtime_settings_procedure";
|
||||
import * as RevokeUserSessionProcedure from "./revoke_user_session_procedure";
|
||||
import * as SaveSnapshotProcedure from "./save_snapshot_procedure";
|
||||
import * as SendSmsVerificationCodeProcedure from "./send_sms_verification_code_procedure";
|
||||
import * as UnpublishCustomWorldProfileProcedure from "./unpublish_custom_world_profile_procedure";
|
||||
import * as UpsertCustomWorldProfileProcedure from "./upsert_custom_world_profile_procedure";
|
||||
import * as UpsertCustomWorldSessionProcedure from "./upsert_custom_world_session_procedure";
|
||||
import * as UpsertPlatformBrowseHistoryProcedure from "./upsert_platform_browse_history_procedure";
|
||||
import * as VerifySmsCodeProcedure from "./verify_sms_code_procedure";
|
||||
|
||||
// Import all table schema definitions
|
||||
import ClientAppConfigRow from "./client_app_config_table";
|
||||
import KickEventRow from "./kick_event_table";
|
||||
import MyAuthAuditLogsRow from "./my_auth_audit_logs_table";
|
||||
import MyAuthRiskBlocksRow from "./my_auth_risk_blocks_table";
|
||||
import MyAuthStateRow from "./my_auth_state_table";
|
||||
import MyBrowseHistoryRow from "./my_browse_history_table";
|
||||
import MyCustomWorldProfilesRow from "./my_custom_world_profiles_table";
|
||||
import MyCustomWorldSessionsRow from "./my_custom_world_sessions_table";
|
||||
import MyProfileDashboardRow from "./my_profile_dashboard_table";
|
||||
import MyProfilePlayedWorldsRow from "./my_profile_played_worlds_table";
|
||||
import MyProfileWalletLedgerRow from "./my_profile_wallet_ledger_table";
|
||||
import MyRuntimeSettingsRow from "./my_runtime_settings_table";
|
||||
import MySnapshotRow from "./my_snapshot_table";
|
||||
import MyUserSessionsRow from "./my_user_sessions_table";
|
||||
import PublishedCustomWorldGalleryRow from "./published_custom_world_gallery_table";
|
||||
import PublishedCustomWorldProfilesRow from "./published_custom_world_profiles_table";
|
||||
import SessionRevocationEventRow from "./session_revocation_event_table";
|
||||
import VerificationPromptEventRow from "./verification_prompt_event_table";
|
||||
|
||||
/** Type-only namespace exports for generated type groups. */
|
||||
|
||||
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
|
||||
const tablesSchema = __schema({
|
||||
kick_event: __table({
|
||||
name: 'kick_event',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
event: true,
|
||||
}, KickEventRow),
|
||||
session_revocation_event: __table({
|
||||
name: 'session_revocation_event',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
event: true,
|
||||
}, SessionRevocationEventRow),
|
||||
verification_prompt_event: __table({
|
||||
name: 'verification_prompt_event',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
event: true,
|
||||
}, VerificationPromptEventRow),
|
||||
client_app_config: __table({
|
||||
name: 'client_app_config',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, ClientAppConfigRow),
|
||||
my_auth_audit_logs: __table({
|
||||
name: 'my_auth_audit_logs',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyAuthAuditLogsRow),
|
||||
my_auth_risk_blocks: __table({
|
||||
name: 'my_auth_risk_blocks',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyAuthRiskBlocksRow),
|
||||
my_auth_state: __table({
|
||||
name: 'my_auth_state',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyAuthStateRow),
|
||||
my_browse_history: __table({
|
||||
name: 'my_browse_history',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyBrowseHistoryRow),
|
||||
my_custom_world_profiles: __table({
|
||||
name: 'my_custom_world_profiles',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyCustomWorldProfilesRow),
|
||||
my_custom_world_sessions: __table({
|
||||
name: 'my_custom_world_sessions',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyCustomWorldSessionsRow),
|
||||
my_profile_dashboard: __table({
|
||||
name: 'my_profile_dashboard',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyProfileDashboardRow),
|
||||
my_profile_played_worlds: __table({
|
||||
name: 'my_profile_played_worlds',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyProfilePlayedWorldsRow),
|
||||
my_profile_wallet_ledger: __table({
|
||||
name: 'my_profile_wallet_ledger',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyProfileWalletLedgerRow),
|
||||
my_runtime_settings: __table({
|
||||
name: 'my_runtime_settings',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyRuntimeSettingsRow),
|
||||
my_snapshot: __table({
|
||||
name: 'my_snapshot',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MySnapshotRow),
|
||||
my_user_sessions: __table({
|
||||
name: 'my_user_sessions',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, MyUserSessionsRow),
|
||||
published_custom_world_gallery: __table({
|
||||
name: 'published_custom_world_gallery',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, PublishedCustomWorldGalleryRow),
|
||||
published_custom_world_profiles: __table({
|
||||
name: 'published_custom_world_profiles',
|
||||
indexes: [
|
||||
],
|
||||
constraints: [
|
||||
],
|
||||
}, PublishedCustomWorldProfilesRow),
|
||||
});
|
||||
|
||||
/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */
|
||||
const reducersSchema = __reducers(
|
||||
);
|
||||
|
||||
/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */
|
||||
const proceduresSchema = __procedures(
|
||||
__procedureSchema("clear_platform_browse_history", ClearPlatformBrowseHistoryProcedure.params, ClearPlatformBrowseHistoryProcedure.returnType),
|
||||
__procedureSchema("delete_custom_world_profile", DeleteCustomWorldProfileProcedure.params, DeleteCustomWorldProfileProcedure.returnType),
|
||||
__procedureSchema("delete_snapshot", DeleteSnapshotProcedure.params, DeleteSnapshotProcedure.returnType),
|
||||
__procedureSchema("lift_my_risk_block", LiftMyRiskBlockProcedure.params, LiftMyRiskBlockProcedure.returnType),
|
||||
__procedureSchema("logout_all_user_sessions", LogoutAllUserSessionsProcedure.params, LogoutAllUserSessionsProcedure.returnType),
|
||||
__procedureSchema("publish_custom_world_profile", PublishCustomWorldProfileProcedure.params, PublishCustomWorldProfileProcedure.returnType),
|
||||
__procedureSchema("put_runtime_settings", PutRuntimeSettingsProcedure.params, PutRuntimeSettingsProcedure.returnType),
|
||||
__procedureSchema("revoke_user_session", RevokeUserSessionProcedure.params, RevokeUserSessionProcedure.returnType),
|
||||
__procedureSchema("save_snapshot", SaveSnapshotProcedure.params, SaveSnapshotProcedure.returnType),
|
||||
__procedureSchema("send_sms_verification_code", SendSmsVerificationCodeProcedure.params, SendSmsVerificationCodeProcedure.returnType),
|
||||
__procedureSchema("unpublish_custom_world_profile", UnpublishCustomWorldProfileProcedure.params, UnpublishCustomWorldProfileProcedure.returnType),
|
||||
__procedureSchema("upsert_custom_world_profile", UpsertCustomWorldProfileProcedure.params, UpsertCustomWorldProfileProcedure.returnType),
|
||||
__procedureSchema("upsert_custom_world_session", UpsertCustomWorldSessionProcedure.params, UpsertCustomWorldSessionProcedure.returnType),
|
||||
__procedureSchema("upsert_platform_browse_history", UpsertPlatformBrowseHistoryProcedure.params, UpsertPlatformBrowseHistoryProcedure.returnType),
|
||||
__procedureSchema("verify_sms_code", VerifySmsCodeProcedure.params, VerifySmsCodeProcedure.returnType),
|
||||
);
|
||||
|
||||
/** The remote SpacetimeDB module schema, both runtime and type information. */
|
||||
const REMOTE_MODULE = {
|
||||
versionInfo: {
|
||||
cliVersion: "2.1.0" as const,
|
||||
},
|
||||
tables: tablesSchema.schemaType.tables,
|
||||
reducers: reducersSchema.reducersType.reducers,
|
||||
...proceduresSchema,
|
||||
} satisfies __RemoteModule<
|
||||
typeof tablesSchema.schemaType,
|
||||
typeof reducersSchema.reducersType,
|
||||
typeof proceduresSchema
|
||||
>;
|
||||
|
||||
/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */
|
||||
export const tables: __QueryBuilder<typeof tablesSchema.schemaType> = __makeQueryBuilder(tablesSchema.schemaType);
|
||||
|
||||
/** The reducers available in this remote SpacetimeDB module. */
|
||||
export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers);
|
||||
|
||||
/** The context type returned in callbacks for all possible events. */
|
||||
export type EventContext = __EventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for reducer events. */
|
||||
export type ReducerEventContext = __ReducerEventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for subscription events. */
|
||||
export type SubscriptionEventContext = __SubscriptionEventContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The context type returned in callbacks for error events. */
|
||||
export type ErrorContext = __ErrorContextInterface<typeof REMOTE_MODULE>;
|
||||
/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */
|
||||
export type SubscriptionHandle = __SubscriptionHandleImpl<typeof REMOTE_MODULE>;
|
||||
|
||||
/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */
|
||||
export class SubscriptionBuilder extends __SubscriptionBuilderImpl<typeof REMOTE_MODULE> {}
|
||||
|
||||
/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */
|
||||
export class DbConnectionBuilder extends __DbConnectionBuilder<DbConnection> {}
|
||||
|
||||
/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */
|
||||
export class DbConnection extends __DbConnectionImpl<typeof REMOTE_MODULE> {
|
||||
/** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */
|
||||
static builder = (): DbConnectionBuilder => {
|
||||
return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig<typeof REMOTE_MODULE>) => new DbConnection(config));
|
||||
};
|
||||
|
||||
/** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */
|
||||
override subscriptionBuilder = (): SubscriptionBuilder => {
|
||||
return new SubscriptionBuilder(this);
|
||||
};
|
||||
}
|
||||
|
||||
18
src/spacetime/generated/kick_event_table.ts
Normal file
18
src/spacetime/generated/kick_event_table.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
targetIdentity: __t.identity().name("target_identity"),
|
||||
reasonCode: __t.string().name("reason_code"),
|
||||
message: __t.string(),
|
||||
issuedAtMs: __t.u64().name("issued_at_ms"),
|
||||
});
|
||||
27
src/spacetime/generated/lift_my_risk_block_procedure.ts
Normal file
27
src/spacetime/generated/lift_my_risk_block_procedure.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RiskBlockScopeType,
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
get scopeType() {
|
||||
return RiskBlockScopeType;
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,23 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
20
src/spacetime/generated/my_auth_audit_logs_table.ts
Normal file
20
src/spacetime/generated/my_auth_audit_logs_table.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
id: __t.u64(),
|
||||
eventType: __t.string().name("event_type"),
|
||||
detail: __t.string(),
|
||||
ip: __t.option(__t.string()),
|
||||
userAgent: __t.option(__t.string()).name("user_agent"),
|
||||
createdAtMs: __t.u64().name("created_at_ms"),
|
||||
});
|
||||
24
src/spacetime/generated/my_auth_risk_blocks_table.ts
Normal file
24
src/spacetime/generated/my_auth_risk_blocks_table.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
RiskBlockScopeType,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
get scopeType() {
|
||||
return RiskBlockScopeType.name("scope_type");
|
||||
},
|
||||
scopeKey: __t.string().name("scope_key"),
|
||||
reason: __t.string(),
|
||||
expiresAtMs: __t.u64().name("expires_at_ms"),
|
||||
});
|
||||
32
src/spacetime/generated/my_auth_state_table.ts
Normal file
32
src/spacetime/generated/my_auth_state_table.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
LoginProvider,
|
||||
AccountStatus,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
userId: __t.string().name("user_id"),
|
||||
identity: __t.identity(),
|
||||
displayName: __t.string().name("display_name"),
|
||||
phoneNumberMasked: __t.option(__t.string()).name("phone_number_masked"),
|
||||
get loginProvider() {
|
||||
return LoginProvider.name("login_provider");
|
||||
},
|
||||
get accountStatus() {
|
||||
return AccountStatus.name("account_status");
|
||||
},
|
||||
smsVerificationRequired: __t.bool().name("sms_verification_required"),
|
||||
smsVerified: __t.bool().name("sms_verified"),
|
||||
jwtPresent: __t.bool().name("jwt_present"),
|
||||
});
|
||||
29
src/spacetime/generated/my_browse_history_table.ts
Normal file
29
src/spacetime/generated/my_browse_history_table.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
CustomWorldThemeMode,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
ownerUserId: __t.string().name("owner_user_id"),
|
||||
profileId: __t.string().name("profile_id"),
|
||||
worldName: __t.string().name("world_name"),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string().name("summary_text"),
|
||||
coverImageSrc: __t.option(__t.string()).name("cover_image_src"),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode.name("theme_mode");
|
||||
},
|
||||
authorDisplayName: __t.string().name("author_display_name"),
|
||||
visitedAtMs: __t.u64().name("visited_at_ms"),
|
||||
});
|
||||
37
src/spacetime/generated/my_custom_world_profiles_table.ts
Normal file
37
src/spacetime/generated/my_custom_world_profiles_table.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
CustomWorldPublicationStatus,
|
||||
CustomWorldThemeMode,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
ownerUserId: __t.string().name("owner_user_id"),
|
||||
profileId: __t.string().name("profile_id"),
|
||||
payloadJson: __t.string().name("payload_json"),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()).name("published_at_ms"),
|
||||
updatedAtMs: __t.u64().name("updated_at_ms"),
|
||||
authorDisplayName: __t.string().name("author_display_name"),
|
||||
worldName: __t.string().name("world_name"),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string().name("summary_text"),
|
||||
coverImageSrc: __t.option(__t.string()).name("cover_image_src"),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode.name("theme_mode");
|
||||
},
|
||||
playableNpcCount: __t.u32().name("playable_npc_count"),
|
||||
landmarkCount: __t.u32().name("landmark_count"),
|
||||
});
|
||||
18
src/spacetime/generated/my_custom_world_sessions_table.ts
Normal file
18
src/spacetime/generated/my_custom_world_sessions_table.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
sessionId: __t.string().name("session_id"),
|
||||
payloadJson: __t.string().name("payload_json"),
|
||||
createdAtMs: __t.u64().name("created_at_ms"),
|
||||
updatedAtMs: __t.u64().name("updated_at_ms"),
|
||||
});
|
||||
18
src/spacetime/generated/my_profile_dashboard_table.ts
Normal file
18
src/spacetime/generated/my_profile_dashboard_table.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
walletBalance: __t.i64().name("wallet_balance"),
|
||||
totalPlayTimeMs: __t.u64().name("total_play_time_ms"),
|
||||
playedWorldCount: __t.u32().name("played_world_count"),
|
||||
updatedAtMs: __t.option(__t.u64()).name("updated_at_ms"),
|
||||
});
|
||||
23
src/spacetime/generated/my_profile_played_worlds_table.ts
Normal file
23
src/spacetime/generated/my_profile_played_worlds_table.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
worldKey: __t.string().name("world_key"),
|
||||
ownerUserId: __t.option(__t.string()).name("owner_user_id"),
|
||||
profileId: __t.option(__t.string()).name("profile_id"),
|
||||
worldType: __t.option(__t.string()).name("world_type"),
|
||||
worldTitle: __t.string().name("world_title"),
|
||||
worldSubtitle: __t.string().name("world_subtitle"),
|
||||
firstPlayedAtMs: __t.u64().name("first_played_at_ms"),
|
||||
lastPlayedAtMs: __t.u64().name("last_played_at_ms"),
|
||||
lastObservedPlayTimeMs: __t.u64().name("last_observed_play_time_ms"),
|
||||
});
|
||||
19
src/spacetime/generated/my_profile_wallet_ledger_table.ts
Normal file
19
src/spacetime/generated/my_profile_wallet_ledger_table.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
id: __t.string(),
|
||||
amountDelta: __t.i64().name("amount_delta"),
|
||||
balanceAfter: __t.i64().name("balance_after"),
|
||||
sourceType: __t.string().name("source_type"),
|
||||
createdAtMs: __t.u64().name("created_at_ms"),
|
||||
});
|
||||
15
src/spacetime/generated/my_runtime_settings_table.ts
Normal file
15
src/spacetime/generated/my_runtime_settings_table.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
musicVolume: __t.f32().name("music_volume"),
|
||||
});
|
||||
19
src/spacetime/generated/my_snapshot_table.ts
Normal file
19
src/spacetime/generated/my_snapshot_table.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
version: __t.u32(),
|
||||
savedAtMs: __t.u64().name("saved_at_ms"),
|
||||
gameStateJson: __t.string().name("game_state_json"),
|
||||
bottomTab: __t.string().name("bottom_tab"),
|
||||
currentStoryJson: __t.option(__t.string()).name("current_story_json"),
|
||||
});
|
||||
22
src/spacetime/generated/my_user_sessions_table.ts
Normal file
22
src/spacetime/generated/my_user_sessions_table.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
sessionId: __t.string().name("session_id"),
|
||||
clientType: __t.string().name("client_type"),
|
||||
userAgent: __t.option(__t.string()).name("user_agent"),
|
||||
ip: __t.option(__t.string()),
|
||||
isCurrent: __t.bool().name("is_current"),
|
||||
createdAtMs: __t.u64().name("created_at_ms"),
|
||||
lastSeenAtMs: __t.u64().name("last_seen_at_ms"),
|
||||
expiresAtMs: __t.option(__t.u64()).name("expires_at_ms"),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
profileId: __t.string(),
|
||||
authorDisplayName: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,36 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
CustomWorldPublicationStatus,
|
||||
CustomWorldThemeMode,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
ownerUserId: __t.string().name("owner_user_id"),
|
||||
profileId: __t.string().name("profile_id"),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()).name("published_at_ms"),
|
||||
updatedAtMs: __t.u64().name("updated_at_ms"),
|
||||
authorDisplayName: __t.string().name("author_display_name"),
|
||||
worldName: __t.string().name("world_name"),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string().name("summary_text"),
|
||||
coverImageSrc: __t.option(__t.string()).name("cover_image_src"),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode.name("theme_mode");
|
||||
},
|
||||
playableNpcCount: __t.u32().name("playable_npc_count"),
|
||||
landmarkCount: __t.u32().name("landmark_count"),
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
import {
|
||||
CustomWorldPublicationStatus,
|
||||
CustomWorldThemeMode,
|
||||
} from "./types";
|
||||
|
||||
|
||||
export default __t.row({
|
||||
ownerUserId: __t.string().name("owner_user_id"),
|
||||
profileId: __t.string().name("profile_id"),
|
||||
payloadJson: __t.string().name("payload_json"),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()).name("published_at_ms"),
|
||||
updatedAtMs: __t.u64().name("updated_at_ms"),
|
||||
authorDisplayName: __t.string().name("author_display_name"),
|
||||
worldName: __t.string().name("world_name"),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string().name("summary_text"),
|
||||
coverImageSrc: __t.option(__t.string()).name("cover_image_src"),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode.name("theme_mode");
|
||||
},
|
||||
playableNpcCount: __t.u32().name("playable_npc_count"),
|
||||
landmarkCount: __t.u32().name("landmark_count"),
|
||||
});
|
||||
24
src/spacetime/generated/put_runtime_settings_procedure.ts
Normal file
24
src/spacetime/generated/put_runtime_settings_procedure.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
musicVolume: __t.f32(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
24
src/spacetime/generated/revoke_user_session_procedure.ts
Normal file
24
src/spacetime/generated/revoke_user_session_procedure.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
sessionId: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
27
src/spacetime/generated/save_snapshot_procedure.ts
Normal file
27
src/spacetime/generated/save_snapshot_procedure.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
savedAtMs: __t.u64(),
|
||||
gameStateJson: __t.string(),
|
||||
bottomTab: __t.string(),
|
||||
currentStoryJson: __t.option(__t.string()),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,28 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
SmsAuthScene,
|
||||
SmsSendCodeResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
phoneNumber: __t.string(),
|
||||
get scene() {
|
||||
return SmsAuthScene;
|
||||
},
|
||||
};
|
||||
export const returnType = SmsSendCodeResult
|
||||
18
src/spacetime/generated/session_revocation_event_table.ts
Normal file
18
src/spacetime/generated/session_revocation_event_table.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
targetSessionId: __t.string().name("target_session_id"),
|
||||
reasonCode: __t.string().name("reason_code"),
|
||||
message: __t.string(),
|
||||
issuedAtMs: __t.u64().name("issued_at_ms"),
|
||||
});
|
||||
600
src/spacetime/generated/types.ts
Normal file
600
src/spacetime/generated/types.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
// The tagged union or sum type for the algebraic type `AccountStatus`.
|
||||
export const AccountStatus = __t.enum("AccountStatus", {
|
||||
Active: __t.unit(),
|
||||
PendingSmsVerification: __t.unit(),
|
||||
Disabled: __t.unit(),
|
||||
});
|
||||
export type AccountStatus = __Infer<typeof AccountStatus>;
|
||||
|
||||
export const AppConfig = __t.object("AppConfig", {
|
||||
id: __t.u8(),
|
||||
guestLoginEnabled: __t.bool(),
|
||||
smsAuthEnabled: __t.bool(),
|
||||
smsVerificationRequired: __t.bool(),
|
||||
smsProvider: __t.string(),
|
||||
smsEndpoint: __t.string(),
|
||||
smsAccessKeyId: __t.string(),
|
||||
smsAccessKeySecret: __t.string(),
|
||||
smsSignName: __t.string(),
|
||||
smsTemplateCode: __t.string(),
|
||||
smsTemplateParamKey: __t.string(),
|
||||
smsCountryCode: __t.string(),
|
||||
smsSchemeName: __t.string(),
|
||||
smsCodeLength: __t.u16(),
|
||||
smsCodeType: __t.u16(),
|
||||
smsValidTimeSeconds: __t.u32(),
|
||||
smsIntervalSeconds: __t.u32(),
|
||||
smsDuplicatePolicy: __t.u16(),
|
||||
smsCaseAuthPolicy: __t.u16(),
|
||||
smsReturnVerifyCode: __t.bool(),
|
||||
smsMockVerifyCode: __t.string(),
|
||||
smsMaxSendPerPhonePerDay: __t.u16(),
|
||||
smsMaxSendPerIpPerHour: __t.u16(),
|
||||
smsMaxVerifyFailuresPerPhonePerHour: __t.u16(),
|
||||
smsMaxVerifyFailuresPerIpPerHour: __t.u16(),
|
||||
smsCaptchaTtlSeconds: __t.u32(),
|
||||
smsCaptchaTriggerVerifyFailuresPerPhone: __t.u16(),
|
||||
smsCaptchaTriggerVerifyFailuresPerIp: __t.u16(),
|
||||
smsBlockPhoneFailureThreshold: __t.u16(),
|
||||
smsBlockIpFailureThreshold: __t.u16(),
|
||||
smsBlockPhoneDurationMinutes: __t.u16(),
|
||||
smsBlockIpDurationMinutes: __t.u16(),
|
||||
defaultMusicVolume: __t.f32(),
|
||||
defaultGuestDisplayNamePrefix: __t.string(),
|
||||
kickMessageUnverified: __t.string(),
|
||||
wechatEnabled: __t.bool(),
|
||||
wechatProvider: __t.string(),
|
||||
wechatAppId: __t.string(),
|
||||
wechatAppSecret: __t.string(),
|
||||
wechatAuthorizeEndpoint: __t.string(),
|
||||
wechatAccessTokenEndpoint: __t.string(),
|
||||
wechatUserInfoEndpoint: __t.string(),
|
||||
wechatCallbackPath: __t.string(),
|
||||
wechatDefaultRedirectPath: __t.string(),
|
||||
wechatMockUserId: __t.string(),
|
||||
wechatMockUnionId: __t.string(),
|
||||
wechatMockDisplayName: __t.string(),
|
||||
wechatMockAvatarUrl: __t.string(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type AppConfig = __Infer<typeof AppConfig>;
|
||||
|
||||
export const AuthAuditLog = __t.object("AuthAuditLog", {
|
||||
id: __t.u64(),
|
||||
userId: __t.string(),
|
||||
eventType: __t.string(),
|
||||
detail: __t.string(),
|
||||
ip: __t.option(__t.string()),
|
||||
userAgent: __t.option(__t.string()),
|
||||
metaJson: __t.option(__t.string()),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthAuditLog = __Infer<typeof AuthAuditLog>;
|
||||
|
||||
export const AuthAuditLogView = __t.object("AuthAuditLogView", {
|
||||
id: __t.u64(),
|
||||
eventType: __t.string(),
|
||||
detail: __t.string(),
|
||||
ip: __t.option(__t.string()),
|
||||
userAgent: __t.option(__t.string()),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthAuditLogView = __Infer<typeof AuthAuditLogView>;
|
||||
|
||||
export const AuthIdentity = __t.object("AuthIdentity", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
get provider() {
|
||||
return AuthIdentityProvider;
|
||||
},
|
||||
providerUid: __t.string(),
|
||||
providerUnionId: __t.option(__t.string()),
|
||||
displayName: __t.option(__t.string()),
|
||||
avatarUrl: __t.option(__t.string()),
|
||||
isVerified: __t.bool(),
|
||||
metaJson: __t.option(__t.string()),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthIdentity = __Infer<typeof AuthIdentity>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `AuthIdentityProvider`.
|
||||
export const AuthIdentityProvider = __t.enum("AuthIdentityProvider", {
|
||||
Guest: __t.unit(),
|
||||
Jwt: __t.unit(),
|
||||
Phone: __t.unit(),
|
||||
Wechat: __t.unit(),
|
||||
});
|
||||
export type AuthIdentityProvider = __Infer<typeof AuthIdentityProvider>;
|
||||
|
||||
export const AuthRiskBlock = __t.object("AuthRiskBlock", {
|
||||
id: __t.u64(),
|
||||
get scopeType() {
|
||||
return RiskBlockScopeType;
|
||||
},
|
||||
scopeKey: __t.string(),
|
||||
reason: __t.string(),
|
||||
expiresAtMs: __t.u64(),
|
||||
liftedAtMs: __t.option(__t.u64()),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthRiskBlock = __Infer<typeof AuthRiskBlock>;
|
||||
|
||||
export const AuthRiskBlockView = __t.object("AuthRiskBlockView", {
|
||||
get scopeType() {
|
||||
return RiskBlockScopeType;
|
||||
},
|
||||
scopeKey: __t.string(),
|
||||
reason: __t.string(),
|
||||
expiresAtMs: __t.u64(),
|
||||
});
|
||||
export type AuthRiskBlockView = __Infer<typeof AuthRiskBlockView>;
|
||||
|
||||
export const AuthSessionView = __t.object("AuthSessionView", {
|
||||
sessionId: __t.string(),
|
||||
clientType: __t.string(),
|
||||
userAgent: __t.option(__t.string()),
|
||||
ip: __t.option(__t.string()),
|
||||
isCurrent: __t.bool(),
|
||||
createdAtMs: __t.u64(),
|
||||
lastSeenAtMs: __t.u64(),
|
||||
expiresAtMs: __t.option(__t.u64()),
|
||||
});
|
||||
export type AuthSessionView = __Infer<typeof AuthSessionView>;
|
||||
|
||||
export const AuthStateView = __t.object("AuthStateView", {
|
||||
userId: __t.string(),
|
||||
identity: __t.identity(),
|
||||
displayName: __t.string(),
|
||||
phoneNumberMasked: __t.option(__t.string()),
|
||||
get loginProvider() {
|
||||
return LoginProvider;
|
||||
},
|
||||
get accountStatus() {
|
||||
return AccountStatus;
|
||||
},
|
||||
smsVerificationRequired: __t.bool(),
|
||||
smsVerified: __t.bool(),
|
||||
jwtPresent: __t.bool(),
|
||||
});
|
||||
export type AuthStateView = __Infer<typeof AuthStateView>;
|
||||
|
||||
export const ClientAppConfigView = __t.object("ClientAppConfigView", {
|
||||
guestLoginEnabled: __t.bool(),
|
||||
smsAuthEnabled: __t.bool(),
|
||||
smsVerificationRequired: __t.bool(),
|
||||
smsProvider: __t.string(),
|
||||
smsCodeLength: __t.u16(),
|
||||
smsValidTimeSeconds: __t.u32(),
|
||||
smsIntervalSeconds: __t.u32(),
|
||||
defaultMusicVolume: __t.f32(),
|
||||
defaultGuestDisplayNamePrefix: __t.string(),
|
||||
wechatEnabled: __t.bool(),
|
||||
});
|
||||
export type ClientAppConfigView = __Infer<typeof ClientAppConfigView>;
|
||||
|
||||
export const CustomWorldGalleryCardView = __t.object("CustomWorldGalleryCardView", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()),
|
||||
updatedAtMs: __t.u64(),
|
||||
authorDisplayName: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
playableNpcCount: __t.u32(),
|
||||
landmarkCount: __t.u32(),
|
||||
});
|
||||
export type CustomWorldGalleryCardView = __Infer<typeof CustomWorldGalleryCardView>;
|
||||
|
||||
export const CustomWorldProfile = __t.object("CustomWorldProfile", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
payloadJson: __t.string(),
|
||||
publishedAtMs: __t.option(__t.u64()),
|
||||
updatedAtMs: __t.u64(),
|
||||
authorDisplayName: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
playableNpcCount: __t.u32(),
|
||||
landmarkCount: __t.u32(),
|
||||
deletedAtMs: __t.option(__t.u64()),
|
||||
});
|
||||
export type CustomWorldProfile = __Infer<typeof CustomWorldProfile>;
|
||||
|
||||
export const CustomWorldProfileView = __t.object("CustomWorldProfileView", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()),
|
||||
updatedAtMs: __t.u64(),
|
||||
authorDisplayName: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
playableNpcCount: __t.u32(),
|
||||
landmarkCount: __t.u32(),
|
||||
});
|
||||
export type CustomWorldProfileView = __Infer<typeof CustomWorldProfileView>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `CustomWorldPublicationStatus`.
|
||||
export const CustomWorldPublicationStatus = __t.enum("CustomWorldPublicationStatus", {
|
||||
Draft: __t.unit(),
|
||||
Published: __t.unit(),
|
||||
});
|
||||
export type CustomWorldPublicationStatus = __Infer<typeof CustomWorldPublicationStatus>;
|
||||
|
||||
export const CustomWorldSession = __t.object("CustomWorldSession", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
sessionId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type CustomWorldSession = __Infer<typeof CustomWorldSession>;
|
||||
|
||||
export const CustomWorldSessionView = __t.object("CustomWorldSessionView", {
|
||||
sessionId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type CustomWorldSessionView = __Infer<typeof CustomWorldSessionView>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `CustomWorldThemeMode`.
|
||||
export const CustomWorldThemeMode = __t.enum("CustomWorldThemeMode", {
|
||||
Martial: __t.unit(),
|
||||
Arcane: __t.unit(),
|
||||
Machina: __t.unit(),
|
||||
Tide: __t.unit(),
|
||||
Rift: __t.unit(),
|
||||
Mythic: __t.unit(),
|
||||
});
|
||||
export type CustomWorldThemeMode = __Infer<typeof CustomWorldThemeMode>;
|
||||
|
||||
export const KickEvent = __t.object("KickEvent", {
|
||||
targetIdentity: __t.identity(),
|
||||
reasonCode: __t.string(),
|
||||
message: __t.string(),
|
||||
issuedAtMs: __t.u64(),
|
||||
});
|
||||
export type KickEvent = __Infer<typeof KickEvent>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `LoginProvider`.
|
||||
export const LoginProvider = __t.enum("LoginProvider", {
|
||||
Guest: __t.unit(),
|
||||
Jwt: __t.unit(),
|
||||
Phone: __t.unit(),
|
||||
Wechat: __t.unit(),
|
||||
});
|
||||
export type LoginProvider = __Infer<typeof LoginProvider>;
|
||||
|
||||
export const MutationResult = __t.object("MutationResult", {
|
||||
ok: __t.bool(),
|
||||
kicked: __t.bool(),
|
||||
code: __t.string(),
|
||||
message: __t.string(),
|
||||
});
|
||||
export type MutationResult = __Infer<typeof MutationResult>;
|
||||
|
||||
export const PlatformBrowseHistoryView = __t.object("PlatformBrowseHistoryView", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
authorDisplayName: __t.string(),
|
||||
visitedAtMs: __t.u64(),
|
||||
});
|
||||
export type PlatformBrowseHistoryView = __Infer<typeof PlatformBrowseHistoryView>;
|
||||
|
||||
export const PlatformBrowseHistoryWriteInput = __t.object("PlatformBrowseHistoryWriteInput", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
authorDisplayName: __t.string(),
|
||||
visitedAtMs: __t.u64(),
|
||||
});
|
||||
export type PlatformBrowseHistoryWriteInput = __Infer<typeof PlatformBrowseHistoryWriteInput>;
|
||||
|
||||
export const ProfileDashboardState = __t.object("ProfileDashboardState", {
|
||||
userId: __t.string(),
|
||||
walletBalance: __t.i64(),
|
||||
totalPlayTimeMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type ProfileDashboardState = __Infer<typeof ProfileDashboardState>;
|
||||
|
||||
export const ProfileDashboardView = __t.object("ProfileDashboardView", {
|
||||
walletBalance: __t.i64(),
|
||||
totalPlayTimeMs: __t.u64(),
|
||||
playedWorldCount: __t.u32(),
|
||||
updatedAtMs: __t.option(__t.u64()),
|
||||
});
|
||||
export type ProfileDashboardView = __Infer<typeof ProfileDashboardView>;
|
||||
|
||||
export const ProfilePlayedWorld = __t.object("ProfilePlayedWorld", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
worldKey: __t.string(),
|
||||
ownerUserId: __t.option(__t.string()),
|
||||
profileId: __t.option(__t.string()),
|
||||
worldType: __t.option(__t.string()),
|
||||
worldTitle: __t.string(),
|
||||
worldSubtitle: __t.string(),
|
||||
firstPlayedAtMs: __t.u64(),
|
||||
lastPlayedAtMs: __t.u64(),
|
||||
lastObservedPlayTimeMs: __t.u64(),
|
||||
});
|
||||
export type ProfilePlayedWorld = __Infer<typeof ProfilePlayedWorld>;
|
||||
|
||||
export const ProfilePlayedWorldView = __t.object("ProfilePlayedWorldView", {
|
||||
worldKey: __t.string(),
|
||||
ownerUserId: __t.option(__t.string()),
|
||||
profileId: __t.option(__t.string()),
|
||||
worldType: __t.option(__t.string()),
|
||||
worldTitle: __t.string(),
|
||||
worldSubtitle: __t.string(),
|
||||
firstPlayedAtMs: __t.u64(),
|
||||
lastPlayedAtMs: __t.u64(),
|
||||
lastObservedPlayTimeMs: __t.u64(),
|
||||
});
|
||||
export type ProfilePlayedWorldView = __Infer<typeof ProfilePlayedWorldView>;
|
||||
|
||||
export const ProfileWalletLedger = __t.object("ProfileWalletLedger", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
amountDelta: __t.i64(),
|
||||
balanceAfter: __t.i64(),
|
||||
sourceType: __t.string(),
|
||||
sourceKey: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type ProfileWalletLedger = __Infer<typeof ProfileWalletLedger>;
|
||||
|
||||
export const ProfileWalletLedgerView = __t.object("ProfileWalletLedgerView", {
|
||||
id: __t.string(),
|
||||
amountDelta: __t.i64(),
|
||||
balanceAfter: __t.i64(),
|
||||
sourceType: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type ProfileWalletLedgerView = __Infer<typeof ProfileWalletLedgerView>;
|
||||
|
||||
export const PublishedCustomWorldProfileView = __t.object("PublishedCustomWorldProfileView", {
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
get visibility() {
|
||||
return CustomWorldPublicationStatus;
|
||||
},
|
||||
publishedAtMs: __t.option(__t.u64()),
|
||||
updatedAtMs: __t.u64(),
|
||||
authorDisplayName: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
playableNpcCount: __t.u32(),
|
||||
landmarkCount: __t.u32(),
|
||||
});
|
||||
export type PublishedCustomWorldProfileView = __Infer<typeof PublishedCustomWorldProfileView>;
|
||||
|
||||
export const RequestMeta = __t.object("RequestMeta", {
|
||||
clientType: __t.string(),
|
||||
userAgent: __t.option(__t.string()),
|
||||
ip: __t.option(__t.string()),
|
||||
});
|
||||
export type RequestMeta = __Infer<typeof RequestMeta>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `RiskBlockScopeType`.
|
||||
export const RiskBlockScopeType = __t.enum("RiskBlockScopeType", {
|
||||
Phone: __t.unit(),
|
||||
Ip: __t.unit(),
|
||||
});
|
||||
export type RiskBlockScopeType = __Infer<typeof RiskBlockScopeType>;
|
||||
|
||||
export const RuntimeSetting = __t.object("RuntimeSetting", {
|
||||
userId: __t.string(),
|
||||
musicVolume: __t.f32(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type RuntimeSetting = __Infer<typeof RuntimeSetting>;
|
||||
|
||||
export const RuntimeSettingsView = __t.object("RuntimeSettingsView", {
|
||||
musicVolume: __t.f32(),
|
||||
});
|
||||
export type RuntimeSettingsView = __Infer<typeof RuntimeSettingsView>;
|
||||
|
||||
export const SaveSnapshot = __t.object("SaveSnapshot", {
|
||||
userId: __t.string(),
|
||||
version: __t.u32(),
|
||||
savedAtMs: __t.u64(),
|
||||
bottomTab: __t.string(),
|
||||
gameStateJson: __t.string(),
|
||||
currentStoryJson: __t.option(__t.string()),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type SaveSnapshot = __Infer<typeof SaveSnapshot>;
|
||||
|
||||
export const SessionRevocationEventRow = __t.object("SessionRevocationEventRow", {
|
||||
targetSessionId: __t.string(),
|
||||
reasonCode: __t.string(),
|
||||
message: __t.string(),
|
||||
issuedAtMs: __t.u64(),
|
||||
});
|
||||
export type SessionRevocationEventRow = __Infer<typeof SessionRevocationEventRow>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `SmsAuthAction`.
|
||||
export const SmsAuthAction = __t.enum("SmsAuthAction", {
|
||||
SendCode: __t.unit(),
|
||||
VerifyCode: __t.unit(),
|
||||
});
|
||||
export type SmsAuthAction = __Infer<typeof SmsAuthAction>;
|
||||
|
||||
export const SmsAuthEvent = __t.object("SmsAuthEvent", {
|
||||
id: __t.u64(),
|
||||
identity: __t.identity(),
|
||||
phoneNumber: __t.string(),
|
||||
get scene() {
|
||||
return SmsAuthScene;
|
||||
},
|
||||
get action() {
|
||||
return SmsAuthAction;
|
||||
},
|
||||
success: __t.bool(),
|
||||
ip: __t.option(__t.string()),
|
||||
ipKey: __t.string(),
|
||||
userAgent: __t.option(__t.string()),
|
||||
createdAtMs: __t.u64(),
|
||||
});
|
||||
export type SmsAuthEvent = __Infer<typeof SmsAuthEvent>;
|
||||
|
||||
// The tagged union or sum type for the algebraic type `SmsAuthScene`.
|
||||
export const SmsAuthScene = __t.enum("SmsAuthScene", {
|
||||
Login: __t.unit(),
|
||||
BindPhone: __t.unit(),
|
||||
ChangePhone: __t.unit(),
|
||||
});
|
||||
export type SmsAuthScene = __Infer<typeof SmsAuthScene>;
|
||||
|
||||
export const SmsSendCodeResult = __t.object("SmsSendCodeResult", {
|
||||
ok: __t.bool(),
|
||||
kicked: __t.bool(),
|
||||
code: __t.string(),
|
||||
message: __t.string(),
|
||||
cooldownSeconds: __t.u32(),
|
||||
expiresInSeconds: __t.u32(),
|
||||
providerRequestId: __t.option(__t.string()),
|
||||
});
|
||||
export type SmsSendCodeResult = __Infer<typeof SmsSendCodeResult>;
|
||||
|
||||
export const SmsVerifyCodeResult = __t.object("SmsVerifyCodeResult", {
|
||||
ok: __t.bool(),
|
||||
kicked: __t.bool(),
|
||||
code: __t.string(),
|
||||
message: __t.string(),
|
||||
verified: __t.bool(),
|
||||
});
|
||||
export type SmsVerifyCodeResult = __Infer<typeof SmsVerifyCodeResult>;
|
||||
|
||||
export const SnapshotView = __t.object("SnapshotView", {
|
||||
version: __t.u32(),
|
||||
savedAtMs: __t.u64(),
|
||||
gameStateJson: __t.string(),
|
||||
bottomTab: __t.string(),
|
||||
currentStoryJson: __t.option(__t.string()),
|
||||
});
|
||||
export type SnapshotView = __Infer<typeof SnapshotView>;
|
||||
|
||||
export const User = __t.object("User", {
|
||||
id: __t.string(),
|
||||
identity: __t.identity(),
|
||||
username: __t.option(__t.string()),
|
||||
passwordHash: __t.option(__t.string()),
|
||||
tokenVersion: __t.u32(),
|
||||
displayName: __t.string(),
|
||||
get loginProvider() {
|
||||
return LoginProvider;
|
||||
},
|
||||
get accountStatus() {
|
||||
return AccountStatus;
|
||||
},
|
||||
phoneNumber: __t.option(__t.string()),
|
||||
phoneVerifiedAtMs: __t.option(__t.u64()),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
});
|
||||
export type User = __Infer<typeof User>;
|
||||
|
||||
export const UserBrowseHistory = __t.object("UserBrowseHistory", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
ownerUserId: __t.string(),
|
||||
profileId: __t.string(),
|
||||
worldName: __t.string(),
|
||||
subtitle: __t.string(),
|
||||
summaryText: __t.string(),
|
||||
coverImageSrc: __t.option(__t.string()),
|
||||
get themeMode() {
|
||||
return CustomWorldThemeMode;
|
||||
},
|
||||
authorDisplayName: __t.string(),
|
||||
visitedAtMs: __t.u64(),
|
||||
});
|
||||
export type UserBrowseHistory = __Infer<typeof UserBrowseHistory>;
|
||||
|
||||
export const UserSession = __t.object("UserSession", {
|
||||
id: __t.string(),
|
||||
userId: __t.string(),
|
||||
refreshTokenHash: __t.string(),
|
||||
clientType: __t.string(),
|
||||
userAgent: __t.option(__t.string()),
|
||||
ip: __t.option(__t.string()),
|
||||
expiresAtMs: __t.option(__t.u64()),
|
||||
revokedAtMs: __t.option(__t.u64()),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
lastSeenAtMs: __t.u64(),
|
||||
});
|
||||
export type UserSession = __Infer<typeof UserSession>;
|
||||
|
||||
export const VerificationPromptEvent = __t.object("VerificationPromptEvent", {
|
||||
targetIdentity: __t.identity(),
|
||||
phoneNumberMasked: __t.option(__t.string()),
|
||||
title: __t.string(),
|
||||
detail: __t.string(),
|
||||
issuedAtMs: __t.u64(),
|
||||
});
|
||||
export type VerificationPromptEvent = __Infer<typeof VerificationPromptEvent>;
|
||||
|
||||
55
src/spacetime/generated/types/procedures.ts
Normal file
55
src/spacetime/generated/types/procedures.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import { type Infer as __Infer } from "spacetimedb";
|
||||
|
||||
// Import all procedure arg schemas
|
||||
import * as ClearPlatformBrowseHistoryProcedure from "../clear_platform_browse_history_procedure";
|
||||
import * as DeleteCustomWorldProfileProcedure from "../delete_custom_world_profile_procedure";
|
||||
import * as DeleteSnapshotProcedure from "../delete_snapshot_procedure";
|
||||
import * as LiftMyRiskBlockProcedure from "../lift_my_risk_block_procedure";
|
||||
import * as LogoutAllUserSessionsProcedure from "../logout_all_user_sessions_procedure";
|
||||
import * as PublishCustomWorldProfileProcedure from "../publish_custom_world_profile_procedure";
|
||||
import * as PutRuntimeSettingsProcedure from "../put_runtime_settings_procedure";
|
||||
import * as RevokeUserSessionProcedure from "../revoke_user_session_procedure";
|
||||
import * as SaveSnapshotProcedure from "../save_snapshot_procedure";
|
||||
import * as SendSmsVerificationCodeProcedure from "../send_sms_verification_code_procedure";
|
||||
import * as UnpublishCustomWorldProfileProcedure from "../unpublish_custom_world_profile_procedure";
|
||||
import * as UpsertCustomWorldProfileProcedure from "../upsert_custom_world_profile_procedure";
|
||||
import * as UpsertCustomWorldSessionProcedure from "../upsert_custom_world_session_procedure";
|
||||
import * as UpsertPlatformBrowseHistoryProcedure from "../upsert_platform_browse_history_procedure";
|
||||
import * as VerifySmsCodeProcedure from "../verify_sms_code_procedure";
|
||||
|
||||
export type ClearPlatformBrowseHistoryArgs = __Infer<typeof ClearPlatformBrowseHistoryProcedure.params>;
|
||||
export type ClearPlatformBrowseHistoryResult = __Infer<typeof ClearPlatformBrowseHistoryProcedure.returnType>;
|
||||
export type DeleteCustomWorldProfileArgs = __Infer<typeof DeleteCustomWorldProfileProcedure.params>;
|
||||
export type DeleteCustomWorldProfileResult = __Infer<typeof DeleteCustomWorldProfileProcedure.returnType>;
|
||||
export type DeleteSnapshotArgs = __Infer<typeof DeleteSnapshotProcedure.params>;
|
||||
export type DeleteSnapshotResult = __Infer<typeof DeleteSnapshotProcedure.returnType>;
|
||||
export type LiftMyRiskBlockArgs = __Infer<typeof LiftMyRiskBlockProcedure.params>;
|
||||
export type LiftMyRiskBlockResult = __Infer<typeof LiftMyRiskBlockProcedure.returnType>;
|
||||
export type LogoutAllUserSessionsArgs = __Infer<typeof LogoutAllUserSessionsProcedure.params>;
|
||||
export type LogoutAllUserSessionsResult = __Infer<typeof LogoutAllUserSessionsProcedure.returnType>;
|
||||
export type PublishCustomWorldProfileArgs = __Infer<typeof PublishCustomWorldProfileProcedure.params>;
|
||||
export type PublishCustomWorldProfileResult = __Infer<typeof PublishCustomWorldProfileProcedure.returnType>;
|
||||
export type PutRuntimeSettingsArgs = __Infer<typeof PutRuntimeSettingsProcedure.params>;
|
||||
export type PutRuntimeSettingsResult = __Infer<typeof PutRuntimeSettingsProcedure.returnType>;
|
||||
export type RevokeUserSessionArgs = __Infer<typeof RevokeUserSessionProcedure.params>;
|
||||
export type RevokeUserSessionResult = __Infer<typeof RevokeUserSessionProcedure.returnType>;
|
||||
export type SaveSnapshotArgs = __Infer<typeof SaveSnapshotProcedure.params>;
|
||||
export type SaveSnapshotResult = __Infer<typeof SaveSnapshotProcedure.returnType>;
|
||||
export type SendSmsVerificationCodeArgs = __Infer<typeof SendSmsVerificationCodeProcedure.params>;
|
||||
export type SendSmsVerificationCodeResult = __Infer<typeof SendSmsVerificationCodeProcedure.returnType>;
|
||||
export type UnpublishCustomWorldProfileArgs = __Infer<typeof UnpublishCustomWorldProfileProcedure.params>;
|
||||
export type UnpublishCustomWorldProfileResult = __Infer<typeof UnpublishCustomWorldProfileProcedure.returnType>;
|
||||
export type UpsertCustomWorldProfileArgs = __Infer<typeof UpsertCustomWorldProfileProcedure.params>;
|
||||
export type UpsertCustomWorldProfileResult = __Infer<typeof UpsertCustomWorldProfileProcedure.returnType>;
|
||||
export type UpsertCustomWorldSessionArgs = __Infer<typeof UpsertCustomWorldSessionProcedure.params>;
|
||||
export type UpsertCustomWorldSessionResult = __Infer<typeof UpsertCustomWorldSessionProcedure.returnType>;
|
||||
export type UpsertPlatformBrowseHistoryArgs = __Infer<typeof UpsertPlatformBrowseHistoryProcedure.params>;
|
||||
export type UpsertPlatformBrowseHistoryResult = __Infer<typeof UpsertPlatformBrowseHistoryProcedure.returnType>;
|
||||
export type VerifySmsCodeArgs = __Infer<typeof VerifySmsCodeProcedure.params>;
|
||||
export type VerifySmsCodeResult = __Infer<typeof VerifySmsCodeProcedure.returnType>;
|
||||
|
||||
10
src/spacetime/generated/types/reducers.ts
Normal file
10
src/spacetime/generated/types/reducers.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import { type Infer as __Infer } from "spacetimedb";
|
||||
|
||||
// Import all reducer arg schemas
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
profileId: __t.string(),
|
||||
authorDisplayName: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,26 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
profileId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
authorDisplayName: __t.string(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,27 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
sessionId: __t.string(),
|
||||
payloadJson: __t.string(),
|
||||
createdAtMs: __t.u64(),
|
||||
updatedAtMs: __t.u64(),
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
@@ -0,0 +1,27 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
MutationResult,
|
||||
PlatformBrowseHistoryWriteInput,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
get entries() {
|
||||
return __t.array(PlatformBrowseHistoryWriteInput);
|
||||
},
|
||||
};
|
||||
export const returnType = MutationResult
|
||||
19
src/spacetime/generated/verification_prompt_event_table.ts
Normal file
19
src/spacetime/generated/verification_prompt_event_table.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
targetIdentity: __t.identity().name("target_identity"),
|
||||
phoneNumberMasked: __t.option(__t.string()).name("phone_number_masked"),
|
||||
title: __t.string(),
|
||||
detail: __t.string(),
|
||||
issuedAtMs: __t.u64().name("issued_at_ms"),
|
||||
});
|
||||
25
src/spacetime/generated/verify_sms_code_procedure.ts
Normal file
25
src/spacetime/generated/verify_sms_code_procedure.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
|
||||
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
|
||||
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
import {
|
||||
TypeBuilder as __TypeBuilder,
|
||||
t as __t,
|
||||
type AlgebraicTypeType as __AlgebraicTypeType,
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
import {
|
||||
RequestMeta,
|
||||
SmsVerifyCodeResult,
|
||||
} from "./types";
|
||||
|
||||
export const params = {
|
||||
get meta() {
|
||||
return RequestMeta;
|
||||
},
|
||||
phoneNumber: __t.string(),
|
||||
code: __t.string(),
|
||||
};
|
||||
export const returnType = SmsVerifyCodeResult
|
||||
387
src/spacetime/mappers.ts
Normal file
387
src/spacetime/mappers.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import type {
|
||||
AuthAuditLogEntry,
|
||||
AuthBindingStatus,
|
||||
AuthLoginMethod,
|
||||
AuthRiskBlockSummary,
|
||||
AuthSessionSummary,
|
||||
AuthUser,
|
||||
} from '../../packages/shared/src/contracts/auth';
|
||||
import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
PlatformBrowseHistoryEntry,
|
||||
ProfileDashboardSummary,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfileWalletLedgerEntry,
|
||||
RuntimeSettings,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type { SavedGameSnapshot } from '../persistence/gameSaveStorage';
|
||||
import type { CustomWorldProfile } from '../types';
|
||||
import type {
|
||||
AuthAuditLogView,
|
||||
AuthRiskBlockView,
|
||||
AuthStateView,
|
||||
AuthSessionView,
|
||||
ClientAppConfigView,
|
||||
CustomWorldGalleryCardView,
|
||||
CustomWorldProfileView,
|
||||
CustomWorldSessionView,
|
||||
PlatformBrowseHistoryView,
|
||||
ProfileDashboardView,
|
||||
ProfilePlayedWorldView,
|
||||
ProfileWalletLedgerView,
|
||||
PublishedCustomWorldProfileView,
|
||||
RuntimeSettingsView,
|
||||
SnapshotView,
|
||||
} from './generated/types';
|
||||
|
||||
function bigintToNumber(value: bigint | number | null | undefined) {
|
||||
if (typeof value === 'bigint') {
|
||||
return Number(value);
|
||||
}
|
||||
return typeof value === 'number' ? value : 0;
|
||||
}
|
||||
|
||||
function bigintToIso(value: bigint | number | null | undefined) {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(bigintToNumber(value)).toISOString();
|
||||
}
|
||||
|
||||
function enumTag(value: { tag: string } | null | undefined) {
|
||||
return value?.tag ?? '';
|
||||
}
|
||||
|
||||
function mapLoginMethod(tag: string): AuthLoginMethod {
|
||||
switch (tag) {
|
||||
case 'Phone':
|
||||
return 'phone';
|
||||
case 'Wechat':
|
||||
return 'wechat';
|
||||
case 'Jwt':
|
||||
return 'jwt';
|
||||
case 'Guest':
|
||||
return 'guest';
|
||||
default:
|
||||
return 'guest';
|
||||
}
|
||||
}
|
||||
|
||||
function mapBindingStatus(row: AuthStateView): AuthBindingStatus {
|
||||
return row.smsVerificationRequired && !row.smsVerified
|
||||
? 'pending_bind_phone'
|
||||
: 'active';
|
||||
}
|
||||
|
||||
export function mapAuthUser(row: AuthStateView): AuthUser {
|
||||
return {
|
||||
id: row.userId,
|
||||
username: row.displayName,
|
||||
displayName: row.displayName,
|
||||
phoneNumberMasked: row.phoneNumberMasked ?? null,
|
||||
loginMethod: mapLoginMethod(enumTag(row.loginProvider)),
|
||||
bindingStatus: mapBindingStatus(row),
|
||||
wechatBound: enumTag(row.loginProvider) === 'Wechat',
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAvailableLoginMethods(
|
||||
config: ClientAppConfigView | null,
|
||||
): AuthLoginMethod[] {
|
||||
const methods: AuthLoginMethod[] = [];
|
||||
if (config?.smsAuthEnabled) {
|
||||
methods.push('phone');
|
||||
}
|
||||
if (config?.wechatEnabled) {
|
||||
methods.push('wechat');
|
||||
}
|
||||
return methods;
|
||||
}
|
||||
|
||||
function mapThemeMode(tag: string): CustomWorldLibraryEntry['themeMode'] {
|
||||
switch (tag) {
|
||||
case 'Martial':
|
||||
return 'martial';
|
||||
case 'Arcane':
|
||||
return 'arcane';
|
||||
case 'Machina':
|
||||
return 'machina';
|
||||
case 'Tide':
|
||||
return 'tide';
|
||||
case 'Rift':
|
||||
return 'rift';
|
||||
default:
|
||||
return 'mythic';
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson<T>(jsonText: string, fallback: T): T {
|
||||
try {
|
||||
return JSON.parse(jsonText) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function mapSnapshotRow(row: SnapshotView): SavedGameSnapshot {
|
||||
return {
|
||||
version: Number(row.version),
|
||||
savedAt: bigintToIso(row.savedAtMs) ?? new Date(0).toISOString(),
|
||||
gameState: parseJson(row.gameStateJson, {}),
|
||||
bottomTab: row.bottomTab,
|
||||
currentStory: row.currentStoryJson
|
||||
? parseJson(row.currentStoryJson, null)
|
||||
: null,
|
||||
} as SavedGameSnapshot;
|
||||
}
|
||||
|
||||
export function mapRuntimeSettings(row: RuntimeSettingsView | null): RuntimeSettings {
|
||||
return {
|
||||
musicVolume: row ? Number(row.musicVolume) : 0.42,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapProfileDashboard(
|
||||
row: ProfileDashboardView | null,
|
||||
): ProfileDashboardSummary {
|
||||
return {
|
||||
walletBalance: row ? bigintToNumber(row.walletBalance) : 0,
|
||||
totalPlayTimeMs: row ? bigintToNumber(row.totalPlayTimeMs) : 0,
|
||||
playedWorldCount: row ? Number(row.playedWorldCount) : 0,
|
||||
updatedAt: row ? bigintToIso(row.updatedAtMs) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWalletLedgerEntry(
|
||||
row: ProfileWalletLedgerView,
|
||||
): ProfileWalletLedgerEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
amountDelta: bigintToNumber(row.amountDelta),
|
||||
balanceAfter: bigintToNumber(row.balanceAfter),
|
||||
sourceType: 'snapshot_sync',
|
||||
createdAt: bigintToIso(row.createdAtMs) ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPlayedWorldEntry(
|
||||
row: ProfilePlayedWorldView,
|
||||
): ProfilePlayedWorkSummary {
|
||||
return {
|
||||
worldKey: row.worldKey,
|
||||
ownerUserId: row.ownerUserId ?? null,
|
||||
profileId: row.profileId ?? null,
|
||||
worldType: row.worldType ?? null,
|
||||
worldTitle: row.worldTitle,
|
||||
worldSubtitle: row.worldSubtitle,
|
||||
firstPlayedAt: bigintToIso(row.firstPlayedAtMs) ?? new Date(0).toISOString(),
|
||||
lastPlayedAt: bigintToIso(row.lastPlayedAtMs) ?? new Date(0).toISOString(),
|
||||
lastObservedPlayTimeMs: bigintToNumber(row.lastObservedPlayTimeMs),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapBrowseHistoryEntry(
|
||||
row: PlatformBrowseHistoryView,
|
||||
): PlatformBrowseHistoryEntry {
|
||||
return {
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
worldName: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summaryText: row.summaryText,
|
||||
coverImageSrc: row.coverImageSrc ?? null,
|
||||
themeMode: mapThemeMode(enumTag(row.themeMode)),
|
||||
authorDisplayName: row.authorDisplayName,
|
||||
visitedAt: bigintToIso(row.visitedAtMs) ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapCustomWorldLibraryEntry(
|
||||
row: CustomWorldProfileView,
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return {
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
profile: parseJson<CustomWorldProfile>(row.payloadJson, {
|
||||
id: row.profileId,
|
||||
settingText: '',
|
||||
name: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summary: row.summaryText,
|
||||
tone: '',
|
||||
playerGoal: '',
|
||||
templateWorldType: 'WUXIA',
|
||||
majorFactions: [],
|
||||
coreConflicts: [],
|
||||
attributeSchema: {
|
||||
dimensions: [],
|
||||
coreStats: [],
|
||||
derivedStats: [],
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
landmarks: [],
|
||||
} as unknown as CustomWorldProfile),
|
||||
visibility: enumTag(row.visibility) === 'Published' ? 'published' : 'draft',
|
||||
publishedAt: bigintToIso(row.publishedAtMs),
|
||||
updatedAt: bigintToIso(row.updatedAtMs) ?? new Date(0).toISOString(),
|
||||
authorDisplayName: row.authorDisplayName,
|
||||
worldName: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summaryText: row.summaryText,
|
||||
coverImageSrc: row.coverImageSrc ?? null,
|
||||
themeMode: mapThemeMode(enumTag(row.themeMode)),
|
||||
playableNpcCount: Number(row.playableNpcCount),
|
||||
landmarkCount: Number(row.landmarkCount),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapGalleryCard(
|
||||
row: CustomWorldGalleryCardView,
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
visibility: enumTag(row.visibility) === 'Published' ? 'published' : 'draft',
|
||||
publishedAt: bigintToIso(row.publishedAtMs),
|
||||
updatedAt: bigintToIso(row.updatedAtMs) ?? new Date(0).toISOString(),
|
||||
authorDisplayName: row.authorDisplayName,
|
||||
worldName: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summaryText: row.summaryText,
|
||||
coverImageSrc: row.coverImageSrc ?? null,
|
||||
themeMode: mapThemeMode(enumTag(row.themeMode)),
|
||||
playableNpcCount: Number(row.playableNpcCount),
|
||||
landmarkCount: Number(row.landmarkCount),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPublishedProfile(
|
||||
row: PublishedCustomWorldProfileView,
|
||||
): CustomWorldLibraryEntry<CustomWorldProfile> {
|
||||
return mapCustomWorldLibraryEntry({
|
||||
ownerUserId: row.ownerUserId,
|
||||
profileId: row.profileId,
|
||||
payloadJson: row.payloadJson,
|
||||
visibility: row.visibility,
|
||||
publishedAtMs: row.publishedAtMs,
|
||||
updatedAtMs: row.updatedAtMs,
|
||||
authorDisplayName: row.authorDisplayName,
|
||||
worldName: row.worldName,
|
||||
subtitle: row.subtitle,
|
||||
summaryText: row.summaryText,
|
||||
coverImageSrc: row.coverImageSrc,
|
||||
themeMode: row.themeMode,
|
||||
playableNpcCount: row.playableNpcCount,
|
||||
landmarkCount: row.landmarkCount,
|
||||
});
|
||||
}
|
||||
|
||||
export function mapCustomWorldSession(
|
||||
row: CustomWorldSessionView,
|
||||
) {
|
||||
return {
|
||||
...parseJson<Record<string, unknown>>(row.payloadJson, {}),
|
||||
sessionId: row.sessionId,
|
||||
createdAt: bigintToIso(row.createdAtMs) ?? new Date(0).toISOString(),
|
||||
updatedAt: bigintToIso(row.updatedAtMs) ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAuditLogEntry(row: AuthAuditLogView): AuthAuditLogEntry {
|
||||
const eventType = row.eventType as AuthAuditLogEntry['eventType'];
|
||||
return {
|
||||
id: String(bigintToNumber(row.id)),
|
||||
eventType,
|
||||
title: row.eventType,
|
||||
detail: row.detail,
|
||||
ipMasked: row.ip ?? null,
|
||||
userAgent: row.userAgent ?? null,
|
||||
createdAt: bigintToIso(row.createdAtMs) ?? new Date(0).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function maskIpAddress(ip: string | null | undefined) {
|
||||
if (!ip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (ip.includes(':')) {
|
||||
const parts = ip.split(':').filter(Boolean);
|
||||
if (parts.length <= 2) {
|
||||
return ip;
|
||||
}
|
||||
return `${parts.slice(0, 2).join(':')}::*`;
|
||||
}
|
||||
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) {
|
||||
return ip;
|
||||
}
|
||||
return `${parts[0]}.${parts[1]}.*.*`;
|
||||
}
|
||||
|
||||
function buildSessionClientLabel(session: {
|
||||
clientType: string;
|
||||
userAgent: string | null | undefined;
|
||||
}) {
|
||||
const userAgent = session.userAgent?.toLowerCase() || '';
|
||||
if (
|
||||
userAgent.includes('mobile') ||
|
||||
userAgent.includes('android') ||
|
||||
userAgent.includes('iphone')
|
||||
) {
|
||||
return '移动端浏览器';
|
||||
}
|
||||
if (session.clientType === 'web' || session.clientType === 'browser') {
|
||||
return '网页端浏览器';
|
||||
}
|
||||
return session.clientType || '未知设备';
|
||||
}
|
||||
|
||||
export function mapAuthSession(
|
||||
row: AuthSessionView,
|
||||
options: {
|
||||
currentSessionId?: string;
|
||||
} = {},
|
||||
): AuthSessionSummary {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
clientType: row.clientType,
|
||||
clientLabel: buildSessionClientLabel({
|
||||
clientType: row.clientType,
|
||||
userAgent: row.userAgent ?? null,
|
||||
}),
|
||||
userAgent: row.userAgent ?? null,
|
||||
ipMasked: maskIpAddress(row.ip ?? null),
|
||||
isCurrent:
|
||||
options.currentSessionId?.trim() === row.sessionId ||
|
||||
Boolean(row.isCurrent),
|
||||
createdAt: bigintToIso(row.createdAtMs) ?? '',
|
||||
lastSeenAt: bigintToIso(row.lastSeenAtMs) ?? '',
|
||||
expiresAt: bigintToIso(row.expiresAtMs) ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAuthRiskBlock(row: AuthRiskBlockView): AuthRiskBlockSummary {
|
||||
const scopeType = enumTag(row.scopeType) === 'Phone' ? 'phone' : 'ip';
|
||||
const expiresAtMs = bigintToNumber(row.expiresAtMs);
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
Math.floor((expiresAtMs - Date.now()) / 1000),
|
||||
);
|
||||
|
||||
return {
|
||||
scopeType,
|
||||
title: scopeType === 'phone' ? '手机号保护中' : 'IP 保护中',
|
||||
detail:
|
||||
scopeType === 'phone'
|
||||
? `当前手机号因 ${row.reason} 被暂时保护`
|
||||
: `当前连接因 ${row.reason} 被暂时保护`,
|
||||
expiresAt: bigintToIso(row.expiresAtMs) ?? '',
|
||||
remainingSeconds,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user