This commit is contained in:
2026-06-14 09:25:59 +08:00
parent a0f441d8ae
commit 6fbf610277
39 changed files with 2492 additions and 2 deletions
+5 -1
View File
@@ -64,7 +64,7 @@ app.add_middleware(
app.mount("/uploads", StaticFiles(directory=settings.UPLOAD_DIR), name="uploads")
# 注册路由
from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, leaves, trees, capsules
from app.routers import auth, users, conversations, messages, friends, admin, uploads, moments, leaves, trees, capsules, echoes, sync, climates, flash
app.include_router(auth.router, prefix="/api/v1/auth", tags=["认证"])
app.include_router(users.router, prefix="/api/v1/users", tags=["用户"])
@@ -77,6 +77,10 @@ app.include_router(moments.router, prefix="/api/v1/moments", tags=["朋友圈"])
app.include_router(leaves.router, prefix="/api/v1/leaves", tags=["心情叶"])
app.include_router(trees.router, prefix="/api/v1/trees", tags=["好友之树"])
app.include_router(capsules.router, prefix="/api/v1/capsules", tags=["时光胶囊"])
app.include_router(echoes.router, prefix="/api/v1/echoes", tags=["念念回音"])
app.include_router(sync.router, prefix="/api/v1/sync", tags=["默契种子"])
app.include_router(climates.router, prefix="/api/v1/climates", tags=["聊天气候"])
app.include_router(flash.router, prefix="/api/v1/flash", tags=["萤火虫时刻"])
# WebSocket
from app.websocket.router import websocket_router
+10
View File
@@ -11,6 +11,10 @@ from app.models.moment import Moment, MomentLike, MomentComment
from app.models.daily_leaf import DailyMoodLeaf
from app.models.friendship_tree import FriendshipTree
from app.models.time_capsule import TimeCapsule
from app.models.miss_echo import MissEcho
from app.models.sync_seed import SyncQuestion, SyncSeed
from app.models.chat_climate import ChatClimate
from app.models.flash_event import FlashEvent, FlashParticipation
__all__ = [
"User",
@@ -26,4 +30,10 @@ __all__ = [
"DailyMoodLeaf",
"FriendshipTree",
"TimeCapsule",
"MissEcho",
"SyncQuestion",
"SyncSeed",
"ChatClimate",
"FlashEvent",
"FlashParticipation",
]
+24
View File
@@ -0,0 +1,24 @@
"""聊天气候模型"""
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, Text, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class ChatClimate(Base):
__tablename__ = "chat_climates"
conversation_id: Mapped[str] = mapped_column(String(36), ForeignKey("conversations.id", ondelete="CASCADE"), primary_key=True)
season: Mapped[str] = mapped_column(String(10), nullable=False) # spring/summer/autumn/winter
temperature: Mapped[int] = mapped_column(Integer, nullable=False) # -10 ~ 40
weather: Mapped[str] = mapped_column(String(20), nullable=False) # sunny/cloudy/rainy/windy/snowy
emoji: Mapped[str] = mapped_column(String(10), nullable=False)
daily_history: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON: [{date, season, temp, emoji}]
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.utcnow(), onupdate=lambda: datetime.utcnow()
)
conversation = relationship("Conversation")
+41
View File
@@ -0,0 +1,41 @@
"""萤火虫时刻模型"""
from datetime import datetime
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class FlashEvent(Base):
"""全服随机萤火虫事件"""
__tablename__ = "flash_events"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
type: Mapped[str] = mapped_column(String(20), default="firefly") # firefly / meteor
start_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
end_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
target_clicks: Mapped[int] = mapped_column(Integer, default=100) # 全服目标点击数
total_clicks: Mapped[int] = mapped_column(Integer, default=0)
reached: Mapped[bool] = mapped_column(Boolean, default=False)
leaf_seed: Mapped[str] = mapped_column(String(16), nullable=False) # 限定纪念叶种子
leaf_variant: Mapped[str] = mapped_column(String(40), nullable=False) # 永不复刻的变体标识
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
class FlashParticipation(Base):
"""用户参与记录(集气点击 + 获得的叶子)"""
__tablename__ = "flash_participations"
__table_args__ = (
UniqueConstraint("event_id", "user_id", name="uq_flash_participation"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True)
event_id: Mapped[str] = mapped_column(String(36), ForeignKey("flash_events.id", ondelete="CASCADE"))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
clicks: Mapped[int] = mapped_column(Integer, default=0)
earned: Mapped[bool] = mapped_column(Boolean, default=False) # 是否达标获得限定叶
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
user = relationship("User", foreign_keys=[user_id])
+24
View File
@@ -0,0 +1,24 @@
"""念念回音模型"""
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class MissEcho(Base):
__tablename__ = "miss_echoes"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
from_user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
to_user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
leaf_seed: Mapped[str] = mapped_column(String(32), nullable=False) # 程序化叶子种子
message: Mapped[str | None] = mapped_column(String(50), nullable=True) # 可选附言
delivered_online: Mapped[bool] = mapped_column(Boolean, default=False) # 是否在线送达
read_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
from_user = relationship("User", foreign_keys=[from_user_id])
to_user = relationship("User", foreign_keys=[to_user_id])
+37
View File
@@ -0,0 +1,37 @@
"""默契种子模型"""
from datetime import datetime
from sqlalchemy import String, Integer, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class SyncQuestion(Base):
__tablename__ = "sync_questions"
id: Mapped[str] = mapped_column(String(36), primary_key=True)
content: Mapped[str] = mapped_column(String(200), nullable=False)
active_date: Mapped[str | None] = mapped_column(String(10), nullable=True) # YYYY-MM-DD
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
class SyncSeed(Base):
"""默契种子:两人对同一题的作答"""
__tablename__ = "sync_seeds"
__table_args__ = (
UniqueConstraint("question_id", "user_a", "user_b", name="uq_sync_seed_pair"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True)
question_id: Mapped[str] = mapped_column(String(36), ForeignKey("sync_questions.id", ondelete="CASCADE"))
user_a: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
user_b: Mapped[str] = mapped_column(String(36), ForeignKey("users.id", ondelete="CASCADE"))
answer_a: Mapped[str | None] = mapped_column(String(500), nullable=True)
answer_b: Mapped[str | None] = mapped_column(String(500), nullable=True)
score: Mapped[int | None] = mapped_column(Integer, nullable=True) # 0-100
leaf_seed: Mapped[str | None] = mapped_column(String(16), nullable=True) # 默契叶种子
status: Mapped[str] = mapped_column(String(20), default="draft") # draft/revealed
created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.utcnow())
revealed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+38
View File
@@ -0,0 +1,38 @@
"""聊天气候路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.services.climate_service import ClimateService
router = APIRouter()
@router.get("/{conversation_id}")
async def get_climate(
conversation_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取/计算会话气候"""
service = ClimateService(db)
try:
return await service.compute(conversation_id, user.id)
except ValueError as e:
raise HTTPException(status_code=403, detail=str(e))
@router.get("/{conversation_id}/calendar")
async def get_calendar(
conversation_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取 30 天气候日历"""
service = ClimateService(db)
try:
return await service.get_calendar(conversation_id, user.id)
except ValueError as e:
raise HTTPException(status_code=403, detail=str(e))
+40
View File
@@ -0,0 +1,40 @@
"""念念回音路由"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.services.echo_service import EchoService
router = APIRouter()
class EchoCreate(BaseModel):
to_user_id: str
message: str | None = Field(None, max_length=50)
@router.post("/")
async def send_echo(
req: EchoCreate,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""发送一片回音叶"""
service = EchoService(db)
try:
return await service.send_echo(user.id, req.to_user_id, req.message)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/")
async def list_echoes(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取回音列表"""
service = EchoService(db)
return await service.get_echoes(user.id)
+68
View File
@@ -0,0 +1,68 @@
"""萤火虫时刻路由"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.services.flash_service import FlashService
router = APIRouter()
@router.get("/active")
async def get_active(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前萤火虫事件。若没有,随机概率尝试触发一个(让功能可被体验到)"""
service = FlashService(db)
event = await service.get_active_event()
if event:
return {"event": event}
# 概率触发:每次请求 ~4% 概率尝试生成(受单日 1 次限制),让萤火虫保持稀有
import hashlib
seed = hashlib.md5(f"{user.id}:{__import__('datetime').datetime.utcnow().strftime('%H%M')}".encode()).hexdigest()
if int(seed[:2], 16) < 10: # ~4%
spawned = await service.try_spawn()
if spawned:
return {"event": spawned}
return {"event": None}
@router.post("/{event_id}/click")
async def click(
event_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""集气点击"""
service = FlashService(db)
try:
return await service.click(user.id, event_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{event_id}/claim")
async def claim(
event_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""领取限定纪念叶"""
service = FlashService(db)
try:
return await service.claim_reward(user.id, event_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/album")
async def get_album(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""我的萤火虫图鉴"""
service = FlashService(db)
return await service.get_my_album(user.id)
+10
View File
@@ -44,3 +44,13 @@ async def get_collection(
"""获取叶子收藏"""
service = LeafService(db)
return await service.get_collection(user.id)
@router.get("/grove")
async def get_grove(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""情绪共鸣林:好友圈今日心情叶聚合"""
service = LeafService(db)
return await service.get_grove(user.id)
+58
View File
@@ -0,0 +1,58 @@
"""默契种子路由"""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_current_user
from app.models.user import User
from app.services.sync_service import SyncService
router = APIRouter()
class AnswerSubmit(BaseModel):
question_id: str
friend_id: str
answer: str = Field(..., min_length=1, max_length=500)
@router.get("/question")
async def get_today_question(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取今日默契题"""
service = SyncService(db)
try:
return await service.get_today_question()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/answer")
async def submit_answer(
req: AnswerSubmit,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""提交答案"""
service = SyncService(db)
try:
return await service.submit_answer(user.id, req.question_id, req.friend_id, req.answer)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{seed_id}")
async def get_seed(
seed_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""查看默契种子结果"""
service = SyncService(db)
try:
return await service.get_seed(user.id, seed_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+11
View File
@@ -43,3 +43,14 @@ async def water_tree(
return await service.water(user.id, friend_id)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{friend_id}/heartbeat")
async def get_heartbeat(
friend_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取心跳同步数据"""
service = TreeService(db)
return await service.get_heartbeat(user.id, friend_id)
+161
View File
@@ -0,0 +1,161 @@
"""聊天气候服务:把对话节奏翻译成季节/温度/天气"""
import json
from datetime import datetime, timedelta
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.chat_climate import ChatClimate
from app.models.message import Message
from app.models.conversation_member import ConversationMember
SEASON_EMOJI = {
"spring": "🌸", "summer": "☀️", "autumn": "🍁", "winter": "❄️",
}
WEATHER_EMOJI = {
"sunny": "", "cloudy": "多云", "rainy": "", "windy": "", "snowy": "",
}
class ClimateService:
def __init__(self, db: AsyncSession):
self.db = db
async def _verify_member(self, conversation_id: str, user_id: str) -> bool:
result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conversation_id,
ConversationMember.user_id == user_id,
)
)
return result.scalars().first() is not None
async def compute(self, conversation_id: str, user_id: str) -> dict:
"""计算/更新会话气候"""
if not await self._verify_member(conversation_id, user_id):
raise ValueError("无权访问该会话")
# 取近 14 天消息
since = datetime.utcnow() - timedelta(days=14)
result = await self.db.execute(
select(Message).where(
Message.conversation_id == conversation_id,
Message.is_deleted == False,
Message.created_at >= since,
).order_by(Message.created_at.asc())
)
msgs = result.scalars().all()
season, temperature, weather = self._analyze(msgs)
emoji = SEASON_EMOJI[season]
# 更新气候记录
cc_result = await self.db.execute(
select(ChatClimate).where(ChatClimate.conversation_id == conversation_id)
)
climate = cc_result.scalars().first()
# 日历历史
history = []
if climate and climate.daily_history:
try:
history = json.loads(climate.daily_history)
except Exception:
history = []
today = datetime.utcnow().strftime("%Y-%m-%d")
history = [h for h in history if h.get("date") != today]
history.append({"date": today, "season": season, "temp": temperature, "emoji": emoji})
history = history[-30:] # 保留 30 天
if climate:
climate.season = season
climate.temperature = temperature
climate.weather = weather
climate.emoji = emoji
climate.daily_history = json.dumps(history, ensure_ascii=False)
else:
climate = ChatClimate(
conversation_id=conversation_id,
season=season, temperature=temperature, weather=weather,
emoji=emoji, daily_history=json.dumps(history, ensure_ascii=False),
)
self.db.add(climate)
await self.db.flush()
return {
"conversation_id": conversation_id,
"season": season,
"temperature": temperature,
"weather": weather,
"emoji": emoji,
"weather_label": WEATHER_EMOJI[weather],
"message_count_14d": len(msgs),
}
def _analyze(self, msgs: list[Message]) -> tuple[str, int, str]:
"""根据消息列表分析季节/温度/天气"""
n = len(msgs)
if n == 0:
return "winter", -8, "snowy"
# 14 天内的消息分布 → 温度(活跃度)
# 温度 = 消息密度映射到 -10..40
density = min(n / 14, 1) # 平均每天消息数(封顶 1 表示满)
temperature = round(-8 + density * 46) # -8 .. 38
# 季节:按温度分段
if temperature >= 28:
season = "summer"
elif temperature >= 15:
season = "spring"
elif temperature >= 5:
season = "autumn"
else:
season = "winter"
# 天气:根据回复间隔/连续性 + 平均字数
if n >= 2:
gaps = []
for i in range(1, n):
gap = (msgs[i].created_at - msgs[i - 1].created_at).total_seconds()
gaps.append(gap)
avg_gap = sum(gaps) / len(gaps)
avg_len = sum(len(m.content or "") for m in msgs) / n
# 连续性(gap 小 = 连续)
if avg_gap < 120: # 2 分钟内,热烈
weather = "sunny"
elif avg_gap < 1800: # 半小时内,正常
weather = "cloudy"
elif avg_gap < 21600: # 6 小时内,稀疏
weather = "rainy"
elif avg_gap < 86400: # 一天内
weather = "windy"
else:
weather = "snowy"
# 字数长 = 有深度对话,偏向"雨"(绵绵)
if avg_len > 50 and weather in ("sunny", "cloudy"):
weather = "rainy"
else:
weather = "cloudy"
return season, temperature, weather
async def get_calendar(self, conversation_id: str, user_id: str) -> list[dict]:
"""获取 30 天气候日历"""
if not await self._verify_member(conversation_id, user_id):
raise ValueError("无权访问该会话")
result = await self.db.execute(
select(ChatClimate).where(ChatClimate.conversation_id == conversation_id)
)
climate = result.scalars().first()
if not climate or not climate.daily_history:
return []
try:
return json.loads(climate.daily_history)
except Exception:
return []
+97
View File
@@ -0,0 +1,97 @@
"""念念回音服务"""
import hashlib
import uuid
from datetime import datetime
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.miss_echo import MissEcho
from app.models.user import User
from app.websocket.events import EventType
from app.websocket.manager import manager
class EchoService:
def __init__(self, db: AsyncSession):
self.db = db
async def send_echo(self, from_user_id: str, to_user_id: str,
message: str | None = None) -> dict:
"""发送一片回音叶"""
if from_user_id == to_user_id:
raise ValueError("不能给自己回音")
# 校验目标用户存在
target_result = await self.db.execute(select(User).where(User.id == to_user_id))
if not target_result.scalars().first():
raise ValueError("目标用户不存在")
# 派生叶子种子
today = datetime.utcnow().strftime("%Y%m%d")
seed = hashlib.md5(f"echo:{from_user_id}:{to_user_id}:{today}".encode()).hexdigest()[:16]
is_online = manager.is_online(to_user_id)
echo = MissEcho(
id=str(uuid.uuid4()),
from_user_id=from_user_id,
to_user_id=to_user_id,
leaf_seed=seed,
message=message,
delivered_online=is_online,
)
self.db.add(echo)
await self.db.flush()
# 发送者信息
from_result = await self.db.execute(select(User).where(User.id == from_user_id))
from_user = from_result.scalars().first()
payload = {
"id": echo.id,
"from_user_id": from_user_id,
"from_username": from_user.username if from_user else "未知",
"from_nickname": from_user.nickname if from_user else None,
"from_avatar": from_user.avatar_url if from_user else None,
"leaf_seed": seed,
"message": message,
"delivered_online": is_online,
"created_at": echo.created_at.isoformat(),
}
# 在线则实时推送,离线则在用户下次打开时收到(落入花园)
if is_online:
await manager.send_to_user(to_user_id, EventType.ECHO_SEND, payload)
return payload
async def get_echoes(self, user_id: str, limit: int = 30) -> list[dict]:
"""获取我收到的回音(花园里飘落的叶子)"""
result = await self.db.execute(
select(MissEcho).where(
or_(
MissEcho.to_user_id == user_id,
MissEcho.from_user_id == user_id,
)
).order_by(MissEcho.created_at.desc()).limit(limit)
)
echoes = []
for e in result.scalars().all():
from_result = await self.db.execute(select(User).where(User.id == e.from_user_id))
fu = from_result.scalars().first()
echoes.append({
"id": e.id,
"from_user_id": e.from_user_id,
"to_user_id": e.to_user_id,
"from_username": fu.username if fu else "未知",
"from_nickname": fu.nickname if fu else None,
"from_avatar": fu.avatar_url if fu else None,
"leaf_seed": e.leaf_seed,
"message": e.message,
"delivered_online": e.delivered_online,
"is_received": e.to_user_id == user_id,
"created_at": e.created_at.isoformat(),
})
return echoes
+213
View File
@@ -0,0 +1,213 @@
"""萤火虫时刻服务:全服协作型随机掉落"""
import hashlib
import uuid
from datetime import datetime, timedelta
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.flash_event import FlashEvent, FlashParticipation
from app.websocket.events import EventType
from app.websocket.manager import manager
# Redis(用于原子计数 + 单日限频)
import redis.asyncio as aioredis
from app.config import settings
class FlashService:
def __init__(self, db: AsyncSession):
self.db = db
self._redis = None
async def _get_redis(self):
if self._redis is None:
self._redis = aioredis.from_url(settings.REDIS_URL, decode_responses=True)
return self._redis
async def get_active_event(self) -> dict | None:
"""获取当前进行中且尚未达标的萤火虫事件"""
now = datetime.utcnow()
result = await self.db.execute(
select(FlashEvent).where(
FlashEvent.start_at <= now,
FlashEvent.end_at > now,
FlashEvent.reached == False, # 已达标的不再算作可参与
).order_by(FlashEvent.start_at.desc())
)
event = result.scalars().first()
if not event:
return None
return self._event_to_dict(event)
async def try_spawn(self) -> dict | None:
"""尝试触发新事件(单日限频,由前端定时调用或后台触发)"""
r = await self._get_redis()
today = datetime.utcnow().strftime("%Y%m%d")
spawn_key = f"flash:spawned:{today}"
# 单日最多 1 次(用 SETNX 抢占),让萤火虫保持稀有惊喜
count = await r.get(spawn_key)
if count and int(count) >= 1:
return None
now = datetime.utcnow()
# 事件持续 60-90 秒
duration = 60 + (hashlib.md5(today.encode()).hexdigest()[:2] is not None) * 0
duration = 75 # 固定 75 秒便于体验
end_at = now + timedelta(seconds=duration)
variant = f"firefly-{today}-{count or '0'}"
seed = hashlib.md5(variant.encode()).hexdigest()[:16]
target = 30 # 目标点击数(小规模便于达标)
event = FlashEvent(
id=str(uuid.uuid4()),
type="firefly",
start_at=now,
end_at=end_at,
target_clicks=target,
leaf_seed=seed,
leaf_variant=variant,
)
self.db.add(event)
await self.db.flush()
await r.incr(spawn_key)
await r.expire(spawn_key, 86400)
payload = self._event_to_dict(event)
# 全服广播
await manager.broadcast(EventType.FLASH_SPAWN, payload)
return payload
async def click(self, user_id: str, event_id: str) -> dict:
"""用户点击集气"""
r = await self._get_redis()
result = await self.db.execute(
select(FlashEvent).where(FlashEvent.id == event_id)
)
event = result.scalars().first()
if not event:
raise ValueError("事件不存在")
now = datetime.utcnow()
if now < event.start_at or now > event.end_at:
raise ValueError("事件已结束")
# 原子计数:全服总点击 + 个人点击
total_key = f"flash:total:{event_id}"
user_key = f"flash:user:{event_id}:{user_id}"
new_total = await r.incr(total_key)
await r.expire(total_key, 300)
user_clicks = await r.incr(user_key)
await r.expire(user_key, 300)
# 同步到 DB(用于持久化和查询)
event.total_clicks = new_total
await self.db.flush()
reached = new_total >= event.target_clicks
if reached and not event.reached:
event.reached = True
# 广播达标
await manager.broadcast(EventType.FLASH_RESULT, {
"event_id": event_id, "reached": True,
"leaf_seed": event.leaf_seed, "leaf_variant": event.leaf_variant,
"message": "萤火虫被点亮了!限定纪念叶降临 🌟",
})
# 广播进度(节流:每 5 次或达标时)
if new_total % 5 == 0 or reached:
await manager.broadcast(EventType.FLASH_PROGRESS, {
"event_id": event_id,
"total_clicks": new_total,
"target": event.target_clicks,
"progress": min(1, new_total / event.target_clicks),
})
return {
"event_id": event_id,
"total_clicks": new_total,
"target": event.target_clicks,
"progress": min(1, new_total / event.target_clicks),
"my_clicks": user_clicks,
"reached": reached,
}
async def claim_reward(self, user_id: str, event_id: str) -> dict:
"""达标后领取限定纪念叶"""
result = await self.db.execute(
select(FlashEvent).where(FlashEvent.id == event_id)
)
event = result.scalars().first()
if not event:
raise ValueError("事件不存在")
if not event.reached:
raise ValueError("尚未达标")
# 记录参与
p_result = await self.db.execute(
select(FlashParticipation).where(
FlashParticipation.event_id == event_id,
FlashParticipation.user_id == user_id,
)
)
p = p_result.scalars().first()
r = await self._get_redis()
user_clicks = int(await r.get(f"flash:user:{event_id}:{user_id}") or 0)
if p:
p.clicks = user_clicks
p.earned = True
else:
p = FlashParticipation(
id=str(uuid.uuid4()),
event_id=event_id, user_id=user_id,
clicks=user_clicks, earned=True,
)
self.db.add(p)
await self.db.flush()
return {
"earned": True,
"leaf_seed": event.leaf_seed,
"leaf_variant": event.leaf_variant,
"type": event.type,
"my_clicks": user_clicks,
}
async def get_my_album(self, user_id: str) -> list[dict]:
"""获取我获得的限定纪念叶图鉴"""
result = await self.db.execute(
select(FlashParticipation).where(
FlashParticipation.user_id == user_id,
FlashParticipation.earned == True,
).order_by(FlashParticipation.created_at.desc())
)
album = []
for p in result.scalars().all():
ev = await self.db.execute(select(FlashEvent).where(FlashEvent.id == p.event_id))
e = ev.scalars().first()
if e:
album.append({
"leaf_seed": e.leaf_seed,
"leaf_variant": e.leaf_variant,
"type": e.type,
"my_clicks": p.clicks,
"earned_at": p.created_at.isoformat(),
})
return album
def _event_to_dict(self, e: FlashEvent) -> dict:
return {
"id": e.id,
"type": e.type,
"start_at": e.start_at.isoformat(),
"end_at": e.end_at.isoformat(),
"target_clicks": e.target_clicks,
"total_clicks": e.total_clicks,
"reached": e.reached,
"leaf_seed": e.leaf_seed,
"leaf_variant": e.leaf_variant,
"progress": min(1, (e.total_clicks or 0) / e.target_clicks) if e.target_clicks else 0,
}
+52
View File
@@ -64,6 +64,58 @@ class LeafService:
)
return [self._leaf_to_dict(l) for l in result.scalars().all()]
async def get_grove(self, user_id: str) -> dict:
"""获取情绪共鸣林:自己 + 好友今日的心情叶,聚合成俯瞰森林"""
from app.models.friend import Friend
today = date.today()
# 自己 + 好友 ID
friends_result = await self.db.execute(
select(Friend.friend_user_id).where(Friend.user_id == user_id)
)
visible_ids = [user_id] + [r[0] for r in friends_result.all()]
# 查今日所有人的叶子
result = await self.db.execute(
select(DailyMoodLeaf).where(
DailyMoodLeaf.user_id.in_(visible_ids),
DailyMoodLeaf.leaf_date == today,
)
)
leaves = result.scalars().all()
# 排布:自己在中心(position 0),好友按确定性环绕
import math
positioned = []
others = [l for l in leaves if l.user_id != user_id]
my_leaf = next((l for l in leaves if l.user_id == user_id), None)
if my_leaf:
positioned.append({
"is_self": True, "user_id": my_leaf.user_id,
"mood": my_leaf.mood, "leaf_seed": my_leaf.leaf_seed,
"angle": 0, "radius": 0,
})
n = len(others)
for i, l in enumerate(others):
angle = (2 * math.pi * i / n) if n > 0 else 0
positioned.append({
"is_self": False, "user_id": l.user_id,
"mood": l.mood, "leaf_seed": l.leaf_seed,
"angle": angle, "radius": 1,
})
# 聚合情绪天气
mood_counts: dict[str, int] = {}
for l in leaves:
mood_counts[l.mood or "unknown"] = mood_counts.get(l.mood or "unknown", 0) + 1
return {
"leaves": positioned,
"total": len(leaves),
"mood_counts": mood_counts,
"date": today.isoformat(),
}
def _leaf_to_dict(self, leaf: DailyMoodLeaf) -> dict:
return {
"id": leaf.id,
+191
View File
@@ -0,0 +1,191 @@
"""默契种子服务"""
import hashlib
import re
import uuid
from datetime import datetime
from sqlalchemy import select, or_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.sync_seed import SyncQuestion, SyncSeed
from app.models.friend import Friend
from app.models.user import User
from app.websocket.events import EventType
from app.websocket.manager import manager
# 预置题目池
DEFAULT_QUESTIONS = [
"用一个词形容你理想中的周末",
"深夜最想吃的一道食物",
"如果有一整天完全自由,你会做什么",
"最近让你开心的瞬间是什么",
"你最向往的旅行目的地",
"用一种颜色形容你现在的状态",
"你的快乐源泉是什么",
"如果会一种超能力,你想要什么",
]
def _tokenize(text: str) -> set[str]:
"""中文分词:按 2-gram + 标点切分;英文按单词"""
if not text:
return set()
tokens = set()
# 英文单词
for w in re.findall(r'[a-zA-Z]+', text.lower()):
if len(w) >= 2:
tokens.add(w)
# 中文 2-gram
cn = re.sub(r'[^一-鿿]', '', text)
for i in range(len(cn) - 1):
tokens.add(cn[i:i + 2])
# 单字
for ch in cn:
tokens.add(ch)
return tokens
def _jaccard(a: set[str], b: set[str]) -> float:
if not a or not b:
return 0.0
inter = len(a & b)
union = len(a | b)
return inter / union if union else 0.0
def _score_answers(a: str, b: str) -> int:
"""0-100 默契分:Jaccard 为主 + 句长相似 + emoji/标点加分"""
if not a or not b:
return 0
ta, tb = _tokenize(a), _tokenize(b)
j = _jaccard(ta, tb)
# 句长相似度
len_diff = abs(len(a) - len(b)) / max(len(a), len(b), 1)
len_sim = 1 - len_diff
# emoji / 相同标点
emoji_a = set(re.findall(r'[\U0001F300-\U0001FAFF]', a))
emoji_b = set(re.findall(r'[\U0001F300-\U0001FAFF]', b))
emoji_bonus = 0.1 if emoji_a and emoji_a & emoji_b else 0
raw = j * 0.7 + len_sim * 0.2 + emoji_bonus
# 放大曲线:让中等重合也能有可见分数
score = round(min(100, raw * 130 + (10 if j > 0 else 0)))
return max(0, min(100, score))
class SyncService:
def __init__(self, db: AsyncSession):
self.db = db
async def _ensure_questions(self):
"""确保题目池存在"""
result = await self.db.execute(select(func.count(SyncQuestion.id)))
if (result.scalar() or 0) == 0:
for q in DEFAULT_QUESTIONS:
self.db.add(SyncQuestion(id=str(uuid.uuid4()), content=q))
await self.db.flush()
async def get_today_question(self) -> dict:
"""获取今日题目(按日期确定性选取)"""
await self._ensure_questions()
result = await self.db.execute(select(SyncQuestion))
questions = result.scalars().all()
if not questions:
raise ValueError("没有题目")
today = datetime.utcnow().strftime("%Y%m%d")
idx = int(hashlib.md5(today.encode()).hexdigest(), 16) % len(questions)
q = questions[idx]
return {"id": q.id, "content": q.content}
async def _get_or_create_seed(self, question_id: str, user_a: str, user_b: str) -> SyncSeed:
a, b = (user_a, user_b) if user_a < user_b else (user_b, user_a)
result = await self.db.execute(
select(SyncSeed).where(
SyncSeed.question_id == question_id,
SyncSeed.user_a == a,
SyncSeed.user_b == b,
)
)
seed = result.scalars().first()
if not seed:
seed = SyncSeed(
id=str(uuid.uuid4()),
question_id=question_id,
user_a=a,
user_b=b,
)
self.db.add(seed)
await self.db.flush()
return seed
async def submit_answer(self, user_id: str, question_id: str, friend_id: str,
answer: str) -> dict:
"""提交自己的答案"""
# 校验是好友
fr = await self.db.execute(
select(Friend).where(Friend.user_id == user_id, Friend.friend_user_id == friend_id)
)
if not fr.scalars().first():
raise ValueError("只能和好友默契")
seed = await self._get_or_create_seed(question_id, user_id, friend_id)
# 写入自己的答案(a 或 b
if seed.user_a == user_id:
seed.answer_a = answer
else:
seed.answer_b = answer
result: dict = {
"id": seed.id,
"question_id": question_id,
"my_answer": answer,
"status": seed.status,
}
# 双方都答了 → 揭晓
if seed.answer_a and seed.answer_b and seed.status == "draft":
score = _score_answers(seed.answer_a, seed.answer_b)
seed.score = score
leaf = hashlib.md5(
f"sync:{question_id}:{seed.user_a}:{seed.user_b}".encode()
).hexdigest()[:16]
seed.leaf_seed = leaf
seed.status = "revealed"
seed.revealed_at = datetime.utcnow()
result["score"] = score
result["leaf_seed"] = leaf
result["partner_answer"] = seed.answer_b if seed.user_a == user_id else seed.answer_a
# 推送给搭档
await manager.send_to_user(friend_id, "sync.revealed", {
"seed_id": seed.id,
"question_id": question_id,
"score": score,
"leaf_seed": leaf,
})
await self.db.flush()
return result
async def get_seed(self, user_id: str, seed_id: str) -> dict:
result = await self.db.execute(select(SyncSeed).where(SyncSeed.id == seed_id))
seed = result.scalars().first()
if not seed:
raise ValueError("种子不存在")
if user_id not in (seed.user_a, seed.user_b):
raise ValueError("无权查看")
my_answer = seed.answer_a if seed.user_a == user_id else seed.answer_b
partner_answer = None
if seed.status == "revealed":
partner_answer = seed.answer_b if seed.user_a == user_id else seed.answer_a
return {
"id": seed.id,
"question_id": seed.question_id,
"my_answer": my_answer,
"partner_answer": partner_answer,
"score": seed.score,
"leaf_seed": seed.leaf_seed,
"status": seed.status,
}
+67
View File
@@ -12,6 +12,7 @@ from app.models.conversation_member import ConversationMember
from app.models.message import Message
from app.models.friend import Friend
from app.models.user import User
from app.websocket.manager import manager
# 阶段定义:分数 -> (阶段索引, 名称, emoji)
@@ -89,6 +90,72 @@ class TreeService:
)
return count_result.scalar() or 0
async def _count_messages_in_days(self, user_id: str, friend_id: str, days: int = 7) -> int:
"""统计近 N 天两人私聊消息数(心跳 BPM 用)"""
result = await self.db.execute(
select(Conversation).join(ConversationMember)
.where(
Conversation.type == "private",
ConversationMember.user_id == user_id,
)
)
conv_id = None
for conv in result.scalars().all():
member_result = await self.db.execute(
select(ConversationMember).where(
ConversationMember.conversation_id == conv.id,
ConversationMember.user_id == friend_id,
)
)
if member_result.scalars().first():
conv_id = conv.id
break
if not conv_id:
return 0
from datetime import datetime, timedelta
since = datetime.utcnow() - timedelta(days=days)
count_result = await self.db.execute(
select(func.count(Message.id)).where(
Message.conversation_id == conv_id,
Message.is_deleted == False,
Message.created_at >= since,
)
)
return count_result.scalar() or 0
async def get_heartbeat(self, user_id: str, friend_id: str) -> dict:
"""获取心跳同步数据:BPM 由近7天消息数决定"""
from app.models.user import User
msg_7d = await self._count_messages_in_days(user_id, friend_id, 7)
# BPM 映射
if msg_7d == 0:
bpm = 42 # 沉睡
elif msg_7d < 30:
bpm = 54 # 平静
elif msg_7d < 100:
bpm = 66 # 正常
elif msg_7d < 300:
bpm = 78 # 活跃
else:
bpm = 90 # 热烈
# 对方信息
friend_result = await self.db.execute(select(User).where(User.id == friend_id))
friend = friend_result.scalars().first()
is_online = manager.is_online(friend_id) if friend else False
return {
"friend_id": friend_id,
"friend_name": friend.nickname or friend.username if friend else "未知",
"friend_avatar": friend.avatar_url if friend else None,
"bpm": bpm,
"msg_7d": msg_7d,
"is_online": is_online,
# leaf_seed 用于渲染对方的迷你叶(确定性)
"friend_leaf_seed": (friend_id or "0")[:16].ljust(16, '0'),
}
async def get_tree(self, user_id: str, friend_id: str) -> dict:
"""获取好友之树"""
tree = await self._get_or_create_tree_row(user_id, friend_id)
+5
View File
@@ -24,6 +24,11 @@ class EventType(str, Enum):
FRIEND_ACCEPTED = "friend.accepted"
PRESENCE_ONLINE = "presence.online"
PRESENCE_OFFLINE = "presence.offline"
ECHO_SEND = "echo.send"
HEARTBEAT_SYNC = "heartbeat.sync"
FLASH_SPAWN = "flash.spawn"
FLASH_PROGRESS = "flash.progress"
FLASH_RESULT = "flash.result"
ERROR = "error"