from __future__ import annotations import secrets from dataclasses import dataclass from datetime import datetime, timedelta, timezone as datetime_timezone from decimal import Decimal from decimal import InvalidOperation from django.conf import settings from django.contrib.auth import get_user_model 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 BulkEntitlementGrantResult: eligible_count: int skipped_existing_count: int grant_count: int executed: 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 SubscriptionAccessDecision: product_code: str mode: str allowed: bool code: str access_source: str entitlement_status: str entitlement_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 grant_plan_to_existing_users( *, plan: SoftwarePlan, reason: str, execute: bool = False, expected_grant_count: int | None = None, actor=None, now=None, ) -> BulkEntitlementGrantResult: reason = _required_reason(reason) if execute and expected_grant_count is None: raise LicensingError("expected_grant_count_required", "实际执行必须提供预期授予人数") if expected_grant_count is not None and expected_grant_count < 0: raise LicensingError("invalid_expected_grant_count", "预期授予人数不能为负数") plan_query = SoftwarePlan.objects if execute: plan_query = plan_query.select_for_update() locked_plan = plan_query.get(pk=plan.pk) if locked_plan.status != SoftwarePlan.Status.ACTIVE: raise LicensingError("plan_inactive", "套餐已停用,不能批量授予权益") users_query = get_user_model().objects.filter( is_active=True, is_staff=False, is_superuser=False, ).order_by("pk") if execute: users_query = users_query.select_for_update() eligible_users = list(users_query) eligible_user_ids = [user.pk for user in eligible_users] effective_now = now or timezone.now() existing_user_ids = set( SoftwareEntitlement.objects.filter( user_id__in=eligible_user_ids, product_code=locked_plan.product_code, status=SoftwareEntitlement.Status.ACTIVE, grace_expires_at__gt=effective_now, ).values_list("user_id", flat=True) ) users_to_grant = [ user for user in eligible_users if user.pk not in existing_user_ids ] grant_count = len(users_to_grant) if execute and expected_grant_count != grant_count: raise LicensingError( "grant_count_mismatch", f"预期授予 {expected_grant_count} 人,当前实际应授予 {grant_count} 人", ) if execute: for user in users_to_grant: grant_software_entitlement( user=user, plan=locked_plan, reason=reason, actor=actor, starts_at=effective_now, ) return BulkEntitlementGrantResult( eligible_count=len(eligible_users), skipped_existing_count=len(existing_user_ids), grant_count=grant_count, executed=execute, ) @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 _usable_entitlement_for_software_order( *, user, product_code, now=None, for_update=False, ): now = now or timezone.now() queryset = SoftwareEntitlement.objects.filter( user=user, product_code=product_code, status=SoftwareEntitlement.Status.ACTIVE, grace_expires_at__gt=now, ) if for_update: queryset = queryset.select_for_update() return queryset.order_by("-expires_at", "-id").first() def _expire_elapsed_entitlements_for_software_order(*, user, product_code, now): entitlement_ids = list( SoftwareEntitlement.objects.select_for_update() .filter( user=user, product_code=product_code, status=SoftwareEntitlement.Status.ACTIVE, grace_expires_at__lte=now, ) .values_list("id", flat=True) ) if entitlement_ids: SoftwareEntitlement.objects.filter(id__in=entitlement_ids).update( status=SoftwareEntitlement.Status.EXPIRED, updated_at=now, ) 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 = _usable_entitlement_for_software_order( user=user, product_code=plan.product_code, ) 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() fulfillment_now = timezone.now() _expire_elapsed_entitlements_for_software_order( user=order.user, product_code=order.product_code, now=fulfillment_now, ) entitlement = _usable_entitlement_for_software_order( user=order.user, product_code=order.product_code, now=fulfillment_now, for_update=True, ) 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 _real_entitlement_status(decision, now) -> str: if decision.allowed: if decision.entitlement and decision.entitlement.expires_at <= now: return "grace" return "active" if decision.code == "subscription_expired": return "expired" return "required" def get_subscription_mode() -> str: mode = str(getattr(settings, "CMSHOPEE_SUBSCRIPTION_MODE", "") or "").strip().lower() if mode in {"open", "shadow", "enforce"}: return mode return ( "enforce" if getattr(settings, "CMSHOPEE_SUBSCRIPTION_ENFORCEMENT", False) else "open" ) def evaluate_subscription_access(*, user, product_code: str, now=None, mode=None): now = now or timezone.now() mode = mode or get_subscription_mode() real_decision = evaluate_account_authorization( user=user, product_code=product_code, now=now, ) entitlement_status = _real_entitlement_status(real_decision, now) if mode == "open": return SubscriptionAccessDecision( product_code=product_code, mode=mode, allowed=True, code="", access_source="open_mode", entitlement_status=entitlement_status, entitlement_code=real_decision.code, ) if mode == "shadow" and not real_decision.allowed: return SubscriptionAccessDecision( product_code=product_code, mode=mode, allowed=True, code="", access_source="shadow_fallback", entitlement_status=entitlement_status, entitlement_code=real_decision.code, ) return SubscriptionAccessDecision( product_code=product_code, mode=mode, allowed=real_decision.allowed, code=real_decision.code, access_source="entitlement", entitlement_status=entitlement_status, entitlement_code=real_decision.code, entitlement=real_decision.entitlement, ) def _subscription_manage_url() -> str: base_url = str(getattr(settings, "PUBLIC_BASE_URL", "") or "").rstrip("/") return f"{base_url}/subscription" if base_url else "/subscription" def _effective_plan(access): entitlement = access.entitlement if entitlement is not None: plan_code = ( f"plan-{entitlement.source_plan_id}" if entitlement.source_plan_id else f"entitlement-{entitlement.id}" ) return { "code": plan_code, "display_name": entitlement.plan_name, # T-630 compatibility fields; new clients use the top-level dates. "name": entitlement.plan_name, "expires_at": entitlement.expires_at.isoformat(), "grace_expires_at": entitlement.grace_expires_at.isoformat(), }, entitlement.expires_at, entitlement.grace_expires_at if access.access_source in {"open_mode", "shadow_fallback"}: expires_at = datetime(2099, 12, 31, 23, 59, 59, tzinfo=datetime_timezone.utc) plan_code = "development-open" if access.access_source == "open_mode" else "shadow-fallback" display_name = "开发测试长期会员" if access.access_source == "open_mode" else "订阅过渡会员" return { "code": plan_code, "display_name": display_name, "name": display_name, "expires_at": expires_at.isoformat(), "grace_expires_at": None, }, expires_at, None return None, None, None def software_subscription_status(*, user, product_code: str, now=None, mode=None) -> dict: access = evaluate_subscription_access( user=user, product_code=product_code, now=now, mode=mode, ) plan, expires_at, grace_expires_at = _effective_plan(access) username = user.get_username() return { "product_code": product_code, "status": ( access.entitlement_status if access.access_source == "entitlement" else "active" ), "allowed": access.allowed, "code": access.code or None, "account": { "display_name": user.get_full_name() or username, "username": username, }, "plan": plan, "expires_at": expires_at.isoformat() if expires_at else None, "grace_expires_at": grace_expires_at.isoformat() if grace_expires_at else None, "manage_url": _subscription_manage_url(), "notice_id": ( f"{product_code}-subscription-{access.mode}-{access.entitlement_status}-v1" ), "access_source": access.access_source, "entitlement_status": access.entitlement_status, "real_entitlement_allowed": access.entitlement_status in {"active", "grace"}, }