client_distributions.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import logging
  2. from typing import Optional
  3. from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
  4. from sqlalchemy.orm import Session
  5. from sqlalchemy import desc, or_
  6. from app.api.v1 import deps
  7. from app.models.user import User
  8. from app.models.client_distribution import ClientDistribution, ClientVersion
  9. from app.schemas.client_distribution import (
  10. ClientDistributionCreate,
  11. ClientDistributionUpdate,
  12. ClientDistributionResponse,
  13. ClientDistributionList,
  14. ClientVersionCreate,
  15. ClientVersionUpdate,
  16. ClientVersionResponse,
  17. ClientVersionList,
  18. )
  19. from app.core.minio import minio_storage
  20. router = APIRouter()
  21. logger = logging.getLogger(__name__)
  22. # 安装包允许类型
  23. ALLOWED_PACKAGE_TYPES = {
  24. "application/vnd.android.package-archive", # APK
  25. "application/octet-stream", # IPA, generic binary
  26. }
  27. MAX_PACKAGE_SIZE = 500 * 1024 * 1024 # 500MB
  28. # 图标允许类型
  29. ALLOWED_ICON_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
  30. MAX_ICON_SIZE = 2 * 1024 * 1024 # 2MB
  31. def _resolve_icon_url(icon_url: Optional[str]) -> Optional[str]:
  32. """若 icon_url 为 MinIO object_key,转为预签名 URL"""
  33. if not icon_url:
  34. return icon_url
  35. if str(icon_url).startswith("http"):
  36. return icon_url
  37. return minio_storage.get_distribution_presigned_url(icon_url) or icon_url
  38. def _check_distribution_access(db: Session, dist_id: int, user: User) -> ClientDistribution:
  39. dist = db.query(ClientDistribution).filter(
  40. ClientDistribution.id == dist_id,
  41. ClientDistribution.is_deleted == 0
  42. ).first()
  43. if not dist:
  44. raise HTTPException(status_code=404, detail="分发不存在")
  45. if user.role != "SUPER_ADMIN" and dist.owner_id != user.id:
  46. raise HTTPException(status_code=403, detail="无权限操作此分发")
  47. return dist
  48. @router.get("/", response_model=ClientDistributionList)
  49. def list_distributions(
  50. skip: int = 0,
  51. limit: int = 10,
  52. search: Optional[str] = None,
  53. db: Session = Depends(deps.get_db),
  54. current_user: User = Depends(deps.get_current_active_user),
  55. ):
  56. """获取分发列表。超级管理员看全部,开发者只看自己的。"""
  57. query = db.query(ClientDistribution).filter(ClientDistribution.is_deleted == 0)
  58. if current_user.role != "SUPER_ADMIN":
  59. query = query.filter(ClientDistribution.owner_id == current_user.id)
  60. if search:
  61. query = query.filter(
  62. or_(
  63. ClientDistribution.name.ilike(f"%{search}%"),
  64. )
  65. )
  66. total = query.count()
  67. rows = query.order_by(desc(ClientDistribution.id)).offset(skip).limit(limit).all()
  68. items = [
  69. ClientDistributionResponse(
  70. id=d.id,
  71. name=d.name,
  72. description=d.description,
  73. icon_url=_resolve_icon_url(d.icon_url),
  74. icon_object_key=d.icon_url if (d.icon_url and not str(d.icon_url).startswith("http")) else None,
  75. owner_id=d.owner_id,
  76. created_at=d.created_at,
  77. updated_at=d.updated_at,
  78. )
  79. for d in rows
  80. ]
  81. return ClientDistributionList(total=total, items=items)
  82. @router.post("/", response_model=ClientDistributionResponse)
  83. def create_distribution(
  84. data: ClientDistributionCreate,
  85. db: Session = Depends(deps.get_db),
  86. current_user: User = Depends(deps.get_current_active_user),
  87. ):
  88. """创建分发。仅开发者或超级管理员可创建。"""
  89. if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"):
  90. raise HTTPException(status_code=403, detail="无权限创建分发")
  91. dist = ClientDistribution(
  92. name=data.name,
  93. description=data.description,
  94. icon_url=data.icon_url,
  95. owner_id=current_user.id,
  96. )
  97. db.add(dist)
  98. db.commit()
  99. db.refresh(dist)
  100. return ClientDistributionResponse(
  101. id=dist.id,
  102. name=dist.name,
  103. description=dist.description,
  104. icon_url=_resolve_icon_url(dist.icon_url),
  105. icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
  106. owner_id=dist.owner_id,
  107. created_at=dist.created_at,
  108. updated_at=dist.updated_at,
  109. )
  110. @router.post("/icon/upload")
  111. async def upload_icon(
  112. file: UploadFile = File(...),
  113. distribution_id: int = Form(0),
  114. db: Session = Depends(deps.get_db),
  115. current_user: User = Depends(deps.get_current_active_user),
  116. ):
  117. """上传分发图标。distribution_id=0 表示创建时的临时图标,>0 表示编辑已有分发。返回 object_key。"""
  118. if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"):
  119. raise HTTPException(status_code=403, detail="无权限")
  120. if distribution_id > 0:
  121. _check_distribution_access(db, distribution_id, current_user)
  122. content = await file.read()
  123. if len(content) > MAX_ICON_SIZE:
  124. raise HTTPException(status_code=400, detail="图标大小超过 2MB 限制")
  125. ct = file.content_type or "image/png"
  126. if ct not in ALLOWED_ICON_TYPES:
  127. raise HTTPException(status_code=400, detail="仅支持 PNG、JPG、GIF、WebP 格式")
  128. try:
  129. object_key = minio_storage.upload_distribution_icon(
  130. file_data=content,
  131. filename=file.filename or "icon.png",
  132. content_type=ct,
  133. distribution_id=distribution_id,
  134. user_id=current_user.id,
  135. )
  136. return {"object_key": object_key}
  137. except Exception as e:
  138. logger.error(f"Icon upload failed: {e}")
  139. raise HTTPException(status_code=500, detail="图标上传失败")
  140. @router.get("/{dist_id}", response_model=ClientDistributionResponse)
  141. def get_distribution(
  142. dist_id: int,
  143. db: Session = Depends(deps.get_db),
  144. current_user: User = Depends(deps.get_current_active_user),
  145. ):
  146. """获取分发详情"""
  147. dist = _check_distribution_access(db, dist_id, current_user)
  148. return ClientDistributionResponse(
  149. id=dist.id,
  150. name=dist.name,
  151. description=dist.description,
  152. icon_url=_resolve_icon_url(dist.icon_url),
  153. icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
  154. owner_id=dist.owner_id,
  155. created_at=dist.created_at,
  156. updated_at=dist.updated_at,
  157. )
  158. @router.put("/{dist_id}", response_model=ClientDistributionResponse)
  159. def update_distribution(
  160. dist_id: int,
  161. data: ClientDistributionUpdate,
  162. db: Session = Depends(deps.get_db),
  163. current_user: User = Depends(deps.get_current_active_user),
  164. ):
  165. """更新分发"""
  166. dist = _check_distribution_access(db, dist_id, current_user)
  167. if data.name is not None:
  168. dist.name = data.name
  169. if data.description is not None:
  170. dist.description = data.description
  171. if data.icon_url is not None:
  172. dist.icon_url = data.icon_url
  173. db.commit()
  174. db.refresh(dist)
  175. return ClientDistributionResponse(
  176. id=dist.id,
  177. name=dist.name,
  178. description=dist.description,
  179. icon_url=_resolve_icon_url(dist.icon_url),
  180. icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
  181. owner_id=dist.owner_id,
  182. created_at=dist.created_at,
  183. updated_at=dist.updated_at,
  184. )
  185. @router.delete("/{dist_id}")
  186. def delete_distribution(
  187. dist_id: int,
  188. db: Session = Depends(deps.get_db),
  189. current_user: User = Depends(deps.get_current_active_user),
  190. ):
  191. """软删除分发"""
  192. dist = _check_distribution_access(db, dist_id, current_user)
  193. dist.is_deleted = 1
  194. db.commit()
  195. return {"message": "已删除"}
  196. # ========== Versions ==========
  197. @router.get("/{dist_id}/versions", response_model=ClientVersionList)
  198. def list_versions(
  199. dist_id: int,
  200. skip: int = 0,
  201. limit: int = 20,
  202. db: Session = Depends(deps.get_db),
  203. current_user: User = Depends(deps.get_current_active_user),
  204. ):
  205. """获取分发下的版本列表"""
  206. _check_distribution_access(db, dist_id, current_user)
  207. query = db.query(ClientVersion).filter(ClientVersion.distribution_id == dist_id)
  208. total = query.count()
  209. items = query.order_by(desc(ClientVersion.created_at)).offset(skip).limit(limit).all()
  210. return ClientVersionList(total=total, items=items)
  211. @router.post("/{dist_id}/versions", response_model=ClientVersionResponse)
  212. async def create_version(
  213. dist_id: int,
  214. file: UploadFile = File(...),
  215. version_code: str = Form(...),
  216. version_name: Optional[str] = Form(None),
  217. release_notes: Optional[str] = Form(None),
  218. platform: Optional[str] = Form(None),
  219. db: Session = Depends(deps.get_db),
  220. current_user: User = Depends(deps.get_current_active_user),
  221. ):
  222. """创建新版本(上传安装包)"""
  223. dist = _check_distribution_access(db, dist_id, current_user)
  224. # 检查版本号唯一
  225. existing = db.query(ClientVersion).filter(
  226. ClientVersion.distribution_id == dist_id,
  227. ClientVersion.version_code == version_code
  228. ).first()
  229. if existing:
  230. raise HTTPException(status_code=400, detail=f"版本号 {version_code} 已存在")
  231. content = await file.read()
  232. if len(content) > MAX_PACKAGE_SIZE:
  233. raise HTTPException(status_code=400, detail="文件大小超过 500MB 限制")
  234. content_type = file.content_type or "application/octet-stream"
  235. if content_type not in ALLOWED_PACKAGE_TYPES and not content_type.startswith("application/"):
  236. pass # 放宽:允许 application/*
  237. try:
  238. object_key = minio_storage.upload_distribution_file(
  239. file_data=content,
  240. filename=file.filename or "package.bin",
  241. content_type=content_type,
  242. distribution_id=dist_id,
  243. )
  244. except Exception as e:
  245. logger.error(f"Upload failed: {e}")
  246. raise HTTPException(status_code=500, detail="文件上传失败")
  247. version = ClientVersion(
  248. distribution_id=dist_id,
  249. version_code=version_code,
  250. version_name=version_name or version_code,
  251. release_notes=release_notes,
  252. object_key=object_key,
  253. file_size=len(content),
  254. platform=platform,
  255. )
  256. db.add(version)
  257. db.commit()
  258. db.refresh(version)
  259. return version
  260. @router.put("/versions/{version_id}", response_model=ClientVersionResponse)
  261. def update_version(
  262. version_id: int,
  263. data: ClientVersionUpdate,
  264. db: Session = Depends(deps.get_db),
  265. current_user: User = Depends(deps.get_current_active_user),
  266. ):
  267. """更新版本信息(不含文件)"""
  268. version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first()
  269. if not version:
  270. raise HTTPException(status_code=404, detail="版本不存在")
  271. _check_distribution_access(db, version.distribution_id, current_user)
  272. if data.version_name is not None:
  273. version.version_name = data.version_name
  274. if data.release_notes is not None:
  275. version.release_notes = data.release_notes
  276. if data.platform is not None:
  277. version.platform = data.platform
  278. db.commit()
  279. db.refresh(version)
  280. return version
  281. @router.delete("/versions/{version_id}")
  282. def delete_version(
  283. version_id: int,
  284. db: Session = Depends(deps.get_db),
  285. current_user: User = Depends(deps.get_current_active_user),
  286. ):
  287. """删除版本"""
  288. version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first()
  289. if not version:
  290. raise HTTPException(status_code=404, detail="版本不存在")
  291. _check_distribution_access(db, version.distribution_id, current_user)
  292. db.delete(version)
  293. db.commit()
  294. return {"message": "已删除"}