user_service.py 70 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622
  1. """
  2. 用户服务模块(SQLAlchemy ORM 版本)
  3. """
  4. import uuid
  5. import random
  6. import asyncio
  7. import logging
  8. from typing import Dict, Optional, Any, Tuple
  9. from datetime import datetime, date
  10. from sqlalchemy import select, update, func, insert, desc
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from database import db_manager
  13. from config import settings
  14. from models_db import (
  15. User,
  16. UserUIState,
  17. UserBackgroundImage,
  18. BackgroundImage,
  19. UserAvatar,
  20. VerificationCode,
  21. UserCommunityChoice,
  22. UserEarlyCommunity,
  23. PageVisitLog,
  24. UserIntimacy,
  25. UserEvent,
  26. )
  27. from error_codes import (
  28. PHONE_ALREADY_REGISTERED,
  29. USER_ID_NOT_FOUND,
  30. ALREADY_REGISTERED,
  31. EMAIL_ALREADY_REGISTERED,
  32. QQ_ALREADY_BOUND,
  33. EMAIL_REQUIRED,
  34. PHONE_REQUIRED,
  35. CODE_REQUIRED,
  36. CODE_INVALID,
  37. )
  38. logger = logging.getLogger(__name__)
  39. class AuthError(Exception):
  40. """业务校验错误,带错误码供 API 层返回 detail={"code": "XXX"}"""
  41. def __init__(self, code: str):
  42. self.code = code
  43. super().__init__(code)
  44. # 默认头像 ID
  45. DEFAULT_AVATAR_ID = "avatar_default"
  46. REGISTERED_AVATAR_ID = "avatar_registered_cap"
  47. LEVEL_THRESHOLDS_MS: Tuple[Tuple[int, int], ...] = (
  48. (5, 240_000),
  49. (4, 120_000),
  50. (3, 60_000),
  51. (2, 30_000),
  52. (1, 10_000),
  53. )
  54. class UserService:
  55. """用户管理服务"""
  56. @staticmethod
  57. def _calculate_user_level(total_ms: int) -> int:
  58. """
  59. 根据累计说话时长计算用户等级
  60. """
  61. for level, threshold in LEVEL_THRESHOLDS_MS:
  62. if total_ms >= threshold:
  63. return level
  64. return 0
  65. async def _init_user_background_image(self, session: AsyncSession, user_id: str) -> None:
  66. """
  67. 初始化用户背景图片(添加到已获得列表)
  68. Args:
  69. session: 数据库会话
  70. user_id: 用户ID
  71. """
  72. try:
  73. default_image_id = "default"
  74. # 检查是否已存在,避免重复添加
  75. result = await session.execute(
  76. select(UserBackgroundImage).where(
  77. UserBackgroundImage.user_id == user_id,
  78. UserBackgroundImage.image_id == default_image_id
  79. )
  80. )
  81. existing = result.scalar_one_or_none()
  82. if not existing:
  83. user_bg_image = UserBackgroundImage(
  84. user_id=user_id,
  85. image_id=default_image_id,
  86. obtained_type="default",
  87. obtained_source="system_init"
  88. )
  89. session.add(user_bg_image)
  90. logger.debug(f"✅ 为用户初始化背景图片: user_id={user_id}, image_id={default_image_id}")
  91. else:
  92. logger.debug(f"⏭️ 用户已拥有默认背景图片: user_id={user_id}")
  93. except Exception as e:
  94. # 背景图片初始化失败不应该影响用户创建,只记录日志
  95. logger.warning(f"⚠️ 初始化用户背景图片失败: user_id={user_id}, error={e}")
  96. async def _init_user_avatar(self, session: AsyncSession, user_id: str, user_type: str = "guest") -> None:
  97. """
  98. 初始化用户头像(添加到已获得列表)
  99. Args:
  100. session: 数据库会话
  101. user_id: 用户ID
  102. user_type: 用户类型(guest 或 registered)
  103. """
  104. try:
  105. # 游客用户只分配默认头像
  106. # 注册用户同时分配默认头像和注册赠送头像
  107. avatars_to_add = []
  108. if user_type == "registered":
  109. # 注册用户:默认头像 + 注册赠送头像
  110. avatars_to_add = [
  111. (DEFAULT_AVATAR_ID, "default", "system_init"),
  112. (REGISTERED_AVATAR_ID, "register", "register_bonus"),
  113. ]
  114. else:
  115. # 游客用户:只分配默认头像
  116. avatars_to_add = [
  117. (DEFAULT_AVATAR_ID, "default", "system_init"),
  118. ]
  119. for avatar_id, obtained_type, obtained_source in avatars_to_add:
  120. # 检查是否已存在,避免重复添加
  121. result = await session.execute(
  122. select(UserAvatar).where(
  123. UserAvatar.user_id == user_id,
  124. UserAvatar.avatar_id == avatar_id
  125. )
  126. )
  127. existing = result.scalar_one_or_none()
  128. if not existing:
  129. user_avatar = UserAvatar(
  130. user_id=user_id,
  131. avatar_id=avatar_id,
  132. obtained_type=obtained_type,
  133. obtained_source=obtained_source
  134. )
  135. session.add(user_avatar)
  136. logger.debug(f"✅ 为用户初始化头像: user_id={user_id}, avatar_id={avatar_id}, user_type={user_type}")
  137. else:
  138. logger.debug(f"⏭️ 用户已拥有头像: user_id={user_id}, avatar_id={avatar_id}")
  139. except Exception as e:
  140. # 头像初始化失败不应该影响用户创建,只记录日志
  141. logger.warning(f"⚠️ 初始化用户头像失败: user_id={user_id}, error={e}")
  142. async def _generate_temp_username(self, session: AsyncSession) -> str:
  143. """
  144. 生成临时用户名(格式:User + 7位随机数字)
  145. 确保用户名不重复
  146. Args:
  147. session: 数据库会话
  148. Returns:
  149. 临时用户名
  150. """
  151. max_attempts = 100 # 最大重试次数,避免无限循环
  152. for attempt in range(max_attempts):
  153. # 生成7位随机数字(1000000-9999999)
  154. random_number = random.randint(1000000, 9999999)
  155. temp_username = f"User{random_number}"
  156. # 检查用户名是否已存在
  157. result = await session.execute(
  158. select(User).where(User.username == temp_username)
  159. )
  160. existing_user = result.scalar_one_or_none()
  161. if not existing_user:
  162. logger.debug(f"生成临时用户名: {temp_username} (尝试次数: {attempt + 1})")
  163. return temp_username
  164. # 如果100次都重复,使用时间戳的后7位作为备选方案
  165. # 使用当前时间戳的毫秒数取模,确保是7位数字
  166. for fallback_attempt in range(10): # 备选方案也最多尝试10次
  167. timestamp_suffix = int(datetime.now().timestamp() * 1000) % 10000000
  168. # 确保至少是7位数字(如果不足7位,前面补0)
  169. fallback_number = str(timestamp_suffix).zfill(7)
  170. temp_username = f"User{fallback_number}"
  171. # 再次检查是否重复
  172. result = await session.execute(
  173. select(User).where(User.username == temp_username)
  174. )
  175. existing_user = result.scalar_one_or_none()
  176. if not existing_user:
  177. logger.warning(f"⚠️ 临时用户名生成达到最大重试次数,使用备选方案: {temp_username}")
  178. return temp_username
  179. # 如果还是重复,等待1毫秒后重试(确保时间戳变化)
  180. await asyncio.sleep(0.001)
  181. # 最后的备选方案:使用UUID的十六进制转数字(取后7位)
  182. uuid_hex = uuid.uuid4().hex
  183. final_number = str(int(uuid_hex[:8], 16) % 10000000).zfill(7)
  184. temp_username = f"User{final_number}"
  185. logger.error(f"❌ 临时用户名生成异常,使用最终备选方案: {temp_username}")
  186. return temp_username
  187. async def create_guest_user(
  188. self,
  189. username: str = "",
  190. bot_name: Optional[str] = None,
  191. *,
  192. user_segment: str = "individual",
  193. org_id: Optional[str] = None,
  194. is_test_user: bool = False,
  195. region: Optional[str] = None,
  196. ) -> Dict:
  197. """
  198. 创建游客用户
  199. Args:
  200. username: 用户名(默认为空字符串,为空时自动生成临时用户名)
  201. bot_name: 用户给机器人取的名字(可选)
  202. Returns:
  203. 用户信息字典(包含user_id)
  204. """
  205. try:
  206. # 生成唯一用户ID
  207. user_id = str(uuid.uuid4())
  208. # 使用 ORM 创建用户
  209. async with db_manager.get_session() as session:
  210. # 如果用户名为空,生成临时用户名
  211. if not username or username.strip() == "":
  212. username = await self._generate_temp_username(session)
  213. logger.info(f"🔧 自动生成临时用户名: {username}")
  214. user = User(
  215. user_id=user_id,
  216. username=username,
  217. user_type="guest",
  218. user_segment=user_segment or "individual",
  219. org_id=org_id,
  220. is_test_user=is_test_user,
  221. region=region,
  222. bot_name=bot_name,
  223. current_background_image_id="default", # 设置默认背景图片
  224. )
  225. session.add(user)
  226. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  227. # 初始化背景图片
  228. await self._init_user_background_image(session, user_id)
  229. # 初始化默认头像
  230. await self._init_user_avatar(session, user_id, user_type="guest")
  231. await session.commit()
  232. await session.refresh(user)
  233. # 初始化 user_ui_states 的 current_avatar_id(游客用户使用默认头像)
  234. await self.upsert_user_ui_state(
  235. user_id=user_id,
  236. current_avatar_id=DEFAULT_AVATAR_ID
  237. )
  238. logger.info(f"✅ 创建游客用户成功: user_id={user_id}, username={username}, bot_name={bot_name}")
  239. return {
  240. "user_id": user_id,
  241. "username": username,
  242. "user_type": "guest",
  243. "bot_name": bot_name,
  244. "user_level": 0,
  245. "current_background_image_id": "default",
  246. "created_at": datetime.now().isoformat()
  247. }
  248. except Exception as e:
  249. logger.error(f"❌ 创建用户失败: {e}")
  250. raise
  251. async def get_user(self, user_id: str) -> Optional[Dict]:
  252. """
  253. 获取用户信息
  254. Args:
  255. user_id: 用户ID
  256. Returns:
  257. 用户信息字典,如果不存在返回None
  258. 包含 current_background_image_metadata 字段(背景图片的metadata配置)
  259. """
  260. try:
  261. async with db_manager.get_session() as session:
  262. result = await session.execute(
  263. select(User).where(User.user_id == user_id)
  264. )
  265. user = result.scalar_one_or_none()
  266. if user:
  267. user_dict = user.to_dict()
  268. # 如果用户有背景图片ID,查询背景图片的metadata
  269. if user.current_background_image_id:
  270. try:
  271. bg_result = await session.execute(
  272. select(BackgroundImage).where(
  273. BackgroundImage.image_id == user.current_background_image_id
  274. )
  275. )
  276. bg_image = bg_result.scalar_one_or_none()
  277. if bg_image and bg_image.metadata_json:
  278. user_dict["current_background_image_metadata"] = bg_image.metadata_json
  279. except Exception as e:
  280. logger.warning(f"⚠️ 获取背景图片metadata失败: image_id={user.current_background_image_id}, error={e}")
  281. return user_dict
  282. return None
  283. except Exception as e:
  284. logger.error(f"❌ 获取用户信息失败: user_id={user_id}, error={e}")
  285. return None
  286. async def update_user_last_active(self, user_id: str):
  287. """
  288. 更新用户最后活跃时间
  289. Args:
  290. user_id: 用户ID
  291. """
  292. try:
  293. async with db_manager.get_session() as session:
  294. await session.execute(
  295. update(User)
  296. .where(User.user_id == user_id)
  297. .values(last_active_at=func.now())
  298. )
  299. await session.commit()
  300. except Exception as e:
  301. logger.warning(f"⚠️ 更新用户活跃时间失败: user_id={user_id}, error={e}")
  302. async def update_username(self, user_id: str, username: str) -> bool:
  303. """
  304. 更新用户名
  305. Args:
  306. user_id: 用户ID
  307. username: 用户名
  308. Returns:
  309. 是否更新成功
  310. """
  311. try:
  312. async with db_manager.get_session() as session:
  313. result = await session.execute(
  314. update(User)
  315. .where(User.user_id == user_id)
  316. .values(username=username)
  317. )
  318. await session.commit()
  319. if result.rowcount > 0:
  320. logger.info(f"✅ 更新用户名成功: user_id={user_id}, username={username}")
  321. return True
  322. return False
  323. except Exception as e:
  324. logger.error(f"❌ 更新用户名失败: user_id={user_id}, error={e}")
  325. return False
  326. async def update_current_background_image_id(self, user_id: str, background_image_id: str) -> bool:
  327. """
  328. 更新用户当前使用的背景图片ID
  329. Args:
  330. user_id: 用户ID
  331. background_image_id: 背景图片ID
  332. Returns:
  333. 是否更新成功
  334. """
  335. try:
  336. async with db_manager.get_session() as session:
  337. result = await session.execute(
  338. update(User)
  339. .where(User.user_id == user_id)
  340. .values(current_background_image_id=background_image_id)
  341. )
  342. await session.commit()
  343. if result.rowcount > 0:
  344. logger.info(f"✅ 更新用户背景图片ID成功: user_id={user_id}, background_image_id={background_image_id}")
  345. return True
  346. return False
  347. except Exception as e:
  348. logger.error(f"❌ 更新用户背景图片ID失败: user_id={user_id}, error={e}")
  349. return False
  350. async def update_bot_name(self, user_id: str, bot_name: str) -> bool:
  351. """
  352. 更新用户给机器人取的名字
  353. Args:
  354. user_id: 用户ID
  355. bot_name: 机器人名字
  356. Returns:
  357. 是否更新成功
  358. """
  359. import time
  360. start_time = time.time()
  361. try:
  362. async with db_manager.get_session() as session:
  363. # 🔥 先查询当前值,避免不必要的更新
  364. query_start = time.time()
  365. result = await session.execute(
  366. select(User.bot_name).where(User.user_id == user_id)
  367. )
  368. current_bot_name = result.scalar_one_or_none()
  369. query_duration = (time.time() - query_start) * 1000
  370. # 如果值相同,直接返回(避免不必要的 UPDATE)
  371. if current_bot_name == bot_name:
  372. total_duration = (time.time() - start_time) * 1000
  373. logger.info(
  374. f"⏭️ 机器人名字未变化,跳过更新: user_id={user_id}, "
  375. f"bot_name={bot_name}, 耗时={total_duration:.1f}ms"
  376. )
  377. return True
  378. # 执行更新
  379. update_start = time.time()
  380. update_result = await session.execute(
  381. update(User)
  382. .where(User.user_id == user_id)
  383. .values(bot_name=bot_name)
  384. )
  385. await session.commit()
  386. update_duration = (time.time() - update_start) * 1000
  387. total_duration = (time.time() - start_time) * 1000
  388. if update_result.rowcount > 0:
  389. logger.info(
  390. f"✅ 更新机器人名字成功: user_id={user_id}, "
  391. f"bot_name={bot_name}, "
  392. f"查询耗时={query_duration:.1f}ms, "
  393. f"更新耗时={update_duration:.1f}ms, "
  394. f"总耗时={total_duration:.1f}ms"
  395. )
  396. return True
  397. else:
  398. logger.warning(
  399. f"⚠️ 更新机器人名字失败: 未找到用户 user_id={user_id}, "
  400. f"总耗时={total_duration:.1f}ms"
  401. )
  402. return False
  403. except asyncio.TimeoutError:
  404. total_duration = (time.time() - start_time) * 1000
  405. logger.error(
  406. f"❌ 更新机器人名字超时: user_id={user_id}, "
  407. f"耗时={total_duration:.1f}ms"
  408. )
  409. return False
  410. except Exception as e:
  411. total_duration = (time.time() - start_time) * 1000
  412. logger.error(
  413. f"❌ 更新机器人名字失败: user_id={user_id}, "
  414. f"error={e}, 耗时={total_duration:.1f}ms"
  415. )
  416. return False
  417. async def update_preferred_language(self, user_id: str, preferred_language: str) -> bool:
  418. """更新用户首选界面语言(zh/en)"""
  419. if preferred_language not in ("zh", "en"):
  420. return False
  421. try:
  422. async with db_manager.get_session() as session:
  423. result = await session.execute(
  424. update(User)
  425. .where(User.user_id == user_id)
  426. .values(preferred_language=preferred_language)
  427. )
  428. await session.commit()
  429. return result.rowcount > 0
  430. except Exception as e:
  431. logger.error(f"❌ 更新首选语言失败: user_id={user_id}, error={e}")
  432. return False
  433. async def update_speech_duration(self, user_id: str, duration_ms: int) -> Optional[Dict[str, int]]:
  434. """
  435. 更新用户累积说话时长
  436. Args:
  437. user_id: 用户ID
  438. duration_ms: 本次说话时长(毫秒)
  439. Returns:
  440. 包含 total_duration_ms 与 user_level 的字典,失败时返回 None
  441. """
  442. try:
  443. if duration_ms <= 0:
  444. return None # 时长无效,跳过更新
  445. async with db_manager.get_session() as session:
  446. result = await session.execute(
  447. select(User.total_speech_duration_ms, User.user_level)
  448. .where(User.user_id == user_id)
  449. .with_for_update()
  450. )
  451. row = result.one_or_none()
  452. if row is None:
  453. logger.warning(f"⚠️ 更新说话时长失败,未找到用户: user_id={user_id}")
  454. return None
  455. current_total = (row[0] or 0)
  456. current_level = (row[1] or 0)
  457. new_total = current_total + duration_ms
  458. new_level = self._calculate_user_level(new_total)
  459. update_payload: Dict[str, Any] = {
  460. "total_speech_duration_ms": new_total,
  461. }
  462. if new_level != current_level:
  463. update_payload["user_level"] = new_level
  464. await session.execute(
  465. update(User)
  466. .where(User.user_id == user_id)
  467. .values(**update_payload)
  468. )
  469. await session.commit()
  470. logger.info(
  471. f"✅ 更新用户说话时长成功: user_id={user_id}, "
  472. f"duration_ms={duration_ms}, total_ms={new_total}, level={new_level}"
  473. )
  474. return {
  475. "total_duration_ms": new_total,
  476. "user_level": new_level,
  477. }
  478. except Exception as e:
  479. logger.error(f"❌ 更新用户说话时长失败: user_id={user_id}, error={e}")
  480. return None
  481. async def get_user_ui_state(self, user_id: str) -> Optional[Dict[str, Any]]:
  482. """
  483. 获取用户前端可视化状态
  484. """
  485. try:
  486. async with db_manager.get_session() as session:
  487. result = await session.execute(
  488. select(UserUIState).where(UserUIState.user_id == user_id)
  489. )
  490. state = result.scalar_one_or_none()
  491. if state is None:
  492. return None
  493. return state.to_dict()
  494. except Exception as e: # noqa: BLE001
  495. logger.error(f"❌ 获取用户UI状态失败: user_id={user_id}, error={e}")
  496. return None
  497. async def upsert_user_ui_state(
  498. self,
  499. user_id: str,
  500. *,
  501. current_page: Optional[str] = None,
  502. visited_routes: Optional[list[str]] = None,
  503. has_shown_tap_to_chat: Optional[bool] = None,
  504. has_shown_cancel_tooltip: Optional[bool] = None,
  505. has_shown_send_tooltip: Optional[bool] = None,
  506. has_shown_progress_bar_tooltip: Optional[bool] = None,
  507. has_shown_mission_stone_modal: Optional[bool] = None,
  508. has_shown_conversation_stone_modal: Optional[bool] = None,
  509. has_clicked_mission_stone: Optional[bool] = None,
  510. has_clicked_conversation_stone: Optional[bool] = None,
  511. avatar_c_unlocked: Optional[bool] = None,
  512. current_avatar_id: Optional[str] = None,
  513. has_shared_avatar: Optional[bool] = None,
  514. subtitle_on: Optional[bool] = None,
  515. ) -> Optional[Dict[str, Any]]:
  516. """
  517. 创建或更新用户前端可视化状态(部分字段更新)
  518. """
  519. try:
  520. async with db_manager.get_session() as session:
  521. result = await session.execute(
  522. select(UserUIState).where(UserUIState.user_id == user_id).with_for_update()
  523. )
  524. state: Optional[UserUIState] = result.scalar_one_or_none()
  525. if state is None:
  526. # 新建记录,仅写入非空字段
  527. data: Dict[str, Any] = {"user_id": user_id}
  528. if current_page is not None:
  529. data["current_page"] = current_page
  530. if visited_routes is not None:
  531. data["visited_routes"] = visited_routes
  532. if has_shown_tap_to_chat is not None:
  533. data["has_shown_tap_to_chat"] = has_shown_tap_to_chat
  534. if has_shown_cancel_tooltip is not None:
  535. data["has_shown_cancel_tooltip"] = has_shown_cancel_tooltip
  536. if has_shown_send_tooltip is not None:
  537. data["has_shown_send_tooltip"] = has_shown_send_tooltip
  538. if has_shown_progress_bar_tooltip is not None:
  539. data["has_shown_progress_bar_tooltip"] = has_shown_progress_bar_tooltip
  540. if has_shown_mission_stone_modal is not None:
  541. data["has_shown_mission_stone_modal"] = has_shown_mission_stone_modal
  542. if has_shown_conversation_stone_modal is not None:
  543. data["has_shown_conversation_stone_modal"] = has_shown_conversation_stone_modal
  544. if has_clicked_mission_stone is not None:
  545. data["has_clicked_mission_stone"] = has_clicked_mission_stone
  546. if has_clicked_conversation_stone is not None:
  547. data["has_clicked_conversation_stone"] = has_clicked_conversation_stone
  548. if avatar_c_unlocked is not None:
  549. data["avatar_c_unlocked"] = avatar_c_unlocked
  550. if current_avatar_id is not None:
  551. data["current_avatar_id"] = current_avatar_id
  552. if has_shared_avatar is not None:
  553. data["has_shared_avatar"] = has_shared_avatar
  554. if subtitle_on is not None:
  555. data["subtitle_on"] = subtitle_on
  556. stmt = insert(UserUIState).values(**data)
  557. await session.execute(stmt)
  558. else:
  559. # 更新现有记录(仅覆盖非空字段)
  560. if current_page is not None:
  561. state.current_page = current_page
  562. if visited_routes is not None:
  563. state.visited_routes = visited_routes
  564. if has_shown_tap_to_chat is not None:
  565. state.has_shown_tap_to_chat = has_shown_tap_to_chat
  566. if has_shown_cancel_tooltip is not None:
  567. state.has_shown_cancel_tooltip = has_shown_cancel_tooltip
  568. if has_shown_send_tooltip is not None:
  569. state.has_shown_send_tooltip = has_shown_send_tooltip
  570. if has_shown_progress_bar_tooltip is not None:
  571. state.has_shown_progress_bar_tooltip = has_shown_progress_bar_tooltip
  572. if has_shown_mission_stone_modal is not None:
  573. state.has_shown_mission_stone_modal = has_shown_mission_stone_modal
  574. if has_shown_conversation_stone_modal is not None:
  575. state.has_shown_conversation_stone_modal = has_shown_conversation_stone_modal
  576. if has_clicked_mission_stone is not None:
  577. state.has_clicked_mission_stone = has_clicked_mission_stone
  578. if has_clicked_conversation_stone is not None:
  579. state.has_clicked_conversation_stone = has_clicked_conversation_stone
  580. if avatar_c_unlocked is not None:
  581. state.avatar_c_unlocked = avatar_c_unlocked
  582. if current_avatar_id is not None:
  583. state.current_avatar_id = current_avatar_id
  584. if has_shared_avatar is not None:
  585. state.has_shared_avatar = has_shared_avatar
  586. if subtitle_on is not None:
  587. state.subtitle_on = subtitle_on
  588. session.add(state)
  589. await session.commit()
  590. # 重新读取最新状态
  591. result = await session.execute(
  592. select(UserUIState).where(UserUIState.user_id == user_id)
  593. )
  594. latest_state = result.scalar_one_or_none()
  595. if latest_state is None:
  596. return None
  597. return latest_state.to_dict()
  598. except Exception as e: # noqa: BLE001
  599. logger.error(f"❌ 更新用户UI状态失败: user_id={user_id}, error={e}")
  600. return None
  601. async def log_page_visit(self, user_id: Optional[str], path: str) -> bool:
  602. """记录一次页面访问(未登录时 user_id 为 None)"""
  603. try:
  604. async with db_manager.get_session() as session:
  605. stmt = insert(PageVisitLog).values(user_id=user_id, path=path[:512])
  606. await session.execute(stmt)
  607. await session.commit()
  608. return True
  609. except Exception as e: # noqa: BLE001
  610. logger.warning(f"记录页面访问失败: path={path}, error={e}")
  611. return False
  612. async def _get_last_visit_date_for_user(
  613. self,
  614. session: AsyncSession,
  615. user_id: str,
  616. on_or_after_date: Optional[date] = None,
  617. before_date: Optional[date] = None,
  618. ) -> Optional[date]:
  619. """
  620. 查询用户最后一次页面访问的日期。
  621. Args:
  622. session: 数据库会话
  623. user_id: 用户 ID
  624. on_or_after_date: 仅统计该日期及之后的访问
  625. before_date: 仅统计早于该日期(严格小于)的访问,用于排除当日访问
  626. """
  627. stmt = (
  628. select(func.date(PageVisitLog.visited_at))
  629. .where(PageVisitLog.user_id == user_id)
  630. .order_by(desc(PageVisitLog.visited_at))
  631. .limit(1)
  632. )
  633. if on_or_after_date is not None:
  634. stmt = stmt.where(func.date(PageVisitLog.visited_at) >= on_or_after_date)
  635. if before_date is not None:
  636. stmt = stmt.where(func.date(PageVisitLog.visited_at) < before_date)
  637. result = await session.execute(stmt)
  638. val = result.scalar_one_or_none()
  639. if val is None:
  640. return None
  641. if isinstance(val, date):
  642. return val
  643. if isinstance(val, str):
  644. return datetime.strptime(val[:10], "%Y-%m-%d").date()
  645. return val
  646. async def save_user_event(self, payload: dict) -> bool:
  647. """
  648. 保存单条用户事件(user_events)
  649. payload 字段应与 UserEventPayload 对齐:
  650. - event_id, user_id, event_name, event_label, event_time, session_id,
  651. platform, app_version, timezone_offset_minutes, properties
  652. """
  653. try:
  654. async with db_manager.get_session() as session:
  655. event_time = payload.get("event_time")
  656. if isinstance(event_time, str):
  657. event_time = datetime.fromisoformat(event_time.replace("Z", "+00:00"))
  658. user_event = UserEvent(
  659. event_id=payload["event_id"],
  660. user_id=payload["user_id"],
  661. event_name=payload["event_name"],
  662. event_label=payload.get("event_label"),
  663. event_time=event_time or datetime.utcnow(),
  664. session_id=payload.get("session_id"),
  665. platform=payload["platform"],
  666. app_version=payload.get("app_version"),
  667. timezone_offset_minutes=payload.get("timezone_offset_minutes"),
  668. properties=payload.get("properties"),
  669. )
  670. session.add(user_event)
  671. await session.commit()
  672. return True
  673. except Exception as e: # noqa: BLE001
  674. logger.warning(f"记录用户事件失败: event_id={payload.get('event_id')}, error={e}")
  675. return False
  676. def _parse_effective_date(self) -> date:
  677. """解析配置中的亲密度规则生效日"""
  678. s = getattr(settings, "INTIMACY_RULE_EFFECTIVE_DATE", "2026-02-13") or "2026-02-13"
  679. return datetime.strptime(s.strip()[:10], "%Y-%m-%d").date()
  680. async def get_intimacy(self, user_id: str) -> Dict[str, Any]:
  681. """
  682. 获取用户亲密度:0-3 颗心。扣心由每日定时脚本完成,此处仅读库并计算展示用 days_since_last_visit。
  683. 只统计 INTIMACY_RULE_EFFECTIVE_DATE 及之后的页面访问。
  684. 返回: {"heart_count": int, "days_since_last_visit": int}
  685. """
  686. today = date.today()
  687. effective = self._parse_effective_date()
  688. try:
  689. async with db_manager.get_session() as session:
  690. # 所有访问记录中最晚的一次(含今天),用于判断是否为新用户
  691. last_any_visit = await self._get_last_visit_date_for_user(
  692. session,
  693. user_id,
  694. on_or_after_date=effective,
  695. )
  696. # 排除今天之后,获取“上一次见面”的日期
  697. last_visit_before_today = await self._get_last_visit_date_for_user(
  698. session,
  699. user_id,
  700. on_or_after_date=effective,
  701. before_date=today,
  702. )
  703. if last_any_visit is None:
  704. # 从未有过访问记录:视为首次见面
  705. days_since = 0
  706. elif last_visit_before_today is None:
  707. # 只有今天的访问记录(例如久未登录后今天第一次回来):
  708. # 文案希望展示“已经过去 X 天”,这里按 0 天处理
  709. days_since = 0
  710. else:
  711. days_since = (today - last_visit_before_today).days
  712. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  713. intimacy = result.scalar_one_or_none()
  714. if intimacy is None:
  715. intimacy = UserIntimacy(user_id=user_id, heart_count=3)
  716. session.add(intimacy)
  717. await session.flush()
  718. await session.commit()
  719. await session.refresh(intimacy)
  720. return {
  721. "heart_count": intimacy.heart_count,
  722. "days_since_last_visit": max(0, days_since),
  723. }
  724. except Exception as e: # noqa: BLE001
  725. logger.error(f"get_intimacy 失败: user_id={user_id}, error={e}")
  726. return {"heart_count": 3, "days_since_last_visit": 0}
  727. async def ensure_user_intimacy(self, session: AsyncSession, user_id: str, heart_count: int = 3) -> None:
  728. """注册时为新用户写入亲密度记录(若不存在则插入)。"""
  729. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  730. if result.scalar_one_or_none() is not None:
  731. return
  732. session.add(UserIntimacy(user_id=user_id, heart_count=heart_count))
  733. await session.flush()
  734. async def light_up_intimacy(self, user_id: str) -> Dict[str, Any]:
  735. """
  736. 点亮一颗心:heart_count = min(3, heart_count + 1)。
  737. 规则:同一用户每天最多点亮一次(按服务端当天日期判断)。
  738. 返回最新 heart_count。
  739. """
  740. try:
  741. today_str = date.today().isoformat()[:10]
  742. async with db_manager.get_session() as session:
  743. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  744. intimacy = result.scalar_one_or_none()
  745. if intimacy is None:
  746. # 首次点亮:给 1 颗心,并记录今天为 last_light_up_date
  747. intimacy = UserIntimacy(user_id=user_id, heart_count=1, last_light_up_date=today_str)
  748. session.add(intimacy)
  749. else:
  750. # 如果今天已经点亮过,则直接返回当前亲密度,不再增加
  751. if intimacy.last_light_up_date == today_str:
  752. await session.commit()
  753. await session.refresh(intimacy)
  754. return {"heart_count": intimacy.heart_count}
  755. # 今日尚未点亮:不满 3 颗心时才增加一颗,并更新 last_light_up_date
  756. if intimacy.heart_count < 3:
  757. intimacy.heart_count = min(3, intimacy.heart_count + 1)
  758. intimacy.last_light_up_date = today_str
  759. await session.commit()
  760. await session.refresh(intimacy)
  761. return {"heart_count": intimacy.heart_count}
  762. except Exception as e: # noqa: BLE001
  763. logger.error(f"light_up_intimacy 失败: user_id={user_id}, error={e}")
  764. return {"heart_count": 0}
  765. async def get_intimacy_status(self, user_id: str) -> Dict[str, Any]:
  766. """
  767. 获取用户当天是否可以点亮亲密度。
  768. 返回:
  769. {
  770. "heart_count": int, # 当前心数 0-3
  771. "can_light_up_today": bool, # 今天是否还能点亮一次
  772. }
  773. """
  774. today_str = date.today().isoformat()[:10]
  775. try:
  776. async with db_manager.get_session() as session:
  777. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  778. intimacy = result.scalar_one_or_none()
  779. if intimacy is None:
  780. # 没有记录时视为 3 颗心、今日尚未点亮(但前端通常会基于 heart_count<3 决定是否展示弹窗)
  781. intimacy = UserIntimacy(user_id=user_id, heart_count=3)
  782. session.add(intimacy)
  783. await session.flush()
  784. await session.commit()
  785. await session.refresh(intimacy)
  786. can_light_up_today = intimacy.heart_count < 3 and intimacy.last_light_up_date != today_str
  787. return {
  788. "heart_count": intimacy.heart_count,
  789. "can_light_up_today": can_light_up_today,
  790. }
  791. except Exception as e: # noqa: BLE001
  792. logger.error(f"get_intimacy_status 失败: user_id={user_id}, error={e}")
  793. # 兜底:认为不能点亮,避免无限点亮
  794. return {
  795. "heart_count": 3,
  796. "can_light_up_today": False,
  797. }
  798. async def register_with_phone(
  799. self,
  800. phone: str,
  801. username: str = "",
  802. user_id: Optional[str] = None,
  803. area_code: Optional[str] = None,
  804. preferred_language: Optional[str] = None,
  805. ) -> Dict:
  806. """
  807. 使用手机号注册
  808. Args:
  809. phone: 手机号
  810. username: 用户名(可选)
  811. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  812. Returns:
  813. 用户信息字典
  814. """
  815. try:
  816. lang = preferred_language if preferred_language in ("zh", "en") else None
  817. async with db_manager.get_session() as session:
  818. # 检查手机号是否已被其他用户注册
  819. result = await session.execute(
  820. select(User).where(User.phone == phone)
  821. )
  822. existing_phone_user = result.scalar_one_or_none()
  823. if existing_phone_user:
  824. # 如果提供了 user_id,检查是否是同一个用户
  825. if user_id and existing_phone_user.user_id == user_id:
  826. # 同一个用户,可能是重新注册,允许继续
  827. pass
  828. else:
  829. raise AuthError(PHONE_ALREADY_REGISTERED)
  830. # 如果提供了 user_id,使用现有用户
  831. final_user_id = None
  832. if user_id:
  833. result = await session.execute(
  834. select(User).where(User.user_id == user_id)
  835. )
  836. existing_user = result.scalar_one_or_none()
  837. if not existing_user:
  838. raise AuthError(USER_ID_NOT_FOUND)
  839. if existing_user.user_type == "registered":
  840. raise AuthError(ALREADY_REGISTERED)
  841. # 更新现有用户
  842. if not username or username.strip() == "":
  843. # 如果用户已有用户名,保留;否则生成新用户名
  844. if not existing_user.username or existing_user.username.strip() == "":
  845. username = await self._generate_temp_username(session)
  846. else:
  847. username = existing_user.username
  848. existing_user.phone = phone
  849. if area_code is not None:
  850. existing_user.area_code = area_code
  851. existing_user.username = username
  852. existing_user.user_type = "registered"
  853. existing_user.is_verified = True
  854. existing_user.registered_at = datetime.now()
  855. if lang is not None:
  856. existing_user.preferred_language = lang
  857. user = existing_user
  858. final_user_id = user_id
  859. await session.flush()
  860. # 如果用户还没有背景图片,初始化背景图片
  861. if not existing_user.current_background_image_id or existing_user.current_background_image_id == "default":
  862. await self._init_user_background_image(session, user_id)
  863. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  864. await self._init_user_avatar(session, user_id, user_type="registered")
  865. await self.ensure_user_intimacy(session, user_id, heart_count=3)
  866. await session.commit()
  867. await session.refresh(user)
  868. else:
  869. # 创建新用户(原有逻辑)
  870. new_user_id = str(uuid.uuid4())
  871. if not username or username.strip() == "":
  872. username = await self._generate_temp_username(session)
  873. user = User(
  874. user_id=new_user_id,
  875. username=username,
  876. phone=phone,
  877. area_code=area_code,
  878. user_type="registered",
  879. is_verified=True,
  880. registered_at=datetime.now(),
  881. current_background_image_id="default", # 设置默认背景图片
  882. preferred_language=lang,
  883. )
  884. session.add(user)
  885. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  886. final_user_id = new_user_id
  887. # 初始化背景图片
  888. await self._init_user_background_image(session, new_user_id)
  889. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  890. await self._init_user_avatar(session, new_user_id, user_type="registered")
  891. await self.ensure_user_intimacy(session, new_user_id, heart_count=3)
  892. await session.commit()
  893. await session.refresh(user)
  894. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  895. final_user_id = final_user_id if final_user_id else user.user_id
  896. await self.upsert_user_ui_state(
  897. user_id=final_user_id,
  898. current_avatar_id=REGISTERED_AVATAR_ID
  899. )
  900. logger.info(f"✅ 手机号注册成功: user_id={final_user_id}, area_code={area_code}, phone={phone}")
  901. return user.to_dict()
  902. except Exception as e:
  903. logger.error(f"❌ 手机号注册失败: {e}")
  904. raise
  905. async def register_with_email(
  906. self,
  907. email: str,
  908. username: str = "",
  909. user_id: Optional[str] = None,
  910. preferred_language: Optional[str] = None,
  911. ) -> Dict:
  912. """
  913. 使用邮箱注册
  914. Args:
  915. email: 邮箱
  916. username: 用户名(可选)
  917. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  918. Returns:
  919. 用户信息字典
  920. """
  921. try:
  922. lang = preferred_language if preferred_language in ("zh", "en") else None
  923. async with db_manager.get_session() as session:
  924. # 检查邮箱是否已被其他用户注册
  925. result = await session.execute(
  926. select(User).where(User.email == email)
  927. )
  928. existing_email_user = result.scalar_one_or_none()
  929. if existing_email_user:
  930. # 如果提供了 user_id,检查是否是同一个用户
  931. if user_id and existing_email_user.user_id == user_id:
  932. # 同一个用户,可能是重新注册,允许继续
  933. pass
  934. else:
  935. raise AuthError(EMAIL_ALREADY_REGISTERED)
  936. # 如果提供了 user_id,使用现有用户
  937. final_user_id = None
  938. if user_id:
  939. result = await session.execute(
  940. select(User).where(User.user_id == user_id)
  941. )
  942. existing_user = result.scalar_one_or_none()
  943. if not existing_user:
  944. raise AuthError(USER_ID_NOT_FOUND)
  945. if existing_user.user_type == "registered":
  946. raise AuthError(ALREADY_REGISTERED)
  947. # 更新现有用户
  948. if not username or username.strip() == "":
  949. # 如果用户已有用户名,保留;否则生成新用户名
  950. if not existing_user.username or existing_user.username.strip() == "":
  951. username = await self._generate_temp_username(session)
  952. else:
  953. username = existing_user.username
  954. existing_user.email = email
  955. existing_user.username = username
  956. existing_user.user_type = "registered"
  957. existing_user.is_verified = True
  958. existing_user.registered_at = datetime.now()
  959. if lang is not None:
  960. existing_user.preferred_language = lang
  961. user = existing_user
  962. final_user_id = user_id
  963. await session.flush()
  964. # 如果用户还没有背景图片,初始化背景图片
  965. if not existing_user.current_background_image_id or existing_user.current_background_image_id == "default":
  966. await self._init_user_background_image(session, user_id)
  967. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  968. await self._init_user_avatar(session, user_id, user_type="registered")
  969. await self.ensure_user_intimacy(session, user_id, heart_count=3)
  970. await session.commit()
  971. await session.refresh(user)
  972. else:
  973. # 创建新用户(原有逻辑)
  974. new_user_id = str(uuid.uuid4())
  975. if not username or username.strip() == "":
  976. username = await self._generate_temp_username(session)
  977. user = User(
  978. user_id=new_user_id,
  979. username=username,
  980. email=email,
  981. user_type="registered",
  982. is_verified=True,
  983. registered_at=datetime.now(),
  984. current_background_image_id="default", # 设置默认背景图片
  985. preferred_language=lang,
  986. )
  987. session.add(user)
  988. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  989. final_user_id = new_user_id
  990. # 初始化背景图片
  991. await self._init_user_background_image(session, new_user_id)
  992. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  993. await self._init_user_avatar(session, new_user_id, user_type="registered")
  994. await self.ensure_user_intimacy(session, new_user_id, heart_count=3)
  995. await session.commit()
  996. await session.refresh(user)
  997. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  998. final_user_id = final_user_id if final_user_id else user.user_id
  999. await self.upsert_user_ui_state(
  1000. user_id=final_user_id,
  1001. current_avatar_id=REGISTERED_AVATAR_ID
  1002. )
  1003. logger.info(f"✅ 邮箱注册成功: user_id={final_user_id}, email={email}")
  1004. return user.to_dict()
  1005. except Exception as e:
  1006. logger.error(f"❌ 邮箱注册失败: {e}")
  1007. raise
  1008. async def login_with_wechat(self, openid: str, username: str = "") -> Dict:
  1009. """
  1010. 使用微信OpenID登录/注册
  1011. Args:
  1012. openid: 微信OpenID
  1013. username: 用户名(可选,仅新用户时使用)
  1014. Returns:
  1015. 用户信息字典
  1016. """
  1017. try:
  1018. async with db_manager.get_session() as session:
  1019. # 检查是否已存在
  1020. result = await session.execute(
  1021. select(User).where(User.wechat_openid == openid)
  1022. )
  1023. existing_user = result.scalar_one_or_none()
  1024. if existing_user:
  1025. # 已存在,直接返回
  1026. logger.info(f"✅ 微信登录成功: user_id={existing_user.user_id}, openid={openid}")
  1027. return existing_user.to_dict()
  1028. # 新用户,创建账号
  1029. user_id = str(uuid.uuid4())
  1030. if not username or username.strip() == "":
  1031. username = await self._generate_temp_username(session)
  1032. user = User(
  1033. user_id=user_id,
  1034. username=username,
  1035. wechat_openid=openid,
  1036. user_type="registered",
  1037. is_verified=True,
  1038. registered_at=datetime.now(),
  1039. current_background_image_id="default", # 设置默认背景图片
  1040. )
  1041. session.add(user)
  1042. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  1043. # 初始化背景图片
  1044. await self._init_user_background_image(session, user_id)
  1045. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1046. await self._init_user_avatar(session, user_id, user_type="registered")
  1047. await session.commit()
  1048. await session.refresh(user)
  1049. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  1050. await self.upsert_user_ui_state(
  1051. user_id=user_id,
  1052. current_avatar_id=REGISTERED_AVATAR_ID
  1053. )
  1054. logger.info(f"✅ 微信注册成功: user_id={user_id}, openid={openid}")
  1055. return user.to_dict()
  1056. except Exception as e:
  1057. logger.error(f"❌ 微信登录/注册失败: {e}")
  1058. raise
  1059. async def login_with_qq(
  1060. self,
  1061. openid: str,
  1062. username: str = "",
  1063. unionid: Optional[str] = None,
  1064. user_id: Optional[str] = None,
  1065. ) -> Dict:
  1066. """
  1067. 使用QQ OpenID登录/注册
  1068. Args:
  1069. openid: QQ OpenID
  1070. username: 用户名(可选,仅新用户时使用)
  1071. unionid: QQ UnionID(可选)
  1072. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  1073. Returns:
  1074. 用户信息字典
  1075. """
  1076. try:
  1077. async with db_manager.get_session() as session:
  1078. # 优先用 openid 查询
  1079. result = await session.execute(
  1080. select(User).where(User.qq_openid == openid)
  1081. )
  1082. existing_user = result.scalar_one_or_none()
  1083. # 如果未命中且拿到了 unionid,则尝试用 unionid 查询
  1084. if not existing_user and unionid:
  1085. union_result = await session.execute(
  1086. select(User).where(User.qq_unionid == unionid)
  1087. )
  1088. existing_user = union_result.scalar_one_or_none()
  1089. # 若命中用户还未绑定 openid,则补充绑定
  1090. if existing_user and not existing_user.qq_openid:
  1091. existing_user.qq_openid = openid
  1092. session.add(existing_user)
  1093. await session.commit()
  1094. await session.refresh(existing_user)
  1095. logger.info(
  1096. f"🔁 已根据 unionid={unionid} 绑定新的 QQ openid={openid}"
  1097. )
  1098. if existing_user:
  1099. # 如果提供了 user_id,检查是否是同一个用户
  1100. if user_id and existing_user.user_id != user_id:
  1101. raise AuthError(QQ_ALREADY_BOUND)
  1102. updated = False
  1103. if unionid and not existing_user.qq_unionid:
  1104. existing_user.qq_unionid = unionid
  1105. updated = True
  1106. if updated:
  1107. session.add(existing_user)
  1108. await session.commit()
  1109. await session.refresh(existing_user)
  1110. logger.info(
  1111. f"✅ QQ登录成功: user_id={existing_user.user_id}, "
  1112. f"openid={openid}, unionid={unionid}"
  1113. )
  1114. return existing_user.to_dict()
  1115. # 如果提供了 user_id,使用现有用户
  1116. final_user_id = None
  1117. if user_id:
  1118. result = await session.execute(
  1119. select(User).where(User.user_id == user_id)
  1120. )
  1121. existing_user_by_id = result.scalar_one_or_none()
  1122. if not existing_user_by_id:
  1123. raise AuthError(USER_ID_NOT_FOUND)
  1124. if existing_user_by_id.user_type == "registered":
  1125. raise AuthError(ALREADY_REGISTERED)
  1126. # 更新现有用户
  1127. if not username or username.strip() == "":
  1128. # 如果用户已有用户名,保留;否则生成新用户名
  1129. if not existing_user_by_id.username or existing_user_by_id.username.strip() == "":
  1130. username = await self._generate_temp_username(session)
  1131. else:
  1132. username = existing_user_by_id.username
  1133. existing_user_by_id.qq_openid = openid
  1134. if unionid:
  1135. existing_user_by_id.qq_unionid = unionid
  1136. existing_user_by_id.username = username
  1137. existing_user_by_id.user_type = "registered"
  1138. existing_user_by_id.is_verified = True
  1139. existing_user_by_id.registered_at = datetime.now()
  1140. user = existing_user_by_id
  1141. final_user_id = user_id
  1142. await session.flush()
  1143. # 如果用户还没有背景图片,初始化背景图片
  1144. if not existing_user_by_id.current_background_image_id or existing_user_by_id.current_background_image_id == "default":
  1145. await self._init_user_background_image(session, user_id)
  1146. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1147. await self._init_user_avatar(session, user_id, user_type="registered")
  1148. await session.commit()
  1149. await session.refresh(user)
  1150. else:
  1151. # 新用户,创建账号
  1152. new_user_id = str(uuid.uuid4())
  1153. if not username or username.strip() == "":
  1154. username = await self._generate_temp_username(session)
  1155. user = User(
  1156. user_id=new_user_id,
  1157. username=username,
  1158. qq_openid=openid,
  1159. qq_unionid=unionid,
  1160. user_type="registered",
  1161. is_verified=True,
  1162. registered_at=datetime.now(),
  1163. current_background_image_id="default", # 设置默认背景图片
  1164. )
  1165. session.add(user)
  1166. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  1167. final_user_id = new_user_id
  1168. # 初始化背景图片
  1169. await self._init_user_background_image(session, new_user_id)
  1170. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1171. await self._init_user_avatar(session, new_user_id, user_type="registered")
  1172. await session.commit()
  1173. await session.refresh(user)
  1174. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  1175. final_user_id = final_user_id if final_user_id else user.user_id
  1176. await self.upsert_user_ui_state(
  1177. user_id=final_user_id,
  1178. current_avatar_id=REGISTERED_AVATAR_ID
  1179. )
  1180. logger.info(
  1181. f"✅ QQ注册成功: user_id={final_user_id}, openid={openid}, unionid={unionid}"
  1182. )
  1183. return user.to_dict()
  1184. except Exception as e:
  1185. logger.error(f"❌ QQ登录/注册失败: {e}")
  1186. raise
  1187. async def login_with_phone(
  1188. self,
  1189. phone: str,
  1190. area_code: Optional[str] = None,
  1191. preferred_language: Optional[str] = None,
  1192. ) -> Optional[Dict]:
  1193. """
  1194. 使用手机号登录
  1195. Args:
  1196. phone: 手机号
  1197. Returns:
  1198. 用户信息字典,如果不存在返回None
  1199. """
  1200. try:
  1201. lang = preferred_language if preferred_language in ("zh", "en") else None
  1202. async with db_manager.get_session() as session:
  1203. result = await session.execute(
  1204. select(User).where(
  1205. User.phone == phone,
  1206. *( [User.area_code == area_code] if area_code else [] )
  1207. )
  1208. )
  1209. user = result.scalar_one_or_none()
  1210. # 兼容旧数据:未存区号时再用空区号回退查询
  1211. if not user and area_code:
  1212. fallback_result = await session.execute(
  1213. select(User).where(User.phone == phone, User.area_code.is_(None))
  1214. )
  1215. user = fallback_result.scalar_one_or_none()
  1216. if user:
  1217. if lang is not None:
  1218. try:
  1219. async with db_manager.get_session() as session:
  1220. await session.execute(
  1221. update(User)
  1222. .where(User.user_id == user.user_id)
  1223. .values(preferred_language=lang)
  1224. )
  1225. await session.commit()
  1226. except Exception as update_error: # noqa: BLE001
  1227. logger.warning(
  1228. f"⚠️ 更新用户首选语言失败: user_id={user.user_id}, lang={lang}, error={update_error}"
  1229. )
  1230. logger.info(f"✅ 手机号登录成功: user_id={user.user_id}, area_code={area_code}, phone={phone}")
  1231. return user.to_dict()
  1232. return None
  1233. except Exception as e:
  1234. logger.error(f"❌ 手机号登录失败: {e}")
  1235. return None
  1236. async def login_with_email(self, email: str, preferred_language: Optional[str] = None) -> Optional[Dict]:
  1237. """
  1238. 使用邮箱登录
  1239. Args:
  1240. email: 邮箱
  1241. Returns:
  1242. 用户信息字典,如果不存在返回None
  1243. """
  1244. try:
  1245. lang = preferred_language if preferred_language in ("zh", "en") else None
  1246. async with db_manager.get_session() as session:
  1247. result = await session.execute(
  1248. select(User).where(User.email == email)
  1249. )
  1250. user = result.scalar_one_or_none()
  1251. if user:
  1252. if lang is not None:
  1253. try:
  1254. async with db_manager.get_session() as session:
  1255. await session.execute(
  1256. update(User)
  1257. .where(User.user_id == user.user_id)
  1258. .values(preferred_language=lang)
  1259. )
  1260. await session.commit()
  1261. except Exception as update_error: # noqa: BLE001
  1262. logger.warning(
  1263. f"⚠️ 更新用户首选语言失败: user_id={user.user_id}, lang={lang}, error={update_error}"
  1264. )
  1265. logger.info(f"✅ 邮箱登录成功: user_id={user.user_id}, email={email}")
  1266. return user.to_dict()
  1267. return None
  1268. except Exception as e:
  1269. logger.error(f"❌ 邮箱登录失败: {e}")
  1270. return None
  1271. async def verify_and_auth(
  1272. self,
  1273. auth_type: str,
  1274. code: str,
  1275. email: Optional[str] = None,
  1276. phone: Optional[str] = None,
  1277. area_code: Optional[str] = None,
  1278. preferred_language: Optional[str] = None,
  1279. ) -> Tuple[Dict, bool]:
  1280. """
  1281. 验证码校验通过后统一登录或注册(V2.0)。
  1282. auth_type: "email" | "phone"
  1283. 返回 (user_dict, is_new_user)
  1284. """
  1285. if auth_type == "email":
  1286. send_type = "email"
  1287. else:
  1288. send_type = "international_sms" if (area_code and area_code.strip() != "+86") else "domestic_sms"
  1289. if auth_type == "email":
  1290. if not email:
  1291. raise AuthError(EMAIL_REQUIRED)
  1292. else:
  1293. if not phone:
  1294. raise AuthError(PHONE_REQUIRED)
  1295. if not code or not code.strip():
  1296. raise AuthError(CODE_REQUIRED)
  1297. async with db_manager.get_session() as session:
  1298. if auth_type == "email":
  1299. result = await session.execute(
  1300. select(VerificationCode)
  1301. .where(
  1302. VerificationCode.email == email,
  1303. VerificationCode.send_type == send_type,
  1304. VerificationCode.code == code.strip(),
  1305. VerificationCode.is_valid == True,
  1306. )
  1307. .order_by(VerificationCode.created_at.desc())
  1308. .limit(1)
  1309. )
  1310. else:
  1311. result = await session.execute(
  1312. select(VerificationCode)
  1313. .where(
  1314. VerificationCode.phone == phone,
  1315. VerificationCode.send_type == send_type,
  1316. VerificationCode.code == code.strip(),
  1317. VerificationCode.is_valid == True,
  1318. )
  1319. .order_by(VerificationCode.created_at.desc())
  1320. .limit(1)
  1321. )
  1322. verification_code = result.scalar_one_or_none()
  1323. if not verification_code:
  1324. logger.warning(f"❌ 验证码无效: type={auth_type}, email={email}, phone={phone}")
  1325. raise AuthError(CODE_INVALID)
  1326. verification_code.is_valid = False
  1327. await session.commit()
  1328. if auth_type == "email":
  1329. user = await self.login_with_email(email, preferred_language=preferred_language)
  1330. if user:
  1331. logger.info(f"✅ verify_and_auth 邮箱登录: user_id={user['user_id']}")
  1332. return (user, False)
  1333. user = await self.register_with_email(email, preferred_language=preferred_language)
  1334. logger.info(f"✅ verify_and_auth 邮箱注册: user_id={user['user_id']}")
  1335. return (user, True)
  1336. else:
  1337. user = await self.login_with_phone(phone, area_code, preferred_language=preferred_language)
  1338. if user:
  1339. logger.info(f"✅ verify_and_auth 手机登录: user_id={user['user_id']}")
  1340. return (user, False)
  1341. user = await self.register_with_phone(phone, area_code=area_code, preferred_language=preferred_language)
  1342. logger.info(f"✅ verify_and_auth 手机注册: user_id={user['user_id']}")
  1343. return (user, True)
  1344. async def get_community_choice(self, user_id: str) -> Optional[Dict]:
  1345. """获取用户「退出早期共创社区」选项(V2.0),用于登录页回显"""
  1346. async with db_manager.get_session() as session:
  1347. result = await session.execute(
  1348. select(UserCommunityChoice).where(UserCommunityChoice.user_id == user_id)
  1349. )
  1350. row = result.scalar_one_or_none()
  1351. return row.to_dict() if row else None
  1352. async def upsert_community_choice(self, user_id: str, opted_out: bool) -> None:
  1353. """更新用户「退出早期共创社区」选项(V2.0)"""
  1354. async with db_manager.get_session() as session:
  1355. result = await session.execute(
  1356. select(UserCommunityChoice).where(UserCommunityChoice.user_id == user_id)
  1357. )
  1358. row = result.scalar_one_or_none()
  1359. if row:
  1360. row.opted_out = opted_out
  1361. else:
  1362. session.add(UserCommunityChoice(user_id=user_id, opted_out=opted_out))
  1363. await session.commit()
  1364. async def get_early_community(self, user_id: str) -> Optional[Dict]:
  1365. """获取用户早期共创状态(V2.0)"""
  1366. async with db_manager.get_session() as session:
  1367. result = await session.execute(
  1368. select(UserEarlyCommunity).where(UserEarlyCommunity.user_id == user_id)
  1369. )
  1370. row = result.scalar_one_or_none()
  1371. return row.to_dict() if row else None
  1372. async def submit_early_community(self, user_id: str, email: Optional[str] = None) -> Dict:
  1373. """提交早期共创:标记弹窗已展示;若传 email 则写入并标记已加入(V2.0)"""
  1374. async with db_manager.get_session() as session:
  1375. result = await session.execute(
  1376. select(UserEarlyCommunity).where(UserEarlyCommunity.user_id == user_id)
  1377. )
  1378. row = result.scalar_one_or_none()
  1379. now = datetime.now()
  1380. if row:
  1381. row.popup_shown = True
  1382. if email and email.strip():
  1383. row.email = email.strip()
  1384. row.joined_at = now
  1385. else:
  1386. session.add(
  1387. UserEarlyCommunity(
  1388. user_id=user_id,
  1389. email=email.strip() if email and email.strip() else None,
  1390. popup_shown=True,
  1391. joined_at=now if (email and email.strip()) else None,
  1392. )
  1393. )
  1394. await session.commit()
  1395. result2 = await session.execute(
  1396. select(UserEarlyCommunity).where(UserEarlyCommunity.user_id == user_id)
  1397. )
  1398. r = result2.scalar_one_or_none()
  1399. return r.to_dict() if r else {}
  1400. async def check_user_exists_by_qq_openid(self, openid: str, unionid: Optional[str] = None) -> bool:
  1401. """
  1402. 检查QQ OpenID对应的用户是否存在
  1403. Args:
  1404. openid: QQ OpenID
  1405. unionid: QQ UnionID(可选)
  1406. Returns:
  1407. 用户是否存在
  1408. """
  1409. try:
  1410. async with db_manager.get_session() as session:
  1411. # 优先用 openid 查询
  1412. result = await session.execute(
  1413. select(User).where(User.qq_openid == openid)
  1414. )
  1415. existing_user = result.scalar_one_or_none()
  1416. logger.debug(f"🔍 [检查QQ用户] openid查询结果 - openid={openid[:16]}..., found={existing_user is not None}, user_id={existing_user.user_id if existing_user else None}")
  1417. # 如果未命中且拿到了 unionid,则尝试用 unionid 查询
  1418. if not existing_user and unionid:
  1419. union_result = await session.execute(
  1420. select(User).where(User.qq_unionid == unionid)
  1421. )
  1422. existing_user = union_result.scalar_one_or_none()
  1423. logger.debug(f"🔍 [检查QQ用户] unionid查询结果 - unionid={unionid[:16] if unionid else None}..., found={existing_user is not None}, user_id={existing_user.user_id if existing_user else None}")
  1424. exists = existing_user is not None
  1425. logger.info(f"🔍 [检查QQ用户] 最终结果 - openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}..., exists={exists}")
  1426. return exists
  1427. except Exception as e:
  1428. logger.error(f"❌ 检查QQ用户是否存在失败: {e}")
  1429. return False
  1430. # 全局用户服务实例
  1431. user_service = UserService()