user_service.py 64 KB

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