411 lines
13 KiB
Python
411 lines
13 KiB
Python
"""开源项目路由"""
|
|
import httpx
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import func as sa_func, or_
|
|
from pydantic import BaseModel
|
|
from typing import Optional, List
|
|
|
|
from database import get_db
|
|
from models.user import User
|
|
from models.project import Project
|
|
from models.like import ProjectCollect
|
|
from routers.auth import get_current_user, get_admin_user
|
|
|
|
router = APIRouter()
|
|
|
|
GITHUB_API = "https://api.github.com"
|
|
|
|
|
|
# ========== Schemas ==========
|
|
|
|
class ProjectCreate(BaseModel):
|
|
name: str
|
|
description: str = ""
|
|
url: str
|
|
homepage: str = ""
|
|
icon: str = ""
|
|
language: str = ""
|
|
category: str = ""
|
|
stars: int = 0
|
|
forks: int = 0
|
|
|
|
|
|
class ProjectUpdate(BaseModel):
|
|
name: Optional[str] = None
|
|
description: Optional[str] = None
|
|
url: Optional[str] = None
|
|
homepage: Optional[str] = None
|
|
icon: Optional[str] = None
|
|
language: Optional[str] = None
|
|
category: Optional[str] = None
|
|
stars: Optional[int] = None
|
|
forks: Optional[int] = None
|
|
sort_order: Optional[int] = None
|
|
is_active: Optional[bool] = None
|
|
|
|
|
|
def _project_to_dict(p: Project, is_collected: bool = False) -> dict:
|
|
return {
|
|
"id": p.id,
|
|
"name": p.name,
|
|
"description": p.description or "",
|
|
"url": p.url,
|
|
"homepage": p.homepage or "",
|
|
"icon": p.icon or "",
|
|
"language": p.language or "",
|
|
"category": p.category or "",
|
|
"stars": p.stars or 0,
|
|
"forks": p.forks or 0,
|
|
"collect_count": getattr(p, 'collect_count', 0) or 0,
|
|
"is_collected": is_collected,
|
|
"sort_order": p.sort_order,
|
|
"is_active": p.is_active,
|
|
"created_at": p.created_at.isoformat() if p.created_at else None,
|
|
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
|
|
}
|
|
|
|
|
|
def _with_collect_status(items: list, user_id: int, db: Session) -> list:
|
|
"""批量查询用户是否已收藏"""
|
|
if not items:
|
|
return []
|
|
project_ids = [p.id for p in items]
|
|
collected_ids = set(
|
|
r[0] for r in db.query(ProjectCollect.project_id)
|
|
.filter(ProjectCollect.project_id.in_(project_ids), ProjectCollect.user_id == user_id)
|
|
.all()
|
|
)
|
|
return [_project_to_dict(p, p.id in collected_ids) for p in items]
|
|
|
|
|
|
# ========== 管理员接口 ==========
|
|
|
|
@router.get("/admin/list")
|
|
def admin_list_projects(
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(20, ge=1, le=100),
|
|
category: Optional[str] = None,
|
|
keyword: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
admin: User = Depends(get_admin_user),
|
|
):
|
|
"""获取所有项目(含禁用),支持分页"""
|
|
query = db.query(Project)
|
|
if category:
|
|
query = query.filter(Project.category == category)
|
|
if keyword:
|
|
kw = f"%{keyword}%"
|
|
query = query.filter(or_(Project.name.like(kw), Project.description.like(kw)))
|
|
total = query.count()
|
|
items = query.order_by(Project.sort_order, Project.id.desc()).offset((page - 1) * size).limit(size).all()
|
|
return {
|
|
"total": total,
|
|
"items": [_project_to_dict(p) for p in items],
|
|
}
|
|
|
|
|
|
@router.post("/admin")
|
|
def admin_create_project(
|
|
data: ProjectCreate,
|
|
db: Session = Depends(get_db),
|
|
admin: User = Depends(get_admin_user),
|
|
):
|
|
"""新增项目"""
|
|
max_order = db.query(sa_func.max(Project.sort_order)).scalar() or 0
|
|
proj = Project(
|
|
name=data.name,
|
|
description=data.description,
|
|
url=data.url,
|
|
homepage=data.homepage,
|
|
icon=data.icon,
|
|
language=data.language,
|
|
category=data.category,
|
|
stars=data.stars,
|
|
forks=data.forks,
|
|
sort_order=max_order + 1,
|
|
)
|
|
db.add(proj)
|
|
db.commit()
|
|
db.refresh(proj)
|
|
return _project_to_dict(proj)
|
|
|
|
|
|
@router.put("/admin/{project_id}")
|
|
def admin_update_project(
|
|
project_id: int,
|
|
data: ProjectUpdate,
|
|
db: Session = Depends(get_db),
|
|
admin: User = Depends(get_admin_user),
|
|
):
|
|
"""编辑项目"""
|
|
proj = db.query(Project).filter(Project.id == project_id).first()
|
|
if not proj:
|
|
raise HTTPException(status_code=404, detail="项目不存在")
|
|
for field in ["name", "description", "url", "homepage", "icon", "language", "category", "stars", "forks", "sort_order", "is_active"]:
|
|
val = getattr(data, field)
|
|
if val is not None:
|
|
setattr(proj, field, val)
|
|
db.commit()
|
|
db.refresh(proj)
|
|
return _project_to_dict(proj)
|
|
|
|
|
|
@router.delete("/admin/{project_id}")
|
|
def admin_delete_project(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
admin: User = Depends(get_admin_user),
|
|
):
|
|
"""删除项目"""
|
|
proj = db.query(Project).filter(Project.id == project_id).first()
|
|
if not proj:
|
|
raise HTTPException(status_code=404, detail="项目不存在")
|
|
db.delete(proj)
|
|
db.commit()
|
|
return {"message": "删除成功"}
|
|
|
|
|
|
# ========== 公开接口 ==========
|
|
|
|
@router.get("/hot")
|
|
def get_hot_projects(
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(12, ge=1, le=50),
|
|
category: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""热门项目(按 stars 降序)"""
|
|
query = db.query(Project).filter(Project.is_active == True)
|
|
if category:
|
|
query = query.filter(Project.category == category)
|
|
total = query.count()
|
|
items = query.order_by(Project.stars.desc(), Project.sort_order, Project.id.desc()).offset((page - 1) * size).limit(size).all()
|
|
return {"total": total, "items": _with_collect_status(items, current_user.id, db)}
|
|
|
|
|
|
@router.get("/latest")
|
|
def get_latest_projects(
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(12, ge=1, le=50),
|
|
category: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""最新项目(按创建时间降序)"""
|
|
query = db.query(Project).filter(Project.is_active == True)
|
|
if category:
|
|
query = query.filter(Project.category == category)
|
|
total = query.count()
|
|
items = query.order_by(Project.created_at.desc(), Project.id.desc()).offset((page - 1) * size).limit(size).all()
|
|
return {"total": total, "items": _with_collect_status(items, current_user.id, db)}
|
|
|
|
|
|
@router.get("/search")
|
|
def search_projects(
|
|
q: str = Query("", min_length=0),
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(12, ge=1, le=50),
|
|
category: Optional[str] = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""搜索项目"""
|
|
query = db.query(Project).filter(Project.is_active == True)
|
|
if q.strip():
|
|
kw = f"%{q.strip()}%"
|
|
query = query.filter(or_(Project.name.like(kw), Project.description.like(kw)))
|
|
if category:
|
|
query = query.filter(Project.category == category)
|
|
total = query.count()
|
|
items = query.order_by(Project.stars.desc(), Project.id.desc()).offset((page - 1) * size).limit(size).all()
|
|
return {"total": total, "items": _with_collect_status(items, current_user.id, db)}
|
|
|
|
|
|
@router.get("/categories")
|
|
def get_project_categories(
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""获取所有有项目的分类"""
|
|
rows = (
|
|
db.query(Project.category, sa_func.count(Project.id))
|
|
.filter(Project.is_active == True, Project.category != "")
|
|
.group_by(Project.category)
|
|
.order_by(sa_func.count(Project.id).desc())
|
|
.all()
|
|
)
|
|
return [{"name": r[0], "count": r[1]} for r in rows]
|
|
|
|
|
|
# ========== 收藏接口 ==========
|
|
|
|
@router.get("/my-collects")
|
|
def get_my_collects(
|
|
page: int = Query(1, ge=1),
|
|
size: int = Query(12, ge=1, le=50),
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""获取当前用户收藏的项目"""
|
|
subq = db.query(ProjectCollect.project_id).filter(ProjectCollect.user_id == current_user.id).subquery()
|
|
query = db.query(Project).filter(Project.id.in_(subq), Project.is_active == True)
|
|
total = query.count()
|
|
items = query.order_by(Project.id.desc()).offset((page - 1) * size).limit(size).all()
|
|
return {"total": total, "items": [_project_to_dict(p, True) for p in items]}
|
|
|
|
|
|
@router.post("/{project_id}/collect")
|
|
def toggle_project_collect(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""收藏/取消收藏项目"""
|
|
proj = db.query(Project).filter(Project.id == project_id).first()
|
|
if not proj:
|
|
raise HTTPException(status_code=404, detail="项目不存在")
|
|
existing = db.query(ProjectCollect).filter(
|
|
ProjectCollect.project_id == project_id, ProjectCollect.user_id == current_user.id
|
|
).first()
|
|
if existing:
|
|
db.delete(existing)
|
|
proj.collect_count = max(0, (proj.collect_count or 0) - 1)
|
|
db.commit()
|
|
return {"collected": False, "collect_count": proj.collect_count}
|
|
else:
|
|
db.add(ProjectCollect(project_id=project_id, user_id=current_user.id))
|
|
proj.collect_count = (proj.collect_count or 0) + 1
|
|
db.commit()
|
|
return {"collected": True, "collect_count": proj.collect_count}
|
|
|
|
|
|
# ========== GitHub 搜索(公共 + 管理员通用) ==========
|
|
|
|
async def _github_search_impl(q: str, sort: str, page: int, per_page: int):
|
|
"""GitHub 搜索核心实现"""
|
|
url = f"{GITHUB_API}/search/repositories"
|
|
params = {"q": q, "sort": sort, "order": "desc", "page": page, "per_page": per_page}
|
|
headers = {"Accept": "application/vnd.github+json"}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
resp = await client.get(url, params=params, headers=headers)
|
|
if resp.status_code != 200:
|
|
raise HTTPException(status_code=502, detail=f"GitHub API 返回 {resp.status_code}")
|
|
data = resp.json()
|
|
except httpx.TimeoutException:
|
|
raise HTTPException(status_code=504, detail="GitHub API 请求超时")
|
|
except httpx.RequestError as e:
|
|
raise HTTPException(status_code=502, detail=f"网络请求失败: {str(e)}")
|
|
|
|
items = []
|
|
for repo in data.get("items", []):
|
|
items.append({
|
|
"github_id": repo["id"],
|
|
"name": repo.get("name", ""),
|
|
"full_name": repo.get("full_name", ""),
|
|
"description": repo.get("description") or "",
|
|
"url": repo.get("html_url", ""),
|
|
"homepage": repo.get("homepage") or "",
|
|
"icon": repo.get("owner", {}).get("avatar_url", ""),
|
|
"language": repo.get("language") or "",
|
|
"stars": repo.get("stargazers_count", 0),
|
|
"forks": repo.get("forks_count", 0),
|
|
"topics": repo.get("topics", []),
|
|
"created_at": repo.get("created_at", ""),
|
|
"updated_at": repo.get("updated_at", ""),
|
|
})
|
|
return {"total": data.get("total_count", 0), "items": items}
|
|
|
|
|
|
@router.get("/github-search")
|
|
async def public_github_search(
|
|
q: str = Query(..., min_length=1),
|
|
sort: str = Query("stars"),
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(12, ge=1, le=30),
|
|
user: User = Depends(get_current_user),
|
|
):
|
|
"""公开 GitHub 搜索(登录用户可用)"""
|
|
return await _github_search_impl(q, sort, page, per_page)
|
|
|
|
|
|
# ========== GitHub 导入接口(管理员) ==========
|
|
|
|
@router.get("/admin/github-search")
|
|
async def github_search(
|
|
q: str = Query(..., min_length=1),
|
|
sort: str = Query("stars"),
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(12, ge=1, le=30),
|
|
admin: User = Depends(get_admin_user),
|
|
):
|
|
"""管理员 GitHub 搜索"""
|
|
return await _github_search_impl(q, sort, page, per_page)
|
|
|
|
|
|
class GitHubImportItem(BaseModel):
|
|
name: str
|
|
description: str = ""
|
|
url: str
|
|
homepage: str = ""
|
|
icon: str = ""
|
|
language: str = ""
|
|
category: str = ""
|
|
stars: int = 0
|
|
forks: int = 0
|
|
|
|
|
|
class GitHubImportRequest(BaseModel):
|
|
items: List[GitHubImportItem]
|
|
|
|
|
|
@router.post("/admin/github-import")
|
|
def github_import(
|
|
data: GitHubImportRequest,
|
|
db: Session = Depends(get_db),
|
|
admin: User = Depends(get_admin_user),
|
|
):
|
|
"""批量导入 GitHub 项目"""
|
|
imported = 0
|
|
skipped = 0
|
|
max_order = db.query(sa_func.max(Project.sort_order)).scalar() or 0
|
|
for item in data.items:
|
|
existing = db.query(Project).filter(Project.url == item.url).first()
|
|
if existing:
|
|
skipped += 1
|
|
continue
|
|
max_order += 1
|
|
proj = Project(
|
|
name=item.name,
|
|
description=item.description,
|
|
url=item.url,
|
|
homepage=item.homepage,
|
|
icon=item.icon,
|
|
language=item.language,
|
|
category=item.category,
|
|
stars=item.stars,
|
|
forks=item.forks,
|
|
sort_order=max_order,
|
|
)
|
|
db.add(proj)
|
|
imported += 1
|
|
db.commit()
|
|
return {"imported": imported, "skipped": skipped}
|
|
|
|
|
|
# ========== 项目详情(通配路由放最后) ==========
|
|
|
|
@router.get("/{project_id}")
|
|
def get_project_detail(
|
|
project_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""项目详情"""
|
|
proj = db.query(Project).filter(Project.id == project_id, Project.is_active == True).first()
|
|
if not proj:
|
|
raise HTTPException(status_code=404, detail="项目不存在")
|
|
return _project_to_dict(proj)
|