chore: harden production logging and redaction

This commit is contained in:
Your Name 2026-05-26 21:05:42 +08:00
parent 501f5cf0d8
commit 286acefe4a
14 changed files with 710 additions and 314 deletions

View File

@ -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

View File

@ -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;
}

View File

@ -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",

View File

@ -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 });
}
}

View File

@ -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

View File

@ -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

View File

@ -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 = `![thumb|||${imageUrls.original}|||${imageSize}](${imageUrls.thumbnail})`;
console.log(`[Volcano Vision Stream] Image ${event.image_index} uploaded, sending...`);
logger.debug(`[Volcano Vision Stream] Image ${event.image_index} uploaded, sending...`);
yield { type: "text-delta" as const, textDelta: markdown + "\n\n" };
} catch (error) {
console.error(`[Volcano Vision Stream] Failed to upload image ${event.image_index}:`, error);
logger.error(`[Volcano Vision Stream] Failed to upload image ${event.image_index}:`, error);
}
} else if (event.type === "image_generation.completed") {
console.log(`[Volcano Vision Stream] Generation completed, total images: ${allImages.length}`);
logger.debug(`[Volcano Vision Stream] Generation completed, total images: ${allImages.length}`);
// 发送完成信号
yield {
@ -1073,7 +1075,7 @@ export function createVolcanoVision({ apiKey, baseURL }: VolcanoVisionConfig) {
}
}
} catch (error) {
console.error("[Volcano Vision Stream] Generation failed:", error);
logger.error("[Volcano Vision Stream] Generation failed:", error);
const errorMessage =
error instanceof Error

View 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);
},
};
}

View 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);
},
};
}

View 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));
}

View File

@ -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;
}
}

View File

@ -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",

View 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.`);

View 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.");