Files
life-echo/api/app/features/user/repo.py
Kevin 8af37e5e8e 修复:CI 部署环境与 ref 错配、迁移碎片化、图片意图 source_span、章节物化脏版式、会话历史与本地语音不一致
新增:TTS 上传 COS 与分片、章节 reading_segments 物化与快照、markdown 清洗、会话消息 repository、语音 store 重构与相关测试
2026-03-20 16:43:02 +08:00

153 lines
5.5 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.
"""User 数据访问:查询与「清空用户业务数据」批量删除。"""
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.cos_url_keys import (
collect_cos_keys_from_tts_url_list,
extract_cos_object_key_if_owned,
)
from app.features.asset.models import Asset
from app.features.auth.models import RefreshToken
from app.features.conversation.models import Conversation, Segment
from app.features.memoir.models import (
Book,
Chapter,
ChapterCoverIntent,
MemoirImage,
MemoirState,
)
from app.features.memory.models import (
MemoryChunk,
MemoryCurationAction,
MemoryFact,
MemorySource,
MemorySummary,
TimelineEvent,
)
from app.features.payment.models import Order
from app.features.story.models import Story, StoryImageIntent
from app.features.user.models import User
async def get_user_by_id(user_id: str, db: AsyncSession) -> User | None:
return await db.get(User, user_id)
async def collect_purge_context(
db: AsyncSession, user_id: str
) -> tuple[list[str], list[str], list[str]]:
"""在删除前收集 Redis / 分布式锁相关 id。"""
conv_rows = await db.execute(
select(Conversation.id).where(Conversation.user_id == user_id)
)
conv_ids = list(conv_rows.scalars().all())
ch_rows = await db.execute(select(Chapter.id).where(Chapter.user_id == user_id))
chapter_ids = list(ch_rows.scalars().all())
st_rows = await db.execute(select(Story.id).where(Story.user_id == user_id))
story_ids = list(st_rows.scalars().all())
return conv_ids, chapter_ids, story_ids
async def related_asset_ids(db: AsyncSession, user_id: str) -> set[str]:
"""用户故事插图 / 章节封面意图引用的 Asset.id。"""
asset_rows = await db.execute(
select(StoryImageIntent.asset_id)
.join(Story, StoryImageIntent.story_id == Story.id)
.where(Story.user_id == user_id, StoryImageIntent.asset_id.isnot(None))
)
from_story = {r for r in asset_rows.scalars().all() if r}
asset_rows_ch = await db.execute(
select(ChapterCoverIntent.asset_id)
.join(Chapter, ChapterCoverIntent.chapter_id == Chapter.id)
.where(Chapter.user_id == user_id, ChapterCoverIntent.asset_id.isnot(None))
)
return from_story | {r for r in asset_rows_ch.scalars().all() if r}
async def collect_object_storage_keys_before_purge(
db: AsyncSession, user_id: str
) -> list[str]:
"""删除行前收集 COS 等对象存储 key尽力删除孤儿对象"""
keys: set[str] = set()
r = await db.execute(
select(MemorySource.storage_key).where(
MemorySource.user_id == user_id,
MemorySource.storage_key.isnot(None),
)
)
keys.update(x for x in r.scalars().all() if x)
r2 = await db.execute(
select(MemoirImage.storage_key)
.join(Chapter, MemoirImage.chapter_id == Chapter.id)
.where(Chapter.user_id == user_id, MemoirImage.storage_key.isnot(None))
)
keys.update(x for x in r2.scalars().all() if x)
asset_ids = await related_asset_ids(db, user_id)
if asset_ids:
r3 = await db.execute(
select(Asset.storage_key).where(
Asset.id.in_(asset_ids),
Asset.storage_key.isnot(None),
)
)
keys.update(x for x in r3.scalars().all() if x)
seg_rows = await db.execute(
select(Segment.audio_url, Segment.tts_audio_urls)
.join(Conversation, Segment.conversation_id == Conversation.id)
.where(Conversation.user_id == user_id)
)
for audio_url, tts_urls in seg_rows.all():
k = extract_cos_object_key_if_owned(audio_url)
if k:
keys.add(k)
keys |= collect_cos_keys_from_tts_url_list(
tts_urls if isinstance(tts_urls, list) else None
)
return sorted(keys)
async def purge_user_related_rows(db: AsyncSession, user_id: str) -> None:
"""
物理删除当前用户除账号users 行)外的业务数据。
顺序按外键依赖memory → 资源意图关联的 assets → story/chapter/book → 对话 → 订单与 refresh token 等。
"""
await db.execute(delete(MemoryFact).where(MemoryFact.user_id == user_id))
await db.execute(delete(MemoryChunk).where(MemoryChunk.user_id == user_id))
await db.execute(delete(MemorySource).where(MemorySource.user_id == user_id))
await db.execute(delete(MemorySummary).where(MemorySummary.user_id == user_id))
await db.execute(delete(TimelineEvent).where(TimelineEvent.user_id == user_id))
await db.execute(
delete(MemoryCurationAction).where(MemoryCurationAction.user_id == user_id)
)
asset_ids = await related_asset_ids(db, user_id)
if asset_ids:
await db.execute(delete(Asset).where(Asset.id.in_(asset_ids)))
await db.execute(delete(Story).where(Story.user_id == user_id))
await db.execute(delete(Chapter).where(Chapter.user_id == user_id))
await db.execute(delete(Book).where(Book.user_id == user_id))
await db.execute(
delete(Segment).where(
Segment.conversation_id.in_(
select(Conversation.id).where(Conversation.user_id == user_id)
)
)
)
await db.execute(delete(Conversation).where(Conversation.user_id == user_id))
await db.execute(delete(MemoirState).where(MemoirState.user_id == user_id))
await db.execute(delete(Order).where(Order.user_id == user_id))
await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user_id))