fix: stabilize dingtalk login and image sizing
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-11 14:24:34 +08:00
parent 510e3dc323
commit 40848ffdb5
5 changed files with 157 additions and 78 deletions

View File

@ -374,7 +374,37 @@ export default function Page() {
};
}, []);
// 根据组织名称启动钉钉登录(在 iframe 中打开)
const getSafeLoginCallbackUrl = () => {
if (typeof window === "undefined") return "/";
const callbackUrl = new URLSearchParams(window.location.search).get(
"callbackUrl",
);
if (
callbackUrl &&
callbackUrl.startsWith("/") &&
!callbackUrl.startsWith("//")
) {
return callbackUrl;
}
return "/";
};
const buildDingTalkLoginUrl = (orgName?: string) => {
const params = new URLSearchParams();
if (orgName) params.set("org", orgName);
const callbackUrl = getSafeLoginCallbackUrl();
if (callbackUrl !== "/") {
params.set("callbackUrl", callbackUrl);
}
const query = params.toString();
return `/api/auth/dingtalk/login${query ? `?${query}` : ""}`;
};
// 根据组织名称启动钉钉登录。钉钉授权页禁止第三方 iframe 嵌入,必须使用顶层跳转。
const startDingTalkLoginForOrg = (orgName: string) => {
// 清除 URL 中的 token 参数,避免干扰
if (
@ -385,14 +415,12 @@ export default function Page() {
window.history.replaceState({}, "", newUrl);
}
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
orgName,
)}`;
const loginUrl = buildDingTalkLoginUrl(orgName);
console.log("[DingTalk] Opening org login:", orgName, loginUrl);
setDingTalkIframeSrc(loginUrl);
console.log("[DingTalk] Redirecting org login:", orgName, loginUrl);
setShowDingTalkModal(true);
setShowOrgMenu(false);
window.location.assign(loginUrl);
};
// 钉钉登录 - 支持多组织
@ -409,7 +437,6 @@ export default function Page() {
return;
}
// 兼容:直接使用旧的方式在 iframe 中打开
if (
typeof window === "undefined" ||
typeof window.location === "undefined"
@ -424,27 +451,9 @@ export default function Page() {
window.history.replaceState({}, "", newUrl);
}
const param = {
redirect_uri: encodeURIComponent(
window.location.origin + "/dingtalk-callback.html",
),
client_id: dingTalkAppKey,
scope: "openid",
response_type: "code",
state: "default",
prompt: "consent",
};
const redirectUrl =
"https://login.dingtalk.com/oauth2/auth?" +
Object.keys(param)
.map((key) => key + "=" + (param as any)[key])
.join("&");
console.log("[DingTalk] Opening OAuth URL (legacy):", redirectUrl);
setDingTalkIframeSrc(redirectUrl);
setShowDingTalkModal(true);
const loginUrl = buildDingTalkLoginUrl();
console.log("[DingTalk] Redirecting OAuth login:", loginUrl);
window.location.assign(loginUrl);
return;
}
@ -633,17 +642,7 @@ export default function Page() {
<button
key={org}
type="button"
onClick={() => {
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
org,
)}`;
console.log(
"[DingTalk] Selected organization:",
org,
loginUrl,
);
setDingTalkIframeSrc(loginUrl);
}}
onClick={() => startDingTalkLoginForOrg(org)}
className="flex w-full items-center justify-between rounded-md border bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800"
>
<span>{org}</span>
@ -651,7 +650,7 @@ export default function Page() {
))}
</div>
<p className="text-xs text-muted-foreground">
选择后将在下方加载钉钉登录页面。
选择后将跳转到钉钉完成授权。
</p>
</div>
)}

View File

@ -6,10 +6,52 @@ import { generateUUID } from "@/lib/utils";
import { signIn } from "@/app/(auth)/auth";
import { eq } from "drizzle-orm";
const normalizeCallbackUrl = (value: unknown) => {
if (typeof value !== "string" || !value) return "/";
try {
const parsed = new URL(value, "https://local.invalid");
if (parsed.origin !== "https://local.invalid") return "/";
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
} catch {
return "/";
}
};
const parseDingTalkState = (state: string | null) => {
if (!state) {
return { org: undefined as string | undefined, callbackUrl: "/" };
}
if (state.startsWith("v1.")) {
try {
const payload = JSON.parse(
Buffer.from(state.slice(3), "base64url").toString("utf8"),
) as { org?: unknown; callbackUrl?: unknown };
return {
org:
typeof payload.org === "string" && payload.org !== "default"
? payload.org
: undefined,
callbackUrl: normalizeCallbackUrl(payload.callbackUrl),
};
} catch (error) {
console.warn("[DingTalk Callback] Failed to parse state payload:", error);
}
}
return {
org: state !== "default" ? state : undefined,
callbackUrl: "/",
};
};
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const dingTalkState = parseDingTalkState(state);
console.log(
"[DingTalk Callback] Received code:",
@ -31,8 +73,8 @@ export async function GET(request: NextRequest) {
}
const organizations = settings.dingTalkOrganizations ?? [];
const selectedOrg = state
? organizations.find((org) => org.name === state)
const selectedOrg = dingTalkState.org
? organizations.find((org) => org.name === dingTalkState.org)
: organizations[0];
const appKey = selectedOrg?.appKey ?? settings.dingTalkAppKey;
@ -290,6 +332,7 @@ export async function GET(request: NextRequest) {
departments: userInfo.departmentNames || [],
},
token,
callbackUrl: dingTalkState.callbackUrl,
});
} catch (error) {

View File

@ -1,6 +1,24 @@
import { NextResponse } from "next/server";
import { getAuthSettings } from "@/lib/db/queries";
const normalizeCallbackUrl = (value: string | null) => {
if (!value) return "/";
try {
const parsed = new URL(value, "https://local.invalid");
if (parsed.origin !== "https://local.invalid") return "/";
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
} catch {
return "/";
}
};
const encodeState = (payload: { org: string; callbackUrl: string }) => {
return `v1.${Buffer.from(JSON.stringify(payload), "utf8").toString(
"base64url",
)}`;
};
export async function GET(request: Request) {
const settings = await getAuthSettings();
@ -34,8 +52,11 @@ export async function GET(request: Request) {
const baseUrl = process.env.AUTH_URL || requestUrl.origin;
const redirectUri = `${baseUrl}/dingtalk-callback.html`;
// 使用组织名称作为 state,便于回调时选择对应的配置
const state = orgName || selectedOrg?.name || "default";
// state 中保存组织和登录后的回跳地址,回调接口仍兼容旧的纯组织名 state。
const state = encodeState({
org: orgName || selectedOrg?.name || "default",
callbackUrl: normalizeCallbackUrl(urlObj.searchParams.get("callbackUrl")),
});
// 钉钉 OAuth 2.0 授权地址
const url =

View File

@ -26,6 +26,7 @@ interface UploadedImage {
const DEFAULT_IMAGE_SIZE = "2K";
const DEFAULT_TIMEOUT_MS = 300000;
const OPENAI_IMAGE_MIN_TOTAL_PIXELS = 3_686_400;
const OPENAI_IMAGE_DIMENSION_MULTIPLE = 16;
const BLTCY_HOST_REGEX = /(^|\.)bltcy\.ai$/i;
const KONTEXT_MODEL_REGEX = /kontext/i;
const SUPPORTED_ASPECT_RATIOS = [
@ -83,16 +84,32 @@ function normalizeAspectRatio(value?: string) {
return undefined;
}
function parseAspectRatioValue(ratio: string) {
function parseAspectRatioPair(ratio: string) {
const [width, height] = ratio.split(":").map((item) => Number(item));
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
return 1;
return null;
}
return { width, height };
}
function parseAspectRatioValue(ratio: string) {
const pair = parseAspectRatioPair(ratio);
if (!pair) return 1;
const { width, height } = pair;
return width / height;
}
function roundEven(value: number) {
return Math.max(2, Math.round(value / 2) * 2);
function ceilToMultiple(value: number, multiple: number) {
return Math.max(multiple, Math.ceil(value / multiple) * multiple);
}
function inferAspectRatioFromPrompt(prompt: string) {
const normalized = String(prompt || "").replace(/:/g, ":");
const match = normalized.match(/(\d{1,2})\s*(?::|比)\s*(\d{1,2})/);
if (!match) return undefined;
const ratio = `${Number(match[1])}:${Number(match[2])}`;
return (SUPPORTED_ASPECT_RATIOS as readonly string[]).includes(ratio) ? ratio : undefined;
}
function inferClosestAspectRatio(aspectValue: number) {
@ -151,29 +168,15 @@ function resolveOpenAISize(aspectRatio: string, imageSize: "1K" | "2K" | "4K") {
"4K": 2048,
};
const safeRatio = parseAspectRatioValue(aspectRatio);
const ratioPair = parseAspectRatioPair(aspectRatio) || { width: 1, height: 1 };
const base = baseMap[imageSize] || 1536;
const targetPixels = Math.max(base * base, OPENAI_IMAGE_MIN_TOTAL_PIXELS);
let width: number;
let height: number;
if (safeRatio >= 1) {
width = roundEven(Math.sqrt(targetPixels * safeRatio));
height = roundEven(width / safeRatio);
} else {
height = roundEven(Math.sqrt(targetPixels / safeRatio));
width = roundEven(height * safeRatio);
}
while (width * height < targetPixels) {
if (safeRatio >= 1) {
width += 2;
height = roundEven(width / safeRatio);
} else {
height += 2;
width = roundEven(height * safeRatio);
}
}
const scale = ceilToMultiple(
Math.sqrt(targetPixels / (ratioPair.width * ratioPair.height)),
OPENAI_IMAGE_DIMENSION_MULTIPLE,
);
const width = ratioPair.width * scale;
const height = ratioPair.height * scale;
return `${width}x${height}`;
}
@ -530,9 +533,10 @@ export function createOpenAIImage({ apiKey, baseURL }: OpenAIImageConfig) {
const { prompt, imageUrls } = extractPromptAndImages(options.messages);
const activeModelId = options.overrideModelId || modelId;
const normalizedAspectRatio = normalizeAspectRatio(options.imageParams?.aspectRatio);
const promptAspectRatio = inferAspectRatioFromPrompt(prompt);
const aspectRatio =
normalizedAspectRatio === "auto"
? await inferAspectRatioFromImageUrl(imageUrls[0] || "")
? promptAspectRatio || (await inferAspectRatioFromImageUrl(imageUrls[0] || ""))
: normalizedAspectRatio;
const imageSize = normalizeImageSizeLabel(options.imageParams?.imageSize);
const size =
@ -546,6 +550,7 @@ export function createOpenAIImage({ apiKey, baseURL }: OpenAIImageConfig) {
model: activeModelId,
size,
aspectRatio: aspectRatio || "auto",
promptAspectRatio: promptAspectRatio || undefined,
imageSize,
useBltcyFluxCompatParams: shouldUseBltcyFluxCompatParams(baseURL, activeModelId),
imageCount: imageUrls.length,

View File

@ -127,7 +127,7 @@
console.log('[DingTalk Callback] Got token:', data.token.substring(0, 10) + '...');
// 直接在这里完成 Auth.js 登录
completeLogin(data.token);
completeLogin(data.token, normalizeCallbackUrl(data.callbackUrl));
} else {
throw new Error(data.error || data.message || '登录失败');
}
@ -137,7 +137,18 @@
}
}
async function completeLogin(token) {
function normalizeCallbackUrl(callbackUrl) {
if (typeof callbackUrl !== 'string' || !callbackUrl) return '/';
try {
const parsed = new URL(callbackUrl, window.location.origin);
if (parsed.origin !== window.location.origin) return '/';
return parsed.pathname + parsed.search + parsed.hash;
} catch (error) {
return '/';
}
}
async function completeLogin(token, callbackUrl) {
try {
statusText.textContent = '正在登录系统...';
@ -157,7 +168,7 @@
body: new URLSearchParams({
csrfToken: csrfToken,
token: token,
callbackUrl: '/',
callbackUrl: callbackUrl,
}),
credentials: 'include',
});
@ -183,14 +194,14 @@
// 使用 window.top.location 确保跳转整个页面(即使在 iframe 中)
try {
if (window.top && window.top.location) {
window.top.location.replace('/');
window.top.location.replace(callbackUrl);
} else {
window.location.replace('/');
window.location.replace(callbackUrl);
}
} catch (e) {
// 如果跨域阻止,使用当前窗口跳转
console.warn('[DingTalk Callback] Cross-origin blocked, using current window');
window.location.replace('/');
window.location.replace(callbackUrl);
}
} else {
console.error('[DingTalk Callback] Session not created, retrying...');
@ -201,7 +212,7 @@
});
const retrySession = await retrySessionRes.json();
if (retrySession && retrySession.user) {
window.location.replace('/');
window.location.replace(callbackUrl);
} else {
throw new Error('登录失败:无法创建会话,请刷新页面重试');
}
@ -244,4 +255,4 @@
})();
</script>
</body>
</html>
</html>