feat: 完成权益到期与续期闭环

This commit is contained in:
2026-08-03 14:32:38 +08:00
parent 20ed4875b8
commit b2b56f43bb
18 changed files with 792 additions and 20 deletions

View File

@@ -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))

View File

@@ -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