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,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,
},
],
)

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)

View File

@@ -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",
]

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -61,6 +61,7 @@ SYNONYM_RULES = (
(re.compile(r"(会议链接|会议号|直播链接|上课链接|腾讯会议|飞书会议)"), "会议链接"),
(re.compile(r"(助教|助理|班主任|辅导老师)"), "课程助理"),
(re.compile(r"(上课|直播|带练|带领练习)"), "上课安排"),
(re.compile(r"(都有哪些|有哪些|都有什么|有什么|全部|所有)"), "有哪些"),
(re.compile(r"(怎么做|如何做|咋做|具体步骤|操作步骤|怎么操作|具体操作)"), "怎么做"),
(re.compile(r"(是什么|什么意思|啥意思|定义|区别)"), "是什么"),
)

View File

@@ -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 "新主题"

View File

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