feat: harden phase 3 api security
This commit is contained in:
@@ -18,6 +18,15 @@ MYSQL_WRITE_TIMEOUT=120
|
||||
# AI key encryption
|
||||
AI_KEY_ENCRYPTION_KEY=base64-fernet-key
|
||||
|
||||
# API safety
|
||||
API_GENERATE_THROTTLE_RATE=60/min
|
||||
API_AUTH_FAILURE_THROTTLE_RATE=30/min
|
||||
IMAGE_URL_MAX_BYTES=10485760
|
||||
IMAGE_URL_MAX_REDIRECTS=3
|
||||
IMAGE_URL_CONNECT_TIMEOUT_SECONDS=10
|
||||
IMAGE_URL_READ_TIMEOUT_SECONDS=60
|
||||
RECHARGE_MAX_AMOUNT_CNY=100000.00
|
||||
|
||||
# Payment callback verification
|
||||
PAYMENT_CALLBACK_MODE=mock
|
||||
PAYMENT_MOCK_CALLBACK_SECRET=change-me-mock-callback-secret
|
||||
|
||||
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
|
||||
|
||||
## 当前状态
|
||||
|
||||
Phase 2 计费核心已完成,T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调与 T-305 扫码充值下单 + 轮询已落地:对外 API 可用 `Authorization: Bearer <API_KEY>` 调用生成能力或查询点数余额;用户端 session 流程可创建充值订单、获取二维码票据并轮询订单状态,充值回调按订单幂等入账。Claude Code 审核后新增 T-306 安全加固,下一步先修 `image_url` SSRF 等上线前风险,再进入 Phase 4 用户端。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306:对外 API 可用 `Authorization: Bearer <API_KEY>` 调用生成能力或查询点数余额;用户端 session 流程可创建充值订单、获取二维码票据并轮询订单状态,充值回调按订单幂等入账;`image_url` 已做 SSRF 防护,生成/认证失败有限流,充值下单有单笔金额上限。下一步进入 Phase 4 用户端,先做 T-501 注册 / 登录(allauth)。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
|
||||
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_
|
||||
from rest_framework.exceptions import AuthenticationFailed, PermissionDenied
|
||||
|
||||
from apps.api.errors import api_error
|
||||
from apps.api.throttles import throttle_api_auth_failure
|
||||
from apps.users.models import ApiKey
|
||||
|
||||
|
||||
@@ -19,24 +20,24 @@ class ApiKeyAuthentication(BaseAuthentication):
|
||||
try:
|
||||
header = raw_header.decode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise self.authentication_failed() from exc
|
||||
raise self.authentication_failed(request) from exc
|
||||
|
||||
parts = header.split()
|
||||
if len(parts) != 2 or parts[0].lower() != self.keyword.lower():
|
||||
raise self.authentication_failed()
|
||||
raise self.authentication_failed(request)
|
||||
|
||||
raw_key = parts[1]
|
||||
if not raw_key:
|
||||
raise self.authentication_failed()
|
||||
raise self.authentication_failed(request)
|
||||
|
||||
key_hash = ApiKey.hash_key(raw_key)
|
||||
try:
|
||||
api_key = ApiKey.objects.select_related("user").get(key_hash=key_hash)
|
||||
except ApiKey.DoesNotExist as exc:
|
||||
raise self.authentication_failed() from exc
|
||||
raise self.authentication_failed(request) from exc
|
||||
|
||||
if not api_key.matches_key(raw_key):
|
||||
raise self.authentication_failed()
|
||||
raise self.authentication_failed(request)
|
||||
|
||||
if not api_key.is_active_key:
|
||||
raise PermissionDenied(
|
||||
@@ -57,5 +58,6 @@ class ApiKeyAuthentication(BaseAuthentication):
|
||||
return self.keyword
|
||||
|
||||
@staticmethod
|
||||
def authentication_failed() -> AuthenticationFailed:
|
||||
def authentication_failed(request) -> AuthenticationFailed:
|
||||
throttle_api_auth_failure(request)
|
||||
return AuthenticationFailed(api_error("unauthorized", "缺失或无效 API Key"))
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework.exceptions import Throttled
|
||||
from rest_framework.views import exception_handler
|
||||
|
||||
from apps.api.errors import api_error
|
||||
|
||||
|
||||
def api_exception_handler(exc, context):
|
||||
response = exception_handler(exc, context)
|
||||
if response is not None and isinstance(exc, Throttled):
|
||||
response.data = api_error("rate_limited", "请求过于频繁,请稍后再试")
|
||||
return response
|
||||
+120
-5
@@ -2,11 +2,15 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import ipaddress
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
from typing import Any, Mapping
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from rest_framework import status
|
||||
|
||||
from apps.ai.aliases import (
|
||||
@@ -47,6 +51,10 @@ class ImageInput:
|
||||
filename: str = "image.png"
|
||||
|
||||
|
||||
IMAGE_URL_ALLOWED_SCHEMES = {"http", "https"}
|
||||
IMAGE_URL_CHUNK_SIZE = 64 * 1024
|
||||
|
||||
|
||||
def generate_title_response(*, user, api_key, request_data: Mapping[str, Any]) -> dict:
|
||||
prompt = request_data["prompt"]
|
||||
alias = request_data.get("model") or None
|
||||
@@ -309,21 +317,128 @@ def decode_image_input(value: str) -> ImageInput:
|
||||
def download_image_input(url: str) -> ImageInput:
|
||||
session = requests.Session()
|
||||
session.trust_env = False
|
||||
current_url = validated_image_url(url)
|
||||
max_redirects = max(0, int(getattr(settings, "IMAGE_URL_MAX_REDIRECTS", 3)))
|
||||
|
||||
for redirect_count in range(max_redirects + 1):
|
||||
try:
|
||||
response = session.get(url, timeout=(10, 60))
|
||||
response.raise_for_status()
|
||||
response = session.get(
|
||||
current_url,
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
timeout=(
|
||||
int(getattr(settings, "IMAGE_URL_CONNECT_TIMEOUT_SECONDS", 10)),
|
||||
int(getattr(settings, "IMAGE_URL_READ_TIMEOUT_SECONDS", 60)),
|
||||
),
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ApiRequestError("bad_request", "无法读取 image_url", status.HTTP_400_BAD_REQUEST) from exc
|
||||
content_type = response.headers.get("Content-Type", "image/png").split(";", 1)[0]
|
||||
|
||||
try:
|
||||
if is_redirect_response(response):
|
||||
if redirect_count >= max_redirects:
|
||||
raise ApiRequestError("bad_request", "image_url 重定向次数过多", status.HTTP_400_BAD_REQUEST)
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
raise ApiRequestError("bad_request", "image_url 重定向无效", status.HTTP_400_BAD_REQUEST)
|
||||
current_url = validated_image_url(urljoin(current_url, location))
|
||||
continue
|
||||
|
||||
response.raise_for_status()
|
||||
content_type = response.headers.get("Content-Type", "image/png").split(";", 1)[0].strip().lower()
|
||||
if not content_type.startswith("image/"):
|
||||
raise ApiRequestError("bad_request", "image_url 不是图片资源", status.HTTP_400_BAD_REQUEST)
|
||||
if not response.content:
|
||||
image = read_limited_image_response(response)
|
||||
if not image:
|
||||
raise ApiRequestError("bad_request", "image_url 图片内容为空", status.HTTP_400_BAD_REQUEST)
|
||||
return ImageInput(
|
||||
data=response.content,
|
||||
data=image,
|
||||
mime_type=content_type,
|
||||
filename=filename_for_mime(content_type),
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ApiRequestError("bad_request", "无法读取 image_url", status.HTTP_400_BAD_REQUEST) from exc
|
||||
finally:
|
||||
close = getattr(response, "close", None)
|
||||
if close:
|
||||
close()
|
||||
|
||||
raise ApiRequestError("bad_request", "image_url 重定向次数过多", status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
def validated_image_url(url: str) -> str:
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ApiRequestError("bad_request", "image_url 地址无效", status.HTTP_400_BAD_REQUEST) from exc
|
||||
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme not in IMAGE_URL_ALLOWED_SCHEMES or not parsed.hostname:
|
||||
raise ApiRequestError("bad_request", "image_url 地址不允许", status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
default_port = 443 if scheme == "https" else 80
|
||||
validate_image_url_host(parsed.hostname, port or default_port)
|
||||
return parsed.geturl()
|
||||
|
||||
|
||||
def validate_image_url_host(hostname: str, port: int) -> None:
|
||||
try:
|
||||
resolved = socket.getaddrinfo(hostname, port, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise ApiRequestError("bad_request", "无法读取 image_url", status.HTTP_400_BAD_REQUEST) from exc
|
||||
|
||||
addresses = {item[4][0] for item in resolved if item and item[4]}
|
||||
if not addresses:
|
||||
raise ApiRequestError("bad_request", "无法读取 image_url", status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
for address in addresses:
|
||||
if image_url_ip_is_blocked(address):
|
||||
raise ApiRequestError("bad_request", "image_url 地址不允许", status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
def image_url_ip_is_blocked(address: str) -> bool:
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
return True
|
||||
if ip.version == 6 and ip.ipv4_mapped is not None:
|
||||
ip = ip.ipv4_mapped
|
||||
return (
|
||||
not ip.is_global
|
||||
or ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
or ip.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def is_redirect_response(response) -> bool:
|
||||
return 300 <= int(getattr(response, "status_code", 0)) < 400
|
||||
|
||||
|
||||
def read_limited_image_response(response) -> bytes:
|
||||
max_bytes = max(1, int(getattr(settings, "IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024)))
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length:
|
||||
try:
|
||||
if int(content_length) > max_bytes:
|
||||
raise ApiRequestError("bad_request", "image_url 图片过大", status.HTTP_400_BAD_REQUEST)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
chunks = []
|
||||
total = 0
|
||||
for chunk in response.iter_content(chunk_size=IMAGE_URL_CHUNK_SIZE):
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise ApiRequestError("bad_request", "image_url 图片过大", status.HTTP_400_BAD_REQUEST)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def filename_for_mime(mime_type: str) -> str:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.billing.models import RechargeOrder
|
||||
@@ -57,6 +58,12 @@ class RechargeCreateRequestSerializer(serializers.Serializer):
|
||||
)
|
||||
pay_method = serializers.ChoiceField(choices=RechargeOrder.PayMethod.values)
|
||||
|
||||
def validate_amount(self, value):
|
||||
max_amount = Decimal(str(settings.RECHARGE_MAX_AMOUNT_CNY))
|
||||
if value > max_amount:
|
||||
raise serializers.ValidationError(f"单笔充值金额不能超过 {max_amount:.2f} CNY")
|
||||
return value
|
||||
|
||||
|
||||
class RechargeStatusRequestSerializer(serializers.Serializer):
|
||||
order_no = serializers.CharField(
|
||||
|
||||
@@ -6,13 +6,16 @@ from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests
|
||||
from cryptography.fernet import Fernet
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import path
|
||||
from django.utils import timezone
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.api.authentication import ApiKeyAuthentication
|
||||
from apps.api.views import ExternalApiView
|
||||
@@ -52,8 +55,14 @@ class AuthenticatedEchoView(ExternalApiView):
|
||||
)
|
||||
|
||||
|
||||
class DefaultAuthProbeView(APIView):
|
||||
def get(self, request):
|
||||
return Response({"ok": True})
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("api/test-auth/", AuthenticatedEchoView.as_view()),
|
||||
path("api/default-auth/", DefaultAuthProbeView.as_view()),
|
||||
]
|
||||
|
||||
|
||||
@@ -62,6 +71,7 @@ class ApiKeyAuthenticationTests(TestCase):
|
||||
url = "/api/test-auth/"
|
||||
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username=f"api-user-{suffix}",
|
||||
@@ -77,6 +87,13 @@ class ApiKeyAuthenticationTests(TestCase):
|
||||
def test_external_api_view_only_uses_api_key_authentication(self):
|
||||
self.assertEqual(AuthenticatedEchoView.authentication_classes, (ApiKeyAuthentication,))
|
||||
|
||||
def test_global_drf_default_does_not_accept_web_session_authentication(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.get("/api/default-auth/")
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_valid_bearer_key_authenticates_user_and_api_key(self):
|
||||
response = self.client.get(self.url, **self.auth_header())
|
||||
|
||||
@@ -101,6 +118,23 @@ class ApiKeyAuthenticationTests(TestCase):
|
||||
self.assertEqual(response["WWW-Authenticate"], "Bearer")
|
||||
self.assertEqual(response.data["error"]["code"], "unauthorized")
|
||||
|
||||
@override_settings(API_AUTH_FAILURE_THROTTLE_RATE="1/min")
|
||||
def test_invalid_api_key_failures_are_throttled_by_ip(self):
|
||||
first = self.client.get(
|
||||
self.url,
|
||||
**self.auth_header("sk_cmhub_invalid"),
|
||||
REMOTE_ADDR="198.51.100.21",
|
||||
)
|
||||
second = self.client.get(
|
||||
self.url,
|
||||
**self.auth_header("sk_cmhub_invalid"),
|
||||
REMOTE_ADDR="198.51.100.21",
|
||||
)
|
||||
|
||||
self.assertEqual(first.status_code, 401)
|
||||
self.assertEqual(second.status_code, 429)
|
||||
self.assertEqual(second.data["error"]["code"], "rate_limited")
|
||||
|
||||
def test_malformed_authorization_header_returns_401(self):
|
||||
response = self.client.get(self.url, HTTP_AUTHORIZATION=f"Token {self.raw_key}")
|
||||
|
||||
@@ -468,6 +502,20 @@ class RechargeCreateStatusApiTests(TestCase):
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
|
||||
|
||||
@override_settings(RECHARGE_MAX_AMOUNT_CNY="100.00")
|
||||
def test_recharge_create_rejects_amount_above_configured_maximum(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
response = self.client.post(
|
||||
self.create_url,
|
||||
{"amount": "100.01", "pay_method": "weixin"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "bad_request")
|
||||
self.assertFalse(RechargeOrder.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_recharge_create_supports_alipay_mock_qr_code(self):
|
||||
self.client.force_login(self.user)
|
||||
|
||||
@@ -578,9 +626,33 @@ class FakeGenerationProvider:
|
||||
)
|
||||
|
||||
|
||||
class FakeImageUrlResponse:
|
||||
def __init__(self, *, status_code=200, headers=None, chunks=()):
|
||||
self.status_code = status_code
|
||||
self.headers = headers or {}
|
||||
self._chunks = list(chunks)
|
||||
self.closed = False
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise requests.HTTPError("image_url request failed", response=self)
|
||||
|
||||
def iter_content(self, chunk_size=1):
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def dns_result(address: str):
|
||||
return [(None, None, None, "", (address, 443))]
|
||||
|
||||
|
||||
@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY)
|
||||
class GenerateApiTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
self.media_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.media_dir.cleanup)
|
||||
@@ -656,6 +728,12 @@ class GenerateApiTests(TestCase):
|
||||
with patch("apps.api.generation.get_provider", return_value=provider or self.provider):
|
||||
return self.client.post(path, payload, format="json", **self.auth_header())
|
||||
|
||||
def assert_generation_not_charged(self):
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
self.assertFalse(CallRecord.objects.filter(user=self.user).exists())
|
||||
self.assertFalse(PointsLedger.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_generate_title_uses_default_alias_charges_points_and_writes_call_record(self):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
@@ -723,6 +801,147 @@ class GenerateApiTests(TestCase):
|
||||
self.assertEqual(call.result_summary, "image_bytes=21")
|
||||
self.assertNotIn("SECRET_RAW", call.result_ref + call.result_summary)
|
||||
|
||||
def test_generate_image_downloads_safe_image_url(self):
|
||||
response = FakeImageUrlResponse(
|
||||
headers={"Content-Type": "image/jpeg"},
|
||||
chunks=[b"remote-image"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("apps.api.generation.socket.getaddrinfo", return_value=dns_result("93.184.216.34")),
|
||||
patch("apps.api.generation.requests.Session.get", return_value=response),
|
||||
):
|
||||
result = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "生成图片",
|
||||
"model": self.image_alias,
|
||||
"image_url": "https://safe.example.com/input.jpg",
|
||||
"resolution": "1K",
|
||||
"aspect_ratio": "1:1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual(self.provider.image_calls[0]["image"], b"remote-image")
|
||||
self.assertEqual(self.provider.image_calls[0]["image_mime_type"], "image/jpeg")
|
||||
self.assertTrue(response.closed)
|
||||
|
||||
def test_image_url_rejects_loopback_address_without_fetch_or_charge(self):
|
||||
with (
|
||||
patch("apps.api.generation.socket.getaddrinfo", return_value=dns_result("127.0.0.1")),
|
||||
patch("apps.api.generation.requests.Session.get") as image_get,
|
||||
):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "生成图片",
|
||||
"model": self.image_alias,
|
||||
"image_url": "http://127.0.0.1/private.png",
|
||||
"resolution": "1K",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "bad_request")
|
||||
image_get.assert_not_called()
|
||||
self.assert_generation_not_charged()
|
||||
|
||||
def test_image_url_rejects_cloud_metadata_address_without_fetch_or_charge(self):
|
||||
with (
|
||||
patch("apps.api.generation.socket.getaddrinfo", return_value=dns_result("169.254.169.254")),
|
||||
patch("apps.api.generation.requests.Session.get") as image_get,
|
||||
):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
{
|
||||
"prompt": "生成标题",
|
||||
"model": self.title_alias,
|
||||
"image_url": "http://169.254.169.254/latest/meta-data/",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "bad_request")
|
||||
image_get.assert_not_called()
|
||||
self.assert_generation_not_charged()
|
||||
|
||||
def test_image_url_rejects_redirect_to_private_address_without_charge(self):
|
||||
def fake_getaddrinfo(host, port, *args, **kwargs):
|
||||
if host == "safe.example.com":
|
||||
return dns_result("93.184.216.34")
|
||||
return dns_result("127.0.0.1")
|
||||
|
||||
redirect = FakeImageUrlResponse(
|
||||
status_code=302,
|
||||
headers={"Location": "http://127.0.0.1/private.png"},
|
||||
)
|
||||
|
||||
with (
|
||||
patch("apps.api.generation.socket.getaddrinfo", side_effect=fake_getaddrinfo),
|
||||
patch("apps.api.generation.requests.Session.get", return_value=redirect) as image_get,
|
||||
):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "生成图片",
|
||||
"model": self.image_alias,
|
||||
"image_url": "https://safe.example.com/input.png",
|
||||
"resolution": "1K",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "bad_request")
|
||||
self.assertEqual(image_get.call_count, 1)
|
||||
self.assertTrue(redirect.closed)
|
||||
self.assert_generation_not_charged()
|
||||
|
||||
@override_settings(IMAGE_URL_MAX_BYTES=4)
|
||||
def test_image_url_rejects_oversized_response_without_charge(self):
|
||||
oversized = FakeImageUrlResponse(
|
||||
headers={"Content-Type": "image/png"},
|
||||
chunks=[b"1234", b"5"],
|
||||
)
|
||||
|
||||
with (
|
||||
patch("apps.api.generation.socket.getaddrinfo", return_value=dns_result("93.184.216.34")),
|
||||
patch("apps.api.generation.requests.Session.get", return_value=oversized),
|
||||
):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "生成图片",
|
||||
"model": self.image_alias,
|
||||
"image_url": "https://safe.example.com/input.png",
|
||||
"resolution": "1K",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "bad_request")
|
||||
self.assertTrue(oversized.closed)
|
||||
self.assert_generation_not_charged()
|
||||
|
||||
@override_settings(API_GENERATE_THROTTLE_RATE="1/min")
|
||||
def test_generate_endpoint_is_throttled_by_api_key_without_extra_charge(self):
|
||||
first = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
{"prompt": "生成标题", "model": self.title_alias},
|
||||
)
|
||||
second = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
{"prompt": "生成标题", "model": self.title_alias},
|
||||
)
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(second.status_code, 429)
|
||||
self.assertEqual(second.data["error"]["code"], "rate_limited")
|
||||
self.assertEqual(len(self.provider.text_calls), 1)
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 98)
|
||||
self.assertEqual(CallRecord.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_insufficient_points_returns_402_without_calling_provider_or_writing_call(self):
|
||||
self.wallet.points_balance = 1
|
||||
self.wallet.save(update_fields=("points_balance", "updated_at"))
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.conf import settings
|
||||
from rest_framework.exceptions import Throttled
|
||||
from rest_framework.throttling import SimpleRateThrottle
|
||||
|
||||
|
||||
class SettingsRateThrottle(SimpleRateThrottle):
|
||||
setting_name = ""
|
||||
default_rate = ""
|
||||
|
||||
def get_rate(self):
|
||||
return getattr(settings, self.setting_name, self.default_rate)
|
||||
|
||||
|
||||
class GenerateRateThrottle(SettingsRateThrottle):
|
||||
scope = "generate"
|
||||
setting_name = "API_GENERATE_THROTTLE_RATE"
|
||||
default_rate = "60/min"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
if request.auth is not None:
|
||||
ident = f"key:{request.auth.pk}"
|
||||
elif request.user and request.user.is_authenticated:
|
||||
ident = f"user:{request.user.pk}"
|
||||
else:
|
||||
ident = self.get_ident(request)
|
||||
return self.cache_format % {"scope": self.scope, "ident": ident}
|
||||
|
||||
|
||||
class ApiAuthFailureThrottle(SettingsRateThrottle):
|
||||
scope = "api_auth_failure"
|
||||
setting_name = "API_AUTH_FAILURE_THROTTLE_RATE"
|
||||
default_rate = "30/min"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
return self.cache_format % {
|
||||
"scope": self.scope,
|
||||
"ident": self.get_ident(request),
|
||||
}
|
||||
|
||||
|
||||
def throttle_api_auth_failure(request) -> None:
|
||||
throttle = ApiAuthFailureThrottle()
|
||||
if not throttle.allow_request(request, None):
|
||||
raise Throttled(wait=throttle.wait())
|
||||
@@ -24,6 +24,7 @@ from apps.api.serializers import (
|
||||
RechargeCreateRequestSerializer,
|
||||
RechargeStatusRequestSerializer,
|
||||
)
|
||||
from apps.api.throttles import GenerateRateThrottle, throttle_api_auth_failure
|
||||
from apps.billing.models import RechargeOrder
|
||||
from apps.billing.payment_gateways import (
|
||||
PaymentOrderCreateError,
|
||||
@@ -56,11 +57,14 @@ class ExternalApiView(APIView):
|
||||
|
||||
def permission_denied(self, request, message=None, code=None):
|
||||
if request.authenticators and not request.successful_authenticator:
|
||||
throttle_api_auth_failure(request)
|
||||
raise AuthenticationFailed(api_error("unauthorized", "缺失或无效 API Key"))
|
||||
super().permission_denied(request, message=message, code=code)
|
||||
|
||||
|
||||
class GenerateTitleView(ExternalApiView):
|
||||
throttle_classes = (GenerateRateThrottle,)
|
||||
|
||||
def post(self, request):
|
||||
serializer = GenerateTitleRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
@@ -80,6 +84,8 @@ class GenerateTitleView(ExternalApiView):
|
||||
|
||||
|
||||
class GenerateImageView(ExternalApiView):
|
||||
throttle_classes = (GenerateRateThrottle,)
|
||||
|
||||
def post(self, request):
|
||||
serializer = GenerateImageRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
|
||||
@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
@@ -50,6 +51,13 @@ def env_int(name: str, default: int) -> int:
|
||||
return int(value)
|
||||
|
||||
|
||||
def env_decimal(name: str, default: str) -> Decimal:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
value = default
|
||||
return Decimal(value)
|
||||
|
||||
|
||||
load_dotenv(BASE_DIR / ".env")
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret.
|
||||
@@ -82,6 +90,14 @@ ALIPAY_DEBUG = env_bool("ALIPAY_DEBUG", False)
|
||||
|
||||
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost,testserver")
|
||||
|
||||
API_GENERATE_THROTTLE_RATE = os.environ.get("API_GENERATE_THROTTLE_RATE", "60/min")
|
||||
API_AUTH_FAILURE_THROTTLE_RATE = os.environ.get("API_AUTH_FAILURE_THROTTLE_RATE", "30/min")
|
||||
IMAGE_URL_MAX_BYTES = env_int("IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024)
|
||||
IMAGE_URL_MAX_REDIRECTS = env_int("IMAGE_URL_MAX_REDIRECTS", 3)
|
||||
IMAGE_URL_CONNECT_TIMEOUT_SECONDS = env_int("IMAGE_URL_CONNECT_TIMEOUT_SECONDS", 10)
|
||||
IMAGE_URL_READ_TIMEOUT_SECONDS = env_int("IMAGE_URL_READ_TIMEOUT_SECONDS", 60)
|
||||
RECHARGE_MAX_AMOUNT_CNY = env_decimal("RECHARGE_MAX_AMOUNT_CNY", "100000.00")
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
@@ -100,6 +116,18 @@ INSTALLED_APPS = [
|
||||
'apps.api',
|
||||
]
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [],
|
||||
"DEFAULT_PERMISSION_CLASSES": [
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
],
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"generate": API_GENERATE_THROTTLE_RATE,
|
||||
"api_auth_failure": API_AUTH_FAILURE_THROTTLE_RATE,
|
||||
},
|
||||
"EXCEPTION_HANDLER": "apps.api.exceptions.api_exception_handler",
|
||||
}
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
|
||||
@@ -38,15 +38,15 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目处于:**Phase 3 对外 API 安全加固**。Phase 2 计费核心已完成到 T-204;Phase 3 功能已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调与 T-305 扫码充值下单 + 轮询;Claude Code 审核后新增 T-306 安全加固。下一步进入 T-306,先修 `image_url` SSRF,再处理 DRF 默认认证、限流和充值金额上限。
|
||||
当前项目处于:**Phase 4 用户端起步**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固。下一步进入 T-501 注册 / 登录(allauth)。
|
||||
|
||||
优先路径:
|
||||
|
||||
1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。
|
||||
2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104/T-105 已完成 provider 层、模型配置表、别名解析、配置审计、录制标题/图片 smoke 与审核修补;真实图片同步耗时待配置 Fernet 主密钥、AiModel/ModelAlias 与真实上游后在 T-302/T-403 前补测。
|
||||
3. Phase 2:计费核心 —— T-201/T-202/T-203 已完成 UserWallet/ApiKey/PointsLedger/CallRecord、计费规则、汇率、计费计算、并发安全扣点与失败退点。
|
||||
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询已完成;下一步 T-306 安全加固。
|
||||
5. Phase 4:用户端(Django 模板 SSR)—— T-306 完成后进入 T-501 注册登录,然后 API Key 管理、个人中心/记录页、充值页。
|
||||
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。
|
||||
5. Phase 4:用户端(Django 模板 SSR)—— 下一步 T-501 注册登录,然后 API Key 管理、个人中心/记录页、充值页。
|
||||
6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
@@ -46,6 +46,8 @@ T-304 已实现 `/api/v1/recharge/callback/wechat` 与 `/api/v1/recharge/callbac
|
||||
|
||||
T-305 已实现 `/api/v1/recharge/create` 与 `/api/v1/recharge/status`:两个端点走 `SessionAuthentication + IsAuthenticated`,属于用户端 session + CSRF 流程,不接受 API Key;创建订单时调用 `apps.billing.services.create_recharge_order()` 锁定当前汇率和预计到账点数,再经 `apps.billing.payment_gateways.create_payment_order()` 获取微信 native `code_url` 或支付宝当面付 `qr_code`;状态查询只允许订单所属用户访问,并在 pending 时尝试 `query_payment_order()` 主动查单补入账,查单不可用时保持 pending 等回调。
|
||||
|
||||
T-306 已实现对外 API 安全加固:`download_image_input()` 在请求前校验 `image_url` 协议与解析后的 IP,只允许公网 `http` / `https`,拒绝私有、回环、链路本地、保留、组播、未指定地址;重定向由服务端手动跟随并逐跳重新校验,响应按 `IMAGE_URL_MAX_BYTES` 流式限长读取。`REST_FRAMEWORK` 全局默认认证为空、默认权限为 `IsAuthenticated`,外部 API 和用户端 session API 必须显式声明认证类;生成接口挂 `GenerateRateThrottle`,认证失败挂 IP 限流;充值下单通过 `RECHARGE_MAX_AMOUNT_CNY` 控制单笔上限。
|
||||
|
||||
**计费层(`apps/billing`)**
|
||||
|
||||
- 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。
|
||||
@@ -344,6 +346,7 @@ CREATE TABLE call_record (
|
||||
| 注册滥用 | 自助注册被批量刷 | 邮箱验证 + 生成接口限流(DRF throttle);**注册不送点数**,无免费额度可薅 |
|
||||
| Web/API 抢 worker | 图片同步长请求占满 worker、拖慢用户端页面 | 单体下按路径把 `/api/generate/*` 与用户端页面**分流到不同 gunicorn/worker 池**(见 5.1) |
|
||||
| 充错账户 | 扫码订单未绑定发起用户 | 订单创建即绑定 `user`;回调按 `order_no` 定位订单→其 user 入账 |
|
||||
| SSRF / 任意 URL 下载 | `image_url` 让服务端请求调用方指定地址 | 仅允许公网 http/https;请求前解析 IP 并拒绝内网/回环/链路本地/保留地址;重定向逐跳校验;流式读取并限制大小 |
|
||||
|
||||
高风险功能(计费扣点、充值回调)应先做最小原型并写并发/幂等测试,再接入完整流程。
|
||||
|
||||
@@ -361,8 +364,9 @@ CREATE TABLE call_record (
|
||||
1. Django + DRF 骨架可运行,django-admin 可登录(Phase 0)。
|
||||
2. 移植并跑通一次 AI 调用(标题 / 图片)原型(Phase 1)。
|
||||
3. 计费:点数扣减(并发安全)+ 计费规则 + 调用记录(Phase 2,T-201~T-203 已完成)。
|
||||
4. 对外 API 鉴权 + 余额查询 + 充值回调(验签、幂等)入账(Phase 3)。
|
||||
5. 运营后台完善、完整验收、部署(Phase 4)。
|
||||
4. 对外 API 鉴权 + 余额查询 + 充值下单/回调 + 安全加固(Phase 3,已完成到 T-306)。
|
||||
5. 用户端注册登录、API Key 管理、个人中心、充值页(Phase 4)。
|
||||
6. 运营后台完善、完整验收、部署(Phase 5)。
|
||||
|
||||
## 七、项目结构建议
|
||||
|
||||
|
||||
+2
-2
@@ -55,7 +55,7 @@
|
||||
| T-303 | 余额查询接口 | T-301 | 返回余额等于流水累加;含测试 | DONE |
|
||||
| T-304 | 充值回调(微信/支付宝验签 + 幂等入账) | T-202 | 两端点 `@csrf_exempt`;微信 SDK 验签解密、支付宝 SDK verify;验签失败不入账;同一 order_no 重复回调只入账一次;校验回调金额与订单金额一致;使用订单创建时锁定的 `points_granted` 锁 wallet 入账写流水;补主动查单兜底;含幂等测试 | DONE |
|
||||
| T-305 | 扫码充值下单 + 轮询(create/status) | T-304 | 支持 weixin(native,金额分)/alipay(precreate,金额元);建 pending 订单绑定 user,并在下单时锁定汇率/预计点数→取 code_url/qr_code→前端渲染 + 轮询 status;缺商户密钥时 mock | DONE |
|
||||
| T-306 | Phase 3 对外 API 安全加固 | T-305 | 按 [`phase-3-review.md`](phase-3-review.md) 处理 **P1**(`download_image_input` SSRF:协议白名单 + 私有/回环/链路本地网段拦截含重定向后地址 + 响应大小上限,含内网/元数据 URL 被拒测试)与 **P2**(全局 `REST_FRAMEWORK` 安全默认认证 + 生成/认证端点限流 + 充值金额上限);P3 仅登记媒体访问控制、完整测试补跑和过期订单口径,充值流水 DB 兜底已在 T-304 完成不要重复迁移。**P1-1 属上线前必须**;`check`/`test`/`init` 全绿并在 `../progress.md` 留证据 | TODO |
|
||||
| T-306 | Phase 3 对外 API 安全加固 | T-305 | 按 [`phase-3-review.md`](phase-3-review.md) 处理 **P1**(`download_image_input` SSRF:协议白名单 + 私有/回环/链路本地网段拦截含重定向后地址 + 响应大小上限,含内网/元数据 URL 被拒测试)与 **P2**(全局 `REST_FRAMEWORK` 安全默认认证 + 生成/认证端点限流 + 充值金额上限);P3 仅登记媒体访问控制、完整测试补跑和过期订单口径,充值流水 DB 兜底已在 T-304 完成不要重复迁移。**P1-1 属上线前必须**;`check`/`test`/`init` 全绿并在 `../progress.md` 留证据 | DONE |
|
||||
|
||||
## Phase 4 · 用户端(Django 模板 SSR)
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
- M1:Django + admin 骨架可运行、自定义 User 就位(T-003)。
|
||||
- M2:服务端跑通一次 AI 生成(T-104)。
|
||||
- M3:计费 + 对外接口 + 充值闭环(T-305)。
|
||||
- M3:计费 + 对外接口 + 充值闭环 + 上线前安全加固(T-306)。
|
||||
- M4:用户端(注册/充值/API Key/记录)可用(T-504)。
|
||||
- M5:运营后台 + MVP 验收 + 可部署(T-403)。
|
||||
|
||||
|
||||
+6
-2
@@ -27,6 +27,8 @@ T-304 已实现充值回调基线:`POST /api/v1/recharge/callback/wechat` 与
|
||||
|
||||
T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create` 与 `GET /api/v1/recharge/status` 走用户端 `SessionAuthentication + CSRF`,不接受 API Key;下单创建 pending 订单并锁定汇率/点数,再返回微信 `code_url` 或支付宝 `qr_code`;状态查询只允许订单所属用户访问,并在 pending 时尝试主动查单补入账,查单不可用时保持 pending 等回调。
|
||||
|
||||
T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated`,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。
|
||||
|
||||
通用错误响应:
|
||||
|
||||
```json
|
||||
@@ -54,6 +56,7 @@ T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create
|
||||
| `no_exchange_rate` | 未配置当前币种汇率,无法创建充值订单 | 400 |
|
||||
| `payment_order_create_failed` | 支付平台下单失败 | 502 |
|
||||
| `order_not_found` | 充值订单不存在或不属于当前用户 | 404 |
|
||||
| `rate_limited` | 请求过于频繁,请稍后再试 | 429 |
|
||||
|
||||
## 对外接口
|
||||
|
||||
@@ -86,7 +89,7 @@ T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create
|
||||
}
|
||||
```
|
||||
|
||||
要点:别名必须映射到声明 `text` 能力的模型;映射到图片模型时返回 `model_not_allowed`;别名无计费规则返回 `no_pricing_rule`。
|
||||
要点:别名必须映射到声明 `text` 能力的模型;映射到图片模型时返回 `model_not_allowed`;别名无计费规则返回 `no_pricing_rule`。若传 `image_url`,服务端只会下载公网 `http` / `https` 图片,并在扣点前拒绝内网、回环、链路本地、元数据地址、跳转到内网的地址和超过大小上限的响应;被拒绝时返回 `bad_request` 且不预扣点。
|
||||
|
||||
### `POST /api/v1/generate/image`
|
||||
|
||||
@@ -116,7 +119,7 @@ T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create
|
||||
}
|
||||
```
|
||||
|
||||
要点:改图类模型缺原图返回 `bad_request`,不落 500;上游失败返回 `upstream_error` 且**不扣点**(已预扣则退回)。结果默认存对象存储返回 `image_url`,避免同步响应体过大。
|
||||
要点:改图类模型缺原图返回 `bad_request`,不落 500;上游失败返回 `upstream_error` 且**不扣点**(已预扣则退回)。结果默认存对象存储返回 `image_url`,避免同步响应体过大。请求里的 `image_url` 适用同标题接口相同的 SSRF 防护和大小上限;内网图片请用 `image_base64`。
|
||||
|
||||
### `GET /api/v1/balance`
|
||||
|
||||
@@ -221,6 +224,7 @@ query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult
|
||||
- 微信走 `pay/transactions/native` 取 `code_url`;支付宝走 `trade.precreate` 取 `qr_code`;前端用 qrcode.js 渲染。
|
||||
- 走 **Web session** 鉴权(用户端流程),不同于对外 API Key;订单绑定发起用户,防充错账户。
|
||||
- 开发 / 测试 `PAYMENT_CALLBACK_MODE=mock` 时返回 mock 二维码票据;生产应使用 `sdk` 模式与真实商户配置。
|
||||
- 单笔金额超过 `RECHARGE_MAX_AMOUNT_CNY` 会返回 `bad_request`,不创建 `RechargeOrder`。
|
||||
|
||||
### `GET /api/v1/recharge/status?order_no=...`
|
||||
|
||||
|
||||
+11
-11
@@ -12,16 +12,16 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-03
|
||||
- 阶段:Phase 3 对外 API 安全加固;Phase 3 功能已完成到 T-305,Claude Code 审核后新增 T-306 安全加固,下一步 T-306
|
||||
- 阶段:Phase 4 用户端起步;Phase 3 对外 API 与充值已完成到 T-306,下一步 T-501 注册 / 登录(allauth)
|
||||
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md`
|
||||
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`
|
||||
- 测试:T-305 验证通过:`./init.ps1`、`py_compile`、`manage.py check`、`makemigrations --check`、`py -3.12 -m compileall apps config`、`py -3.12 manage.py test apps.billing apps.api --noinput --keepdb --verbosity 2`(53 tests OK),并用 `MYSQL_CONNECT_TIMEOUT=90` 补跑全量失败类 `BillingCoreModelTests` / `AdminSmokeTests`(10 tests OK)和最终下单服务补测(2 tests OK)。完整 `py -3.12 manage.py test --noinput --keepdb --verbosity 2` 本轮未作为绿灯:发现 81 tests,跑过 71 tests,其中已运行用例均无断言失败;远程 MySQL `43.128.3.240:3306` 连接超时导致 `BillingCoreModelTests.setUpClass` 与 `AdminSmokeTests.setUpClass` 失败。
|
||||
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限
|
||||
- 测试:T-306 已验证:`py -3.12 -m py_compile config/settings.py apps/api/exceptions.py apps/api/generation.py apps/api/authentication.py apps/api/views.py apps/api/serializers.py apps/api/throttles.py apps/api/tests.py`、`py -3.12 manage.py check`、`py -3.12 manage.py makemigrations --check --dry-run`、`py -3.12 -m compileall apps config`、`./init.ps1` 均通过;`py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2` 通过(39 tests OK,含 SSRF、限流、充值上限);认证/生成限流单点补测通过(2 tests OK)。`py -3.12 manage.py test apps.billing apps.api --noinput --keepdb --verbosity 2` 未作为绿灯:62 tests 跑到末尾,功能用例输出均为 ok,最后远程 MySQL 在并发扣点测试的 flush/重连阶段 reset 连接;随后 `$env:MYSQL_CONNECT_TIMEOUT='90'; py -3.12 manage.py test apps.billing.tests.ConcurrentDebitTests --noinput --keepdb --verbosity 2` 通过(1 test OK)。
|
||||
- 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入
|
||||
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
|
||||
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
|
||||
- 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策
|
||||
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。
|
||||
- 当前 blocker:T-306 待处理 `image_url` SSRF 上线前安全问题;远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;T-305 相关测试已通过。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。
|
||||
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。
|
||||
- 当前 blocker:T-306 上线前 SSRF 风险已修复;远程 MySQL 连接仍可能很慢或间歇超时,完整测试需预留较长时间并优先用 `--keepdb` 串行跑。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
|
||||
| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 |
|
||||
| `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi |
|
||||
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301/T-302/T-303/T-304/T-305 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询与路由 |
|
||||
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301~T-306 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询、`image_url` SSRF 防护、生成/认证限流与统一 429 错误响应 |
|
||||
| `manage.py` | 已有 | T-001 创建 |
|
||||
| `tests/` | 待建 | 随各任务补充 |
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
|
||||
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
|
||||
|
||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询。
|
||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固。
|
||||
- 正在进行:无。
|
||||
- 当前 blocker:T-306 SSRF 安全加固待做;远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;支付商户真实密钥/证书与生产 SDK 依赖仍待提供。
|
||||
- 下一个可领取任务:**T-306 Phase 3 对外 API 安全加固**。
|
||||
- 当前 blocker:远程 MySQL 连接当前不稳定或很慢;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。
|
||||
- 下一个可领取任务:**T-501 注册 / 登录(allauth)**。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
@@ -78,14 +78,14 @@ python3.12 manage.py smoke_ai_generation image --recorded
|
||||
- `POST /api/v1/recharge/callback/wechat`
|
||||
- `POST /api/v1/recharge/callback/alipay`
|
||||
|
||||
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。
|
||||
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。
|
||||
|
||||
## 开始编码前检查
|
||||
|
||||
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
|
||||
2. 读 `docs/00-ai-start-here.md`。
|
||||
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
|
||||
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-306)。
|
||||
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-501)。
|
||||
5. 将该任务状态改为 `DOING`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
+18
-3
@@ -48,7 +48,21 @@
|
||||
|
||||
AI 上游连接超时与读取超时不走全局环境变量:连接超时由 `AiModel.connect_timeout_seconds` 控制,读取超时由 `AiModel.timeout_seconds` 控制;当读取超时为 `0` 时,Provider 按分辨率使用内置默认值。
|
||||
|
||||
## 五、支付配置
|
||||
## 五、对外 API 安全配置
|
||||
|
||||
| 变量 | 必填 | 示例 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `API_GENERATE_THROTTLE_RATE` | 否 | `60/min` | 生成标题 / 图片接口按 API Key 或用户限流,DRF throttle rate 格式 |
|
||||
| `API_AUTH_FAILURE_THROTTLE_RATE` | 否 | `30/min` | 缺失、畸形或无效 API Key 的认证失败按 IP 限流 |
|
||||
| `IMAGE_URL_MAX_BYTES` | 否 | `10485760` | `image_url` 服务端下载的最大响应字节数,默认 10 MiB |
|
||||
| `IMAGE_URL_MAX_REDIRECTS` | 否 | `3` | `image_url` 手动跟随重定向次数上限;每跳都会重新校验目标地址 |
|
||||
| `IMAGE_URL_CONNECT_TIMEOUT_SECONDS` | 否 | `10` | `image_url` 下载连接超时秒数 |
|
||||
| `IMAGE_URL_READ_TIMEOUT_SECONDS` | 否 | `60` | `image_url` 下载读取超时秒数 |
|
||||
| `RECHARGE_MAX_AMOUNT_CNY` | 否 | `100000.00` | 用户端单笔充值金额上限,超过则拒绝创建订单 |
|
||||
|
||||
`image_url` 只允许 `http` / `https`,服务端会在请求前解析域名,拒绝私有网段、回环、链路本地、保留地址、组播、未指定地址;重定向后的目标地址也会重复执行同样校验。内网图片不应通过 `image_url` 传入,调用方应改用 `image_base64`。
|
||||
|
||||
## 六、支付配置
|
||||
|
||||
| 变量 | 必填 | 示例 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -79,7 +93,7 @@ AI 上游连接超时与读取超时不走全局环境变量:连接超时由 `
|
||||
|
||||
缺少真实商户配置时,充值任务只能使用 mock 支付客户端;mock 必须在代码和测试中标明,不得伪装成真实支付。
|
||||
|
||||
## 六、对象存储配置
|
||||
## 七、对象存储配置
|
||||
|
||||
图片结果默认返回 URL,避免大 base64 进入同步响应体。对象存储选型未最终落地前,可使用本地开发存储,但生产必须给出可公开访问或可签名访问的 URL。
|
||||
|
||||
@@ -93,10 +107,11 @@ AI 上游连接超时与读取超时不走全局环境变量:连接超时由 `
|
||||
| `S3_ACCESS_KEY_ID` | s3 是 | `change-me` | access key |
|
||||
| `S3_SECRET_ACCESS_KEY` | s3 是 | `change-me` | secret key |
|
||||
|
||||
## 七、上线前检查
|
||||
## 八、上线前检查
|
||||
|
||||
- `DJANGO_DEBUG=false`。
|
||||
- `DJANGO_SECRET_KEY`、`AI_KEY_ENCRYPTION_KEY`、数据库密码、支付密钥均已使用生产值。
|
||||
- `ALLOWED_HOSTS`、`CSRF_TRUSTED_ORIGINS`、支付 `notify_url` 使用同一公网域名。
|
||||
- MySQL 为 8.4 LTS / InnoDB / `utf8mb4`,不是 SQLite 或已有 MySQL 5.7。
|
||||
- `image_url` 下载上限、生成限流、认证失败限流、单笔充值金额上限已按生产容量调整。
|
||||
- 图片同步链路的客户端、Nginx、Gunicorn、上游 read timeout 均按最慢图片模型放大到同一量级。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# cmhub 项目介绍(给管理层)
|
||||
|
||||
> 面向决策与汇报的项目概览。技术细节见同目录架构与需求文档。
|
||||
> 日期:2026-07-02 | 阶段:Phase 3 起步(T-301 API Key 鉴权)
|
||||
> 日期:2026-07-03 | 阶段:Phase 4 起步(T-501 注册 / 登录)
|
||||
|
||||
## 一句话概括
|
||||
|
||||
@@ -103,7 +103,8 @@
|
||||
- M1 骨架已完成:Django + admin + 自定义 User + MySQL 8.4 已跑通。
|
||||
- Phase 1 已完成 T-101~T-104:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置审计与录制标题生成 smoke 已落地。
|
||||
- Phase 2 计费核心已完成:PricingRule / ExchangeRate、计费计算、并发安全扣点与失败退点已落地,并覆盖并发测试。
|
||||
- 下一步是 T-301:实现对外 API Key 鉴权,确保生成/余额接口只认 Key、不认 Web session。
|
||||
- Phase 3 对外 API 与充值已完成到 T-306:API Key 鉴权、生成/余额接口、扫码充值下单与轮询、支付回调幂等入账、`image_url` SSRF 防护、生成/认证限流和充值金额上限已落地。
|
||||
- 下一步是 T-501:实现用户端注册 / 登录(allauth),注册后钱包点数为 0,不送免费点数。
|
||||
|
||||
---
|
||||
*更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。*
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# cmhub · 一页汇报版
|
||||
|
||||
> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-07-02 | Phase 3 起步
|
||||
> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-07-03 | Phase 4 起步
|
||||
|
||||
## 电梯陈述(30 秒)
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
## 进度
|
||||
|
||||
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;Phase 2 计费核心已完成到并发安全扣点 / 退点。下一步做 API Key 鉴权。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
|
||||
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306。下一步进入 Phase 4 用户端,先做注册 / 登录,再做 API Key 管理、个人中心和充值页。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
|
||||
|
||||
---
|
||||
*详见 `project-brief.md`(完整介绍)。*
|
||||
|
||||
+25
@@ -703,3 +703,28 @@
|
||||
- 更新 `docs/06-tasks.md`:T-306 验收口径明确 P3 只登记媒体访问控制、完整测试补跑和过期订单口径。
|
||||
- 验证:文档口径修正,无代码变更;执行 `git diff --check` 作为格式检查。
|
||||
- 下一步:领取 T-306 Phase 3 对外 API 安全加固。
|
||||
|
||||
## 2026-07-03 T-306 Phase 3 对外 API 安全加固
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:
|
||||
- `apps/api/generation.py`:重写 `download_image_input()` 安全边界;`image_url` 只允许 `http` / `https`,请求前解析域名并拒绝私有、回环、链路本地、保留、组播、未指定地址;手动跟随重定向并逐跳重校验;响应按 `IMAGE_URL_MAX_BYTES` 流式限长读取,超限 / 非图片 / 空内容均返回 `bad_request`,且发生在预扣点前。
|
||||
- `apps/api/throttles.py` / `apps/api/exceptions.py`:新增生成接口按 API Key / 用户限流、认证失败按 IP 限流;DRF 429 统一返回 `rate_limited` 错误结构。
|
||||
- `apps/api/authentication.py` / `apps/api/views.py`:无效 / 畸形 / 缺失 API Key 进入认证失败限流;生成标题、生成图片视图挂生成限流。
|
||||
- `config/settings.py`:新增全局 `REST_FRAMEWORK` 安全默认(默认空认证 + 默认 `IsAuthenticated`,强制视图显式 opt-in)、限流 / `image_url` 下载 / 充值金额上限配置。
|
||||
- `apps/api/serializers.py`:`RechargeCreateRequestSerializer` 增加 `RECHARGE_MAX_AMOUNT_CNY` 单笔金额上限校验。
|
||||
- `apps/api/tests.py`:新增裸 `APIView` 不接受 Web session 默认认证、无效 Key 限流、生成接口限流、`image_url` 回环 / 云元数据 / 重定向到内网 / 超大响应拒绝且不扣点、合法公网图片下载、充值金额上限测试。
|
||||
- 同步更新 `.env.example`、`README.md`、`docs/00-ai-start-here.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/env.md`、`docs/06-tasks.md`、`docs/current-state.md`、`docs/project-brief.md`、`docs/project-onepager.md`。
|
||||
- 验证:
|
||||
- `py -3.12 -m py_compile config/settings.py apps/api/exceptions.py apps/api/generation.py apps/api/authentication.py apps/api/views.py apps/api/serializers.py apps/api/throttles.py apps/api/tests.py`:通过。
|
||||
- `py -3.12 manage.py check`:通过,0 issues。
|
||||
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
|
||||
- `py -3.12 manage.py test apps.api.tests.ApiKeyAuthenticationTests.test_invalid_api_key_failures_are_throttled_by_ip apps.api.tests.GenerateApiTests.test_generate_endpoint_is_throttled_by_api_key_without_extra_charge --noinput --keepdb --verbosity 2`:通过,2 tests OK。
|
||||
- `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2`:通过,39 tests OK。
|
||||
- `py -3.12 -m compileall apps config`:通过。
|
||||
- `py -3.12 manage.py test apps.billing apps.api --noinput --keepdb --verbosity 2`:未作为绿灯;62 tests 跑到末尾,功能用例输出均为 ok,最后远程 MySQL 在并发扣点测试的 flush/重连阶段 reset 连接,报 `OperationalError (2013 Lost connection to MySQL server during query)`。
|
||||
- `$env:MYSQL_CONNECT_TIMEOUT='90'; py -3.12 manage.py test apps.billing.tests.ConcurrentDebitTests --noinput --keepdb --verbosity 2`:通过,1 test OK。
|
||||
- `./init.ps1`:通过,Python 3.12.3、依赖已满足、`manage.py check` 0 issues。
|
||||
- `git diff --check`:通过,仅 Windows CRLF 提示。
|
||||
- 阻塞:T-306 功能无阻塞;完整大套件仍受远程 MySQL 连接稳定性影响。生产真实支付仍需微信 / 支付宝商户密钥、证书与 SDK 配置;真实 AI 上游 smoke 仍需配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。
|
||||
- 下一步:领取 T-501 注册 / 登录(allauth)。
|
||||
|
||||
Reference in New Issue
Block a user