Initial miaojingAI project with image resolution guard

This commit is contained in:
FengLee
2026-05-09 11:32:34 +08:00
commit d499020d4e
264 changed files with 54160 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
import { NextResponse } from 'next/server';
import { getDbClient } from '@/storage/database/local-db';
import { ensureEmailSchema } from '@/lib/email-service';
import { getRequiredProductionSecret, isProductionRuntime } from '@/lib/runtime-env';
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
const ADMIN_EMAIL = 'admin@miaojing.ai';
export async function GET() {
try {
const client = await getDbClient();
try {
await ensureEmailSchema(client);
await ensureProfilePreferenceSchema(client);
const result = await client.query(
'SELECT id, nickname FROM profiles WHERE role = $1 LIMIT 1',
['admin']
);
if (result.rows.length > 0) {
return NextResponse.json({ exists: true, nickname: result.rows[0].nickname });
}
if (isProductionRuntime()) {
return NextResponse.json({ exists: false, autoCreated: false });
}
getRequiredProductionSecret('ADMIN_DEFAULT_PASSWORD', 'admin123');
// Development only: bootstrap the default admin profile.
const userId = crypto.randomUUID();
await client.query(
'INSERT INTO auth.users (id, email, created_at) VALUES ($1, $2, NOW())',
[userId, ADMIN_EMAIL]
);
await client.query(
`INSERT INTO profiles (
id, email, nickname, role, membership_tier, credits_balance,
daily_quota_limit, daily_quota_used, is_active, email_verified,
email_verified_at, email_bound_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, true, NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET
role = $4,
membership_tier = $5,
credits_balance = $6,
daily_quota_limit = $7,
nickname = $3,
email_verified = true,
email_verified_at = COALESCE(profiles.email_verified_at, NOW()),
email_bound_at = COALESCE(profiles.email_bound_at, NOW())`,
[userId, ADMIN_EMAIL, '管理员', 'admin', 'enterprise', 9999, 999, 0, true]
);
try {
await client.query(
'INSERT INTO credit_transactions (user_id, amount, balance_after, type, description) VALUES ($1, $2, $3, $4, $5)',
[userId, 9999, 9999, 'gift', '管理员初始积分']
);
} catch { /* non-critical */ }
console.log('[admin-exists] Default admin account created: account=admin, password=***');
return NextResponse.json({
exists: true,
autoCreated: true,
nickname: '管理员',
});
} finally {
client.release();
}
} catch (err) {
console.error('[admin-exists] Error:', err);
return NextResponse.json({ exists: false, error: '数据库连接失败' });
}
}

View File

@@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from 'next/server';
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError } from '@/lib/custom-api-fetch';
interface FetchModelsRequest {
apiUrl: string;
apiKey: string;
provider: string;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { apiUrl, apiKey, provider } = body as FetchModelsRequest;
if (!apiUrl || !apiKey) {
return NextResponse.json(
{ success: false, error: '请填写 API 请求地址和 API Key' },
{ status: 400 }
);
}
// Derive the base URL from the apiUrl
const baseUrl = apiUrl.replace(/\/images\/generations.*/, '').replace(/\/videos\/generations.*/, '').replace(/\/chat\/completions.*/, '').replace(/\/+$/, '');
const modelsUrl = `${baseUrl}/models`;
let response: Response;
try {
response = await fetchWithRetry(
modelsUrl,
{
method: 'GET',
headers: buildCustomApiHeaders(apiKey),
},
15_000,
0, // no retry
);
} catch (fetchError: unknown) {
const msg = fetchError instanceof Error ? fetchError.message : '请求失败';
return NextResponse.json({
success: false,
error: `网络错误: ${msg}`,
suggestion: '请检查 API 地址是否正确、网络是否可达',
});
}
if (response.ok) {
try {
const data = await response.json();
if (Array.isArray(data.data)) {
const models = data.data.map((m: Record<string, unknown>) => ({
id: typeof m.id === 'string' ? m.id : '',
name: typeof m.name === 'string' ? m.name : '',
description: typeof m.description === 'string' ? m.description : '',
provider: provider,
})).filter((m: { id: string }) => m.id);
return NextResponse.json({
success: true,
models: models,
message: `成功获取 ${models.length} 个模型`,
});
} else {
return NextResponse.json({
success: false,
error: 'API 返回的数据格式不正确',
suggestion: '请检查 API 地址是否正确,确保它支持 /models 端点',
});
}
} catch (parseError) {
return NextResponse.json({
success: false,
error: '解析模型数据失败',
suggestion: 'API 返回的数据格式可能不正确',
});
}
} else {
const errorText = await response.text().catch(() => '');
const isHtml = errorText.trim().startsWith('<!') || errorText.trim().startsWith('<html') || errorText.trim().startsWith('<HTML');
const parsed = isHtml
? { error: parseCustomApiError(response.status, errorText), suggestion: '' }
: parseApiError(response.status, errorText);
return NextResponse.json({
success: false,
error: parsed.error,
statusCode: response.status,
suggestion: parsed.suggestion || getDiagnosticSuggestion(response.status, isHtml),
});
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : '获取模型列表失败';
return NextResponse.json({ success: false, error: message }, { status: 500 });
}
}
/**
* Get diagnostic suggestion based on response status and content type
*/
function getDiagnosticSuggestion(statusCode: number, isHtml: boolean): string {
if (isHtml) {
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
return 'API 代理(如 Cloudflare返回错误。你的 API 在本地可用但部署环境不可用时,通常是代理防火墙拦截了服务器请求。建议:①检查 API 代理的 WAF/防火墙设置 ②将服务器 IP 加入白名单 ③尝试使用 API 的直连地址(绕过 Cloudflare';
}
if (statusCode === 403) {
return '代理防火墙拦截了请求。建议:①检查 Cloudflare WAF 规则 ②将服务器 IP 加入白名单 ③使用 API 的直连地址';
}
return 'API 返回了错误页面而非 JSON 响应,可能是代理防火墙拦截。建议使用 API 的直连地址(绕过 CDN/代理)';
}
const suggestions: Record<number, string> = {
401: 'API Key 无效或已过期,请检查密钥是否正确',
403: '账户无权限访问该模型,请检查账户状态',
404: 'API 地址不正确,请确认完整的请求端点 URL',
429: '请求频率过高或账户余额不足',
500: 'API 服务端内部错误,请稍后重试',
502: 'API 网关错误。可能原因①API 服务端宕机 ②代理防火墙拦截了服务器 IP',
503: '服务暂不可用。可能原因:①账户余额不足 ②服务维护中 ③代理限制了服务器IP',
};
return suggestions[statusCode] || '';
}
/**
* Parse common API error status codes and bodies into user-friendly messages
*/
function parseApiError(statusCode: number, errorBody: string): { error: string; suggestion: string } {
// Delegate HTML detection to shared utility
const friendlyError = parseCustomApiError(statusCode, errorBody);
const suggestions: Record<number, string> = {
401: 'API Key 无效或已过期,请检查密钥是否正确',
403: '账户无权限访问该模型,请检查账户状态',
404: 'API 地址不正确,请确认完整的请求端点 URL',
429: '请求频率过高或账户余额不足',
500: 'API 服务端内部错误,请稍后重试',
502: 'API 网关错误。可能原因①API 服务端宕机 ②代理防火墙拦截了服务器 IP',
503: '服务暂不可用。可能原因:①账户余额不足 ②服务维护中 ③代理限制了服务器IP',
};
return {
error: friendlyError,
suggestion: suggestions[statusCode] || '',
};
}

View File

@@ -0,0 +1,290 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDbClient } from '@/storage/database/local-db';
import { ensureEmailSchema } from '@/lib/email-service';
import { createSessionToken } from '@/lib/session-auth';
import { getRequiredProductionSecret } from '@/lib/runtime-env';
import { writePlatformLog } from '@/lib/platform-logs';
import { ensureProfilePreferenceSchema, normalizePreferredTheme } from '@/lib/profile-preferences';
function normalizeRoleForTier(role: string | null | undefined, tier: string | null | undefined): string {
const currentRole = role || 'user';
if (currentRole === 'admin' || currentRole === 'enterprise_admin') return currentRole;
return tier && tier !== 'free' ? 'vip' : currentRole === 'vip' ? 'user' : currentRole;
}
async function verifyPasswordHash(client: Awaited<ReturnType<typeof getDbClient>>, passwordHash: string, password: string): Promise<boolean> {
const result = await client.query(
'SELECT $1::text = crypt($2::text, $1::text) AS ok',
[passwordHash, password]
);
return result.rows[0]?.ok === true;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { email: rawEmail, account, phone: rawPhone, password, adminOnly } = body;
const identifier = account || rawEmail || rawPhone;
if (!identifier || !password) {
return NextResponse.json({ error: 'Please enter account and password' }, { status: 400 });
}
const client = await getDbClient();
try {
await ensureEmailSchema(client);
await ensureProfilePreferenceSchema(client);
let loginEmail = identifier;
let userId = '';
let userRole = 'user';
let userNickname = '';
let userMembershipTier = 'free';
let userCreditsBalance = 0;
let userDailyQuotaUsed = 0;
let userDailyQuotaLimit = 5;
let userAvatarUrl: string | null = null;
let userPhone: string | null = null;
let userCreatedAt: string | null = null;
let userEmailVerified = false;
let userEmailVerifiedAt: string | null = null;
let userPreferredTheme: 'dark' | 'light' = 'dark';
const isEmailFormat = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(identifier);
let isAdminAccount = false;
let adminProfileId: string | null = null;
if (!isEmailFormat) {
const adminLookup = await client.query(
"SELECT id, email, nickname, role FROM profiles WHERE (nickname = $1 OR phone = $1) AND role = 'admin' LIMIT 1",
[identifier]
);
if (adminLookup.rows.length > 0) {
isAdminAccount = true;
adminProfileId = adminLookup.rows[0].id;
loginEmail = adminLookup.rows[0].email;
userNickname = adminLookup.rows[0].nickname || '';
} else {
const nicknameLower = String(identifier).toLowerCase();
if (nicknameLower === 'admin' || nicknameLower.startsWith('admin')) {
const anyLookup = await client.query(
"SELECT id, email, nickname, role FROM profiles WHERE role = 'admin' ORDER BY created_at ASC LIMIT 1"
);
if (anyLookup.rows.length > 0) {
isAdminAccount = true;
adminProfileId = anyLookup.rows[0].id;
loginEmail = anyLookup.rows[0].email;
userNickname = anyLookup.rows[0].nickname || '';
}
}
}
} else {
const adminLookup = await client.query(
"SELECT id, email, nickname, role FROM profiles WHERE email = $1 AND role = 'admin' LIMIT 1",
[identifier]
);
if (adminLookup.rows.length > 0) {
isAdminAccount = true;
adminProfileId = adminLookup.rows[0].id;
loginEmail = identifier;
userNickname = adminLookup.rows[0].nickname || '';
}
}
if (isAdminAccount) {
const authResult = await client.query(
'SELECT id, email, created_at, password_hash FROM auth.users WHERE email = $1',
[loginEmail]
);
if (authResult.rows.length > 0 && authResult.rows[0].password_hash) {
const passwordOk = await verifyPasswordHash(client, authResult.rows[0].password_hash, password);
if (!passwordOk) {
return NextResponse.json({ error: 'Invalid admin password' }, { status: 401 });
}
} else if (password !== getRequiredProductionSecret('ADMIN_DEFAULT_PASSWORD', 'admin123')) {
return NextResponse.json({ error: 'Invalid admin password' }, { status: 401 });
}
userRole = 'admin';
userMembershipTier = 'enterprise';
userCreditsBalance = 9999;
userDailyQuotaLimit = 999;
userNickname = userNickname || '管理员';
userEmailVerified = true;
userEmailVerifiedAt = new Date().toISOString();
if (authResult.rows.length > 0) {
userId = authResult.rows[0].id;
userCreatedAt = authResult.rows[0].created_at;
} else if (adminProfileId) {
userId = adminProfileId;
await client.query(
'INSERT INTO auth.users (id, email, created_at) VALUES ($1, $2, NOW()) ON CONFLICT (id) DO NOTHING',
[userId, loginEmail]
);
userCreatedAt = new Date().toISOString();
} else {
userId = crypto.randomUUID();
await client.query(
'INSERT INTO auth.users (id, email, created_at) VALUES ($1, $2, NOW())',
[userId, loginEmail]
);
userCreatedAt = new Date().toISOString();
}
await client.query(
`INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_limit, daily_quota_used, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (id) DO UPDATE SET
role = $4,
membership_tier = $5,
credits_balance = $6,
daily_quota_limit = $7,
nickname = $3,
is_active = true,
email_verified = true,
email_verified_at = COALESCE(profiles.email_verified_at, NOW()),
email_bound_at = COALESCE(profiles.email_bound_at, NOW())`,
[userId, loginEmail, userNickname, 'admin', 'enterprise', 9999, 999, 0, true]
);
const adminThemeResult = await client.query(
'SELECT preferred_theme FROM profiles WHERE id = $1 LIMIT 1',
[userId]
);
userPreferredTheme = normalizePreferredTheme(adminThemeResult.rows[0]?.preferred_theme);
if (adminProfileId && adminProfileId !== userId) {
await client.query(
'UPDATE profiles SET role = $1, membership_tier = $2, credits_balance = $3, daily_quota_limit = $4 WHERE id = $5',
['admin', 'enterprise', 9999, 999, adminProfileId]
);
}
} else {
if (!isEmailFormat) {
const profileResult = await client.query(
'SELECT id, email, nickname, phone, role FROM profiles WHERE nickname = $1 OR phone = $1 LIMIT 1',
[identifier]
);
if (profileResult.rows.length > 0) {
const profile = profileResult.rows[0];
loginEmail = profile.email;
userId = profile.id;
userRole = profile.role || 'user';
userNickname = profile.nickname;
userPhone = profile.phone;
} else {
return NextResponse.json({ error: 'Account does not exist' }, { status: 401 });
}
}
const authResult = await client.query(
'SELECT id, email, created_at, password_hash FROM auth.users WHERE email = $1',
[loginEmail]
);
if (authResult.rows.length === 0) {
return NextResponse.json({ error: 'Account does not exist' }, { status: 401 });
}
const authUser = authResult.rows[0];
if (authUser.password_hash) {
const passwordOk = await verifyPasswordHash(client, authUser.password_hash, password);
if (!passwordOk) {
return NextResponse.json({ error: 'Invalid password' }, { status: 401 });
}
} else {
return NextResponse.json({ error: '该账号缺少密码凭据,请联系管理员重置密码后再登录' }, { status: 401 });
}
userId = authUser.id;
userCreatedAt = authUser.created_at;
const profileResult = await client.query(
'SELECT nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit, avatar_url, phone, email_verified, email_verified_at, preferred_theme FROM profiles WHERE id = $1',
[userId]
);
if (profileResult.rows.length > 0) {
const profile = profileResult.rows[0];
userNickname = profile.nickname || loginEmail.split('@')[0];
userMembershipTier = profile.membership_tier || 'free';
userRole = normalizeRoleForTier(profile.role, userMembershipTier);
userCreditsBalance = profile.credits_balance || 0;
userDailyQuotaUsed = profile.daily_quota_used || 0;
userDailyQuotaLimit = profile.daily_quota_limit || 5;
userAvatarUrl = profile.avatar_url || null;
userPhone = profile.phone || null;
userEmailVerified = profile.email_verified === true;
userEmailVerifiedAt = profile.email_verified_at || null;
userPreferredTheme = normalizePreferredTheme(profile.preferred_theme);
if (userRole !== (profile.role || 'user')) {
await client.query('UPDATE profiles SET role = $1, updated_at = NOW() WHERE id = $2', [userRole, userId]);
}
} else {
userNickname = loginEmail.split('@')[0];
await client.query(
`INSERT INTO profiles (id, email, nickname, role, membership_tier, credits_balance, daily_quota_used, daily_quota_limit)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (id) DO UPDATE SET email = $2, nickname = $3, email_verified = false, email_verified_at = NULL`,
[userId, loginEmail, userNickname, userRole, userMembershipTier, userCreditsBalance, userDailyQuotaUsed, userDailyQuotaLimit]
);
}
}
if (adminOnly === true && userRole !== 'admin' && userRole !== 'enterprise_admin') {
void writePlatformLog({
type: 'security',
level: 'warning',
action: 'console_login_denied',
message: '非管理员账号尝试登录管理后台被拒绝',
userId,
userName: userNickname,
userEmail: loginEmail,
request,
});
return NextResponse.json({ error: 'Only administrators can log in to the console' }, { status: 403 });
}
const accessToken = createSessionToken(userId, userRole);
void writePlatformLog({
type: 'auth',
level: 'info',
action: adminOnly === true ? 'console_login_success' : 'user_login_success',
message: adminOnly === true ? '管理员登录管理后台成功' : '用户登录成功',
userId,
userName: userNickname,
userEmail: loginEmail,
request,
});
return NextResponse.json({
user: {
id: userId,
email: loginEmail,
nickname: userNickname,
role: userRole,
membership_tier: userMembershipTier,
credits_balance: userCreditsBalance,
daily_quota_used: userDailyQuotaUsed,
daily_quota_limit: userDailyQuotaLimit,
avatar_url: userAvatarUrl,
phone: userPhone,
created_at: userCreatedAt,
email_verified: userEmailVerified,
email_verified_at: userEmailVerifiedAt,
preferred_theme: userPreferredTheme,
},
session: { access_token: accessToken },
});
} finally {
client.release();
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Login failed';
console.error('[Login Error]', message);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,175 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDbClient } from '@/storage/database/local-db';
import { ensureEmailSchema, getRequestBaseUrl, normalizeEmail, sendTemplatedEmail, verifyEmailCode } from '@/lib/email-service';
import { getRequiredProductionSecret } from '@/lib/runtime-env';
import { ensureProfilePreferenceSchema } from '@/lib/profile-preferences';
function isStrongPassword(password: string): boolean {
return password.length >= 8 && /[A-Za-z]/.test(password) && /\d/.test(password);
}
export async function POST(request: NextRequest) {
try {
const { email, password, nickname, phone, inviteCode, emailCode, acceptedTerms } = await request.json();
const normalizedEmail = normalizeEmail(email);
if (!normalizedEmail || !password) {
return NextResponse.json({ error: 'Please enter email and password' }, { status: 400 });
}
if (acceptedTerms !== true) {
return NextResponse.json({ error: '请先阅读并同意服务条款和隐私政策' }, { status: 400 });
}
if (!isStrongPassword(password)) {
return NextResponse.json({ error: '密码至少 8 位,并同时包含字母和数字' }, { status: 400 });
}
const isAdminRegistration = typeof inviteCode === 'string'
&& inviteCode === getRequiredProductionSecret('ADMIN_INVITE_CODE', 'miaojing-admin-2024');
const client = await getDbClient();
try {
await ensureEmailSchema(client);
await ensureProfilePreferenceSchema(client);
if (isAdminRegistration) {
const existingAdminResult = await client.query(
'SELECT id FROM profiles WHERE role = $1',
['admin']
);
if (existingAdminResult.rows.length > 0) {
return NextResponse.json(
{ error: 'Admin account already exists' },
{ status: 400 }
);
}
}
const existingUserResult = await client.query(
'SELECT id FROM profiles WHERE email = $1',
[normalizedEmail]
);
if (existingUserResult.rows.length > 0) {
return NextResponse.json(
{ error: 'Email is already registered' },
{ status: 400 }
);
}
const userId = crypto.randomUUID();
if (!isAdminRegistration) {
if (typeof emailCode !== 'string' || !/^[a-z0-9]{4,10}$/i.test(emailCode)) {
return NextResponse.json({ error: '请输入正确的邮箱验证码' }, { status: 400 });
}
await client.query('BEGIN');
try {
await verifyEmailCode(client, {
email: normalizedEmail,
type: 'register',
code: typeof emailCode === 'string' ? emailCode : '',
});
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
}
}
await client.query(
`INSERT INTO auth.users (id, email, password_hash, created_at)
VALUES ($1, $2, crypt($3, gen_salt('bf')), NOW())`,
[userId, normalizedEmail, password]
);
const role = isAdminRegistration ? 'admin' : 'user';
const membershipTier = isAdminRegistration ? 'enterprise' : 'free';
const creditsBalance = isAdminRegistration ? 9999 : 10;
const dailyQuotaLimit = isAdminRegistration ? 999 : 5;
const displayName = nickname || normalizedEmail.split('@')[0];
await client.query(
`INSERT INTO profiles (
id, email, nickname, phone, role, membership_tier, credits_balance,
daily_quota_limit, daily_quota_used, is_active, email_verified,
email_verified_at, email_bound_at, email_sender_domain
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, CASE WHEN $11 THEN NOW() ELSE NULL END, CASE WHEN $11 THEN NOW() ELSE NULL END, $12)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
nickname = EXCLUDED.nickname,
phone = EXCLUDED.phone,
role = EXCLUDED.role,
membership_tier = EXCLUDED.membership_tier,
credits_balance = EXCLUDED.credits_balance,
daily_quota_limit = EXCLUDED.daily_quota_limit,
daily_quota_used = EXCLUDED.daily_quota_used,
is_active = EXCLUDED.is_active,
email_verified = EXCLUDED.email_verified,
email_verified_at = EXCLUDED.email_verified_at,
email_bound_at = EXCLUDED.email_bound_at,
email_sender_domain = EXCLUDED.email_sender_domain`,
[
userId,
normalizedEmail,
displayName,
phone || null,
role,
membershipTier,
creditsBalance,
dailyQuotaLimit,
0,
true,
true,
normalizedEmail.split('@')[1] || null,
]
);
try {
await client.query(
'INSERT INTO credit_transactions (user_id, amount, balance_after, type, description) VALUES ($1, $2, $3, $4, $5)',
[userId, creditsBalance, creditsBalance, 'gift', isAdminRegistration ? 'Admin initial credits' : 'New user registration bonus']
);
} catch {
// Ignore credit transaction errors.
}
await sendTemplatedEmail(client, {
to: normalizedEmail,
type: 'register_success',
subject: '【妙境】注册成功',
title: '注册成功',
intro: isAdminRegistration ? '管理员账号已创建成功。' : '你的妙境账号已注册成功,邮箱也已完成验证。',
note: '若非本人操作,请尽快联系管理员。',
assetBaseUrl: getRequestBaseUrl(request) || undefined,
}).catch(() => undefined);
return NextResponse.json({
user: {
id: userId,
email: normalizedEmail,
nickname: displayName,
role,
membership_tier: membershipTier,
credits_balance: creditsBalance,
daily_quota_used: 0,
daily_quota_limit: dailyQuotaLimit,
avatar_url: null,
phone: phone || null,
email_verified: true,
email_verified_at: new Date().toISOString(),
preferred_theme: 'dark',
},
message: isAdminRegistration ? 'Admin account registered' : 'Registration successful',
});
} finally {
client.release();
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Registration failed';
console.error('[Register Error]', message);
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,215 @@
import { NextRequest, NextResponse } from 'next/server';
import { buildCustomApiHeaders, fetchWithRetry, parseCustomApiError } from '@/lib/custom-api-fetch';
interface TestApiRequest {
apiUrl: string;
apiKey: string;
modelName: string;
provider: string;
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { apiUrl, apiKey, modelName, provider } = body as TestApiRequest;
if (!apiUrl || !apiKey) {
return NextResponse.json(
{ success: false, error: '请填写 API 请求地址和 API Key' },
{ status: 400 }
);
}
// ---- Step 1: Quick connectivity check with a lightweight request ----
// Try the /models endpoint first (most APIs support this, no cost)
// Derive the base URL from the apiUrl
const baseUrl = apiUrl.replace(/\/images\/generations.*/, '').replace(/\/videos\/generations.*/, '').replace(/\/chat\/completions.*/, '').replace(/\/+$/, '');
const modelsUrl = `${baseUrl}/models`;
let response: Response;
try {
response = await fetchWithRetry(
modelsUrl,
{
method: 'GET',
headers: buildCustomApiHeaders(apiKey),
},
15_000,
0, // no retry for test - keep it fast
);
} catch (fetchError: unknown) {
// If /models fails with timeout or network error, try the actual endpoint
if (fetchError instanceof DOMException && fetchError.name === 'AbortError') {
return await testActualEndpoint(apiUrl, apiKey, modelName || 'gpt-image-2');
}
const msg = fetchError instanceof Error ? fetchError.message : '请求失败';
// Network error - could be DNS, connection refused, or firewall
if (msg.includes('ECONNREFUSED') || msg.includes('ENOTFOUND') || msg.includes('fetch failed')) {
return NextResponse.json({
success: false,
error: `无法连接到 API 地址: ${msg}`,
suggestion: '请检查 API 地址是否正确、服务是否运行。常见原因:①地址拼写错误 ②服务未启动 ③DNS 无法解析',
});
}
return NextResponse.json({
success: false,
error: `网络错误: ${msg}`,
suggestion: '请检查 API 地址是否正确、网络是否可达。如果使用了代理(如 Cloudflare可能代理防火墙拦截了服务器请求',
});
}
// If /models returned successfully, the key is valid
if (response.ok) {
let modelInfo = '';
try {
const data = await response.json();
if (Array.isArray(data.data)) {
const targetModel = modelName || 'gpt-image-2';
const found = data.data.some((m: Record<string, unknown>) =>
typeof m.id === 'string' && m.id.includes(targetModel.replace('gpt-image-2', 'dall'))
);
modelInfo = found ? `,模型 ${modelName || 'gpt-image-2'} 可用` : `,已连接(共 ${data.data.length} 个模型)`;
}
} catch {
// Ignore parse error, connectivity is confirmed
}
return NextResponse.json({
success: true,
message: `连接成功${modelInfo}`,
});
}
// /models returned an error - check if it's HTML (Cloudflare block)
const errorText = await response.text().catch(() => '');
const isHtml = errorText.trim().startsWith('<!') || errorText.trim().startsWith('<html') || errorText.trim().startsWith('<HTML');
if (response.status === 404 && !isHtml) {
// /models not supported (not a Cloudflare error), try actual endpoint
return await testActualEndpoint(apiUrl, apiKey, modelName || 'gpt-image-2');
}
// Auth/permission error or Cloudflare block
const parsed = isHtml
? { error: parseCustomApiError(response.status, errorText), suggestion: '' }
: parseApiError(response.status, errorText);
return NextResponse.json({
success: false,
error: parsed.error,
statusCode: response.status,
suggestion: parsed.suggestion || getDiagnosticSuggestion(response.status, isHtml),
});
} catch (error: unknown) {
const message = error instanceof Error ? error.message : '测试连接失败';
return NextResponse.json({ success: false, error: message }, { status: 500 });
}
}
/**
* Fallback: test by sending a minimal request to the actual generation endpoint
*/
async function testActualEndpoint(apiUrl: string, apiKey: string, modelName: string): Promise<NextResponse> {
try {
const response = await fetchWithRetry(
apiUrl,
{
method: 'POST',
headers: buildCustomApiHeaders(apiKey),
body: JSON.stringify({
model: modelName,
prompt: 'test',
n: 1,
size: '1024x1024',
}),
},
15_000,
0, // no retry for test
);
if (response.ok) {
return NextResponse.json({
success: true,
message: `连接成功,模型 ${modelName} 可用`,
});
}
const errorText = await response.text().catch(() => '');
const parsed = parseApiError(response.status, errorText);
return NextResponse.json({
success: false,
error: parsed.error,
statusCode: response.status,
suggestion: parsed.suggestion,
});
} catch (fetchError: unknown) {
if (fetchError instanceof DOMException && fetchError.name === 'AbortError') {
return NextResponse.json({
success: false,
error: '连接超时15秒请检查 API 地址是否正确',
suggestion: '可能原因①API 地址有误 ②服务响应过慢 ③代理限制了服务器IP访问',
});
}
const msg = fetchError instanceof Error ? fetchError.message : '请求失败';
return NextResponse.json({
success: false,
error: `网络错误: ${msg}`,
suggestion: '请检查 API 地址和网络连通性',
});
}
}
/**
* Get diagnostic suggestion based on response status and content type
*/
function getDiagnosticSuggestion(statusCode: number, isHtml: boolean): string {
if (isHtml) {
if (statusCode === 502 || statusCode === 503 || statusCode === 504) {
return 'API 代理(如 Cloudflare返回错误。你的 API 在本地可用但部署环境不可用时,通常是代理防火墙拦截了服务器请求。建议:①检查 API 代理的 WAF/防火墙设置 ②将服务器 IP 加入白名单 ③尝试使用 API 的直连地址(绕过 Cloudflare';
}
if (statusCode === 403) {
return '代理防火墙拦截了请求。建议:①检查 Cloudflare WAF 规则 ②将服务器 IP 加入白名单 ③使用 API 的直连地址';
}
return 'API 返回了错误页面而非 JSON 响应,可能是代理防火墙拦截。建议使用 API 的直连地址(绕过 CDN/代理)';
}
const suggestions: Record<number, string> = {
401: 'API Key 无效或已过期,请检查密钥是否正确',
403: '账户无权限访问该模型,请检查账户状态',
404: 'API 地址不正确,请确认完整的请求端点 URL',
429: '请求频率过高或账户余额不足',
500: 'API 服务端内部错误,请稍后重试',
502: 'API 网关错误。可能原因①API 服务端宕机 ②代理防火墙拦截了服务器 IP',
503: '服务暂不可用。可能原因:①账户余额不足 ②服务维护中 ③代理限制了服务器IP',
};
return suggestions[statusCode] || '';
}
/**
* Parse common API error status codes and bodies into user-friendly messages
*/
function parseApiError(statusCode: number, errorBody: string): { error: string; suggestion: string } {
// Delegate HTML detection to shared utility
const friendlyError = parseCustomApiError(statusCode, errorBody);
const suggestions: Record<number, string> = {
401: 'API Key 无效或已过期,请检查密钥是否正确',
403: '账户无权限访问该模型,请检查账户状态',
404: 'API 地址不正确,请确认完整的请求端点 URL',
429: '请求频率过高或账户余额不足',
500: 'API 服务端内部错误,请稍后重试',
502: 'API 网关错误。可能原因①API 服务端宕机 ②代理防火墙拦截了服务器 IP',
503: '服务暂不可用。可能原因:①账户余额不足 ②服务维护中 ③代理限制了服务器IP',
};
return {
error: friendlyError,
suggestion: suggestions[statusCode] || '',
};
}