Files
bianchengshequ/backend/routers/projects.py

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)