admin.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. import hashlib
  2. import re
  3. import zipfile
  4. from datetime import datetime, timezone
  5. from io import BytesIO
  6. from typing import Optional
  7. import yaml
  8. from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
  9. from pydantic import BaseModel
  10. from sqlalchemy import select
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from ..auth import create_token, get_current_admin, verify_password
  13. from ..config import get_settings
  14. from ..database import get_db
  15. from ..models import Admin, App, AppCategoryMap, AppVersion, Category, Developer
  16. router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])
  17. APP_UID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-]{1,62}[a-z0-9]$")
  18. class LoginRequest(BaseModel):
  19. username: str
  20. password: str
  21. class LoginResponse(BaseModel):
  22. token: str
  23. username: str
  24. class AppUploadResponse(BaseModel):
  25. app_uid: str
  26. name: str
  27. version: str
  28. type: str
  29. category: Optional[str]
  30. icon: Optional[str]
  31. description: Optional[str]
  32. storage_url: str
  33. message: str
  34. @router.post("/login", response_model=LoginResponse)
  35. async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
  36. result = await db.execute(
  37. select(Admin).where(Admin.username == req.username, Admin.is_active == True)
  38. )
  39. admin = result.scalar_one_or_none()
  40. if not admin or not verify_password(req.password, admin.password_hash):
  41. raise HTTPException(status_code=401, detail="Invalid credentials")
  42. admin.last_login_at = datetime.now(timezone.utc)
  43. await db.commit()
  44. token = create_token(admin.id, admin.username)
  45. return LoginResponse(token=token, username=admin.username)
  46. def _get_localized(value, lang: str = "zh-CN") -> str:
  47. if isinstance(value, dict):
  48. return value.get(lang) or value.get("en-US") or next(iter(value.values()), "")
  49. return str(value) if value else ""
  50. @router.post("/apps/upload", response_model=AppUploadResponse)
  51. async def upload_app(
  52. file: UploadFile = File(...),
  53. admin: Admin = Depends(get_current_admin),
  54. db: AsyncSession = Depends(get_db),
  55. ):
  56. if not file.filename or not file.filename.endswith(".zip"):
  57. raise HTTPException(status_code=400, detail="Only .zip files allowed")
  58. content = await file.read()
  59. file_hash = hashlib.sha256(content).hexdigest()
  60. file_size = len(content)
  61. try:
  62. with zipfile.ZipFile(BytesIO(content)) as zf:
  63. yml_name = next((n for n in zf.namelist() if n.endswith("app.yml")), None)
  64. if not yml_name:
  65. raise HTTPException(status_code=400, detail="app.yml not found in zip")
  66. manifest = yaml.safe_load(zf.read(yml_name))
  67. except zipfile.BadZipFile:
  68. raise HTTPException(status_code=400, detail="Invalid zip file")
  69. app_uid = manifest.get("id")
  70. version_str = manifest.get("version")
  71. if not app_uid or not version_str:
  72. raise HTTPException(status_code=400, detail="app.yml missing id or version")
  73. if not APP_UID_PATTERN.match(app_uid):
  74. raise HTTPException(status_code=400, detail="Invalid app_uid format")
  75. settings = get_settings()
  76. storage_dir = settings.storage_path / "apps" / app_uid
  77. storage_dir.mkdir(parents=True, exist_ok=True)
  78. zip_filename = f"{app_uid}-v{version_str}.zip"
  79. zip_path = storage_dir / zip_filename
  80. zip_path.write_bytes(content)
  81. storage_url = f"{settings.storage_base_url}/apps/{app_uid}/{zip_filename}"
  82. result = await db.execute(select(App).where(App.app_uid == app_uid))
  83. app = result.scalar_one_or_none()
  84. app_name = _get_localized(manifest.get("name"))
  85. app_type = manifest.get("type", "community")
  86. short_desc = _get_localized(manifest.get("description"))
  87. icon = manifest.get("icon")
  88. category_slug = manifest.get("category")
  89. if not app:
  90. dev_result = await db.execute(select(Developer).where(Developer.verified == True).limit(1))
  91. developer = dev_result.scalar_one_or_none()
  92. app = App(
  93. app_uid=app_uid,
  94. name=app_name,
  95. type=app_type,
  96. developer_id=developer.id if developer else None,
  97. short_description=short_desc,
  98. status="published",
  99. publish_time=datetime.now(timezone.utc),
  100. )
  101. db.add(app)
  102. await db.flush()
  103. if category_slug:
  104. cat_result = await db.execute(select(Category).where(Category.slug == category_slug))
  105. category = cat_result.scalar_one_or_none()
  106. if category:
  107. db.add(AppCategoryMap(app_id=app.id, category_id=category.id))
  108. else:
  109. app.name = app_name
  110. app.short_description = short_desc
  111. version_result = await db.execute(
  112. select(AppVersion).where(AppVersion.app_id == app.id, AppVersion.version == version_str)
  113. )
  114. existing_version = version_result.scalar_one_or_none()
  115. if existing_version:
  116. existing_version.storage_url = storage_url
  117. existing_version.checksum_sha256 = file_hash
  118. existing_version.size_bytes = file_size
  119. existing_version.manifest = manifest
  120. existing_version.status = "published"
  121. existing_version.published_at = datetime.now(timezone.utc)
  122. version = existing_version
  123. else:
  124. version = AppVersion(
  125. app_id=app.id,
  126. version=version_str,
  127. storage_url=storage_url,
  128. checksum_sha256=file_hash,
  129. size_bytes=file_size,
  130. manifest=manifest,
  131. status="published",
  132. published_at=datetime.now(timezone.utc),
  133. )
  134. db.add(version)
  135. await db.flush()
  136. app.current_version_id = version.id
  137. await db.commit()
  138. return AppUploadResponse(
  139. app_uid=app_uid,
  140. name=app_name,
  141. version=version_str,
  142. type=app_type,
  143. category=category_slug,
  144. icon=icon,
  145. description=short_desc,
  146. storage_url=storage_url,
  147. message="App uploaded successfully",
  148. )