统一外部 access_token 存储与轻应用 URL 拼接,修复嵌入会话鉴权隔离。

将 access_token 纳入 scoped storage 迁移,yUserId 登录优先并独立持久化 token;轻应用/按钮管理默认改用 access_token 参数名,支持 hash 路由占位符替换与自动追加。
This commit is contained in:
fengxiangdebaozi committed 2026-06-29 23:00:31 +08:00
1 parent 8c6d08d6bf
commit 6378cd33bd
10 files changed
+210 -39

No files matched your search

@@ -1,3 +1,7 @@
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
export const EXTERNAL_ACCESS_TOKEN_STORAGE_KEY = "access_token";
export function normalizeExternalAccessToken(value: unknown): string {
if (value == null) return "";
const token = String(value).trim();
@@ -5,3 +9,53 @@ export function normalizeExternalAccessToken(value: unknown): string {
const lowered = token.toLowerCase();
return lowered === "null" || lowered === "undefined" ? "" : token;
}
function readTokenFromStoredValue(raw: string | null): string {
const token = normalizeExternalAccessToken(raw);
if (!token) return "";
if (!token.startsWith("{")) return token;
try {
const parsed = JSON.parse(token);
if (!parsed || typeof parsed !== "object") return token;
const obj = parsed as Record<string, unknown>;
return (
normalizeExternalAccessToken(obj.access_token) ||
normalizeExternalAccessToken(obj.accessToken) ||
normalizeExternalAccessToken(obj.token)
);
} catch {
return token;
}
}
export function readStoredExternalAccessTokenValue(): string {
return readTokenFromStoredValue(authStorageGet(EXTERNAL_ACCESS_TOKEN_STORAGE_KEY));
}
export function readExternalAccessTokenValue(userInfo: Record<string, unknown>): string {
return normalizeExternalAccessToken(userInfo.access_token) || normalizeExternalAccessToken(userInfo.accessToken);
}
export function readCurrentExternalAccessTokenValue(userInfo: Record<string, unknown>): string {
return readStoredExternalAccessTokenValue() || readExternalAccessTokenValue(userInfo);
}
export function writeExternalAccessTokenFields(
userInfo: Record<string, unknown>,
token: string,
): void {
userInfo.access_token = token;
userInfo.accessToken = token;
}
export function writeStoredExternalAccessToken(token: string): void {
const normalized = normalizeExternalAccessToken(token);
if (normalized) {
authStorageSet(EXTERNAL_ACCESS_TOKEN_STORAGE_KEY, normalized);
}
}
export function deleteExternalAccessTokenFields(userInfo: Record<string, unknown>): void {
delete userInfo.access_token;
delete userInfo.accessToken;
}
+1 -1
View File
@@ -62,7 +62,7 @@ export function authStorageRemove(key: string): void {
* ticket/userId/env/maxkeyOnlineToken/userDetailToken)。用于「开启独立会话」时把当前登录态从
* localStorage 快照搬进 sessionStorage,避免切换存储后被登出。
*/
const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token"];
const PROTECTED_AUTH_EXACT_KEYS = ["deerflow.auth", "userInfo", "userId", "tools_token", "access_token"];
const PROTECTED_AUTH_PREFIX = "login.";
function isProtectedAuthKey(key: string): boolean {
+93 -13
View File
@@ -1,6 +1,13 @@
import { requestCrudService } from "@/service/request-axios-service";
import { authStorageGet, authStorageSet } from "@/core/auth/scoped-storage";
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
import {
deleteExternalAccessTokenFields,
normalizeExternalAccessToken,
readStoredExternalAccessTokenValue,
readExternalAccessTokenValue,
writeExternalAccessTokenFields,
writeStoredExternalAccessToken,
} from "@/core/auth/external-access-token";
const CONSUMER_URL = import.meta.env.VITE_AIPI_CONSUMER_URL || "";
@@ -36,6 +43,38 @@ function readStoredYUserId(info: Record<string, unknown>): string {
return value == null ? "" : String(value).trim();
}
function normalizeExternalUserId(value: unknown): string {
if (value == null) return "";
const text = String(value).trim();
if (!text) return "";
const lowered = text.toLowerCase();
return lowered === "null" || lowered === "undefined" ? "" : text;
}
function maskToken(token: string): string {
if (!token) return "";
if (token.length <= 10) return `${token.slice(0, 2)}***(${token.length})`;
return `${token.slice(0, 6)}...${token.slice(-4)}(${token.length})`;
}
function summarizeUserInfo(info: Record<string, unknown>): Record<string, unknown> {
return {
keys: Object.keys(info),
yUserId: info.yUserId,
userId: info.userId,
userName: info.userName,
gateDepartName: info.gateDepartName,
typeName: info.typeName,
access_token: maskToken(normalizeExternalAccessToken(info.access_token)),
accessToken: maskToken(normalizeExternalAccessToken(info.accessToken)),
};
}
function logYUserLogin(step: string, payload: Record<string, unknown>): void {
if (typeof console === "undefined") return;
console.info(`[yUserId-login] ${step}`, payload);
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
@@ -95,28 +134,34 @@ function buildExternalUserInfo(
}
}
next.yUserId = yUserId;
const externalUserId =
normalizeExternalUserId(result.id) ||
normalizeExternalUserId(result.userId) ||
normalizeExternalUserId(result.user_id) ||
yUserId;
next.userId = externalUserId;
const incomingAccessToken = normalizeExternalAccessToken(result.accessToken);
const incomingAccessToken = readExternalAccessTokenValue(result);
if (incomingAccessToken) {
next.accessToken = incomingAccessToken;
writeExternalAccessTokenFields(next, incomingAccessToken);
return next;
}
const queryAccessToken = normalizeExternalAccessToken(access_token);
if (queryAccessToken) {
next.accessToken = queryAccessToken;
writeExternalAccessTokenFields(next, queryAccessToken);
return next;
}
const previousYUserId = readStoredYUserId(previous);
const previousAccessToken =
previousYUserId === yUserId ? normalizeExternalAccessToken(previous.accessToken) : "";
const cachedAccessToken = normalizeExternalAccessToken(cached.accessToken);
previousYUserId === yUserId ? readExternalAccessTokenValue(previous) : "";
const cachedAccessToken = readExternalAccessTokenValue(cached);
const fallbackAccessToken = previousAccessToken || cachedAccessToken;
if (fallbackAccessToken) {
next.accessToken = fallbackAccessToken;
writeExternalAccessTokenFields(next, fallbackAccessToken);
} else {
delete next.accessToken;
deleteExternalAccessTokenFields(next);
}
return next;
}
@@ -134,8 +179,16 @@ export async function loginByYUserId(
authStorageSet(TOOLS_TOKEN_STORAGE_KEY, token);
}
const requestUrl = `${CONSUMER_URL}/cop/userDetailByYuserId`;
logYUserLogin("request", {
requestUrl,
yUserId,
hasToolsToken: Boolean(token),
queryAccessToken: maskToken(normalizeExternalAccessToken(access_token)),
});
const res = (await requestCrudService.get(
`${CONSUMER_URL}/cop/userDetailByYuserId`,
requestUrl,
{ yuserId: yUserId },
)) as UserDetailResponse;
@@ -143,12 +196,38 @@ export async function loginByYUserId(
// 一并存入路由参数 yUserId,使「应用管理」里 source=yUserId 的路由参数能取到(与
// source=userName/gateDepartName/typeName 直接取接口值统一走 userInfo[source])。
// 注意:接口返回的 accessToken 优先;登录 URL 的 access_token 只在接口值为空时兜底。
// The consumer API may return accessToken. URL access_token is only used as
// a fallback when that API token is empty.
const result = res.data ?? {};
logYUserLogin("response", {
keys: Object.keys(result),
id: result.id,
userId: result.userId,
user_id: result.user_id,
userName: result.userName,
gateDepartName: result.gateDepartName,
typeName: result.typeName,
access_token: maskToken(normalizeExternalAccessToken(result.access_token)),
accessToken: maskToken(normalizeExternalAccessToken(result.accessToken)),
});
const previous = readExternalUserInfo();
const cached = readCachedYUserInfo(yUserId);
const next = buildExternalUserInfo(yUserId, result, access_token, previous, cached);
const externalUserId = normalizeExternalUserId(next.userId);
if (externalUserId) {
authStorageSet(EXTERNAL_USER_ID_STORAGE_KEY, externalUserId);
}
const externalAccessToken = readExternalAccessTokenValue(next);
if (externalAccessToken) {
writeStoredExternalAccessToken(externalAccessToken);
}
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(next));
writeCachedYUserInfo(yUserId, next);
logYUserLogin("stored", {
userId: externalUserId,
topLevelAccessToken: maskToken(readStoredExternalAccessTokenValue()),
userInfo: summarizeUserInfo(next),
});
}
export function persistToolsToken(token: string): void {
@@ -156,17 +235,18 @@ export function persistToolsToken(token: string): void {
}
/**
* 把内网登录路由参数 access_token 合并写入 userInfo.accessToken。
* 把内网登录路由参数 access_token 合并写入 userInfo.access_token / userInfo.accessToken。
* 据此拼参的有:XK/PG 中任务 iframe(XK 用 accessToken、PG 用 access_token,二者均读
* external-links.getAccessToken() = userInfo.accessToken);以及「应用管理」里
* source=accessToken 的轻应用路由参数(resolveRouteParamValue 取 userInfo.accessToken)。
* external-links.getAccessToken());以及「应用管理」里 source=access_token / accessToken
* 的轻应用路由参数(resolveRouteParamValue 统一读两种字段)。
* 用合并而非覆盖整对象,避免抹掉 loginByYUserId 从 consumer 拿到的用户信息。
*/
export function persistAccessToken(token: string): void {
const trimmed = normalizeExternalAccessToken(token);
if (!trimmed) return;
const info = readExternalUserInfo();
info.accessToken = trimmed;
writeExternalAccessTokenFields(info, trimmed);
writeStoredExternalAccessToken(trimmed);
authStorageSet(EXTERNAL_USER_INFO_STORAGE_KEY, JSON.stringify(info));
const yUserId = readStoredYUserId(info);
if (yUserId) {
@@ -1,7 +1,7 @@
/** Strategy 模块外部 iframe / 嵌入页链接(来自 Vite 环境变量) */
import { authStorageGet } from "@/core/auth/scoped-storage";
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token";
function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
@@ -44,7 +44,7 @@ function readUserInfo(): Record<string, unknown> {
/** 当前登录态 accessToken(无则空串)。 */
export function getAccessToken(): string {
return normalizeExternalAccessToken(readUserInfo().accessToken);
return readCurrentExternalAccessTokenValue(readUserInfo());
}
/**
@@ -89,9 +89,9 @@ const emptyForm = (): FormState => ({
});
const newParam = (): LightAppRouteParam => ({
key: "",
key: "access_token",
paramType: "login",
source: "accessToken",
source: "access_token",
customValue: "",
themeValue: "red=red;blue=blue;dark-blue=dark-blue",
});
@@ -107,7 +107,7 @@ function formFromApp(app: LightApp): FormState {
routeParams: app.routeParams.map((p) => ({
key: p.key,
paramType: p.paramType,
source: p.source ?? "accessToken",
source: p.source ?? "access_token",
customValue: p.customValue ?? "",
themeValue: p.themeValue ?? "",
})),
@@ -118,7 +118,7 @@ function getParamSourceLabel(param: LightAppRouteParam): string {
if (param.paramType === "theme") {
return `主题参数: ${param.themeValue || ""}`;
}
const source = param.source || "accessToken";
const source = param.source || "access_token";
const option = LOGIN_SOURCE_OPTIONS.find((o) => o.value === source);
if (source === "other") {
return `登录参数: ${param.customValue || ""}`;
@@ -977,7 +977,7 @@ export default function LightAppManagementPage() {
onChange={(v) =>
patchParam(idx, {
paramType: v as "login" | "theme",
source: v === "login" ? (p.source || "accessToken") : undefined,
source: v === "login" ? (p.source || "access_token") : undefined,
})
}
/>
@@ -988,7 +988,7 @@ export default function LightAppManagementPage() {
<Select
className="w-full"
size="small"
value={p.source || "accessToken"}
value={p.source || "access_token"}
options={LOGIN_SOURCE_OPTIONS}
popupProps={DIALOG_SELECT_POPUP}
onChange={(v) => patchParam(idx, { source: v as string })}
@@ -1112,7 +1112,7 @@ function cleanParams(params: LightAppRouteParam[]): LightAppRouteParam[] {
.map((p) => {
const base: LightAppRouteParam = { key: p.key.trim(), paramType: p.paramType };
if (p.paramType === "login") {
base.source = p.source || "accessToken";
base.source = p.source || "access_token";
if (base.source === "other") {
base.customValue = (p.customValue ?? "").trim();
}
+3 -3
View File
@@ -179,10 +179,10 @@ export default function LoginPage() {
auth = await loginByToken(authToken);
} else {
let deerflowUsername = resolvedUsername;
if (onlineTicket) {
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
} else if (yUserId) {
if (yUserId) {
await loginByYUserId(yUserId, toolToken || null, access_token || null);
} else if (onlineTicket) {
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
} else if (toolToken) {
persistToolsToken(toolToken);
}
+1 -1
View File
@@ -124,7 +124,7 @@ export default function TaskButtonsPage() {
);
const addLoginParam = (id: string) =>
updateLoginParams(id, (ps) => [...ps, { key: "", source: "accessToken", customValue: "" }]);
updateLoginParams(id, (ps) => [...ps, { key: "access_token", source: "access_token", customValue: "" }]);
const patchLoginParam = (id: string, idx: number, patch: Partial<TaskButtonLoginParam>) =>
updateLoginParams(id, (ps) => ps.map((p, i) => (i === idx ? { ...p, ...patch } : p)));
@@ -49,7 +49,7 @@ interface ServerButton {
function toLoginParam(p: ServerLoginParam): TaskButtonLoginParam {
return {
key: p.key ?? "",
source: p.source || "accessToken",
source: p.source || "access_token",
customValue: p.custom_value ?? "",
};
}
@@ -28,6 +28,7 @@ import {
platformTypeChange,
} from './GeneralQA.mock';
import { requestCrudService } from '@/service/request-axios-service';
import { authStorageGet } from '@/core/auth/scoped-storage';
/**
* 通用问答组件
@@ -138,7 +139,7 @@ const GeneralQA: React.FC<{
const mockSingleRoundStoppedRef = useRef(false);
const mockMultiRoundStoppedRef = useRef(false);
const chatListRef = useRef<HTMLDivElement | null>(null);
const userId = useRef(localStorage.getItem('userId') || '387'); // 用户id
const userId = useRef(authStorageGet('userId') || '387'); // 用户id
const qaType = useRef('GENERAL'); // 问答类型
const chatDataId = useRef(); // 聊天窗口id
const compareChatId = useRef(); // 对比对话记录id
@@ -7,7 +7,7 @@
import type { LightApp, LightAppRouteParam } from "@/strategy-components/api/light-apps";
import { authStorageGet } from "@/core/auth/scoped-storage";
import { normalizeExternalAccessToken } from "@/core/auth/external-access-token";
import { readCurrentExternalAccessTokenValue } from "@/core/auth/external-access-token";
/** iframe 可挂载的父菜单(取自 core/page-layout/sidebar-menu.ts 的现有菜单 id)。 */
export const MOUNT_OPTIONS: { label: string; value: string }[] = [
@@ -29,7 +29,8 @@ export function mountLabel(value: string | null | undefined): string {
* userName / gateDepartName / typeName 取 consumer 接口 userDetailByYuserId 的返回值。
*/
export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
{ label: "accessToken", value: "accessToken" },
{ label: "access_token", value: "access_token" },
{ label: "accessToken(兼容旧配置)", value: "accessToken" },
{ label: "yUserId", value: "yUserId" },
{ label: "userName", value: "userName" },
{ label: "gateDepartName", value: "gateDepartName" },
@@ -143,8 +144,8 @@ export function resolveRouteParamValue(
}
if (param.source) {
const value = userInfo[param.source];
if (param.source === "accessToken") {
return normalizeExternalAccessToken(value);
if (param.source === "access_token" || param.source === "accessToken") {
return readCurrentExternalAccessTokenValue(userInfo);
}
return value == null ? "" : String(value);
}
@@ -157,15 +158,50 @@ export function resolveRouteParamValue(
* 兼容 hash 路由地址(如 `https://x#/route`)—— 管理员粘贴完整地址,参数自动追加在末尾,
* 落在 hash 路由的 query 上(与现有 XK/PG 页面 `?accessToken=` 拼接方式一致)。
*/
function appendQueryPairs(url: string, pairs: string[]): string {
if (pairs.length === 0) return url;
const hashIndex = url.indexOf("#");
if (hashIndex >= 0) {
const beforeHash = url.slice(0, hashIndex);
const hash = url.slice(hashIndex);
const sep = hash.includes("?") ? "&" : "?";
return `${beforeHash}${hash}${sep}${pairs.join("&")}`;
}
const sep = url.includes("?") ? "&" : "?";
return `${url}${sep}${pairs.join("&")}`;
}
function replaceAccessTokenParam(url: string, accessToken: string): { url: string; replaced: boolean } {
if (!accessToken) return { url, replaced: false };
let replaced = false;
const nextUrl = url.replace(/([?&]access_token)(?:=[^&#]*)?(?=[&#]|$)/g, (match) => {
replaced = true;
return `${match.split("=")[0]}=${encodeURIComponent(accessToken)}`;
});
return { url: nextUrl, replaced };
}
export function buildLightAppUrl(
app: Pick<LightApp, "url" | "routeParams">,
userInfo: Record<string, unknown> = readUserInfo(),
theme?: AppThemeColor,
): string {
const pairs = (app.routeParams ?? [])
.filter((p) => p.key)
.map((p) => `${encodeURIComponent(p.key)}=${encodeURIComponent(resolveRouteParamValue(p, userInfo, theme))}`);
if (pairs.length === 0) return app.url;
const sep = app.url.includes("?") ? "&" : "?";
return `${app.url}${sep}${pairs.join("&")}`;
const routeParams = app.routeParams ?? [];
const accessToken = readCurrentExternalAccessTokenValue(userInfo);
const tokenUrlResult = replaceAccessTokenParam(app.url, accessToken);
const pairs = routeParams
.filter((p) => (p.key ?? "").trim())
.filter((p) => (p.key ?? "").trim() !== "access_token" || !tokenUrlResult.replaced)
.map((p) => {
const key = (p.key ?? "").trim();
const value = key === "access_token" ? accessToken : resolveRouteParamValue(p, userInfo, theme);
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
});
const hasAccessTokenParam =
tokenUrlResult.replaced ||
routeParams.some((p) => (p.key ?? "").trim() === "access_token");
if (!hasAccessTokenParam && accessToken) {
pairs.push(`${encodeURIComponent("access_token")}=${encodeURIComponent(accessToken)}`);
}
return appendQueryPairs(tokenUrlResult.url, pairs);
}