client_distributions.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  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. DistributionPlatform,
  19. )
  20. from app.core.minio import minio_storage
  21. from app.core.version import version_sort_key, validate_version_code
  22. from app.core.distribution_share import encode_distribution_share_id
  23. router = APIRouter()
  24. logger = logging.getLogger(__name__)
  25. # 安装包允许类型
  26. ALLOWED_PACKAGE_TYPES = {
  27. "application/vnd.android.package-archive", # APK
  28. "application/octet-stream", # IPA, generic binary
  29. }
  30. MAX_PACKAGE_SIZE = 500 * 1024 * 1024 # 500MB
  31. # 图标允许类型
  32. ALLOWED_ICON_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
  33. MAX_ICON_SIZE = 2 * 1024 * 1024 # 2MB
  34. def _resolve_icon_url(icon_url: Optional[str]) -> Optional[str]:
  35. """若 icon_url 为 MinIO object_key,转为预签名 URL"""
  36. if not icon_url:
  37. return icon_url
  38. if str(icon_url).startswith("http"):
  39. return icon_url
  40. return minio_storage.get_distribution_presigned_url(icon_url) or icon_url
  41. def _check_distribution_access(db: Session, dist_id: int, user: User) -> ClientDistribution:
  42. dist = db.query(ClientDistribution).filter(
  43. ClientDistribution.id == dist_id,
  44. ClientDistribution.is_deleted == 0
  45. ).first()
  46. if not dist:
  47. raise HTTPException(status_code=404, detail="分发不存在")
  48. if user.role != "SUPER_ADMIN" and dist.owner_id != user.id:
  49. raise HTTPException(status_code=403, detail="无权限操作此分发")
  50. return dist
  51. @router.get("/", response_model=ClientDistributionList)
  52. def list_distributions(
  53. skip: int = 0,
  54. limit: int = 10,
  55. search: Optional[str] = None,
  56. db: Session = Depends(deps.get_db),
  57. current_user: User = Depends(deps.get_current_active_user),
  58. ):
  59. """获取分发列表。超级管理员看全部,开发者只看自己的。"""
  60. query = db.query(ClientDistribution).filter(ClientDistribution.is_deleted == 0)
  61. if current_user.role != "SUPER_ADMIN":
  62. query = query.filter(ClientDistribution.owner_id == current_user.id)
  63. if search:
  64. query = query.filter(
  65. or_(
  66. ClientDistribution.name.ilike(f"%{search}%"),
  67. )
  68. )
  69. total = query.count()
  70. rows = query.order_by(desc(ClientDistribution.id)).offset(skip).limit(limit).all()
  71. items = [
  72. ClientDistributionResponse(
  73. id=d.id,
  74. name=d.name,
  75. description=d.description,
  76. icon_url=_resolve_icon_url(d.icon_url),
  77. icon_object_key=d.icon_url if (d.icon_url and not str(d.icon_url).startswith("http")) else None,
  78. owner_id=d.owner_id,
  79. created_at=d.created_at,
  80. updated_at=d.updated_at,
  81. share_id=encode_distribution_share_id(d.id, d.created_at),
  82. )
  83. for d in rows
  84. ]
  85. return ClientDistributionList(total=total, items=items)
  86. @router.post("/", response_model=ClientDistributionResponse)
  87. def create_distribution(
  88. data: ClientDistributionCreate,
  89. db: Session = Depends(deps.get_db),
  90. current_user: User = Depends(deps.get_current_active_user),
  91. ):
  92. """创建分发。仅开发者或超级管理员可创建。"""
  93. if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"):
  94. raise HTTPException(status_code=403, detail="无权限创建分发")
  95. dist = ClientDistribution(
  96. name=data.name,
  97. description=data.description,
  98. icon_url=data.icon_url,
  99. owner_id=current_user.id,
  100. )
  101. db.add(dist)
  102. db.commit()
  103. db.refresh(dist)
  104. return ClientDistributionResponse(
  105. id=dist.id,
  106. name=dist.name,
  107. description=dist.description,
  108. icon_url=_resolve_icon_url(dist.icon_url),
  109. icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
  110. owner_id=dist.owner_id,
  111. created_at=dist.created_at,
  112. updated_at=dist.updated_at,
  113. share_id=encode_distribution_share_id(dist.id, dist.created_at),
  114. )
  115. @router.post("/icon/upload")
  116. async def upload_icon(
  117. file: UploadFile = File(...),
  118. distribution_id: int = Form(0),
  119. db: Session = Depends(deps.get_db),
  120. current_user: User = Depends(deps.get_current_active_user),
  121. ):
  122. """上传分发图标。distribution_id=0 表示创建时的临时图标,>0 表示编辑已有分发。返回 object_key。"""
  123. if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"):
  124. raise HTTPException(status_code=403, detail="无权限")
  125. if distribution_id > 0:
  126. _check_distribution_access(db, distribution_id, current_user)
  127. content = await file.read()
  128. if len(content) > MAX_ICON_SIZE:
  129. raise HTTPException(status_code=400, detail="图标大小超过 2MB 限制")
  130. ct = file.content_type or "image/png"
  131. if ct not in ALLOWED_ICON_TYPES:
  132. raise HTTPException(status_code=400, detail="仅支持 PNG、JPG、GIF、WebP 格式")
  133. try:
  134. object_key = minio_storage.upload_distribution_icon(
  135. file_data=content,
  136. filename=file.filename or "icon.png",
  137. content_type=ct,
  138. distribution_id=distribution_id,
  139. user_id=current_user.id,
  140. )
  141. return {"object_key": object_key}
  142. except Exception as e:
  143. logger.error(f"Icon upload failed: {e}")
  144. raise HTTPException(status_code=500, detail="图标上传失败")
  145. @router.get("/{dist_id}", response_model=ClientDistributionResponse)
  146. def get_distribution(
  147. dist_id: int,
  148. db: Session = Depends(deps.get_db),
  149. current_user: User = Depends(deps.get_current_active_user),
  150. ):
  151. """获取分发详情"""
  152. dist = _check_distribution_access(db, dist_id, current_user)
  153. return ClientDistributionResponse(
  154. id=dist.id,
  155. name=dist.name,
  156. description=dist.description,
  157. icon_url=_resolve_icon_url(dist.icon_url),
  158. icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
  159. owner_id=dist.owner_id,
  160. created_at=dist.created_at,
  161. updated_at=dist.updated_at,
  162. share_id=encode_distribution_share_id(dist.id, dist.created_at),
  163. )
  164. @router.put("/{dist_id}", response_model=ClientDistributionResponse)
  165. def update_distribution(
  166. dist_id: int,
  167. data: ClientDistributionUpdate,
  168. db: Session = Depends(deps.get_db),
  169. current_user: User = Depends(deps.get_current_active_user),
  170. ):
  171. """更新分发"""
  172. dist = _check_distribution_access(db, dist_id, current_user)
  173. if data.name is not None:
  174. dist.name = data.name
  175. if data.description is not None:
  176. dist.description = data.description
  177. if data.icon_url is not None:
  178. dist.icon_url = data.icon_url
  179. db.commit()
  180. db.refresh(dist)
  181. return ClientDistributionResponse(
  182. id=dist.id,
  183. name=dist.name,
  184. description=dist.description,
  185. icon_url=_resolve_icon_url(dist.icon_url),
  186. icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None,
  187. owner_id=dist.owner_id,
  188. created_at=dist.created_at,
  189. updated_at=dist.updated_at,
  190. share_id=encode_distribution_share_id(dist.id, dist.created_at),
  191. )
  192. @router.delete("/{dist_id}")
  193. def delete_distribution(
  194. dist_id: int,
  195. db: Session = Depends(deps.get_db),
  196. current_user: User = Depends(deps.get_current_active_user),
  197. ):
  198. """软删除分发"""
  199. dist = _check_distribution_access(db, dist_id, current_user)
  200. dist.is_deleted = 1
  201. db.commit()
  202. return {"message": "已删除"}
  203. # ========== Versions ==========
  204. @router.get("/{dist_id}/versions", response_model=ClientVersionList)
  205. def list_versions(
  206. dist_id: int,
  207. skip: int = 0,
  208. limit: int = 20,
  209. platform: Optional[str] = None,
  210. search: Optional[str] = None,
  211. db: Session = Depends(deps.get_db),
  212. current_user: User = Depends(deps.get_current_active_user),
  213. ):
  214. """获取分发下的版本列表(按语义版本号从新到旧排序)。支持按平台分类筛选与关键词聚合检索。"""
  215. _check_distribution_access(db, dist_id, current_user)
  216. query = db.query(ClientVersion).filter(ClientVersion.distribution_id == dist_id)
  217. if platform and platform.strip():
  218. query = query.filter(ClientVersion.platform == platform.strip().lower())
  219. if search and search.strip():
  220. term = f"%{search.strip()}%"
  221. query = query.filter(
  222. or_(
  223. ClientVersion.version_code.ilike(term),
  224. ClientVersion.version_name.ilike(term),
  225. ClientVersion.release_notes.ilike(term),
  226. )
  227. )
  228. all_versions = query.all()
  229. all_versions.sort(key=lambda v: version_sort_key(v.version_code), reverse=True)
  230. total = len(all_versions)
  231. items = all_versions[skip : skip + limit]
  232. return ClientVersionList(total=total, items=items)
  233. @router.post("/{dist_id}/versions", response_model=ClientVersionResponse)
  234. async def create_version(
  235. dist_id: int,
  236. file: UploadFile = File(...),
  237. version_code: str = Form(...),
  238. version_name: Optional[str] = Form(None),
  239. release_notes: Optional[str] = Form(None),
  240. platform: Optional[str] = Form(None),
  241. db: Session = Depends(deps.get_db),
  242. current_user: User = Depends(deps.get_current_active_user),
  243. ):
  244. """创建新版本(上传安装包)。平台必填;版本号不能低于该平台当前最新版本。"""
  245. dist = _check_distribution_access(db, dist_id, current_user)
  246. # 平台必填并规范化
  247. if not platform or not str(platform).strip():
  248. raise HTTPException(status_code=400, detail="请选择平台")
  249. platform_lower = str(platform).strip().lower()
  250. allowed = {p.value for p in DistributionPlatform}
  251. if platform_lower not in allowed:
  252. raise HTTPException(
  253. status_code=400,
  254. detail=f"平台必须是以下之一: {', '.join(sorted(allowed))}",
  255. )
  256. platform = platform_lower
  257. # 校验版本号格式
  258. version_code = (version_code or "").strip()
  259. try:
  260. validate_version_code(version_code)
  261. except ValueError as e:
  262. raise HTTPException(status_code=400, detail=str(e))
  263. # 该平台下版本号不能低于已有最新版本
  264. versions_same_platform = (
  265. db.query(ClientVersion)
  266. .filter(
  267. ClientVersion.distribution_id == dist_id,
  268. ClientVersion.platform == platform,
  269. )
  270. .all()
  271. )
  272. if versions_same_platform:
  273. latest_same_platform = max(
  274. versions_same_platform,
  275. key=lambda v: version_sort_key(v.version_code),
  276. )
  277. if version_sort_key(version_code) < version_sort_key(latest_same_platform.version_code):
  278. raise HTTPException(
  279. status_code=400,
  280. detail=f"该平台当前最新版本为 {latest_same_platform.version_code},新版本号不能低于此版本",
  281. )
  282. # 检查版本号唯一(同分发内版本号不可重复)
  283. existing = db.query(ClientVersion).filter(
  284. ClientVersion.distribution_id == dist_id,
  285. ClientVersion.version_code == version_code
  286. ).first()
  287. if existing:
  288. raise HTTPException(status_code=400, detail=f"版本号 {version_code} 已存在")
  289. content = await file.read()
  290. if len(content) > MAX_PACKAGE_SIZE:
  291. raise HTTPException(status_code=400, detail="文件大小超过 500MB 限制")
  292. content_type = file.content_type or "application/octet-stream"
  293. if content_type not in ALLOWED_PACKAGE_TYPES and not content_type.startswith("application/"):
  294. pass # 放宽:允许 application/*
  295. try:
  296. object_key = minio_storage.upload_distribution_file(
  297. file_data=content,
  298. filename=file.filename or "package.bin",
  299. content_type=content_type,
  300. distribution_id=dist_id,
  301. )
  302. except Exception as e:
  303. logger.error(f"Upload failed: {e}")
  304. raise HTTPException(status_code=500, detail="文件上传失败")
  305. version = ClientVersion(
  306. distribution_id=dist_id,
  307. version_code=version_code,
  308. version_name=version_name or version_code,
  309. release_notes=release_notes,
  310. object_key=object_key,
  311. file_size=len(content),
  312. platform=platform,
  313. )
  314. db.add(version)
  315. db.commit()
  316. db.refresh(version)
  317. return version
  318. @router.put("/versions/{version_id}", response_model=ClientVersionResponse)
  319. def update_version(
  320. version_id: int,
  321. data: ClientVersionUpdate,
  322. db: Session = Depends(deps.get_db),
  323. current_user: User = Depends(deps.get_current_active_user),
  324. ):
  325. """更新版本信息(不含文件)"""
  326. version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first()
  327. if not version:
  328. raise HTTPException(status_code=404, detail="版本不存在")
  329. _check_distribution_access(db, version.distribution_id, current_user)
  330. if data.version_name is not None:
  331. version.version_name = data.version_name
  332. if data.release_notes is not None:
  333. version.release_notes = data.release_notes
  334. if data.platform is not None:
  335. version.platform = data.platform
  336. db.commit()
  337. db.refresh(version)
  338. return version
  339. @router.delete("/versions/{version_id}")
  340. def delete_version(
  341. version_id: int,
  342. db: Session = Depends(deps.get_db),
  343. current_user: User = Depends(deps.get_current_active_user),
  344. ):
  345. """删除版本"""
  346. version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first()
  347. if not version:
  348. raise HTTPException(status_code=404, detail="版本不存在")
  349. _check_distribution_access(db, version.distribution_id, current_user)
  350. db.delete(version)
  351. db.commit()
  352. return {"message": "已删除"}
  353. # ========== 自建更新文件(update 桶,公开读)==========
  354. @router.get("/{dist_id}/update-files")
  355. def list_update_files(
  356. dist_id: int,
  357. platform: str,
  358. db: Session = Depends(deps.get_db),
  359. current_user: User = Depends(deps.get_current_active_user),
  360. ):
  361. """列出该分发、该平台下 update/ 目录中的文件(MinIO 路径:创建时间戳/平台/update/...)"""
  362. dist = _check_distribution_access(db, dist_id, current_user)
  363. if not platform or platform.strip().lower() not in {p.value for p in DistributionPlatform}:
  364. raise HTTPException(status_code=400, detail="请选择有效平台")
  365. platform = platform.strip().lower()
  366. ts = int(dist.created_at.timestamp())
  367. files = minio_storage.list_update_files(ts, platform)
  368. base_url = minio_storage._update_bucket_public_base_url()
  369. for f in files:
  370. f["url"] = minio_storage.get_update_file_public_url(f["key"])
  371. return {"base_url": f"{base_url}{ts}/{platform}/update/", "files": files}
  372. @router.post("/{dist_id}/update-files")
  373. async def upload_update_file(
  374. dist_id: int,
  375. platform: str = Form(...),
  376. file: UploadFile = File(...),
  377. db: Session = Depends(deps.get_db),
  378. current_user: User = Depends(deps.get_current_active_user),
  379. ):
  380. """上传文件到该分发、该平台的 update/ 目录,透传不改名"""
  381. dist = _check_distribution_access(db, dist_id, current_user)
  382. if platform.strip().lower() not in {p.value for p in DistributionPlatform}:
  383. raise HTTPException(status_code=400, detail="请选择有效平台")
  384. platform = platform.strip().lower()
  385. filename = file.filename or "file.bin"
  386. content = await file.read()
  387. ts = int(dist.created_at.timestamp())
  388. object_key = minio_storage.upload_update_file(
  389. created_at_timestamp=ts,
  390. platform=platform,
  391. file_data=content,
  392. filename=filename,
  393. content_type=file.content_type or "application/octet-stream",
  394. )
  395. url = minio_storage.get_update_file_public_url(object_key)
  396. return {"object_key": object_key, "url": url}
  397. @router.delete("/{dist_id}/update-files")
  398. def delete_update_file(
  399. dist_id: int,
  400. platform: str,
  401. key: str,
  402. db: Session = Depends(deps.get_db),
  403. current_user: User = Depends(deps.get_current_active_user),
  404. ):
  405. """删除 update/ 目录下的文件,key 为 list 接口返回的完整 object_key"""
  406. dist = _check_distribution_access(db, dist_id, current_user)
  407. if not platform or not key:
  408. raise HTTPException(status_code=400, detail="缺少 platform 或 key")
  409. platform = platform.strip().lower()
  410. ts = int(dist.created_at.timestamp())
  411. prefix = f"{ts}/{platform}/update/"
  412. if not key.startswith(prefix):
  413. raise HTTPException(status_code=400, detail="key 不属当前分发/平台")
  414. minio_storage.delete_update_file(key)
  415. return {"message": "已删除"}