feat: add software subscription orders
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 层只传别名,由本模块解析到具体模型并选适配器。
|
||||||
|
|||||||
@@ -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 已按设备观测 → 套餐权益 → 存量迁移 → 专属授权影子校验 → 订阅订单顺序完成;生产启用前仍须用真实微信商户完成充值和订阅两条回调闭环验收。
|
||||||
|
|
||||||
## 维护规则
|
## 维护规则
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
### 支付宝当面付
|
### 支付宝当面付
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 分钟无失败输出后超时,未记为通过;线上真实微信付款验收仍未执行。
|
||||||
|
|||||||
Reference in New Issue
Block a user