feat: 完成权益到期与续期闭环
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -32,6 +33,10 @@ class EntitlementView:
|
||||
effective_at: datetime | None = None
|
||||
expired_at: datetime | None = None
|
||||
source: str = "legacy"
|
||||
lifecycle_status: str = "legacy"
|
||||
days_until_expiry: int | None = None
|
||||
previous_plan_name: str | None = None
|
||||
previous_expired_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def monthly_topic_remaining(self) -> int | None:
|
||||
@@ -92,7 +97,30 @@ class EntitlementService:
|
||||
|
||||
plan = EntitlementService.default_plan(db)
|
||||
if plan is not None:
|
||||
return view_from_plan(plan, monthly_topic_used=monthly_topic_used, entitlement=None, source="default")
|
||||
previous = db.execute(
|
||||
select(UserEntitlement, EntitlementPlan)
|
||||
.join(EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id)
|
||||
.where(
|
||||
UserEntitlement.user_id == user.id,
|
||||
UserEntitlement.status.in_(("active", "expired")),
|
||||
UserEntitlement.expired_at.is_not(None),
|
||||
UserEntitlement.expired_at < now,
|
||||
EntitlementPlan.plan_type != "teacher",
|
||||
)
|
||||
.order_by(UserEntitlement.expired_at.desc(), UserEntitlement.id.desc())
|
||||
.limit(1)
|
||||
).first()
|
||||
view = view_from_plan(plan, monthly_topic_used=monthly_topic_used, entitlement=None, source="default")
|
||||
if previous:
|
||||
expired_entitlement, expired_plan = previous
|
||||
return replace(
|
||||
view,
|
||||
source="default_after_expiry",
|
||||
lifecycle_status="expired_fallback",
|
||||
previous_plan_name=expired_plan.name,
|
||||
previous_expired_at=expired_entitlement.expired_at,
|
||||
)
|
||||
return view
|
||||
|
||||
return EntitlementView(
|
||||
plan_id=None,
|
||||
@@ -108,8 +136,127 @@ class EntitlementService:
|
||||
allow_share_draft=True,
|
||||
deduct_quota=True,
|
||||
source="legacy",
|
||||
lifecycle_status="legacy",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def expire_due_entitlements(db: Session, *, batch_size: int = 500, now: datetime | None = None) -> int:
|
||||
current_time = now or _now()
|
||||
total = 0
|
||||
while True:
|
||||
due = list(
|
||||
db.scalars(
|
||||
select(UserEntitlement)
|
||||
.where(
|
||||
UserEntitlement.status == "active",
|
||||
UserEntitlement.expired_at.is_not(None),
|
||||
UserEntitlement.expired_at < current_time,
|
||||
)
|
||||
.order_by(UserEntitlement.id.asc())
|
||||
.limit(batch_size)
|
||||
)
|
||||
)
|
||||
if not due:
|
||||
return total
|
||||
for entitlement in due:
|
||||
entitlement.status = "expired"
|
||||
db.add(entitlement)
|
||||
db.add(
|
||||
UserEntitlementLog(
|
||||
user_id=entitlement.user_id,
|
||||
entitlement_id=entitlement.id,
|
||||
from_plan_id=entitlement.plan_id,
|
||||
to_plan_id=None,
|
||||
action="expire",
|
||||
detail_json=json.dumps(
|
||||
{"expiredAt": entitlement.expired_at.isoformat() if entitlement.expired_at else None},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
operated_by=None,
|
||||
created_at=current_time,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
total += len(due)
|
||||
|
||||
@staticmethod
|
||||
def renew_user_plan(
|
||||
db: Session,
|
||||
*,
|
||||
user: User,
|
||||
extension_days: int,
|
||||
request_key: str,
|
||||
operated_by: int | None,
|
||||
remark: str | None = None,
|
||||
) -> UserEntitlement:
|
||||
existing_log = db.scalar(select(UserEntitlementLog).where(UserEntitlementLog.request_key == request_key))
|
||||
if existing_log is not None and existing_log.entitlement_id is not None:
|
||||
existing = db.get(UserEntitlement, existing_log.entitlement_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
db.scalar(select(User.id).where(User.id == user.id).with_for_update())
|
||||
existing_log = db.scalar(select(UserEntitlementLog).where(UserEntitlementLog.request_key == request_key))
|
||||
if existing_log is not None and existing_log.entitlement_id is not None:
|
||||
existing = db.get(UserEntitlement, existing_log.entitlement_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
now = _now()
|
||||
current = db.scalar(
|
||||
select(UserEntitlement)
|
||||
.where(UserEntitlement.user_id == user.id, UserEntitlement.status.in_(("active", "expired")))
|
||||
.order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该用户尚未分配专属权益,请先选择权益版本")
|
||||
plan = db.get(EntitlementPlan, current.plan_id)
|
||||
if plan is None or plan.status != 1 or plan.plan_type == "teacher":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="原权益版本已停用,无法续期,请重新分配权益")
|
||||
if current.status == "active" and current.expired_at is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="当前权益为长期有效,无需续期")
|
||||
|
||||
previous_expired_at = current.expired_at
|
||||
base_time = max(now, previous_expired_at) if previous_expired_at is not None else now
|
||||
renewed_expired_at = base_time + timedelta(days=extension_days)
|
||||
current.status = "replaced"
|
||||
db.add(current)
|
||||
renewed = UserEntitlement(
|
||||
user_id=user.id,
|
||||
plan_id=plan.id,
|
||||
status="active",
|
||||
effective_at=now,
|
||||
expired_at=renewed_expired_at,
|
||||
assigned_by=operated_by,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(renewed)
|
||||
db.flush()
|
||||
db.add(
|
||||
UserEntitlementLog(
|
||||
user_id=user.id,
|
||||
entitlement_id=renewed.id,
|
||||
from_plan_id=plan.id,
|
||||
to_plan_id=plan.id,
|
||||
action="renew",
|
||||
request_key=request_key,
|
||||
detail_json=json.dumps(
|
||||
{
|
||||
"extensionDays": extension_days,
|
||||
"previousExpiredAt": previous_expired_at.isoformat() if previous_expired_at else None,
|
||||
"renewedExpiredAt": renewed_expired_at.isoformat(),
|
||||
"sourceEntitlementId": current.id,
|
||||
"remark": remark,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
operated_by=operated_by,
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
return renewed
|
||||
|
||||
@staticmethod
|
||||
def assign_user_plan(
|
||||
db: Session,
|
||||
@@ -217,6 +364,10 @@ def entitlement_dict(view: EntitlementView) -> dict:
|
||||
"effectiveAt": view.effective_at,
|
||||
"expiredAt": view.expired_at,
|
||||
"source": view.source,
|
||||
"lifecycleStatus": view.lifecycle_status,
|
||||
"daysUntilExpiry": view.days_until_expiry,
|
||||
"previousPlanName": view.previous_plan_name,
|
||||
"previousExpiredAt": view.previous_expired_at,
|
||||
}
|
||||
|
||||
|
||||
@@ -240,6 +391,17 @@ def view_from_plan(
|
||||
entitlement: UserEntitlement | None,
|
||||
source: str,
|
||||
) -> EntitlementView:
|
||||
days_until_expiry = _days_until_expiry(entitlement.expired_at) if entitlement else None
|
||||
lifecycle_status = "default"
|
||||
if entitlement is not None:
|
||||
if entitlement.expired_at is None:
|
||||
lifecycle_status = "long_term"
|
||||
elif days_until_expiry is not None and days_until_expiry <= 7:
|
||||
lifecycle_status = "expiring_7"
|
||||
elif days_until_expiry is not None and days_until_expiry <= 30:
|
||||
lifecycle_status = "expiring_30"
|
||||
else:
|
||||
lifecycle_status = "active"
|
||||
return EntitlementView(
|
||||
plan_id=plan.id,
|
||||
name=plan.name,
|
||||
@@ -256,8 +418,16 @@ def view_from_plan(
|
||||
effective_at=entitlement.effective_at if entitlement else None,
|
||||
expired_at=entitlement.expired_at if entitlement else None,
|
||||
source=source,
|
||||
lifecycle_status=lifecycle_status,
|
||||
days_until_expiry=days_until_expiry,
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _days_until_expiry(expired_at: datetime | None) -> int | None:
|
||||
if expired_at is None:
|
||||
return None
|
||||
return max(0, math.ceil((expired_at - _now()).total_seconds() / 86400))
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.core.database import SessionLocal
|
||||
from app.models.knowledge import KnowledgeRetrievalCandidate, KnowledgeRetrievalLog
|
||||
from app.models.logs import LogRetentionPolicy
|
||||
from app.services.redis_client import get_sync_redis_client
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
|
||||
|
||||
class MaintenanceService:
|
||||
@@ -31,6 +32,8 @@ class MaintenanceService:
|
||||
if not lock_acquired:
|
||||
return
|
||||
with SessionLocal() as db:
|
||||
EntitlementService.expire_due_entitlements(db)
|
||||
db.commit()
|
||||
policy = db.scalar(select(LogRetentionPolicy).order_by(LogRetentionPolicy.id).limit(1))
|
||||
if not policy or not policy.enabled or not policy.retention_days:
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user