feat: up
This commit is contained in:
parent
15d61a005f
commit
57f964744f
553
design.md
Normal file
553
design.md
Normal file
@ -0,0 +1,553 @@
|
||||
好的!我理解了,你需要:
|
||||
|
||||
1. **前端**:现代化框架(React/Vue)
|
||||
2. **后端**:独立的轻量后端(不用 Next.js API Routes)
|
||||
3. **部署**:都要简单方便
|
||||
|
||||
让我重新设计一个最佳方案!🎯 **最终推荐架构**
|
||||
|
||||
* * *
|
||||
|
||||
### **技术选型**
|
||||
|
||||
前端 (SPA):
|
||||
- Framework: Vite + React 18 + TypeScript
|
||||
- UI: Tailwind CSS + shadcn/ui
|
||||
- State: Zustand + TanStack Query
|
||||
- Charts: Recharts + D3.js
|
||||
- 部署: Cloudflare Pages / Vercel (静态托管)
|
||||
|
||||
后端 (API):
|
||||
- Framework: Fastify + TypeScript (比Express快2倍)
|
||||
- GitHub SDK: Octokit
|
||||
- Database: PostgreSQL (Supabase免费托管)
|
||||
- Cache: Upstash Redis (Serverless)
|
||||
- 部署: Railway.app ⭐ (一键部署,免费额度够用)
|
||||
|
||||
认证:
|
||||
- GitHub OAuth 2.0
|
||||
- JWT Token
|
||||
|
||||
实时通信:
|
||||
- Server-Sent Events (SSE) - 比WebSocket简单
|
||||
- 或 Pusher (免费版)
|
||||
|
||||
* * *
|
||||
|
||||
🏗️ **系统架构图**
|
||||
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 用户浏览器 │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────┐ │
|
||||
│ │ React SPA (Vite) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┐ ┌───────────┐ ┌──────────────┐ │ │
|
||||
│ │ │Dashboard │ │Repository │ │Analytics │ │ │
|
||||
│ │ │ Page │ │Detail Page│ │ Page │ │ │
|
||||
│ │ └────┬─────┘ └─────┬─────┘ └──────┬───────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ └──────────────┴────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌─────────▼──────────┐ │ │
|
||||
│ │ │ TanStack Query │ (数据获取层) │ │
|
||||
│ │ │ + Axios/Fetch │ │ │
|
||||
│ │ └─────────┬──────────┘ │ │
|
||||
│ └──────────────────────┼───────────────────────────────┘ │
|
||||
└─────────────────────────┼──────────────────────────────────┘
|
||||
│
|
||||
HTTPS (REST API)
|
||||
│
|
||||
┌─────────────────────────▼──────────────────────────────────┐
|
||||
│ 后端服务器 (Railway) │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ Fastify API Server (Node.js) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ Auth │ │ Repos │ │ Webhooks │ │ │
|
||||
│ │ │ /login │ │ /repos/* │ │ /webhooks/* │ │ │
|
||||
│ │ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ │
|
||||
│ │ │ │ │ │ │
|
||||
│ │ └─────────────┴────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ ┌──────────▼──────────┐ │ │
|
||||
│ │ │ Service Layer │ │ │
|
||||
│ │ │ • GitHubService │ │ │
|
||||
│ │ │ • CacheService │ │ │
|
||||
│ │ │ • WebhookService │ │ │
|
||||
│ │ └──────────┬──────────┘ │ │
|
||||
│ └─────────────────────┼────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────┼──────────────┐ │
|
||||
│ │ │ │ │
|
||||
│ ┌────▼─────┐ ┌────▼────┐ ┌──────▼──────┐ │
|
||||
│ │ Octokit │ │ Prisma │ │ Redis Cache │ │
|
||||
│ │ GitHub │ │ ORM │ │ (Upstash) │ │
|
||||
│ │ API │ └────┬────┘ └─────────────┘ │
|
||||
│ └──────────┘ │ │
|
||||
└───────────────────────┼─────────────────────────────────────┘
|
||||
│
|
||||
┌──────▼──────┐
|
||||
│ PostgreSQL │
|
||||
│ (Supabase) │
|
||||
└─────────────┘
|
||||
|
||||
┌────────────────────────────────────────────┐
|
||||
│ 外部服务 │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────┐ │
|
||||
│ │ GitHub API │ │ GitHub Webhooks │ │
|
||||
│ │ (公开接口) │ │ (实时事件推送) │ │
|
||||
│ └──────────────┘ └──────────────────┘ │
|
||||
└────────────────────────────────────────────┘
|
||||
|
||||
* * *
|
||||
|
||||
📂 **项目结构**
|
||||
|
||||
github-dashboard/
|
||||
├── frontend/ # 前端项目 (Vite)
|
||||
│ ├── src/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── dashboard/
|
||||
│ │ │ │ ├── StatsCard.tsx
|
||||
│ │ │ │ ├── ActivityChart.tsx
|
||||
│ │ │ │ └── RepoList.tsx
|
||||
│ │ │ ├── charts/
|
||||
│ │ │ └── ui/ # shadcn/ui 组件
|
||||
│ │ ├── pages/
|
||||
│ │ │ ├── Dashboard.tsx
|
||||
│ │ │ ├── RepoDetail.tsx
|
||||
│ │ │ └── Analytics.tsx
|
||||
│ │ ├── hooks/
|
||||
│ │ │ ├── useRepos.ts
|
||||
│ │ │ └── useRepoStats.ts
|
||||
│ │ ├── lib/
|
||||
│ │ │ ├── api.ts # API 请求封装
|
||||
│ │ │ └── auth.ts # 认证逻辑
|
||||
│ │ ├── stores/
|
||||
│ │ │ └── authStore.ts # Zustand store
|
||||
│ │ ├── App.tsx
|
||||
│ │ └── main.tsx
|
||||
│ ├── package.json
|
||||
│ ├── vite.config.ts
|
||||
│ └── tailwind.config.js
|
||||
│
|
||||
├── backend/ # 后端项目 (Fastify)
|
||||
│ ├── src/
|
||||
│ │ ├── routes/
|
||||
│ │ │ ├── auth.ts # POST /auth/login
|
||||
│ │ │ ├── repos.ts # GET /repos, /repos/:id
|
||||
│ │ │ ├── webhooks.ts # POST /webhooks/github
|
||||
│ │ │ └── stats.ts # GET /stats/*
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── github.service.ts
|
||||
│ │ │ ├── cache.service.ts
|
||||
│ │ │ └── webhook.service.ts
|
||||
│ │ ├── middlewares/
|
||||
│ │ │ ├── auth.middleware.ts
|
||||
│ │ │ └── error.middleware.ts
|
||||
│ │ ├── prisma/
|
||||
│ │ │ └── schema.prisma
|
||||
│ │ ├── types/
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── utils/
|
||||
│ │ │ └── logger.ts
|
||||
│ │ ├── app.ts # Fastify 实例
|
||||
│ │ └── server.ts # 启动文件
|
||||
│ ├── package.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── .env.example
|
||||
│
|
||||
└── README.md
|
||||
|
||||
* * *
|
||||
|
||||
🚀 **部署方案**
|
||||
|
||||
### **前端部署:Cloudflare Pages** ⭐
|
||||
|
||||
# 1. 构建
|
||||
cd frontend
|
||||
npm run build
|
||||
|
||||
# 2. Cloudflare Pages 自动部署
|
||||
# - 连接 GitHub 仓库
|
||||
# - 构建命令: npm run build
|
||||
# - 输出目录: dist
|
||||
# - 自动部署: 推送到 main 分支即触发
|
||||
|
||||
**优势:**
|
||||
|
||||
* ✅ 完全免费
|
||||
* ✅ 全球 CDN(超快)
|
||||
* ✅ 自动 HTTPS
|
||||
* ✅ 无限带宽
|
||||
* ✅ 支持自定义域名
|
||||
|
||||
* * *
|
||||
|
||||
### **后端部署:Railway.app** ⭐⭐⭐
|
||||
|
||||
# 1. 安装 Railway CLI
|
||||
npm install -g @railway/cli
|
||||
|
||||
# 2. 登录
|
||||
railway login
|
||||
|
||||
# 3. 初始化项目
|
||||
cd backend
|
||||
railway init
|
||||
|
||||
# 4. 部署
|
||||
railway up
|
||||
|
||||
# 5. 添加数据库
|
||||
railway add postgresql
|
||||
|
||||
# 6. 设置环境变量
|
||||
railway variables set GITHUB_CLIENT_ID=xxx
|
||||
railway variables set GITHUB_CLIENT_SECRET=xxx
|
||||
|
||||
**Railway 配置文件 (`railway.json`)**: { "$schema": "https://railway.app/railway.schema.json", "build": { "builder": "NIXPACKS" }, "deploy": { "startCommand": "npm run start", "restartPolicyType": "ON_FAILURE", "restartPolicyMaxRetries": 10 } }
|
||||
|
||||
**优势:**
|
||||
|
||||
* ✅ 免费额度:$5/月(足够小项目)
|
||||
* ✅ 一键部署,零配置
|
||||
* ✅ 自动 HTTPS
|
||||
* ✅ 内置 PostgreSQL
|
||||
* ✅ 环境变量管理
|
||||
* ✅ 自动 Git 同步部署
|
||||
|
||||
* * *
|
||||
|
||||
### **替代方案对比**
|
||||
|
||||
| 平台 | 免费额度 | 部署难度 | 启动速度 | 推荐度 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Railway** | $5/月 | ⭐⭐⭐⭐⭐ | 快 | ⭐⭐⭐⭐⭐ |
|
||||
| Render | 750h/月 | ⭐⭐⭐⭐ | 慢(冷启动) | ⭐⭐⭐⭐ |
|
||||
| Fly.io | 3个免费实例 | ⭐⭐⭐ | 快 | ⭐⭐⭐⭐ |
|
||||
| Heroku | 无免费版 | ⭐⭐⭐⭐ | 中等 | ⭐⭐ |
|
||||
|
||||
* * *
|
||||
|
||||
💻 **核心代码示例**
|
||||
|
||||
### **后端 - Fastify 入口 (`backend/src/app.ts`)**
|
||||
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import { authRoutes } from './routes/auth';
|
||||
import { repoRoutes } from './routes/repos';
|
||||
import { webhookRoutes } from './routes/webhooks';
|
||||
|
||||
export function createApp() {
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
||||
// 插件
|
||||
app.register(cors, {
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.register(jwt, {
|
||||
secret: process.env.JWT_SECRET!,
|
||||
});
|
||||
|
||||
// 路由
|
||||
app.register(authRoutes, { prefix: '/api/auth' });
|
||||
app.register(repoRoutes, { prefix: '/api/repos' });
|
||||
app.register(webhookRoutes, { prefix: '/api/webhooks' });
|
||||
|
||||
// 健康检查
|
||||
app.get('/health', async () => {
|
||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
### **后端 - GitHub Service (`backend/src/services/github.service.ts`)**
|
||||
|
||||
import { Octokit } from '@octokit/rest';
|
||||
import { Redis } from '@upstash/redis';
|
||||
|
||||
const redis = new Redis({
|
||||
url: process.env.UPSTASH_REDIS_URL!,
|
||||
token: process.env.UPSTASH_REDIS_TOKEN!,
|
||||
});
|
||||
|
||||
export class GitHubService {
|
||||
private octokit: Octokit;
|
||||
|
||||
constructor(accessToken: string) {
|
||||
this.octokit = new Octokit({ auth: accessToken });
|
||||
}
|
||||
|
||||
async getRepositories() {
|
||||
const cacheKey = 'repos:list';
|
||||
|
||||
// 尝试从缓存读取
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// 从 GitHub API 获取
|
||||
const { data } = await this.octokit.repos.listForAuthenticatedUser({
|
||||
sort: 'updated',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
// 缓存5分钟
|
||||
await redis.set(cacheKey, data, { ex: 300 });
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getRepositoryStats(owner: string, repo: string) {
|
||||
const cacheKey = `repo:${owner}/${repo}:stats`;
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const [repoData, issues, pulls, commits] = await Promise.all([
|
||||
this.octokit.repos.get({ owner, repo }),
|
||||
this.octokit.issues.listForRepo({ owner, repo, state: 'open' }),
|
||||
this.octokit.pulls.list({ owner, repo, state: 'open' }),
|
||||
this.octokit.repos.listCommits({
|
||||
owner,
|
||||
repo,
|
||||
per_page: 100,
|
||||
since: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
}),
|
||||
]);
|
||||
|
||||
const stats = {
|
||||
name: repoData.data.name,
|
||||
stars: repoData.data.stargazers_count,
|
||||
forks: repoData.data.forks_count,
|
||||
openIssues: issues.data.length,
|
||||
openPRs: pulls.data.length,
|
||||
commitsLastMonth: commits.data.length,
|
||||
language: repoData.data.language,
|
||||
updatedAt: repoData.data.updated_at,
|
||||
};
|
||||
|
||||
await redis.set(cacheKey, stats, { ex: 300 });
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
### **前端 - API 客户端 (`frontend/src/lib/api.ts`)**
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// 请求拦截器:添加 token
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器:处理错误
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
### **前端 - 数据获取 Hook (`frontend/src/hooks/useRepos.ts`)**
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface Repository {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
description: string;
|
||||
stargazers_count: number;
|
||||
open_issues_count: number;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export function useRepositories() {
|
||||
return useQuery({
|
||||
queryKey: ['repositories'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<Repository[]>('/repos');
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5分钟
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRepositoryStats(owner: string, repo: string) {
|
||||
return useQuery({
|
||||
queryKey: ['repo-stats', owner, repo],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/repos/${owner}/${repo}/stats`);
|
||||
return data;
|
||||
},
|
||||
enabled: !!owner && !!repo,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
* * *
|
||||
|
||||
🔐 **认证流程**
|
||||
|
||||
┌────────┐ ┌──────────┐ ┌─────────┐
|
||||
│ 前端 │ │ 后端 │ │ GitHub │
|
||||
└───┬────┘ └────┬─────┘ └────┬────┘
|
||||
│ │ │
|
||||
│ 1. 点击 "Login with GitHub" │ │
|
||||
├──────────────────────────>│ │
|
||||
│ │ │
|
||||
│ 2. 重定向到 GitHub OAuth │ │
|
||||
├───────────────────────────┼─────────────────────────>│
|
||||
│ │ │
|
||||
│ 3. 用户授权 │ │
|
||||
│ │<──────────────────────────┤
|
||||
│ │ │
|
||||
│ 4. GitHub 回调 + code │ │
|
||||
│<───────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 5. 发送 code 到后端 │ │
|
||||
├──────────────────────────>│ │
|
||||
│ │ │
|
||||
│ │ 6. 用 code 换 access_token│
|
||||
│ ├─────────────────────────>│
|
||||
│ │ │
|
||||
│ │<──────────────────────────┤
|
||||
│ │ │
|
||||
│ 7. 返回 JWT token │ │
|
||||
│<───────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 8. 保存 token 到 localStorage │
|
||||
│ │ │
|
||||
|
||||
* * *
|
||||
|
||||
📊 **数据流**
|
||||
|
||||
前端 Dashboard 页面加载:
|
||||
|
||||
1. 检查 localStorage 是否有 token
|
||||
├─ 无 → 跳转登录页
|
||||
└─ 有 → 继续
|
||||
|
||||
2. 发起并行请求(TanStack Query):
|
||||
├─ GET /api/repos (获取仓库列表)
|
||||
├─ GET /api/stats/overview (总体统计)
|
||||
└─ GET /api/stats/activity (活动数据)
|
||||
|
||||
3. 后端处理:
|
||||
├─ 验证 JWT token
|
||||
├─ 检查 Redis 缓存
|
||||
│ ├─ 命中 → 直接返回
|
||||
│ └─ 未命中 → 调用 GitHub API + 写入缓存
|
||||
└─ 返回数据
|
||||
|
||||
4. 前端渲染:
|
||||
├─ TanStack Query 自动管理加载/错误状态
|
||||
├─ 渲染图表(Recharts)
|
||||
└─ 显示数据卡片
|
||||
|
||||
* * *
|
||||
|
||||
🎯 **开发步骤**
|
||||
|
||||
### **1. 初始化项目**
|
||||
|
||||
# 创建项目目录
|
||||
mkdir github-dashboard
|
||||
cd github-dashboard
|
||||
|
||||
# 初始化前端
|
||||
npm create vite@latest frontend -- --template react-ts
|
||||
cd frontend
|
||||
npm install
|
||||
npm install @tanstack/react-query axios zustand
|
||||
npm install -D tailwindcss postcss autoprefixer
|
||||
npx tailwindcss init -p
|
||||
|
||||
# 初始化后端
|
||||
cd ..
|
||||
mkdir backend && cd backend
|
||||
npm init -y
|
||||
npm install fastify @fastify/cors @fastify/jwt
|
||||
npm install @octokit/rest @upstash/redis @prisma/client
|
||||
npm install -D typescript @types/node tsx prisma
|
||||
npx prisma init
|
||||
|
||||
### **2. 配置 Railway 部署**
|
||||
|
||||
cd backend
|
||||
|
||||
# 创建 railway.json
|
||||
cat > railway.json << EOF
|
||||
{
|
||||
"build": {
|
||||
"builder": "NIXPACKS"
|
||||
},
|
||||
"deploy": {
|
||||
"startCommand": "npm run start"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# 部署
|
||||
railway login
|
||||
railway init
|
||||
railway up
|
||||
|
||||
### **3. 配置 Cloudflare Pages**
|
||||
|
||||
cd frontend
|
||||
|
||||
# 构建测试
|
||||
npm run build
|
||||
|
||||
# 在 Cloudflare Pages Dashboard:
|
||||
# 1. 连接 GitHub 仓库
|
||||
# 2. 设置构建命令: npm run build
|
||||
# 3. 设置输出目录: dist
|
||||
# 4. 添加环境变量: VITE_API_URL=你的Railway后端URL
|
||||
|
||||
* * *
|
||||
|
||||
🎉 **总结**
|
||||
|
||||
这个架构的优势:
|
||||
|
||||
✅ **前后端分离** - 各自独立部署和扩展✅ **轻量高效** - Fastify 性能优秀,Vite 开发体验好✅ **部署简单** - Railway + Cloudflare Pages 都是一键部署✅ **成本低廉** - 免费额度足够个人/小团队使用✅ **可扩展性** - 需要时可轻松升级到付费版✅ **现代化** - TypeScript 全栈,类型安全
|
||||
|
||||
需要我提供:
|
||||
|
||||
1. ✅ 完整的项目初始化脚本?
|
||||
2. ✅ Prisma Schema 数据库设计?
|
||||
3. ✅ 前端 Dashboard 组件代码?
|
||||
4. ✅ 详细的部署文档?
|
||||
|
||||
选择任意方向深入!🚀
|
||||
715
package-lock.json
generated
715
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -6,7 +6,7 @@
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "ts-node-dev --respawn --transpile-only --exit-child src/server.ts",
|
||||
"build": "tsc",
|
||||
"build": "prisma generate && tsc",
|
||||
"start": "node dist/server.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev"
|
||||
@ -14,19 +14,24 @@
|
||||
"author": "Google LLC",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"dotenv": "^17.2.3",
|
||||
"@fastify/cors": "^11.1.0",
|
||||
"@fastify/jwt": "^10.0.0",
|
||||
"@octokit/rest": "^22.0.0",
|
||||
"@fastify/swagger": "^9.5.2",
|
||||
"@octokit/rest": "^19.0.13",
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@upstash/redis": "^1.35.6",
|
||||
"fastify": "^5.6.1"
|
||||
"axios": "^1.12.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-type-provider-zod": "^6.0.0",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.12",
|
||||
"nodemon": "^3.1.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"prisma": "^6.18.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,43 +1,67 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { GitHubService } from '../services/github.service';
|
||||
import { authMiddleware } from '../middlewares/auth.middleware';
|
||||
|
||||
// Schema for the URL parameters
|
||||
const repoParamsSchema = {
|
||||
type: 'object',
|
||||
required: ['owner', 'repo'],
|
||||
properties: {
|
||||
owner: { type: 'string' },
|
||||
repo: { type: 'string' },
|
||||
},
|
||||
} as const; // Using 'as const' for stronger type inference
|
||||
// Schema for request parameters
|
||||
const repoParamsSchema = z.object({
|
||||
owner: z.string(),
|
||||
repo: z.string(),
|
||||
});
|
||||
|
||||
// Schema for the response of the repository stats endpoint
|
||||
const repoStatsSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
stars: { type: 'number' },
|
||||
forks: { type: 'number' },
|
||||
openIssues: { type: 'number' },
|
||||
commits: { type: 'number' },
|
||||
releases: { type: 'number' },
|
||||
contributors: { type: 'number' },
|
||||
},
|
||||
} as const;
|
||||
// Schema for repository statistics response
|
||||
const repoStatsSchema = z.object({
|
||||
stars: z.number(),
|
||||
forks: z.number(),
|
||||
openIssues: z.number(),
|
||||
commits: z.number(),
|
||||
releases: z.number(),
|
||||
contributors: z.number(),
|
||||
});
|
||||
|
||||
// Schema for repository list response
|
||||
const repoListItemSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
full_name: z.string(),
|
||||
private: z.boolean(),
|
||||
html_url: z.string().url(),
|
||||
description: z.string().nullable(),
|
||||
stargazers_count: z.number(),
|
||||
watchers_count: z.number(),
|
||||
forks_count: z.number(),
|
||||
language: z.string().nullable(),
|
||||
owner: z.object({
|
||||
login: z.string(),
|
||||
avatar_url: z.string().url(),
|
||||
}),
|
||||
});
|
||||
|
||||
const reposListResponseSchema = z.array(repoListItemSchema);
|
||||
|
||||
export async function repoRoutes(app: FastifyInstance) {
|
||||
// All routes in this plugin require authentication
|
||||
app.addHook('preHandler', authMiddleware);
|
||||
|
||||
// This route retrieves all repositories for the authenticated user.
|
||||
// The response type is inferred from Octokit, so a detailed schema is omitted for brevity,
|
||||
// but in a real-world scenario, you might want to define the properties you actually use.
|
||||
app.get('/', async (request, reply) => {
|
||||
const githubService = new GitHubService(request.user.accessToken);
|
||||
const repos = await githubService.getRepositories();
|
||||
reply.send(repos);
|
||||
});
|
||||
// Route to list repositories for the authenticated user
|
||||
app.get(
|
||||
'/',
|
||||
{
|
||||
schema: {
|
||||
response: {
|
||||
200: reposListResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { accessToken, username } = request.user!;
|
||||
const githubService = new GitHubService(accessToken, username);
|
||||
const repos = await githubService.getRepositories();
|
||||
return repos;
|
||||
},
|
||||
);
|
||||
|
||||
// This route gets specific stats for a single repository.
|
||||
// Route to get statistics for a specific repository
|
||||
app.get(
|
||||
'/:owner/:repo/stats',
|
||||
{
|
||||
@ -49,13 +73,10 @@ export async function repoRoutes(app: FastifyInstance) {
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
// request.params is now strongly typed based on repoParamsSchema!
|
||||
const { owner, repo } = request.params;
|
||||
const githubService = new GitHubService(request.user.accessToken);
|
||||
|
||||
const { owner, repo } = request.params as z.infer<typeof repoParamsSchema>;
|
||||
const { accessToken, username } = request.user!;
|
||||
const githubService = new GitHubService(accessToken, username);
|
||||
const stats = await githubService.getRepositoryStats(owner, repo);
|
||||
|
||||
// The return type is validated against repoStatsSchema
|
||||
return stats;
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,5 +1,21 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { StatsService } from '../services/stats.service';
|
||||
import { authMiddleware } from '../middlewares/auth.middleware';
|
||||
|
||||
export async function statsRoutes(app: FastifyInstance) {
|
||||
// TODO: Implement stats routes
|
||||
app.addHook('preHandler', authMiddleware);
|
||||
|
||||
app.get('/overview', async (request, reply) => {
|
||||
const { accessToken, username } = request.user!;
|
||||
const statsService = new StatsService(accessToken, username);
|
||||
const overviewStats = await statsService.getOverviewStats();
|
||||
reply.send(overviewStats);
|
||||
});
|
||||
|
||||
app.get('/activity', async (request, reply) => {
|
||||
const { accessToken, username } = request.user!;
|
||||
const statsService = new StatsService(accessToken, username);
|
||||
const activityStats = await statsService.getActivityStats();
|
||||
reply.send(activityStats);
|
||||
});
|
||||
}
|
||||
|
||||
129
src/server.ts
129
src/server.ts
@ -1,40 +1,123 @@
|
||||
import fastify from 'fastify';
|
||||
import { authRoutes } from './routes/auth';
|
||||
import { webhookRoutes } from './routes/webhooks';
|
||||
import { repoRoutes } from './routes/repos';
|
||||
import {
|
||||
serializerCompiler,
|
||||
validatorCompiler,
|
||||
ZodTypeProvider
|
||||
} from 'fastify-type-provider-zod';
|
||||
import fastifyJwt from '@fastify/jwt';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import 'dotenv/config';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user?: {
|
||||
sub: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
accessToken: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
import { authRoutes } from './routes/auth';
|
||||
import { repoRoutes } from './routes/repos';
|
||||
import { statsRoutes } from './routes/stats';
|
||||
import { webhookRoutes } from './routes/webhooks';
|
||||
|
||||
import swagger from '@fastify/swagger';
|
||||
|
||||
// Main function to bootstrap the server
|
||||
async function bootstrap() {
|
||||
// Initialize Fastify with ZodTypeProvider
|
||||
const app = fastify({
|
||||
logger: true,
|
||||
});
|
||||
logger: {
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
translateTime: 'HH:MM:ss Z',
|
||||
ignore: 'pid,hostname',
|
||||
},
|
||||
},
|
||||
},
|
||||
}).withTypeProvider<ZodTypeProvider>();
|
||||
|
||||
app.register(fastifyJwt, {
|
||||
// Set the validator and serializer for Zod
|
||||
app.setValidatorCompiler(validatorCompiler);
|
||||
app.setSerializerCompiler(serializerCompiler);
|
||||
|
||||
// Register plugins
|
||||
await app.register(fastifyJwt, {
|
||||
secret: process.env.JWT_SECRET!,
|
||||
});
|
||||
|
||||
app.register(fastifyCors, {
|
||||
origin: process.env.FRONTEND_URL,
|
||||
await app.register(fastifyCors, {
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.register(authRoutes, { prefix: '/auth' });
|
||||
app.register(webhookRoutes, { prefix: '/webhooks' });
|
||||
app.register(repoRoutes, { prefix: '/repos' });
|
||||
// Register Swagger for OpenAPI spec generation
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: 'Project Dash API',
|
||||
description: 'Backend API documentation for Project Dash application.',
|
||||
version: '1.0.0',
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [
|
||||
{
|
||||
bearerAuth: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await app.listen({ port: 3333, host: '0.0.0.0' });
|
||||
// --- API Documentation with RapiDoc ---
|
||||
app.get('/docs', (req, reply) => {
|
||||
reply.type('text/html').send(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<rapi-doc
|
||||
spec-url="/docs/json"
|
||||
theme="dark"
|
||||
render-style="view"
|
||||
show-header="false"
|
||||
allow-server-selection="false"
|
||||
allow-authentication="true"
|
||||
> </rapi-doc>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
});
|
||||
|
||||
app.get('/docs/json', (req, reply) => {
|
||||
reply.send(app.swagger());
|
||||
});
|
||||
|
||||
// Register routes
|
||||
await app.register(authRoutes, { prefix: '/auth' });
|
||||
await app.register(repoRoutes, { prefix: '/repos' });
|
||||
await app.register(statsRoutes, { prefix: '/stats' });
|
||||
await app.register(webhookRoutes, { prefix: '/webhooks' });
|
||||
|
||||
// Add a generic error handler
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
app.log.error(error);
|
||||
reply.status(500).send({ error: 'Internal Server Error' });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
// Start the server
|
||||
(async () => {
|
||||
try {
|
||||
const app = await bootstrap();
|
||||
await app.listen({ port: 3333, host: '0.0.0.0' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
@ -7,7 +7,8 @@ import { GitHubUser } from '../types/github';
|
||||
const GITHUB_API_BASE_URL = 'https://api.github.com';
|
||||
|
||||
// Define a type for the repository list response data for clarity
|
||||
type ReposListForAuthenticatedUserResponse = Endpoints['GET /user/repos']['response']['data'];
|
||||
type ReposListForAuthenticatedUserResponse =
|
||||
Endpoints['GET /user/repos']['response']['data'];
|
||||
|
||||
export async function exchangeCodeForToken(code: string): Promise<string> {
|
||||
const response = await axios.post(
|
||||
@ -35,27 +36,31 @@ export async function getGithubUser(accessToken: string): Promise<GitHubUser> {
|
||||
|
||||
export class GitHubService {
|
||||
private octokit: Octokit;
|
||||
private username: string;
|
||||
|
||||
constructor(accessToken: string) {
|
||||
constructor(accessToken: string, username: string) {
|
||||
this.octokit = new Octokit({ auth: accessToken });
|
||||
this.username = username;
|
||||
}
|
||||
|
||||
async getRepositories(): Promise<ReposListForAuthenticatedUserResponse> {
|
||||
const cacheKey = `repos:${this.username}`;
|
||||
const cachedRepos = await redis.get(cacheKey);
|
||||
|
||||
if (cachedRepos) {
|
||||
return JSON.parse(cachedRepos as string);
|
||||
}
|
||||
|
||||
const { data } = await this.octokit.repos.listForAuthenticatedUser();
|
||||
await redis.set(cacheKey, JSON.stringify(data), { ex: 3600 }); // Cache for 1 hour
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async getRepositoryStats(owner: string, repo: string) {
|
||||
const cacheKey = `repo-stats:${owner}:${repo}`;
|
||||
const cachedStats = await redis.get(cacheKey);
|
||||
|
||||
if (cachedStats) {
|
||||
return JSON.parse(cachedStats as string);
|
||||
}
|
||||
|
||||
async getRepositoryStats(owner: string, repo: string, since?: string) {
|
||||
const [repoData, commits, releases, contributors] = await Promise.all([
|
||||
this.octokit.repos.get({ owner, repo }),
|
||||
this.octokit.repos.listCommits({ owner, repo }),
|
||||
this.octokit.repos.listCommits({ owner, repo, since }),
|
||||
this.octokit.repos.listReleases({ owner, repo }),
|
||||
this.octokit.repos.listContributors({ owner, repo }),
|
||||
]);
|
||||
@ -69,8 +74,6 @@ export class GitHubService {
|
||||
contributors: contributors.data.length,
|
||||
};
|
||||
|
||||
await redis.set(cacheKey, JSON.stringify(stats), { ex: 3600 }); // Cache for 1 hour
|
||||
|
||||
return stats;
|
||||
}
|
||||
}
|
||||
|
||||
42
src/services/stats.service.ts
Normal file
42
src/services/stats.service.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { GitHubService } from './github.service';
|
||||
|
||||
export class StatsService {
|
||||
private githubService: GitHubService;
|
||||
|
||||
constructor(accessToken: string, username: string) {
|
||||
this.githubService = new GitHubService(accessToken, username);
|
||||
}
|
||||
|
||||
async getOverviewStats() {
|
||||
const repos = await this.githubService.getRepositories();
|
||||
const totalStars = repos.reduce((acc, repo) => acc + (repo.stargazers_count || 0), 0);
|
||||
const totalForks = repos.reduce((acc, repo) => acc + (repo.forks_count || 0), 0);
|
||||
|
||||
return {
|
||||
totalRepos: repos.length,
|
||||
totalStars,
|
||||
totalForks,
|
||||
};
|
||||
}
|
||||
|
||||
async getActivityStats() {
|
||||
const repos = await this.githubService.getRepositories();
|
||||
const thirtyDaysAgo = new Date(
|
||||
Date.now() - 30 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
|
||||
const commitPromises = repos.map((repo) => {
|
||||
const [owner, repoName] = repo.full_name.split('/');
|
||||
return this.githubService.getRepositoryStats(owner, repoName, thirtyDaysAgo);
|
||||
});
|
||||
|
||||
const allStats = await Promise.all(commitPromises);
|
||||
|
||||
const totalCommits = allStats.reduce((acc, stats) => acc + stats.commits, 0);
|
||||
|
||||
return {
|
||||
totalCommitsLast30Days: totalCommits,
|
||||
};
|
||||
}
|
||||
}
|
||||
14
src/types/fastify.d.ts
vendored
Normal file
14
src/types/fastify.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
import '@fastify/jwt';
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
// This is the type for the payload that is signed
|
||||
// and the type of the `request.user` object.
|
||||
user: {
|
||||
sub: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
accessToken: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"target": "ES2020", // More modern target
|
||||
"module": "commonjs",
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
"strict": true, // Enable all strict type-checking options
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true, // Skip type checking of declaration files
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts", // Include all .ts files in the src directory
|
||||
"src/types/**/*.d.ts" // Include our custom type definitions
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user