Files
life-echo/api/app/features/quota/service.py
Kevin 786ebf8ae6 refactor(api,expo): 多智能体与会话收敛、回忆录兼容层移除、后端测试集大幅删减
- 对齐「多智能体收敛」与「回忆录 stories-first / markdown-first」方向:收紧运行时契约、
  删除过渡兼容路径与双轨逻辑,并同步更新客户端与文档。

- Chat:以 ChatOrchestrator 为实时编排入口;删除独立 conversation_agent,精简 prompts。
- Memoir:删除 memory_agent;MemoirOrchestrator、classification / story_route 与 prompts 收敛到
  prepare_batches + run_story_pipeline_for_category_batch 主链路。
- 将 agents 侧 processor 迁入 feature 层为 background_runner,并移除 features 下重复/过时
  processor 封装。

- 新增 history_store,强化「conversation_messages 为 DB 真源、Redis 为缓存」模型。
- 调整 models、repo、service、session_history;精简 WS message_types,重构 pipeline 与 router。

- 移除章节占位、整章再生等旧路径;章节列表与封面逻辑要求 story 关联;收紧 cover 资格与
  enqueue。
- helpers、repo、service、router、reading_segment_materialize、story_pipeline_sync、pdf_service
  等按 canonical markdown / cover_asset_id 收缩;删除 memoir_images/provider 等冗余。
- tasks:memoir_tasks、chapter_cover_tasks 等大幅瘦身;story_image_tasks 等与当前图片任务对齐。

- core:config、logging、redis、task_tracker 小幅调整。
- auth / user / payment / quota:路由或服务侧删减过时接口或逻辑(如 payment router 行数减少)。

- pyproject.toml、development.sh、.env.example / .env.production、README 等同步说明或变量。

- Alembic 0001_initial_schema 微调(与当前 schema 叙事一致的小改动)。

- 回忆录:types / mappers / api、章节页与 memoir 页与后端契约对齐;markdown-renderer 调整。
- 语音:删除 voice/player,voice-segment-store 相应精简。

- api/tests:删除 conftest 及绝大部分既有测试文件(websocket_baseline、conversation、memoir
  图片、PDF、SMS 等),属有意收缩/待按 backend-test-system 重建的信号。
- docs:新增多智能体收敛与移除兼容层计划摘要;更新 story-first 设计、backend-test-system、
  multi-agent-refactor-plan、实施总结等。

BREAKING CHANGE: 后端对外契约、回忆录章节字段与若干路由/任务行为已变更;大量 API 测试被移除,
  CI 若依赖这些用例需按新策略补测或调整流水线。
2026-03-22 18:10:28 +08:00

160 lines
5.7 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 轮。
"""
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.quota.schemas import QuotaCheckResponse
PLAN_QUOTAS = {
"free": {
"max_conversations": 50,
"max_chapters": 1,
"max_words": None,
},
"pro": {
"max_conversations": 2000,
"max_chapters": None,
"max_words": None,
},
"pro_plus": {
"max_conversations": 10000,
"max_chapters": None,
"max_words": None,
},
"premium": {
"max_conversations": None,
"max_chapters": None,
"max_words": None,
},
"test": {
"max_conversations": None,
"max_chapters": None,
"max_words": None,
},
}
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]:
"""检查用户是否还能发送消息(对话轮数)。返回 (是否允许, 提示信息)。"""
quotas = PLAN_QUOTAS.get(subscription_type, PLAN_QUOTAS["free"])
max_conv = quotas.get("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 个章节。"""
quotas = PLAN_QUOTAS.get(subscription_type, PLAN_QUOTAS["free"])
max_ch = quotas.get("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:
"""检查用户配额使用情况。"""
quotas = PLAN_QUOTAS.get(subscription_type, PLAN_QUOTAS["free"])
segment_count, chapter_count = await self.get_usage(user_id)
max_conversations = quotas.get("max_conversations")
max_chapters = quotas.get("max_chapters")
max_words = quotas.get("max_words")
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,
)