Files
life-echo/api/app/features/quota/service.py
Kevin 309a051038 feat: 回忆录证据血缘与内部评测可追溯,顺带对齐本地评测台与 CI
数据库与模型:新增多版迁移(章节证据快照、对话血缘、记忆事实/时间线 lineage 等),把「成稿 ↔ 对话/记忆」的溯源信息落到表结构里。
业务链路:会话与 WS、回忆录/故事流水线、记忆写入与 enrichment 等跟着接上线索与快照;新增章节证据快照与评测侧 EvalTraceService 等模块,方便组评审用的证据包。
内部评测:自动化 run 与手工 memoir 评审共用可追溯证据;rubric/ judge 相关脚本与文档有配套调整。
app-eval-web:Memoir/实验详情里能展开看证据摘要与 evidence_trace(含对话轮次 id);Vite 代理与 development.sh 注入的 API 端口与当前默认内部评测端口一致,避免改端口后页面连错服务。
工程杂项:GitHub Actions / 仓库说明有更新;各适配器与支付/配额/plan 等多处为小改动或跟随主改动的收尾;新增/扩充了?
2026-04-08 15:37:09 +08:00

134 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
配额检查业务逻辑。
「对话轮数」的定义每条用户发出的消息Segment 表的记录数)计为 1 轮。
限额与 [app.features.plan.catalog](catalog) 中的套餐定义一致,单一事实来源。
"""
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.features.conversation.models import Conversation, Segment
from app.features.memoir.models import Chapter
from app.features.plan.catalog import get_plan_by_type
from app.features.quota.schemas import QuotaCheckResponse
async def get_segment_count(user_id: str, db: AsyncSession) -> int:
"""获取用户已消耗的对话轮数(= 该用户所有 Segment 记录数)。"""
stmt = (
select(func.count(Segment.id))
.join(Conversation, Segment.conversation_id == Conversation.id)
.where(
Conversation.user_id == user_id,
Conversation.deleted_at.is_(None),
)
)
result = await db.execute(stmt)
return result.scalar() or 0
async def get_chapter_count(user_id: str, db: AsyncSession) -> int:
"""获取用户当前章节数量。"""
stmt = select(func.count(Chapter.id)).where(Chapter.user_id == user_id)
result = await db.execute(stmt)
return result.scalar() or 0
async def get_conversation_count(user_id: str, db: AsyncSession) -> int:
"""别名:实际按 Segment 计数。"""
return await get_segment_count(user_id, db)
def check_can_send_message(
subscription_type: str,
segment_count: int,
) -> tuple[bool, str]:
"""检查用户是否还能发送消息(对话轮数)。返回 (是否允许, 提示信息)。"""
plan = get_plan_by_type(subscription_type)
max_conv = plan.max_conversations
if max_conv is None:
return True, ""
if segment_count >= max_conv:
return (
False,
f"对话轮数已用完({segment_count}/{max_conv}),请升级 Pro 或 Pro+ 继续使用",
)
return True, ""
def check_can_submit_organize(
subscription_type: str,
chapter_count: int,
) -> tuple[bool, str]:
"""检查是否可以提交整理任务(生成新章节)。免费版仅允许 1 个章节。"""
plan = get_plan_by_type(subscription_type)
max_ch = plan.max_chapters
if max_ch is None:
return True, ""
if chapter_count >= max_ch:
return False, "章节数量已达上限(免费版仅支持 1 个章节整理),请升级后继续"
return True, ""
class QuotaService:
def __init__(self, db: AsyncSession):
self._db = db
async def get_usage(self, user_id: str) -> tuple[int, int]:
"""Return (segment_count, chapter_count)."""
seg = await get_segment_count(user_id, self._db)
ch = await get_chapter_count(user_id, self._db)
return seg, ch
async def check_can_send_message(
self, user_id: str, subscription_type: str
) -> tuple[bool, str]:
"""检查用户是否还能发送消息(对话轮数)。返回 (是否允许, 提示信息)。"""
count = await get_segment_count(user_id, self._db)
return check_can_send_message(subscription_type, count)
async def check_can_submit_organize(
self, user_id: str, subscription_type: str
) -> tuple[bool, str]:
"""检查是否可以提交整理任务(生成新章节)。返回 (是否允许, 提示信息)。"""
chapter_count = await get_chapter_count(user_id, self._db)
return check_can_submit_organize(subscription_type, chapter_count)
async def check(self, user_id: str, subscription_type: str) -> QuotaCheckResponse:
"""检查用户配额使用情况。"""
plan = get_plan_by_type(subscription_type)
segment_count, chapter_count = await self.get_usage(user_id)
max_conversations = plan.max_conversations
max_chapters = plan.max_chapters
remaining_conversations = None
remaining_chapters = None
remaining_words = None
if max_conversations is not None:
remaining_conversations = max(0, max_conversations - segment_count)
if max_chapters is not None:
remaining_chapters = max(0, max_chapters - chapter_count)
has_quota = True
message = "配额充足"
if max_conversations is not None and segment_count >= max_conversations:
has_quota = False
message = f"对话轮数已用完({segment_count}/{max_conversations}),请升级 Pro 或 Pro+ 继续使用"
elif max_chapters is not None and chapter_count >= max_chapters:
has_quota = False
message = "章节数量已达上限(免费版仅支持 1 个章节整理),请升级后继续"
return QuotaCheckResponse(
has_quota=has_quota,
remaining_conversations=remaining_conversations,
remaining_chapters=remaining_chapters,
remaining_words=remaining_words,
used_conversations=segment_count,
used_chapters=chapter_count,
max_conversations=max_conversations,
max_chapters=max_chapters,
message=message,
)