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) => { const startDingTalkLoginForOrg = (orgName: string) => {
// 清除 URL 中的 token 参数,避免干扰 // 清除 URL 中的 token 参数,避免干扰
if ( if (
@ -385,14 +415,12 @@ export default function Page() {
window.history.replaceState({}, "", newUrl); window.history.replaceState({}, "", newUrl);
} }
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent( const loginUrl = buildDingTalkLoginUrl(orgName);
orgName,
)}`;
console.log("[DingTalk] Opening org login:", orgName, loginUrl); console.log("[DingTalk] Redirecting org login:", orgName, loginUrl);
setDingTalkIframeSrc(loginUrl);
setShowDingTalkModal(true); setShowDingTalkModal(true);
setShowOrgMenu(false); setShowOrgMenu(false);
window.location.assign(loginUrl);
}; };
// 钉钉登录 - 支持多组织 // 钉钉登录 - 支持多组织
@ -409,7 +437,6 @@ export default function Page() {
return; return;
} }
// 兼容:直接使用旧的方式在 iframe 中打开
if ( if (
typeof window === "undefined" || typeof window === "undefined" ||
typeof window.location === "undefined" typeof window.location === "undefined"
@ -424,27 +451,9 @@ export default function Page() {
window.history.replaceState({}, "", newUrl); window.history.replaceState({}, "", newUrl);
} }
const param = { const loginUrl = buildDingTalkLoginUrl();
redirect_uri: encodeURIComponent( console.log("[DingTalk] Redirecting OAuth login:", loginUrl);
window.location.origin + "/dingtalk-callback.html", window.location.assign(loginUrl);
),
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);
return; return;
} }
@ -633,17 +642,7 @@ export default function Page() {
<button <button
key={org} key={org}
type="button" type="button"
onClick={() => { onClick={() => startDingTalkLoginForOrg(org)}
const loginUrl = `/api/auth/dingtalk/login?org=${encodeURIComponent(
org,
)}`;
console.log(
"[DingTalk] Selected organization:",
org,
loginUrl,
);
setDingTalkIframeSrc(loginUrl);
}}
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" 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> <span>{org}</span>
@ -651,7 +650,7 @@ export default function Page() {
))} ))}
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
选择后将在下方加载钉钉登录页面。 选择后将跳转到钉钉完成授权。
</p> </p>
</div> </div>
)} )}

View File

@ -6,10 +6,52 @@ import { generateUUID } from "@/lib/utils";
import { signIn } from "@/app/(auth)/auth"; import { signIn } from "@/app/(auth)/auth";
import { eq } from "drizzle-orm"; 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) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const code = searchParams.get("code"); const code = searchParams.get("code");
const state = searchParams.get("state"); const state = searchParams.get("state");
const dingTalkState = parseDingTalkState(state);
console.log( console.log(
"[DingTalk Callback] Received code:", "[DingTalk Callback] Received code:",
@ -31,8 +73,8 @@ export async function GET(request: NextRequest) {
} }
const organizations = settings.dingTalkOrganizations ?? []; const organizations = settings.dingTalkOrganizations ?? [];
const selectedOrg = state const selectedOrg = dingTalkState.org
? organizations.find((org) => org.name === state) ? organizations.find((org) => org.name === dingTalkState.org)
: organizations[0]; : organizations[0];
const appKey = selectedOrg?.appKey ?? settings.dingTalkAppKey; const appKey = selectedOrg?.appKey ?? settings.dingTalkAppKey;
@ -290,6 +332,7 @@ export async function GET(request: NextRequest) {
departments: userInfo.departmentNames || [], departments: userInfo.departmentNames || [],
}, },
token, token,
callbackUrl: dingTalkState.callbackUrl,
}); });
} catch (error) { } catch (error) {

View File

@ -1,6 +1,24 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getAuthSettings } from "@/lib/db/queries"; 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) { export async function GET(request: Request) {
const settings = await getAuthSettings(); const settings = await getAuthSettings();
@ -34,8 +52,11 @@ export async function GET(request: Request) {
const baseUrl = process.env.AUTH_URL || requestUrl.origin; const baseUrl = process.env.AUTH_URL || requestUrl.origin;
const redirectUri = `${baseUrl}/dingtalk-callback.html`; const redirectUri = `${baseUrl}/dingtalk-callback.html`;
// 使用组织名称作为 state,便于回调时选择对应的配置 // state 中保存组织和登录后的回跳地址,回调接口仍兼容旧的纯组织名 state。
const state = orgName || selectedOrg?.name || "default"; const state = encodeState({
org: orgName || selectedOrg?.name || "default",
callbackUrl: normalizeCallbackUrl(urlObj.searchParams.get("callbackUrl")),
});
// 钉钉 OAuth 2.0 授权地址 // 钉钉 OAuth 2.0 授权地址
const url = const url =

View File

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

View File

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