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
+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)