Files
Genarrative/apps/ai-game-creator-shell/src/services/platformSession.ts
T
kdletters 2449e77461
Project CI / Repository checks (push) Successful in 4m24s
Project CI / Frontend tests (push) Successful in 5m10s
Project CI / Backend tests (push) Successful in 5m37s
Project CI / Native shell tests (push) Successful in 13m9s
修复切换账号后画布资源不可编辑 (#182)
## 变更

- 新增按服务 origin、平台 userId / Developer Key 摘要和本地 projectId 分区的 External Editor 项目绑定
- 新增按当前 principal、远端项目、本地 assetId、源 SHA-256、媒体类型和 canonical kind 分区的资源绑定
- manifest 中的 canvasProjectId / resourceId / assetObjectId 仅保留来源信息,不再作为当前账号的可编辑授权
- 切换账号后从本地正式资源重新上传、confirm、登记;图片、视频、角色动画、素材画布参考、art-spec 派生和 Direct 恢复统一使用当前账号绑定
- prepared / accepted / running 账本继续冻结原 principal;账号变化或远端结果不确定时停止补偿并保留现场等待对账
- 同步技术方案、decision log 和 pitfalls

## Review 结论

- 两路独立代码 review 均未发现剩余 P0-P2
- Review 发现并关闭了 max-pass 测试误放宽问题,恢复为绑定最大轮次的强断言
- 首轮 CI 暴露两处本 PR import 排序错误,已在独立提交 09486f142 中修复并复核

## 验证

- Rust 完整测试:2301 passed,0 failed,16 ignored
- Rust 集成与构建测试:5 + 2 + 14 passed
- repository-ci 本地同构门禁通过:lint、typecheck、139 表 SpacetimeDB schema guard、403 个 appSurface 测试、web/admin-web build
- External Editor procedure 真实 smoke 通过:精确重放、冲突、删除 fail-close、并发和孤儿检查
- Encoding check:5594 files
- git diff --check 通过
- Gitea Project CI run 1253:Repository checks、Frontend、Backend、Native shell tests 全部通过
- 当前 head a0b8415be 已合并 origin/master 44ee28c43,PR 无冲突

## 后续依赖

PR #176 暴露了这一公共账号身份缺陷。该 PR 合并后,#176 需要 rebase,并删除或接入其局部 canonical cache,不能保留第二套账号绑定系统。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/182
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-08-24 11:33:37 +08:00

416 lines
13 KiB
TypeScript

import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri';
import {
getCurrentClientAuthUser,
getStoredAuthAccessToken,
refreshClientAuthAccessToken,
} from './clientAuth';
import { getClientServerBaseUrl } from './clientHttp';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
type CommittedPlatformSession = {
user: AuthUser;
accessToken: string;
apiBaseUrl: string;
generation: number;
};
export type PlatformSessionRefreshResult =
| { status: 'refreshed'; user: AuthUser; generation: number }
| { status: 'stale' }
| { status: 'failed'; error: unknown };
type PlatformSessionRefreshListener = (
result: PlatformSessionRefreshResult,
) => void;
type PlatformSessionGenerationListener = (generation: number) => void;
let platformAuthGeneration = 0;
let platformNativeGeneration = 0;
let platformNativeGenerationFloorPromise: Promise<number> | null = null;
let committedPlatformSession: CommittedPlatformSession | null = null;
let desiredPlatformSession: CommittedPlatformSession | null = null;
let platformSessionRefreshPromise: Promise<PlatformSessionRefreshResult> | null =
null;
let platformSessionNativeMutationTail: Promise<void> = Promise.resolve();
const platformSessionRefreshListeners =
new Set<PlatformSessionRefreshListener>();
const platformSessionGenerationListeners =
new Set<PlatformSessionGenerationListener>();
function restoreCommittedAccessToken() {
if (committedPlatformSession?.accessToken) {
window.localStorage.setItem(
ACCESS_TOKEN_STORAGE_KEY,
committedPlatformSession.accessToken,
);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
function restoreCurrentRendererAccessToken() {
if (!desiredPlatformSession) {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
return;
}
restoreCommittedAccessToken();
}
function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) {
for (const listener of platformSessionRefreshListeners) {
listener(result);
}
}
function notifyPlatformSessionGeneration() {
for (const listener of platformSessionGenerationListeners) {
listener(platformAuthGeneration);
}
}
async function installNativePlatformSession(
session: CommittedPlatformSession,
generation: number,
) {
const invoke = resolveTauriInvoke();
if (!invoke) return;
await invoke('install_platform_account_session', {
userId: session.user.id,
accessToken: session.accessToken,
apiBaseUrl: session.apiBaseUrl,
generation,
});
}
async function clearNativePlatformSession(generation: number) {
const invoke = resolveTauriInvoke();
if (!invoke) return;
await invoke('clear_platform_account_session', { generation });
}
function enqueuePlatformSessionNativeMutation<T>(
operation: () => Promise<T>,
): Promise<T> {
const pending = platformSessionNativeMutationTail
.catch(() => undefined)
.then(operation);
platformSessionNativeMutationTail = pending.then(
() => undefined,
() => undefined,
);
return pending;
}
async function readNativePlatformSessionGenerationFloor() {
const invoke = resolveTauriInvoke();
if (!invoke) return 0;
const floor = await invoke<number | null>(
'read_platform_account_session_generation',
);
// Browser/unit-test adapters commonly expose a no-op invoke that returns null
// for native-only read commands. They have no surviving Rust generation floor.
if (floor === null) return 0;
if (!Number.isSafeInteger(floor) || floor < 0) {
throw new Error('本地运行时登录态 generation 无效,请重启客户端后重试');
}
return floor;
}
async function reserveNativePlatformSessionGeneration() {
platformNativeGenerationFloorPromise ??=
readNativePlatformSessionGenerationFloor();
const nativeGenerationFloor = await platformNativeGenerationFloorPromise;
platformNativeGeneration = Math.max(
platformNativeGeneration + 1,
platformAuthGeneration,
nativeGenerationFloor + 1,
);
return platformNativeGeneration;
}
async function reconcileNativePlatformSessionToCurrentAuthority() {
for (;;) {
const authoritativeGeneration = platformAuthGeneration;
const authoritativeSession = desiredPlatformSession
? { ...desiredPlatformSession }
: null;
const reconciliationGeneration =
await reserveNativePlatformSessionGeneration();
restoreCurrentRendererAccessToken();
try {
if (authoritativeSession) {
await installNativePlatformSession(
authoritativeSession,
reconciliationGeneration,
);
} else {
await clearNativePlatformSession(reconciliationGeneration);
}
} catch (error) {
if (platformAuthGeneration === authoritativeGeneration) {
committedPlatformSession = null;
desiredPlatformSession = null;
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
}
throw error;
}
if (platformAuthGeneration !== authoritativeGeneration) {
continue;
}
committedPlatformSession = authoritativeSession
? {
...authoritativeSession,
generation: authoritativeGeneration,
}
: null;
desiredPlatformSession = committedPlatformSession
? { ...committedPlatformSession }
: null;
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
return;
}
}
function resolvePlatformApiBaseUrl() {
return getClientServerBaseUrl();
}
async function commitPlatformSession(
user: AuthUser,
accessToken: string,
apiBaseUrl: string,
expectedGeneration: number,
): Promise<CommittedPlatformSession | null> {
if (platformAuthGeneration !== expectedGeneration) {
restoreCurrentRendererAccessToken();
return null;
}
const candidate: CommittedPlatformSession = {
user,
accessToken,
apiBaseUrl,
generation: expectedGeneration + 1,
};
// Reserve a new generation so older refresh/login work becomes stale, but keep the previous
// committed identity authoritative until Rust and Runner have accepted the candidate.
platformAuthGeneration = candidate.generation;
desiredPlatformSession = { ...candidate };
notifyPlatformSessionGeneration();
restoreCommittedAccessToken();
const nativeGeneration = await reserveNativePlatformSessionGeneration();
try {
await installNativePlatformSession(candidate, nativeGeneration);
} catch (error) {
if (platformAuthGeneration === candidate.generation) {
desiredPlatformSession = committedPlatformSession
? { ...committedPlatformSession }
: null;
}
await reconcileNativePlatformSessionToCurrentAuthority();
if (platformAuthGeneration !== candidate.generation) return null;
throw error;
}
if (platformAuthGeneration !== candidate.generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
return null;
}
committedPlatformSession = candidate;
desiredPlatformSession = { ...candidate };
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
return candidate;
}
export function currentPlatformSessionGeneration() {
return platformAuthGeneration;
}
export function currentPlatformSessionApiBaseUrl() {
return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl();
}
export function beginPlatformSessionTransition() {
platformAuthGeneration += 1;
desiredPlatformSession = committedPlatformSession
? { ...committedPlatformSession }
: null;
notifyPlatformSessionGeneration();
return platformAuthGeneration;
}
export function beginPlatformSessionClearTransition() {
platformAuthGeneration += 1;
desiredPlatformSession = null;
notifyPlatformSessionGeneration();
return platformAuthGeneration;
}
export async function commitAuthenticatedPlatformSession(
user: AuthUser,
expectedGeneration: number,
apiBaseUrl = resolvePlatformApiBaseUrl(),
) {
const accessToken = getStoredAuthAccessToken();
if (!accessToken) {
throw new Error('陶泥儿登录凭据缺失,请重新登录');
}
return enqueuePlatformSessionNativeMutation(async () => {
const session = await commitPlatformSession(
user,
accessToken,
apiBaseUrl,
expectedGeneration,
);
if (!session) return null;
return session.generation;
});
}
export async function refreshPlatformSessionForGeneration(
expectedGeneration: number,
apiBaseUrl = resolvePlatformApiBaseUrl(),
) {
const token = await refreshClientAuthAccessToken(apiBaseUrl);
if (platformAuthGeneration !== expectedGeneration) {
restoreCurrentRendererAccessToken();
return null;
}
return token;
}
export function requestPlatformSessionRefresh(expectedUserId?: string) {
if (platformSessionRefreshPromise) return platformSessionRefreshPromise;
const expectedGeneration = platformAuthGeneration;
const apiBaseUrl =
committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl();
const expectedSessionUserId =
expectedUserId?.trim() || committedPlatformSession?.user.id || '';
platformSessionRefreshPromise =
(async (): Promise<PlatformSessionRefreshResult> => {
try {
const refreshed = await refreshPlatformSessionForGeneration(
expectedGeneration,
apiBaseUrl,
);
if (!refreshed) return { status: 'stale' };
const user = await getCurrentClientAuthUser(apiBaseUrl);
if (
platformAuthGeneration !== expectedGeneration ||
!user ||
(expectedSessionUserId && user.id !== expectedSessionUserId)
) {
restoreCurrentRendererAccessToken();
return { status: 'stale' };
}
const committedGeneration = await commitAuthenticatedPlatformSession(
user,
expectedGeneration,
apiBaseUrl,
);
if (committedGeneration === null) return { status: 'stale' };
return {
status: 'refreshed',
user,
generation: committedGeneration,
};
} catch (error) {
if (platformAuthGeneration !== expectedGeneration) {
restoreCurrentRendererAccessToken();
return { status: 'stale' };
}
let failure = error;
const currentOwnerUserId = committedPlatformSession?.user.id || '';
if (
currentOwnerUserId &&
currentOwnerUserId !== expectedSessionUserId
) {
restoreCurrentRendererAccessToken();
return { status: 'stale' };
}
if (
!currentOwnerUserId ||
currentOwnerUserId === expectedSessionUserId
) {
const clearGeneration = beginPlatformSessionClearTransition();
try {
await clearCommittedPlatformSession(clearGeneration);
} catch (clearError) {
failure = clearError;
}
}
return { status: 'failed', error: failure };
}
})().then((result) => {
notifyPlatformSessionRefresh(result);
return result;
});
platformSessionRefreshPromise.finally(() => {
platformSessionRefreshPromise = null;
});
return platformSessionRefreshPromise;
}
export function subscribePlatformSessionRefresh(
listener: PlatformSessionRefreshListener,
) {
platformSessionRefreshListeners.add(listener);
return () => {
platformSessionRefreshListeners.delete(listener);
};
}
export function subscribePlatformSessionGeneration(
listener: PlatformSessionGenerationListener,
) {
platformSessionGenerationListeners.add(listener);
return () => {
platformSessionGenerationListeners.delete(listener);
};
}
export async function clearCommittedPlatformSession(generation: number) {
return enqueuePlatformSessionNativeMutation(async () => {
if (platformAuthGeneration !== generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
return;
}
desiredPlatformSession = null;
const nativeGeneration = await reserveNativePlatformSessionGeneration();
try {
await clearNativePlatformSession(nativeGeneration);
} catch {
if (platformAuthGeneration === generation) {
desiredPlatformSession = null;
}
await reconcileNativePlatformSessionToCurrentAuthority();
return;
}
if (platformAuthGeneration !== generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
return;
}
committedPlatformSession = null;
desiredPlatformSession = null;
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
});
}
export function resetPlatformSessionStateForTests() {
platformAuthGeneration = 0;
platformNativeGeneration = 0;
platformNativeGenerationFloorPromise = null;
committedPlatformSession = null;
desiredPlatformSession = null;
platformSessionRefreshPromise = null;
platformSessionNativeMutationTail = Promise.resolve();
platformSessionRefreshListeners.clear();
platformSessionGenerationListeners.clear();
}