apps.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. from typing import Literal, Optional
  2. from fastapi import APIRouter, Depends, HTTPException, Query
  3. from sqlalchemy import func, select
  4. from sqlalchemy.ext.asyncio import AsyncSession
  5. from sqlalchemy.orm import joinedload
  6. from ..config import get_settings
  7. from ..database import get_db
  8. from ..models import App, AppCategoryMap, AppVersion, Category
  9. from ..schemas import (
  10. AppDetail,
  11. AppListResponse,
  12. AppSummary,
  13. DownloadInfo,
  14. ErrorResponse,
  15. ManifestOut,
  16. VersionInfo,
  17. )
  18. router = APIRouter(prefix="/api/v1", tags=["Apps"])
  19. AppType = Literal["system", "community", "external"]
  20. SortType = Literal["newest", "popular"]
  21. def _build_base_query():
  22. return (
  23. select(App)
  24. .options(
  25. joinedload(App.developer),
  26. joinedload(App.categories),
  27. joinedload(App.current_version),
  28. )
  29. .where(App.status == "published")
  30. )
  31. def _apply_filters(
  32. stmt,
  33. category: Optional[str],
  34. app_type: Optional[str],
  35. search: Optional[str],
  36. featured: Optional[bool],
  37. ):
  38. if category:
  39. stmt = (
  40. stmt.join(AppCategoryMap, App.id == AppCategoryMap.app_id)
  41. .join(Category, Category.id == AppCategoryMap.category_id)
  42. .where(Category.slug == category)
  43. )
  44. if app_type:
  45. stmt = stmt.where(App.type == app_type)
  46. if search:
  47. pattern = f"%{search}%"
  48. stmt = stmt.where(
  49. App.name.ilike(pattern) | App.short_description.ilike(pattern)
  50. )
  51. if featured is not None:
  52. stmt = stmt.where(App.is_featured == featured)
  53. return stmt
  54. def _apply_sort(stmt, sort: str):
  55. if sort == "popular":
  56. return stmt.order_by(App.download_count.desc(), App.publish_time.desc())
  57. return stmt.order_by(App.publish_time.desc(), App.id.desc())
  58. def _get_published_version(app: App) -> Optional[AppVersion]:
  59. """Get published version, falling back to latest published if current is not published."""
  60. if app.current_version and app.current_version.status == "published":
  61. return app.current_version
  62. return None
  63. def _to_version_info(version: Optional[AppVersion]) -> Optional[VersionInfo]:
  64. if not version or version.status != "published":
  65. return None
  66. return VersionInfo(
  67. version=version.version,
  68. release_notes=version.release_notes,
  69. min_client_version=version.min_client_version,
  70. published_at=version.published_at,
  71. size_bytes=version.size_bytes,
  72. )
  73. def _to_app_summary(app: App) -> AppSummary:
  74. return AppSummary(
  75. app_uid=app.app_uid,
  76. name=app.name,
  77. type=app.type,
  78. icon_url=app.icon_url,
  79. short_description=app.short_description,
  80. categories=app.categories,
  81. developer=app.developer,
  82. current_version=_to_version_info(app.current_version),
  83. download_count=app.download_count,
  84. is_featured=app.is_featured,
  85. publish_time=app.publish_time,
  86. )
  87. @router.get("/apps", response_model=AppListResponse)
  88. async def list_apps(
  89. category: Optional[str] = Query(None, description="Filter by category slug"),
  90. type: Optional[AppType] = Query(None, description="Filter by app type"),
  91. search: Optional[str] = Query(None, min_length=1, description="Search keyword"),
  92. featured: Optional[bool] = Query(None, description="Filter featured apps only"),
  93. sort: SortType = Query("newest", description="Sort order"),
  94. page: int = Query(1, ge=1, description="Page number"),
  95. page_size: Optional[int] = Query(None, ge=1, le=100, description="Items per page"),
  96. db: AsyncSession = Depends(get_db),
  97. ) -> AppListResponse:
  98. settings = get_settings()
  99. limit = min(page_size or settings.default_page_size, settings.max_page_size)
  100. offset = (page - 1) * limit
  101. base_stmt = _build_base_query().distinct()
  102. filtered_stmt = _apply_filters(base_stmt, category, type, search, featured)
  103. count_base = select(func.count(func.distinct(App.id))).where(App.status == "published")
  104. count_stmt = _apply_filters(count_base, category, type, search, featured)
  105. total_result = await db.execute(count_stmt)
  106. total = total_result.scalar_one() or 0
  107. query_stmt = _apply_sort(filtered_stmt, sort).offset(offset).limit(limit)
  108. result = await db.execute(query_stmt)
  109. apps = result.scalars().unique().all()
  110. return AppListResponse(
  111. items=[_to_app_summary(app) for app in apps],
  112. total=total,
  113. page=page,
  114. page_size=limit,
  115. )
  116. @router.get(
  117. "/apps/{app_uid}",
  118. response_model=AppDetail,
  119. responses={404: {"model": ErrorResponse}},
  120. )
  121. async def get_app_detail(app_uid: str, db: AsyncSession = Depends(get_db)) -> AppDetail:
  122. stmt = _build_base_query().where(App.app_uid == app_uid)
  123. result = await db.execute(stmt)
  124. app = result.scalars().unique().one_or_none()
  125. if not app:
  126. raise HTTPException(status_code=404, detail="App not found")
  127. manifest = None
  128. if app.current_version and app.current_version.manifest:
  129. m = app.current_version.manifest
  130. manifest = ManifestOut(
  131. pages=m.get("pages"),
  132. commands=m.get("commands"),
  133. permissions=m.get("permissions"),
  134. )
  135. return AppDetail(
  136. app_uid=app.app_uid,
  137. name=app.name,
  138. type=app.type,
  139. icon_url=app.icon_url,
  140. short_description=app.short_description,
  141. description=app.description,
  142. homepage_url=app.homepage_url,
  143. categories=app.categories,
  144. developer=app.developer,
  145. current_version=_to_version_info(app.current_version),
  146. download_count=app.download_count,
  147. is_featured=app.is_featured,
  148. publish_time=app.publish_time,
  149. manifest=manifest,
  150. )
  151. @router.get(
  152. "/apps/{app_uid}/download",
  153. response_model=DownloadInfo,
  154. responses={404: {"model": ErrorResponse}},
  155. )
  156. async def get_download_info(app_uid: str, db: AsyncSession = Depends(get_db)) -> DownloadInfo:
  157. stmt = _build_base_query().where(App.app_uid == app_uid)
  158. result = await db.execute(stmt)
  159. app = result.scalars().unique().one_or_none()
  160. if not app:
  161. raise HTTPException(status_code=404, detail="App not found")
  162. version = app.current_version
  163. if not version or version.status != "published":
  164. version_stmt = (
  165. select(AppVersion)
  166. .where(AppVersion.app_id == app.id, AppVersion.status == "published")
  167. .order_by(AppVersion.published_at.desc())
  168. .limit(1)
  169. )
  170. version_result = await db.execute(version_stmt)
  171. version = version_result.scalar_one_or_none()
  172. if not version:
  173. raise HTTPException(status_code=404, detail="No published version available")
  174. return DownloadInfo(
  175. app_uid=app.app_uid,
  176. version=version.version,
  177. url=version.storage_url,
  178. checksum_sha256=version.checksum_sha256,
  179. size_bytes=version.size_bytes,
  180. )