"""开源项目路由""" 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)