From 286acefe4afe8669459dadf0ab6d38f571e8635f Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 26 May 2026 21:05:42 +0800 Subject: [PATCH] chore: harden production logging and redaction --- .github/workflows/lint.yml | 3 + app/(auth)/login/page.tsx | 90 ++++++++----- app/(chat)/api/files/upload/route.ts | 46 +++---- app/api/auth/dingtalk/callback/route.ts | 128 ++++++++++++------ lib/ai/providers/gemini-image.ts | 152 +++++++++++----------- lib/ai/providers/qianwen-video.ts | 62 ++++----- lib/ai/providers/volcano-vision.ts | 166 ++++++++++++------------ lib/observability/client-logger.ts | 70 ++++++++++ lib/observability/logger.ts | 80 ++++++++++++ lib/observability/redaction.ts | 97 ++++++++++++++ lib/storage/service.ts | 60 ++++----- package.json | 1 + scripts/check-log-hygiene.mjs | 35 +++++ scripts/check-log-redaction.ts | 34 +++++ 14 files changed, 710 insertions(+), 314 deletions(-) create mode 100644 lib/observability/client-logger.ts create mode 100644 lib/observability/logger.ts create mode 100644 lib/observability/redaction.ts create mode 100644 scripts/check-log-hygiene.mjs create mode 100644 scripts/check-log-redaction.ts diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 2b2d09164..7e185c7fa 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -30,6 +30,9 @@ jobs: - name: Run lint run: pnpm lint + - name: Check scoped log hygiene + run: pnpm check:logs + - name: Run typecheck run: pnpm exec tsc --noEmit --pretty false diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index d9bf95058..62b4069fd 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -20,7 +20,9 @@ import { toast } from "@/components/toast"; import { type LoginActionState, login } from "../actions"; import { Button } from "@/components/ui/button"; import { AntigravityBackground } from "@/components/antigravity-background"; +import { createClientLogger } from "@/lib/observability/client-logger"; +const logger = createClientLogger("auth-login"); export default function Page() { const [email, setEmail] = useState(""); const [isSuccessful, setIsSuccessful] = useState(false); @@ -86,14 +88,14 @@ export default function Page() { // 使用 token 完成钉钉登录 const completeDingTalkLogin = useCallback(async (token: string) => { - console.log("[DingTalk] Completing login with token:", token.substring(0, 10) + "..."); + logger.debug("[DingTalk] Completing login with token", { hasToken: Boolean(token) }); setIsHandlingToken(true); try { // 获取 CSRF token const csrfRes = await fetch("/api/auth/csrf"); const { csrfToken } = await csrfRes.json(); - console.log("[DingTalk] Got CSRF token"); + logger.debug("[DingTalk] CSRF token resolved"); // 手动构造登录请求 const loginRes = await fetch("/api/auth/callback/credentials", { @@ -109,16 +111,17 @@ export default function Page() { credentials: "include", // 重要:确保 cookie 被发送和接收 }); - console.log("[DingTalk] Login response status:", loginRes.status); - console.log("[DingTalk] Login response headers:", Object.fromEntries(loginRes.headers.entries())); - // 检查响应类型 const contentType = loginRes.headers.get("content-type"); - console.log("[DingTalk] Content-Type:", contentType); + logger.debug("[DingTalk] Login response received", { + status: loginRes.status, + redirected: loginRes.redirected, + contentType, + }); if (loginRes.redirected || loginRes.status === 302) { // 登录成功,被重定向 - console.log("[DingTalk] Login successful (redirected to:", loginRes.url, ")"); + logger.debug("[DingTalk] Login redirected successfully"); // 等待一小段时间确保 cookie 被设置 await new Promise(resolve => setTimeout(resolve, 300)); @@ -126,10 +129,12 @@ export default function Page() { // 验证 session 是否创建成功 const sessionRes = await fetch("/api/auth/session"); const session = await sessionRes.json(); - console.log("[DingTalk] Session after login:", session); + logger.debug("[DingTalk] Session validation completed", { + hasUser: Boolean(session?.user), + }); if (session && session.user) { - console.log("[DingTalk] Session validated, redirecting to home..."); + logger.debug("[DingTalk] Session validated, redirecting to home..."); setIsSuccessful(true); // 使用 replace 而不是 href,避免在历史记录中留下登录页 // 增加短暂延迟确保 cookie 完全同步 @@ -137,17 +142,21 @@ export default function Page() { window.location.replace("/"); }, 200); } else { - console.error("[DingTalk] Session not created after login"); + logger.error("[DingTalk] Session not created after login"); setIsHandlingToken(false); toast({ type: "error", description: "登录失败:无法创建会话" }); } } else if (contentType?.includes("application/json")) { const result = await loginRes.json(); - console.log("[DingTalk] Login result:", result); + logger.debug("[DingTalk] Login JSON response received", { + ok: loginRes.ok, + hasUrl: Boolean(result?.url), + hasError: Boolean(result?.error), + }); if (result.url || loginRes.ok) { // 登录成功 - console.log("[DingTalk] Login successful"); + logger.debug("[DingTalk] Login successful"); // 等待一小段时间确保 cookie 被设置 await new Promise(resolve => setTimeout(resolve, 300)); @@ -155,10 +164,12 @@ export default function Page() { // 验证 session const sessionRes = await fetch("/api/auth/session"); const session = await sessionRes.json(); - console.log("[DingTalk] Session after login:", session); + logger.debug("[DingTalk] Session validation completed", { + hasUser: Boolean(session?.user), + }); if (session && session.user) { - console.log("[DingTalk] Session validated, redirecting..."); + logger.debug("[DingTalk] Session validated, redirecting..."); setIsSuccessful(true); // 使用 replace 而不是 href,避免在历史记录中留下登录页 // 增加短暂延迟确保 cookie 完全同步 @@ -166,25 +177,30 @@ export default function Page() { window.location.replace(result.url || "/"); }, 200); } else { - console.error("[DingTalk] Session not created"); + logger.error("[DingTalk] Session not created"); setIsHandlingToken(false); toast({ type: "error", description: "登录失败:无法创建会话" }); } } else { // 登录失败 - console.error("[DingTalk] Login failed:", result); + logger.error("[DingTalk] Login failed", { + hasError: Boolean(result?.error), + }); setIsHandlingToken(false); toast({ type: "error", description: result.error || "登录失败,请重试" }); } } else { // 未知响应 - const responseText = await loginRes.text(); - console.error("[DingTalk] Unexpected response type. Body:", responseText.substring(0, 200)); + await loginRes.text(); + logger.error("[DingTalk] Unexpected response type", { + status: loginRes.status, + contentType, + }); setIsHandlingToken(false); toast({ type: "error", description: "登录失败:服务器响应异常" }); } } catch (error) { - console.error("[DingTalk] Login error:", error); + logger.error("[DingTalk] Login error:", error); setIsHandlingToken(false); toast({ type: "error", description: "登录发生错误:" + (error instanceof Error ? error.message : String(error)) }); } @@ -195,7 +211,7 @@ export default function Page() { const handleChunkError = (event: ErrorEvent) => { const target = event.target as any; if (target?.src?.includes("/_next/static/chunks/") || event.message?.includes("Failed to load chunk")) { - console.error("[ChunkError] 检测到chunk加载失败,3秒后自动刷新页面"); + logger.error("[ChunkError] 检测到chunk加载失败,3秒后自动刷新页面"); // 3秒后自动刷新页面(清除缓存) setTimeout(() => { window.location.reload(); @@ -211,33 +227,31 @@ export default function Page() { useEffect(() => { setMounted(true); - // 测试标记 - 确认代码已更新 - console.log("=".repeat(50)); - console.log("🔔 登录页面代码版本: 2024-12-10 v3.0"); - console.log("=".repeat(50)); - // 获取网站设置(添加时间戳防止缓存) fetch(`/api/site-settings?scope=auth&t=${Date.now()}`) .then((res) => res.json()) .then((data) => { - console.log("[Login] Site settings data:", data); + logger.debug("[Login] Site settings loaded", { + hasSiteName: Boolean(data.siteName), + hasSiteLogo: Boolean(data.siteLogo), + hasNotification: Boolean(data.notification), + }); if (data.siteName) setSiteName(data.siteName); if (data.siteLogo) setSiteLogo(data.siteLogo); if (data.siteLogoDark) setSiteLogoDark(data.siteLogoDark); // 处理系统通知 - console.log("[Login] Notification data:", data.notification); if (data.notification && data.notification.enabled) { - console.log("[Login] Setting notification and showing dialog"); + logger.debug("[Login] Setting notification and showing dialog"); setNotification(data.notification); // 每次访问登录页都显示通知(重要公告) setShowNotification(true); } else { - console.log("[Login] Notification not enabled or missing"); + logger.debug("[Login] Notification not enabled or missing"); } }) .catch((err) => { - console.error("[Login] Failed to fetch site settings:", err); + logger.error("[Login] Failed to fetch site settings:", err); }); // 获取认证设置 @@ -268,7 +282,9 @@ export default function Page() { setIsRegistrationEnabled(data.isRegistrationEnabled); } }) - .catch(console.error); + .catch((error) => { + logger.error("[Login] Failed to fetch auth settings", error); + }); }, []); const handleGoogleLogin = async () => { @@ -297,7 +313,10 @@ export default function Page() { // 监听来自钉钉回调页面的消息 useEffect(() => { const handleMessage = (event: MessageEvent) => { - console.log("[DingTalk] Received message:", event.data); + logger.debug("[DingTalk] Received message", { + type: event.data?.type, + hasToken: Boolean(event.data?.token), + }); if (event.data && event.data.type === "dingtalk_login_success") { setShowDingTalkModal(false); setDingTalkIframeSrc(""); @@ -417,7 +436,10 @@ export default function Page() { const loginUrl = buildDingTalkLoginUrl(orgName); - console.log("[DingTalk] Redirecting org login:", orgName, loginUrl); + logger.debug("[DingTalk] Redirecting org login", { + hasOrg: Boolean(orgName), + loginUrl, + }); setShowDingTalkModal(true); setShowOrgMenu(false); window.location.assign(loginUrl); @@ -452,7 +474,7 @@ export default function Page() { } const loginUrl = buildDingTalkLoginUrl(); - console.log("[DingTalk] Redirecting OAuth login:", loginUrl); + logger.debug("[DingTalk] Redirecting OAuth login", { loginUrl }); window.location.assign(loginUrl); return; } diff --git a/app/(chat)/api/files/upload/route.ts b/app/(chat)/api/files/upload/route.ts index f2bc520e6..a6ed88444 100644 --- a/app/(chat)/api/files/upload/route.ts +++ b/app/(chat)/api/files/upload/route.ts @@ -6,7 +6,9 @@ import { createAgentWorkflowPreviewImageBuffer } from "@/lib/agents/workflow-pre import { upsertMediaAsset } from "@/lib/db/queries"; import { isSiteAdminType } from "@/lib/middleware/admin"; import { uploadFile } from "@/lib/storage/service"; +import { createLogger } from "@/lib/observability/logger"; +const logger = createLogger("upload-api"); /** * 使用Sharp智能优化图片(保持原格式) * 策略:避免双重压缩,只在需要时优化 @@ -20,7 +22,7 @@ async function optimizeImage(buffer: Buffer, mimeType: string): Promise // ✅ 智能判断:如果文件已经很小,跳过优化(避免双重压缩) if (fileSizeMB < OPTIMIZE_THRESHOLD_MB) { - console.log(`[Upload] 文件已较小(${fileSizeMB.toFixed(2)}MB < ${OPTIMIZE_THRESHOLD_MB}MB),跳过优化,避免双重压缩`); + logger.debug(`[Upload] 文件已较小(${fileSizeMB.toFixed(2)}MB < ${OPTIMIZE_THRESHOLD_MB}MB),跳过优化,避免双重压缩`); return buffer; } @@ -45,10 +47,10 @@ async function optimizeImage(buffer: Buffer, mimeType: string): Promise // 只有优化后文件明显更小才使用(避免不必要的处理) const reductionPercent = ((1 - optimized.length / buffer.length) * 100); if (reductionPercent > 5) { // 只有减少超过5%才使用 - console.log(`[Upload] JPEG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`); + logger.debug(`[Upload] JPEG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`); return optimized; } else { - console.log(`[Upload] JPEG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`); + logger.debug(`[Upload] JPEG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`); return buffer; } } else if (mimeType === 'image/png') { @@ -67,10 +69,10 @@ async function optimizeImage(buffer: Buffer, mimeType: string): Promise // 只有优化后文件明显更小才使用 const reductionPercent = ((1 - optimized.length / buffer.length) * 100); if (reductionPercent > 5) { // 只有减少超过5%才使用 - console.log(`[Upload] PNG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`); + logger.debug(`[Upload] PNG优化: ${buffer.length} → ${optimized.length} bytes (减少${reductionPercent.toFixed(1)}%)`); return optimized; } else { - console.log(`[Upload] PNG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`); + logger.debug(`[Upload] PNG优化效果不明显(${reductionPercent.toFixed(1)}%),使用原图`); return buffer; } } @@ -78,7 +80,7 @@ async function optimizeImage(buffer: Buffer, mimeType: string): Promise // 其他格式返回原图 return buffer; } catch (error) { - console.error("[Upload] 图片优化失败,使用原图:", error); + logger.error("[Upload] 图片优化失败,使用原图:", error); // 优化失败时返回原图,不影响上传流程 return buffer; } @@ -109,17 +111,17 @@ const FileSchema = z.object({ }); export async function POST(request: Request) { - console.log("[Upload API] Received upload request"); + logger.debug("[Upload API] Received upload request"); const session = await auth(); if (!session) { - console.log("[Upload API] Unauthorized - no session"); + logger.debug("[Upload API] Unauthorized - no session"); return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } if (request.body === null) { - console.log("[Upload API] Request body is empty"); + logger.debug("[Upload API] Request body is empty"); return new Response("Request body is empty", { status: 400 }); } @@ -127,10 +129,10 @@ export async function POST(request: Request) { const formData = await request.formData(); const file = formData.get("file") as Blob; - console.log("[Upload API] FormData received, file exists:", !!file); + logger.debug("[Upload API] FormData received, file exists:", !!file); if (!file) { - console.log("[Upload API] No file in FormData"); + logger.debug("[Upload API] No file in FormData"); return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); } @@ -146,7 +148,7 @@ export async function POST(request: Request) { ); } - console.log("[Upload API] File info:", { + logger.debug("[Upload API] File info:", { type: file.type, size: file.size, sizeInMB: (file.size / 1024 / 1024).toFixed(2) + "MB" @@ -159,16 +161,16 @@ export async function POST(request: Request) { .map((error) => error.message) .join(", "); - console.log("[Upload API] Validation failed:", errorMessage); + logger.debug("[Upload API] Validation failed:", errorMessage); return NextResponse.json({ error: errorMessage }, { status: 400 }); } // Get filename from formData since Blob doesn't have name property const filename = (formData.get("file") as File).name; - console.log("[Upload API] Filename:", filename); + logger.debug("[Upload API] Filename:", filename); const fileBuffer = await file.arrayBuffer(); - console.log("[Upload API] File buffer size:", fileBuffer.byteLength); + logger.debug("[Upload API] File buffer size:", fileBuffer.byteLength); try { if (uploadPurpose === "agent-workflow-preview" && file.type !== "image/svg+xml") { @@ -202,7 +204,7 @@ export async function POST(request: Request) { }); assetId = asset?.id ?? null; } catch (assetError) { - console.warn("[Upload API] Failed to register workflow preview asset:", assetError); + logger.warn("[Upload API] Failed to register workflow preview asset:", assetError); } return NextResponse.json({ @@ -232,9 +234,9 @@ export async function POST(request: Request) { optimizedBuffer = await optimizeImage(optimizedBuffer, file.type); } else if (file.type === 'image/webp' || file.type === 'image/svg+xml') { // WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。 - console.log(`[Upload] ${file.type}格式,跳过优化,直接上传`); + logger.debug(`[Upload] ${file.type}格式,跳过优化,直接上传`); } else if (uploadPurpose === "canvas-upload") { - console.log("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop"); + logger.debug("[Upload] Canvas upload, skip server-side image optimization for faster paste/drop"); } // 将Buffer转换为ArrayBuffer用于上传 @@ -267,23 +269,23 @@ export async function POST(request: Request) { }); assetId = asset?.id ?? null; } catch (assetError) { - console.warn("[Upload API] Failed to register uploaded image as media asset:", assetError); + logger.warn("[Upload API] Failed to register uploaded image as media asset:", assetError); } - console.log("[Upload API] Upload successful:", data.url); + logger.debug("[Upload API] Upload successful:", data.url); return NextResponse.json({ ...data, assetId, }); } catch (_error) { - console.error("[Upload API] Upload failed:", _error); + logger.error("[Upload API] Upload failed:", _error); return NextResponse.json({ error: "Upload failed", details: _error instanceof Error ? _error.message : String(_error) }, { status: 500 }); } } catch (_error) { - console.error("[Upload API] Request processing failed:", _error); + logger.error("[Upload API] Request processing failed:", _error); return NextResponse.json( { error: "Failed to process request", diff --git a/app/api/auth/dingtalk/callback/route.ts b/app/api/auth/dingtalk/callback/route.ts index 85f320ea5..429be024e 100644 --- a/app/api/auth/dingtalk/callback/route.ts +++ b/app/api/auth/dingtalk/callback/route.ts @@ -5,7 +5,9 @@ import { loginToken, user } from "@/lib/db/schema"; import { generateUUID } from "@/lib/utils"; import { signIn } from "@/app/(auth)/auth"; import { eq } from "drizzle-orm"; +import { createLogger } from "@/lib/observability/logger"; +const logger = createLogger("dingtalk-callback"); const normalizeCallbackUrl = (value: unknown) => { if (typeof value !== "string" || !value) return "/"; @@ -37,7 +39,7 @@ const parseDingTalkState = (state: string | null) => { callbackUrl: normalizeCallbackUrl(payload.callbackUrl), }; } catch (error) { - console.warn("[DingTalk Callback] Failed to parse state payload:", error); + logger.warn("[DingTalk Callback] Failed to parse state payload:", error); } } @@ -53,12 +55,11 @@ export async function GET(request: NextRequest) { const state = searchParams.get("state"); const dingTalkState = parseDingTalkState(state); - console.log( - "[DingTalk Callback] Received code:", - code?.substring(0, 10) + "...", - "state:", - state, - ); + logger.debug("[DingTalk Callback] Received callback", { + hasCode: Boolean(code), + hasState: Boolean(state), + hasOrg: Boolean(dingTalkState.org), + }); if (!code) { return NextResponse.json({ success: false, error: "Missing code" }, { status: 400 }); @@ -89,7 +90,7 @@ export async function GET(request: NextRequest) { try { // 1. 获取 userAccessToken - console.log("[DingTalk Callback] Getting user access token..."); + logger.debug("[DingTalk Callback] Getting user access token..."); const tokenResponse = await fetch( "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", { @@ -105,15 +106,21 @@ export async function GET(request: NextRequest) { ); const tokenData = await tokenResponse.json(); - console.log("[DingTalk Callback] Token response:", tokenData.accessToken ? "success" : "failed"); + logger.debug("[DingTalk Callback] Token response received", { + ok: Boolean(tokenData.accessToken), + status: tokenResponse.status, + }); if (!tokenData.accessToken) { - console.error("[DingTalk Callback] Token Error:", tokenData); + logger.error("[DingTalk Callback] Token exchange failed", { + status: tokenResponse.status, + hasMessage: Boolean(tokenData.message), + }); return NextResponse.json({ success: false, error: "Failed to get access token: " + (tokenData.message || JSON.stringify(tokenData)) }, { status: 400 }); } // 2. 获取用户 unionId - console.log("[DingTalk Callback] Getting user unionId..."); + logger.debug("[DingTalk Callback] Getting user unionId..."); const userMeResponse = await fetch("https://api.dingtalk.com/v1.0/contact/users/me", { headers: { "x-acs-dingtalk-access-token": tokenData.accessToken, @@ -121,15 +128,20 @@ export async function GET(request: NextRequest) { }); const userMeData = await userMeResponse.json(); - console.log("[DingTalk Callback] User me response:", userMeData.unionId ? "success" : "failed"); + logger.debug("[DingTalk Callback] User identity response received", { + ok: Boolean(userMeData.unionId), + status: userMeResponse.status, + }); if (!userMeData.unionId) { - console.error("[DingTalk Callback] User Me Error:", userMeData); + logger.error("[DingTalk Callback] User identity lookup failed", { + status: userMeResponse.status, + }); return NextResponse.json({ success: false, error: "Failed to get user unionId" }, { status: 400 }); } // 3. 获取应用访问令牌 (用于获取更详细的用户信息) - console.log("[DingTalk Callback] Getting app access token..."); + logger.debug("[DingTalk Callback] Getting app access token..."); const appTokenResponse = await fetch( `https://oapi.dingtalk.com/gettoken?appkey=${encodeURIComponent( appKey, @@ -138,19 +150,25 @@ export async function GET(request: NextRequest) { const appTokenData = await appTokenResponse.json(); if (!appTokenData.access_token) { - console.error("[DingTalk Callback] App Token Error:", appTokenData); + logger.error("[DingTalk Callback] App token lookup failed", { + status: appTokenResponse.status, + hasMessage: Boolean(appTokenData.message), + }); return NextResponse.json({ success: false, error: "Failed to get app access token" }, { status: 400 }); } // 4. 根据 unionId 获取 userId - console.log("[DingTalk Callback] Getting userId by unionId..."); + logger.debug("[DingTalk Callback] Getting userId by unionId..."); const userIdResponse = await fetch(`https://oapi.dingtalk.com/topapi/user/getbyunionid?access_token=${appTokenData.access_token}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ unionid: userMeData.unionId }), }); const userIdData = await userIdResponse.json(); - console.log("[DingTalk Callback] UserId response:", userIdData); + logger.debug("[DingTalk Callback] UserId response received", { + ok: Boolean(userIdData.result?.userid), + status: userIdResponse.status, + }); let userInfo: any = { unionId: userMeData.unionId, @@ -160,7 +178,7 @@ export async function GET(request: NextRequest) { // 如果能获取到 userId,则获取更详细的用户信息 if (userIdData.result && userIdData.result.userid) { - console.log("[DingTalk Callback] Getting detailed user info..."); + logger.debug("[DingTalk Callback] Getting detailed user info..."); const detailResponse = await fetch(`https://oapi.dingtalk.com/topapi/v2/user/get?access_token=${appTokenData.access_token}`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -174,7 +192,9 @@ export async function GET(request: NextRequest) { const deptIdList = detailData.result.dept_id_list || []; if (deptIdList.length > 0) { - console.log("[DingTalk Callback] Getting department info for dept_ids:", deptIdList); + logger.debug("[DingTalk Callback] Getting department info", { + departmentCount: deptIdList.length, + }); try { // 获取每个部门的详细信息 for (const deptId of deptIdList) { @@ -188,9 +208,11 @@ export async function GET(request: NextRequest) { departmentNames.push(deptData.result.name); } } - console.log("[DingTalk Callback] Department names:", departmentNames); + logger.debug("[DingTalk Callback] Department info resolved", { + departmentCount: departmentNames.length, + }); } catch (deptError) { - console.error("[DingTalk Callback] Failed to get department info:", deptError); + logger.error("[DingTalk Callback] Failed to get department info:", deptError); } } @@ -209,16 +231,20 @@ export async function GET(request: NextRequest) { } } - console.log("[DingTalk Callback] Final user info:", { - ...userInfo, - mobile: "***", - departments: userInfo.departmentNames?.join(", ") || "无部门信息" + logger.debug("[DingTalk Callback] Final user info resolved", { + hasEmail: Boolean(userInfo.email), + hasName: Boolean(userInfo.name || userInfo.nick), + hasMobile: Boolean(userInfo.mobile), + departmentCount: userInfo.departmentNames?.length || 0, }); // 5. 查找或创建用户 // 使用 unionId 作为唯一标识,email 可能为空 const email = userInfo.email || `dingtalk_${userInfo.unionId}@dingtalk.local`; - console.log("[DingTalk Callback] Checking user:", email); + logger.debug("[DingTalk Callback] Checking user", { + hasEmail: Boolean(email), + isSyntheticEmail: !userInfo.email, + }); // 检查用户是否存在(直接使用 Drizzle 查询,不通过 getUser) let existingUsers; @@ -227,17 +253,17 @@ export async function GET(request: NextRequest) { .select() .from(user) .where(eq(user.email, email)); - console.log("[DingTalk Callback] User found:", existingUsers.length > 0); + logger.debug("[DingTalk Callback] User found:", existingUsers.length > 0); if (existingUsers.length > 0) { - console.log("[DingTalk Callback] User details:", { + logger.debug("[DingTalk Callback] User details:", { id: existingUsers[0].id, - email: existingUsers[0].email, - name: existingUsers[0].name, + hasEmail: Boolean(existingUsers[0].email), + hasName: Boolean(existingUsers[0].name), isActive: existingUsers[0].isActive }); } } catch (error) { - console.error("[DingTalk Callback] Error querying user:", error); + logger.error("[DingTalk Callback] Error querying user:", error); throw error; } @@ -245,15 +271,23 @@ export async function GET(request: NextRequest) { const departmentStr = userInfo.departmentNames?.length > 0 ? userInfo.departmentNames.join(" / ") : null; - console.log("[DingTalk Callback] Department string:", departmentStr); + logger.debug("[DingTalk Callback] Department string resolved", { + hasDepartment: Boolean(departmentStr), + }); // 获取钉钉用户ID const dingtalkUserId = userInfo.userId || null; - console.log("[DingTalk Callback] DingTalk userId:", dingtalkUserId); + logger.debug("[DingTalk Callback] DingTalk user id resolved", { + hasUserId: Boolean(dingtalkUserId), + }); if (existingUsers.length === 0) { // 创建新用户 - console.log("[DingTalk Callback] Creating new user:", email, "with name:", userInfo.name || userInfo.nick); + logger.debug("[DingTalk Callback] Creating new user", { + hasName: Boolean(userInfo.name || userInfo.nick), + hasDepartment: Boolean(departmentStr), + hasDingTalkUserId: Boolean(dingtalkUserId), + }); try { await db.insert(user).values({ email, @@ -264,20 +298,26 @@ export async function GET(request: NextRequest) { role: "regular", isActive: true, }); - console.log("[DingTalk Callback] User created successfully with department:", departmentStr, ", dingtalkUserId:", dingtalkUserId); + logger.debug("[DingTalk Callback] User created successfully", { + hasDepartment: Boolean(departmentStr), + hasDingTalkUserId: Boolean(dingtalkUserId), + }); // 重新查询用户 existingUsers = await db .select() .from(user) .where(eq(user.email, email)); } catch (error) { - console.error("[DingTalk Callback] Error creating user:", error); + logger.error("[DingTalk Callback] Error creating user:", error); throw error; } } else { // 更新现有用户的姓名、部门信息和钉钉用户ID const existingUser = existingUsers[0]; - console.log("[DingTalk Callback] Existing user found, current name:", existingUser.name, ", department:", (existingUser as any).department); + logger.debug("[DingTalk Callback] Existing user found", { + hasName: Boolean(existingUser.name), + hasDepartment: Boolean((existingUser as any).department), + }); const updateData: Record = {}; @@ -297,14 +337,16 @@ export async function GET(request: NextRequest) { } if (Object.keys(updateData).length > 0) { - console.log("[DingTalk Callback] Updating user info:", updateData); + logger.debug("[DingTalk Callback] Updating user info", { + fields: Object.keys(updateData), + }); try { await db.update(user) .set(updateData) .where(eq(user.email, email)); - console.log("[DingTalk Callback] User info updated successfully"); + logger.debug("[DingTalk Callback] User info updated successfully"); } catch (error) { - console.error("[DingTalk Callback] Error updating user info:", error); + logger.error("[DingTalk Callback] Error updating user info:", error); // 不抛出错误,继续登录流程 } } @@ -318,7 +360,9 @@ export async function GET(request: NextRequest) { expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5分钟有效期 }); - console.log("[DingTalk Callback] Login successful for:", email); + logger.debug("[DingTalk Callback] Login successful", { + isSyntheticEmail: !userInfo.email, + }); // 7. 返回成功响应(给 iframe 中的回调页面) return NextResponse.json({ @@ -336,7 +380,7 @@ export async function GET(request: NextRequest) { }); } catch (error) { - console.error("[DingTalk Callback] Error:", error); + logger.error("[DingTalk Callback] Error:", error); return NextResponse.json({ success: false, error: "Internal server error: " + (error as Error).message }, { status: 500 }); } } diff --git a/lib/ai/providers/gemini-image.ts b/lib/ai/providers/gemini-image.ts index 0333a7223..d8200b528 100644 --- a/lib/ai/providers/gemini-image.ts +++ b/lib/ai/providers/gemini-image.ts @@ -2,7 +2,9 @@ import "server-only"; import { resolveGrsaiGptImageSize } from "@/lib/ai/grsai-gpt-image-sizes"; import { uploadFile } from "@/lib/storage/service"; +import { createLogger } from "@/lib/observability/logger"; +const logger = createLogger("gemini-image"); interface GeminiImageConfig { apiKey: string; baseURL: string; @@ -24,10 +26,10 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise { if (url.startsWith("data:")) { try { - console.log("[Gemini Image] Converting Base64 reference image to S3 URL..."); + logger.debug("[Gemini Image] Converting Base64 reference image to S3 URL..."); // 解析 Base64 const matches = url.match(/^data:([A-Za-z-+\/]+);base64,(.+)$/); if (!matches) { - console.warn("[Gemini Image] Invalid Base64 format, skipping upload"); + logger.warn("[Gemini Image] Invalid Base64 format, skipping upload"); return url; } @@ -58,10 +60,10 @@ async function ensureImageUrl(url: string): Promise { // 上传到 S3(需要转换为 ArrayBuffer) const arrayBuffer = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer; const result = await uploadFile(filename, arrayBuffer); - console.log(`[Gemini Image] Uploaded reference image to: ${result.url}`); + logger.debug(`[Gemini Image] Uploaded reference image to: ${result.url}`); return result.url; } catch (error) { - console.error("[Gemini Image] Failed to upload reference image to S3:", error); + logger.error("[Gemini Image] Failed to upload reference image to S3:", error); // 如果上传失败,返回原 Base64(虽然可能也会失败,但没别的办法) return url; } @@ -106,7 +108,7 @@ async function inferImageAspectRatioFromUrl(url: string): Promise return `${metadata.width}:${metadata.height}`; } catch (error) { - console.warn("[Gemini Image] Failed to infer reference aspect ratio:", error); + logger.warn("[Gemini Image] Failed to infer reference aspect ratio:", error); return null; } } @@ -126,7 +128,7 @@ async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jp const thumbnailFilename = `gemini-thumb-${timestamp}-${randomStr}.${ext}`; const originalFilename = `gemini-orig-${timestamp}-${randomStr}.${ext}`; - console.log(`[Gemini Image] Original size: ${buffer.length} bytes`); + logger.debug(`[Gemini Image] Original size: ${buffer.length} bytes`); // 生成缩略图 const thumbnailBuffer = await createThumbnail(buffer, mimeType); @@ -142,22 +144,22 @@ async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jp ) as ArrayBuffer; // 并行上传缩略图和原图到 S3 - console.log(`[Gemini Image] Uploading thumbnail and original to S3...`); + logger.debug(`[Gemini Image] Uploading thumbnail and original to S3...`); const [thumbnailResult, originalResult] = await Promise.all([ uploadFile(thumbnailFilename, thumbnailArrayBuffer), uploadFile(originalFilename, originalArrayBuffer), ]); - console.log(`[Gemini Image] Uploaded to S3:`); - console.log(` - Thumbnail: ${thumbnailResult.url}`); - console.log(` - Original: ${originalResult.url}`); + logger.debug(`[Gemini Image] Uploaded to S3:`); + logger.debug(` - Thumbnail: ${thumbnailResult.url}`); + logger.debug(` - Original: ${originalResult.url}`); return { thumbnail: thumbnailResult.url, original: originalResult.url, }; } catch (error) { - console.error("[Gemini Image] Failed to upload to S3:", error); + logger.error("[Gemini Image] Failed to upload to S3:", error); // 如果上传失败,抛出错误,不要返回 Base64,否则会导致前端崩溃和数据库保存失败 throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`); } @@ -170,7 +172,7 @@ async function uploadBase64ToS3(base64Data: string, mimeType: string = "image/jp async function downloadAndUploadToS3(imageUrl: string, retries = 3): Promise<{ thumbnail: string; original: string }> { for (let attempt = 0; attempt < retries; attempt++) { try { - console.log(`[Gemini Image] Downloading image from URL (attempt ${attempt + 1}/${retries}): ${imageUrl.substring(0, 100)}...`); + logger.debug(`[Gemini Image] Downloading image from URL (attempt ${attempt + 1}/${retries}): ${imageUrl.substring(0, 100)}...`); // 下载图片(增加超时时间到 30 秒) const controller = new AbortController(); @@ -207,7 +209,7 @@ async function downloadAndUploadToS3(imageUrl: string, retries = 3): Promise<{ t const thumbnailFilename = `gemini-thumb-${timestamp}-${randomStr}.${ext}`; const originalFilename = `gemini-orig-${timestamp}-${randomStr}.${ext}`; - console.log(`[Gemini Image] Downloaded size: ${buffer.length} bytes`); + logger.debug(`[Gemini Image] Downloaded size: ${buffer.length} bytes`); // 生成缩略图 const thumbnailBuffer = await createThumbnail(buffer, contentType); @@ -223,15 +225,15 @@ async function downloadAndUploadToS3(imageUrl: string, retries = 3): Promise<{ t ) as ArrayBuffer; // 并行上传缩略图和原图到 S3 - console.log(`[Gemini Image] Uploading downloaded image to S3...`); + logger.debug(`[Gemini Image] Uploading downloaded image to S3...`); const [thumbnailResult, originalResult] = await Promise.all([ uploadFile(thumbnailFilename, thumbnailArrayBuffer), uploadFile(originalFilename, originalArrayBuffer), ]); - console.log(`[Gemini Image] Uploaded to S3:`); - console.log(` - Thumbnail: ${thumbnailResult.url}`); - console.log(` - Original: ${originalResult.url}`); + logger.debug(`[Gemini Image] Uploaded to S3:`); + logger.debug(` - Thumbnail: ${thumbnailResult.url}`); + logger.debug(` - Original: ${originalResult.url}`); return { thumbnail: thumbnailResult.url, @@ -239,7 +241,7 @@ async function downloadAndUploadToS3(imageUrl: string, retries = 3): Promise<{ t }; } catch (error) { const isLastAttempt = attempt === retries - 1; - console.error( + logger.error( `[Gemini Image] Download attempt ${attempt + 1}/${retries} failed:`, error instanceof Error ? error.message : String(error) ); @@ -253,7 +255,7 @@ async function downloadAndUploadToS3(imageUrl: string, retries = 3): Promise<{ t // 等待后重试(指数退避) const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s... - console.log(`[Gemini Image] Retrying in ${delay}ms...`); + logger.debug(`[Gemini Image] Retrying in ${delay}ms...`); await new Promise((resolve) => setTimeout(resolve, delay)); } } @@ -313,7 +315,7 @@ function extractImageDataFromText(text: string): Array<{ url: string; isBase64: * 返回图片数据数组(包含缩略图和原图 URL) */ async function extractAndUploadImages(response: any): Promise> { - console.log("[Gemini Image] Extracting images from response:", JSON.stringify(response).substring(0, 500)); // 🔍 调试日志 + logger.debug("[Gemini Image] Extracting images from response:", JSON.stringify(response).substring(0, 500)); // 🔍 调试日志 const images: Array<{ thumbnail: string; original: string }> = []; const uploadPromises: Promise[] = []; @@ -321,7 +323,7 @@ async function extractAndUploadImages(response: any): Promise 0) { const content = response.choices[0]?.message?.content; if (content && typeof content === "string") { - console.log("[Gemini Image] Parsing OpenAI format, content:", content.substring(0, 100) + "..."); + logger.debug("[Gemini Image] Parsing OpenAI format, content:", content.substring(0, 100) + "..."); const imageData = extractImageDataFromText(content); for (const img of imageData) { @@ -332,14 +334,14 @@ async function extractAndUploadImages(response: any): Promise { - console.log("[Gemini Image] Successfully downloaded and uploaded external image to S3"); + logger.debug("[Gemini Image] Successfully downloaded and uploaded external image to S3"); images.push(urls); }) .catch(error => { - console.error("[Gemini Image] Failed to download/upload URL after retries:", error); + logger.error("[Gemini Image] Failed to download/upload URL after retries:", error); // 不再 fallback 到原始 URL,而是抛出错误 throw new Error(`无法下载并上传图片到国内中转: ${error instanceof Error ? error.message : "未知错误"}`); }); @@ -351,19 +353,19 @@ async function extractAndUploadImages(response: any): Promise { - console.log("[Gemini Image] Successfully downloaded and uploaded Nano Banana image to S3"); + logger.debug("[Gemini Image] Successfully downloaded and uploaded Nano Banana image to S3"); images.push(urls); }) .catch(error => { - console.error("[Gemini Image] Failed to download/upload Nano Banana URL after retries:", error); + logger.error("[Gemini Image] Failed to download/upload Nano Banana URL after retries:", error); // 不再 fallback 到原始 URL,而是抛出错误 throw new Error(`无法下载并上传图片到国内中转: ${error instanceof Error ? error.message : "未知错误"}`); }); @@ -386,14 +388,14 @@ async function extractAndUploadImages(response: any): Promise { - console.log("[Gemini Image] Successfully downloaded and uploaded Gemini external image to S3"); + logger.debug("[Gemini Image] Successfully downloaded and uploaded Gemini external image to S3"); images.push(urls); }) .catch(error => { - console.error("[Gemini Image] Failed to download/upload URL in Gemini response after retries:", error); + logger.error("[Gemini Image] Failed to download/upload URL in Gemini response after retries:", error); // 不再 fallback 到原始 URL,而是抛出错误 throw new Error(`无法下载并上传图片到国内中转: ${error instanceof Error ? error.message : "未知错误"}`); }); @@ -420,7 +422,7 @@ async function extractAndUploadImages(response: any): Promise 0) { - console.log(`${apiLogPrefix} Processing ${urls.length} reference images...`); + logger.debug(`${apiLogPrefix} Processing ${urls.length} reference images...`); for (const url of urls) { const s3Url = await ensureImageUrl(url); processedUrls.push(s3Url); } } - console.log(`${apiLogPrefix} Sending request to:`, url); - console.log(`${apiLogPrefix} Using model:`, modelId); - console.log(`${apiLogPrefix} Prompt:`, prompt.substring(0, 100)); - console.log(`${apiLogPrefix} Reference image count:`, urls.length); - console.log(`${apiLogPrefix} Image params:`, imageParams); + logger.debug(`${apiLogPrefix} Sending request to:`, url); + logger.debug(`${apiLogPrefix} Using model:`, modelId); + logger.debug(`${apiLogPrefix} Prompt:`, prompt.substring(0, 100)); + logger.debug(`${apiLogPrefix} Reference image count:`, urls.length); + logger.debug(`${apiLogPrefix} Image params:`, imageParams); // 使用对应模型的原生格式 const requestBody: any = { @@ -533,9 +535,9 @@ async function sendGeminiRequest( requestBody.size = resolveGrsaiGptImageSize(resolvedAspectRatio, imageParams?.imageSize); requestBody.shutProgress = false; if (inferredAspectRatio) { - console.log(`${apiLogPrefix} Inferred reference aspect ratio: ${inferredAspectRatio}`); + logger.debug(`${apiLogPrefix} Inferred reference aspect ratio: ${inferredAspectRatio}`); } - console.log( + logger.debug( `${apiLogPrefix} Resolved size: aspectRatio=${resolvedAspectRatio}, imageSize=${imageParams?.imageSize || "2K"} => ${requestBody.size}` ); } else { @@ -548,11 +550,11 @@ async function sendGeminiRequest( } } - console.log(`${apiLogPrefix} Request body (truncated):`, JSON.stringify(requestBody).substring(0, 500) + "..."); + logger.debug(`${apiLogPrefix} Request body (truncated):`, JSON.stringify(requestBody).substring(0, 500) + "..."); // 根据分辨率动态设置超时时间 const timeout = imageParams?.imageSize === "4K" ? 300000 : 120000; // 4K: 5分钟, 其他: 2分钟 - console.log(`${apiLogPrefix} Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`); + logger.debug(`${apiLogPrefix} Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); @@ -572,14 +574,14 @@ async function sendGeminiRequest( if (!response.ok) { const errorText = await response.text(); - console.error(`${apiLogPrefix} Request failed:`, response.status, errorText); + logger.error(`${apiLogPrefix} Request failed:`, response.status, errorText); throw new Error( `${isGptImageModel ? "Grsai GPT Image" : "Gemini"} API error: ${response.status} ${response.statusText}` ); } // 处理流式响应(SSE格式) - console.log(`${apiLogPrefix} Reading stream response...`); + logger.debug(`${apiLogPrefix} Reading stream response...`); const reader = response.body?.getReader(); const decoder = new TextDecoder(); let buffer = ""; @@ -593,7 +595,7 @@ async function sendGeminiRequest( const { done, value } = await reader.read(); if (done) { - console.log("[Gemini Image] Stream completed"); + logger.debug("[Gemini Image] Stream completed"); break; } @@ -618,13 +620,13 @@ async function sendGeminiRequest( // 记录进度 if (data.progress !== undefined) { - console.log(`[Gemini Image] Progress: ${data.progress}%`); + logger.debug(`[Gemini Image] Progress: ${data.progress}%`); } // 如果成功完成,立即返回 if (data.status === "succeeded") { - console.log("[Gemini Image] Generation succeeded!"); - console.log("[Gemini Image] Full success data:", JSON.stringify(data).substring(0, 1000)); // 🔍 打印完整成功数据 + logger.debug("[Gemini Image] Generation succeeded!"); + logger.debug("[Gemini Image] Full success data:", JSON.stringify(data).substring(0, 1000)); // 🔍 打印完整成功数据 return data as GeminiResponse; } @@ -637,7 +639,7 @@ async function sendGeminiRequest( part && arr.findIndex((candidate) => candidate === part) === index ); const errorMsg = `图片生成失败: ${detailParts.join(" / ") || "未知错误"}`; - console.error("[Gemini Image] Generation failed:", data.failure_reason, data.error); + logger.error("[Gemini Image] Generation failed:", data.failure_reason, data.error); clearTimeout(timeoutId); throw new Error(errorMsg); } @@ -646,7 +648,7 @@ async function sendGeminiRequest( if (e instanceof Error && e.message.includes("图片生成失败")) { throw e; // 重新抛出失败错误 } - console.warn("[Gemini Image] Failed to parse SSE line:", trimmedLine); + logger.warn("[Gemini Image] Failed to parse SSE line:", trimmedLine); } } } @@ -654,7 +656,7 @@ async function sendGeminiRequest( // 流结束后,返回最后一个有效数据 if (lastValidData) { - console.log("[Gemini Image] Returning last valid data"); + logger.debug("[Gemini Image] Returning last valid data"); return lastValidData as GeminiResponse; } @@ -663,7 +665,7 @@ async function sendGeminiRequest( clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { const timeoutSeconds = Math.round(timeout / 1000); - console.error(`${apiLogPrefix} Request timeout after ${timeoutSeconds} seconds`); + logger.error(`${apiLogPrefix} Request timeout after ${timeoutSeconds} seconds`); throw new Error( `${isGptImageModel ? "Grsai GPT Image" : "Gemini"} 请求超时(${timeoutSeconds}秒),将自动切换备用模型` ); @@ -680,12 +682,12 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { return { chat: (modelId: string) => ({ doGenerate: async (options: any) => { - console.log("[Gemini Image] doGenerate called!"); // 调试日志 - console.log("[Gemini Image] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); + logger.debug("[Gemini Image] doGenerate called!"); // 调试日志 + logger.debug("[Gemini Image] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); // 🆕 支持覆盖 modelId(用于备用模型切换) const effectiveModelId = options.overrideModelId || modelId; - console.log("[Gemini Image] Using modelId:", effectiveModelId, options.overrideModelId ? "(overridden)" : "(default)"); - console.log("[Gemini Image] Image params from options:", options.imageParams); + logger.debug("[Gemini Image] Using modelId:", effectiveModelId, options.overrideModelId ? "(overridden)" : "(default)"); + logger.debug("[Gemini Image] Image params from options:", options.imageParams); try { // 直接传递完整的消息数组(包括文本和图片) @@ -696,11 +698,11 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { }; // 🆕 获取用户选择的图片数量,默认 1 张 const imageCount = Math.min(Math.max(options.imageParams?.imageCount || 1, 1), 2); // Gemini 限制 1-2 张 - console.log("[Gemini Image] Final imageParams after defaults:", imageParams); - console.log(`[Gemini Image] Image count: ${imageCount}`); + logger.debug("[Gemini Image] Final imageParams after defaults:", imageParams); + logger.debug(`[Gemini Image] Image count: ${imageCount}`); // 🆕 根据用户选择的数量发起请求 - console.log(`[Gemini Image] Starting ${imageCount} concurrent request(s)...`); + logger.debug(`[Gemini Image] Starting ${imageCount} concurrent request(s)...`); const startTime = Date.now(); // 创建请求数组(使用 effectiveModelId) @@ -710,7 +712,7 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { const responses = await Promise.all(requestPromises); - console.log(`[Gemini Image] All ${imageCount} request(s) completed in ${Date.now() - startTime}ms`); + logger.debug(`[Gemini Image] All ${imageCount} request(s) completed in ${Date.now() - startTime}ms`); // 提取图片并上传 base64 到 S3(并行处理) const extractPromises = responses.map(response => extractAndUploadImages(response)); @@ -720,7 +722,7 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { const allImageUrls = imageUrlsArrays.flat(); const markdownText = formatImageResponse(allImageUrls, imageParams.imageSize); - console.log( + logger.debug( `[Gemini Image] Generated ${allImageUrls.length} images from ${imageCount} concurrent request(s) (uploaded to S3)` ); @@ -734,7 +736,7 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { }, }; } catch (error) { - console.error("[Gemini Image] Generation failed:", error); + logger.error("[Gemini Image] Generation failed:", error); // ❌ 抛出错误以触发故障转移(不要吞掉错误) throw error; @@ -742,12 +744,12 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { }, doStream: async function* (options: any) { - console.log("[Gemini Image] ===== doStream ENTERED! ====="); // 调试日志 - console.log("[Gemini Image] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); + logger.debug("[Gemini Image] ===== doStream ENTERED! ====="); // 调试日志 + logger.debug("[Gemini Image] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); // 🆕 支持覆盖 modelId(用于备用模型切换) const effectiveModelId = options.overrideModelId || modelId; - console.log("[Gemini Image] Using modelId:", effectiveModelId, options.overrideModelId ? "(overridden)" : "(default)"); - console.log("[Gemini Image] Image params from options:", options.imageParams); + logger.debug("[Gemini Image] Using modelId:", effectiveModelId, options.overrideModelId ? "(overridden)" : "(default)"); + logger.debug("[Gemini Image] Image params from options:", options.imageParams); try { // 直接传递完整的消息数组(包括文本和图片) @@ -758,11 +760,11 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { }; // 🆕 获取用户选择的图片数量,默认 1 张 const imageCount = Math.min(Math.max(options.imageParams?.imageCount || 1, 1), 2); // Gemini 限制 1-2 张 - console.log("[Gemini Image] Final imageParams after defaults:", imageParams); - console.log(`[Gemini Image Stream] Image count: ${imageCount}`); + logger.debug("[Gemini Image] Final imageParams after defaults:", imageParams); + logger.debug(`[Gemini Image Stream] Image count: ${imageCount}`); // 🆕 根据用户选择的数量发起请求 - console.log(`[Gemini Image Stream] Starting ${imageCount} concurrent request(s)...`); + logger.debug(`[Gemini Image Stream] Starting ${imageCount} concurrent request(s)...`); const startTime = Date.now(); // 创建请求数组(使用 effectiveModelId) @@ -772,7 +774,7 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { const responses = await Promise.all(requestPromises); - console.log(`[Gemini Image Stream] All ${imageCount} request(s) completed in ${Date.now() - startTime}ms`); + logger.debug(`[Gemini Image Stream] All ${imageCount} request(s) completed in ${Date.now() - startTime}ms`); // 提取图片并上传 base64 到 S3(并行处理) const extractPromises = responses.map(response => extractAndUploadImages(response)); @@ -782,14 +784,14 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { const allImageUrls = imageUrlsArrays.flat(); const markdownText = formatImageResponse(allImageUrls, imageParams.imageSize); - console.log( + logger.debug( `[Gemini Image Stream] Generated ${allImageUrls.length} images from ${imageCount} concurrent request(s) (uploaded to S3)` ); // 一次性发送整个图片 markdown(不再分块,因为 base64 图片很大) yield { type: "text-delta" as const, textDelta: markdownText }; - console.log("[Gemini Image Stream] Sent complete markdown"); + logger.debug("[Gemini Image Stream] Sent complete markdown"); // 发送完成信号 yield { @@ -801,7 +803,7 @@ export function createGeminiImage({ apiKey, baseURL }: GeminiImageConfig) { }, }; } catch (error) { - console.error("[Gemini Image Stream] Generation failed:", error); + logger.error("[Gemini Image Stream] Generation failed:", error); const errorMessage = error instanceof Error diff --git a/lib/ai/providers/qianwen-video.ts b/lib/ai/providers/qianwen-video.ts index 8a3aec0a5..5784d11fa 100644 --- a/lib/ai/providers/qianwen-video.ts +++ b/lib/ai/providers/qianwen-video.ts @@ -4,7 +4,9 @@ */ import { uploadFile } from "@/lib/storage/service"; +import { createLogger } from "@/lib/observability/logger"; +const logger = createLogger("qianwen-video"); /** * 将 base64 图片转换为 HTTP URL(上传到 S3) */ @@ -16,7 +18,7 @@ async function convertBase64ToUrl(base64Data: string): Promise { return base64Data; } - console.log("[Qianwen Video] Converting base64 to URL..."); + logger.debug("[Qianwen Video] Converting base64 to URL..."); // 提取 base64 数据 const matches = base64Data.match(/^data:image\/(\w+);base64,(.+)$/); @@ -29,11 +31,11 @@ async function convertBase64ToUrl(base64Data: string): Promise { // 上传到 S3 const uploadResult = await uploadFile(filename, imageBuffer as any); - console.log("[Qianwen Video] Image uploaded to:", uploadResult.url); + logger.debug("[Qianwen Video] Image uploaded to:", uploadResult.url); return uploadResult.url; } catch (error) { - console.error("[Qianwen Video] Failed to convert base64 to URL:", error); + logger.error("[Qianwen Video] Failed to convert base64 to URL:", error); throw new Error("图片处理失败,请重试"); } } @@ -66,10 +68,10 @@ async function submitVideoTask( ): Promise { const url = `${baseURL}/services/aigc/video-generation/generation`; - console.log("[Qianwen Video] Submitting task to:", url); - console.log("[Qianwen Video] Model:", modelId); - console.log("[Qianwen Video] Prompt:", prompt); - console.log("[Qianwen Video] Image URL:", imageUrl); + logger.debug("[Qianwen Video] Submitting task to:", url); + logger.debug("[Qianwen Video] Model:", modelId); + logger.debug("[Qianwen Video] Prompt:", prompt); + logger.debug("[Qianwen Video] Image URL:", imageUrl); const requestBody = { model: modelId, @@ -79,7 +81,7 @@ async function submitVideoTask( }, }; - console.log("[Qianwen Video] 完整请求体:", JSON.stringify(requestBody, null, 2)); + logger.debug("[Qianwen Video] 完整请求体:", JSON.stringify(requestBody, null, 2)); const response = await fetch(url, { method: "POST", @@ -93,14 +95,14 @@ async function submitVideoTask( if (!response.ok) { const errorText = await response.text(); - console.error("[Qianwen Video] Submit failed:", response.status, errorText); + logger.error("[Qianwen Video] Submit failed:", response.status, errorText); throw new Error( `视频任务提交失败: ${response.status} ${response.statusText}: ${errorText}` ); } const data = await response.json(); - console.log("[Qianwen Video] Task submitted, task_id:", data.output?.task_id); + logger.debug("[Qianwen Video] Task submitted, task_id:", data.output?.task_id); return data; } @@ -142,16 +144,16 @@ async function waitForTaskComplete( const startTime = Date.now(); const pollInterval = 3000; // 3秒轮询一次 - console.log("[Qianwen Video] Waiting for task:", taskId); + logger.debug("[Qianwen Video] Waiting for task:", taskId); while (Date.now() - startTime < maxWaitTime) { const status = await fetchTaskStatus(baseURL, apiKey, taskId); - console.log("[Qianwen Video] Task status:", status.output?.task_status); + logger.debug("[Qianwen Video] Task status:", status.output?.task_status); if (status.output?.task_status === "SUCCEEDED") { if (status.output?.video_url) { - console.log("[Qianwen Video] Task completed, video URL:", status.output.video_url); + logger.debug("[Qianwen Video] Task completed, video URL:", status.output.video_url); return status.output.video_url; } else { throw new Error("任务完成但未返回视频URL"); @@ -174,7 +176,7 @@ async function waitForTaskComplete( */ async function downloadAndUploadVideo(videoUrl: string): Promise { try { - console.log("[Qianwen Video] Downloading video from:", videoUrl.substring(0, 100) + "..."); + logger.debug("[Qianwen Video] Downloading video from:", videoUrl.substring(0, 100) + "..."); // 下载视频 const response = await fetch(videoUrl); @@ -183,16 +185,16 @@ async function downloadAndUploadVideo(videoUrl: string): Promise { } const videoBuffer = Buffer.from(await response.arrayBuffer()); - console.log("[Qianwen Video] Video downloaded, size:", videoBuffer.length, "bytes"); + logger.debug("[Qianwen Video] Video downloaded, size:", videoBuffer.length, "bytes"); // 上传到 S3 const filename = `qianwen-video-${Date.now()}.mp4`; const uploadResult = await uploadFile(filename, videoBuffer as any); - console.log("[Qianwen Video] Video uploaded to S3:", uploadResult.url); + logger.debug("[Qianwen Video] Video uploaded to S3:", uploadResult.url); return uploadResult.url; } catch (error) { - console.error("[Qianwen Video] Failed to download/upload video:", error); + logger.error("[Qianwen Video] Failed to download/upload video:", error); // 如果失败,返回原始 URL return videoUrl; } @@ -211,7 +213,7 @@ export async function scheduleVideoUrlReplacement( // 延迟 5 分钟后执行 setTimeout(async () => { try { - console.log("[Qianwen Video] Starting video URL replacement for message:", messageId); + logger.debug("[Qianwen Video] Starting video URL replacement for message:", messageId); // 下载并上传到 S3 const s3VideoUrl = await downloadAndUploadVideo(tempVideoUrl); @@ -232,9 +234,9 @@ export async function scheduleVideoUrlReplacement( }), }); - console.log("[Qianwen Video] ✅ Video URL replaced successfully"); + logger.debug("[Qianwen Video] ✅ Video URL replaced successfully"); } catch (error) { - console.error("[Qianwen Video] Failed to replace video URL:", error); + logger.error("[Qianwen Video] Failed to replace video URL:", error); } }, 5 * 60 * 1000); // 5 分钟 } @@ -261,8 +263,8 @@ export function createQianwenVideo({ apiKey, baseURL }: QianwenVideoConfig) { return { chat: (modelId: string) => ({ doGenerate: async (options: any) => { - console.log("[Qianwen Video] doGenerate called!"); - console.log("[Qianwen Video] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); + logger.debug("[Qianwen Video] doGenerate called!"); + logger.debug("[Qianwen Video] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); try { // 提取文本提示词 @@ -284,9 +286,9 @@ export function createQianwenVideo({ apiKey, baseURL }: QianwenVideoConfig) { throw new Error("请上传一张图片作为视频生成的起始帧"); } - console.log("[Qianwen Video] Prompt:", prompt); - console.log("[Qianwen Video] Image URL:", rawImageUrl); - console.log("[Qianwen Video] Image part type:", imagePart?.type); + logger.debug("[Qianwen Video] Prompt:", prompt); + logger.debug("[Qianwen Video] Image URL:", rawImageUrl); + logger.debug("[Qianwen Video] Image part type:", imagePart?.type); // 1. 提交任务 const taskResponse = await submitVideoTask( @@ -317,7 +319,7 @@ export function createQianwenVideo({ apiKey, baseURL }: QianwenVideoConfig) { }, }; } catch (error) { - console.error("[Qianwen Video] Generation failed:", error); + logger.error("[Qianwen Video] Generation failed:", error); const errorMessage = error instanceof Error ? `视频生成失败: ${error.message}` @@ -331,7 +333,7 @@ export function createQianwenVideo({ apiKey, baseURL }: QianwenVideoConfig) { }, doStream: async function* (options: any) { - console.log("[Qianwen Video] doStream called!"); + logger.debug("[Qianwen Video] doStream called!"); try { // 提取文本提示词 @@ -353,8 +355,8 @@ export function createQianwenVideo({ apiKey, baseURL }: QianwenVideoConfig) { throw new Error("请上传一张图片作为视频生成的起始帧"); } - console.log("[Qianwen Video Stream] Image URL:", rawImageUrl); - console.log("[Qianwen Video Stream] Image part type:", imagePart?.type); + logger.debug("[Qianwen Video Stream] Image URL:", rawImageUrl); + logger.debug("[Qianwen Video Stream] Image part type:", imagePart?.type); // 1. 提交任务 const taskResponse = await submitVideoTask( @@ -390,7 +392,7 @@ export function createQianwenVideo({ apiKey, baseURL }: QianwenVideoConfig) { }, }; } catch (error) { - console.error("[Qianwen Video] Stream generation failed:", error); + logger.error("[Qianwen Video] Stream generation failed:", error); const errorMessage = error instanceof Error diff --git a/lib/ai/providers/volcano-vision.ts b/lib/ai/providers/volcano-vision.ts index 0e4447594..01c233bc2 100644 --- a/lib/ai/providers/volcano-vision.ts +++ b/lib/ai/providers/volcano-vision.ts @@ -2,7 +2,9 @@ import "server-only"; import { uploadFile } from "@/lib/storage/service"; import { getDefaultStorageConfig } from "@/lib/db/queries"; +import { createLogger } from "@/lib/observability/logger"; +const logger = createLogger("volcano-vision"); interface VolcanoVisionConfig { apiKey: string; baseURL: string; @@ -77,7 +79,7 @@ function resolveVolcanoImageSizeForModel( if (SEEDREAM_MODEL_ID_PATTERN.test(modelId)) { // doubao-seedream-4-5 在当前链路下 1K 会返回 InvalidParameter,强制回落到 2K if (normalized === "1K") { - console.log( + logger.debug( `[Volcano Vision] Size normalized for ${modelId}: 1K -> 2K (model constraint)` ); return "2K"; @@ -182,7 +184,7 @@ async function inferImageAspectRatioFromSource( try { const response = await fetch(source, { signal: controller.signal }); if (!response.ok) { - console.warn("[Volcano Vision] Failed to fetch reference image for aspect inference:", response.status); + logger.warn("[Volcano Vision] Failed to fetch reference image for aspect inference:", response.status); return null; } buffer = Buffer.from(await response.arrayBuffer()); @@ -199,7 +201,7 @@ async function inferImageAspectRatioFromSource( return `${metadata.width}:${metadata.height}`; } catch (error) { - console.warn("[Volcano Vision] Failed to infer reference aspect ratio:", error); + logger.warn("[Volcano Vision] Failed to infer reference aspect ratio:", error); return null; } } @@ -229,17 +231,17 @@ async function resolveVolcanoRequestSize(params: { const aspectAwareSize = resolveVolcanoAspectAwareSize(resolvedAspectRatio, fallbackSize); if (inferredAspectRatio) { - console.log(`[Volcano Vision] Inferred reference aspect ratio: ${inferredAspectRatio}`); + logger.debug(`[Volcano Vision] Inferred reference aspect ratio: ${inferredAspectRatio}`); } if (aspectAwareSize) { - console.log( + logger.debug( `[Volcano Vision] Resolved size: aspectRatio=${resolvedAspectRatio}, imageSize=${fallbackSize} => ${aspectAwareSize}` ); return { size: aspectAwareSize, fallbackSize, inferredAspectRatio }; } - console.log( + logger.debug( `[Volcano Vision] Resolved size: aspectRatio=${resolvedAspectRatio || "default"}, imageSize=${fallbackSize} => ${fallbackSize}` ); return { size: fallbackSize, fallbackSize, inferredAspectRatio }; @@ -275,7 +277,7 @@ async function fetchImageAsDataUri(url: string): Promise { try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) { - console.warn("[Volcano Vision] Failed to fetch source image for inline retry:", response.status, url); + logger.warn("[Volcano Vision] Failed to fetch source image for inline retry:", response.status, url); return null; } @@ -305,7 +307,7 @@ async function fetchImageAsDataUri(url: string): Promise { return `data:${inputMime};base64,${buffer.toString("base64")}`; } } catch (error) { - console.warn("[Volcano Vision] Inline image fetch failed:", error); + logger.warn("[Volcano Vision] Inline image fetch failed:", error); return null; } finally { clearTimeout(timeoutId); @@ -375,7 +377,7 @@ async function rewriteUploadPublicUrlForVolcano(url: string): Promise { if (!parsed.pathname.startsWith("/uploads/")) return url; const rewritten = `${config.endpointBase}/${config.bucket}${parsed.pathname}${parsed.search}`; - console.log("[Volcano Vision] Rewrote upload URL for provider fetch:", rewritten); + logger.debug("[Volcano Vision] Rewrote upload URL for provider fetch:", rewritten); return rewritten; } catch { return url; @@ -398,10 +400,10 @@ async function createThumbnail(buffer: Buffer, mimeType: string): Promise { const url = `${baseURL}/api/v3/images/generations`; - console.log("[Volcano Vision] Sending non-stream request to:", url); - console.log("[Volcano Vision] Using model:", modelId); + logger.debug("[Volcano Vision] Sending non-stream request to:", url); + logger.debug("[Volcano Vision] Using model:", modelId); // 转换消息格式(直接使用URL) let { prompt, image } = await convertMessagesToVolcanoFormat(messages); @@ -698,8 +700,8 @@ async function sendVolcanoRequest( } } - console.log("[Volcano Vision] Prompt:", prompt.substring(0, 100) + "..."); - console.log("[Volcano Vision] Image count:", image ? (Array.isArray(image) ? image.length : 1) : 0); + logger.debug("[Volcano Vision] Prompt:", prompt.substring(0, 100) + "..."); + logger.debug("[Volcano Vision] Image count:", image ? (Array.isArray(image) ? image.length : 1) : 0); // 构建请求体 const resolvedSize = await resolveVolcanoRequestSize({ @@ -728,26 +730,26 @@ async function sendVolcanoRequest( requestBody.sequential_image_generation_options = { max_images: maxImages, }; - console.log(`[Volcano Vision] Generating up to ${maxImages} images (mode: ${image ? "image-to-image" : "text-to-image"})`); + logger.debug(`[Volcano Vision] Generating up to ${maxImages} images (mode: ${image ? "image-to-image" : "text-to-image"})`); } else { requestBody.sequential_image_generation = "disabled"; - console.log(`[Volcano Vision] Generating 1 image (mode: ${image ? "image-to-image" : "text-to-image"})`); + logger.debug(`[Volcano Vision] Generating 1 image (mode: ${image ? "image-to-image" : "text-to-image"})`); } - console.log("[Volcano Vision] Request body:", JSON.stringify(requestBody).substring(0, 500) + "..."); - console.log("[Volcano Vision] Full request body (prompt + image info):"); - console.log(" - Prompt:", requestBody.prompt); - console.log(" - Image field exists:", !!requestBody.image); - console.log(" - Image type:", typeof requestBody.image); - console.log(" - Image is array:", Array.isArray(requestBody.image)); - console.log(" - Image count:", Array.isArray(requestBody.image) ? requestBody.image.length : (requestBody.image ? 1 : 0)); + logger.debug("[Volcano Vision] Request body:", JSON.stringify(requestBody).substring(0, 500) + "..."); + logger.debug("[Volcano Vision] Full request body (prompt + image info):"); + logger.debug(" - Prompt:", requestBody.prompt); + logger.debug(" - Image field exists:", !!requestBody.image); + logger.debug(" - Image type:", typeof requestBody.image); + logger.debug(" - Image is array:", Array.isArray(requestBody.image)); + logger.debug(" - Image count:", Array.isArray(requestBody.image) ? requestBody.image.length : (requestBody.image ? 1 : 0)); if (requestBody.image) { if (Array.isArray(requestBody.image)) { requestBody.image.forEach((img: string, idx: number) => { - console.log(` - Image ${idx}: ${img.substring(0, 100)}...`); + logger.debug(` - Image ${idx}: ${img.substring(0, 100)}...`); }); } else { - console.log(` - Image: ${requestBody.image.substring(0, 100)}...`); + logger.debug(` - Image: ${requestBody.image.substring(0, 100)}...`); } } @@ -768,7 +770,7 @@ async function sendVolcanoRequest( if (!response.ok) { const errorText = await response.text(); - console.error(`[Volcano Vision] Request failed (${tag}):`, response.status, errorText); + logger.error(`[Volcano Vision] Request failed (${tag}):`, response.status, errorText); const err = new Error( `Volcano Vision API error: ${response.status} ${response.statusText} - ${errorText}` ) as Error & { status?: number; body?: string }; @@ -778,7 +780,7 @@ async function sendVolcanoRequest( } const data = await response.json(); - console.log( + logger.debug( `[Volcano Vision] Response received (${tag}):`, JSON.stringify(data).substring(0, 200) + "..." ); @@ -797,7 +799,7 @@ async function sendVolcanoRequest( return await executeRequest(requestBody, "primary"); } catch (error) { if (error instanceof Error && error.name === "AbortError") { - console.error("[Volcano Vision] Request timeout after 10 minutes"); + logger.error("[Volcano Vision] Request timeout after 10 minutes"); throw new Error("请求超时,图片处理时间过长,请稍后重试"); } @@ -810,7 +812,7 @@ async function sendVolcanoRequest( requestBody.size !== resolvedSize.fallbackSize && shouldRetryVolcanoWithBucketSize(errorBody) ) { - console.warn( + logger.warn( `[Volcano Vision] Retrying with bucket size fallback: ${requestBody.size} -> ${resolvedSize.fallbackSize}` ); const sizeFallbackBody = { ...requestBody, size: resolvedSize.fallbackSize }; @@ -824,13 +826,13 @@ async function sendVolcanoRequest( } if (inlineRetryBody.image && shouldRetryVolcanoWithInlineImage(latestErrorBody)) { - console.warn("[Volcano Vision] Retrying with inline base64 image payload..."); + logger.warn("[Volcano Vision] Retrying with inline base64 image payload..."); const inlineImage = await convertImageFieldToInlineDataUri(inlineRetryBody.image); if (inlineImage) { const retryBody = { ...inlineRetryBody, image: inlineImage }; return await executeRequest(retryBody, "inline-retry"); } - console.warn("[Volcano Vision] Inline retry skipped: failed to convert source image(s)."); + logger.warn("[Volcano Vision] Inline retry skipped: failed to convert source image(s)."); } throw latestError; } @@ -849,14 +851,14 @@ async function* sendVolcanoStreamRequest( ): AsyncGenerator { const url = `${baseURL}/api/v3/images/generations`; - console.log("[Volcano Vision] Sending stream request to:", url); - console.log("[Volcano Vision] Using model:", modelId); + logger.debug("[Volcano Vision] Sending stream request to:", url); + logger.debug("[Volcano Vision] Using model:", modelId); // 转换消息格式(直接使用URL) const { prompt, image } = await convertMessagesToVolcanoFormat(messages); - console.log("[Volcano Vision] Prompt:", prompt.substring(0, 100) + "..."); - console.log("[Volcano Vision] Image count:", image ? (Array.isArray(image) ? image.length : 1) : 0); + logger.debug("[Volcano Vision] Prompt:", prompt.substring(0, 100) + "..."); + logger.debug("[Volcano Vision] Image count:", image ? (Array.isArray(image) ? image.length : 1) : 0); // 构建请求体 const resolvedSize = await resolveVolcanoRequestSize({ @@ -879,13 +881,13 @@ async function* sendVolcanoStreamRequest( requestBody.image = image; } - console.log("[Volcano Vision] Stream request body:", JSON.stringify(requestBody).substring(0, 500) + "..."); - console.log("[Volcano Vision] Stream - Full request info:"); - console.log(" - Prompt:", requestBody.prompt); - console.log(" - Image field exists:", !!requestBody.image); - console.log(" - Image type:", typeof requestBody.image); - console.log(" - Image is array:", Array.isArray(requestBody.image)); - console.log(" - Image count:", Array.isArray(requestBody.image) ? requestBody.image.length : (requestBody.image ? 1 : 0)); + logger.debug("[Volcano Vision] Stream request body:", JSON.stringify(requestBody).substring(0, 500) + "..."); + logger.debug("[Volcano Vision] Stream - Full request info:"); + logger.debug(" - Prompt:", requestBody.prompt); + logger.debug(" - Image field exists:", !!requestBody.image); + logger.debug(" - Image type:", typeof requestBody.image); + logger.debug(" - Image is array:", Array.isArray(requestBody.image)); + logger.debug(" - Image count:", Array.isArray(requestBody.image) ? requestBody.image.length : (requestBody.image ? 1 : 0)); // 设置 10 分钟超时 const controller = new AbortController(); @@ -906,7 +908,7 @@ async function* sendVolcanoStreamRequest( if (!response.ok) { const errorText = await response.text(); - console.error("[Volcano Vision] Stream request failed:", response.status, errorText); + logger.error("[Volcano Vision] Stream request failed:", response.status, errorText); throw new Error( `Volcano Vision API error: ${response.status} ${response.statusText}` ); @@ -937,10 +939,10 @@ async function* sendVolcanoStreamRequest( const jsonStr = trimmedLine.slice(6); try { const event = JSON.parse(jsonStr) as VolcanoVisionStreamEvent; - console.log("[Volcano Vision] Stream event:", event.type, event.image_index); + logger.debug("[Volcano Vision] Stream event:", event.type, event.image_index); yield event; } catch (error) { - console.error("[Volcano Vision] Failed to parse stream event:", jsonStr); + logger.error("[Volcano Vision] Failed to parse stream event:", jsonStr); } } } @@ -948,7 +950,7 @@ async function* sendVolcanoStreamRequest( } catch (error) { clearTimeout(timeoutId); if (error instanceof Error && error.name === "AbortError") { - console.error("[Volcano Vision] Stream request timeout after 10 minutes"); + logger.error("[Volcano Vision] Stream request timeout after 10 minutes"); throw new Error("请求超时,图片处理时间过长,请稍后重试"); } throw error; @@ -962,11 +964,11 @@ export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) { return { chat: (modelId: string) => ({ doGenerate: async (options: any) => { - console.log("[Volcano Vision] doGenerate called!"); - console.log("[Volcano Vision] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); + logger.debug("[Volcano Vision] doGenerate called!"); + logger.debug("[Volcano Vision] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); const activeModelId = options.overrideModelId || modelId; - console.log("[Volcano Vision] Using modelId:", activeModelId); - console.log("[Volcano Vision] Image params from options:", options.imageParams); + logger.debug("[Volcano Vision] Using modelId:", activeModelId); + logger.debug("[Volcano Vision] Image params from options:", options.imageParams); const imageSize = resolveVolcanoImageSizeForModel( activeModelId, options.imageParams?.imageSize @@ -975,7 +977,7 @@ export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) { // 🆕 优先使用用户选择的图片数量,其次是 maxImages 参数,默认 1 张 const imageCount = options.imageParams?.imageCount || options.maxImages || 1; const maxImages = Math.min(Math.max(imageCount, 1), 3); // 限制 1-3 张 - console.log("[Volcano Vision] Max images:", maxImages); + logger.debug("[Volcano Vision] Max images:", maxImages); try { // 发送请求 @@ -994,7 +996,7 @@ export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) { const responseImageSize = response.data?.find((item) => item.size)?.size || imageSize; const markdownText = formatImageResponse(imageUrls, responseImageSize); - console.log( + logger.debug( `[Volcano Vision] Generated ${imageUrls.length} images (uploaded to S3)` ); @@ -1008,7 +1010,7 @@ export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) { }, }; } catch (error) { - console.error("[Volcano Vision] Generation failed:", error); + logger.error("[Volcano Vision] Generation failed:", error); // 返回错误信息 const errorMessage = @@ -1025,9 +1027,9 @@ export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) { }, doStream: async function* (options: any) { - console.log("[Volcano Vision] ===== doStream ENTERED! ====="); - console.log("[Volcano Vision] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); - console.log("[Volcano Vision] Using modelId:", modelId); + logger.debug("[Volcano Vision] ===== doStream ENTERED! ====="); + logger.debug("[Volcano Vision] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "..."); + logger.debug("[Volcano Vision] Using modelId:", modelId); try { const allImages: Array<{ thumbnail: string; original: string }> = []; @@ -1052,14 +1054,14 @@ export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) { allImages.push(imageUrls); const markdown = `![thumb|||${imageUrls.original}|||${imageSize}](${imageUrls.thumbnail})`; - console.log(`[Volcano Vision Stream] Image ${event.image_index} uploaded, sending...`); + logger.debug(`[Volcano Vision Stream] Image ${event.image_index} uploaded, sending...`); yield { type: "text-delta" as const, textDelta: markdown + "\n\n" }; } catch (error) { - console.error(`[Volcano Vision Stream] Failed to upload image ${event.image_index}:`, error); + logger.error(`[Volcano Vision Stream] Failed to upload image ${event.image_index}:`, error); } } else if (event.type === "image_generation.completed") { - console.log(`[Volcano Vision Stream] Generation completed, total images: ${allImages.length}`); + logger.debug(`[Volcano Vision Stream] Generation completed, total images: ${allImages.length}`); // 发送完成信号 yield { @@ -1073,7 +1075,7 @@ export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) { } } } catch (error) { - console.error("[Volcano Vision Stream] Generation failed:", error); + logger.error("[Volcano Vision Stream] Generation failed:", error); const errorMessage = error instanceof Error diff --git a/lib/observability/client-logger.ts b/lib/observability/client-logger.ts new file mode 100644 index 000000000..07be419e8 --- /dev/null +++ b/lib/observability/client-logger.ts @@ -0,0 +1,70 @@ +import { redactLogArgs, redactValue } from "@/lib/observability/redaction"; + +function isClientLoggingEnabled() { + return ( + process.env.NODE_ENV !== "production" || + process.env.NEXT_PUBLIC_ENABLE_CLIENT_LOGS === "true" + ); +} + +function normalizeEvent(event: string) { + return event + .trim() + .replace(/^\[[^\]]+\]\s*/, "") + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase() + .slice(0, 80) || "event"; +} + +const SENSITIVE_EVENT_PATTERN = + /(authorization|body|cookie|header|image url|messages received|password|prompt|request body|session|token)/i; + +function writeClientLog( + level: "debug" | "info" | "warn" | "error", + scope: string, + event: string, + args: unknown[] +) { + if (!isClientLoggingEnabled()) return; + const payload = redactValue({ + level, + scope, + event: normalizeEvent(event), + message: event, + ...(args.length > 0 + ? { + meta: SENSITIVE_EVENT_PATTERN.test(event) + ? { omitted: true, argCount: args.length } + : redactLogArgs(args), + } + : {}), + }); + + if (level === "error") { + console.error(payload); + return; + } + if (level === "warn") { + console.warn(payload); + return; + } + console.log(payload); +} + +export function createClientLogger(scope: string) { + return { + debug(event: string, ...args: unknown[]) { + writeClientLog("debug", scope, event, args); + }, + info(event: string, ...args: unknown[]) { + writeClientLog("info", scope, event, args); + }, + warn(event: string, ...args: unknown[]) { + writeClientLog("warn", scope, event, args); + }, + error(event: string, ...args: unknown[]) { + writeClientLog("error", scope, event, args); + }, + }; +} diff --git a/lib/observability/logger.ts b/lib/observability/logger.ts new file mode 100644 index 000000000..e060e5875 --- /dev/null +++ b/lib/observability/logger.ts @@ -0,0 +1,80 @@ +import { redactLogArgs, redactValue } from "@/lib/observability/redaction"; + +type LogLevel = "debug" | "info" | "warn" | "error" | "silent"; + +const LEVEL_ORDER: Record = { + debug: 10, + info: 20, + warn: 30, + error: 40, + silent: 50, +}; +const SENSITIVE_EVENT_PATTERN = + /(authorization|body|cookie|header|image url|messages received|password|prompt|request body|session|token)/i; + +function resolveLogLevel(): LogLevel { + const configured = String(process.env.LOG_LEVEL || "").toLowerCase(); + if (configured in LEVEL_ORDER) return configured as LogLevel; + return process.env.NODE_ENV === "production" ? "info" : "debug"; +} + +function shouldLog(level: Exclude) { + return LEVEL_ORDER[level] >= LEVEL_ORDER[resolveLogLevel()]; +} + +function normalizeEvent(event: string) { + return event + .trim() + .replace(/^\[[^\]]+\]\s*/, "") + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .toLowerCase() + .slice(0, 80) || "event"; +} + +function writeLog(level: Exclude, scope: string, event: string, args: unknown[]) { + if (!shouldLog(level)) return; + + const payload = { + level, + scope, + event: normalizeEvent(event), + message: event, + timestamp: new Date().toISOString(), + ...(args.length > 0 + ? { + meta: SENSITIVE_EVENT_PATTERN.test(event) + ? { omitted: true, argCount: args.length } + : redactLogArgs(args), + } + : {}), + }; + + const serialized = JSON.stringify(redactValue(payload)); + if (level === "error") { + console.error(serialized); + return; + } + if (level === "warn") { + console.warn(serialized); + return; + } + console.log(serialized); +} + +export function createLogger(scope: string) { + return { + debug(event: string, ...args: unknown[]) { + writeLog("debug", scope, event, args); + }, + info(event: string, ...args: unknown[]) { + writeLog("info", scope, event, args); + }, + warn(event: string, ...args: unknown[]) { + writeLog("warn", scope, event, args); + }, + error(event: string, ...args: unknown[]) { + writeLog("error", scope, event, args); + }, + }; +} diff --git a/lib/observability/redaction.ts b/lib/observability/redaction.ts new file mode 100644 index 000000000..e39bdc828 --- /dev/null +++ b/lib/observability/redaction.ts @@ -0,0 +1,97 @@ +const REDACTED = "[REDACTED]"; +const MAX_STRING_LENGTH = 240; + +const SENSITIVE_KEY_PATTERN = + /(authorization|cookie|password|secret|token|apikey|api_key|accesskey|access_key|clientsecret|client_secret|credential|prompt|session|csrf|code)/i; +const URL_KEY_PATTERN = /(url|uri|endpoint|origin|href|src|image|thumbnail|avatar|file)/i; +const DATA_URL_PATTERN = /^data:[^;]+;base64,/i; +const EMBEDDED_DATA_URL_PATTERN = /data:[^;,\s]+;base64,[A-Za-z0-9+/=_-]+/gi; +const EMBEDDED_URL_PATTERN = /https?:\/\/[^\s"'<>),]+/gi; +const EMBEDDED_SECRET_PATTERN = + /\b(token|api[_-]?key|secret|password|authorization|cookie|csrf|code)\s*[:=]\s*([^\s,;]+)/gi; +const LONG_BASE64_PATTERN = /^[A-Za-z0-9+/=_-]{120,}$/; + +export type RedactedValue = + | null + | boolean + | number + | string + | RedactedValue[] + | { [key: string]: RedactedValue }; + +function hashString(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash.toString(16).padStart(8, "0"); +} + +export function redactUrl(value: string) { + try { + const parsed = new URL(value); + return `${parsed.origin}${parsed.pathname ? "/..." : ""}#${hashString(value)}`; + } catch { + return `[url#${hashString(value)}]`; + } +} + +function redactString(value: string, key?: string) { + if (!value) return value; + if (key && SENSITIVE_KEY_PATTERN.test(key)) return REDACTED; + if (DATA_URL_PATTERN.test(value)) return `[base64:${value.length}]`; + if (LONG_BASE64_PATTERN.test(value)) return `[base64:${value.length}]`; + if (/^https?:\/\//i.test(value)) return redactUrl(value); + if (key && URL_KEY_PATTERN.test(key)) return redactUrl(value); + const redacted = value + .replace(EMBEDDED_DATA_URL_PATTERN, (match) => `[base64:${match.length}]`) + .replace(EMBEDDED_URL_PATTERN, (match) => redactUrl(match)) + .replace(EMBEDDED_SECRET_PATTERN, "$1: [REDACTED]"); + if (redacted.length > MAX_STRING_LENGTH) { + return `${redacted.slice(0, MAX_STRING_LENGTH)}...[${redacted.length}]`; + } + return redacted; +} + +export function redactValue(value: unknown, key?: string, depth = 0): RedactedValue { + if (value === null || value === undefined) return null; + if (typeof value === "string") return redactString(value, key); + if (typeof value === "number" || typeof value === "boolean") return value; + if (typeof value === "bigint") return value.toString(); + if (typeof value === "symbol" || typeof value === "function") { + return `[${typeof value}]`; + } + if (value instanceof Error) { + const includeStack = + process.env.LOG_INCLUDE_STACK === "true" && process.env.NODE_ENV !== "production"; + return { + name: value.name, + message: redactString(value.message), + ...(includeStack && value.stack ? { stack: redactString(value.stack) } : {}), + }; + } + if (value instanceof URL) return redactUrl(value.toString()); + if (value instanceof Date) return value.toISOString(); + if (depth > 5) return "[MaxDepth]"; + if (Array.isArray(value)) { + return value.slice(0, 20).map((item) => redactValue(item, key, depth + 1)); + } + if (typeof value === "object") { + const entries = Object.entries(value as Record).slice(0, 60); + return Object.fromEntries( + entries.map(([entryKey, entryValue]) => [ + entryKey, + SENSITIVE_KEY_PATTERN.test(entryKey) + ? REDACTED + : redactValue(entryValue, entryKey, depth + 1), + ]) + ); + } + return String(value); +} + +export function redactLogArgs(args: unknown[]) { + if (args.length === 0) return undefined; + if (args.length === 1) return redactValue(args[0]); + return args.map((arg) => redactValue(arg)); +} diff --git a/lib/storage/service.ts b/lib/storage/service.ts index f11dbfe79..c62590286 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -19,7 +19,9 @@ import { type OssImageMigrationConfig, } from "@/lib/storage/migration-config"; import type { StorageConfig } from "@/lib/db/schema"; +import { createLogger } from "@/lib/observability/logger"; +const logger = createLogger("storage"); export type S3UploadConfig = { endpoint: string; region: string; @@ -142,7 +144,7 @@ async function uploadToS3( const contentType = getContentType(filename); try { - console.log(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`); + logger.debug(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`); // 上传文件 const command = new PutObjectCommand({ @@ -156,7 +158,7 @@ async function uploadToS3( await s3Client.send(command); - console.log(`[S3 Upload] Upload successful: ${key}`); + logger.debug(`[S3 Upload] Upload successful: ${key}`); // 返回文件的公开 URL // MinIO 的公开 URL 格式通常是: http://endpoint:port/bucket/key @@ -169,7 +171,7 @@ async function uploadToS3( fileUrl = `${normalizedEndpoint}/${bucket}/${key}`; } - console.log(`[S3 Upload] File URL: ${fileUrl}`); + logger.debug(`[S3 Upload] File URL: ${fileUrl}`); return { url: fileUrl, @@ -177,7 +179,7 @@ async function uploadToS3( contentType, }; } catch (error) { - console.error("[S3 Upload] Error:", error); + logger.error("[S3 Upload] Error:", error); throw error; } } @@ -347,7 +349,7 @@ async function uploadWithBackupStorageConfig( primaryStorageConfigId && failover.backupStorageConfigId === primaryStorageConfigId ) { - console.warn( + logger.warn( "[Upload] Backup storage config equals default storage config, skipping failover retry" ); return null; @@ -359,13 +361,13 @@ async function uploadWithBackupStorageConfig( const backupS3Config = toDbS3UploadConfig(backupStorageConfig); if (!backupS3Config) { - console.warn( + logger.warn( "[Upload] Backup storage config is invalid, disabled, or not S3 compatible" ); return null; } - console.log( + logger.debug( `[Upload] Retrying with backup S3 config: ${backupStorageConfig?.name ?? failover.backupStorageConfigId}` ); @@ -376,7 +378,7 @@ async function uploadWithBackupStorageConfig( storageConfig: backupStorageConfig, }; } catch (error) { - console.error("[Upload] Backup storage config retry failed:", error); + logger.error("[Upload] Backup storage config retry failed:", error); return null; } } @@ -386,7 +388,7 @@ export async function uploadFile( filename: string, buffer: ArrayBuffer ): Promise { - console.log(`[Upload] Starting upload for file: ${filename}`); + logger.debug(`[Upload] Starting upload for file: ${filename}`); let dbS3Config: S3UploadConfig | null = null; let primaryStorageConfigId: string | undefined; @@ -402,9 +404,9 @@ export async function uploadFile( primaryStorageConfigId = selectedStorageConfig?.id; dbS3Config = toDbS3UploadConfig(selectedStorageConfig); if (dbS3Config) { - console.log(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`); - console.log(`[Upload] Endpoint: ${dbS3Config.endpoint}`); - console.log(`[Upload] Bucket: ${dbS3Config.bucket}`); + logger.debug(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`); + logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`); + logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`); const uploaded = await uploadToS3(filename, buffer, dbS3Config); lastKnownGoodDbS3Config = dbS3Config; await maybeEnqueueAliyunMigrationTask({ @@ -412,13 +414,13 @@ export async function uploadFile( storageConfig: selectedStorageConfig, migrationConfig, }).catch((error) => { - console.error("[Upload] Failed to enqueue OSS migration task:", error); + logger.error("[Upload] Failed to enqueue OSS migration task:", error); }); return uploaded; } - console.warn("[Upload] No valid S3 config in database"); + logger.warn("[Upload] No valid S3 config in database"); } catch (error) { - console.error("[Upload] Database storage path failed:", error); + logger.error("[Upload] Database storage path failed:", error); const uploadedFromBackup = await uploadWithBackupStorageConfig( filename, @@ -431,17 +433,17 @@ export async function uploadFile( storageConfig: uploadedFromBackup.storageConfig, migrationConfig, }).catch((enqueueError) => { - console.error("[Upload] Failed to enqueue backup OSS migration task:", enqueueError); + logger.error("[Upload] Failed to enqueue backup OSS migration task:", enqueueError); }); return uploadedFromBackup.uploaded; } if (lastKnownGoodDbS3Config) { try { - console.log("[Upload] Retrying with last known good database S3 config"); + logger.debug("[Upload] Retrying with last known good database S3 config"); return await uploadToS3(filename, buffer, lastKnownGoodDbS3Config); } catch (retryError) { - console.error("[Upload] Last known S3 config upload failed:", retryError); + logger.error("[Upload] Last known S3 config upload failed:", retryError); } } } @@ -458,7 +460,7 @@ export async function uploadFile( storageConfig: uploadedFromBackup.storageConfig, migrationConfig, }).catch((enqueueError) => { - console.error("[Upload] Failed to enqueue backup OSS migration task:", enqueueError); + logger.error("[Upload] Failed to enqueue backup OSS migration task:", enqueueError); }); return uploadedFromBackup.uploaded; } @@ -466,7 +468,7 @@ export async function uploadFile( const envS3Config = buildEnvS3Config(); if (envS3Config) { - console.log("[Upload] Using environment variable S3 config"); + logger.debug("[Upload] Using environment variable S3 config"); return await uploadToS3(filename, buffer, envS3Config); } @@ -477,7 +479,7 @@ export async function uploadFile( } // 最终回退到 Vercel Blob - console.log("[Upload] Falling back to Vercel Blob"); + logger.debug("[Upload] Falling back to Vercel Blob"); const data = await put(filename, buffer, { access: "public", }); @@ -506,12 +508,12 @@ export async function createStorageUploadSession(): Promise { - console.log(`[Upload] Starting session upload for file: ${filename}`); + logger.debug(`[Upload] Starting session upload for file: ${filename}`); try { const uploaded = await uploadToS3(filename, buffer, dbS3Config, s3Client); lastKnownGoodDbS3Config = dbS3Config; @@ -520,11 +522,11 @@ export async function createStorageUploadSession(): Promise { - console.error("[Upload] Failed to enqueue OSS migration task:", error); + logger.error("[Upload] Failed to enqueue OSS migration task:", error); }); return uploaded; } catch (error) { - console.error("[Upload] Reusable storage session failed:", error); + logger.error("[Upload] Reusable storage session failed:", error); const uploadedFromBackup = await uploadWithBackupStorageConfig( filename, buffer, @@ -536,7 +538,7 @@ export async function createStorageUploadSession(): Promise { - console.error("[Upload] Failed to enqueue backup OSS migration task:", migrationError); + logger.error("[Upload] Failed to enqueue backup OSS migration task:", migrationError); }); return uploadedFromBackup.uploaded; } @@ -545,7 +547,7 @@ export async function createStorageUploadSession(): Promise .next/prerender-manifest.json && echo 'Build fallback created')", "start": "next start", "lint": "npx ultracite@latest check", + "check:logs": "node scripts/check-log-hygiene.mjs && tsx scripts/check-log-redaction.ts", "format": "npx ultracite@latest fix", "db:generate": "drizzle-kit generate", "db:migrate": "npx tsx lib/db/migrate.ts", diff --git a/scripts/check-log-hygiene.mjs b/scripts/check-log-hygiene.mjs new file mode 100644 index 000000000..e1a524233 --- /dev/null +++ b/scripts/check-log-hygiene.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; + +const files = [ + "lib/storage/service.ts", + "app/(chat)/api/files/upload/route.ts", + "app/(auth)/login/page.tsx", + "app/api/auth/dingtalk/callback/route.ts", + "lib/ai/providers/gemini-image.ts", + "lib/ai/providers/volcano-vision.ts", + "lib/ai/providers/qianwen-video.ts", +]; + +const consolePattern = /\bconsole\.(log|warn|error|info|debug)\b/g; +const violations = []; + +for (const file of files) { + const source = readFileSync(file, "utf8"); + const lines = source.split(/\r?\n/); + lines.forEach((line, index) => { + if (consolePattern.test(line)) { + violations.push(`${file}:${index + 1}: ${line.trim()}`); + } + consolePattern.lastIndex = 0; + }); +} + +if (violations.length > 0) { + console.error("Scoped log hygiene failed. Use createLogger/createClientLogger instead of console.* in governed files."); + console.error(violations.join("\n")); + process.exit(1); +} + +console.log(`Scoped log hygiene passed for ${files.length} files.`); diff --git a/scripts/check-log-redaction.ts b/scripts/check-log-redaction.ts new file mode 100644 index 000000000..392ddd256 --- /dev/null +++ b/scripts/check-log-redaction.ts @@ -0,0 +1,34 @@ +import { redactValue } from "../lib/observability/redaction"; + +const raw = { + apiKey: "sk-live-secret-value", + password: "plain-password", + token: "oauth-token-value", + Authorization: "Bearer abc.def.ghi", + Cookie: "session=secret", + callbackUrl: "https://example.com/callback?code=abc&token=def", + base64: `data:image/png;base64,${"a".repeat(160)}`, + nested: { + prompt: "draw a private prompt", + imageUrl: "https://cdn.example.com/private/image.png?signature=secret", + }, +}; + +const output = JSON.stringify(redactValue(raw)); +const forbidden = [ + "sk-live-secret-value", + "plain-password", + "oauth-token-value", + "Bearer abc.def.ghi", + "session=secret", + "code=abc", + "signature=secret", + "a".repeat(80), +]; + +const leaked = forbidden.filter((value) => output.includes(value)); +if (leaked.length > 0) { + throw new Error(`Redaction leaked sensitive values: ${leaked.join(", ")}`); +} + +console.log("Log redaction checks passed.");