cosmo/backend/app/services/social_service.py

163 lines
6.7 KiB
Python

"""
Social Service - Handles user follows and channel messages
"""
import logging
import json
from typing import List, Optional, Dict
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete, func
from app.models.db.user_follow import UserFollow
from app.models.db.celestial_body import CelestialBody
from app.models.db.user import User
from app.models.schemas.social import ChannelMessageResponse
from app.services.redis_cache import redis_cache
logger = logging.getLogger(__name__)
class SocialService:
def __init__(self):
self.channel_message_prefix = "channel:messages:"
self.channel_message_ttl_seconds = 7 * 24 * 60 * 60 # 7 days
self.max_channel_messages = 500 # Max messages to keep in a channel
# --- User Follows ---
async def follow_body(self, user_id: int, body_id: str, db: AsyncSession) -> UserFollow:
"""User follows a celestial body"""
# Check if already following
existing_follow = await self.get_follow(user_id, body_id, db)
if existing_follow:
raise ValueError("Already following this body")
# Check if body exists
body = await db.execute(select(CelestialBody).where(CelestialBody.id == body_id))
if not body.scalar_one_or_none():
raise ValueError("Celestial body not found")
follow = UserFollow(user_id=user_id, body_id=body_id)
db.add(follow)
await db.commit()
await db.refresh(follow)
logger.info(f"User {user_id} followed body {body_id}")
return follow
async def unfollow_body(self, user_id: int, body_id: str, db: AsyncSession) -> bool:
"""User unfollows a celestial body"""
result = await db.execute(
delete(UserFollow).where(UserFollow.user_id == user_id, UserFollow.body_id == body_id)
)
await db.commit()
if result.rowcount > 0:
logger.info(f"User {user_id} unfollowed body {body_id}")
return True
return False
async def get_follow(self, user_id: int, body_id: str, db: AsyncSession) -> Optional[UserFollow]:
"""Get a specific follow record"""
result = await db.execute(
select(UserFollow).where(UserFollow.user_id == user_id, UserFollow.body_id == body_id)
)
return result.scalar_one_or_none()
async def get_user_follows(self, user_id: int, db: AsyncSession) -> List[CelestialBody]:
"""Get all bodies followed by a user"""
result = await db.execute(
select(CelestialBody)
.join(UserFollow, UserFollow.body_id == CelestialBody.id)
.where(UserFollow.user_id == user_id)
)
return result.scalars().all()
async def get_user_follows_with_time(self, user_id: int, db: AsyncSession) -> List[dict]:
"""Get all bodies followed by a user with created_at time and body details"""
from sqlalchemy.orm import selectinload
result = await db.execute(
select(UserFollow, CelestialBody)
.join(CelestialBody, UserFollow.body_id == CelestialBody.id)
.where(UserFollow.user_id == user_id)
)
follows_with_bodies = result.all()
return [
{
"user_id": follow.user_id,
"body_id": follow.body_id,
"created_at": follow.created_at, # Keep as created_at to match schema
"id": body.id,
"name": body.name,
"name_zh": body.name_zh,
"type": body.type,
"is_active": body.is_active,
}
for follow, body in follows_with_bodies
]
async def get_body_followers_count(self, body_id: str, db: AsyncSession) -> int:
"""Get the number of followers for a celestial body"""
result = await db.execute(
select(func.count()).where(UserFollow.body_id == body_id)
)
return result.scalar_one()
# --- Channel Messages (Redis based) ---
async def post_channel_message(self, user_id: int, body_id: str, content: str, db: AsyncSession) -> ChannelMessageResponse:
"""Post a message to a celestial body's channel"""
# Verify user and body exist
user_result = await db.execute(select(User.username).where(User.id == user_id))
username = user_result.scalar_one_or_none()
if not username:
raise ValueError("User not found")
body_result = await db.execute(select(CelestialBody).where(CelestialBody.id == body_id))
if not body_result.scalar_one_or_none():
raise ValueError("Celestial body not found")
# Verify user is following the body to post
# According to the requirement, only followed users can post.
is_following = await self.get_follow(user_id, body_id, db)
if not is_following:
raise ValueError("User is not following this celestial body channel")
message_data = {
"user_id": user_id,
"username": username,
"body_id": body_id,
"content": content,
"created_at": datetime.utcnow().isoformat() # Store as ISO string
}
channel_key = f"{self.channel_message_prefix}{body_id}"
# Add message to the right of the list (newest)
await redis_cache.rpush(channel_key, json.dumps(message_data)) # Store as JSON string
# Trim list to max_channel_messages
await redis_cache.ltrim(channel_key, -self.max_channel_messages, -1)
# Set/reset TTL for the channel list (e.g., if no activity, it expires)
await redis_cache.expire(channel_key, self.channel_message_ttl_seconds)
logger.info(f"Message posted to channel {body_id} by user {user_id}")
return ChannelMessageResponse(**message_data)
async def get_channel_messages(self, body_id: str, db: AsyncSession, limit: int = 50) -> List[ChannelMessageResponse]:
"""Get recent messages from a celestial body's channel"""
channel_key = f"{self.channel_message_prefix}{body_id}"
# Get messages from Redis list (newest first for display)
# LPUSH for oldest first, RPUSH for newest first. We use RPUSH, so -limit to -1 is newest
raw_messages = await redis_cache.lrange(channel_key, -limit, -1)
messages = []
for msg_str in raw_messages:
try:
msg_data = json.loads(msg_str)
messages.append(ChannelMessageResponse(**msg_data))
except json.JSONDecodeError:
logger.warning(f"Could not decode message from channel {body_id}: {msg_str}")
return messages
social_service = SocialService()