diff --git a/backend/app/main.py b/backend/app/main.py
index eef4fa7..4e43585 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 8d4a1ca..a43905a 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -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",
]
diff --git a/backend/app/models/chat_climate.py b/backend/app/models/chat_climate.py
new file mode 100644
index 0000000..b9438af
--- /dev/null
+++ b/backend/app/models/chat_climate.py
@@ -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")
diff --git a/backend/app/models/flash_event.py b/backend/app/models/flash_event.py
new file mode 100644
index 0000000..4065e66
--- /dev/null
+++ b/backend/app/models/flash_event.py
@@ -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])
diff --git a/backend/app/models/miss_echo.py b/backend/app/models/miss_echo.py
new file mode 100644
index 0000000..1144291
--- /dev/null
+++ b/backend/app/models/miss_echo.py
@@ -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])
diff --git a/backend/app/models/sync_seed.py b/backend/app/models/sync_seed.py
new file mode 100644
index 0000000..cf3c6bb
--- /dev/null
+++ b/backend/app/models/sync_seed.py
@@ -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)
diff --git a/backend/app/routers/climates.py b/backend/app/routers/climates.py
new file mode 100644
index 0000000..84a7734
--- /dev/null
+++ b/backend/app/routers/climates.py
@@ -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))
diff --git a/backend/app/routers/echoes.py b/backend/app/routers/echoes.py
new file mode 100644
index 0000000..7b791ca
--- /dev/null
+++ b/backend/app/routers/echoes.py
@@ -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)
diff --git a/backend/app/routers/flash.py b/backend/app/routers/flash.py
new file mode 100644
index 0000000..6fa2764
--- /dev/null
+++ b/backend/app/routers/flash.py
@@ -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)
diff --git a/backend/app/routers/leaves.py b/backend/app/routers/leaves.py
index 711a767..1a2940a 100644
--- a/backend/app/routers/leaves.py
+++ b/backend/app/routers/leaves.py
@@ -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)
diff --git a/backend/app/routers/sync.py b/backend/app/routers/sync.py
new file mode 100644
index 0000000..2970fa3
--- /dev/null
+++ b/backend/app/routers/sync.py
@@ -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))
diff --git a/backend/app/routers/trees.py b/backend/app/routers/trees.py
index 82e9a2e..83421c5 100644
--- a/backend/app/routers/trees.py
+++ b/backend/app/routers/trees.py
@@ -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)
diff --git a/backend/app/services/climate_service.py b/backend/app/services/climate_service.py
new file mode 100644
index 0000000..afc11dc
--- /dev/null
+++ b/backend/app/services/climate_service.py
@@ -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 []
diff --git a/backend/app/services/echo_service.py b/backend/app/services/echo_service.py
new file mode 100644
index 0000000..c22598b
--- /dev/null
+++ b/backend/app/services/echo_service.py
@@ -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
diff --git a/backend/app/services/flash_service.py b/backend/app/services/flash_service.py
new file mode 100644
index 0000000..9cf1266
--- /dev/null
+++ b/backend/app/services/flash_service.py
@@ -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,
+ }
diff --git a/backend/app/services/leaf_service.py b/backend/app/services/leaf_service.py
index c2fe1e4..aeb7783 100644
--- a/backend/app/services/leaf_service.py
+++ b/backend/app/services/leaf_service.py
@@ -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,
diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py
new file mode 100644
index 0000000..97ada8d
--- /dev/null
+++ b/backend/app/services/sync_service.py
@@ -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,
+ }
diff --git a/backend/app/services/tree_service.py b/backend/app/services/tree_service.py
index 61e78ad..a56b07c 100644
--- a/backend/app/services/tree_service.py
+++ b/backend/app/services/tree_service.py
@@ -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)
diff --git a/backend/app/websocket/events.py b/backend/app/websocket/events.py
index 8bd1c1d..c98368d 100644
--- a/backend/app/websocket/events.py
+++ b/backend/app/websocket/events.py
@@ -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"
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index d49a389..161e145 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -4,6 +4,8 @@
{{ reward.leaf_variant }} 永不复刻 · 已收入图鉴限定纪念叶到手!
+
全服随机降临,一起集气点亮,掉落永不复刻的限定叶
+点击屏幕中央的萤火虫面板参与集气
+此刻森林很安静
+萤火虫会在不经意时降临,留意闪烁的光点
+在好友资料卡寄一片"我在想你"的回音叶
+和好友盲答一题,默契会长出独特的叶子
+俯瞰朋友圈今日的情绪天气,你并不孤单
+你们的叶子在同一节拍上呼吸
+全服随机降临,集气点亮掉限定叶
+{{ weatherText }}
+今天还是一片空林
+领取你的今日心情叶,森林就会亮起来
+你们的呼吸,在同一节拍上
+加个好友,开始同步心跳
+和好友盲答同一题,看看默契长出什么样的叶子
+种子已种下,等 TA 来回答
+你们都答完,默契叶就会发芽
+