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

@@ -9,7 +9,12 @@ from app.core.responses import api_success
from app.models.admin import Admin
from app.models.entitlement import EntitlementPlan
from app.models.user import User
from app.schemas.admin import EntitlementPlanSaveRequest, UserEntitlementAssignRequest
from app.schemas.admin import (
EntitlementPlanSaveRequest,
UserEntitlementAssignRequest,
UserEntitlementBatchRenewRequest,
UserEntitlementRenewRequest,
)
from app.services.admin_service import OperationLogService
from app.services.entitlement_service import EntitlementService, entitlement_dict, plan_dict
from app.services.topic_session_service import TopicSessionService
@@ -97,6 +102,79 @@ def assign_user_entitlement(
return api_success(entitlement_dict(view))
@router.post("/user/{user_id}/entitlement/renew")
def renew_user_entitlement(
user_id: int,
payload: UserEntitlementRenewRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
user = db.get(User, user_id)
if user is None or user.is_deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
EntitlementService.renew_user_plan(
db,
user=user,
extension_days=payload.extensionDays,
request_key=f"{payload.idempotencyKey}:{user.id}",
operated_by=current_admin.id,
remark=payload.remark,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="entitlement",
action="renew_user_plan",
target_id=user.id,
)
db.commit()
view = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
)
return api_success(entitlement_dict(view))
@router.post("/user/entitlement/batch-renew")
def batch_renew_user_entitlement(
payload: UserEntitlementBatchRenewRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
succeeded: list[dict] = []
failed: list[dict] = []
for user_id in dict.fromkeys(payload.userIds):
user = db.get(User, user_id)
if user is None or user.is_deleted:
failed.append({"userId": user_id, "reason": "用户不存在"})
continue
try:
EntitlementService.renew_user_plan(
db,
user=user,
extension_days=payload.extensionDays,
request_key=f"{payload.idempotencyKey}:{user.id}",
operated_by=current_admin.id,
remark=payload.remark,
)
succeeded.append({"userId": user.id, "name": user.name})
except HTTPException as exc:
failed.append({"userId": user.id, "name": user.name, "reason": str(exc.detail)})
OperationLogService.write(
db,
admin_id=current_admin.id,
module="entitlement",
action="batch_renew_user_plan",
target_id=None,
result="SUCCESS" if not failed else "PARTIAL",
)
db.commit()
return api_success(
{"succeeded": succeeded, "failed": failed, "successCount": len(succeeded), "failureCount": len(failed)}
)
def _apply_plan_payload(plan: EntitlementPlan, payload: EntitlementPlanSaveRequest) -> None:
plan.name = payload.name.strip()
plan.plan_type = payload.planType

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
import re
from dataclasses import replace
from datetime import UTC, date, datetime, timedelta
from io import BytesIO
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
@@ -45,6 +47,8 @@ class AdminGenerateReportRequest(BaseModel):
@router.get("/user/list")
def list_users(
keyword: str = Query(default=""),
planId: Annotated[int | None, Query(gt=0)] = None,
entitlementStatus: Annotated[str, Query(pattern="^(all|active|expiring7|expiring30|expired|default)$")] = "all",
page: int = Query(default=1, ge=1),
pageSize: int = Query(default=20, ge=10, le=100),
db: Session = Depends(get_db),
@@ -54,6 +58,51 @@ def list_users(
if keyword:
like = f"%{keyword}%"
query = query.where((User.phone.like(like)) | (User.name.like(like)))
now = datetime.now(UTC).replace(tzinfo=None)
active_user_ids = select(UserEntitlement.user_id).join(
EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id
).where(
UserEntitlement.status == "active",
EntitlementPlan.status == 1,
EntitlementPlan.plan_type != "teacher",
(UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now),
(UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now),
)
if planId is not None:
query = query.where(User.id.in_(active_user_ids.where(UserEntitlement.plan_id == planId)))
if entitlementStatus == "active":
query = query.where(User.id.in_(active_user_ids))
elif entitlementStatus == "expiring7":
query = query.where(
User.id.in_(
active_user_ids.where(
UserEntitlement.expired_at.is_not(None),
UserEntitlement.expired_at <= now + timedelta(days=7),
)
)
)
elif entitlementStatus == "expiring30":
query = query.where(
User.id.in_(
active_user_ids.where(
UserEntitlement.expired_at.is_not(None),
UserEntitlement.expired_at > now + timedelta(days=7),
UserEntitlement.expired_at <= now + timedelta(days=30),
)
)
)
elif entitlementStatus == "default":
query = query.where(User.id.not_in(active_user_ids))
elif entitlementStatus == "expired":
expired_user_ids = select(UserEntitlement.user_id).join(
EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id
).where(
UserEntitlement.status.in_(("active", "expired")),
UserEntitlement.expired_at.is_not(None),
UserEntitlement.expired_at < now,
EntitlementPlan.plan_type != "teacher",
)
query = query.where(User.id.not_in(active_user_ids), User.id.in_(expired_user_ids))
total = db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0
users = db.scalars(query.offset((page - 1) * pageSize).limit(pageSize)).all()
entitlements = _entitlement_views(db, users)
@@ -457,13 +506,32 @@ def _entitlement_views(db: Session, users: list[User]) -> dict[int, dict]:
return {}
counts = _monthly_topic_counts(db, user_ids)
explicit = _active_entitlement_rows(db, user_ids)
previous_expired = _latest_expired_entitlement_rows(db, user_ids)
default_plan = EntitlementService.default_plan(db)
result: dict[int, dict] = {}
for user in users:
if user.id in explicit:
entitlement, plan = explicit[user.id]
view = view_from_plan(plan, monthly_topic_used=counts.get(user.id, 0), entitlement=entitlement, source="assigned")
else:
view = EntitlementService.active_entitlement(db, user, monthly_topic_used=counts.get(user.id, 0))
if default_plan is None:
view = EntitlementService.active_entitlement(db, user, monthly_topic_used=counts.get(user.id, 0))
else:
view = view_from_plan(
default_plan,
monthly_topic_used=counts.get(user.id, 0),
entitlement=None,
source="default",
)
if user.id in previous_expired:
expired_entitlement, expired_plan = previous_expired[user.id]
view = replace(
view,
source="default_after_expiry",
lifecycle_status="expired_fallback",
previous_plan_name=expired_plan.name,
previous_expired_at=expired_entitlement.expired_at,
)
result[user.id] = entitlement_dict(view)
return result
@@ -488,6 +556,26 @@ def _active_entitlement_rows(db: Session, user_ids: list[int]) -> dict[int, tupl
return result
def _latest_expired_entitlement_rows(db: Session, user_ids: list[int]) -> dict[int, tuple[UserEntitlement, EntitlementPlan]]:
now = datetime.now(UTC).replace(tzinfo=None)
rows = db.execute(
select(UserEntitlement, EntitlementPlan)
.join(EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id)
.where(
UserEntitlement.user_id.in_(user_ids),
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())
).all()
result: dict[int, tuple[UserEntitlement, EntitlementPlan]] = {}
for entitlement, plan in rows:
result.setdefault(entitlement.user_id, (entitlement, plan))
return result
def _user_metrics(db: Session, *, user: User, monthly_topic_limit: int | None) -> dict:
now = datetime.now(UTC).replace(tzinfo=None)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)