chore: harden production logging and redaction
This commit is contained in:
parent
501f5cf0d8
commit
286acefe4a
3
.github/workflows/lint.yml
vendored
3
.github/workflows/lint.yml
vendored
@ -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
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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<Buffer>
|
||||
|
||||
// ✅ 智能判断:如果文件已经很小,跳过优化(避免双重压缩)
|
||||
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<Buffer>
|
||||
// 只有优化后文件明显更小才使用(避免不必要的处理)
|
||||
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<Buffer>
|
||||
// 只有优化后文件明显更小才使用
|
||||
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<Buffer>
|
||||
// 其他格式返回原图
|
||||
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",
|
||||
|
||||
@ -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<string, string | null> = {};
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<Buffer
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
console.log(`[Gemini Image] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
|
||||
logger.debug(`[Gemini Image] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
|
||||
return thumbnailBuffer;
|
||||
} catch (error) {
|
||||
console.error("[Gemini Image] Failed to create thumbnail:", error);
|
||||
logger.error("[Gemini Image] Failed to create thumbnail:", error);
|
||||
// 如果失败,返回原图
|
||||
return buffer;
|
||||
}
|
||||
@ -41,11 +43,11 @@ async function ensureImageUrl(url: string): Promise<string> {
|
||||
|
||||
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<string> {
|
||||
// 上传到 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<string | null>
|
||||
|
||||
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<Array<{ thumbnail: string; original: string }>> {
|
||||
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<void>[] = [];
|
||||
|
||||
@ -321,7 +323,7 @@ async function extractAndUploadImages(response: any): Promise<Array<{ thumbnail:
|
||||
if (response.choices && response.choices.length > 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<Array<{ thumbnail:
|
||||
uploadPromises.push(uploadPromise);
|
||||
} else {
|
||||
// 非 base64 图片(OpenAI URL 等),必须下载并上传到 S3(国内中转)
|
||||
console.log("[Gemini Image] Detected external URL, will download and upload to S3:", img.url.substring(0, 100) + "...");
|
||||
logger.debug("[Gemini Image] Detected external URL, will download and upload to S3:", img.url.substring(0, 100) + "...");
|
||||
const uploadPromise = downloadAndUploadToS3(img.url)
|
||||
.then(urls => {
|
||||
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<Array<{ thumbnail:
|
||||
|
||||
// 2. 尝试 Nano Banana API 格式 (results[].url)
|
||||
if (response.results && Array.isArray(response.results)) {
|
||||
console.log("[Gemini Image] Parsing Nano Banana format, results count:", response.results.length);
|
||||
logger.debug("[Gemini Image] Parsing Nano Banana format, results count:", response.results.length);
|
||||
for (const result of response.results) {
|
||||
if (result.url && typeof result.url === "string") {
|
||||
console.log("[Gemini Image] Found Nano Banana image URL:", result.url.substring(0, 100) + "...");
|
||||
logger.debug("[Gemini Image] Found Nano Banana image URL:", result.url.substring(0, 100) + "...");
|
||||
// Nano Banana 返回的是外部URL,必须下载并上传到 S3(国内中转)
|
||||
// 如果下载失败,抛出错误,不要使用原始URL(官方地址可能无法访问)
|
||||
const uploadPromise = downloadAndUploadToS3(result.url)
|
||||
.then(urls => {
|
||||
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<Array<{ thumbnail:
|
||||
uploadPromises.push(uploadPromise);
|
||||
} else {
|
||||
// 非 base64 图片(OpenAI URL 等),必须下载并上传到 S3(国内中转)
|
||||
console.log("[Gemini Image] Detected external URL in Gemini response, will download and upload to S3:", img.url.substring(0, 100) + "...");
|
||||
logger.debug("[Gemini Image] Detected external URL in Gemini response, will download and upload to S3:", img.url.substring(0, 100) + "...");
|
||||
const uploadPromise = downloadAndUploadToS3(img.url)
|
||||
.then(urls => {
|
||||
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<Array<{ thumbnail:
|
||||
// 等待所有上传完成
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
console.log("[Gemini Image] Extracted and uploaded", images.length, "images (thumbnail + original)");
|
||||
logger.debug("[Gemini Image] Extracted and uploaded", images.length, "images (thumbnail + original)");
|
||||
return images;
|
||||
}
|
||||
|
||||
@ -499,18 +501,18 @@ async function sendGeminiRequest(
|
||||
// 🆕 处理参考图:上传 Base64 到 S3
|
||||
const processedUrls: string[] = [];
|
||||
if (urls.length > 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
|
||||
|
||||
@ -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<string> {
|
||||
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<string> {
|
||||
|
||||
// 上传到 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<VideoTaskResponse> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@ -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<string | null> {
|
||||
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<string | null> {
|
||||
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<string> {
|
||||
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<Buffer
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
|
||||
console.log(`[Volcano Vision] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
|
||||
logger.debug(`[Volcano Vision] Thumbnail created, size: ${thumbnailBuffer.length} bytes (original: ${buffer.length} bytes)`);
|
||||
return thumbnailBuffer;
|
||||
} catch (error) {
|
||||
console.error("[Volcano Vision] Failed to create thumbnail:", error);
|
||||
logger.error("[Volcano Vision] Failed to create thumbnail:", error);
|
||||
// 如果失败,返回原图
|
||||
return buffer;
|
||||
}
|
||||
@ -426,7 +428,7 @@ async function uploadImageToS3(imageData: string): Promise<{ thumbnail: string;
|
||||
}
|
||||
} else {
|
||||
// 是 URL,下载图片
|
||||
console.log(`[Volcano Vision] Downloading image from URL...`);
|
||||
logger.debug(`[Volcano Vision] Downloading image from URL...`);
|
||||
const response = await fetch(imageData);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||
@ -443,7 +445,7 @@ async function uploadImageToS3(imageData: string): Promise<{ thumbnail: string;
|
||||
const thumbnailFilename = `volcano-thumb-${timestamp}-${randomStr}.${ext}`;
|
||||
const originalFilename = `volcano-orig-${timestamp}-${randomStr}.${ext}`;
|
||||
|
||||
console.log(`[Volcano Vision] Original size: ${buffer.length} bytes`);
|
||||
logger.debug(`[Volcano Vision] Original size: ${buffer.length} bytes`);
|
||||
|
||||
// 生成缩略图
|
||||
const thumbnailBuffer = await createThumbnail(buffer, mimeType);
|
||||
@ -459,22 +461,22 @@ async function uploadImageToS3(imageData: string): Promise<{ thumbnail: string;
|
||||
) as ArrayBuffer;
|
||||
|
||||
// 并行上传缩略图和原图到 S3
|
||||
console.log(`[Volcano Vision] Uploading thumbnail and original to S3...`);
|
||||
logger.debug(`[Volcano Vision] Uploading thumbnail and original to S3...`);
|
||||
const [thumbnailResult, originalResult] = await Promise.all([
|
||||
uploadFile(thumbnailFilename, thumbnailArrayBuffer),
|
||||
uploadFile(originalFilename, originalArrayBuffer),
|
||||
]);
|
||||
|
||||
console.log(`[Volcano Vision] Uploaded to S3:`);
|
||||
console.log(` - Thumbnail: ${thumbnailResult.url}`);
|
||||
console.log(` - Original: ${originalResult.url}`);
|
||||
logger.debug(`[Volcano Vision] Uploaded to S3:`);
|
||||
logger.debug(` - Thumbnail: ${thumbnailResult.url}`);
|
||||
logger.debug(` - Original: ${originalResult.url}`);
|
||||
|
||||
return {
|
||||
thumbnail: thumbnailResult.url,
|
||||
original: originalResult.url,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Volcano Vision] Failed to upload to S3:", error);
|
||||
logger.error("[Volcano Vision] Failed to upload to S3:", error);
|
||||
throw new Error(`图片上传到存储桶失败: ${error instanceof Error ? error.message : "未知错误"}。请检查系统设置中的存储配置。`);
|
||||
}
|
||||
}
|
||||
@ -545,7 +547,7 @@ async function extractAndUploadImages(response: VolcanoVisionResponse): Promise<
|
||||
// 等待所有上传完成
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
console.log("[Volcano Vision] Extracted and uploaded", images.length, "images (thumbnail + original)");
|
||||
logger.debug("[Volcano Vision] Extracted and uploaded", images.length, "images (thumbnail + original)");
|
||||
return images;
|
||||
}
|
||||
|
||||
@ -577,7 +579,7 @@ async function convertMessagesToVolcanoFormat(messages: any[]): Promise<{ prompt
|
||||
let prompt = "";
|
||||
const images: string[] = [];
|
||||
|
||||
console.log("[Volcano Vision] Converting messages, last message:", JSON.stringify(lastMessage).substring(0, 500));
|
||||
logger.debug("[Volcano Vision] Converting messages, last message:", JSON.stringify(lastMessage).substring(0, 500));
|
||||
|
||||
// 提取文本和图片
|
||||
if (lastMessage.content) {
|
||||
@ -585,7 +587,7 @@ async function convertMessagesToVolcanoFormat(messages: any[]): Promise<{ prompt
|
||||
prompt = lastMessage.content;
|
||||
} else if (Array.isArray(lastMessage.content)) {
|
||||
for (const part of lastMessage.content) {
|
||||
console.log("[Volcano Vision] Processing part:", JSON.stringify(part).substring(0, 150));
|
||||
logger.debug("[Volcano Vision] Processing part:", JSON.stringify(part).substring(0, 150));
|
||||
|
||||
if (part.type === "text") {
|
||||
prompt += part.text;
|
||||
@ -593,33 +595,33 @@ async function convertMessagesToVolcanoFormat(messages: any[]): Promise<{ prompt
|
||||
// OpenAI 格式:{ type: "image_url", image_url: { url: "..." } }
|
||||
if (part.image_url && part.image_url.url) {
|
||||
images.push(part.image_url.url);
|
||||
console.log("[Volcano Vision] ✅ Found image_url:", part.image_url.url.substring(0, 100) + "...");
|
||||
logger.debug("[Volcano Vision] ✅ Found image_url:", part.image_url.url.substring(0, 100) + "...");
|
||||
}
|
||||
} else if (part.type === "file") {
|
||||
// 文件格式:{ type: "file", mediaType: "image/...", data: "url" }
|
||||
console.log("[Volcano Vision] File part - mediaType:", part.mediaType, "data exists:", !!part.data);
|
||||
logger.debug("[Volcano Vision] File part - mediaType:", part.mediaType, "data exists:", !!part.data);
|
||||
if (part.data && part.mediaType && part.mediaType.startsWith("image/")) {
|
||||
images.push(part.data);
|
||||
console.log("[Volcano Vision] ✅ Found file (image):", part.data.substring(0, 100) + "...");
|
||||
logger.debug("[Volcano Vision] ✅ Found file (image):", part.data.substring(0, 100) + "...");
|
||||
} else {
|
||||
console.log("[Volcano Vision] ❌ File part skipped - mediaType:", part.mediaType, "data:", !!part.data);
|
||||
logger.debug("[Volcano Vision] ❌ File part skipped - mediaType:", part.mediaType, "data:", !!part.data);
|
||||
}
|
||||
} else if (part.type === "image") {
|
||||
// 其他格式:{ type: "image", image: "..." } 或 { type: "image", url: "..." }
|
||||
if (part.image) {
|
||||
images.push(part.image);
|
||||
console.log("[Volcano Vision] ✅ Found image:", part.image.substring(0, 100) + "...");
|
||||
logger.debug("[Volcano Vision] ✅ Found image:", part.image.substring(0, 100) + "...");
|
||||
} else if (part.url) {
|
||||
images.push(part.url);
|
||||
console.log("[Volcano Vision] ✅ Found image url:", part.url.substring(0, 100) + "...");
|
||||
logger.debug("[Volcano Vision] ✅ Found image url:", part.url.substring(0, 100) + "...");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Volcano Vision] Extracted prompt:", prompt.substring(0, 100));
|
||||
console.log("[Volcano Vision] Extracted images count:", images.length);
|
||||
logger.debug("[Volcano Vision] Extracted prompt:", prompt.substring(0, 100));
|
||||
logger.debug("[Volcano Vision] Extracted images count:", images.length);
|
||||
|
||||
// 🆕 直接使用图片URL,不再转换Base64(既梦API支持URL格式)
|
||||
// 只有当图片是Base64格式时才保持原样
|
||||
@ -627,7 +629,7 @@ async function convertMessagesToVolcanoFormat(messages: any[]): Promise<{ prompt
|
||||
for (const imageUrl of images) {
|
||||
if (imageUrl.startsWith('data:image')) {
|
||||
// Base64格式:需要先上传到S3获取URL
|
||||
console.log("[Volcano Vision] Found Base64 image, uploading to S3...");
|
||||
logger.debug("[Volcano Vision] Found Base64 image, uploading to S3...");
|
||||
const uploaded = await uploadImageToS3(imageUrl);
|
||||
const rewritten = await rewriteUploadPublicUrlForVolcano(uploaded.original);
|
||||
processedImages.push(rewritten);
|
||||
@ -635,14 +637,14 @@ async function convertMessagesToVolcanoFormat(messages: any[]): Promise<{ prompt
|
||||
// URL格式:直接使用
|
||||
const rewritten = await rewriteUploadPublicUrlForVolcano(imageUrl);
|
||||
processedImages.push(rewritten);
|
||||
console.log("[Volcano Vision] Using image URL directly:", rewritten.substring(0, 80) + "...");
|
||||
logger.debug("[Volcano Vision] Using image URL directly:", rewritten.substring(0, 80) + "...");
|
||||
}
|
||||
}
|
||||
|
||||
// 不在这里添加"生成多个变体"的提示,由调用方根据maxImages决定
|
||||
// 这样可以避免在场景裂变等只需要1张图片的场景中生成多张图片
|
||||
const enhancedPrompt = prompt;
|
||||
console.log("[Volcano Vision] Prompt:", enhancedPrompt.substring(0, 150));
|
||||
logger.debug("[Volcano Vision] Prompt:", enhancedPrompt.substring(0, 150));
|
||||
|
||||
// 构建请求体
|
||||
const result: { prompt: string; image?: string | string[] } = { prompt: enhancedPrompt };
|
||||
@ -670,8 +672,8 @@ async function sendVolcanoRequest(
|
||||
): Promise<VolcanoVisionResponse> {
|
||||
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<VolcanoVisionStreamEvent> {
|
||||
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 = ``;
|
||||
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
|
||||
|
||||
70
lib/observability/client-logger.ts
Normal file
70
lib/observability/client-logger.ts
Normal file
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
80
lib/observability/logger.ts
Normal file
80
lib/observability/logger.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { redactLogArgs, redactValue } from "@/lib/observability/redaction";
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error" | "silent";
|
||||
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
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<LogLevel, "silent">) {
|
||||
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<LogLevel, "silent">, 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
97
lib/observability/redaction.ts
Normal file
97
lib/observability/redaction.ts
Normal file
@ -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<string, unknown>).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));
|
||||
}
|
||||
@ -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<UploadedFileResult> {
|
||||
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<StorageUploadFunctio
|
||||
}
|
||||
|
||||
const s3Client = createS3Client(dbS3Config);
|
||||
console.log(`[Upload] Created reusable upload session: ${selectedStorageConfig?.name}`);
|
||||
console.log(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
|
||||
console.log(`[Upload] Bucket: ${dbS3Config.bucket}`);
|
||||
logger.debug(`[Upload] Created reusable upload session: ${selectedStorageConfig?.name}`);
|
||||
logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
|
||||
logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`);
|
||||
|
||||
return async (filename: string, buffer: ArrayBuffer) => {
|
||||
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<StorageUploadFunctio
|
||||
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;
|
||||
} 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<StorageUploadFunctio
|
||||
storageConfig: uploadedFromBackup.storageConfig,
|
||||
migrationConfig,
|
||||
}).catch((migrationError) => {
|
||||
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<StorageUploadFunctio
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Upload] Failed to create reusable upload session:", error);
|
||||
logger.error("[Upload] Failed to create reusable upload session:", error);
|
||||
return uploadFile;
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
"build:skip-prerender": "tsx lib/db/migrate && next build --no-lint || (echo 'Build with errors, creating fallback...' && mkdir -p .next && echo '{}' > .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",
|
||||
|
||||
35
scripts/check-log-hygiene.mjs
Normal file
35
scripts/check-log-hygiene.mjs
Normal file
@ -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.`);
|
||||
34
scripts/check-log-redaction.ts
Normal file
34
scripts/check-log-redaction.ts
Normal file
@ -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.");
|
||||
Loading…
x
Reference in New Issue
Block a user