feat: add entitlement plans and topic sessions

This commit is contained in:
2026-07-31 15:22:11 +08:00
parent ad2161497f
commit 884dc765ad
27 changed files with 2389 additions and 15 deletions

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_admin
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.services.admin_service import OperationLogService
from app.services.entitlement_service import EntitlementService, entitlement_dict, plan_dict
from app.services.topic_session_service import TopicSessionService
router = APIRouter()
@router.get("/entitlement/plan/list")
def list_entitlement_plans(
includeDisabled: bool = Query(default=True),
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
plans = EntitlementService.list_plans(db, include_disabled=includeDisabled)
return api_success([plan_dict(plan) for plan in plans])
@router.post("/entitlement/plan")
def create_entitlement_plan(
payload: EntitlementPlanSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
plan = EntitlementPlan()
_apply_plan_payload(plan, payload)
db.add(plan)
db.flush()
OperationLogService.write(db, admin_id=current_admin.id, module="entitlement", action="create_plan", target_id=plan.id)
db.commit()
db.refresh(plan)
return api_success(plan_dict(plan))
@router.put("/entitlement/plan/{plan_id}")
def update_entitlement_plan(
plan_id: int,
payload: EntitlementPlanSaveRequest,
db: Session = Depends(get_db),
current_admin: Admin = Depends(get_current_admin),
) -> dict:
plan = db.get(EntitlementPlan, plan_id)
if plan is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在")
_apply_plan_payload(plan, payload)
db.add(plan)
OperationLogService.write(db, admin_id=current_admin.id, module="entitlement", action="update_plan", target_id=plan.id)
db.commit()
db.refresh(plan)
return api_success(plan_dict(plan))
@router.post("/user/{user_id}/entitlement")
def assign_user_entitlement(
user_id: int,
payload: UserEntitlementAssignRequest,
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="用户不存在")
entitlement = EntitlementService.assign_user_plan(
db,
user=user,
plan_id=payload.planId,
operated_by=current_admin.id,
effective_at=payload.effectiveAt,
expired_at=payload.expiredAt,
remark=payload.remark,
)
OperationLogService.write(
db,
admin_id=current_admin.id,
module="entitlement",
action="assign_user_plan",
target_id=user.id,
)
db.commit()
db.refresh(entitlement)
view = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
)
return api_success(entitlement_dict(view))
def _apply_plan_payload(plan: EntitlementPlan, payload: EntitlementPlanSaveRequest) -> None:
plan.name = payload.name.strip()
plan.plan_type = payload.planType
plan.description = payload.description.strip() if payload.description else None
plan.validity_days = payload.validityDays
plan.monthly_topic_limit = payload.monthlyTopicLimit
plan.enable_growth_profile = payload.enableGrowthProfile
plan.enable_periodic_reports = payload.enablePeriodicReports
plan.allow_help_card = payload.allowHelpCard
plan.allow_share_draft = payload.allowShareDraft
plan.deduct_quota = payload.deductQuota
plan.status = payload.status
plan.sort_order = payload.sortOrder

View File

@@ -304,6 +304,7 @@ def _message_dict(message: ChatMessage) -> dict:
return {
"id": message.id,
"sessionId": message.session_id,
"topicSessionId": message.topic_session_id,
"userId": message.user_id,
"role": message.role,
"content": message.content,

View File

@@ -1,14 +1,14 @@
from __future__ import annotations
import re
from datetime import date, datetime
from datetime import UTC, date, datetime
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from pydantic import ValidationError
from sqlalchemy import func, select
from sqlalchemy import extract, func, select
from sqlalchemy.orm import Session
from app.core.database import get_db
@@ -16,9 +16,12 @@ from app.core.dependencies import get_current_admin
from app.core.responses import api_success
from app.models.admin import Admin
from app.models.ai_config import SystemConfig
from app.models.entitlement import EntitlementPlan, UserEntitlement
from app.models.chat import TopicSession
from app.models.user import User
from app.schemas.admin import AdminUserCreateRequest, AdminUserImportItem, AdminUserImportRequest, AdminUserUpdateRequest
from app.services.admin_service import OperationLogService
from app.services.entitlement_service import EntitlementService, entitlement_dict, view_from_plan
from app.api.pagination import page_result
router = APIRouter()
@@ -40,7 +43,8 @@ def list_users(
query = query.where((User.phone.like(like)) | (User.name.like(like)))
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()
return api_success(page_result([_user_dict(user) for user in users], total=total, page=page, page_size=pageSize))
entitlements = _entitlement_views(db, users)
return api_success(page_result([_user_dict(user, entitlements.get(user.id)) for user in users], total=total, page=page, page_size=pageSize))
@router.post("/user")
@@ -68,7 +72,12 @@ def create_user(
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="create", target_id=user.id)
db.commit()
db.refresh(user)
return api_success(_user_dict(user))
entitlement = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=_monthly_topic_counts(db, [user.id]).get(user.id, 0),
)
return api_success(_user_dict(user, entitlement_dict(entitlement)))
@router.post("/user/import")
@@ -240,10 +249,24 @@ def update_user(
if payload.expiredAt is not None:
user.expired_at = payload.expiredAt.replace(tzinfo=None)
db.add(user)
if payload.entitlementPlanId is not None:
EntitlementService.assign_user_plan(
db,
user=user,
plan_id=payload.entitlementPlanId,
operated_by=current_admin.id,
expired_at=payload.entitlementExpiredAt,
remark=payload.entitlementRemark,
)
OperationLogService.write(db, admin_id=current_admin.id, module="user", action="update", target_id=user.id)
db.commit()
db.refresh(user)
return api_success(_user_dict(user))
entitlement = EntitlementService.active_entitlement(
db,
user,
monthly_topic_used=_monthly_topic_counts(db, [user.id]).get(user.id, 0),
)
return api_success(_user_dict(user, entitlement_dict(entitlement)))
@router.delete("/user/{user_id}")
@@ -269,7 +292,7 @@ def _get_user(db: Session, user_id: int) -> User:
return user
def _user_dict(user: User) -> dict:
def _user_dict(user: User, entitlement: dict | None = None) -> dict:
return {
"id": user.id,
"phone": user.phone,
@@ -281,9 +304,64 @@ def _user_dict(user: User) -> dict:
"expiredAt": user.expired_at,
"lastLoginAt": user.last_login_at,
"createdAt": user.created_at,
"entitlement": entitlement,
}
def _entitlement_views(db: Session, users: list[User]) -> dict[int, dict]:
user_ids = [user.id for user in users]
if not user_ids:
return {}
counts = _monthly_topic_counts(db, user_ids)
explicit = _active_entitlement_rows(db, user_ids)
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))
result[user.id] = entitlement_dict(view)
return result
def _active_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 == "active",
EntitlementPlan.status == 1,
)
.where((UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now))
.where((UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now))
.order_by(UserEntitlement.created_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 _monthly_topic_counts(db: Session, user_ids: list[int]) -> dict[int, int]:
if not user_ids:
return {}
now = datetime.now(UTC).replace(tzinfo=None)
rows = db.execute(
select(TopicSession.user_id, func.count(TopicSession.id))
.where(
TopicSession.user_id.in_(user_ids),
TopicSession.quota_deducted == 1,
extract("year", TopicSession.started_at) == now.year,
extract("month", TopicSession.started_at) == now.month,
)
.group_by(TopicSession.user_id)
).all()
return {int(user_id): int(count) for user_id, count in rows}
def _apply_user_payload(
user: User,
payload: AdminUserCreateRequest | AdminUserImportItem,

View File

@@ -6,6 +6,7 @@ from app.api import (
admin_auth,
admin_agent_records,
admin_dashboard,
admin_entitlements,
admin_knowledge,
admin_knowledge_lifecycle,
admin_records,
@@ -25,6 +26,7 @@ api_router.include_router(chat.router, prefix="/chat", tags=["chat"])
api_router.include_router(admin_auth.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_agent_records.router, prefix="/admin", tags=["admin-agent-records"])
api_router.include_router(admin_dashboard.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_entitlements.router, prefix="/admin", tags=["admin-entitlements"])
api_router.include_router(admin_users.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge.router, prefix="/admin", tags=["admin"])
api_router.include_router(admin_knowledge_lifecycle.router, prefix="/admin", tags=["admin-knowledge-lifecycle"])

View File

@@ -1,15 +1,29 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.dependencies import get_current_user
from app.core.responses import api_success
from app.models.user import User
from app.schemas.user import UserProfile
from app.services.entitlement_service import EntitlementService, entitlement_dict
from app.services.topic_session_service import TopicSessionService
router = APIRouter()
@router.get("/profile")
def profile(current_user: User = Depends(get_current_user)) -> dict:
return api_success(UserProfile.model_validate(current_user).model_dump(mode="json"))
def profile(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
data = UserProfile.model_validate(current_user).model_dump(mode="json")
view = EntitlementService.active_entitlement(
db,
current_user,
monthly_topic_used=TopicSessionService.monthly_used_count(db, current_user.id),
)
data["entitlement"] = entitlement_dict(view)
return api_success(data)