feat: 完成权益到期与续期闭环
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user