| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- 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,
- )
- from app.core.minio import minio_storage
- 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,
- )
- 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,
- )
- @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,
- )
- @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,
- )
- @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,
- 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)
- total = query.count()
- items = query.order_by(desc(ClientVersion.created_at)).offset(skip).limit(limit).all()
- 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)
- # 检查版本号唯一
- 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": "已删除"}
|