Files
cmhub/apps/licensing/services.py
T

997 lines
34 KiB
Python

from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import timedelta
from decimal import Decimal
from decimal import InvalidOperation
from django.conf import settings
from django.db import IntegrityError, transaction
from django.utils import timezone
from apps.licensing.models import (
ClientDevice,
DeviceCredential,
DeviceBindingAudit,
DeviceSession,
LegacyMigrationGrant,
LicenseEvent,
LicenseSeat,
MigrationRequest,
SoftwareEntitlement,
SoftwareOrder,
SoftwarePlan,
)
class DeviceRegistrationError(Exception):
def __init__(self, code: str, message: str):
self.code = code
self.message = message
super().__init__(message)
class DeviceSessionValidationError(DeviceRegistrationError):
pass
class LicensingError(Exception):
def __init__(self, code: str, message: str):
self.code = code
self.message = 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)
class AuthorizationDecision:
product_code: str
allowed: bool
code: str
credential_id: int | None = None
@property
def would_reject(self) -> bool:
return not self.allowed
@dataclass(frozen=True)
class SubscriptionAuthorizationDecision:
product_code: str
allowed: bool
code: str
entitlement: SoftwareEntitlement | None = None
@property
def would_reject(self) -> bool:
return not self.allowed
@dataclass(frozen=True)
class DeviceRegistrationResult:
device: ClientDevice
session: DeviceSession
session_token: str
created: bool
def _session_expiry(now):
return now + timedelta(seconds=max(60, settings.DEVICE_SESSION_TTL_SECONDS))
def _find_or_create_device(*, user, product_code, device_id_version, device_id, public_key, platform, client_version, now):
device_fingerprint = ClientDevice.fingerprint_device_id(device_id_version, device_id)
public_key_fingerprint = ClientDevice.fingerprint_public_key(public_key)
lookup = {
"user": user,
"product_code": product_code,
"device_fingerprint": device_fingerprint,
}
device = ClientDevice.objects.select_for_update().filter(**lookup).first()
created = False
if device is None:
try:
with transaction.atomic():
device = ClientDevice.objects.create(
**lookup,
device_id_version=device_id_version,
public_key_fingerprint=public_key_fingerprint,
platform=platform,
client_version=client_version,
last_seen_at=now,
)
created = True
except IntegrityError:
device = ClientDevice.objects.select_for_update().get(**lookup)
if device.status != ClientDevice.Status.ACTIVE:
raise DeviceRegistrationError("device_revoked", "设备已被吊销")
if not device.public_key_fingerprint == public_key_fingerprint:
raise DeviceRegistrationError("device_identity_mismatch", "设备身份校验失败")
updates = []
if device.platform != platform:
device.platform = platform
updates.append("platform")
if device.client_version != client_version:
device.client_version = client_version
updates.append("client_version")
update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS))
if now - device.last_seen_at >= update_after:
device.last_seen_at = now
updates.append("last_seen_at")
if updates:
device.save(update_fields=[*updates, "updated_at"])
return device, created
@transaction.atomic
def register_device(*, user, api_key, product_code, device_id_version, device_id, public_key, platform, client_version):
now = timezone.now()
device, created = _find_or_create_device(
user=user,
product_code=product_code,
device_id_version=device_id_version,
device_id=device_id,
public_key=public_key,
platform=platform,
client_version=client_version,
now=now,
)
# Registration rotates the short-lived session while keeping one device row.
DeviceSession.objects.filter(
device=device,
revoked_at__isnull=True,
expires_at__gt=now,
).update(revoked_at=now)
raw_token = DeviceSession.generate_plaintext_token()
session = DeviceSession.objects.create(
device=device,
token_hash=DeviceSession.hash_token(raw_token),
expires_at=_session_expiry(now),
)
if created:
DeviceBindingAudit.objects.create(
user=user,
device=device,
api_key=api_key,
action=DeviceBindingAudit.Action.REGISTERED,
client_version=client_version,
)
DeviceBindingAudit.objects.create(
user=user,
device=device,
api_key=api_key,
action=DeviceBindingAudit.Action.SESSION_ISSUED,
client_version=client_version,
)
return DeviceRegistrationResult(
device=device,
session=session,
session_token=raw_token,
created=created,
)
@transaction.atomic
def record_device_heartbeat(session: DeviceSession, *, now=None) -> bool:
now = now or timezone.now()
session = (
DeviceSession.objects.select_for_update()
.select_related("device", "device__user")
.get(pk=session.pk)
)
if not session.is_active_at(now) or session.device.status != ClientDevice.Status.ACTIVE:
raise DeviceRegistrationError("device_revoked", "设备会话不可用")
update_after = timedelta(seconds=max(60, settings.DEVICE_ACTIVITY_UPDATE_SECONDS))
last_seen_at = session.device.last_seen_at
if now - last_seen_at < update_after:
return False
ClientDevice.objects.filter(pk=session.device_id).update(last_seen_at=now)
DeviceSession.objects.filter(pk=session.pk).update(last_used_at=now)
DeviceBindingAudit.objects.create(
user=session.device.user,
device=session.device,
action=DeviceBindingAudit.Action.HEARTBEAT,
client_version=session.device.client_version,
)
return True
def resolve_optional_device_session(*, user, raw_token: str) -> DeviceSession | None:
raw_token = str(raw_token or "").strip()
if not raw_token:
return None
try:
session = DeviceSession.objects.select_related("device", "device__user").get(
token_hash=DeviceSession.hash_token(raw_token)
)
except DeviceSession.DoesNotExist as exc:
raise DeviceSessionValidationError(
"device_session_invalid",
"设备会话无效或已过期",
) from exc
if not session.matches_token(raw_token) or not session.is_active_at():
raise DeviceSessionValidationError("device_session_invalid", "设备会话无效或已过期")
if session.device.status != ClientDevice.Status.ACTIVE:
raise DeviceSessionValidationError("device_revoked", "设备已被吊销")
if session.device.user_id != user.id:
raise DeviceSessionValidationError("device_mismatch", "设备会话不属于当前账号")
return session
def _required_reason(reason: str) -> str:
normalized_reason = str(reason or "").strip()
if not normalized_reason:
raise LicensingError("reason_required", "必须填写操作原因")
return normalized_reason
def _create_license_event(
*,
entitlement: SoftwareEntitlement,
action: str,
reason: str,
actor=None,
seat: LicenseSeat | None = None,
device: ClientDevice | None = None,
metadata: dict | None = None,
) -> LicenseEvent:
return LicenseEvent.objects.create(
entitlement=entitlement,
seat=seat,
device=device,
actor=actor,
action=action,
reason=_required_reason(reason),
metadata=metadata or {},
)
@transaction.atomic
def grant_software_entitlement(*, user, plan: SoftwarePlan, reason: str, actor=None, starts_at=None):
reason = _required_reason(reason)
if plan.status != SoftwarePlan.Status.ACTIVE:
raise LicensingError("plan_inactive", "套餐已停用,不能授予权益")
now = timezone.now()
starts_at = starts_at or now
expires_at = starts_at + timedelta(days=plan.duration_days)
grace_expires_at = expires_at + timedelta(days=plan.grace_days)
entitlement = SoftwareEntitlement.objects.create(
user=user,
product_code=plan.product_code,
source_plan=plan,
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,
starts_at=starts_at,
expires_at=expires_at,
grace_expires_at=grace_expires_at,
)
LicenseSeat.objects.bulk_create(
[
LicenseSeat(entitlement=entitlement, seat_number=seat_number)
for seat_number in range(1, plan.device_limit + 1)
]
)
_create_license_event(
entitlement=entitlement,
action=LicenseEvent.Action.GRANTED,
reason=reason,
actor=actor,
metadata={
"source_plan_id": plan.id,
"expires_at": expires_at.isoformat(),
"device_limit": plan.device_limit,
},
)
return entitlement
@transaction.atomic
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)
now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if locked_entitlement.status == SoftwareEntitlement.Status.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)
locked_entitlement.expires_at = extension_start + timedelta(
days=duration_days
)
locked_entitlement.grace_expires_at = locked_entitlement.expires_at + timedelta(
days=grace_days
)
locked_entitlement.status = SoftwareEntitlement.Status.ACTIVE
locked_entitlement.revoked_at = None
locked_entitlement.save(
update_fields=(
"expires_at",
"grace_expires_at",
"status",
"revoked_at",
"updated_at",
)
)
DeviceCredential.objects.filter(
entitlement=locked_entitlement,
revoked_at__isnull=True,
).update(expires_at=locked_entitlement.grace_expires_at)
_create_license_event(
entitlement=locked_entitlement,
action=LicenseEvent.Action.RENEWED,
reason=reason,
actor=actor,
metadata={
"extension_start": extension_start.isoformat(),
"expires_at": locked_entitlement.expires_at.isoformat(),
"duration_days": duration_days,
"grace_days": grace_days,
},
)
return locked_entitlement
@transaction.atomic
def revoke_software_entitlement(*, entitlement: SoftwareEntitlement, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if locked_entitlement.status == SoftwareEntitlement.Status.REVOKED:
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.revoked_at = now
locked_entitlement.save(update_fields=("status", "revoked_at", "updated_at"))
_create_license_event(
entitlement=locked_entitlement,
action=LicenseEvent.Action.REVOKED,
reason=reason,
actor=actor,
)
for credential in active_credentials:
revoke_device_credential(
credential=credential,
reason=reason,
actor=actor,
now=now,
)
return locked_entitlement
@transaction.atomic
def assign_license_seat(*, entitlement: SoftwareEntitlement, device: ClientDevice, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_entitlement = SoftwareEntitlement.objects.select_for_update().get(pk=entitlement.pk)
if not locked_entitlement.is_usable_at(now):
raise LicensingError("entitlement_unavailable", "软件权益当前不可用")
if device.user_id != locked_entitlement.user_id:
raise LicensingError("device_user_mismatch", "设备不属于权益用户")
if device.product_code != locked_entitlement.product_code:
raise LicensingError("device_product_mismatch", "设备产品与权益不匹配")
if device.status != ClientDevice.Status.ACTIVE:
raise LicensingError("device_revoked", "设备已被吊销")
seats = list(
LicenseSeat.objects.select_for_update()
.filter(entitlement=locked_entitlement)
.order_by("seat_number")
)
existing_seat = next((seat for seat in seats if seat.device_id == device.id), None)
if existing_seat is not None:
return existing_seat
available_seat = next((seat for seat in seats if seat.device_id is None), None)
if available_seat is None:
raise LicensingError("seat_limit_reached", "设备席位已用完")
available_seat.device = device
available_seat.bound_at = now
available_seat.released_at = None
available_seat.save(update_fields=("device", "bound_at", "released_at", "updated_at"))
_create_license_event(
entitlement=locked_entitlement,
seat=available_seat,
device=device,
action=LicenseEvent.Action.SEAT_ASSIGNED,
reason=reason,
actor=actor,
)
return available_seat
@transaction.atomic
def release_license_seat(*, seat: LicenseSeat, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_seat = LicenseSeat.objects.select_for_update().select_related("entitlement", "device").get(
pk=seat.pk
)
if locked_seat.device_id is None:
return locked_seat
previous_device = locked_seat.device
locked_seat.device = None
locked_seat.released_at = now
locked_seat.save(update_fields=("device", "released_at", "updated_at"))
_create_license_event(
entitlement=locked_seat.entitlement,
seat=locked_seat,
device=previous_device,
action=LicenseEvent.Action.SEAT_RELEASED,
reason=reason,
actor=actor,
)
return locked_seat
@transaction.atomic
def create_legacy_migration_grant(
*,
user,
plan: SoftwarePlan,
reason: str,
actor=None,
eligibility_snapshot: dict | None = None,
):
reason = _required_reason(reason)
if LegacyMigrationGrant.objects.filter(
user=user,
product_code=plan.product_code,
).exists():
raise LicensingError("migration_grant_exists", "该用户已有此产品的迁移资格")
entitlement = grant_software_entitlement(
user=user,
plan=plan,
reason=reason,
actor=actor,
)
snapshot = {
"source": "manual",
"plan_id": plan.id,
"plan_name": plan.name,
"plan_device_limit": plan.device_limit,
"plan_duration_days": plan.duration_days,
}
snapshot.update(eligibility_snapshot or {})
grant = LegacyMigrationGrant.objects.create(
user=user,
product_code=plan.product_code,
entitlement=entitlement,
eligibility_snapshot=snapshot,
reason=reason,
actor=actor,
)
_create_license_event(
entitlement=entitlement,
action=LicenseEvent.Action.MIGRATION_GRANTED,
reason=reason,
actor=actor,
metadata={"migration_grant_id": grant.id},
)
return grant
@transaction.atomic
def create_migration_request(*, user, device: ClientDevice, now=None):
now = now or timezone.now()
if device.user_id != user.id:
raise LicensingError("device_mismatch", "设备不属于当前账号")
if device.status != ClientDevice.Status.ACTIVE:
raise LicensingError("device_revoked", "设备已被吊销")
grant = (
LegacyMigrationGrant.objects.select_related("entitlement")
.select_for_update()
.filter(
user=user,
product_code=device.product_code,
status=LegacyMigrationGrant.Status.ACTIVE,
)
.first()
)
if grant is None:
raise LicensingError("migration_not_eligible", "当前账号没有可用的存量迁移资格")
if not grant.entitlement.is_usable_at(now):
raise LicensingError("license_expired", "迁移权益已过期或不可用")
if DeviceCredential.objects.filter(
device=device,
entitlement=grant.entitlement,
revoked_at__isnull=True,
).exists():
raise LicensingError("device_credential_exists", "当前设备已完成迁移绑定")
raw_token = MigrationRequest.generate_plaintext_credential_token()
request = MigrationRequest.objects.create(
user=user,
device=device,
migration_grant=grant,
credential_token_hash=MigrationRequest.hash_credential_token(raw_token),
credential_token_prefix=raw_token[:20],
expires_at=now + timedelta(seconds=max(60, settings.MIGRATION_REQUEST_TTL_SECONDS)),
)
return request, raw_token
@transaction.atomic
def confirm_migration_request(*, request_id, user, now=None):
now = now or timezone.now()
request = (
MigrationRequest.objects.select_for_update()
.select_related("user", "device", "migration_grant", "migration_grant__entitlement")
.filter(request_id=request_id)
.first()
)
if request is None:
raise LicensingError("migration_request_not_found", "迁移请求不存在")
if request.user_id != user.id:
raise LicensingError("migration_request_forbidden", "迁移请求不属于当前账号")
existing_credential = DeviceCredential.objects.filter(migration_request=request).first()
if existing_credential is not None:
return request, existing_credential, False
if not request.is_pending_at(now):
raise LicensingError("migration_request_expired", "迁移请求已过期或不可用")
grant = request.migration_grant
if grant.status != LegacyMigrationGrant.Status.ACTIVE:
raise LicensingError("migration_not_eligible", "迁移资格已撤销")
if not grant.entitlement.is_usable_at(now):
raise LicensingError("license_expired", "迁移权益已过期或不可用")
seat = assign_license_seat(
entitlement=grant.entitlement,
device=request.device,
reason="存量迁移网页登录确认绑定",
actor=user,
now=now,
)
existing_device_credential = DeviceCredential.objects.filter(
device=request.device,
entitlement=grant.entitlement,
revoked_at__isnull=True,
).first()
if existing_device_credential is not None:
raise LicensingError("device_credential_exists", "当前设备已完成迁移绑定")
credential = DeviceCredential.objects.create(
user=user,
product_code=request.device.product_code,
device=request.device,
entitlement=grant.entitlement,
seat=seat,
migration_request=request,
token_hash=request.credential_token_hash,
token_prefix=request.credential_token_prefix,
expires_at=grant.entitlement.grace_expires_at,
)
request.status = MigrationRequest.Status.CONFIRMED
request.confirmed_at = now
request.save(update_fields=("status", "confirmed_at", "updated_at"))
_create_license_event(
entitlement=grant.entitlement,
seat=seat,
device=request.device,
action=LicenseEvent.Action.CREDENTIAL_ISSUED,
reason="存量迁移网页登录确认签发设备凭证",
actor=user,
metadata={"migration_request_id": str(request.request_id)},
)
return request, credential, True
@transaction.atomic
def revoke_device_credential(*, credential: DeviceCredential, reason: str, actor=None, now=None):
reason = _required_reason(reason)
now = now or timezone.now()
locked_credential = (
DeviceCredential.objects.select_for_update()
.select_related("seat", "entitlement", "device")
.get(pk=credential.pk)
)
if locked_credential.revoked_at is not None:
return locked_credential
locked_credential.revoked_at = now
locked_credential.revoke_reason = reason
locked_credential.save(update_fields=("revoked_at", "revoke_reason"))
_create_license_event(
entitlement=locked_credential.entitlement,
seat=locked_credential.seat,
device=locked_credential.device,
action=LicenseEvent.Action.CREDENTIAL_REVOKED,
reason=reason,
actor=actor,
)
if locked_credential.seat.device_id == locked_credential.device_id:
release_license_seat(
seat=locked_credential.seat,
reason=reason,
actor=actor,
now=now,
)
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):
now = now or timezone.now()
if device is None:
return AuthorizationDecision(product_code, False, "device_not_bound")
if device.user_id != user.id or device.product_code != product_code:
return AuthorizationDecision(product_code, False, "device_mismatch")
raw_credential_token = str(raw_credential_token or "").strip()
if not raw_credential_token:
return AuthorizationDecision(product_code, False, "license_required")
credential = (
DeviceCredential.objects.select_related("device", "entitlement", "seat")
.filter(token_hash=MigrationRequest.hash_credential_token(raw_credential_token))
.first()
)
if credential is None:
return AuthorizationDecision(product_code, False, "license_required")
if (
credential.user_id != user.id
or credential.product_code != product_code
or credential.device_id != device.id
or credential.seat.device_id != device.id
):
return AuthorizationDecision(product_code, False, "device_mismatch", credential.id)
if not credential.is_active_at(now) or not credential.entitlement.is_usable_at(now):
return AuthorizationDecision(product_code, False, "license_expired", credential.id)
return AuthorizationDecision(product_code, True, "", credential.id)
def evaluate_account_authorization(*, user, product_code: str, now=None):
"""Authorize a product by account entitlement, without device or credential checks."""
now = now or timezone.now()
usable_entitlement = (
SoftwareEntitlement.objects.filter(
user=user,
product_code=product_code,
status=SoftwareEntitlement.Status.ACTIVE,
starts_at__lte=now,
grace_expires_at__gt=now,
)
.order_by("-expires_at", "-id")
.first()
)
if usable_entitlement is not None:
return SubscriptionAuthorizationDecision(
product_code=product_code,
allowed=True,
code="",
entitlement=usable_entitlement,
)
historical_entitlement = (
SoftwareEntitlement.objects.filter(
user=user,
product_code=product_code,
)
.exclude(status=SoftwareEntitlement.Status.REVOKED)
.order_by("-grace_expires_at", "-id")
.first()
)
if historical_entitlement is not None:
return SubscriptionAuthorizationDecision(
product_code=product_code,
allowed=False,
code="subscription_expired",
entitlement=historical_entitlement,
)
return SubscriptionAuthorizationDecision(
product_code=product_code,
allowed=False,
code="subscription_required",
)
def software_subscription_status(*, user, product_code: str, now=None) -> dict:
decision = evaluate_account_authorization(
user=user,
product_code=product_code,
now=now,
)
entitlement = decision.entitlement
return {
"product_code": product_code,
"status": (
"active"
if decision.allowed
else ("expired" if decision.code == "subscription_expired" else "required")
),
"allowed": decision.allowed,
"code": decision.code or None,
"plan": {
"name": entitlement.plan_name,
"expires_at": entitlement.expires_at.isoformat(),
"grace_expires_at": entitlement.grace_expires_at.isoformat(),
}
if entitlement is not None
else None,
}