feat: add software subscription orders

This commit is contained in:
QiuSW
2026-07-21 11:52:49 +08:00
parent 341864dc70
commit bc9d1aa0f7
25 changed files with 1179 additions and 47 deletions
+1
View File
@@ -83,6 +83,7 @@ WECHAT_PAY_API_V3_KEY=change-me
WECHAT_PAY_CERT_SERIAL_NO=your-cert-serial-no WECHAT_PAY_CERT_SERIAL_NO=your-cert-serial-no
WECHAT_PAY_PRIVATE_KEY_PATH=/secure/wechat/apiclient_key.pem WECHAT_PAY_PRIVATE_KEY_PATH=/secure/wechat/apiclient_key.pem
WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat
SOFTWARE_WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/software-orders/callback/wechat
# Alipay face-to-face payment # Alipay face-to-face payment
ALIPAY_APPID=your-alipay-appid ALIPAY_APPID=your-alipay-appid
+8
View File
@@ -132,6 +132,14 @@ class RechargeStatusRequestSerializer(serializers.Serializer):
) )
class SoftwareOrderStatusRequestSerializer(serializers.Serializer):
order_no = serializers.CharField(
trim_whitespace=True,
allow_blank=False,
max_length=64,
)
class DeviceRegistrationRequestSerializer(serializers.Serializer): class DeviceRegistrationRequestSerializer(serializers.Serializer):
product_code = serializers.ChoiceField(choices=ClientDevice.ProductCode.values) product_code = serializers.ChoiceField(choices=ClientDevice.ProductCode.values)
device_id = serializers.CharField( device_id = serializers.CharField(
+85 -1
View File
@@ -57,12 +57,14 @@ from apps.billing.models import (
from apps.billing.payment_gateways import ( from apps.billing.payment_gateways import (
build_mock_alipay_signature, build_mock_alipay_signature,
build_mock_body_signature, build_mock_body_signature,
PaymentOrderCode,
) )
from apps.billing.services import RechargePayment from apps.billing.services import RechargePayment
from apps.moderation.models import SensitiveWord from apps.moderation.models import SensitiveWord
from apps.moderation.providers.keyword import reset_keyword_matcher_cache from apps.moderation.providers.keyword import reset_keyword_matcher_cache
from apps.portal.models import DownloadRelease from apps.portal.models import DownloadRelease
from apps.licensing.services import register_device from apps.licensing.models import ClientDevice, SoftwareOrder, SoftwarePlan
from apps.licensing.services import create_software_order, register_device
from apps.users.models import ApiKey from apps.users.models import ApiKey
from apps.users.models import UserWallet from apps.users.models import UserWallet
@@ -3099,3 +3101,85 @@ class GenerateApiTests(TestCase):
).count(), ).count(),
1, 1,
) )
@override_settings(
PAYMENT_CALLBACK_MODE="mock",
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
)
class SoftwareOrderCallbackApiTests(TestCase):
callback_url = "/api/v1/software-orders/callback/wechat"
def setUp(self):
self.user = get_user_model().objects.create_user(
username="software-callback-user",
email="software-callback@example.com",
password="test-password",
)
self.plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="软件月度套餐",
duration_days=30,
price=Decimal("19.90"),
device_limit=1,
)
self.order = create_software_order(
user=self.user,
plan=self.plan,
pay_method=SoftwareOrder.PayMethod.WEIXIN,
payment_order_func=lambda _order: PaymentOrderCode(
code_url="weixin://software-order-test",
expires_at=timezone.now() + timedelta(minutes=10),
),
)
def signed_body(self, *, amount_cents=1990, transaction_id="wx-software-callback-001"):
payload = {
"event_type": "TRANSACTION.SUCCESS",
"resource": {
"trade_state": "SUCCESS",
"out_trade_no": self.order.order_no,
"transaction_id": transaction_id,
"success_time": "2026-07-21T12:00:00+08:00",
"amount": {"total": amount_cents},
},
}
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
return body, build_mock_body_signature(body)
def test_callback_fulfills_once_without_writing_points_ledger(self):
body, signature = self.signed_body()
first = self.client.post(
self.callback_url,
data=body,
content_type="application/json",
HTTP_WECHATPAY_SIGNATURE=signature,
)
second = self.client.post(
self.callback_url,
data=body,
content_type="application/json",
HTTP_WECHATPAY_SIGNATURE=signature,
)
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.order.refresh_from_db()
self.assertEqual(self.order.status, SoftwareOrder.Status.PAID)
self.assertIsNotNone(self.order.entitlement_id)
self.assertEqual(PointsLedger.objects.filter(user=self.user).count(), 0)
def test_callback_rejects_amount_mismatch_without_fulfilling(self):
body, signature = self.signed_body(amount_cents=1989)
response = self.client.post(
self.callback_url,
data=body,
content_type="application/json",
HTTP_WECHATPAY_SIGNATURE=signature,
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["error"]["code"], "amount_mismatch")
self.order.refresh_from_db()
self.assertEqual(self.order.status, SoftwareOrder.Status.PENDING)
self.assertEqual(PointsLedger.objects.filter(user=self.user).count(), 0)
+12
View File
@@ -20,7 +20,9 @@ from .views import (
MigrationRequestDetailView, MigrationRequestDetailView,
RechargeCreateView, RechargeCreateView,
RechargeStatusView, RechargeStatusView,
SoftwareOrderStatusView,
WechatRechargeCallbackView, WechatRechargeCallbackView,
WechatSoftwareOrderCallbackView,
) )
urlpatterns = [ urlpatterns = [
@@ -86,6 +88,11 @@ urlpatterns = [
), ),
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"), path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"), path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"),
path(
"v1/software-orders/status",
SoftwareOrderStatusView.as_view(),
name="api-software-order-status",
),
path( path(
"v1/recharge/callback/wechat", "v1/recharge/callback/wechat",
WechatRechargeCallbackView.as_view(), WechatRechargeCallbackView.as_view(),
@@ -96,4 +103,9 @@ urlpatterns = [
AlipayRechargeCallbackView.as_view(), AlipayRechargeCallbackView.as_view(),
name="api-recharge-callback-alipay", name="api-recharge-callback-alipay",
), ),
path(
"v1/software-orders/callback/wechat",
WechatSoftwareOrderCallbackView.as_view(),
name="api-software-order-callback-wechat",
),
] ]
+103 -2
View File
@@ -33,6 +33,7 @@ from apps.api.serializers import (
GenerateTitleRequestSerializer, GenerateTitleRequestSerializer,
RechargeCreateRequestSerializer, RechargeCreateRequestSerializer,
RechargeStatusRequestSerializer, RechargeStatusRequestSerializer,
SoftwareOrderStatusRequestSerializer,
) )
from apps.api.telemetry import ( from apps.api.telemetry import (
log_generation_route_usage, log_generation_route_usage,
@@ -67,16 +68,23 @@ from apps.billing.services import (
from apps.portal.models import DownloadRelease from apps.portal.models import DownloadRelease
from apps.licensing.authentication import DeviceSessionAuthentication from apps.licensing.authentication import DeviceSessionAuthentication
from apps.licensing.services import ( from apps.licensing.services import (
LicensingError,
DeviceRegistrationError, DeviceRegistrationError,
DeviceSessionValidationError, DeviceSessionValidationError,
LicensingError,
SoftwareOrderAmountMismatchError,
SoftwareOrderError,
SoftwareOrderNotFoundError,
SoftwareOrderPayMethodMismatchError,
apply_software_payment,
create_migration_request, create_migration_request,
evaluate_device_authorization, evaluate_device_authorization,
expire_software_order,
query_and_apply_software_payment,
record_device_heartbeat, record_device_heartbeat,
register_device, register_device,
resolve_optional_device_session, resolve_optional_device_session,
) )
from apps.licensing.models import MigrationRequest from apps.licensing.models import MigrationRequest, SoftwareOrder
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
authorization_logger = logging.getLogger("cmhub.licensing.authorization") authorization_logger = logging.getLogger("cmhub.licensing.authorization")
@@ -790,6 +798,65 @@ class RechargeStatusView(PortalSessionApiView):
return Response(_recharge_order_response(order), status=status.HTTP_200_OK) return Response(_recharge_order_response(order), status=status.HTTP_200_OK)
def _software_order_response(order: SoftwareOrder) -> dict:
return {
"order_no": order.order_no,
"product_code": order.product_code,
"plan_name": order.plan_name,
"amount": f"{order.amount_money:.2f}",
"currency": order.currency,
"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,
"fulfilled_at": order.fulfilled_at.isoformat() if order.fulfilled_at else None,
}
class SoftwareOrderStatusView(PortalSessionApiView):
def get(self, request):
serializer = SoftwareOrderStatusRequestSerializer(data=request.query_params)
if not serializer.is_valid():
return Response(api_error("bad_request", "参数错误"), status=status.HTTP_400_BAD_REQUEST)
order = SoftwareOrder.objects.filter(
user=request.user,
order_no=serializer.validated_data["order_no"],
).first()
if order is None:
return Response(
api_error("software_order_not_found", "软件订单不存在"),
status=status.HTTP_404_NOT_FOUND,
)
if order.status == SoftwareOrder.Status.PENDING:
order = expire_software_order(order=order)
if order.status == SoftwareOrder.Status.PENDING:
try:
result = query_and_apply_software_payment(order.order_no, query_payment_order)
order = result.order
except PaymentQueryUnavailableError:
order.refresh_from_db()
except SoftwareOrderError as exc:
logger.warning(
"Rejected active software order query for %s: %s",
order.order_no,
exc.code,
)
return Response(
api_error(exc.code, "支付查单结果与本地软件订单不一致"),
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as exc: # pragma: no cover - external gateway/runtime.
logger.warning(
"Active software order query failed for %s: %s",
order.order_no,
exc.__class__.__name__,
)
order.refresh_from_db()
return Response(_software_order_response(order), status=status.HTTP_200_OK)
class RechargeCallbackView(APIView): class RechargeCallbackView(APIView):
authentication_classes = () authentication_classes = ()
permission_classes = () permission_classes = ()
@@ -847,3 +914,37 @@ class AlipayRechargeCallbackView(RechargeCallbackView):
logger.warning("Rejected Alipay recharge callback: %s", exc.__class__.__name__) logger.warning("Rejected Alipay recharge callback: %s", exc.__class__.__name__)
return HttpResponse("fail", status=status.HTTP_400_BAD_REQUEST) return HttpResponse("fail", status=status.HTTP_400_BAD_REQUEST)
return HttpResponse("success", content_type="text/plain", status=status.HTTP_200_OK) return HttpResponse("success", content_type="text/plain", status=status.HTTP_200_OK)
@method_decorator(csrf_exempt, name="dispatch")
class WechatSoftwareOrderCallbackView(APIView):
authentication_classes = ()
permission_classes = ()
def post(self, request):
try:
payment = verify_wechat_callback(request.headers, request.body)
apply_software_payment(payment)
except PaymentVerificationError:
logger.warning("Rejected WeChat software order callback: signature_invalid")
return Response(
api_error("signature_invalid", "支付回调验签失败"),
status=status.HTTP_400_BAD_REQUEST,
)
except SoftwareOrderAmountMismatchError:
logger.warning("Rejected WeChat software order callback: amount_mismatch")
return Response(
api_error("amount_mismatch", "支付回调金额与本地软件订单金额不一致"),
status=status.HTTP_400_BAD_REQUEST,
)
except (
SoftwareOrderNotFoundError,
SoftwareOrderPayMethodMismatchError,
SoftwareOrderError,
) as exc:
logger.warning("Rejected WeChat software order callback: %s", exc.code)
return Response(
api_error(exc.code, "支付回调处理失败"),
status=status.HTTP_400_BAD_REQUEST,
)
return Response({"code": "SUCCESS", "message": "成功"}, status=status.HTTP_200_OK)
+58 -24
View File
@@ -15,7 +15,6 @@ from django.utils import timezone
from django.utils.dateparse import parse_datetime from django.utils.dateparse import parse_datetime
from .models import RechargeOrder from .models import RechargeOrder
from .services import RechargePayment
class PaymentVerificationError(RuntimeError): class PaymentVerificationError(RuntimeError):
@@ -36,6 +35,15 @@ class PaymentOrderCode:
expires_at: object expires_at: object
@dataclass(frozen=True)
class PaymentReceipt:
order_no: str
pay_method: str
amount: Decimal
transaction_id: str
paid_at: object | None = None
def payment_callback_mode() -> str: def payment_callback_mode() -> str:
return str(getattr(settings, "PAYMENT_CALLBACK_MODE", "") or "").strip().lower() return str(getattr(settings, "PAYMENT_CALLBACK_MODE", "") or "").strip().lower()
@@ -140,7 +148,7 @@ def _verify_mock_alipay_signature(data: dict) -> None:
raise PaymentVerificationError("Invalid mock Alipay callback signature.") raise PaymentVerificationError("Invalid mock Alipay callback signature.")
def verify_wechat_callback(headers, body: bytes) -> RechargePayment: def verify_wechat_callback(headers, body: bytes) -> PaymentReceipt:
if payment_callback_mode() == "mock": if payment_callback_mode() == "mock":
_verify_mock_wechat_signature(headers, body) _verify_mock_wechat_signature(headers, body)
try: try:
@@ -158,7 +166,7 @@ def verify_wechat_callback(headers, body: bytes) -> RechargePayment:
total_cents = Decimal(str((resource.get("amount") or {}).get("total"))) total_cents = Decimal(str((resource.get("amount") or {}).get("total")))
except (InvalidOperation, TypeError, ValueError) as exc: except (InvalidOperation, TypeError, ValueError) as exc:
raise PaymentVerificationError("Invalid WeChat payment amount.") from exc raise PaymentVerificationError("Invalid WeChat payment amount.") from exc
return RechargePayment( return PaymentReceipt(
order_no=str(resource.get("out_trade_no") or ""), order_no=str(resource.get("out_trade_no") or ""),
pay_method=RechargeOrder.PayMethod.WEIXIN, pay_method=RechargeOrder.PayMethod.WEIXIN,
amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")), amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")),
@@ -169,7 +177,7 @@ def verify_wechat_callback(headers, body: bytes) -> RechargePayment:
return _verify_wechat_callback_with_sdk(headers, body) return _verify_wechat_callback_with_sdk(headers, body)
def verify_alipay_callback(data: dict) -> RechargePayment: def verify_alipay_callback(data: dict) -> PaymentReceipt:
callback_data = {key: str(value) for key, value in data.items()} callback_data = {key: str(value) for key, value in data.items()}
if payment_callback_mode() == "mock": if payment_callback_mode() == "mock":
_verify_mock_alipay_signature(callback_data) _verify_mock_alipay_signature(callback_data)
@@ -179,7 +187,7 @@ def verify_alipay_callback(data: dict) -> RechargePayment:
if callback_data.get("trade_status") not in {"TRADE_SUCCESS", "TRADE_FINISHED"}: if callback_data.get("trade_status") not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
raise PaymentVerificationError("Alipay trade is not successful.") raise PaymentVerificationError("Alipay trade is not successful.")
return RechargePayment( return PaymentReceipt(
order_no=str(callback_data.get("out_trade_no") or ""), order_no=str(callback_data.get("out_trade_no") or ""),
pay_method=RechargeOrder.PayMethod.ALIPAY, pay_method=RechargeOrder.PayMethod.ALIPAY,
amount=_decimal_money(callback_data.get("total_amount")), amount=_decimal_money(callback_data.get("total_amount")),
@@ -188,13 +196,27 @@ def verify_alipay_callback(data: dict) -> RechargePayment:
) )
def create_payment_order(order: RechargeOrder) -> PaymentOrderCode: def create_payment_order(
order,
*,
description: str = "cmhub points recharge",
wechat_notify_url: str | None = None,
alipay_notify_url: str | None = None,
) -> PaymentOrderCode:
if payment_callback_mode() == "mock": if payment_callback_mode() == "mock":
return _create_mock_payment_order(order) return _create_mock_payment_order(order)
if order.pay_method == RechargeOrder.PayMethod.WEIXIN: if order.pay_method == RechargeOrder.PayMethod.WEIXIN:
return _create_wechat_payment_order_with_sdk(order) return _create_wechat_payment_order_with_sdk(
order,
description=description,
notify_url=wechat_notify_url,
)
if order.pay_method == RechargeOrder.PayMethod.ALIPAY: if order.pay_method == RechargeOrder.PayMethod.ALIPAY:
return _create_alipay_payment_order_with_sdk(order) return _create_alipay_payment_order_with_sdk(
order,
description=description,
notify_url=alipay_notify_url,
)
raise PaymentOrderCreateError("Unsupported payment method.") raise PaymentOrderCreateError("Unsupported payment method.")
@@ -215,12 +237,18 @@ def _create_mock_payment_order(order: RechargeOrder) -> PaymentOrderCode:
return PaymentOrderCode(code_url=code_url, expires_at=_default_expires_at()) return PaymentOrderCode(code_url=code_url, expires_at=_default_expires_at())
def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode: def _create_wechat_payment_order_with_sdk(
order,
*,
description: str = "cmhub points recharge",
notify_url: str | None = None,
) -> PaymentOrderCode:
try: try:
from wechatpayv3 import WeChatPay, WeChatPayType # type: ignore from wechatpayv3 import WeChatPay, WeChatPayType # type: ignore
except ImportError as exc: except ImportError as exc:
raise PaymentOrderCreateError("wechatpayv3 is not installed.") from exc raise PaymentOrderCreateError("wechatpayv3 is not installed.") from exc
effective_notify_url = str(notify_url or settings.WECHAT_PAY_NOTIFY_URL or "").strip()
_require_payment_config( _require_payment_config(
{ {
"WECHAT_PAY_APPID": settings.WECHAT_PAY_APPID, "WECHAT_PAY_APPID": settings.WECHAT_PAY_APPID,
@@ -228,7 +256,7 @@ def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC
"WECHAT_PAY_API_V3_KEY": settings.WECHAT_PAY_API_V3_KEY, "WECHAT_PAY_API_V3_KEY": settings.WECHAT_PAY_API_V3_KEY,
"WECHAT_PAY_CERT_SERIAL_NO": settings.WECHAT_PAY_CERT_SERIAL_NO, "WECHAT_PAY_CERT_SERIAL_NO": settings.WECHAT_PAY_CERT_SERIAL_NO,
"WECHAT_PAY_PRIVATE_KEY_PATH": settings.WECHAT_PAY_PRIVATE_KEY_PATH, "WECHAT_PAY_PRIVATE_KEY_PATH": settings.WECHAT_PAY_PRIVATE_KEY_PATH,
"WECHAT_PAY_NOTIFY_URL": settings.WECHAT_PAY_NOTIFY_URL, "WECHAT_PAY_NOTIFY_URL": effective_notify_url,
}, },
"WeChat", "WeChat",
) )
@@ -240,11 +268,11 @@ def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC
cert_serial_no=settings.WECHAT_PAY_CERT_SERIAL_NO, cert_serial_no=settings.WECHAT_PAY_CERT_SERIAL_NO,
apiv3_key=settings.WECHAT_PAY_API_V3_KEY, apiv3_key=settings.WECHAT_PAY_API_V3_KEY,
appid=settings.WECHAT_PAY_APPID, appid=settings.WECHAT_PAY_APPID,
notify_url=settings.WECHAT_PAY_NOTIFY_URL, notify_url=effective_notify_url,
) )
try: try:
response = client.pay( response = client.pay(
description="cmhub points recharge", description=description,
out_trade_no=order.order_no, out_trade_no=order.order_no,
amount={ amount={
"total": _wechat_amount_cents(order.amount_money), "total": _wechat_amount_cents(order.amount_money),
@@ -265,18 +293,24 @@ def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC
return PaymentOrderCode(code_url=str(code_url), expires_at=_default_expires_at()) return PaymentOrderCode(code_url=str(code_url), expires_at=_default_expires_at())
def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode: def _create_alipay_payment_order_with_sdk(
order,
*,
description: str = "cmhub points recharge",
notify_url: str | None = None,
) -> PaymentOrderCode:
try: try:
from alipay import AliPay # type: ignore from alipay import AliPay # type: ignore
except ImportError as exc: except ImportError as exc:
raise PaymentOrderCreateError("python-alipay-sdk is not installed.") from exc raise PaymentOrderCreateError("python-alipay-sdk is not installed.") from exc
effective_notify_url = str(notify_url or settings.ALIPAY_NOTIFY_URL or "").strip()
_require_payment_config( _require_payment_config(
{ {
"ALIPAY_APPID": settings.ALIPAY_APPID, "ALIPAY_APPID": settings.ALIPAY_APPID,
"ALIPAY_APP_PRIVATE_KEY_PATH": settings.ALIPAY_APP_PRIVATE_KEY_PATH, "ALIPAY_APP_PRIVATE_KEY_PATH": settings.ALIPAY_APP_PRIVATE_KEY_PATH,
"ALIPAY_PUBLIC_KEY_PATH": settings.ALIPAY_PUBLIC_KEY_PATH, "ALIPAY_PUBLIC_KEY_PATH": settings.ALIPAY_PUBLIC_KEY_PATH,
"ALIPAY_NOTIFY_URL": settings.ALIPAY_NOTIFY_URL, "ALIPAY_NOTIFY_URL": effective_notify_url,
}, },
"Alipay", "Alipay",
) )
@@ -288,7 +322,7 @@ def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC
) )
client = AliPay( client = AliPay(
appid=settings.ALIPAY_APPID, appid=settings.ALIPAY_APPID,
app_notify_url=settings.ALIPAY_NOTIFY_URL, app_notify_url=effective_notify_url,
app_private_key_string=app_private_key, app_private_key_string=app_private_key,
alipay_public_key_string=alipay_public_key, alipay_public_key_string=alipay_public_key,
sign_type="RSA2", sign_type="RSA2",
@@ -296,10 +330,10 @@ def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC
) )
try: try:
response = client.api_alipay_trade_precreate( response = client.api_alipay_trade_precreate(
subject="cmhub points recharge", subject=description,
out_trade_no=order.order_no, out_trade_no=order.order_no,
total_amount=_format_money(order.amount_money), total_amount=_format_money(order.amount_money),
notify_url=settings.ALIPAY_NOTIFY_URL, notify_url=effective_notify_url,
) )
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime. except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
raise PaymentOrderCreateError("Alipay precreate order failed.") from exc raise PaymentOrderCreateError("Alipay precreate order failed.") from exc
@@ -310,7 +344,7 @@ def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC
return PaymentOrderCode(code_url=str(qr_code), expires_at=_default_expires_at()) return PaymentOrderCode(code_url=str(qr_code), expires_at=_default_expires_at())
def _verify_wechat_callback_with_sdk(headers, body: bytes) -> RechargePayment: def _verify_wechat_callback_with_sdk(headers, body: bytes) -> PaymentReceipt:
try: try:
from wechatpayv3 import WeChatPay # type: ignore from wechatpayv3 import WeChatPay # type: ignore
except ImportError as exc: except ImportError as exc:
@@ -338,7 +372,7 @@ def _verify_wechat_callback_with_sdk(headers, body: bytes) -> RechargePayment:
total_cents = Decimal(str((resource.get("amount") or {}).get("total"))) total_cents = Decimal(str((resource.get("amount") or {}).get("total")))
except (InvalidOperation, TypeError, ValueError) as exc: except (InvalidOperation, TypeError, ValueError) as exc:
raise PaymentVerificationError("Invalid WeChat payment amount.") from exc raise PaymentVerificationError("Invalid WeChat payment amount.") from exc
return RechargePayment( return PaymentReceipt(
order_no=str(resource.get("out_trade_no") or ""), order_no=str(resource.get("out_trade_no") or ""),
pay_method=RechargeOrder.PayMethod.WEIXIN, pay_method=RechargeOrder.PayMethod.WEIXIN,
amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")), amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")),
@@ -372,7 +406,7 @@ def _verify_alipay_callback_with_sdk(data: dict) -> None:
raise PaymentVerificationError("Invalid Alipay callback signature.") raise PaymentVerificationError("Invalid Alipay callback signature.")
def query_payment_order(order: RechargeOrder) -> RechargePayment: def query_payment_order(order) -> PaymentReceipt:
if payment_callback_mode() == "mock": if payment_callback_mode() == "mock":
raise PaymentQueryUnavailableError( raise PaymentQueryUnavailableError(
f"Mock payment query for {order.order_no} is not configured." f"Mock payment query for {order.order_no} is not configured."
@@ -386,7 +420,7 @@ def query_payment_order(order: RechargeOrder) -> RechargePayment:
) )
def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePayment: def _query_wechat_payment_order_with_sdk(order) -> PaymentReceipt:
try: try:
from wechatpayv3 import WeChatPay # type: ignore from wechatpayv3 import WeChatPay # type: ignore
except ImportError as exc: except ImportError as exc:
@@ -432,7 +466,7 @@ def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePaymen
total_cents = Decimal(str((resource.get("amount") or {}).get("total"))) total_cents = Decimal(str((resource.get("amount") or {}).get("total")))
except (InvalidOperation, TypeError, ValueError) as exc: except (InvalidOperation, TypeError, ValueError) as exc:
raise PaymentQueryUnavailableError("Invalid WeChat payment amount.") from exc raise PaymentQueryUnavailableError("Invalid WeChat payment amount.") from exc
return RechargePayment( return PaymentReceipt(
order_no=str(resource.get("out_trade_no") or order.order_no), order_no=str(resource.get("out_trade_no") or order.order_no),
pay_method=RechargeOrder.PayMethod.WEIXIN, pay_method=RechargeOrder.PayMethod.WEIXIN,
amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")), amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")),
@@ -441,7 +475,7 @@ def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePaymen
) )
def _query_alipay_payment_order_with_sdk(order: RechargeOrder) -> RechargePayment: def _query_alipay_payment_order_with_sdk(order) -> PaymentReceipt:
try: try:
from alipay import AliPay # type: ignore from alipay import AliPay # type: ignore
except ImportError as exc: except ImportError as exc:
@@ -477,7 +511,7 @@ def _query_alipay_payment_order_with_sdk(order: RechargeOrder) -> RechargePaymen
trade_status = response.get("trade_status") if isinstance(response, dict) else None trade_status = response.get("trade_status") if isinstance(response, dict) else None
if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}: if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
raise PaymentQueryUnavailableError("Alipay trade is not paid yet.") raise PaymentQueryUnavailableError("Alipay trade is not paid yet.")
return RechargePayment( return PaymentReceipt(
order_no=str(response.get("out_trade_no") or order.order_no), order_no=str(response.get("out_trade_no") or order.order_no),
pay_method=RechargeOrder.PayMethod.ALIPAY, pay_method=RechargeOrder.PayMethod.ALIPAY,
amount=_decimal_money(response.get("total_amount")), amount=_decimal_money(response.get("total_amount")),
+2 -8
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import secrets import secrets
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from django.db import transaction from django.db import transaction
@@ -18,6 +17,7 @@ from .models import (
SignupBonusGrant, SignupBonusGrant,
normalize_resolution, normalize_resolution,
) )
from .payment_gateways import PaymentReceipt
from .pricing import quote_recharge_points from .pricing import quote_recharge_points
@@ -127,13 +127,7 @@ class BalanceSnapshot:
ledger_balance: int ledger_balance: int
@dataclass(frozen=True) RechargePayment = PaymentReceipt
class RechargePayment:
order_no: str
pay_method: str
amount: Decimal
transaction_id: str
paid_at: datetime | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
+20
View File
@@ -16,6 +16,7 @@ from .models import (
LegacyMigrationGrant, LegacyMigrationGrant,
MigrationRequest, MigrationRequest,
SoftwareEntitlement, SoftwareEntitlement,
SoftwareOrder,
SoftwarePlan, SoftwarePlan,
) )
from .services import ( from .services import (
@@ -422,3 +423,22 @@ class DeviceCredentialAdmin(ReadOnlyLicenseAdmin):
list_filter = ("product_code", "revoked_at", "expires_at") list_filter = ("product_code", "revoked_at", "expires_at")
search_fields = ("token_prefix", "user__username", "user__email") search_fields = ("token_prefix", "user__username", "user__email")
list_select_related = ("user", "device", "entitlement", "seat") list_select_related = ("user", "device", "entitlement", "seat")
@admin.register(SoftwareOrder)
class SoftwareOrderAdmin(ReadOnlyLicenseAdmin):
list_display = (
"created_at",
"order_no",
"user",
"plan_name",
"amount_money",
"pay_method",
"status",
"payment_txn_no",
"entitlement",
"fulfilled_at",
)
list_filter = ("product_code", "pay_method", "status", "created_at")
search_fields = ("=order_no", "=payment_txn_no", "user__username", "user__email")
list_select_related = ("user", "source_plan", "entitlement", "fulfillment_event")
@@ -0,0 +1,57 @@
# Generated by Django 5.2.15 on 2026-07-21 03:25
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('licensing', '0003_alter_licenseevent_action_legacymigrationgrant_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='licenseevent',
name='action',
field=models.CharField(choices=[('granted', '人工授予'), ('renewed', '续期'), ('revoked', '撤销'), ('seat_assigned', '绑定席位'), ('seat_released', '解绑席位'), ('migration_granted', '迁移资格授予'), ('credential_issued', '设备凭证签发'), ('credential_revoked', '设备凭证吊销'), ('order_fulfilled', '套餐订单权益发放')], max_length=32, verbose_name='动作'),
),
migrations.CreateModel(
name='SoftwareOrder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order_no', models.CharField(editable=False, max_length=64, unique=True, verbose_name='软件订单号')),
('product_code', models.CharField(choices=[('cmshopee', '虾皮圈优化助手')], max_length=32, verbose_name='产品代码')),
('plan_name', models.CharField(max_length=120, verbose_name='套餐名称快照')),
('plan_duration_days', models.PositiveIntegerField(verbose_name='套餐有效天数快照')),
('plan_price', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='套餐价格快照')),
('plan_device_limit', models.PositiveSmallIntegerField(verbose_name='设备数量快照')),
('plan_grace_days', models.PositiveSmallIntegerField(default=0, verbose_name='宽限天数快照')),
('amount_money', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='支付金额')),
('currency', models.CharField(default='CNY', max_length=8, verbose_name='币种')),
('pay_method', models.CharField(choices=[('weixin', '微信')], max_length=20, verbose_name='支付方式')),
('status', models.CharField(choices=[('pending', '待支付'), ('paid', '已支付'), ('failed', '下单失败'), ('expired', '已过期')], default='pending', max_length=20, verbose_name='订单状态')),
('code_url', models.TextField(blank=True, verbose_name='二维码票据')),
('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='支付票据过期时间')),
('payment_txn_no', models.CharField(blank=True, max_length=128, null=True, verbose_name='支付交易号')),
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='支付时间')),
('fulfilled_at', models.DateTimeField(blank=True, null=True, verbose_name='权益发放时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('entitlement', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='software_orders', to='licensing.softwareentitlement', verbose_name='发放权益')),
('fulfillment_event', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='software_order', to='licensing.licenseevent', verbose_name='权益发放事件')),
('source_plan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='software_orders', to='licensing.softwareplan', verbose_name='来源套餐')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='software_orders', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
],
options={
'verbose_name': '软件套餐订单',
'verbose_name_plural': '软件套餐订单',
'db_table': 'software_order',
'ordering': ('-created_at', '-id'),
'indexes': [models.Index(fields=['user', 'product_code', 'status'], name='software_or_user_id_1781fe_idx'), models.Index(fields=['status', 'expires_at'], name='software_or_status_7eb559_idx')],
'constraints': [models.UniqueConstraint(fields=('pay_method', 'payment_txn_no'), name='software_order_pay_method_txn_unique'), models.CheckConstraint(condition=models.Q(('plan_duration_days__gt', 0)), name='software_order_duration_positive'), models.CheckConstraint(condition=models.Q(('plan_price__gt', 0)), name='software_order_plan_price_positive'), models.CheckConstraint(condition=models.Q(('plan_device_limit__gt', 0)), name='software_order_device_limit_positive'), models.CheckConstraint(condition=models.Q(('amount_money__gt', 0)), name='software_order_amount_positive')],
},
),
]
+105
View File
@@ -219,6 +219,110 @@ class SoftwareEntitlement(models.Model):
return self.status == self.Status.ACTIVE and self.grace_expires_at > now return self.status == self.Status.ACTIVE and self.grace_expires_at > now
class SoftwareOrder(models.Model):
class PayMethod(models.TextChoices):
WEIXIN = "weixin", "微信"
class Status(models.TextChoices):
PENDING = "pending", "待支付"
PAID = "paid", "已支付"
FAILED = "failed", "下单失败"
EXPIRED = "expired", "已过期"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name="用户",
on_delete=models.PROTECT,
related_name="software_orders",
)
source_plan = models.ForeignKey(
SoftwarePlan,
verbose_name="来源套餐",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="software_orders",
)
entitlement = models.ForeignKey(
SoftwareEntitlement,
verbose_name="发放权益",
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="software_orders",
)
fulfillment_event = models.OneToOneField(
"LicenseEvent",
verbose_name="权益发放事件",
null=True,
blank=True,
on_delete=models.PROTECT,
related_name="software_order",
)
order_no = models.CharField("软件订单号", max_length=64, unique=True, editable=False)
product_code = models.CharField(
"产品代码",
max_length=32,
choices=ClientDevice.ProductCode.choices,
)
plan_name = models.CharField("套餐名称快照", max_length=120)
plan_duration_days = models.PositiveIntegerField("套餐有效天数快照")
plan_price = models.DecimalField("套餐价格快照", max_digits=12, decimal_places=2)
plan_device_limit = models.PositiveSmallIntegerField("设备数量快照")
plan_grace_days = models.PositiveSmallIntegerField("宽限天数快照", default=0)
amount_money = models.DecimalField("支付金额", max_digits=12, decimal_places=2)
currency = models.CharField("币种", max_length=8, default="CNY")
pay_method = models.CharField("支付方式", max_length=20, choices=PayMethod.choices)
status = models.CharField(
"订单状态",
max_length=20,
choices=Status.choices,
default=Status.PENDING,
)
code_url = models.TextField("二维码票据", blank=True)
expires_at = models.DateTimeField("支付票据过期时间", null=True, blank=True)
payment_txn_no = models.CharField("支付交易号", max_length=128, null=True, blank=True)
paid_at = models.DateTimeField("支付时间", null=True, blank=True)
fulfilled_at = models.DateTimeField("权益发放时间", null=True, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
db_table = "software_order"
verbose_name = "软件套餐订单"
verbose_name_plural = "软件套餐订单"
ordering = ("-created_at", "-id")
constraints = [
models.UniqueConstraint(
fields=("pay_method", "payment_txn_no"),
name="software_order_pay_method_txn_unique",
),
models.CheckConstraint(
condition=Q(plan_duration_days__gt=0),
name="software_order_duration_positive",
),
models.CheckConstraint(
condition=Q(plan_price__gt=0),
name="software_order_plan_price_positive",
),
models.CheckConstraint(
condition=Q(plan_device_limit__gt=0),
name="software_order_device_limit_positive",
),
models.CheckConstraint(
condition=Q(amount_money__gt=0),
name="software_order_amount_positive",
),
]
indexes = [
models.Index(fields=("user", "product_code", "status")),
models.Index(fields=("status", "expires_at")),
]
def __str__(self) -> str:
return f"{self.order_no} {self.user} {self.plan_name}"
class LicenseSeat(models.Model): class LicenseSeat(models.Model):
entitlement = models.ForeignKey( entitlement = models.ForeignKey(
SoftwareEntitlement, SoftwareEntitlement,
@@ -273,6 +377,7 @@ class LicenseEvent(models.Model):
MIGRATION_GRANTED = "migration_granted", "迁移资格授予" MIGRATION_GRANTED = "migration_granted", "迁移资格授予"
CREDENTIAL_ISSUED = "credential_issued", "设备凭证签发" CREDENTIAL_ISSUED = "credential_issued", "设备凭证签发"
CREDENTIAL_REVOKED = "credential_revoked", "设备凭证吊销" CREDENTIAL_REVOKED = "credential_revoked", "设备凭证吊销"
ORDER_FULFILLED = "order_fulfilled", "套餐订单权益发放"
entitlement = models.ForeignKey( entitlement = models.ForeignKey(
SoftwareEntitlement, SoftwareEntitlement,
+294 -3
View File
@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
import secrets
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
from decimal import Decimal
from decimal import InvalidOperation
from django.conf import settings from django.conf import settings
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
@@ -17,6 +20,7 @@ from apps.licensing.models import (
LicenseSeat, LicenseSeat,
MigrationRequest, MigrationRequest,
SoftwareEntitlement, SoftwareEntitlement,
SoftwareOrder,
SoftwarePlan, SoftwarePlan,
) )
@@ -39,6 +43,37 @@ class LicensingError(Exception):
super().__init__(message) super().__init__(message)
class SoftwareOrderError(LicensingError):
pass
class SoftwareOrderNotFoundError(SoftwareOrderError):
def __init__(self, order_no: str):
super().__init__("software_order_not_found", f"软件订单不存在:{order_no}")
class SoftwareOrderAmountMismatchError(SoftwareOrderError):
def __init__(self):
super().__init__("amount_mismatch", "支付回调金额与本地软件订单金额不一致")
class SoftwareOrderPayMethodMismatchError(SoftwareOrderError):
def __init__(self):
super().__init__("bad_request", "支付回调通道与本地软件订单不一致")
class SoftwareOrderTransactionMismatchError(SoftwareOrderError):
def __init__(self):
super().__init__("bad_request", "支付交易号与已处理软件订单不一致")
@dataclass(frozen=True)
class SoftwarePaymentResult:
order: SoftwareOrder
entitlement: SoftwareEntitlement
applied: bool
@dataclass(frozen=True) @dataclass(frozen=True)
class AuthorizationDecision: class AuthorizationDecision:
product_code: str product_code: str
@@ -282,19 +317,32 @@ def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=N
@transaction.atomic @transaction.atomic
def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None): def renew_software_entitlement(
*,
entitlement: SoftwareEntitlement,
reason: str,
actor=None,
now=None,
duration_days: int | None = None,
grace_days: int | None = None,
):
reason = _required_reason(reason) reason = _required_reason(reason)
now = now or timezone.now() now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk) locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED: if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED:
raise LicensingError("entitlement_revoked", "已撤销权益不能续期") raise LicensingError("entitlement_revoked", "已撤销权益不能续期")
duration_days = duration_days or locked_entitlement.plan_duration_days
grace_days = grace_days if grace_days is not None else locked_entitlement.plan_grace_days
if duration_days <= 0 or grace_days < 0:
raise LicensingError("invalid_plan_snapshot", "套餐快照无效")
extension_start = max(now, locked_entitlement.expires_at) extension_start = max(now, locked_entitlement.expires_at)
locked_entitlement.expires_at = extension_start + timedelta( locked_entitlement.expires_at = extension_start + timedelta(
days=locked_entitlement.plan_duration_days days=duration_days
) )
locked_entitlement.grace_expires_at = locked_entitlement.expires_at + timedelta( locked_entitlement.grace_expires_at = locked_entitlement.expires_at + timedelta(
days=locked_entitlement.plan_grace_days days=grace_days
) )
locked_entitlement.status = SoftwareEntitlement.Status.ACTIVE locked_entitlement.status = SoftwareEntitlement.Status.ACTIVE
locked_entitlement.revoked_at = None locked_entitlement.revoked_at = None
@@ -307,6 +355,10 @@ def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str,
"updated_at", "updated_at",
) )
) )
DeviceCredential.objects.filter(
entitlement=locked_entitlement,
revoked_at__isnull=True,
).update(expires_at=locked_entitlement.grace_expires_at)
_create_license_event( _create_license_event(
entitlement=locked_entitlement, entitlement=locked_entitlement,
action=LicenseEvent.Action.RENEWED, action=LicenseEvent.Action.RENEWED,
@@ -315,6 +367,8 @@ def renew_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str,
metadata={ metadata={
"extension_start": extension_start.isoformat(), "extension_start": extension_start.isoformat(),
"expires_at": locked_entitlement.expires_at.isoformat(), "expires_at": locked_entitlement.expires_at.isoformat(),
"duration_days": duration_days,
"grace_days": grace_days,
}, },
) )
return locked_entitlement return locked_entitlement
@@ -328,6 +382,12 @@ def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str
if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED: if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED:
return locked_entitlement return locked_entitlement
active_credentials = list(
DeviceCredential.objects.select_for_update()
.filter(entitlement=locked_entitlement, revoked_at__isnull=True)
.order_by("id")
)
locked_entitlement.status = SoftwareEntitlement.Status.REVOKED locked_entitlement.status = SoftwareEntitlement.Status.REVOKED
locked_entitlement.revoked_at = now locked_entitlement.revoked_at = now
locked_entitlement.save(update_fields=("status", "revoked_at", "updated_at")) locked_entitlement.save(update_fields=("status", "revoked_at", "updated_at"))
@@ -337,6 +397,13 @@ def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str
reason=reason, reason=reason,
actor=actor, actor=actor,
) )
for credential in active_credentials:
revoke_device_credential(
credential=credential,
reason=reason,
actor=actor,
now=now,
)
return locked_entitlement return locked_entitlement
@@ -593,6 +660,230 @@ def revoke_device_credential(*, credential: DeviceCredential, reason: str, actor
return locked_credential return locked_credential
def _normalize_software_order_amount(value) -> Decimal:
try:
return Decimal(str(value)).quantize(Decimal("0.01"))
except (InvalidOperation, TypeError, ValueError) as exc:
raise SoftwareOrderError("bad_request", "软件订单金额无效") from exc
def _generate_software_order_no() -> str:
for _attempt in range(10):
timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
order_no = f"S{timestamp}{secrets.token_hex(4).upper()}"
if not SoftwareOrder.objects.filter(order_no=order_no).exists():
return order_no
raise SoftwareOrderError("order_number_failed", "无法生成软件订单号")
def _active_entitlement_for_software_order(*, user, product_code):
return (
SoftwareEntitlement.objects.select_for_update()
.filter(
user=user,
product_code=product_code,
status=SoftwareEntitlement.Status.ACTIVE,
)
.order_by("-expires_at", "-id")
.first()
)
def create_software_order(*, user, plan: SoftwarePlan, pay_method: str, payment_order_func=None):
if plan.status != SoftwarePlan.Status.ACTIVE:
raise SoftwareOrderError("plan_inactive", "套餐已停用,无法购买")
if pay_method != SoftwareOrder.PayMethod.WEIXIN:
raise SoftwareOrderError("bad_request", "当前软件订阅仅支持微信支付")
existing_entitlement = (
SoftwareEntitlement.objects.filter(
user=user,
product_code=plan.product_code,
status=SoftwareEntitlement.Status.ACTIVE,
)
.order_by("-expires_at", "-id")
.first()
)
if existing_entitlement is not None and existing_entitlement.source_plan_id != plan.id:
raise SoftwareOrderError("plan_change_not_supported", "当前套餐变更请联系运营处理")
order = SoftwareOrder.objects.create(
user=user,
source_plan=plan,
order_no=_generate_software_order_no(),
product_code=plan.product_code,
plan_name=plan.name,
plan_duration_days=plan.duration_days,
plan_price=plan.price,
plan_device_limit=plan.device_limit,
plan_grace_days=plan.grace_days,
amount_money=plan.price,
currency="CNY",
pay_method=pay_method,
)
if payment_order_func is None:
from apps.billing.payment_gateways import create_payment_order
def payment_order_func(payment_order):
return create_payment_order(
payment_order,
description="虾皮圈软件订阅",
wechat_notify_url=settings.SOFTWARE_WECHAT_PAY_NOTIFY_URL,
)
try:
payment_order = payment_order_func(order)
code_url = str(getattr(payment_order, "code_url", "") or "").strip()
if not code_url:
raise SoftwareOrderError("payment_order_create_failed", "支付平台未返回二维码")
except Exception:
order.status = SoftwareOrder.Status.FAILED
order.save(update_fields=("status", "updated_at"))
raise
order.code_url = code_url
order.expires_at = getattr(payment_order, "expires_at", None)
order.save(update_fields=("code_url", "expires_at", "updated_at"))
return order
def _grant_software_order_entitlement(*, order: SoftwareOrder, now):
entitlement = SoftwareEntitlement.objects.create(
user=order.user,
product_code=order.product_code,
source_plan=order.source_plan,
plan_name=order.plan_name,
plan_duration_days=order.plan_duration_days,
plan_price=order.plan_price,
plan_device_limit=order.plan_device_limit,
plan_grace_days=order.plan_grace_days,
starts_at=now,
expires_at=now + timedelta(days=order.plan_duration_days),
grace_expires_at=now + timedelta(days=order.plan_duration_days + order.plan_grace_days),
)
LicenseSeat.objects.bulk_create(
[
LicenseSeat(entitlement=entitlement, seat_number=seat_number)
for seat_number in range(1, order.plan_device_limit + 1)
]
)
_create_license_event(
entitlement=entitlement,
action=LicenseEvent.Action.GRANTED,
reason="软件套餐订单首次发放权益",
metadata={"software_order_no": order.order_no},
)
return entitlement
@transaction.atomic
def apply_software_payment(payment) -> SoftwarePaymentResult:
order_no = str(getattr(payment, "order_no", "") or "").strip()
transaction_id = str(getattr(payment, "transaction_id", "") or "").strip()
callback_amount = _normalize_software_order_amount(getattr(payment, "amount", None))
callback_pay_method = str(getattr(payment, "pay_method", "") or "").strip().lower()
paid_at = getattr(payment, "paid_at", None) or timezone.now()
if not transaction_id:
raise SoftwareOrderError("bad_request", "支付回调缺少交易号")
try:
order = (
SoftwareOrder.objects.select_for_update()
.select_related("user", "source_plan", "entitlement")
.get(order_no=order_no)
)
except SoftwareOrder.DoesNotExist as exc:
raise SoftwareOrderNotFoundError(order_no) from exc
if order.status == SoftwareOrder.Status.PAID:
if order.payment_txn_no != transaction_id:
raise SoftwareOrderTransactionMismatchError()
return SoftwarePaymentResult(order=order, entitlement=order.entitlement, applied=False)
if order.status != SoftwareOrder.Status.PENDING:
raise SoftwareOrderError("bad_request", "软件订单当前状态不能入账")
if order.pay_method != callback_pay_method:
raise SoftwareOrderPayMethodMismatchError()
if _normalize_software_order_amount(order.amount_money) != callback_amount:
raise SoftwareOrderAmountMismatchError()
if SoftwareOrder.objects.filter(
pay_method=order.pay_method,
payment_txn_no=transaction_id,
).exclude(pk=order.pk).exists():
raise SoftwareOrderTransactionMismatchError()
entitlement = _active_entitlement_for_software_order(
user=order.user,
product_code=order.product_code,
)
if entitlement is None:
entitlement = _grant_software_order_entitlement(order=order, now=paid_at)
elif entitlement.source_plan_id == order.source_plan_id:
entitlement = renew_software_entitlement(
entitlement=entitlement,
reason="软件套餐订单续订",
now=paid_at,
duration_days=order.plan_duration_days,
grace_days=order.plan_grace_days,
)
else:
raise SoftwareOrderError("plan_change_not_supported", "当前套餐变更请联系运营处理")
fulfillment_event = _create_license_event(
entitlement=entitlement,
action=LicenseEvent.Action.ORDER_FULFILLED,
reason="软件套餐订单权益发放",
metadata={
"software_order_no": order.order_no,
"payment_txn_no": transaction_id,
"pay_method": order.pay_method,
},
)
order.status = SoftwareOrder.Status.PAID
order.payment_txn_no = transaction_id
order.paid_at = paid_at
order.fulfilled_at = timezone.now()
order.entitlement = entitlement
order.fulfillment_event = fulfillment_event
try:
order.save(
update_fields=(
"status",
"payment_txn_no",
"paid_at",
"fulfilled_at",
"entitlement",
"fulfillment_event",
"updated_at",
)
)
except IntegrityError as exc:
raise SoftwareOrderTransactionMismatchError() from exc
return SoftwarePaymentResult(order=order, entitlement=entitlement, applied=True)
def query_and_apply_software_payment(order_no: str, query_func) -> SoftwarePaymentResult:
order = SoftwareOrder.objects.filter(order_no=order_no).first()
if order is None:
raise SoftwareOrderNotFoundError(order_no)
if order.status == SoftwareOrder.Status.PAID:
return SoftwarePaymentResult(order=order, entitlement=order.entitlement, applied=False)
payment = query_func(order)
return apply_software_payment(payment)
@transaction.atomic
def expire_software_order(*, order: SoftwareOrder, now=None) -> SoftwareOrder:
now = now or timezone.now()
locked_order = SoftwareOrder.objects.select_for_update().get(pk=order.pk)
if (
locked_order.status == SoftwareOrder.Status.PENDING
and locked_order.expires_at is not None
and locked_order.expires_at <= now
):
locked_order.status = SoftwareOrder.Status.EXPIRED
locked_order.save(update_fields=("status", "updated_at"))
return locked_order
def evaluate_device_authorization(*, user, product_code: str, device=None, raw_credential_token: str = "", now=None): def evaluate_device_authorization(*, user, product_code: str, device=None, raw_credential_token: str = "", now=None):
now = now or timezone.now() now = now or timezone.now()
if device is None: if device is None:
+215
View File
@@ -8,6 +8,8 @@ from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.billing.models import PointsLedger
from apps.billing.payment_gateways import PaymentOrderCode, PaymentReceipt
from apps.licensing.models import ( from apps.licensing.models import (
ClientDevice, ClientDevice,
DeviceBindingAudit, DeviceBindingAudit,
@@ -18,14 +20,19 @@ from apps.licensing.models import (
LicenseSeat, LicenseSeat,
MigrationRequest, MigrationRequest,
SoftwareEntitlement, SoftwareEntitlement,
SoftwareOrder,
SoftwarePlan, SoftwarePlan,
) )
from apps.licensing.services import ( from apps.licensing.services import (
LicensingError, LicensingError,
SoftwareOrderAmountMismatchError,
SoftwareOrderTransactionMismatchError,
apply_software_payment,
assign_license_seat, assign_license_seat,
confirm_migration_request, confirm_migration_request,
create_legacy_migration_grant, create_legacy_migration_grant,
create_migration_request, create_migration_request,
create_software_order,
evaluate_device_authorization, evaluate_device_authorization,
grant_software_entitlement, grant_software_entitlement,
record_device_heartbeat, record_device_heartbeat,
@@ -345,6 +352,152 @@ class SoftwareEntitlementServiceTests(TestCase):
grant_software_entitlement(user=self.user, plan=self.plan, reason="") grant_software_entitlement(user=self.user, plan=self.plan, reason="")
class SoftwareOrderServiceTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="software-order-user",
email="software-order@example.com",
password="test-password",
)
self.plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="月度订阅",
duration_days=30,
price=Decimal("19.90"),
device_limit=1,
grace_days=3,
)
def create_order(self):
return create_software_order(
user=self.user,
plan=self.plan,
pay_method=SoftwareOrder.PayMethod.WEIXIN,
payment_order_func=lambda _order: PaymentOrderCode(
code_url="weixin://software-order-test",
expires_at=timezone.now() + timedelta(minutes=10),
),
)
@staticmethod
def payment_for(order, *, amount=None, transaction_id="wx-software-001"):
return PaymentReceipt(
order_no=order.order_no,
pay_method=SoftwareOrder.PayMethod.WEIXIN,
amount=amount or order.amount_money,
transaction_id=transaction_id,
paid_at=timezone.now(),
)
def test_order_snapshots_payment_once_and_never_credits_points(self):
order = self.create_order()
self.plan.name = "已修改套餐"
self.plan.price = Decimal("29.90")
self.plan.save()
first = apply_software_payment(self.payment_for(order))
second = apply_software_payment(self.payment_for(order))
order.refresh_from_db()
self.assertTrue(first.applied)
self.assertFalse(second.applied)
self.assertEqual(order.status, SoftwareOrder.Status.PAID)
self.assertEqual(order.plan_name, "月度订阅")
self.assertEqual(order.amount_money, Decimal("19.90"))
self.assertEqual(order.entitlement.plan_name, "月度订阅")
self.assertEqual(order.fulfillment_event.action, LicenseEvent.Action.ORDER_FULFILLED)
self.assertEqual(
PointsLedger.objects.filter(user=self.user).count(),
0,
)
def test_duplicate_callback_with_changed_transaction_or_amount_is_rejected(self):
order = self.create_order()
with self.assertRaises(SoftwareOrderAmountMismatchError):
apply_software_payment(self.payment_for(order, amount=Decimal("19.89")))
self.assertEqual(SoftwareEntitlement.objects.count(), 0)
apply_software_payment(self.payment_for(order))
with self.assertRaises(SoftwareOrderTransactionMismatchError):
apply_software_payment(
self.payment_for(order, transaction_id="wx-software-other")
)
def test_second_paid_order_renews_same_plan_entitlement_once(self):
first_order = self.create_order()
first = apply_software_payment(self.payment_for(first_order))
first_expiry = first.entitlement.expires_at
second_order = self.create_order()
second = apply_software_payment(
self.payment_for(second_order, transaction_id="wx-software-002")
)
self.assertEqual(second.entitlement.pk, first.entitlement.pk)
self.assertEqual(second.entitlement.expires_at, first_expiry + timedelta(days=30))
self.assertEqual(
SoftwareEntitlement.objects.filter(user=self.user).count(),
1,
)
class SoftwareOrderConcurrencyTests(TransactionTestCase):
def setUp(self):
self.user = User.objects.create_user(
username="software-order-concurrency",
email="software-order-concurrency@example.com",
password="test-password",
)
self.plan = SoftwarePlan.objects.create(
product_code=ClientDevice.ProductCode.CMSHOPEE,
name="并发订阅套餐",
duration_days=30,
price=Decimal("19.90"),
device_limit=1,
)
self.order = create_software_order(
user=self.user,
plan=self.plan,
pay_method=SoftwareOrder.PayMethod.WEIXIN,
payment_order_func=lambda _order: PaymentOrderCode(
code_url="weixin://software-order-concurrency",
expires_at=timezone.now() + timedelta(minutes=10),
),
)
def test_concurrent_same_order_callback_fulfills_once(self):
def apply_callback():
close_old_connections()
try:
order = SoftwareOrder.objects.get(pk=self.order.pk)
result = apply_software_payment(
PaymentReceipt(
order_no=order.order_no,
pay_method=SoftwareOrder.PayMethod.WEIXIN,
amount=order.amount_money,
transaction_id="wx-software-concurrency-001",
paid_at=timezone.now(),
)
)
return result.applied
finally:
close_old_connections()
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = list(executor.map(lambda _index: apply_callback(), range(2)))
self.order.refresh_from_db()
self.assertEqual(outcomes.count(True), 1)
self.assertEqual(self.order.status, SoftwareOrder.Status.PAID)
self.assertEqual(
LicenseEvent.objects.filter(
action=LicenseEvent.Action.ORDER_FULFILLED,
metadata__software_order_no=self.order.order_no,
).count(),
1,
)
class SoftwareEntitlementAdminTests(TestCase): class SoftwareEntitlementAdminTests(TestCase):
def setUp(self): def setUp(self):
self.operator = User.objects.create_user( self.operator = User.objects.create_user(
@@ -506,6 +659,19 @@ class LegacyMigrationFlowTests(TestCase):
"HTTP_X_DEVICE_SESSION": self.device_session_token, "HTTP_X_DEVICE_SESSION": self.device_session_token,
} }
def issue_credential(self):
grant = self.grant_migration()
migration_request, raw_token = create_migration_request(
user=self.user,
device=self.device,
)
_request, credential, created = confirm_migration_request(
request_id=migration_request.request_id,
user=self.user,
)
self.assertTrue(created)
return grant.entitlement, credential, raw_token
def test_request_confirm_poll_and_revoke_flow_keeps_credential_hashed(self): def test_request_confirm_poll_and_revoke_flow_keeps_credential_hashed(self):
self.grant_migration() self.grant_migration()
create_response = self.client.post( create_response = self.client.post(
@@ -673,3 +839,52 @@ class LegacyMigrationFlowTests(TestCase):
) )
self.assertTrue(expired.would_reject) self.assertTrue(expired.would_reject)
self.assertEqual(expired.code, "license_expired") self.assertEqual(expired.code, "license_expired")
def test_renewal_extends_active_credential_and_keeps_authorization_valid(self):
entitlement, credential, raw_token = self.issue_credential()
previous_credential_expiry = credential.expires_at
renewed = renew_software_entitlement(
entitlement=entitlement,
reason="用户续订",
now=timezone.now(),
)
credential.refresh_from_db()
self.assertGreater(credential.expires_at, previous_credential_expiry)
self.assertEqual(credential.expires_at, renewed.grace_expires_at)
decision = evaluate_device_authorization(
user=self.user,
product_code=ClientDevice.ProductCode.CMSHOPEE,
device=self.device,
raw_credential_token=raw_token,
)
self.assertTrue(decision.allowed)
def test_revoking_entitlement_revokes_credentials_and_releases_seats(self):
entitlement, credential, _raw_token = self.issue_credential()
revoke_software_entitlement(
entitlement=entitlement,
reason="运营撤销套餐权益",
)
credential.refresh_from_db()
credential.seat.refresh_from_db()
self.assertIsNotNone(credential.revoked_at)
self.assertEqual(credential.revoke_reason, "运营撤销套餐权益")
self.assertIsNone(credential.seat.device_id)
self.assertTrue(
LicenseEvent.objects.filter(
entitlement=entitlement,
action=LicenseEvent.Action.CREDENTIAL_REVOKED,
reason="运营撤销套餐权益",
).exists()
)
self.assertTrue(
LicenseEvent.objects.filter(
entitlement=entitlement,
action=LicenseEvent.Action.SEAT_RELEASED,
reason="运营撤销套餐权益",
).exists()
)
+23
View File
@@ -4,6 +4,7 @@ from django import forms
from django.conf import settings from django.conf import settings
from apps.billing.models import RechargeOrder from apps.billing.models import RechargeOrder
from apps.licensing.models import ClientDevice, SoftwareOrder, SoftwarePlan
class ApiKeyCreateForm(forms.Form): class ApiKeyCreateForm(forms.Form):
@@ -52,3 +53,25 @@ class RechargeCreateForm(forms.Form):
if amount > max_amount: if amount > max_amount:
raise forms.ValidationError(f"单笔充值金额不能超过 {max_amount:.2f} CNY") raise forms.ValidationError(f"单笔充值金额不能超过 {max_amount:.2f} CNY")
return amount return amount
class SoftwareOrderCreateForm(forms.Form):
plan = forms.ModelChoiceField(
label="套餐",
queryset=SoftwarePlan.objects.none(),
empty_label=None,
widget=forms.Select(attrs={"class": "form-select"}),
)
pay_method = forms.ChoiceField(
label="支付方式",
choices=((SoftwareOrder.PayMethod.WEIXIN, "微信"),),
initial=SoftwareOrder.PayMethod.WEIXIN,
widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["plan"].queryset = SoftwarePlan.objects.filter(
product_code=ClientDevice.ProductCode.CMSHOPEE,
status=SoftwarePlan.Status.ACTIVE,
).order_by("name", "id")
+1
View File
@@ -17,6 +17,7 @@
{% if user.is_authenticated %} {% if user.is_authenticated %}
<a class="btn btn-sm {% if current_url == 'portal-dashboard' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-dashboard' %} aria-current="page"{% endif %} href="{% url 'portal-dashboard' %}">控制台</a> <a class="btn btn-sm {% if current_url == 'portal-dashboard' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-dashboard' %} aria-current="page"{% endif %} href="{% url 'portal-dashboard' %}">控制台</a>
<a class="btn btn-sm {% if current_url == 'portal-recharge' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-recharge' %} aria-current="page"{% endif %} href="{% url 'portal-recharge' %}">充值</a> <a class="btn btn-sm {% if current_url == 'portal-recharge' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-recharge' %} aria-current="page"{% endif %} href="{% url 'portal-recharge' %}">充值</a>
<a class="btn btn-sm {% if current_url == 'portal-subscription' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-subscription' %} aria-current="page"{% endif %} href="{% url 'portal-subscription' %}">软件订阅</a>
<a class="btn btn-sm {% if current_url == 'portal-apikeys' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-apikeys' %} aria-current="page"{% endif %} href="{% url 'portal-apikeys' %}">API Key</a> <a class="btn btn-sm {% if current_url == 'portal-apikeys' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-apikeys' %} aria-current="page"{% endif %} href="{% url 'portal-apikeys' %}">API Key</a>
<a class="btn btn-sm {% if current_url == 'portal-models' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-models' %} aria-current="page"{% endif %} href="{% url 'portal-models' %}">可用模型</a> <a class="btn btn-sm {% if current_url == 'portal-models' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-models' %} aria-current="page"{% endif %} href="{% url 'portal-models' %}">可用模型</a>
<a class="btn btn-sm {% if current_url == 'portal-recharge-records' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-recharge-records' %} aria-current="page"{% endif %} href="{% url 'portal-recharge-records' %}">充值记录</a> <a class="btn btn-sm {% if current_url == 'portal-recharge-records' %}btn-primary{% else %}btn-outline-secondary{% endif %}"{% if current_url == 'portal-recharge-records' %} aria-current="page"{% endif %} href="{% url 'portal-recharge-records' %}">充值记录</a>
@@ -0,0 +1,109 @@
{% extends "portal/base.html" %}
{% load static %}
{% block title %}软件订阅 - 虾皮圈{% endblock %}
{% block content %}
<div class="d-flex flex-column gap-4">
<div>
<h1 class="h3 mb-1">软件订阅</h1>
<div class="text-secondary">虾皮圈优化助手</div>
</div>
<section class="cmhub-surface">
<h2 class="h5 mb-3">当前授权</h2>
{% if entitlement %}
<dl class="row mb-0">
<dt class="col-sm-3">套餐</dt><dd class="col-sm-9">{{ entitlement.plan_name }}</dd>
<dt class="col-sm-3">状态</dt><dd class="col-sm-9">{{ entitlement.status }}</dd>
<dt class="col-sm-3">到期时间</dt><dd class="col-sm-9">{{ entitlement.expires_at|date:"Y-m-d H:i" }}</dd>
<dt class="col-sm-3">宽限截止</dt><dd class="col-sm-9">{{ entitlement.grace_expires_at|date:"Y-m-d H:i" }}</dd>
</dl>
{% else %}
<div class="text-secondary">暂无有效授权记录</div>
{% endif %}
</section>
<section class="cmhub-surface">
<h2 class="h5 mb-3">购买或续订</h2>
<form method="post" class="row g-3 align-items-end" novalidate>
{% csrf_token %}
{% if form.non_field_errors %}
<div class="col-12"><div class="alert alert-danger mb-0">{{ form.non_field_errors|striptags }}</div></div>
{% endif %}
<div class="col-md-6">
<label class="form-label" for="{{ form.plan.id_for_label }}">{{ form.plan.label }}</label>
{{ form.plan }}
{% if form.plan.errors %}<div class="text-danger small mt-1">{{ form.plan.errors|striptags }}</div>{% endif %}
</div>
<div class="col-md-3">
<div class="form-label">{{ form.pay_method.label }}</div>
{% for radio in form.pay_method %}
<div class="form-check">{{ radio.tag }}<label class="form-check-label" for="{{ radio.id_for_label }}">{{ radio.choice_label }}</label></div>
{% endfor %}
</div>
<div class="col-md-3"><button class="btn btn-primary w-100" type="submit">创建订单</button></div>
</form>
</section>
{% if current_order %}
<section class="cmhub-surface" data-software-order data-order-no="{{ current_order.order_no }}" data-status="{{ current_order.status }}" data-code-url="{{ current_order.code_url }}" data-status-url="{% url 'api-software-order-status' %}">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap mb-3">
<div><h2 class="h5 mb-1">订阅订单</h2><div class="text-secondary small">订单号:<code>{{ current_order.order_no }}</code></div></div>
<span class="badge text-bg-secondary" id="software-order-status">{{ current_order.status }}</span>
</div>
{% if is_mock_payment_mode %}<div class="alert alert-warning">当前为支付测试模式,二维码不能用于真实付款。</div>{% endif %}
<div class="row g-4 align-items-start">
<div class="col-md-5"><canvas id="software-order-qr" class="border rounded bg-white p-2" width="240" height="240"></canvas></div>
<div class="col-md-7">
<dl class="row mb-0">
<dt class="col-sm-4">套餐</dt><dd class="col-sm-8">{{ current_order.plan_name }}</dd>
<dt class="col-sm-4">金额</dt><dd class="col-sm-8">{{ current_order.amount_money|floatformat:2 }} {{ current_order.currency }}</dd>
<dt class="col-sm-4">支付票据有效期</dt><dd class="col-sm-8">{{ current_order.expires_at|date:"Y-m-d H:i"|default:"-" }}</dd>
<dt class="col-sm-4">权益发放时间</dt><dd class="col-sm-8" id="software-order-fulfilled">{{ current_order.fulfilled_at|date:"Y-m-d H:i"|default:"-" }}</dd>
</dl>
<div class="alert alert-info mt-3 mb-0" id="software-order-hint" aria-live="polite">正在等待支付结果。</div>
</div>
</div>
</section>
{% endif %}
<section class="cmhub-surface">
<h2 class="h5 mb-3">最近订单</h2>
{% if recent_orders %}
<div class="table-responsive"><table class="table align-middle mb-0"><thead><tr><th>订单号</th><th>套餐</th><th>金额</th><th>状态</th><th>创建时间</th></tr></thead><tbody>
{% for order in recent_orders %}<tr><td><code>{{ order.order_no }}</code></td><td>{{ order.plan_name }}</td><td>{{ order.amount_money|floatformat:2 }} {{ order.currency }}</td><td>{{ order.status }}</td><td>{{ order.created_at|date:"Y-m-d H:i" }}</td></tr>{% endfor %}
</tbody></table></div>
{% else %}<div class="text-secondary">暂无软件订阅订单</div>{% endif %}
</section>
</div>
<script src="{% static 'portal/vendor/qrcode/qrcode.js' %}"></script>
<script>
(function () {
const panel = document.querySelector("[data-software-order]");
if (!panel) return;
const canvas = document.getElementById("software-order-qr");
const statusBadge = document.getElementById("software-order-status");
const fulfilled = document.getElementById("software-order-fulfilled");
const hint = document.getElementById("software-order-hint");
if (panel.dataset.codeUrl && window.QRCode && typeof window.QRCode.toCanvas === "function") {
window.QRCode.toCanvas(canvas, panel.dataset.codeUrl, { width: 240, margin: 1 });
}
function poll() {
fetch(panel.dataset.statusUrl + "?order_no=" + encodeURIComponent(panel.dataset.orderNo), { credentials: "same-origin", headers: { Accept: "application/json" } })
.then(function (response) { return response.json().then(function (data) { if (!response.ok) throw new Error(); return data; }); })
.then(function (data) {
statusBadge.textContent = data.status;
statusBadge.className = data.status === "paid" ? "badge text-bg-success" : "badge text-bg-secondary";
if (data.fulfilled_at) fulfilled.textContent = new Date(data.fulfilled_at).toLocaleString();
if (data.status === "paid") { hint.textContent = "订阅权益已发放,页面即将刷新。"; window.setTimeout(function () { window.location.reload(); }, 1200); return; }
hint.textContent = data.status === "pending" ? "正在等待支付结果。" : "订单当前状态为 " + data.status + "。";
if (data.status === "pending") window.setTimeout(poll, 1000);
})
.catch(function () { hint.textContent = "状态查询失败,稍后自动重试。"; window.setTimeout(poll, 3000); });
}
if (panel.dataset.status === "pending") window.setTimeout(poll, 1000);
}());
</script>
{% endblock %}
+2
View File
@@ -12,6 +12,7 @@ from .views import (
MigrationConfirmView, MigrationConfirmView,
RechargePageView, RechargePageView,
RechargeRecordListView, RechargeRecordListView,
SubscriptionPageView,
UsageRecordListView, UsageRecordListView,
) )
@@ -25,6 +26,7 @@ urlpatterns = [
path("apikeys/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"), path("apikeys/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"),
path("models", ModelCatalogView.as_view(), name="portal-models"), path("models", ModelCatalogView.as_view(), name="portal-models"),
path("recharge", RechargePageView.as_view(), name="portal-recharge"), path("recharge", RechargePageView.as_view(), name="portal-recharge"),
path("subscription", SubscriptionPageView.as_view(), name="portal-subscription"),
path( path(
"migration/confirm/<uuid:request_id>", "migration/confirm/<uuid:request_id>",
MigrationConfirmView.as_view(), MigrationConfirmView.as_view(),
+48 -2
View File
@@ -18,14 +18,16 @@ from apps.billing.services import (
get_balance_snapshot, get_balance_snapshot,
) )
from apps.users.models import ApiKey from apps.users.models import ApiKey
from apps.licensing.models import DeviceCredential, MigrationRequest from apps.licensing.models import DeviceCredential, MigrationRequest, SoftwareEntitlement, SoftwareOrder
from apps.licensing.services import ( from apps.licensing.services import (
LicensingError, LicensingError,
SoftwareOrderError,
confirm_migration_request, confirm_migration_request,
create_software_order,
revoke_device_credential, revoke_device_credential,
) )
from .forms import ApiKeyCreateForm, RechargeCreateForm from .forms import ApiKeyCreateForm, RechargeCreateForm, SoftwareOrderCreateForm
from .models import DownloadRelease, ImportTemplate from .models import DownloadRelease, ImportTemplate
@@ -250,6 +252,50 @@ class RechargePageView(LoginRequiredMixin, FormView):
return redirect(f"{recharge_url}?order_no={order.order_no}") return redirect(f"{recharge_url}?order_no={order.order_no}")
class SubscriptionPageView(LoginRequiredMixin, FormView):
template_name = "portal/subscription.html"
form_class = SoftwareOrderCreateForm
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
order_no = str(self.request.GET.get("order_no") or "").strip()
context["current_order"] = SoftwareOrder.objects.filter(
user=self.request.user,
order_no=order_no,
).first() if order_no else None
context["entitlement"] = (
SoftwareEntitlement.objects.filter(
user=self.request.user,
product_code="cmshopee",
)
.order_by("-expires_at", "-id")
.first()
)
context["recent_orders"] = SoftwareOrder.objects.filter(
user=self.request.user,
product_code="cmshopee",
).order_by("-created_at", "-id")[:10]
context["is_mock_payment_mode"] = payment_callback_mode() == "mock"
return context
def form_valid(self, form):
try:
order = create_software_order(
user=self.request.user,
plan=form.cleaned_data["plan"],
pay_method=form.cleaned_data["pay_method"],
)
except SoftwareOrderError as exc:
form.add_error(None, exc.message)
return self.form_invalid(form)
except PaymentOrderCreateError:
form.add_error(None, "支付下单失败,请稍后重试。")
return self.form_invalid(form)
messages.success(self.request, "软件订阅订单已创建,请扫码支付。")
subscription_url = reverse("portal-subscription")
return redirect(f"{subscription_url}?order_no={order.order_no}")
class MigrationConfirmView(LoginRequiredMixin, TemplateView): class MigrationConfirmView(LoginRequiredMixin, TemplateView):
template_name = "portal/migration_confirm.html" template_name = "portal/migration_confirm.html"
+1
View File
@@ -80,6 +80,7 @@ PAYMENT_MOCK_CALLBACK_SECRET = os.environ.get(
"PAYMENT_MOCK_CALLBACK_SECRET", "PAYMENT_MOCK_CALLBACK_SECRET",
"cmhub-dev-mock-callback-secret" if DEBUG else "", "cmhub-dev-mock-callback-secret" if DEBUG else "",
) )
SOFTWARE_WECHAT_PAY_NOTIFY_URL = os.environ.get("SOFTWARE_WECHAT_PAY_NOTIFY_URL", "").strip()
PAYMENT_QR_EXPIRES_MINUTES = env_int("PAYMENT_QR_EXPIRES_MINUTES", 10) PAYMENT_QR_EXPIRES_MINUTES = env_int("PAYMENT_QR_EXPIRES_MINUTES", 10)
EMAIL_BACKEND = os.environ.get( EMAIL_BACKEND = os.environ.get(
"DJANGO_EMAIL_BACKEND", "DJANGO_EMAIL_BACKEND",
+4 -1
View File
@@ -42,7 +42,9 @@ T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用
T-624/T-625 已新增 `apps.licensing`:`POST /api/v1/client/devices/register` 用既有 API Key 建立或复用设备记录,并签发数据库仅存 hash 的短期设备会话;`POST /api/v1/client/devices/heartbeat` 用 `X-Device-Session` 更新活跃观测。设备 ID 使用服务端 pepper 后的摘要,安装公钥只存摘要,默认按设备每日节流 `last_seen_at` 写入。四个生成提交入口可选解析同账号有效会话,将服务端解析的 `ClientDevice` 写入可空 `CallRecord.client_device`;无会话仍走原 API Key、计费与任务路径,异步轮询不读取设备会话。伪造/过期会话在预扣前拒绝,跨账号会话拒绝;`generation_route_usage` 仅记录产品代码、内部设备 ID 与是否带会话等白名单字段。T-628 才进入专属授权影子校验。 T-624/T-625 已新增 `apps.licensing`:`POST /api/v1/client/devices/register` 用既有 API Key 建立或复用设备记录,并签发数据库仅存 hash 的短期设备会话;`POST /api/v1/client/devices/heartbeat` 用 `X-Device-Session` 更新活跃观测。设备 ID 使用服务端 pepper 后的摘要,安装公钥只存摘要,默认按设备每日节流 `last_seen_at` 写入。四个生成提交入口可选解析同账号有效会话,将服务端解析的 `ClientDevice` 写入可空 `CallRecord.client_device`;无会话仍走原 API Key、计费与任务路径,异步轮询不读取设备会话。伪造/过期会话在预扣前拒绝,跨账号会话拒绝;`generation_route_usage` 仅记录产品代码、内部设备 ID 与是否带会话等白名单字段。T-628 才进入专属授权影子校验。
T-626 已在同一 app 建立授权基础:`SoftwarePlan` 可由运营维护产品、时长、价格、设备数、宽限期和状态;`SoftwareEntitlement` 在人工授予时复制套餐名称、价格、时长、设备数和宽限期快照,之后套餐改动不回写历史权益。服务层在事务内预创建固定数量 `LicenseSeat`,绑定时先锁权益和全部席位,避免并发超售;人工授予、续期、撤销、绑定和解绑都必须给出原因并写只读 `LicenseEvent`。续期从 `max(now, expires_at)` 起算,且全程不读取或修改点数钱包。django-admin 通过专用操作页处理授予 / 续期 / 撤销,权益、席位和事件不允许直接篡改。此阶段没有购买入口、支付订单、迁移凭证或生成授权拦截。 T-626 已在同一 app 建立授权基础:`SoftwarePlan` 可由运营维护产品、时长、价格、设备数、宽限期和状态;`SoftwareEntitlement` 在人工授予时复制套餐名称、价格、时长、设备数和宽限期快照,之后套餐改动不回写历史权益。服务层在事务内预创建固定数量 `LicenseSeat`,绑定时先锁权益和全部席位,避免并发超售;人工授予、续期、撤销、绑定和解绑都必须给出原因并写只读 `LicenseEvent`。续期从 `max(now, expires_at)` 起算,并同步延长所有未吊销 `DeviceCredential` 的到期时间;撤销权益会逐项吊销未吊销凭证、释放其席位并留审计。全程不读取或修改点数钱包。django-admin 通过专用操作页处理授予 / 续期 / 撤销,权益、席位和事件不允许直接篡改。
T-629 已完成:`SoftwareOrder` 属于 `apps.licensing`,与 `RechargeOrder`、`PointsLedger` 完全隔离。订单在创建时锁定产品、套餐、金额、时长、设备数和宽限期快照;支付成功后锁订单,校验金额/通道/交易号,首次购买创建权益,续订延长同套餐权益,并记录一项可反查订单的 `LicenseEvent(order_fulfilled)`。`(pay_method, payment_txn_no)` 唯一且已支付订单只接受原交易号,防止跨订单复用交易号;任何路径不得调用充值入账服务或修改钱包。微信下单、验签与查单只复用通用网关适配,订阅使用独立 notify URL、回调与状态查询路由。第一版不做自动代扣、退款发起或自动回收权益;已支付退款及权益撤销由运营按订单人工处理。
T-627 已建立存量迁移闭环:运营在 admin 显式创建 `LegacyMigrationGrant`,它关联一项套餐快照权益及资格快照,新注册用户不会自动创建。客户端必须以旧 API Key 加服务端验证的当前设备会话申请 `MigrationRequest`;请求只短时有效,生成的设备凭证明文只在该 API 响应出现一次。portal 仅允许请求所属账号确认,确认事务内分配席位、按请求中已存 token hash 签发 `DeviceCredential`,重复确认只返回既有结果。用户自助解绑会吊销凭证、写授权事件并释放对应席位。原始凭证、机器标识、公钥和 API Key 都不写入迁移记录 / 事件;T-628 才会用凭证参与专属接口影子校验。 T-627 已建立存量迁移闭环:运营在 admin 显式创建 `LegacyMigrationGrant`,它关联一项套餐快照权益及资格快照,新注册用户不会自动创建。客户端必须以旧 API Key 加服务端验证的当前设备会话申请 `MigrationRequest`;请求只短时有效,生成的设备凭证明文只在该 API 响应出现一次。portal 仅允许请求所属账号确认,确认事务内分配席位、按请求中已存 token hash 签发 `DeviceCredential`,重复确认只返回既有结果。用户自助解绑会吊销凭证、写授权事件并释放对应席位。原始凭证、机器标识、公钥和 API Key 都不写入迁移记录 / 事件;T-628 才会用凭证参与专属接口影子校验。
@@ -123,6 +125,7 @@ T-619 多图理解继续复用上述 URL 下载器和逐跳 SSRF 校验,并额
| CallRecord.client_device | 生成调用 | 可空关联到服务端验证后的 `ClientDevice`;仅有效设备会话的标题、图片、异步提交和 vision 写入,历史/无头调用保持为空 | | CallRecord.client_device | 生成调用 | 可空关联到服务端验证后的 `ClientDevice`;仅有效设备会话的标题、图片、异步提交和 vision 写入,历史/无头调用保持为空 |
| SoftwarePlan | 运营配置 | 产品、名称、有效天数、价格、设备数、宽限天数和启停状态;修改仅影响后续人工授予 / 购买 | | SoftwarePlan | 运营配置 | 产品、名称、有效天数、价格、设备数、宽限天数和启停状态;修改仅影响后续人工授予 / 购买 |
| SoftwareEntitlement | 授权服务 | 用户、产品、来源套餐(可空)及购买/授予时套餐快照、状态、开始/到期/宽限截止/撤销时间;不从套餐反向同步 | | SoftwareEntitlement | 授权服务 | 用户、产品、来源套餐(可空)及购买/授予时套餐快照、状态、开始/到期/宽限截止/撤销时间;不从套餐反向同步 |
| SoftwareOrder | 软件订阅支付 | 用户、来源套餐(可空)、完整套餐/金额/币种/支付通道快照、二维码票据、订单状态、支付交易号、支付/发放时间,以及目标权益和唯一的 `order_fulfilled` 事件;`order_no` 唯一,`(pay_method, payment_txn_no)` 唯一且未支付交易号为 NULL |
| LicenseSeat | 授权服务 | 每项权益按序号预创建的固定席位,可空关联已绑定设备及绑定 / 解绑时间;`(entitlement, seat_number)` 唯一 | | LicenseSeat | 授权服务 | 每项权益按序号预创建的固定席位,可空关联已绑定设备及绑定 / 解绑时间;`(entitlement, seat_number)` 唯一 |
| LicenseEvent | 授权服务 | 授予、续期、撤销、绑定、解绑等不可变事件;保存权益、可选席位/设备、操作人、必填原因和非敏感元数据 | | LicenseEvent | 授权服务 | 授予、续期、撤销、绑定、解绑等不可变事件;保存权益、可选席位/设备、操作人、必填原因和非敏感元数据 |
| LegacyMigrationGrant | 运营迁移 | 显式授予的存量用户资格、资格快照、关联权益、操作人和原因;`(user, product_code)` 唯一,禁止注册自动创建 | | LegacyMigrationGrant | 运营迁移 | 显式授予的存量用户资格、资格快照、关联权益、操作人和原因;`(user, product_code)` 唯一,禁止注册自动创建 |
+1 -1
View File
@@ -107,7 +107,7 @@
| T-626 | 软件套餐、权益与设备席位基础模型 | T-624, T-401 | 已完成阶段 2 基础模型:新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent` 和 `licensing.0002_softwareentitlement_licenseseat_licenseevent_and_more`。授予时复制套餐名称/价格/时长/设备数/宽限期快照并预创建固定席位;`(entitlement, seat_number)` 唯一。服务层以事务锁权益和席位执行授予、续期、撤销、绑定与解绑,所有人工操作原因必填并写事件;续期从 `max(now, expires_at)` 起算,不触碰钱包。admin 提供套餐维护及权益授予/续期/撤销专用入口,权益/席位/事件只读。未接购买、支付、迁移凭证或生成授权。已通过套餐快照、续期、撤销、审计、后台权限及双连接单席位并发测试、迁移和 `check`。**评审遗留(2026-07-21,须在 T-629 强制前修复)**:① `renew_software_entitlement` 只更新权益自身字段,不刷新该权益下已签发 `DeviceCredential.expires_at`(签发时钉死为旧 `grace_expires_at`),续费用户在旧宽限期后会被判 `license_expired`;现有续期测试只断言权益未覆盖凭证。② `revoke_software_entitlement` 不吊销凭证也不释放席位,裁决层由 `is_usable_at()` 兜底不构成安全问题,但留下 `revoked_at=null` 凭证与占用席位的数据残留。 | DONE | | T-626 | 软件套餐、权益与设备席位基础模型 | T-624, T-401 | 已完成阶段 2 基础模型:新增 `SoftwarePlan`、`SoftwareEntitlement`、`LicenseSeat`、`LicenseEvent` 和 `licensing.0002_softwareentitlement_licenseseat_licenseevent_and_more`。授予时复制套餐名称/价格/时长/设备数/宽限期快照并预创建固定席位;`(entitlement, seat_number)` 唯一。服务层以事务锁权益和席位执行授予、续期、撤销、绑定与解绑,所有人工操作原因必填并写事件;续期从 `max(now, expires_at)` 起算,不触碰钱包。admin 提供套餐维护及权益授予/续期/撤销专用入口,权益/席位/事件只读。未接购买、支付、迁移凭证或生成授权。已通过套餐快照、续期、撤销、审计、后台权限及双连接单席位并发测试、迁移和 `check`。**评审遗留(2026-07-21,须在 T-629 强制前修复)**:① `renew_software_entitlement` 只更新权益自身字段,不刷新该权益下已签发 `DeviceCredential.expires_at`(签发时钉死为旧 `grace_expires_at`),续费用户在旧宽限期后会被判 `license_expired`;现有续期测试只断言权益未覆盖凭证。② `revoke_software_entitlement` 不吊销凭证也不释放席位,裁决层由 `is_usable_at()` 兜底不构成安全问题,但留下 `revoked_at=null` 凭证与占用席位的数据残留。 | DONE |
| T-627 | 存量用户迁移权益、网页确认与设备凭证 | T-624, T-626, T-501 | 已完成存量迁移闭环:新增 `LegacyMigrationGrant`、短时 `MigrationRequest` 和 `DeviceCredential`,均只保存 hash/摘要;admin 通过显式用户+套餐授予迁移资格和快照,新注册用户无自动路径。客户端用旧 API Key + 当前设备会话申请迁移并一次性取得凭证明文;同账号 portal 确认后事务内绑定席位、按 hash 签发凭证,重复确认/轮询不重复占位或签发。用户可自助解绑,凭证吊销并释放席位且写审计。新增迁移 API、portal 确认/设备页;旧生成 API 完全不变。已覆盖资格、缺会话、跨账号、过期、重复确认、凭证 hash、状态轮询与解绑;迁移 / `check` / 迁移一致性通过。 | DONE | | T-627 | 存量用户迁移权益、网页确认与设备凭证 | T-624, T-626, T-501 | 已完成存量迁移闭环:新增 `LegacyMigrationGrant`、短时 `MigrationRequest` 和 `DeviceCredential`,均只保存 hash/摘要;admin 通过显式用户+套餐授予迁移资格和快照,新注册用户无自动路径。客户端用旧 API Key + 当前设备会话申请迁移并一次性取得凭证明文;同账号 portal 确认后事务内绑定席位、按 hash 签发凭证,重复确认/轮询不重复占位或签发。用户可自助解绑,凭证吊销并释放席位且写审计。新增迁移 API、portal 确认/设备页;旧生成 API 完全不变。已覆盖资格、缺会话、跨账号、过期、重复确认、凭证 hash、状态轮询与解绑;迁移 / `check` / 迁移一致性通过。 | DONE |
| T-628 | 蝦皮圈专属授权入口与影子校验 | T-625, T-626, T-627, T-613, T-614 | 已完成:新增 `/api/v1/cmshopee/` 的 title、vision、异步图片提交和已接受任务读取,均复用原 API / 计费 core。统一授权判定检查设备会话、凭证、用户、产品、席位和权益,返回 `device_not_bound` / `device_mismatch` / `license_required` / `license_expired`;当前仅写脱敏 `would_reject` 日志,不阻断专属或通用调用。通用 `/api/v1/generate/*` 未改,任务读取不追加订阅校验;`CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true` 为部署口径。已验证正确 / 缺失 / 过期凭证判定、迁移流程回归、`check` 和迁移一致性。**评审说明(2026-07-21)**:`CMSHOPEE_AUTHORIZATION_SHADOW_MODE` 当前仅被写入日志事件,没有任何分支依据它决定放行或拒绝,属阶段 2 预期行为;须留档提醒——切换强制拦截不是改环境变量即可,需要在专属入口显式加入拒绝分支并同步部署说明,避免运维误判为纯配置开关。 | DONE | | T-628 | 蝦皮圈专属授权入口与影子校验 | T-625, T-626, T-627, T-613, T-614 | 已完成:新增 `/api/v1/cmshopee/` 的 title、vision、异步图片提交和已接受任务读取,均复用原 API / 计费 core。统一授权判定检查设备会话、凭证、用户、产品、席位和权益,返回 `device_not_bound` / `device_mismatch` / `license_required` / `license_expired`;当前仅写脱敏 `would_reject` 日志,不阻断专属或通用调用。通用 `/api/v1/generate/*` 未改,任务读取不追加订阅校验;`CMSHOPEE_AUTHORIZATION_SHADOW_MODE=true` 为部署口径。已验证正确 / 缺失 / 过期凭证判定、迁移流程回归、`check` 和迁移一致性。**评审说明(2026-07-21)**:`CMSHOPEE_AUTHORIZATION_SHADOW_MODE` 当前仅被写入日志事件,没有任何分支依据它决定放行或拒绝,属阶段 2 预期行为;须留档提醒——切换强制拦截不是改环境变量即可,需要在专属入口显式加入拒绝分支并同步部署说明,避免运维误判为纯配置开关。 | DONE |
| T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | **DOING。** 新增与 `RechargeOrder`、`PointsLedger` 完全分离的 `SoftwareOrder`,实现蝦皮圈套餐选择、创建待支付订单、支付回调/主动查单后的幂等权益发放与续期;不得把软件订阅金额兑换为点数,也不得修改既有充值订单、充值回调或钱包入账契约。订单必须锁定产品代码、套餐名称、金额、币种、时长、设备数、宽限期和支付通道快照,并保存明确的发放结果(目标权益、`LicenseEvent`、发放时间);支付渠道加 `payment_txn_no` 必须只归属一个软件订单,已支付订单收到不同交易号一律拒绝。微信支付能力仅复用验签、下单、查单的渠道适配,订阅回调/查单服务与路由独立于 `RechargePayment` / `apply_recharge_payment()`。回调必须验签、校验订单金额/通道、锁订单并按订单号幂等;同一订单的并发或重复回调最多发放/续期一次。portal 提供订阅状态、套餐购买/续订入口和订单只读记录;订单状态明确为 `pending -> paid`,渠道下单失败为 `failed`,过期支付票据为 `expired`,不得把未支付订单发放权益。第一版“月订阅”由用户每月主动续订,不假设自动代扣协议。**退款范围:本卡不新增退款发起或自动退款回收权益。** 已支付订单的退款及关联权益撤销走运营人工流程、必须按订单留痕;要支持自动回收时,需另建按订单权益周期/发放账本任务,不能简单缩短累计 `expires_at` 而误伤后续续费。测试覆盖订单/套餐快照、同订单重复与并发回调、不同交易号、金额/通道/验签不一致、主动查单、续期起算、未支付不发权益、充值链路回归,以及回调 HTTP 契约。真实生产支付验收依赖既有微信回调到账闭环修复;未提供商户条件时仅按 mock/SDK 契约测试。**前置修复(本卡支付上线验收前完成)**:先修 T-626 遗留 ①,在 `renew_software_entitlement` 事务内同步刷新该权益下所有未吊销 `DeviceCredential.expires_at`,并测试续期后凭证仍可通过授权判定;再修遗留 ②,撤销权益时吊销未吊销凭证并释放其占用席位,逐项写审计事件。 | DOING | | T-629 | 软件套餐购买、续订订单与权益入账 | T-626, T-627, T-628, T-304, T-305 | **已完成。** 新增与 `RechargeOrder`、`PointsLedger` 完全分离的 `SoftwareOrder` 和 `licensing.0004_alter_licenseevent_action_softwareorder`。订单锁定产品、套餐、金额、币种、时长、设备数、宽限期和通道快照,保存关联权益、唯一 `order_fulfilled` 事件和发放时间;`order_no` 与 `(pay_method, payment_txn_no)` 受数据库唯一约束,已支付订单收到不同交易号一律拒绝。微信支付仅复用通用验签、下单、查单适配,新增独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,未调用 `apply_recharge_payment()`、未写 `UserWallet` / `PointsLedger`。portal 新增 `/subscription` 套餐购买/续订、状态和最近订单,admin 新增只读软件套餐订单检索。成功回调/查单锁订单后首次创建权益或续订同套餐权益;同订单重复/并发回调最多发放一次,金额/通道/验签不一致不发权益。支付票据到期转 `expired`,下单失败转 `failed`。第一版仅微信主动月度续订,不做自动代扣、退款发起或自动退款回收;已支付退款与权益撤销由运营按订单人工处理。**同时修复 T-626 遗留**:续期同步刷新未吊销凭证到期时间,撤销权益逐项吊销凭证、释放席位并写审计。已通过 12 条 licensing/API 目标测试(含双连接并发回调)和 31 条既有充值回归;`check`、`makemigrations --check --dry-run`、编译检查通过。全量测试在远端 MySQL 测试库运行 10 分钟未返回且无失败输出,未将其记为通过。 | DONE |
## 里程碑 ## 里程碑
+16
View File
@@ -39,6 +39,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-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create` 与 `GET /api/v1/recharge/status` 走用户端 `SessionAuthentication + CSRF`,不接受 API Key;下单创建 pending 订单并锁定汇率/点数,再返回微信 `code_url` 或支付宝 `qr_code`;状态查询只允许订单所属用户访问,并在 pending 时尝试主动查单补入账,查单不可用时保持 pending 等回调。
T-629 已完成:软件套餐订单使用独立 `SoftwareOrder`、`POST /api/v1/software-orders/callback/wechat` 与 `GET /api/v1/software-orders/status`,只发放/续订软件权益,绝不兑换点数或触碰 `RechargeOrder` / `PointsLedger`。订阅微信下单必须配置独立 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`,不得复用充值回调地址。
T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated`,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。 T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated`,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。
T-604 目标口径:生成接口在 serializer 基础校验后先执行 prompt 本地敏感词检查;命中返回 `400 content_blocked`,且不得下载 `image_url`、不得预扣点、不得写 `CallRecord` / `PointsLedger`、不得调用上游。T-604 不启用输出审核和图片审核;详细规则见 [`moderation.md`](moderation.md)。 T-604 目标口径:生成接口在 serializer 基础校验后先执行 prompt 本地敏感词检查;命中返回 `400 content_blocked`,且不得下载 `image_url`、不得预扣点、不得写 `CallRecord` / `PointsLedger`、不得调用上游。T-604 不启用输出审核和图片审核;详细规则见 [`moderation.md`](moderation.md)。
@@ -686,6 +688,20 @@ query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult
- 若订单仍为 `pending`,服务端会尝试主动查单并复用 `query_and_apply_recharge_payment()` 补入账;查单不可用或尚未支付时仍返回当前本地状态。 - 若订单仍为 `pending`,服务端会尝试主动查单并复用 `query_and_apply_recharge_payment()` 补入账;查单不可用或尚未支付时仍返回当前本地状态。
- 到账以服务端回调或主动查单后的本地订单状态为准;`is_expired` 仅是二维码本地有效期提示,不自动阻断延迟到达的真实支付回调。 - 到账以服务端回调或主动查单后的本地订单状态为准;`is_expired` 仅是二维码本地有效期提示,不自动阻断延迟到达的真实支付回调。
## 软件套餐订阅支付
`/subscription` 是已登录用户的虾皮圈套餐购买/续订页,使用 session + CSRF 创建软件订单并展示二维码。第一版只开放微信主动月度续订,不接受 API Key,不支持自动代扣。
### `POST /api/v1/software-orders/callback/wechat`
微信服务端回调,`@csrf_exempt`、无登录态。先按既有微信 V3/mock 协议验签,再锁 `SoftwareOrder`,校验 `out_trade_no`、支付通道、金额和交易号;同订单同交易号重复回调返回成功但不重复发放,已支付订单收到不同交易号返回 `400 bad_request`。成功时创建/续订 `SoftwareEntitlement` 并写 `LicenseEvent(order_fulfilled)`,不写 `UserWallet` 或 `PointsLedger`。
### `GET /api/v1/software-orders/status?order_no=...`
仅当前登录用户可读取自己的软件订单。pending 订单先检查二维码本地过期状态,再尝试主动查单并走与回调相同的权益发放服务。响应字段为 `order_no`、`product_code`、`plan_name`、`amount`、`currency`、`pay_method`、`status`、`code_url`、`expires_at`、`paid_at` 与 `fulfilled_at`。
退款不属于该接口或自动化范围:已支付订单的退款和权益撤销由运营按订单人工处理;不得根据某一笔旧订单直接回滚累计权益到期时间。
## AI 调用模块合约(`apps/ai`) ## AI 调用模块合约(`apps/ai`)
分两层:**别名解析** + **Provider 适配器**。API 层只传别名,由本模块解析到具体模型并选适配器。 分两层:**别名解析** + **Provider 适配器**。API 层只传别名,由本模块解析到具体模型并选适配器。
+5 -5
View File
@@ -75,11 +75,11 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 任务状态以 [`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-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。 - 已完成: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 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送试用点数(当前 10 点);T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core);T-614 生图异步任务化接口(提交+轮询,新增不动旧接口);T-615 旧同步生图接口用量遥测 + 弃用口径;T-616 生图失败自动重试 2 次;T-617 桌面端版本检查接口增加文件大小字段;T-618 客户端发布版本后台必填文件校验元数据。
- 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型;T-627 存量用户迁移权益、网页确认与设备凭证。 - 已完成补充:T-619 多张图片理解并返回文字;T-620 图生图支持单图 / 多图主图与参考图;T-622 图片生成任务后台图片缩略预览;T-623 图片生成任务单图 / 多图筛选;T-624 蝦皮圈设备登记与会话观测;T-625 蝦皮圈设备使用关联与迁移观测;T-626 软件套餐、权益与设备席位基础模型及凭证续期/撤销清理;T-627 存量用户迁移权益、网页确认与设备凭证;T-628 蝦皮圈专属授权入口与影子校验;T-629 软件套餐购买、续订订单与权益入账。
- 正在进行:T-629 软件套餐购买、续订订单与权益入账;先修 T-626 的凭证续期同步与撤销清理,再建设与充值隔离的软件订单、支付回调和 portal。 - 正在进行:无。
- 待开始:T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。 - 待开始:T-621 注册赠点运营后台配置继续留在 Backlog。真实支付回调到账闭环、客户端发布、生产多图理解模型配置和线上旧同步接口用量观察仍可继续拆任务。
- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-616 并完成工程侧处理。
- 当前任务:T-629 软件套餐购买、续订订单与权益入账。软件订阅订单与 `RechargeOrder` / 点数账本隔离;第一版不实现自动退款回收权益,真实支付验收仍依赖微信回调到账闭环。T-621 保留在 Backlog,具体范围见 [`06-tasks.md`](06-tasks.md)。 - 下一个可领取任务:无。T-621 保留在 Backlog,真实微信商户支付验收仍是线上 blocker;软件订阅订单已完成代码与 mock/SDK 契约验证,但未进行真实付款验收。具体范围见 [`06-tasks.md`](06-tasks.md)。
## 当前可运行内容 ## 当前可运行内容
@@ -153,8 +153,8 @@ T-619 已落地同步多图理解:调用方提交有序 `images` 列表,服
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。 2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前应先做 T-629。 4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前没有可直接领取的任务。
5. T-624~T-629 按设备观测 → 套餐权益 → 存量迁移 → 专属授权影子校验 → 订阅订单顺序执行;T-629 前须保留并验证真实支付回调到账闭环。 5. T-624~T-629 已按设备观测 → 套餐权益 → 存量迁移 → 专属授权影子校验 → 订阅订单顺序完成;生产启用前仍须用真实微信商户完成充值和订阅两条回调闭环验收。
## 维护规则 ## 维护规则
+1
View File
@@ -138,6 +138,7 @@ T-604 只做 prompt 本地敏感词快筛。命中时返回 `content_blocked`,
| `WECHAT_PAY_CERT_SERIAL_NO` | 真实支付是 | `ABC...` | 商户证书序列号 | | `WECHAT_PAY_CERT_SERIAL_NO` | 真实支付是 | `ABC...` | 商户证书序列号 |
| `WECHAT_PAY_PRIVATE_KEY_PATH` | 真实支付是 | `/secure/wechat/apiclient_key.pem` | 私钥文件路径 | | `WECHAT_PAY_PRIVATE_KEY_PATH` | 真实支付是 | `/secure/wechat/apiclient_key.pem` | 私钥文件路径 |
| `WECHAT_PAY_NOTIFY_URL` | 真实支付是 | `https://cmhub.example.com/api/v1/recharge/callback/wechat` | 公网回调地址 | | `WECHAT_PAY_NOTIFY_URL` | 真实支付是 | `https://cmhub.example.com/api/v1/recharge/callback/wechat` | 公网回调地址 |
| `SOFTWARE_WECHAT_PAY_NOTIFY_URL` | 软件订阅真实支付是 | `https://cmhub.example.com/api/v1/software-orders/callback/wechat` | 软件套餐订单专用微信回调地址;必须与充值回调地址不同,未配置时 SDK 下单失败且订单标为 failed |
### 支付宝当面付 ### 支付宝当面付
+1
View File
@@ -78,6 +78,7 @@ T-627 已新增迁移申请 / 轮询和网页登录确认:客户端必须同
| 设备会话 / 审计 | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 | | 设备会话 / 审计 | DeviceSession / DeviceBindingAudit | 只读查看会话有效期、吊销状态及登记/心跳审计;不展示会话令牌或安装公钥 |
| 软件套餐 | SoftwarePlan | 维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态;修改不回写已有权益快照 | | 软件套餐 | SoftwarePlan | 维护蝦皮圈套餐的时长、价格、设备数、宽限期与启停状态;修改不回写已有权益快照 |
| 软件权益 | SoftwareEntitlement | 只读查看用户权益和套餐快照;通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 | | 软件权益 | SoftwareEntitlement | 只读查看用户权益和套餐快照;通过专用后台页面人工授予、续期或撤销,三种操作均必须填写原因并写授权事件 |
| 软件套餐订单 | SoftwareOrder | 只读检索套餐快照、金额、支付通道、交易号、状态、关联权益与发放时间;不允许后台直接修改订单、交易号或权益关联 |
| 授权席位 / 授权事件 | LicenseSeat / LicenseEvent | 只读检索设备绑定、解绑时间和全部授权审计;不支持后台直接编辑席位或事件 | | 授权席位 / 授权事件 | LicenseSeat / LicenseEvent | 只读检索设备绑定、解绑时间和全部授权审计;不支持后台直接编辑席位或事件 |
| 存量迁移资格 | LegacyMigrationGrant | 通过专用后台入口按用户与套餐显式授予;资格快照、关联权益和原因只读,新注册用户不会自动创建 | | 存量迁移资格 | LegacyMigrationGrant | 通过专用后台入口按用户与套餐显式授予;资格快照、关联权益和原因只读,新注册用户不会自动创建 |
| 迁移请求 / 设备凭证 | MigrationRequest / DeviceCredential | 只读排查短时确认状态和凭证前缀 / 吊销时间;不展示令牌明文或 hash | | 迁移请求 / 设备凭证 | MigrationRequest / DeviceCredential | 只读排查短时确认状态和凭证前缀 / 吊销时间;不展示令牌明文或 hash |
+7
View File
@@ -2104,3 +2104,10 @@
- 先收紧任务口径:软件订单、支付回调与权益发放必须和 `RechargeOrder` / `PointsLedger` 隔离;支付交易号在软件订单域内唯一,并可追溯至目标权益和授权事件。 - 先收紧任务口径:软件订单、支付回调与权益发放必须和 `RechargeOrder` / `PointsLedger` 隔离;支付交易号在软件订单域内唯一,并可追溯至目标权益和授权事件。
- 第一版只支持用户主动月度续订,不实现自动代扣,也不实现支付退款后的自动权益回收;退款与撤销走带订单号和原因的运营人工流程。自动回收须在未来以按订单权益周期/发放账本为基础单列任务,不能直接回滚累计到期时间。 - 第一版只支持用户主动月度续订,不实现自动代扣,也不实现支付退款后的自动权益回收;退款与撤销走带订单号和原因的运营人工流程。自动回收须在未来以按订单权益周期/发放账本为基础单列任务,不能直接回滚累计到期时间。
- 实现顺序:先修 T-626 的凭证续期同步和撤销清理,再实现独立软件订单、独立回调/查单、portal 与 HTTP/并发回归测试。 - 实现顺序:先修 T-626 的凭证续期同步和撤销清理,再实现独立软件订单、独立回调/查单、portal 与 HTTP/并发回归测试。
## 2026-07-21 完成:T-629 软件套餐购买、续订订单与权益入账
- 实现:新增 `SoftwareOrder`、`LicenseEvent(order_fulfilled)` 和 licensing `0004` 迁移;软件订单锁定套餐/金额快照,支付交易号按通道唯一,并关联目标权益、发放事件和发放时间。支付成功仅创建或续订 `SoftwareEntitlement`,不写 `RechargeOrder`、`UserWallet` 或 `PointsLedger`。
- 支付:支付网关抽取无业务副作用的 `PaymentReceipt`,充值继续将其交给 `apply_recharge_payment()`;订阅改用独立的微信回调/状态路由和 `SOFTWARE_WECHAT_PAY_NOTIFY_URL`,避免软件支付进入点数充值回调。portal 新增 `/subscription`,admin 新增只读软件套餐订单。
- T-626 修复:续期在事务内刷新全部未吊销设备凭证到新的宽限截止;撤销权益会吊销未吊销凭证、释放已占席位并分别写审计事件。
- 验证:`py -3.12 manage.py test apps.licensing.tests.SoftwareOrderServiceTests apps.licensing.tests.SoftwareOrderConcurrencyTests apps.licensing.tests.LegacyMigrationFlowTests apps.api.tests.SoftwareOrderCallbackApiTests --keepdb` 通过(12 tests);`py -3.12 manage.py test apps.billing.tests.BillingServiceTests apps.api.tests.RechargeCallbackApiTests apps.api.tests.RechargeCreateStatusApiTests --keepdb` 通过(31 tests);`py -3.12 manage.py check`、`py -3.12 manage.py makemigrations --check --dry-run` 和 `compileall` 通过。两组均仅有既有 allauth MySQL 条件唯一约束 `models.W036` 警告。完整 `manage.py test --keepdb` 在远端 MySQL 测试库运行 10 分钟无失败输出后超时,未记为通过;线上真实微信付款验收仍未执行。