Initial miaojingAI project with image resolution guard
This commit is contained in:
78
src/app/api/auth/admin-exists/route.ts
Normal file
78
src/app/api/auth/admin-exists/route.ts
Normal 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: '数据库连接失败' });
|
||||
}
|
||||
}
|
||||
146
src/app/api/auth/fetch-models/route.ts
Normal file
146
src/app/api/auth/fetch-models/route.ts
Normal 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] || '',
|
||||
};
|
||||
}
|
||||
290
src/app/api/auth/login/route.ts
Normal file
290
src/app/api/auth/login/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
175
src/app/api/auth/register/route.ts
Normal file
175
src/app/api/auth/register/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
215
src/app/api/auth/test-api/route.ts
Normal file
215
src/app/api/auth/test-api/route.ts
Normal 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] || '',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user