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

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