Files
model-platform/frontend/app/context/AuthContext.tsx
T
tao.chenandtao.chen 96aaa7bb64 fix(scripts): AuthContext binding + load() cache invalidation (Codex review)
Codex (deepseek-v4-flash) review of feat(scripts) parent_path filter
surfaced four problems:

1. Critical — AuthContext listScripts binding silently dropped parentPath
   - frontend/app/context/AuthContext.tsx:224 had:
       listScripts: () => rawApi.listScripts(workspaceId)
     so loadScripts("foo/bar") hit GET /api/v1/scripts with no query and
     always received root-level scripts. Typecheck passed because
     `() => ...` is assignable to `(parentPath?: string) => ...`.
   - Fix: forward the parentPath argument.

2. High — load() cache invalidation gap on toolbar refresh / create / delete
   - load() replaced `scripts` with root-only items but never invalidated
     `loadedScriptPaths` for subfolders, so previously-expanded folders
     rendered empty (cached no-op on re-expand) and open tabs pointing
     into subfolders were dropped by the validIds filter.
   - Fix: load() now re-fetches every path currently in
     loadedScriptPaths (and loadedChildPaths for directories), then
     dedups by id/path. On initial mount the cache is empty so this
     degrades to a single root fetch.

3. Low — test docstring overclaimed coverage
   - The "actual ORM roundtrip is covered by the existing integration
     tests" line is false (no other list_scripts test exists).
   - Fix: honest docstring noting the repo has no endpoint integration
     test layer; SQL assertions are brittle to SQLAlchemy/dialect
     formatting.

4. Low — backend docstring overclaimed index role
   - Claimed `idx_storage_workspace_relative_path` "avoided全表扫", but
     the index isn't declared in the ORM model, only exists via the
     baseline migration's upgrade path, and EXPLAIN doesn't drive
     through it (scripts-first plan via idx_scripts_workspace).
   - Fix: accurate description — MySQL drives via idx_scripts_workspace,
     storage_objects PK lookup applies LIKE per row. Notes where to
     optimize if 10万-scale perf becomes a real problem.

Skipped findings (out of scope):
- Medium LIKE escape for `_` / `%` (pre-existing in
  list_workspace_directories; not introduced here).
- Low dashboard `scripts.length` count underreport (separate UI bug,
  pre-existing assumption that broke under the new semantics).

Verified: pytest 50 passed, pnpm typecheck clean.
2026-09-02 10:10:41 +08:00

312 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useLocation, useNavigate } from "react-router";
import * as rawApi from "../services/api";
import type { WorkspaceBoundApi } from "../services/api";
export type AuthUser = {
user_id: string;
username: string;
display_name: string;
email: string | null;
status: string;
role_code: string | null;
is_system_admin: boolean;
};
export type AuthWorkspace = {
workspace_id: string;
workspace_code: string;
workspace_name: string;
role_code: string;
role_name: string;
};
type AuthSession = {
user: AuthUser;
workspaces: AuthWorkspace[];
default_workspace_id: string | null;
};
type ApiEnvelope<T> = {
data: T;
};
type AuthContextValue = {
user: AuthUser | null;
workspaces: AuthWorkspace[];
currentWorkspace: AuthWorkspace | null;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
setCurrentWorkspace: (workspaceId: string) => void;
refreshWorkspaces: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
const workspaceStorageKey = "model-platform-current-workspace";
async function authRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...init,
credentials: "same-origin",
headers: {
...(init.body ? { "Content-Type": "application/json" } : {}),
...init.headers,
},
});
const payload = await response.json().catch(() => ({})) as
| ApiEnvelope<T>
| { detail?: string };
if (!response.ok) {
throw new Error(
"detail" in payload && typeof payload.detail === "string"
? payload.detail
: `请求失败(HTTP ${response.status}`,
);
}
return (payload as ApiEnvelope<T>).data;
}
function selectWorkspace(
session: AuthSession,
preferredId?: string | null,
): AuthWorkspace | null {
return session.workspaces.find((item) => item.workspace_id === preferredId)
?? session.workspaces.find(
(item) => item.workspace_id === session.default_workspace_id,
)
?? session.workspaces[0]
?? null;
}
export function AuthProvider({ children }: { children: ReactNode }) {
const location = useLocation();
const navigate = useNavigate();
const [user, setUser] = useState<AuthUser | null>(null);
const [workspaces, setWorkspaces] = useState<AuthWorkspace[]>([]);
const [currentWorkspace, setWorkspace] = useState<AuthWorkspace | null>(null);
const [loading, setLoading] = useState(true);
const applySession = useCallback((session: AuthSession) => {
const storedId = typeof window === "undefined"
? null
: window.localStorage.getItem(workspaceStorageKey);
const workspace = selectWorkspace(session, storedId);
setUser(session.user);
setWorkspaces(session.workspaces);
setWorkspace(workspace);
if (workspace && typeof window !== "undefined") {
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
}
}, []);
useEffect(() => {
let cancelled = false;
void authRequest<AuthSession>("/api/v1/auth/me")
.then((session) => {
if (!cancelled) applySession(session);
})
.catch(() => {
if (!cancelled) {
setUser(null);
setWorkspaces([]);
setWorkspace(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [applySession]);
useEffect(() => {
if (loading) return;
if (!user && location.pathname !== "/login") {
navigate("/login", { replace: true });
} else if (user && location.pathname === "/login") {
navigate("/workbench", { replace: true });
}
}, [loading, location.pathname, navigate, user]);
const login = useCallback(async (username: string, password: string) => {
const session = await authRequest<AuthSession>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
applySession(session);
navigate("/workbench", { replace: true });
}, [applySession, navigate]);
const logout = useCallback(async () => {
try {
await authRequest<{ logged_out: boolean }>("/api/v1/auth/logout", {
method: "POST",
});
} finally {
setUser(null);
setWorkspaces([]);
setWorkspace(null);
navigate("/login", { replace: true });
}
}, [navigate]);
const setCurrentWorkspace = useCallback((workspaceId: string) => {
const workspace = workspaces.find((item) => item.workspace_id === workspaceId);
if (!workspace) return;
setWorkspace(workspace);
window.localStorage.setItem(workspaceStorageKey, workspace.workspace_id);
}, [workspaces]);
const refreshWorkspaces = useCallback(async () => {
try {
const session = await authRequest<AuthSession>("/api/v1/auth/me");
const storedId = typeof window === "undefined"
? null
: window.localStorage.getItem(workspaceStorageKey);
const newWorkspace = selectWorkspace(session, storedId);
setWorkspaces(session.workspaces);
setWorkspace(newWorkspace);
if (newWorkspace && typeof window !== "undefined") {
window.localStorage.setItem(workspaceStorageKey, newWorkspace.workspace_id);
}
} catch (error) {
console.error("刷新 workspace 列表失败:", error);
}
}, []);
const value = useMemo<AuthContextValue>(() => ({
user,
workspaces,
currentWorkspace,
loading,
login,
logout,
setCurrentWorkspace,
refreshWorkspaces,
}), [currentWorkspace, loading, login, logout, setCurrentWorkspace, user, workspaces, refreshWorkspaces]);
if (loading) {
return (
<div className="auth-loading" role="status">
<span />
正在验证登录状态…
</div>
);
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth 必须在 AuthProvider 内使用");
}
return context;
}
export function useApi(): WorkspaceBoundApi {
const { currentWorkspace } = useAuth();
const workspaceId = currentWorkspace?.workspace_id ?? "";
return useMemo<WorkspaceBoundApi>(() => ({
listScripts: (parentPath) => rawApi.listScripts(workspaceId, parentPath),
listResources: (opts) => rawApi.listResources(workspaceId, opts),
createScript: (input) => rawApi.createScript(workspaceId, input),
uploadScript: (file, parentPath, visibility) =>
rawApi.uploadScript(workspaceId, file, parentPath, visibility),
createResourceUpload: (body) =>
rawApi.createResourceUpload(workspaceId, body),
uploadResourceBytes: (uploadId, fileBytes, contentType) =>
rawApi.uploadResourceBytes(workspaceId, uploadId, fileBytes, contentType),
bindResourceUpload: (uploadId, body) =>
rawApi.bindResourceUpload(workspaceId, uploadId, body),
deleteResource: (resourceId) =>
rawApi.deleteResource(workspaceId, resourceId),
updateScript: (scriptId, input) =>
rawApi.updateScript(workspaceId, scriptId, input),
deleteScript: (scriptId) => rawApi.deleteScript(workspaceId, scriptId),
setScriptLock: (scriptId, isLocked) =>
rawApi.setScriptLock(workspaceId, scriptId, isLocked),
listWorkspaceDirectories: (parentPath?: string) =>
rawApi.listWorkspaceDirectories(workspaceId, parentPath ?? ""),
createWorkspaceDirectory: (directoryName, parentPath) =>
rawApi.createWorkspaceDirectory(workspaceId, directoryName, parentPath),
deleteWorkspaceDirectory: (path) =>
rawApi.deleteWorkspaceDirectory(workspaceId, path),
acquireFileLock: (script) => rawApi.acquireFileLock(workspaceId, script),
heartbeatFileLock: (session) => rawApi.heartbeatFileLock(workspaceId, session),
releaseFileLock: (session) => rawApi.releaseFileLock(workspaceId, session),
releaseFileLockOnUnload: (session) =>
rawApi.releaseFileLockOnUnload(workspaceId, session),
createJupyterAccessTicket: (session) =>
rawApi.createJupyterAccessTicket(workspaceId, session),
getLatestScriptVersion: (scriptId) =>
rawApi.getLatestScriptVersion(workspaceId, scriptId),
publishScriptVersion: (input) => rawApi.publishScriptVersion(workspaceId, input),
listSchedules: () => rawApi.listSchedules(workspaceId),
getSchedule: (scheduleId) => rawApi.getSchedule(workspaceId, scheduleId),
createSchedule: (input) => rawApi.createSchedule(workspaceId, input),
updateSchedule: (scheduleId, input) =>
rawApi.updateSchedule(workspaceId, scheduleId, input),
deleteSchedule: (scheduleId, workflowVersion) =>
rawApi.deleteSchedule(workspaceId, scheduleId, workflowVersion),
listScheduleArtifacts: () => rawApi.listScheduleArtifacts(workspaceId),
hideScheduleArtifact: (versionsId) =>
rawApi.hideScheduleArtifact(workspaceId, versionsId),
listEmployees: () => rawApi.listEmployees(workspaceId),
listPlatformEmployees: () => rawApi.listPlatformEmployees(),
createEmployee: (input) => rawApi.createEmployee(workspaceId, input),
createPlatformEmployee: (input) => rawApi.createPlatformEmployee(input),
updateEmployee: (userId, input) =>
rawApi.updateEmployee(workspaceId, userId, input),
updatePlatformEmployee: (userId, input) =>
rawApi.updatePlatformEmployee(userId, input),
deleteEmployee: (userId) => rawApi.deleteEmployee(workspaceId, userId),
deletePlatformEmployee: (userId) => rawApi.deletePlatformEmployee(userId),
createScheduleNode: (scheduleId, input) =>
rawApi.createScheduleNode(workspaceId, scheduleId, input),
updateScheduleNode: (scheduleId, nodeId, input) =>
rawApi.updateScheduleNode(workspaceId, scheduleId, nodeId, input),
deleteScheduleNode: (scheduleId, nodeId, workflowVersion, options) =>
rawApi.deleteScheduleNode(
workspaceId,
scheduleId,
nodeId,
workflowVersion,
options,
),
createScheduleEdge: (scheduleId, input) =>
rawApi.createScheduleEdge(workspaceId, scheduleId, input),
deleteScheduleEdge: (scheduleId, edgeId, workflowVersion) =>
rawApi.deleteScheduleEdge(workspaceId, scheduleId, edgeId, workflowVersion),
validateSchedule: (scheduleId) => rawApi.validateSchedule(workspaceId, scheduleId),
previewCron: (input) => rawApi.previewCron(workspaceId, input),
runScheduleNow: (scheduleId) => rawApi.runScheduleNow(workspaceId, scheduleId),
listScheduleRuns: (input) => rawApi.listScheduleRuns(workspaceId, input),
getScheduleRun: (runId) => rawApi.getScheduleRun(workspaceId, runId),
getScheduleNodeRunArtifacts: (runId, nodeRunId) =>
rawApi.getScheduleNodeRunArtifacts(workspaceId, runId, nodeRunId),
// Workspace (Project) Management - 系统管理接口(跨 workspace,不需要传入 workspaceId
listWorkspaces: () => rawApi.listWorkspaces(),
createWorkspace: (input) => rawApi.createWorkspace(input),
updateWorkspace: (workspaceId, input) => rawApi.updateWorkspace(workspaceId, input),
deleteWorkspace: (workspaceId) => rawApi.deleteWorkspace(workspaceId),
listWorkspaceMembers: (workspaceId) => rawApi.listWorkspaceMembers(workspaceId),
addWorkspaceMember: (workspaceId, input) => rawApi.addWorkspaceMember(workspaceId, input),
updateWorkspaceMember: (workspaceId, userId, input) => rawApi.updateWorkspaceMember(workspaceId, userId, input),
deleteWorkspaceMember: (workspaceId, userId) => rawApi.deleteWorkspaceMember(workspaceId, userId),
}), [workspaceId]);
}