import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from sqlalchemy.orm import Session from sqlalchemy import desc, or_ from app.api.v1 import deps from app.models.user import User from app.models.client_distribution import ClientDistribution, ClientVersion from app.schemas.client_distribution import ( ClientDistributionCreate, ClientDistributionUpdate, ClientDistributionResponse, ClientDistributionList, ClientVersionCreate, ClientVersionUpdate, ClientVersionResponse, ClientVersionList, DistributionPlatform, ) from app.core.minio import minio_storage from app.core.version import version_sort_key, validate_version_code from app.core.distribution_share import encode_distribution_share_id router = APIRouter() logger = logging.getLogger(__name__) # 安装包允许类型 ALLOWED_PACKAGE_TYPES = { "application/vnd.android.package-archive", # APK "application/octet-stream", # IPA, generic binary } MAX_PACKAGE_SIZE = 500 * 1024 * 1024 # 500MB # 图标允许类型 ALLOWED_ICON_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"} MAX_ICON_SIZE = 2 * 1024 * 1024 # 2MB def _resolve_icon_url(icon_url: Optional[str]) -> Optional[str]: """若 icon_url 为 MinIO object_key,转为预签名 URL""" if not icon_url: return icon_url if str(icon_url).startswith("http"): return icon_url return minio_storage.get_distribution_presigned_url(icon_url) or icon_url def _check_distribution_access(db: Session, dist_id: int, user: User) -> ClientDistribution: dist = db.query(ClientDistribution).filter( ClientDistribution.id == dist_id, ClientDistribution.is_deleted == 0 ).first() if not dist: raise HTTPException(status_code=404, detail="分发不存在") if user.role != "SUPER_ADMIN" and dist.owner_id != user.id: raise HTTPException(status_code=403, detail="无权限操作此分发") return dist @router.get("/", response_model=ClientDistributionList) def list_distributions( skip: int = 0, limit: int = 10, search: Optional[str] = None, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """获取分发列表。超级管理员看全部,开发者只看自己的。""" query = db.query(ClientDistribution).filter(ClientDistribution.is_deleted == 0) if current_user.role != "SUPER_ADMIN": query = query.filter(ClientDistribution.owner_id == current_user.id) if search: query = query.filter( or_( ClientDistribution.name.ilike(f"%{search}%"), ) ) total = query.count() rows = query.order_by(desc(ClientDistribution.id)).offset(skip).limit(limit).all() items = [ ClientDistributionResponse( id=d.id, name=d.name, description=d.description, icon_url=_resolve_icon_url(d.icon_url), icon_object_key=d.icon_url if (d.icon_url and not str(d.icon_url).startswith("http")) else None, owner_id=d.owner_id, created_at=d.created_at, updated_at=d.updated_at, share_id=encode_distribution_share_id(d.id, d.created_at), ) for d in rows ] return ClientDistributionList(total=total, items=items) @router.post("/", response_model=ClientDistributionResponse) def create_distribution( data: ClientDistributionCreate, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """创建分发。仅开发者或超级管理员可创建。""" if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"): raise HTTPException(status_code=403, detail="无权限创建分发") dist = ClientDistribution( name=data.name, description=data.description, icon_url=data.icon_url, owner_id=current_user.id, ) db.add(dist) db.commit() db.refresh(dist) return ClientDistributionResponse( id=dist.id, name=dist.name, description=dist.description, icon_url=_resolve_icon_url(dist.icon_url), icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None, owner_id=dist.owner_id, created_at=dist.created_at, updated_at=dist.updated_at, share_id=encode_distribution_share_id(dist.id, dist.created_at), ) @router.post("/icon/upload") async def upload_icon( file: UploadFile = File(...), distribution_id: int = Form(0), db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """上传分发图标。distribution_id=0 表示创建时的临时图标,>0 表示编辑已有分发。返回 object_key。""" if current_user.role not in ("SUPER_ADMIN", "DEVELOPER"): raise HTTPException(status_code=403, detail="无权限") if distribution_id > 0: _check_distribution_access(db, distribution_id, current_user) content = await file.read() if len(content) > MAX_ICON_SIZE: raise HTTPException(status_code=400, detail="图标大小超过 2MB 限制") ct = file.content_type or "image/png" if ct not in ALLOWED_ICON_TYPES: raise HTTPException(status_code=400, detail="仅支持 PNG、JPG、GIF、WebP 格式") try: object_key = minio_storage.upload_distribution_icon( file_data=content, filename=file.filename or "icon.png", content_type=ct, distribution_id=distribution_id, user_id=current_user.id, ) return {"object_key": object_key} except Exception as e: logger.error(f"Icon upload failed: {e}") raise HTTPException(status_code=500, detail="图标上传失败") @router.get("/{dist_id}", response_model=ClientDistributionResponse) def get_distribution( dist_id: int, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """获取分发详情""" dist = _check_distribution_access(db, dist_id, current_user) return ClientDistributionResponse( id=dist.id, name=dist.name, description=dist.description, icon_url=_resolve_icon_url(dist.icon_url), icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None, owner_id=dist.owner_id, created_at=dist.created_at, updated_at=dist.updated_at, share_id=encode_distribution_share_id(dist.id, dist.created_at), ) @router.put("/{dist_id}", response_model=ClientDistributionResponse) def update_distribution( dist_id: int, data: ClientDistributionUpdate, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """更新分发""" dist = _check_distribution_access(db, dist_id, current_user) if data.name is not None: dist.name = data.name if data.description is not None: dist.description = data.description if data.icon_url is not None: dist.icon_url = data.icon_url db.commit() db.refresh(dist) return ClientDistributionResponse( id=dist.id, name=dist.name, description=dist.description, icon_url=_resolve_icon_url(dist.icon_url), icon_object_key=dist.icon_url if (dist.icon_url and not str(dist.icon_url).startswith("http")) else None, owner_id=dist.owner_id, created_at=dist.created_at, updated_at=dist.updated_at, share_id=encode_distribution_share_id(dist.id, dist.created_at), ) @router.delete("/{dist_id}") def delete_distribution( dist_id: int, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """软删除分发""" dist = _check_distribution_access(db, dist_id, current_user) dist.is_deleted = 1 db.commit() return {"message": "已删除"} # ========== Versions ========== @router.get("/{dist_id}/versions", response_model=ClientVersionList) def list_versions( dist_id: int, skip: int = 0, limit: int = 20, platform: Optional[str] = None, search: Optional[str] = None, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """获取分发下的版本列表(按语义版本号从新到旧排序)。支持按平台分类筛选与关键词聚合检索。""" _check_distribution_access(db, dist_id, current_user) query = db.query(ClientVersion).filter(ClientVersion.distribution_id == dist_id) if platform and platform.strip(): query = query.filter(ClientVersion.platform == platform.strip().lower()) if search and search.strip(): term = f"%{search.strip()}%" query = query.filter( or_( ClientVersion.version_code.ilike(term), ClientVersion.version_name.ilike(term), ClientVersion.release_notes.ilike(term), ) ) all_versions = query.all() all_versions.sort(key=lambda v: version_sort_key(v.version_code), reverse=True) total = len(all_versions) items = all_versions[skip : skip + limit] return ClientVersionList(total=total, items=items) @router.post("/{dist_id}/versions", response_model=ClientVersionResponse) async def create_version( dist_id: int, file: UploadFile = File(...), version_code: str = Form(...), version_name: Optional[str] = Form(None), release_notes: Optional[str] = Form(None), platform: Optional[str] = Form(None), db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """创建新版本(上传安装包)。平台必填;版本号不能低于该平台当前最新版本。""" dist = _check_distribution_access(db, dist_id, current_user) # 平台必填并规范化 if not platform or not str(platform).strip(): raise HTTPException(status_code=400, detail="请选择平台") platform_lower = str(platform).strip().lower() allowed = {p.value for p in DistributionPlatform} if platform_lower not in allowed: raise HTTPException( status_code=400, detail=f"平台必须是以下之一: {', '.join(sorted(allowed))}", ) platform = platform_lower # 校验版本号格式 version_code = (version_code or "").strip() try: validate_version_code(version_code) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) # 该平台下版本号不能低于已有最新版本 versions_same_platform = ( db.query(ClientVersion) .filter( ClientVersion.distribution_id == dist_id, ClientVersion.platform == platform, ) .all() ) if versions_same_platform: latest_same_platform = max( versions_same_platform, key=lambda v: version_sort_key(v.version_code), ) if version_sort_key(version_code) < version_sort_key(latest_same_platform.version_code): raise HTTPException( status_code=400, detail=f"该平台当前最新版本为 {latest_same_platform.version_code},新版本号不能低于此版本", ) # 检查版本号唯一(同分发内版本号不可重复) existing = db.query(ClientVersion).filter( ClientVersion.distribution_id == dist_id, ClientVersion.version_code == version_code ).first() if existing: raise HTTPException(status_code=400, detail=f"版本号 {version_code} 已存在") content = await file.read() if len(content) > MAX_PACKAGE_SIZE: raise HTTPException(status_code=400, detail="文件大小超过 500MB 限制") content_type = file.content_type or "application/octet-stream" if content_type not in ALLOWED_PACKAGE_TYPES and not content_type.startswith("application/"): pass # 放宽:允许 application/* try: object_key = minio_storage.upload_distribution_file( file_data=content, filename=file.filename or "package.bin", content_type=content_type, distribution_id=dist_id, ) except Exception as e: logger.error(f"Upload failed: {e}") raise HTTPException(status_code=500, detail="文件上传失败") version = ClientVersion( distribution_id=dist_id, version_code=version_code, version_name=version_name or version_code, release_notes=release_notes, object_key=object_key, file_size=len(content), platform=platform, ) db.add(version) db.commit() db.refresh(version) return version @router.put("/versions/{version_id}", response_model=ClientVersionResponse) def update_version( version_id: int, data: ClientVersionUpdate, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """更新版本信息(不含文件)""" version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first() if not version: raise HTTPException(status_code=404, detail="版本不存在") _check_distribution_access(db, version.distribution_id, current_user) if data.version_name is not None: version.version_name = data.version_name if data.release_notes is not None: version.release_notes = data.release_notes if data.platform is not None: version.platform = data.platform db.commit() db.refresh(version) return version @router.delete("/versions/{version_id}") def delete_version( version_id: int, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """删除版本""" version = db.query(ClientVersion).filter(ClientVersion.id == version_id).first() if not version: raise HTTPException(status_code=404, detail="版本不存在") _check_distribution_access(db, version.distribution_id, current_user) db.delete(version) db.commit() return {"message": "已删除"} # ========== 自建更新文件(update 桶,公开读)========== @router.get("/{dist_id}/update-files") def list_update_files( dist_id: int, platform: str, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """列出该分发、该平台下 update/ 目录中的文件(MinIO 路径:创建时间戳/平台/update/...)""" dist = _check_distribution_access(db, dist_id, current_user) if not platform or platform.strip().lower() not in {p.value for p in DistributionPlatform}: raise HTTPException(status_code=400, detail="请选择有效平台") platform = platform.strip().lower() ts = int(dist.created_at.timestamp()) files = minio_storage.list_update_files(ts, platform) base_url = minio_storage._update_bucket_public_base_url() for f in files: f["url"] = minio_storage.get_update_file_public_url(f["key"]) return {"base_url": f"{base_url}{ts}/{platform}/update/", "files": files} @router.post("/{dist_id}/update-files") async def upload_update_file( dist_id: int, platform: str = Form(...), file: UploadFile = File(...), db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """上传文件到该分发、该平台的 update/ 目录,透传不改名""" dist = _check_distribution_access(db, dist_id, current_user) if platform.strip().lower() not in {p.value for p in DistributionPlatform}: raise HTTPException(status_code=400, detail="请选择有效平台") platform = platform.strip().lower() filename = file.filename or "file.bin" content = await file.read() ts = int(dist.created_at.timestamp()) object_key = minio_storage.upload_update_file( created_at_timestamp=ts, platform=platform, file_data=content, filename=filename, content_type=file.content_type or "application/octet-stream", ) url = minio_storage.get_update_file_public_url(object_key) return {"object_key": object_key, "url": url} @router.delete("/{dist_id}/update-files") def delete_update_file( dist_id: int, platform: str, key: str, db: Session = Depends(deps.get_db), current_user: User = Depends(deps.get_current_active_user), ): """删除 update/ 目录下的文件,key 为 list 接口返回的完整 object_key""" dist = _check_distribution_access(db, dist_id, current_user) if not platform or not key: raise HTTPException(status_code=400, detail="缺少 platform 或 key") platform = platform.strip().lower() ts = int(dist.created_at.timestamp()) prefix = f"{ts}/{platform}/update/" if not key.startswith(prefix): raise HTTPException(status_code=400, detail="key 不属当前分发/平台") minio_storage.delete_update_file(key) return {"message": "已删除"}