Files
Genarrative/apps/ai-game-creator-shell/src/services/clientAuth.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

294 lines
8.1 KiB
TypeScript

import type {
AuthEntryRequest,
AuthEntryResponse,
AuthMeResponse,
AuthPhoneLoginRequest,
AuthPhoneLoginResponse,
AuthPhoneNumberInput,
AuthPhoneSendCodeRequest,
AuthPhoneSendCodeResponse,
AuthRefreshResponse,
LogoutResponse,
} from '../../../../packages/shared/src/contracts/auth';
import {
API_RESPONSE_ENVELOPE_HEADER,
API_RESPONSE_ENVELOPE_VERSION,
unwrapApiResponse,
} from '../../../../packages/shared/src/http';
import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
export function normalizeAuthPhoneInput(phone: string) {
const compactPhone = phone.replace(/[^\d+]/gu, '').trim();
const mainlandChinaInternationalPhone =
compactPhone.match(/^\+?86(1\d{10})$/u);
return mainlandChinaInternationalPhone?.[1] ?? compactPhone;
}
function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput {
return {
countryCode: '86',
purePhoneNumber: normalizeAuthPhoneInput(phone),
};
}
export function getStoredAuthAccessToken() {
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
}
function setStoredAuthAccessToken(token: string) {
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
const clientAuthRefreshPromises = new Map<string, Promise<string>>();
const CLIENT_AUTH_NETWORK_ERROR_MESSAGE =
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试';
function getClientAuthNetworkErrorMessage(error: unknown) {
const detail =
error instanceof Error ? error.message.trim() : String(error).trim();
if (/timed? ?out|timeout|超时/iu.test(detail)) {
return '无法连接登录服务:连接超时,请检查服务器地址和网络后重试';
}
if (/econnrefused|connection refused|拒绝连接/iu.test(detail)) {
return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口';
}
if (/dns|resolve|name or service not known|无法解析/iu.test(detail)) {
return '无法连接登录服务:服务器地址无法解析,请检查服务器选择';
}
if (/certificate|tls|ssl|证书/iu.test(detail)) {
return '无法连接登录服务:安全连接失败,请检查服务器地址和证书';
}
return CLIENT_AUTH_NETWORK_ERROR_MESSAGE;
}
class ClientAuthRequestError extends Error {
readonly status: number | null;
readonly networkError: boolean;
constructor(
message: string,
options: { status?: number | null; networkError?: boolean } = {},
) {
super(message);
this.name = 'ClientAuthRequestError';
this.status = options.status ?? null;
this.networkError = options.networkError ?? false;
}
}
function isClientAuthUnauthorizedError(error: unknown) {
return (
error instanceof ClientAuthRequestError &&
(error.status === 401 || error.status === 403)
);
}
export function isClientAuthRecoverableCheckError(error: unknown) {
return !isClientAuthUnauthorizedError(error);
}
export function getClientAuthErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
async function readAuthErrorMessage(response: Response, fallback: string) {
const text = await response.text();
if (!text.trim()) {
return fallback;
}
let parsed: unknown;
try {
parsed = JSON.parse(text) as unknown;
} catch {
return fallback;
}
try {
unwrapApiResponse(parsed);
} catch (error) {
return error instanceof Error ? error.message : fallback;
}
return fallback;
}
async function requestAuthJson<T>(
url: string,
init: RequestInit,
fallbackMessage: string,
options: { skipAuth?: boolean; apiBaseUrl?: string } = {},
) {
const headers = new Headers(init.headers);
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
if (!options.skipAuth) {
const token = getStoredAuthAccessToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
}
let response: Response;
try {
response = await fetchClientHttp(
url,
{
...init,
credentials: 'same-origin',
headers,
},
{ serverBaseUrl: options.apiBaseUrl },
);
} catch (error) {
throw new ClientAuthRequestError(getClientAuthNetworkErrorMessage(error), {
networkError: true,
});
}
if (!response.ok) {
throw new ClientAuthRequestError(
await readAuthErrorMessage(response, fallbackMessage),
{ status: response.status },
);
}
const text = await response.text();
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
}
export async function getCurrentClientAuthUser(
apiBaseUrl = getClientServerBaseUrl(),
) {
const response = await requestAuthJson<AuthMeResponse>(
'/api/auth/me',
{ method: 'GET' },
'读取当前用户失败',
{ apiBaseUrl },
);
return response.user;
}
export async function refreshClientAuthAccessToken(
apiBaseUrl = getClientServerBaseUrl(),
) {
const current = clientAuthRefreshPromises.get(apiBaseUrl);
if (current) return current;
const refreshPromise = requestAuthJson<AuthRefreshResponse>(
'/api/auth/refresh',
{ method: 'POST' },
'刷新登录状态失败',
{ skipAuth: true, apiBaseUrl },
)
.then((response) => {
setStoredAuthAccessToken(response.token);
return response.token;
})
.finally(() => {
if (clientAuthRefreshPromises.get(apiBaseUrl) === refreshPromise) {
clientAuthRefreshPromises.delete(apiBaseUrl);
}
});
clientAuthRefreshPromises.set(apiBaseUrl, refreshPromise);
return refreshPromise;
}
export async function loginClientWithPassword(
phone: string,
password: string,
apiBaseUrl = getClientServerBaseUrl(),
) {
const request: AuthEntryRequest = {
...buildClientAuthPhoneInput(phone),
password: password.trim(),
};
const response = await requestAuthJson<AuthEntryResponse>(
'/api/auth/entry',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
},
'登录失败',
{ skipAuth: true, apiBaseUrl },
);
setStoredAuthAccessToken(response.token);
return response.user;
}
export async function sendClientPhoneLoginCode(
phone: string,
apiBaseUrl = getClientServerBaseUrl(),
) {
const request: AuthPhoneSendCodeRequest = {
...buildClientAuthPhoneInput(phone),
scene: 'login',
};
return requestAuthJson<AuthPhoneSendCodeResponse>(
'/api/auth/phone/send-code',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
},
'发送验证码失败',
{ skipAuth: true, apiBaseUrl },
);
}
export async function loginClientWithPhoneCode(
phone: string,
code: string,
apiBaseUrl = getClientServerBaseUrl(),
) {
const request: AuthPhoneLoginRequest = {
...buildClientAuthPhoneInput(phone),
code: code.trim(),
};
const response = await requestAuthJson<AuthPhoneLoginResponse>(
'/api/auth/phone/login',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
},
'登录失败',
{ skipAuth: true, apiBaseUrl },
);
setStoredAuthAccessToken(response.token);
return response.user;
}
export async function logoutClientAuthSession(
apiBaseUrl = getClientServerBaseUrl(),
) {
try {
if (!getStoredAuthAccessToken()) {
await refreshClientAuthAccessToken(apiBaseUrl).catch(() => '');
}
try {
await requestAuthJson<LogoutResponse>(
'/api/auth/logout',
{ method: 'POST' },
'退出登录失败',
{ apiBaseUrl },
);
} catch {
await refreshClientAuthAccessToken(apiBaseUrl).catch(() => '');
await requestAuthJson<LogoutResponse>(
'/api/auth/logout',
{ method: 'POST' },
'退出登录失败',
{ apiBaseUrl },
);
}
} finally {
clearStoredAuthAccessToken();
}
}