feat: add client latest release api

This commit is contained in:
QiuSW
2026-07-07 08:33:49 +08:00
parent f158a08b9d
commit 5f17235a13
13 changed files with 374 additions and 17 deletions
+188 -1
View File
@@ -8,6 +8,7 @@ from unittest.mock import patch
import requests
from cryptography.fernet import Fernet
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.test import TestCase, override_settings
@@ -19,7 +20,7 @@ from rest_framework.views import APIView
from apps.api.authentication import ApiKeyAuthentication
from apps.api.throttles import GenerateRateThrottle
from apps.api.views import ExternalApiView, ModelsView
from apps.api.views import ClientLatestReleaseView, ExternalApiView, ModelsView
from apps.ai.models import AiModel, ModelAlias
from apps.ai.providers import (
AiCapabilityError,
@@ -41,6 +42,7 @@ from apps.billing.payment_gateways import (
from apps.billing.services import RechargePayment
from apps.moderation.models import SensitiveWord
from apps.moderation.providers.keyword import reset_keyword_matcher_cache
from apps.portal.models import DownloadRelease
from apps.users.models import ApiKey
from apps.users.models import UserWallet
@@ -404,6 +406,191 @@ class ModelsCatalogApiTests(TestCase):
self.assertEqual(item["prices"], [])
class ClientLatestReleaseApiTests(TestCase):
url = "/api/v1/client/releases/latest"
def setUp(self):
cache.clear()
self.client = APIClient()
def create_release(
self,
*,
platform: str = DownloadRelease.Platform.WINDOWS,
version: str = "1.0.0",
is_current: bool = True,
external_url: str = "https://download.example.com/cmhub-desktop.exe",
file_name: str = "",
sha256: str = "a" * 64,
release_notes: str = "首版 Windows 客户端",
) -> DownloadRelease:
return DownloadRelease.objects.create(
platform=platform,
version=version,
is_current=is_current,
external_url=external_url,
file=file_name,
sha256=sha256,
release_notes=release_notes,
)
def test_latest_release_is_public_without_api_key_and_returns_current_release(self):
release = self.create_release(
version="1.2.3",
external_url="https://download.example.com/cmhub-1.2.3.exe",
sha256="b" * 64,
release_notes="修复下载入口并补充 SHA256",
)
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertNotIn(GenerateRateThrottle, ClientLatestReleaseView.throttle_classes)
self.assertEqual(response.data["platform"], "windows")
self.assertEqual(
set(response.data["release"]),
{"version", "download_url", "sha256", "release_notes", "published_at"},
)
self.assertEqual(response.data["release"]["version"], "1.2.3")
self.assertEqual(
response.data["release"]["download_url"],
"https://download.example.com/cmhub-1.2.3.exe",
)
self.assertEqual(response.data["release"]["sha256"], "b" * 64)
self.assertEqual(
response.data["release"]["release_notes"],
"修复下载入口并补充 SHA256",
)
self.assertEqual(
response.data["release"]["published_at"],
timezone.localtime(release.updated_at).isoformat(),
)
def test_latest_release_ignores_web_session_and_does_not_return_user_data(self):
user = get_user_model().objects.create_user(
username="release-session-user",
email="release-session-user@example.com",
password="password",
)
self.create_release()
self.client.force_login(user)
response = self.client.get(self.url, HTTP_AUTHORIZATION="Bearer sk_cmhub_invalid")
self.assertEqual(response.status_code, 200)
response_body = json.dumps(response.data, ensure_ascii=False)
self.assertNotIn(user.username, response_body)
self.assertNotIn(user.email, response_body)
self.assertNotIn("api_key", response_body)
self.assertNotIn("key_hash", response_body)
def test_latest_release_builds_absolute_file_url(self):
self.create_release(
external_url="",
file_name="downloads/cmhub-desktop-1.0.0.exe",
)
response = self.client.get(self.url, secure=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data["release"]["download_url"],
"https://testserver/media/downloads/cmhub-desktop-1.0.0.exe",
)
def test_latest_release_prefers_external_url_over_uploaded_file(self):
self.create_release(
external_url="https://cdn.example.com/cmhub-desktop-1.0.0.exe",
file_name="downloads/local-secret-name.exe",
)
response = self.client.get(self.url, secure=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data["release"]["download_url"],
"https://cdn.example.com/cmhub-desktop-1.0.0.exe",
)
response_body = json.dumps(response.data, ensure_ascii=False)
self.assertNotIn("local-secret-name.exe", response_body)
self.assertNotIn(str(settings.MEDIA_ROOT), response_body)
def test_latest_release_returns_unpublished_when_no_current_release(self):
self.create_release(version="0.9.0", is_current=False)
response = self.client.get(f"{self.url}?platform=windows")
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.data,
{
"platform": "windows",
"release": None,
"message": "暂未发布",
},
)
def test_latest_release_returns_unpublished_when_current_release_has_no_download_url(self):
self.create_release(external_url="", file_name="")
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.data["release"])
self.assertEqual(response.data["message"], "暂未发布")
def test_latest_release_rejects_invalid_platform(self):
response = self.client.get(f"{self.url}?platform=android")
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["error"]["code"], "bad_request")
def test_latest_release_supports_non_windows_platform(self):
self.create_release(
platform=DownloadRelease.Platform.MACOS,
version="2.0.0",
external_url="https://download.example.com/cmhub-2.0.0.dmg",
sha256="c" * 64,
release_notes="macOS 客户端",
)
response = self.client.get(f"{self.url}?platform=macos")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["platform"], "macos")
self.assertEqual(response.data["release"]["version"], "2.0.0")
def test_latest_release_response_does_not_expose_internal_fields(self):
self.create_release(
external_url="",
file_name="downloads/cmhub-desktop-1.0.0.exe",
)
response = self.client.get(self.url, secure=True)
self.assertEqual(response.status_code, 200)
self.assertEqual(set(response.data), {"platform", "release"})
self.assertEqual(
set(response.data["release"]),
{"version", "download_url", "sha256", "release_notes", "published_at"},
)
response_body = json.dumps(response.data, ensure_ascii=False)
for forbidden in (
"id",
"is_current",
"created_at",
"updated_at",
"MEDIA_ROOT",
str(settings.MEDIA_ROOT),
"user",
"email",
"api_key",
"api_key_encrypted",
"model_used",
):
self.assertNotIn(forbidden, response_body)
@override_settings(
PAYMENT_CALLBACK_MODE="mock",
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
+6
View File
@@ -3,6 +3,7 @@ from django.urls import path
from .views import (
AlipayRechargeCallbackView,
BalanceView,
ClientLatestReleaseView,
GenerateImageView,
GenerateTitleView,
ModelsView,
@@ -14,6 +15,11 @@ from .views import (
urlpatterns = [
path("v1/balance", BalanceView.as_view(), name="api-balance"),
path("v1/models", ModelsView.as_view(), name="api-models"),
path(
"v1/client/releases/latest",
ClientLatestReleaseView.as_view(),
name="api-client-release-latest",
),
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"),
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
+67 -1
View File
@@ -6,7 +6,7 @@ from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from rest_framework.views import APIView
@@ -48,6 +48,7 @@ from apps.billing.services import (
get_balance_snapshot,
query_and_apply_recharge_payment,
)
from apps.portal.models import DownloadRelease
logger = logging.getLogger(__name__)
@@ -132,6 +133,71 @@ class ModelsView(ExternalApiView):
)
def _release_unpublished_response(platform: str) -> dict:
return {
"platform": platform,
"release": None,
"message": "暂未发布",
}
def _absolute_download_url(request, release: DownloadRelease) -> str:
download_url = release.download_url
if not download_url:
return ""
if download_url.startswith(("http://", "https://")):
return download_url
return request.build_absolute_uri(download_url)
class ClientLatestReleaseView(APIView):
authentication_classes = ()
permission_classes = (AllowAny,)
def get(self, request):
platform = (
request.query_params.get("platform")
or DownloadRelease.Platform.WINDOWS
).strip().lower()
if platform not in DownloadRelease.Platform.values:
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
release = (
DownloadRelease.objects.filter(platform=platform, is_current=True)
.order_by("-created_at", "-id")
.first()
)
if release is None:
return Response(
_release_unpublished_response(platform),
status=status.HTTP_200_OK,
)
download_url = _absolute_download_url(request, release)
if not download_url:
return Response(
_release_unpublished_response(platform),
status=status.HTTP_200_OK,
)
return Response(
{
"platform": platform,
"release": {
"version": release.version,
"download_url": download_url,
"sha256": release.sha256,
"release_notes": release.release_notes,
"published_at": timezone.localtime(release.updated_at).isoformat(),
},
},
status=status.HTTP_200_OK,
)
class PortalSessionApiView(APIView):
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)