feat: add entitlement plans and topic sessions
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
"""add entitlement plans and topic sessions
|
||||
|
||||
Revision ID: 0014_entitlements_topics
|
||||
Revises: 0013_question_insight_indexes
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "0014_entitlements_topics"
|
||||
down_revision = "0013_question_insight_indexes"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
PRIMARY_KEY_TYPE = sa.BigInteger().with_variant(sa.Integer(), "sqlite")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
tables = set(inspector.get_table_names())
|
||||
if "sys_entitlement_plan" not in tables:
|
||||
op.create_table(
|
||||
"sys_entitlement_plan",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(80), nullable=False),
|
||||
sa.Column("plan_type", sa.String(30), nullable=False),
|
||||
sa.Column("description", sa.String(255), nullable=True),
|
||||
sa.Column("validity_days", sa.Integer(), nullable=True),
|
||||
sa.Column("monthly_topic_limit", sa.Integer(), nullable=True),
|
||||
sa.Column("enable_growth_profile", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("enable_periodic_reports", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("allow_help_card", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("allow_share_draft", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("deduct_quota", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("status", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_entitlement_plan_plan_type", "sys_entitlement_plan", ["plan_type"])
|
||||
op.create_index("ix_sys_entitlement_plan_status", "sys_entitlement_plan", ["status"])
|
||||
_seed_default_plans()
|
||||
|
||||
if "sys_user_entitlement" not in tables:
|
||||
op.create_table(
|
||||
"sys_user_entitlement",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("plan_id", sa.BigInteger(), sa.ForeignKey("sys_entitlement_plan.id"), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||
sa.Column("effective_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("expired_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("assigned_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("remark", sa.String(255), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_user_entitlement_user_id", "sys_user_entitlement", ["user_id"])
|
||||
op.create_index("ix_sys_user_entitlement_plan_id", "sys_user_entitlement", ["plan_id"])
|
||||
op.create_index("ix_sys_user_entitlement_status", "sys_user_entitlement", ["status"])
|
||||
|
||||
if "sys_user_entitlement_log" not in tables:
|
||||
op.create_table(
|
||||
"sys_user_entitlement_log",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), nullable=False),
|
||||
sa.Column("entitlement_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("from_plan_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("to_plan_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("action", sa.String(30), nullable=False),
|
||||
sa.Column("detail_json", sa.Text(), nullable=True),
|
||||
sa.Column("operated_by", sa.BigInteger(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_user_entitlement_log_user_id", "sys_user_entitlement_log", ["user_id"])
|
||||
|
||||
if "sys_topic_session" not in tables:
|
||||
op.create_table(
|
||||
"sys_topic_session",
|
||||
sa.Column("id", PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True),
|
||||
sa.Column("user_id", sa.BigInteger(), sa.ForeignKey("sys_user.id"), nullable=False),
|
||||
sa.Column("chat_session_id", sa.BigInteger(), sa.ForeignKey("sys_chat_session.id"), nullable=False),
|
||||
sa.Column("title", sa.String(120), nullable=False),
|
||||
sa.Column("core_question", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.String(20), nullable=False, server_default="active"),
|
||||
sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("token_input", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("token_output", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("quota_deducted", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("recommended_homework", sa.Text(), nullable=True),
|
||||
sa.Column("help_card_generated", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("share_draft_generated", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("started_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("ended_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_sys_topic_session_user_id", "sys_topic_session", ["user_id"])
|
||||
op.create_index("ix_sys_topic_session_chat_session_id", "sys_topic_session", ["chat_session_id"])
|
||||
op.create_index("ix_sys_topic_session_status", "sys_topic_session", ["status"])
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("sys_chat_message")}
|
||||
if "topic_session_id" not in columns:
|
||||
op.add_column("sys_chat_message", sa.Column("topic_session_id", sa.BigInteger(), nullable=True))
|
||||
op.create_index("ix_sys_chat_message_topic_session_id", "sys_chat_message", ["topic_session_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
columns = {column["name"] for column in inspector.get_columns("sys_chat_message")}
|
||||
if "topic_session_id" in columns:
|
||||
op.drop_index("ix_sys_chat_message_topic_session_id", table_name="sys_chat_message")
|
||||
op.drop_column("sys_chat_message", "topic_session_id")
|
||||
tables = set(inspector.get_table_names())
|
||||
if "sys_topic_session" in tables:
|
||||
op.drop_index("ix_sys_topic_session_status", table_name="sys_topic_session")
|
||||
op.drop_index("ix_sys_topic_session_chat_session_id", table_name="sys_topic_session")
|
||||
op.drop_index("ix_sys_topic_session_user_id", table_name="sys_topic_session")
|
||||
op.drop_table("sys_topic_session")
|
||||
if "sys_user_entitlement_log" in tables:
|
||||
op.drop_index("ix_sys_user_entitlement_log_user_id", table_name="sys_user_entitlement_log")
|
||||
op.drop_table("sys_user_entitlement_log")
|
||||
if "sys_user_entitlement" in tables:
|
||||
op.drop_index("ix_sys_user_entitlement_status", table_name="sys_user_entitlement")
|
||||
op.drop_index("ix_sys_user_entitlement_plan_id", table_name="sys_user_entitlement")
|
||||
op.drop_index("ix_sys_user_entitlement_user_id", table_name="sys_user_entitlement")
|
||||
op.drop_table("sys_user_entitlement")
|
||||
if "sys_entitlement_plan" in tables:
|
||||
op.drop_index("ix_sys_entitlement_plan_status", table_name="sys_entitlement_plan")
|
||||
op.drop_index("ix_sys_entitlement_plan_plan_type", table_name="sys_entitlement_plan")
|
||||
op.drop_table("sys_entitlement_plan")
|
||||
|
||||
|
||||
def _seed_default_plans() -> None:
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"sys_entitlement_plan",
|
||||
sa.column("name"),
|
||||
sa.column("plan_type"),
|
||||
sa.column("description"),
|
||||
sa.column("validity_days"),
|
||||
sa.column("monthly_topic_limit"),
|
||||
sa.column("enable_growth_profile"),
|
||||
sa.column("enable_periodic_reports"),
|
||||
sa.column("allow_help_card"),
|
||||
sa.column("allow_share_draft"),
|
||||
sa.column("deduct_quota"),
|
||||
sa.column("status"),
|
||||
sa.column("sort_order"),
|
||||
),
|
||||
[
|
||||
{
|
||||
"name": "大本营基础版",
|
||||
"plan_type": "basic",
|
||||
"description": "随大本营提供,支持基础知识查询、功课方向和求助卡生成。",
|
||||
"validity_days": None,
|
||||
"monthly_topic_limit": 30,
|
||||
"enable_growth_profile": 0,
|
||||
"enable_periodic_reports": 0,
|
||||
"allow_help_card": 1,
|
||||
"allow_share_draft": 1,
|
||||
"deduct_quota": 1,
|
||||
"status": 1,
|
||||
"sort_order": 10,
|
||||
},
|
||||
{
|
||||
"name": "五个月深度陪伴版",
|
||||
"plan_type": "deep",
|
||||
"description": "支持长期成长档案、阶段报告和更高主题会话额度。",
|
||||
"validity_days": 150,
|
||||
"monthly_topic_limit": 90,
|
||||
"enable_growth_profile": 1,
|
||||
"enable_periodic_reports": 1,
|
||||
"allow_help_card": 1,
|
||||
"allow_share_draft": 1,
|
||||
"deduct_quota": 1,
|
||||
"status": 1,
|
||||
"sort_order": 20,
|
||||
},
|
||||
{
|
||||
"name": "高频加购包",
|
||||
"plan_type": "addon",
|
||||
"description": "用于少量高频用户补充主题会话额度。",
|
||||
"validity_days": 31,
|
||||
"monthly_topic_limit": 30,
|
||||
"enable_growth_profile": 0,
|
||||
"enable_periodic_reports": 0,
|
||||
"allow_help_card": 1,
|
||||
"allow_share_draft": 1,
|
||||
"deduct_quota": 1,
|
||||
"status": 1,
|
||||
"sort_order": 30,
|
||||
},
|
||||
{
|
||||
"name": "老师工作版",
|
||||
"plan_type": "teacher",
|
||||
"description": "内部老师使用,不消耗普通学员权益额度。",
|
||||
"validity_days": None,
|
||||
"monthly_topic_limit": None,
|
||||
"enable_growth_profile": 0,
|
||||
"enable_periodic_reports": 0,
|
||||
"allow_help_card": 1,
|
||||
"allow_share_draft": 1,
|
||||
"deduct_quota": 0,
|
||||
"status": 1,
|
||||
"sort_order": 40,
|
||||
},
|
||||
],
|
||||
)
|
||||
112
ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py
Normal file
112
ai_knowledge_base_v2/apps/backend/app/api/admin_entitlements.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from app.models.admin import Admin, Role
|
||||
from app.models.ai_config import ModelConfig, Prompt, SystemConfig
|
||||
from app.models.base import Base
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
||||
from app.models.knowledge import (
|
||||
HumanAttentionHistory,
|
||||
HumanAttentionRecord,
|
||||
@@ -28,6 +29,7 @@ __all__ = [
|
||||
"Base",
|
||||
"ChatMessage",
|
||||
"ChatSession",
|
||||
"EntitlementPlan",
|
||||
"Knowledge",
|
||||
"KnowledgeCard",
|
||||
"KnowledgeChunk",
|
||||
@@ -46,9 +48,12 @@ __all__ = [
|
||||
"OperationLog",
|
||||
"LogRetentionPolicy",
|
||||
"StorageSnapshot",
|
||||
"TopicSession",
|
||||
"Prompt",
|
||||
"Role",
|
||||
"SystemConfig",
|
||||
"User",
|
||||
"UserEntitlement",
|
||||
"UserEntitlementLog",
|
||||
"UserKnowledgePermission",
|
||||
]
|
||||
|
||||
@@ -29,6 +29,7 @@ class ChatMessage(Base):
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), index=True, nullable=False)
|
||||
topic_session_id: Mapped[int | None] = mapped_column(BigInteger, index=True, nullable=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
@@ -40,3 +41,25 @@ class ChatMessage(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
|
||||
session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages")
|
||||
|
||||
|
||||
class TopicSession(Base):
|
||||
__tablename__ = "sys_topic_session"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger().with_variant(Integer, "sqlite"), primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||
chat_session_id: Mapped[int] = mapped_column(ForeignKey("sys_chat_session.id"), index=True, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
core_question: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", index=True, nullable=False)
|
||||
message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
token_input: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
token_output: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
quota_deducted: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
recommended_homework: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
help_card_generated: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
share_draft_generated: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
started_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
|
||||
55
ai_knowledge_base_v2/apps/backend/app/models/entitlement.py
Normal file
55
ai_knowledge_base_v2/apps/backend/app/models/entitlement.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
PRIMARY_KEY_TYPE = BigInteger().with_variant(Integer, "sqlite")
|
||||
|
||||
|
||||
class EntitlementPlan(Base, TimestampMixin):
|
||||
__tablename__ = "sys_entitlement_plan"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
plan_type: Mapped[str] = mapped_column(String(30), index=True, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
validity_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
monthly_topic_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
enable_growth_profile: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
enable_periodic_reports: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
allow_help_card: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
allow_share_draft: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
deduct_quota: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
status: Mapped[int] = mapped_column(Integer, default=1, index=True, nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
|
||||
class UserEntitlement(Base, TimestampMixin):
|
||||
__tablename__ = "sys_user_entitlement"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("sys_user.id"), index=True, nullable=False)
|
||||
plan_id: Mapped[int] = mapped_column(ForeignKey("sys_entitlement_plan.id"), index=True, nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", index=True, nullable=False)
|
||||
effective_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
expired_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
assigned_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
|
||||
class UserEntitlementLog(Base):
|
||||
__tablename__ = "sys_user_entitlement_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False)
|
||||
entitlement_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
from_plan_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
to_plan_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
action: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
detail_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
operated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
@@ -41,6 +41,9 @@ class AdminUserUpdateRequest(BaseModel):
|
||||
status: int | None = Field(default=None, ge=0, le=1)
|
||||
dailyChatLimit: int | None = Field(default=None, ge=0, le=100000)
|
||||
expiredAt: datetime | None = None
|
||||
entitlementPlanId: int | None = Field(default=None, gt=0)
|
||||
entitlementExpiredAt: datetime | None = None
|
||||
entitlementRemark: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class AdminUserCreateRequest(BaseModel):
|
||||
@@ -65,6 +68,28 @@ class AdminUserImportRequest(BaseModel):
|
||||
students: list[AdminUserImportItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EntitlementPlanSaveRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
planType: Literal["basic", "deep", "addon", "teacher"] = "basic"
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
validityDays: int | None = Field(default=None, ge=1, le=3650)
|
||||
monthlyTopicLimit: int | None = Field(default=None, ge=0, le=100000)
|
||||
enableGrowthProfile: int = Field(default=0, ge=0, le=1)
|
||||
enablePeriodicReports: int = Field(default=0, ge=0, le=1)
|
||||
allowHelpCard: int = Field(default=1, ge=0, le=1)
|
||||
allowShareDraft: int = Field(default=1, ge=0, le=1)
|
||||
deductQuota: int = Field(default=1, ge=0, le=1)
|
||||
status: int = Field(default=1, ge=0, le=1)
|
||||
sortOrder: int = Field(default=0, ge=0, le=100000)
|
||||
|
||||
|
||||
class UserEntitlementAssignRequest(BaseModel):
|
||||
planId: int = Field(gt=0)
|
||||
effectiveAt: datetime | None = None
|
||||
expiredAt: datetime | None = None
|
||||
remark: str | None = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class KnowledgeSaveRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=100)
|
||||
feishuSpaceId: str = Field(min_length=1, max_length=100)
|
||||
|
||||
@@ -22,6 +22,7 @@ class ChatSessionRead(ORMModel):
|
||||
|
||||
class ChatMessageRead(ORMModel):
|
||||
id: int
|
||||
topic_session_id: int | None = None
|
||||
role: str
|
||||
content: str
|
||||
message_status: str
|
||||
|
||||
@@ -18,3 +18,4 @@ class UserProfile(ORMModel):
|
||||
effective_at: datetime | None = None
|
||||
expired_at: datetime | None = None
|
||||
last_login_at: datetime | None = None
|
||||
entitlement: dict | None = None
|
||||
|
||||
@@ -11,10 +11,12 @@ from sqlalchemy.orm import Session
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.user import User
|
||||
from app.services.ai_request_log_service import AiRequestLogService
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.chat_context_service import ChatContextService
|
||||
from app.services.model_service import ModelClientService
|
||||
from app.services.rag_service import RagService
|
||||
from app.services.topic_session_service import TopicSessionService
|
||||
|
||||
|
||||
class ChatService:
|
||||
@@ -75,11 +77,25 @@ class ChatService:
|
||||
user = ChatService.prepare_daily_quota(db, user)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
ChatService._ensure_quota(user)
|
||||
entitlement = EntitlementService.active_entitlement(
|
||||
db,
|
||||
user,
|
||||
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
|
||||
)
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
|
||||
now = _now()
|
||||
normalized_question = question.strip()
|
||||
topic = TopicSessionService.get_or_create_active(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question=normalized_question,
|
||||
deduct_quota=entitlement.deduct_quota,
|
||||
)
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=normalized_question,
|
||||
@@ -87,6 +103,7 @@ class ChatService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_message)
|
||||
TopicSessionService.attach_user_message(user_message, topic)
|
||||
db.flush()
|
||||
|
||||
started_at = perf_counter()
|
||||
@@ -141,6 +158,7 @@ class ChatService:
|
||||
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=completion.answer,
|
||||
@@ -152,6 +170,12 @@ class ChatService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
TopicSessionService.attach_assistant_message(
|
||||
assistant_message,
|
||||
topic,
|
||||
token_input=completion.input_token,
|
||||
token_output=completion.output_token,
|
||||
)
|
||||
db.flush()
|
||||
|
||||
session.message_count += 2
|
||||
@@ -198,6 +222,18 @@ class ChatService:
|
||||
if user.daily_chat_used >= user.daily_chat_limit:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="今日提问次数已用完")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_topic_quota(db: Session, user: User, session: ChatSession, entitlement) -> None:
|
||||
if not entitlement.deduct_quota or entitlement.monthly_topic_limit is None:
|
||||
return
|
||||
if TopicSessionService.active_for_session(db, user=user, session=session) is not None:
|
||||
return
|
||||
if entitlement.monthly_topic_used >= entitlement.monthly_topic_limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="本月深度主题使用较多,建议先完成已有功课;如需继续高频使用,可以联系运营老师确认权益。",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def prepare_daily_quota(db: Session, user: User) -> User:
|
||||
locked_user = db.scalar(select(User).where(User.id == user.id).with_for_update())
|
||||
|
||||
@@ -16,11 +16,13 @@ from app.models.user import User
|
||||
from app.services.ai_request_log_service import AiRequestLogService
|
||||
from app.services.chat_service import ChatService, _title_from_question
|
||||
from app.services.chat_context_service import ChatContextService
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.external_errors import ExternalServiceError
|
||||
from app.services.human_attention_service import HumanAttentionService
|
||||
from app.services.model_stream_service import ModelStreamService
|
||||
from app.services.rag_async_service import AsyncRagService
|
||||
from app.services.rag_service import RagService
|
||||
from app.services.topic_session_service import TopicSessionService
|
||||
|
||||
|
||||
class ChatStreamService:
|
||||
@@ -29,11 +31,25 @@ class ChatStreamService:
|
||||
user = ChatService.prepare_daily_quota(db, user)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
ChatService._ensure_quota(user)
|
||||
entitlement = EntitlementService.active_entitlement(
|
||||
db,
|
||||
user,
|
||||
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
|
||||
)
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
|
||||
now = _now()
|
||||
normalized_question = question.strip()
|
||||
topic = TopicSessionService.get_or_create_active(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question=normalized_question,
|
||||
deduct_quota=entitlement.deduct_quota,
|
||||
)
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=normalized_question,
|
||||
@@ -41,6 +57,7 @@ class ChatStreamService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_message)
|
||||
TopicSessionService.attach_user_message(user_message, topic)
|
||||
db.flush()
|
||||
|
||||
history = list(
|
||||
@@ -122,6 +139,7 @@ class ChatStreamService:
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=answer,
|
||||
@@ -133,6 +151,12 @@ class ChatStreamService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
TopicSessionService.attach_assistant_message(
|
||||
assistant_message,
|
||||
topic,
|
||||
token_input=model_response.input_token if model_response is not None else None,
|
||||
token_output=_rough_token_count(answer),
|
||||
)
|
||||
db.flush()
|
||||
|
||||
session.message_count += 2
|
||||
@@ -163,11 +187,25 @@ class ChatStreamService:
|
||||
user = ChatService.prepare_daily_quota(db, user)
|
||||
session = ChatService._get_user_session(db, user, session_id)
|
||||
ChatService._ensure_quota(user)
|
||||
entitlement = EntitlementService.active_entitlement(
|
||||
db,
|
||||
user,
|
||||
monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id),
|
||||
)
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
|
||||
now = _now()
|
||||
normalized_question = question.strip()
|
||||
topic = TopicSessionService.get_or_create_active(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question=normalized_question,
|
||||
deduct_quota=entitlement.deduct_quota,
|
||||
)
|
||||
user_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id,
|
||||
user_id=user.id,
|
||||
role="user",
|
||||
content=normalized_question,
|
||||
@@ -175,6 +213,7 @@ class ChatStreamService:
|
||||
created_at=now,
|
||||
)
|
||||
db.add(user_message)
|
||||
TopicSessionService.attach_user_message(user_message, topic)
|
||||
db.flush()
|
||||
|
||||
history = list(
|
||||
@@ -262,6 +301,7 @@ class ChatStreamService:
|
||||
model_response=model_response,
|
||||
started_at=started_at,
|
||||
now=now,
|
||||
topic=topic,
|
||||
)
|
||||
|
||||
|
||||
@@ -284,10 +324,12 @@ def _write_success(
|
||||
model_response,
|
||||
started_at: float,
|
||||
now: datetime,
|
||||
topic,
|
||||
) -> None:
|
||||
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||
assistant_message = ChatMessage(
|
||||
session_id=session.id,
|
||||
topic_session_id=topic.id if topic is not None else None,
|
||||
user_id=user.id,
|
||||
role="assistant",
|
||||
content=answer,
|
||||
@@ -299,6 +341,13 @@ def _write_success(
|
||||
created_at=now,
|
||||
)
|
||||
db.add(assistant_message)
|
||||
if topic is not None:
|
||||
TopicSessionService.attach_assistant_message(
|
||||
assistant_message,
|
||||
topic,
|
||||
token_input=model_response.input_token if model_response is not None else None,
|
||||
token_output=_rough_token_count(answer),
|
||||
)
|
||||
db.flush()
|
||||
|
||||
session.message_count += 2
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
DEFAULT_PLAN_TYPE = "basic"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EntitlementView:
|
||||
plan_id: int | None
|
||||
name: str
|
||||
plan_type: str
|
||||
monthly_topic_limit: int | None
|
||||
monthly_topic_used: int
|
||||
enable_growth_profile: bool
|
||||
enable_periodic_reports: bool
|
||||
allow_help_card: bool
|
||||
allow_share_draft: bool
|
||||
deduct_quota: bool
|
||||
effective_at: datetime | None = None
|
||||
expired_at: datetime | None = None
|
||||
source: str = "legacy"
|
||||
|
||||
@property
|
||||
def monthly_topic_remaining(self) -> int | None:
|
||||
if self.monthly_topic_limit is None:
|
||||
return None
|
||||
return max(0, self.monthly_topic_limit - self.monthly_topic_used)
|
||||
|
||||
|
||||
class EntitlementService:
|
||||
@staticmethod
|
||||
def list_plans(db: Session, *, include_disabled: bool = False) -> list[EntitlementPlan]:
|
||||
query = select(EntitlementPlan)
|
||||
if not include_disabled:
|
||||
query = query.where(EntitlementPlan.status == 1)
|
||||
return list(db.scalars(query.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())))
|
||||
|
||||
@staticmethod
|
||||
def default_plan(db: Session) -> EntitlementPlan | None:
|
||||
plan = db.scalar(
|
||||
select(EntitlementPlan)
|
||||
.where(EntitlementPlan.plan_type == DEFAULT_PLAN_TYPE, EntitlementPlan.status == 1)
|
||||
.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())
|
||||
.limit(1)
|
||||
)
|
||||
if plan is not None:
|
||||
return plan
|
||||
return db.scalar(
|
||||
select(EntitlementPlan)
|
||||
.where(EntitlementPlan.status == 1)
|
||||
.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def active_entitlement(db: Session, user: User, *, monthly_topic_used: int = 0) -> EntitlementView:
|
||||
now = _now()
|
||||
row = db.execute(
|
||||
select(UserEntitlement, EntitlementPlan)
|
||||
.join(EntitlementPlan, EntitlementPlan.id == UserEntitlement.plan_id)
|
||||
.where(
|
||||
UserEntitlement.user_id == user.id,
|
||||
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())
|
||||
.limit(1)
|
||||
).first()
|
||||
if row:
|
||||
entitlement, plan = row
|
||||
return view_from_plan(plan, monthly_topic_used=monthly_topic_used, entitlement=entitlement, source="assigned")
|
||||
|
||||
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")
|
||||
|
||||
return EntitlementView(
|
||||
plan_id=None,
|
||||
name="旧版每日额度",
|
||||
plan_type="legacy",
|
||||
monthly_topic_limit=None,
|
||||
monthly_topic_used=monthly_topic_used,
|
||||
enable_growth_profile=False,
|
||||
enable_periodic_reports=False,
|
||||
allow_help_card=True,
|
||||
allow_share_draft=True,
|
||||
deduct_quota=True,
|
||||
source="legacy",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def assign_user_plan(
|
||||
db: Session,
|
||||
*,
|
||||
user: User,
|
||||
plan_id: int,
|
||||
operated_by: int | None,
|
||||
effective_at: datetime | None = None,
|
||||
expired_at: datetime | None = None,
|
||||
remark: str | None = None,
|
||||
) -> UserEntitlement:
|
||||
plan = db.get(EntitlementPlan, plan_id)
|
||||
if plan is None or plan.status != 1:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在或已停用")
|
||||
now = _now()
|
||||
if effective_at is not None:
|
||||
effective_at = effective_at.replace(tzinfo=None)
|
||||
if expired_at is None and plan.validity_days:
|
||||
start = effective_at or now
|
||||
expired_at = start + timedelta(days=plan.validity_days)
|
||||
elif expired_at is not None:
|
||||
expired_at = expired_at.replace(tzinfo=None)
|
||||
if expired_at is not None and effective_at is not None and expired_at < effective_at:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="权益到期时间不能早于生效时间")
|
||||
|
||||
current = db.scalar(
|
||||
select(UserEntitlement)
|
||||
.where(UserEntitlement.user_id == user.id, UserEntitlement.status == "active")
|
||||
.order_by(UserEntitlement.created_at.desc(), UserEntitlement.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
from_plan_id = current.plan_id if current else None
|
||||
if current is not None:
|
||||
current.status = "replaced"
|
||||
db.add(current)
|
||||
|
||||
entitlement = UserEntitlement(
|
||||
user_id=user.id,
|
||||
plan_id=plan.id,
|
||||
status="active",
|
||||
effective_at=effective_at,
|
||||
expired_at=expired_at,
|
||||
assigned_by=operated_by,
|
||||
remark=remark,
|
||||
)
|
||||
db.add(entitlement)
|
||||
db.flush()
|
||||
db.add(
|
||||
UserEntitlementLog(
|
||||
user_id=user.id,
|
||||
entitlement_id=entitlement.id,
|
||||
from_plan_id=from_plan_id,
|
||||
to_plan_id=plan.id,
|
||||
action="assign",
|
||||
detail_json=json.dumps(
|
||||
{
|
||||
"effectiveAt": effective_at.isoformat() if effective_at else None,
|
||||
"expiredAt": expired_at.isoformat() if expired_at else None,
|
||||
"remark": remark,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
operated_by=operated_by,
|
||||
created_at=now,
|
||||
)
|
||||
)
|
||||
return entitlement
|
||||
|
||||
|
||||
def plan_dict(plan: EntitlementPlan) -> dict:
|
||||
return {
|
||||
"id": plan.id,
|
||||
"name": plan.name,
|
||||
"planType": plan.plan_type,
|
||||
"description": plan.description,
|
||||
"validityDays": plan.validity_days,
|
||||
"monthlyTopicLimit": plan.monthly_topic_limit,
|
||||
"enableGrowthProfile": bool(plan.enable_growth_profile),
|
||||
"enablePeriodicReports": bool(plan.enable_periodic_reports),
|
||||
"allowHelpCard": bool(plan.allow_help_card),
|
||||
"allowShareDraft": bool(plan.allow_share_draft),
|
||||
"deductQuota": bool(plan.deduct_quota),
|
||||
"status": plan.status,
|
||||
"sortOrder": plan.sort_order,
|
||||
"createdAt": plan.created_at,
|
||||
"updatedAt": plan.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def entitlement_dict(view: EntitlementView) -> dict:
|
||||
return {
|
||||
"planId": view.plan_id,
|
||||
"name": view.name,
|
||||
"planType": view.plan_type,
|
||||
"monthlyTopicLimit": view.monthly_topic_limit,
|
||||
"monthlyTopicUsed": view.monthly_topic_used,
|
||||
"monthlyTopicRemaining": view.monthly_topic_remaining,
|
||||
"enableGrowthProfile": view.enable_growth_profile,
|
||||
"enablePeriodicReports": view.enable_periodic_reports,
|
||||
"allowHelpCard": view.allow_help_card,
|
||||
"allowShareDraft": view.allow_share_draft,
|
||||
"deductQuota": view.deduct_quota,
|
||||
"effectiveAt": view.effective_at,
|
||||
"expiredAt": view.expired_at,
|
||||
"source": view.source,
|
||||
}
|
||||
|
||||
|
||||
def view_from_plan(
|
||||
plan: EntitlementPlan,
|
||||
*,
|
||||
monthly_topic_used: int,
|
||||
entitlement: UserEntitlement | None,
|
||||
source: str,
|
||||
) -> EntitlementView:
|
||||
return EntitlementView(
|
||||
plan_id=plan.id,
|
||||
name=plan.name,
|
||||
plan_type=plan.plan_type,
|
||||
monthly_topic_limit=plan.monthly_topic_limit,
|
||||
monthly_topic_used=monthly_topic_used,
|
||||
enable_growth_profile=bool(plan.enable_growth_profile),
|
||||
enable_periodic_reports=bool(plan.enable_periodic_reports),
|
||||
allow_help_card=bool(plan.allow_help_card),
|
||||
allow_share_draft=bool(plan.allow_share_draft),
|
||||
deduct_quota=bool(plan.deduct_quota),
|
||||
effective_at=entitlement.effective_at if entitlement else None,
|
||||
expired_at=entitlement.expired_at if entitlement else None,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
@@ -61,6 +61,7 @@ SYNONYM_RULES = (
|
||||
(re.compile(r"(会议链接|会议号|直播链接|上课链接|腾讯会议|飞书会议)"), "会议链接"),
|
||||
(re.compile(r"(助教|助理|班主任|辅导老师)"), "课程助理"),
|
||||
(re.compile(r"(上课|直播|带练|带领练习)"), "上课安排"),
|
||||
(re.compile(r"(都有哪些|有哪些|都有什么|有什么|全部|所有)"), "有哪些"),
|
||||
(re.compile(r"(怎么做|如何做|咋做|具体步骤|操作步骤|怎么操作|具体操作)"), "怎么做"),
|
||||
(re.compile(r"(是什么|什么意思|啥意思|定义|区别)"), "是什么"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import extract, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat import ChatMessage, ChatSession, TopicSession
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class TopicSessionService:
|
||||
@staticmethod
|
||||
def active_for_session(db: Session, *, user: User, session: ChatSession) -> TopicSession | None:
|
||||
return db.scalar(
|
||||
select(TopicSession)
|
||||
.where(
|
||||
TopicSession.user_id == user.id,
|
||||
TopicSession.chat_session_id == session.id,
|
||||
TopicSession.status == "active",
|
||||
)
|
||||
.order_by(TopicSession.created_at.desc(), TopicSession.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def monthly_used_count(db: Session, user_id: int, *, at: datetime | None = None) -> int:
|
||||
current = at or _now()
|
||||
return int(
|
||||
db.scalar(
|
||||
select(func.count(TopicSession.id)).where(
|
||||
TopicSession.user_id == user_id,
|
||||
extract("year", TopicSession.started_at) == current.year,
|
||||
extract("month", TopicSession.started_at) == current.month,
|
||||
TopicSession.quota_deducted == 1,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_active(
|
||||
db: Session,
|
||||
*,
|
||||
user: User,
|
||||
session: ChatSession,
|
||||
question: str,
|
||||
deduct_quota: bool,
|
||||
) -> TopicSession:
|
||||
topic = TopicSessionService.active_for_session(db, user=user, session=session)
|
||||
if topic is not None:
|
||||
return topic
|
||||
topic = TopicSession(
|
||||
user_id=user.id,
|
||||
chat_session_id=session.id,
|
||||
title=_title_from_question(question),
|
||||
core_question=question.strip(),
|
||||
status="active",
|
||||
message_count=0,
|
||||
token_input=0,
|
||||
token_output=0,
|
||||
quota_deducted=1 if deduct_quota else 0,
|
||||
started_at=_now(),
|
||||
)
|
||||
db.add(topic)
|
||||
db.flush()
|
||||
return topic
|
||||
|
||||
@staticmethod
|
||||
def attach_user_message(message: ChatMessage, topic: TopicSession) -> None:
|
||||
message.topic_session_id = topic.id
|
||||
topic.message_count += 1
|
||||
|
||||
@staticmethod
|
||||
def attach_assistant_message(
|
||||
message: ChatMessage,
|
||||
topic: TopicSession,
|
||||
*,
|
||||
token_input: int | None,
|
||||
token_output: int | None,
|
||||
) -> None:
|
||||
message.topic_session_id = topic.id
|
||||
topic.message_count += 1
|
||||
topic.token_input += int(token_input or 0)
|
||||
topic.token_output += int(token_output or 0)
|
||||
|
||||
@staticmethod
|
||||
def topic_dict(topic: TopicSession) -> dict:
|
||||
return {
|
||||
"id": topic.id,
|
||||
"userId": topic.user_id,
|
||||
"chatSessionId": topic.chat_session_id,
|
||||
"title": topic.title,
|
||||
"coreQuestion": topic.core_question,
|
||||
"status": topic.status,
|
||||
"messageCount": topic.message_count,
|
||||
"tokenInput": topic.token_input,
|
||||
"tokenOutput": topic.token_output,
|
||||
"quotaDeducted": bool(topic.quota_deducted),
|
||||
"helpCardGenerated": bool(topic.help_card_generated),
|
||||
"shareDraftGenerated": bool(topic.share_draft_generated),
|
||||
"startedAt": topic.started_at,
|
||||
"endedAt": topic.ended_at,
|
||||
"updatedAt": topic.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _title_from_question(question: str) -> str:
|
||||
title = question.strip().replace("\n", " ")
|
||||
return title[:40] if title else "新主题"
|
||||
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.models import Base
|
||||
from app.models.chat import ChatSession, TopicSession
|
||||
from app.models.entitlement import EntitlementPlan
|
||||
from app.models.user import User
|
||||
from app.services.chat_service import ChatService
|
||||
from app.services.entitlement_service import EntitlementService
|
||||
from app.services.topic_session_service import TopicSessionService
|
||||
|
||||
|
||||
def _db() -> Session:
|
||||
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _seed_user_session(db: Session) -> tuple[User, ChatSession]:
|
||||
user = User(id=1, phone="13800000001", name="测试用户", daily_chat_limit=100, daily_chat_used=0)
|
||||
session = ChatSession(id=1, user_id=1, title="新聊天", message_count=0, last_message_at=_now(), is_deleted=0)
|
||||
db.add_all([user, session])
|
||||
db.commit()
|
||||
return user, session
|
||||
|
||||
|
||||
def test_default_entitlement_uses_basic_plan_when_user_has_no_assignment():
|
||||
with _db() as db:
|
||||
user, _session = _seed_user_session(db)
|
||||
db.add(
|
||||
EntitlementPlan(
|
||||
id=10,
|
||||
name="大本营基础版",
|
||||
plan_type="basic",
|
||||
monthly_topic_limit=30,
|
||||
status=1,
|
||||
sort_order=10,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
view = EntitlementService.active_entitlement(db, user, monthly_topic_used=3)
|
||||
|
||||
assert view.plan_id == 10
|
||||
assert view.name == "大本营基础版"
|
||||
assert view.source == "default"
|
||||
assert view.monthly_topic_remaining == 27
|
||||
|
||||
|
||||
def test_assign_user_plan_replaces_previous_active_plan():
|
||||
with _db() as db:
|
||||
user, _session = _seed_user_session(db)
|
||||
db.add_all(
|
||||
[
|
||||
EntitlementPlan(id=10, name="基础版", plan_type="basic", monthly_topic_limit=30, status=1, sort_order=10),
|
||||
EntitlementPlan(id=20, name="深度陪伴版", plan_type="deep", monthly_topic_limit=90, status=1, sort_order=20),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
first = EntitlementService.assign_user_plan(db, user=user, plan_id=10, operated_by=99)
|
||||
second = EntitlementService.assign_user_plan(db, user=user, plan_id=20, operated_by=99)
|
||||
db.commit()
|
||||
|
||||
db.refresh(first)
|
||||
db.refresh(second)
|
||||
view = EntitlementService.active_entitlement(db, user, monthly_topic_used=4)
|
||||
|
||||
assert first.status == "replaced"
|
||||
assert second.status == "active"
|
||||
assert view.plan_id == 20
|
||||
assert view.source == "assigned"
|
||||
assert view.monthly_topic_remaining == 86
|
||||
|
||||
|
||||
def test_monthly_topic_quota_blocks_new_topic_but_allows_existing_topic():
|
||||
with _db() as db:
|
||||
user, session = _seed_user_session(db)
|
||||
db.add(EntitlementPlan(id=10, name="限额版", plan_type="basic", monthly_topic_limit=1, status=1, sort_order=10))
|
||||
db.add(
|
||||
TopicSession(
|
||||
id=100,
|
||||
user_id=user.id,
|
||||
chat_session_id=99,
|
||||
title="旧主题",
|
||||
core_question="旧主题",
|
||||
status="active",
|
||||
quota_deducted=1,
|
||||
started_at=_now(),
|
||||
created_at=_now(),
|
||||
updated_at=_now(),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
entitlement = EntitlementService.active_entitlement(db, user, monthly_topic_used=TopicSessionService.monthly_used_count(db, user.id))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
TopicSessionService.get_or_create_active(
|
||||
db,
|
||||
user=user,
|
||||
session=session,
|
||||
question="当前主题",
|
||||
deduct_quota=True,
|
||||
)
|
||||
db.flush()
|
||||
|
||||
ChatService._ensure_topic_quota(db, user, session, entitlement)
|
||||
Reference in New Issue
Block a user