1226 lines
42 KiB
Python
1226 lines
42 KiB
Python
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"},
|
|
}
|