Files
cmhub/apps/api/views.py
T

497 lines
18 KiB
Python
Raw Normal View History

2026-07-03 09:07:21 +08:00
import logging
from django.http import HttpResponse
2026-07-03 09:34:07 +08:00
from django.utils import timezone
2026-07-03 09:07:21 +08:00
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
2026-07-03 09:34:07 +08:00
from rest_framework.authentication import SessionAuthentication
2026-07-02 17:40:26 +08:00
from rest_framework.exceptions import AuthenticationFailed
2026-07-07 08:33:49 +08:00
from rest_framework.permissions import AllowAny, IsAuthenticated
2026-07-02 22:41:37 +08:00
from rest_framework.response import Response
from rest_framework import status
2026-07-02 17:40:26 +08:00
from rest_framework.views import APIView
2026-07-02 09:07:15 +08:00
2026-07-02 17:40:26 +08:00
from apps.api.authentication import ApiKeyAuthentication
from apps.api.errors import api_error
2026-07-02 22:41:37 +08:00
from apps.api.generation import (
ApiRequestError,
2026-07-16 14:13:19 +08:00
analyze_images_response,
2026-07-02 22:41:37 +08:00
generate_image_response,
generate_title_response,
)
2026-07-08 22:08:48 +08:00
from apps.api.image_tasks import (
create_image_generation_task,
task_detail_response,
task_submit_response,
)
from apps.api.models import ImageGenerationTask
2026-07-02 22:41:37 +08:00
from apps.api.serializers import (
2026-07-16 14:13:19 +08:00
AnalyzeImagesRequestSerializer,
2026-07-02 22:41:37 +08:00
GenerateImageRequestSerializer,
GenerateTitleRequestSerializer,
2026-07-03 09:34:07 +08:00
RechargeCreateRequestSerializer,
RechargeStatusRequestSerializer,
2026-07-02 22:41:37 +08:00
)
2026-07-08 22:44:17 +08:00
from apps.api.telemetry import (
log_generation_route_usage,
request_alias,
telemetry_elapsed_ms,
telemetry_start_time,
)
2026-07-03 10:34:37 +08:00
from apps.api.throttles import GenerateRateThrottle, throttle_api_auth_failure
2026-07-04 10:14:16 +08:00
from apps.ai.catalog import get_public_model_catalog
2026-07-03 09:34:07 +08:00
from apps.billing.models import RechargeOrder
2026-07-03 09:07:21 +08:00
from apps.billing.payment_gateways import (
2026-07-03 09:34:07 +08:00
PaymentOrderCreateError,
PaymentQueryUnavailableError,
2026-07-03 09:07:21 +08:00
PaymentVerificationError,
2026-07-03 09:34:07 +08:00
query_payment_order,
2026-07-03 09:07:21 +08:00
verify_alipay_callback,
verify_wechat_callback,
)
2026-07-03 09:34:07 +08:00
from apps.billing.pricing import NoExchangeRateError
2026-07-03 09:07:21 +08:00
from apps.billing.services import (
InvalidRechargeOrderStateError,
RechargeAmountMismatchError,
RechargeCallbackError,
2026-07-03 09:34:07 +08:00
RechargeOrderCreateError,
2026-07-03 09:07:21 +08:00
RechargeOrderNotFoundError,
RechargePayMethodMismatchError,
apply_recharge_payment,
2026-07-03 09:34:07 +08:00
create_recharge_order,
2026-07-03 09:07:21 +08:00
get_balance_snapshot,
2026-07-03 09:34:07 +08:00
query_and_apply_recharge_payment,
2026-07-03 09:07:21 +08:00
)
2026-07-07 08:33:49 +08:00
from apps.portal.models import DownloadRelease
2026-07-03 09:07:21 +08:00
logger = logging.getLogger(__name__)
2026-07-02 17:40:26 +08:00
class ExternalApiView(APIView):
authentication_classes = (ApiKeyAuthentication,)
permission_classes = (IsAuthenticated,)
def permission_denied(self, request, message=None, code=None):
if request.authenticators and not request.successful_authenticator:
2026-07-03 10:34:37 +08:00
throttle_api_auth_failure(request)
2026-07-02 17:40:26 +08:00
raise AuthenticationFailed(api_error("unauthorized", "缺失或无效 API Key"))
super().permission_denied(request, message=message, code=code)
2026-07-02 22:41:37 +08:00
class GenerateTitleView(ExternalApiView):
2026-07-03 10:34:37 +08:00
throttle_classes = (GenerateRateThrottle,)
2026-07-02 22:41:37 +08:00
def post(self, request):
serializer = GenerateTitleRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
try:
data = generate_title_response(
user=request.user,
api_key=request.auth,
request_data=serializer.validated_data,
)
except ApiRequestError as exc:
return Response(exc.as_response_data(), status=exc.http_status)
return Response(data, status=status.HTTP_200_OK)
2026-07-16 14:13:19 +08:00
class AnalyzeImagesView(ExternalApiView):
throttle_classes = (GenerateRateThrottle,)
def post(self, request):
serializer = AnalyzeImagesRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
try:
data = analyze_images_response(
user=request.user,
api_key=request.auth,
request_data=serializer.validated_data,
)
except ApiRequestError as exc:
return Response(exc.as_response_data(), status=exc.http_status)
return Response(data, status=status.HTTP_200_OK)
2026-07-02 22:41:37 +08:00
class GenerateImageView(ExternalApiView):
2026-07-03 10:34:37 +08:00
throttle_classes = (GenerateRateThrottle,)
2026-07-02 22:41:37 +08:00
def post(self, request):
2026-07-08 22:44:17 +08:00
started = telemetry_start_time()
alias = request_alias(request.data)
2026-07-02 22:41:37 +08:00
serializer = GenerateImageRequestSerializer(data=request.data)
if not serializer.is_valid():
2026-07-08 22:44:17 +08:00
log_generation_route_usage(
route_type="sync",
request=request,
alias=alias,
status="error",
latency_ms=telemetry_elapsed_ms(started),
error_code="bad_request",
http_status=status.HTTP_400_BAD_REQUEST,
)
2026-07-02 22:41:37 +08:00
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
2026-07-08 22:44:17 +08:00
alias = serializer.validated_data.get("model") or alias
2026-07-02 22:41:37 +08:00
try:
data = generate_image_response(
user=request.user,
api_key=request.auth,
request_data=serializer.validated_data,
2026-07-08 21:24:59 +08:00
image_url_builder=request.build_absolute_uri,
2026-07-02 22:41:37 +08:00
)
except ApiRequestError as exc:
2026-07-08 22:44:17 +08:00
log_generation_route_usage(
route_type="sync",
request=request,
alias=alias,
status="error",
latency_ms=telemetry_elapsed_ms(started),
error_code=exc.code,
http_status=exc.http_status,
)
2026-07-02 22:41:37 +08:00
return Response(exc.as_response_data(), status=exc.http_status)
2026-07-08 22:44:17 +08:00
log_generation_route_usage(
route_type="sync",
request=request,
alias=data.get("alias") or alias,
status="success",
latency_ms=telemetry_elapsed_ms(started),
http_status=status.HTTP_200_OK,
)
2026-07-02 22:41:37 +08:00
return Response(data, status=status.HTTP_200_OK)
2026-07-03 08:36:30 +08:00
2026-07-08 22:08:48 +08:00
class GenerateImageTaskSubmitView(ExternalApiView):
throttle_classes = (GenerateRateThrottle,)
def post(self, request):
2026-07-08 22:44:17 +08:00
started = telemetry_start_time()
alias = request_alias(request.data)
2026-07-08 22:08:48 +08:00
serializer = GenerateImageRequestSerializer(data=request.data)
if not serializer.is_valid():
2026-07-08 22:44:17 +08:00
log_generation_route_usage(
route_type="async",
request=request,
alias=alias,
status="error",
latency_ms=telemetry_elapsed_ms(started),
error_code="bad_request",
http_status=status.HTTP_400_BAD_REQUEST,
)
2026-07-08 22:08:48 +08:00
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
2026-07-08 22:44:17 +08:00
alias = serializer.validated_data.get("model") or alias
2026-07-08 22:08:48 +08:00
try:
task, _created = create_image_generation_task(
user=request.user,
api_key=request.auth,
request_data=serializer.validated_data,
idempotency_key=request.headers.get("Idempotency-Key", ""),
)
except ApiRequestError as exc:
2026-07-08 22:44:17 +08:00
log_generation_route_usage(
route_type="async",
request=request,
alias=alias,
status="error",
latency_ms=telemetry_elapsed_ms(started),
error_code=exc.code,
http_status=exc.http_status,
)
2026-07-08 22:08:48 +08:00
return Response(exc.as_response_data(), status=exc.http_status)
2026-07-08 22:44:17 +08:00
log_generation_route_usage(
route_type="async",
request=request,
alias=task.call_record.alias,
status="success",
latency_ms=telemetry_elapsed_ms(started),
http_status=status.HTTP_202_ACCEPTED,
)
2026-07-08 22:08:48 +08:00
return Response(task_submit_response(task), status=status.HTTP_202_ACCEPTED)
class GenerateImageTaskDetailView(ExternalApiView):
def get(self, request, task_id):
task = (
ImageGenerationTask.objects.select_related("call_record")
.filter(task_id=task_id, user=request.user)
.first()
)
if task is None:
return Response(
api_error("task_not_found", "图片生成任务不存在"),
status=status.HTTP_404_NOT_FOUND,
)
return Response(task_detail_response(task), status=status.HTTP_200_OK)
2026-07-03 08:36:30 +08:00
class BalanceView(ExternalApiView):
def get(self, request):
balance = get_balance_snapshot(request.user)
username = request.user.get_username()
display_name = request.user.get_full_name() or username
2026-07-03 08:36:30 +08:00
return Response(
{
"user": username,
2026-07-03 08:36:30 +08:00
"points_balance": balance.points_balance,
"account": {
"username": username,
"display_name": display_name,
},
2026-07-03 08:36:30 +08:00
},
status=status.HTTP_200_OK,
)
2026-07-03 09:07:21 +08:00
2026-07-04 10:14:16 +08:00
class ModelsView(ExternalApiView):
def get(self, request):
return Response(
{"models": get_public_model_catalog()},
status=status.HTTP_200_OK,
)
2026-07-07 08:33:49 +08:00
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,
2026-07-08 15:38:59 +08:00
"force_update": release.force_update,
2026-07-13 17:04:59 +08:00
"size_bytes": release.size_bytes,
2026-07-07 08:33:49 +08:00
"published_at": timezone.localtime(release.updated_at).isoformat(),
},
},
status=status.HTTP_200_OK,
)
2026-07-03 09:34:07 +08:00
class PortalSessionApiView(APIView):
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
def _recharge_order_response(order: RechargeOrder) -> dict:
is_expired = bool(
order.status == RechargeOrder.Status.PENDING
and order.expires_at is not None
and order.expires_at <= timezone.now()
)
return {
"order_no": order.order_no,
"amount": f"{order.amount_money:.2f}",
"currency": order.currency,
"exchange_rate": f"{order.exchange_rate:.4f}",
"points_granted": order.points_granted,
"pay_method": order.pay_method,
"status": order.status,
"code_url": order.code_url,
"expires_at": order.expires_at.isoformat() if order.expires_at else None,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"is_expired": is_expired,
}
class RechargeCreateView(PortalSessionApiView):
def post(self, request):
serializer = RechargeCreateRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
try:
order = create_recharge_order(
user=request.user,
amount=serializer.validated_data["amount"],
pay_method=serializer.validated_data["pay_method"],
)
except NoExchangeRateError:
return Response(
api_error("no_exchange_rate", "未配置当前币种汇率"),
status=status.HTTP_400_BAD_REQUEST,
)
except RechargeOrderCreateError:
return Response(
api_error("bad_request", "充值下单参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
except PaymentOrderCreateError:
return Response(
api_error("payment_order_create_failed", "支付下单失败"),
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(_recharge_order_response(order), status=status.HTTP_201_CREATED)
class RechargeStatusView(PortalSessionApiView):
def get(self, request):
serializer = RechargeStatusRequestSerializer(data=request.query_params)
if not serializer.is_valid():
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
order_no = serializer.validated_data["order_no"]
order = RechargeOrder.objects.filter(
user=request.user,
order_no=order_no,
).first()
if order is None:
return Response(
api_error("order_not_found", "充值订单不存在"),
status=status.HTTP_404_NOT_FOUND,
)
if order.status == RechargeOrder.Status.PENDING:
try:
result = query_and_apply_recharge_payment(order.order_no, query_payment_order)
order = result.order
except (PaymentQueryUnavailableError, PaymentOrderCreateError):
order.refresh_from_db()
except RechargeCallbackError as exc:
logger.warning(
"Rejected active recharge query result for %s: %s",
order.order_no,
exc.__class__.__name__,
)
return Response(
api_error(exc.code, "支付查单结果与本地订单不一致"),
status=status.HTTP_400_BAD_REQUEST,
)
2026-07-06 08:56:30 +08:00
except Exception as exc: # pragma: no cover - external gateway/runtime.
logger.warning(
"Active recharge query failed for %s: %s",
order.order_no,
exc.__class__.__name__,
)
order.refresh_from_db()
2026-07-03 09:34:07 +08:00
return Response(_recharge_order_response(order), status=status.HTTP_200_OK)
2026-07-03 09:07:21 +08:00
class RechargeCallbackView(APIView):
authentication_classes = ()
permission_classes = ()
@staticmethod
def _error_response(exc: Exception):
if isinstance(exc, PaymentVerificationError):
return (
api_error("signature_invalid", "支付回调验签失败"),
status.HTTP_400_BAD_REQUEST,
)
if isinstance(exc, RechargeAmountMismatchError):
return (
api_error("amount_mismatch", "支付回调金额与本地订单金额不一致"),
status.HTTP_400_BAD_REQUEST,
)
if isinstance(exc, RechargeCallbackError):
return api_error(exc.code, "支付回调处理失败"), status.HTTP_400_BAD_REQUEST
return api_error("bad_request", "支付回调处理失败"), status.HTTP_400_BAD_REQUEST
@method_decorator(csrf_exempt, name="dispatch")
class WechatRechargeCallbackView(RechargeCallbackView):
def post(self, request):
try:
payment = verify_wechat_callback(request.headers, request.body)
apply_recharge_payment(payment)
except (
PaymentVerificationError,
RechargeAmountMismatchError,
InvalidRechargeOrderStateError,
RechargeOrderNotFoundError,
RechargePayMethodMismatchError,
) as exc:
logger.warning("Rejected WeChat recharge callback: %s", exc.__class__.__name__)
data, http_status = self._error_response(exc)
return Response(data, status=http_status)
return Response({"code": "SUCCESS", "message": "成功"}, status=status.HTTP_200_OK)
@method_decorator(csrf_exempt, name="dispatch")
class AlipayRechargeCallbackView(RechargeCallbackView):
def post(self, request):
try:
payload = request.data.dict() if hasattr(request.data, "dict") else dict(request.data)
payment = verify_alipay_callback(payload)
apply_recharge_payment(payment)
except (
PaymentVerificationError,
RechargeAmountMismatchError,
InvalidRechargeOrderStateError,
RechargeOrderNotFoundError,
RechargePayMethodMismatchError,
) as exc:
logger.warning("Rejected Alipay recharge callback: %s", exc.__class__.__name__)
return HttpResponse("fail", status=status.HTTP_400_BAD_REQUEST)
return HttpResponse("success", content_type="text/plain", status=status.HTTP_200_OK)