🎉 first commit

This commit is contained in:
LIlGG
2025-09-24 13:06:25 +08:00
commit 1f4fb103e9
409 changed files with 61222 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
import {
deleteVercelConnectionSettings,
getVercelConnectionSettings,
saveVercelConnectionSettings,
} from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.vercel.auth');
export type HandleVercelAuthArgs = {
request: Request;
userId: string;
};
export async function handleVercelAuth({ request, userId }: HandleVercelAuthArgs) {
try {
const { token } = await request.json();
// 从数据库中获取 token
const connectionSettings = await getVercelConnectionSettings(userId);
if (!token && !connectionSettings?.token) {
return errorResponse(400, '缺少令牌参数');
}
const vercelToken = token || connectionSettings?.token;
const response = await fetch('https://api.vercel.com/v2/user', {
headers: {
Authorization: `Bearer ${vercelToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
await deleteVercelConnectionSettings(userId);
return errorResponse(401, '无效的令牌或未经授权');
}
const userData = await response.json();
await saveVercelConnectionSettings(userId, vercelToken);
logger.info(`用户 ${userId} 成功验证并保存了 Vercel 令牌`);
return successResponse(
{
user: userData.user || userData,
isConnect: !!userData,
},
'Vercel 令牌验证成功',
);
} catch (error) {
logger.error('验证 Vercel 令牌失败:', error);
return errorResponse(500, '验证 Vercel 令牌失败');
}
}

View File

@@ -0,0 +1,75 @@
import { getVercelConnectionSettings } from '~/lib/.server/connectionSettings';
import { deleteDeploymentById, getDeploymentById } from '~/lib/.server/deployment';
import { request } from '~/lib/fetch';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
import type { VercelResponseError } from './type';
const logger = createScopedLogger('api.vercel.delete');
export type DeletePageArgs = {
userId: string;
request: Request;
};
/**
* 删除 Vercel 中指定的部署
*
* @param token Vercel API 令牌
* @param deploymentId 部署 ID
* @returns 是否成功
*/
async function removeVercelDeployment(token: string, deploymentId: string): Promise<boolean> {
try {
const response = await request(`https://api.vercel.com/v13/deployments/${deploymentId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = (await response.json()) as VercelResponseError;
logger.error(`删除 Vercel 部署 ${deploymentId} 失败: ${errorData.error?.message}`);
throw new Error(`${errorData.error?.message}`);
}
return true;
} catch (error) {
logger.error(`删除 Vercel 部署 ${deploymentId} 时发生错误:`, error);
throw new Error(`${error}`);
}
}
export async function deletePage({ userId, request }: DeletePageArgs) {
const { id } = await request.json();
try {
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
const connectionSettings = await getVercelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到 Vercel请重新连接至 Vercel');
}
const deploymentId = deployment.deploymentId;
if (!deploymentId) {
return errorResponse(400, '部署记录缺少必要信息');
}
const { token } = connectionSettings;
await removeVercelDeployment(token, deploymentId);
await deleteDeploymentById(id);
logger.info(`用户 ${userId} 已删除 Vercel 部署 ${id}`);
return successResponse(id, '页面已删除');
} catch (error) {
logger.error(`删除 Vercel 部署 ${id} 失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '删除失败');
}
}

View File

@@ -0,0 +1,308 @@
import { getVercelConnectionSettings, saveVercelConnectionSettings } from '~/lib/.server/connectionSettings';
import { createOrUpdateDeployment, getLatestDeployment } from '~/lib/.server/deployment';
import { createScopedLogger } from '~/lib/.server/logger';
import { DeploymentPlatformEnum, DeploymentStatusEnum } from '~/types/deployment';
import type { VercelProjectInfo } from '~/types/vercel';
import { errorResponse, successResponse } from '~/utils/api-response';
import { isBinaryString } from '~/utils/file-utils';
const logger = createScopedLogger('api.vercel.deploy');
export type GetVercelDeployByProjectIdArgs = {
request: Request;
userId: string;
};
export async function getVercelDeployByProjectId({ request, userId }: GetVercelDeployByProjectIdArgs) {
const url = new URL(request.url);
const projectId = url.searchParams.get('projectId');
const requestToken = url.searchParams.get('token');
// 从用户设置中获取连接信息
let connectionSettings = await getVercelConnectionSettings(userId);
// 如果请求参数中提供了token优先使用请求参数中的信息并更新用户设置
if (requestToken) {
connectionSettings = {
token: requestToken,
};
// 更新用户设置
await saveVercelConnectionSettings(userId, requestToken);
}
// 如果没有连接信息,返回错误
if (!connectionSettings) {
return errorResponse(401, '未连接到Vercel请先设置访问令牌');
}
const { token } = connectionSettings;
if (!projectId) {
return errorResponse(400, '缺少项目ID');
}
try {
// Get project info
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${projectId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!projectResponse.ok) {
return errorResponse(400, '无法获取项目');
}
const projectData = (await projectResponse.json()) as any;
// Get latest deployment
const deploymentsResponse = await fetch(`https://api.vercel.com/v6/deployments?projectId=${projectId}&limit=1`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!deploymentsResponse.ok) {
return errorResponse(400, '获取部署信息失败');
}
const deploymentsData = (await deploymentsResponse.json()) as any;
const latestDeployment = deploymentsData.deployments?.[0];
return successResponse(
{
project: {
id: projectData.id,
name: projectData.name,
url: `https://${projectData.name}.vercel.app`,
},
deploy: latestDeployment
? {
id: latestDeployment.id,
state: latestDeployment.state,
url: latestDeployment.url ? `https://${latestDeployment.url}` : `https://${projectData.name}.vercel.app`,
}
: null,
},
'获取部署信息成功',
);
} catch (error) {
logger.error('Error fetching Vercel deployment:', error);
return errorResponse(500, '获取部署失败');
}
}
interface DeployRequestBody {
projectId?: string;
files: Record<string, string>;
chatId: string;
token?: string;
}
export type HandleVercelDeployArgs = {
request: Request;
userId: string;
};
// Existing action function for POST requests
export async function handleVercelDeploy({ request, userId }: HandleVercelDeployArgs) {
try {
const { projectId, files, token: requestToken, chatId } = (await request.json()) as DeployRequestBody;
// 从用户设置中获取连接信息
let connectionSettings = await getVercelConnectionSettings(userId);
// 如果请求体中提供了token优先使用请求体中的信息并更新用户设置
if (requestToken) {
connectionSettings = {
token: requestToken,
};
// 更新用户设置
await saveVercelConnectionSettings(userId, requestToken);
}
// 如果没有连接信息,返回错误
if (!connectionSettings) {
return errorResponse(401, '未连接到Vercel请先设置访问令牌');
}
const { token } = connectionSettings;
const existingDeployment = await getLatestDeployment(userId, chatId, DeploymentPlatformEnum.VERCEL);
let targetProjectId;
if (projectId) {
targetProjectId = projectId;
} else if (existingDeployment?.deploymentId) {
targetProjectId = existingDeployment.deploymentId;
} else {
targetProjectId = undefined;
}
let projectInfo: VercelProjectInfo | undefined;
if (targetProjectId) {
const projectResponse = await fetch(`https://api.vercel.com/v9/projects/${targetProjectId}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (projectResponse.ok) {
const existingProject = (await projectResponse.json()) as any;
projectInfo = {
id: existingProject.id,
name: existingProject.name,
url: `https://${existingProject.name}.vercel.app`,
chatId,
};
}
}
if (!projectInfo) {
const projectName = `upage-${chatId}-${Date.now()}`.toLocaleLowerCase();
const createProjectResponse = await fetch('https://api.vercel.com/v9/projects', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectName,
framework: null,
}),
});
if (!createProjectResponse.ok) {
const errorData = (await createProjectResponse.json()) as any;
return errorResponse(400, `创建项目失败: ${errorData.error?.message || '未知错误'}`);
}
const newProject = (await createProjectResponse.json()) as any;
targetProjectId = newProject.id;
projectInfo = {
id: newProject.id,
name: newProject.name,
url: `https://${newProject.name}.vercel.app`,
chatId,
};
}
// Prepare files for deployment
const deploymentFiles = [];
for (const [filePath, content] of Object.entries(files)) {
// Ensure file path doesn't start with a slash for Vercel
const normalizedPath = filePath.startsWith('/') ? filePath.substring(1) : filePath;
deploymentFiles.push({
file: normalizedPath,
data: isBinaryString(content) ? Buffer.from(content, 'binary').toString('base64') : content,
encoding: isBinaryString(content) ? 'base64' : 'utf-8',
});
}
// Create a new deployment
const deployResponse = await fetch(`https://api.vercel.com/v13/deployments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: projectInfo.name,
project: targetProjectId,
target: 'production',
files: deploymentFiles,
routes: [{ src: '/(.*)', dest: '/$1' }],
}),
});
if (!deployResponse.ok) {
const errorData = (await deployResponse.json()) as any;
return errorResponse(400, `创建部署失败: ${errorData.error?.message || '未知错误'}`);
}
const deployData = (await deployResponse.json()) as any;
// Poll for deployment status
let retryCount = 0;
const maxRetries = 60;
let deploymentUrl = '';
let deploymentState = '';
while (retryCount < maxRetries) {
const statusResponse = await fetch(`https://api.vercel.com/v13/deployments/${deployData.id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (statusResponse.ok) {
const status = (await statusResponse.json()) as any;
deploymentState = status.readyState;
const alias = status.alias;
const automaticAliases = status.automaticAliases;
if (status.readyState === 'READY' || status.readyState === 'ERROR') {
if (status.aliasAssigned) {
const diffAlias = alias.filter((item: string) => !automaticAliases.includes(item));
deploymentUrl = `https://${diffAlias[0]}`;
} else {
deploymentUrl = status.url ? `https://${status.url}` : '';
}
break;
}
}
retryCount++;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
if (deploymentState === 'ERROR') {
return errorResponse(500, '部署失败');
}
if (retryCount >= maxRetries) {
return errorResponse(500, '部署超时');
}
const finalUrl = deploymentUrl || projectInfo.url;
// 记录部署信息
try {
await createOrUpdateDeployment({
userId,
chatId,
platform: DeploymentPlatformEnum.VERCEL,
deploymentId: deployData.id,
url: finalUrl,
status: deploymentState === 'READY' ? DeploymentStatusEnum.SUCCESS : DeploymentStatusEnum.PENDING,
metadata: {
projectId: projectInfo.id,
projectName: projectInfo.name,
},
});
logger.info(`为用户 ${userId} 创建了 Vercel 部署记录`);
} catch (error) {
logger.error('创建部署记录失败:', error);
// 不影响主流程,继续返回成功
}
return successResponse(
{
deploy: {
id: deployData.id,
state: deploymentState,
url: finalUrl,
},
project: projectInfo,
},
'部署成功',
);
} catch (error) {
logger.error('Vercel deploy error:', error);
return errorResponse(500, '部署失败');
}
}

View File

@@ -0,0 +1,64 @@
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { requireAuth } from '~/lib/.server/auth';
import { createScopedLogger } from '~/lib/.server/logger';
import { errorResponse } from '~/utils/api-response';
import { handleVercelAuth } from './auth.server';
import { deletePage } from './delete.server';
import { getVercelDeployByProjectId, handleVercelDeploy } from './deploy.server';
import { getVercelStats } from './stats.server';
import { toggleAccess } from './toggle-access.server';
const logger = createScopedLogger('api.vercel.route');
export async function loader(args: LoaderFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
// 根据参数调用不同的处理函数
switch (params.action) {
case 'deploy':
return getVercelDeployByProjectId({ request, userId });
case 'stats':
return getVercelStats({ userId });
default:
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}
export async function action(args: ActionFunctionArgs) {
const { request, params } = args;
const authResult = await requireAuth(request, { isApi: true });
if (authResult instanceof Response) {
return authResult;
}
const userId = authResult.userInfo?.sub;
if (!userId) {
return errorResponse(401, '用户未登录');
}
logger.debug('处理 Vercel API 请求', { action: params.action });
// 根据参数调用不同的处理函数
switch (params.action) {
case 'deploy':
return handleVercelDeploy({ request, userId });
case 'auth':
return handleVercelAuth({ request, userId });
case 'toggle-access':
return toggleAccess({ ...args, userId });
case 'delete':
return deletePage({ ...args, userId });
default:
logger.warn('未知的 API 操作', { action: params.action });
return errorResponse(404, `未知的 API 操作: ${params.action}`);
}
}

View File

@@ -0,0 +1,75 @@
import { getVercelConnectionSettings } from '~/lib/.server/connectionSettings';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('api.vercel.stats');
export type GetVercelStatsArgs = {
userId: string;
};
export async function getVercelStats({ userId }: GetVercelStatsArgs) {
try {
const connectionSettings = await getVercelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到Vercel请先设置访问令牌');
}
const { token } = connectionSettings;
const projectsResponse = await fetch('https://api.vercel.com/v9/projects', {
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!projectsResponse.ok) {
return errorResponse(projectsResponse.status, '获取项目列表失败');
}
const projectsData = await projectsResponse.json();
const projects = projectsData.projects || [];
const projectsWithDeployments = await Promise.all(
projects.map(async (project: any) => {
try {
const deploymentsResponse = await fetch(
`https://api.vercel.com/v6/deployments?projectId=${project.id}&limit=1`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
},
);
if (deploymentsResponse.ok) {
const deploymentsData = await deploymentsResponse.json();
return {
...project,
latestDeployments: deploymentsData.deployments || [],
};
}
return project;
} catch (error) {
logger.error(`获取项目 ${project.id} 的部署信息失败:`, error);
return project;
}
}),
);
return successResponse(
{
projects: projectsWithDeployments,
totalProjects: projectsWithDeployments.length,
},
'获取 Vercel 项目统计信息成功',
);
} catch (error) {
logger.error('获取 Vercel 项目统计信息失败:', error);
return errorResponse(500, '获取 Vercel 项目统计信息失败');
}
}

View File

@@ -0,0 +1,190 @@
import { getVercelConnectionSettings } from '~/lib/.server/connectionSettings';
import { getDeploymentById, updateDeploymentStatus } from '~/lib/.server/deployment';
import { request } from '~/lib/fetch';
import { errorResponse, successResponse } from '~/utils/api-response';
import { createScopedLogger } from '~/utils/logger';
import type { VercelAlias, VercelResponseAliases, VercelResponseError } from './type';
const logger = createScopedLogger('api.vercel.manage');
/**
* 获取 Vercel 项目的别名列表
* @param token Vercel API 令牌
* @param deploymentId Vercel 平台部署 ID
* @returns 域名别名列表
*/
async function getVercelDomainAliases(token: string, deploymentId: string): Promise<VercelAlias[]> {
try {
const response = await request(`https://api.vercel.com/v2/deployments/${deploymentId}/aliases`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = (await response.json()) as VercelResponseError;
logger.error(`获取 Vercel 项目 ${deploymentId} 的域名别名失败: ${errorData.error?.message}`);
throw new Error(`${errorData.error?.message}`);
}
const data = (await response.json()) as VercelResponseAliases;
return data.aliases;
} catch (error) {
logger.error(`获取 Vercel 项目 ${deploymentId} 的域名别名时发生错误:`, error);
throw new Error(`${error}`);
}
}
/**
* 为 Vercel 项目添加域名别名
* @param token Vercel API 令牌
* @param deploymentId 项目 ID
* @param domain 域名
* @returns 是否成功
*/
async function setVercelDomainAlias(
token: string,
deploymentId: string,
alias: string,
redirect?: string,
): Promise<VercelAlias> {
try {
const response = await request(`https://api.vercel.com/v2/deployments/${deploymentId}/aliases`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
alias,
redirect: redirect ?? null,
}),
});
if (!response.ok) {
const errorData = (await response.json()) as VercelResponseError;
logger.error(`为 Vercel 项目 ${deploymentId} 添加域名别名 ${alias} 失败:`, errorData);
throw new Error(`${errorData.error?.message}`);
}
const data = (await response.json()) as VercelAlias;
return data;
} catch (error) {
logger.error(`为 Vercel 项目 ${deploymentId} 添加域名别名 ${alias} 时发生错误:`, error);
throw new Error(`${error}`);
}
}
/**
* 删除 Vercel 中指定的别名
* @param token Vercel API 令牌
* @param aliasId 别名 ID
* @returns 是否成功
*/
async function removeVercelDomainAlias(token: string, aliasId: string): Promise<boolean> {
try {
const response = await request(`https://api.vercel.com/v2/aliases/${aliasId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
const errorData = (await response.json()) as VercelResponseError;
logger.error(`删除 Vercel 项目 ${aliasId} 的域名别名失败: ${errorData.error?.message}`);
throw new Error(`${errorData.error?.message}`);
}
return true;
} catch (error) {
logger.error(`删除 Vercel 项目 ${aliasId} 的域名别名时发生错误:`, error);
throw new Error(`${error}`);
}
}
export type ToggleAccessArgs = {
userId: string;
request: Request;
};
export async function toggleAccess({ userId, request }: ToggleAccessArgs) {
const { id } = await request.json();
try {
const deployment = await getDeploymentById(id);
if (!deployment) {
return errorResponse(404, '未找到部署记录');
}
const connectionSettings = await getVercelConnectionSettings(userId);
if (!connectionSettings) {
return errorResponse(401, '未连接到 Vercel请重新连接至 Vercel');
}
const { token } = connectionSettings;
const projectId = deployment.deploymentId;
if (!projectId) {
return errorResponse(400, '部署记录缺少必要信息');
}
// 获取当前状态
const currentStatus = deployment.status;
const newStatus = currentStatus === 'inactive' ? 'success' : 'inactive';
// 获取当前域名别名
const currentDomains = await getVercelDomainAliases(token, projectId);
// 获取部署记录的元数据,确保它是一个对象
const metadata: Record<string, any> =
typeof deployment.metadata === 'object' && deployment.metadata !== null
? { ...(deployment.metadata as Record<string, any>) }
: {};
if (newStatus === 'inactive') {
if (currentDomains.length > 0) {
// 保存替换当前域名别名记录
metadata.aliases = currentDomains;
// 删除当前所有域名别名
for (const alias of currentDomains) {
await removeVercelDomainAlias(token, alias.uid!);
logger.info(`已删除 Vercel 项目 ${projectId} 的域名别名: ${alias.alias}`);
}
}
} else {
if (currentDomains.length === 0) {
const newAliases: VercelAlias[] = [];
// 恢复已保存的所有域名别名
const savedAliases = (metadata.aliases as VercelAlias[]) || [];
if (savedAliases.length === 0) {
// 如果保存的域名别名列表为空,则使用 deployment.url 的域名
const urlObj = new URL(deployment.url);
savedAliases.push({
alias: urlObj.hostname,
redirect: deployment.url,
oldDeploymentId: deployment.id,
});
}
for (const alias of savedAliases) {
const newAlias = await setVercelDomainAlias(token, projectId, alias.alias, alias.redirect);
newAliases.push(newAlias);
logger.info(`已为 Vercel 项目 ${projectId} 添加域名别名: ${alias.alias}`);
}
metadata.aliases = newAliases;
}
}
// 更新状态和元数据
await updateDeploymentStatus(id, newStatus, metadata);
logger.info(`用户 ${userId}${newStatus === 'success' ? '开启' : '停止'} Vercel 项目 ${projectId} 的访问`);
return successResponse(id, `${newStatus === 'success' ? '开启' : '停止'}访问`);
} catch (error) {
logger.error(`切换Vercel部署 ${id} 访问状态失败:`, error);
return errorResponse(500, error instanceof Error ? error.message : '操作失败');
}
}

View File

@@ -0,0 +1,22 @@
export type VercelAlias = {
uid?: string;
alias: string;
created?: string;
redirect?: string;
oldDeploymentId?: string;
};
export type VercelResponseAliases = {
aliases: VercelAlias[];
};
export type VercelResponseError = {
error?: {
code?: string;
message?: string;
saml?: boolean;
teamId?: string | null;
scope?: string;
enforced?: boolean;
};
};