user_service.py 69 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605
  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_speech_duration(self, user_id: str, duration_ms: int) -> Optional[Dict[str, int]]:
  418. """
  419. 更新用户累积说话时长
  420. Args:
  421. user_id: 用户ID
  422. duration_ms: 本次说话时长(毫秒)
  423. Returns:
  424. 包含 total_duration_ms 与 user_level 的字典,失败时返回 None
  425. """
  426. try:
  427. if duration_ms <= 0:
  428. return None # 时长无效,跳过更新
  429. async with db_manager.get_session() as session:
  430. result = await session.execute(
  431. select(User.total_speech_duration_ms, User.user_level)
  432. .where(User.user_id == user_id)
  433. .with_for_update()
  434. )
  435. row = result.one_or_none()
  436. if row is None:
  437. logger.warning(f"⚠️ 更新说话时长失败,未找到用户: user_id={user_id}")
  438. return None
  439. current_total = (row[0] or 0)
  440. current_level = (row[1] or 0)
  441. new_total = current_total + duration_ms
  442. new_level = self._calculate_user_level(new_total)
  443. update_payload: Dict[str, Any] = {
  444. "total_speech_duration_ms": new_total,
  445. }
  446. if new_level != current_level:
  447. update_payload["user_level"] = new_level
  448. await session.execute(
  449. update(User)
  450. .where(User.user_id == user_id)
  451. .values(**update_payload)
  452. )
  453. await session.commit()
  454. logger.info(
  455. f"✅ 更新用户说话时长成功: user_id={user_id}, "
  456. f"duration_ms={duration_ms}, total_ms={new_total}, level={new_level}"
  457. )
  458. return {
  459. "total_duration_ms": new_total,
  460. "user_level": new_level,
  461. }
  462. except Exception as e:
  463. logger.error(f"❌ 更新用户说话时长失败: user_id={user_id}, error={e}")
  464. return None
  465. async def get_user_ui_state(self, user_id: str) -> Optional[Dict[str, Any]]:
  466. """
  467. 获取用户前端可视化状态
  468. """
  469. try:
  470. async with db_manager.get_session() as session:
  471. result = await session.execute(
  472. select(UserUIState).where(UserUIState.user_id == user_id)
  473. )
  474. state = result.scalar_one_or_none()
  475. if state is None:
  476. return None
  477. return state.to_dict()
  478. except Exception as e: # noqa: BLE001
  479. logger.error(f"❌ 获取用户UI状态失败: user_id={user_id}, error={e}")
  480. return None
  481. async def upsert_user_ui_state(
  482. self,
  483. user_id: str,
  484. *,
  485. current_page: Optional[str] = None,
  486. visited_routes: Optional[list[str]] = None,
  487. has_shown_tap_to_chat: Optional[bool] = None,
  488. has_shown_cancel_tooltip: Optional[bool] = None,
  489. has_shown_send_tooltip: Optional[bool] = None,
  490. has_shown_progress_bar_tooltip: Optional[bool] = None,
  491. has_shown_mission_stone_modal: Optional[bool] = None,
  492. has_shown_conversation_stone_modal: Optional[bool] = None,
  493. has_clicked_mission_stone: Optional[bool] = None,
  494. has_clicked_conversation_stone: Optional[bool] = None,
  495. avatar_c_unlocked: Optional[bool] = None,
  496. current_avatar_id: Optional[str] = None,
  497. has_shared_avatar: Optional[bool] = None,
  498. subtitle_on: Optional[bool] = None,
  499. ) -> Optional[Dict[str, Any]]:
  500. """
  501. 创建或更新用户前端可视化状态(部分字段更新)
  502. """
  503. try:
  504. async with db_manager.get_session() as session:
  505. result = await session.execute(
  506. select(UserUIState).where(UserUIState.user_id == user_id).with_for_update()
  507. )
  508. state: Optional[UserUIState] = result.scalar_one_or_none()
  509. if state is None:
  510. # 新建记录,仅写入非空字段
  511. data: Dict[str, Any] = {"user_id": user_id}
  512. if current_page is not None:
  513. data["current_page"] = current_page
  514. if visited_routes is not None:
  515. data["visited_routes"] = visited_routes
  516. if has_shown_tap_to_chat is not None:
  517. data["has_shown_tap_to_chat"] = has_shown_tap_to_chat
  518. if has_shown_cancel_tooltip is not None:
  519. data["has_shown_cancel_tooltip"] = has_shown_cancel_tooltip
  520. if has_shown_send_tooltip is not None:
  521. data["has_shown_send_tooltip"] = has_shown_send_tooltip
  522. if has_shown_progress_bar_tooltip is not None:
  523. data["has_shown_progress_bar_tooltip"] = has_shown_progress_bar_tooltip
  524. if has_shown_mission_stone_modal is not None:
  525. data["has_shown_mission_stone_modal"] = has_shown_mission_stone_modal
  526. if has_shown_conversation_stone_modal is not None:
  527. data["has_shown_conversation_stone_modal"] = has_shown_conversation_stone_modal
  528. if has_clicked_mission_stone is not None:
  529. data["has_clicked_mission_stone"] = has_clicked_mission_stone
  530. if has_clicked_conversation_stone is not None:
  531. data["has_clicked_conversation_stone"] = has_clicked_conversation_stone
  532. if avatar_c_unlocked is not None:
  533. data["avatar_c_unlocked"] = avatar_c_unlocked
  534. if current_avatar_id is not None:
  535. data["current_avatar_id"] = current_avatar_id
  536. if has_shared_avatar is not None:
  537. data["has_shared_avatar"] = has_shared_avatar
  538. if subtitle_on is not None:
  539. data["subtitle_on"] = subtitle_on
  540. stmt = insert(UserUIState).values(**data)
  541. await session.execute(stmt)
  542. else:
  543. # 更新现有记录(仅覆盖非空字段)
  544. if current_page is not None:
  545. state.current_page = current_page
  546. if visited_routes is not None:
  547. state.visited_routes = visited_routes
  548. if has_shown_tap_to_chat is not None:
  549. state.has_shown_tap_to_chat = has_shown_tap_to_chat
  550. if has_shown_cancel_tooltip is not None:
  551. state.has_shown_cancel_tooltip = has_shown_cancel_tooltip
  552. if has_shown_send_tooltip is not None:
  553. state.has_shown_send_tooltip = has_shown_send_tooltip
  554. if has_shown_progress_bar_tooltip is not None:
  555. state.has_shown_progress_bar_tooltip = has_shown_progress_bar_tooltip
  556. if has_shown_mission_stone_modal is not None:
  557. state.has_shown_mission_stone_modal = has_shown_mission_stone_modal
  558. if has_shown_conversation_stone_modal is not None:
  559. state.has_shown_conversation_stone_modal = has_shown_conversation_stone_modal
  560. if has_clicked_mission_stone is not None:
  561. state.has_clicked_mission_stone = has_clicked_mission_stone
  562. if has_clicked_conversation_stone is not None:
  563. state.has_clicked_conversation_stone = has_clicked_conversation_stone
  564. if avatar_c_unlocked is not None:
  565. state.avatar_c_unlocked = avatar_c_unlocked
  566. if current_avatar_id is not None:
  567. state.current_avatar_id = current_avatar_id
  568. if has_shared_avatar is not None:
  569. state.has_shared_avatar = has_shared_avatar
  570. if subtitle_on is not None:
  571. state.subtitle_on = subtitle_on
  572. session.add(state)
  573. await session.commit()
  574. # 重新读取最新状态
  575. result = await session.execute(
  576. select(UserUIState).where(UserUIState.user_id == user_id)
  577. )
  578. latest_state = result.scalar_one_or_none()
  579. if latest_state is None:
  580. return None
  581. return latest_state.to_dict()
  582. except Exception as e: # noqa: BLE001
  583. logger.error(f"❌ 更新用户UI状态失败: user_id={user_id}, error={e}")
  584. return None
  585. async def log_page_visit(self, user_id: Optional[str], path: str) -> bool:
  586. """记录一次页面访问(未登录时 user_id 为 None)"""
  587. try:
  588. async with db_manager.get_session() as session:
  589. stmt = insert(PageVisitLog).values(user_id=user_id, path=path[:512])
  590. await session.execute(stmt)
  591. await session.commit()
  592. return True
  593. except Exception as e: # noqa: BLE001
  594. logger.warning(f"记录页面访问失败: path={path}, error={e}")
  595. return False
  596. async def _get_last_visit_date_for_user(
  597. self,
  598. session: AsyncSession,
  599. user_id: str,
  600. on_or_after_date: Optional[date] = None,
  601. before_date: Optional[date] = None,
  602. ) -> Optional[date]:
  603. """
  604. 查询用户最后一次页面访问的日期。
  605. Args:
  606. session: 数据库会话
  607. user_id: 用户 ID
  608. on_or_after_date: 仅统计该日期及之后的访问
  609. before_date: 仅统计早于该日期(严格小于)的访问,用于排除当日访问
  610. """
  611. stmt = (
  612. select(func.date(PageVisitLog.visited_at))
  613. .where(PageVisitLog.user_id == user_id)
  614. .order_by(desc(PageVisitLog.visited_at))
  615. .limit(1)
  616. )
  617. if on_or_after_date is not None:
  618. stmt = stmt.where(func.date(PageVisitLog.visited_at) >= on_or_after_date)
  619. if before_date is not None:
  620. stmt = stmt.where(func.date(PageVisitLog.visited_at) < before_date)
  621. result = await session.execute(stmt)
  622. val = result.scalar_one_or_none()
  623. if val is None:
  624. return None
  625. if isinstance(val, date):
  626. return val
  627. if isinstance(val, str):
  628. return datetime.strptime(val[:10], "%Y-%m-%d").date()
  629. return val
  630. async def save_user_event(self, payload: dict) -> bool:
  631. """
  632. 保存单条用户事件(user_events)
  633. payload 字段应与 UserEventPayload 对齐:
  634. - event_id, user_id, event_name, event_label, event_time, session_id,
  635. platform, app_version, timezone_offset_minutes, properties
  636. """
  637. try:
  638. async with db_manager.get_session() as session:
  639. event_time = payload.get("event_time")
  640. if isinstance(event_time, str):
  641. event_time = datetime.fromisoformat(event_time.replace("Z", "+00:00"))
  642. user_event = UserEvent(
  643. event_id=payload["event_id"],
  644. user_id=payload["user_id"],
  645. event_name=payload["event_name"],
  646. event_label=payload.get("event_label"),
  647. event_time=event_time or datetime.utcnow(),
  648. session_id=payload.get("session_id"),
  649. platform=payload["platform"],
  650. app_version=payload.get("app_version"),
  651. timezone_offset_minutes=payload.get("timezone_offset_minutes"),
  652. properties=payload.get("properties"),
  653. )
  654. session.add(user_event)
  655. await session.commit()
  656. return True
  657. except Exception as e: # noqa: BLE001
  658. logger.warning(f"记录用户事件失败: event_id={payload.get('event_id')}, error={e}")
  659. return False
  660. def _parse_effective_date(self) -> date:
  661. """解析配置中的亲密度规则生效日"""
  662. s = getattr(settings, "INTIMACY_RULE_EFFECTIVE_DATE", "2026-02-13") or "2026-02-13"
  663. return datetime.strptime(s.strip()[:10], "%Y-%m-%d").date()
  664. async def get_intimacy(self, user_id: str) -> Dict[str, Any]:
  665. """
  666. 获取用户亲密度:0-3 颗心。扣心由每日定时脚本完成,此处仅读库并计算展示用 days_since_last_visit。
  667. 只统计 INTIMACY_RULE_EFFECTIVE_DATE 及之后的页面访问。
  668. 返回: {"heart_count": int, "days_since_last_visit": int}
  669. """
  670. today = date.today()
  671. effective = self._parse_effective_date()
  672. try:
  673. async with db_manager.get_session() as session:
  674. # 所有访问记录中最晚的一次(含今天),用于判断是否为新用户
  675. last_any_visit = await self._get_last_visit_date_for_user(
  676. session,
  677. user_id,
  678. on_or_after_date=effective,
  679. )
  680. # 排除今天之后,获取“上一次见面”的日期
  681. last_visit_before_today = await self._get_last_visit_date_for_user(
  682. session,
  683. user_id,
  684. on_or_after_date=effective,
  685. before_date=today,
  686. )
  687. if last_any_visit is None:
  688. # 从未有过访问记录:视为首次见面
  689. days_since = 0
  690. elif last_visit_before_today is None:
  691. # 只有今天的访问记录(例如久未登录后今天第一次回来):
  692. # 文案希望展示“已经过去 X 天”,这里按 0 天处理
  693. days_since = 0
  694. else:
  695. days_since = (today - last_visit_before_today).days
  696. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  697. intimacy = result.scalar_one_or_none()
  698. if intimacy is None:
  699. intimacy = UserIntimacy(user_id=user_id, heart_count=3)
  700. session.add(intimacy)
  701. await session.flush()
  702. await session.commit()
  703. await session.refresh(intimacy)
  704. return {
  705. "heart_count": intimacy.heart_count,
  706. "days_since_last_visit": max(0, days_since),
  707. }
  708. except Exception as e: # noqa: BLE001
  709. logger.error(f"get_intimacy 失败: user_id={user_id}, error={e}")
  710. return {"heart_count": 3, "days_since_last_visit": 0}
  711. async def ensure_user_intimacy(self, session: AsyncSession, user_id: str, heart_count: int = 3) -> None:
  712. """注册时为新用户写入亲密度记录(若不存在则插入)。"""
  713. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  714. if result.scalar_one_or_none() is not None:
  715. return
  716. session.add(UserIntimacy(user_id=user_id, heart_count=heart_count))
  717. await session.flush()
  718. async def light_up_intimacy(self, user_id: str) -> Dict[str, Any]:
  719. """
  720. 点亮一颗心:heart_count = min(3, heart_count + 1)。
  721. 规则:同一用户每天最多点亮一次(按服务端当天日期判断)。
  722. 返回最新 heart_count。
  723. """
  724. try:
  725. today_str = date.today().isoformat()[:10]
  726. async with db_manager.get_session() as session:
  727. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  728. intimacy = result.scalar_one_or_none()
  729. if intimacy is None:
  730. # 首次点亮:给 1 颗心,并记录今天为 last_light_up_date
  731. intimacy = UserIntimacy(user_id=user_id, heart_count=1, last_light_up_date=today_str)
  732. session.add(intimacy)
  733. else:
  734. # 如果今天已经点亮过,则直接返回当前亲密度,不再增加
  735. if intimacy.last_light_up_date == today_str:
  736. await session.commit()
  737. await session.refresh(intimacy)
  738. return {"heart_count": intimacy.heart_count}
  739. # 今日尚未点亮:不满 3 颗心时才增加一颗,并更新 last_light_up_date
  740. if intimacy.heart_count < 3:
  741. intimacy.heart_count = min(3, intimacy.heart_count + 1)
  742. intimacy.last_light_up_date = today_str
  743. await session.commit()
  744. await session.refresh(intimacy)
  745. return {"heart_count": intimacy.heart_count}
  746. except Exception as e: # noqa: BLE001
  747. logger.error(f"light_up_intimacy 失败: user_id={user_id}, error={e}")
  748. return {"heart_count": 0}
  749. async def get_intimacy_status(self, user_id: str) -> Dict[str, Any]:
  750. """
  751. 获取用户当天是否可以点亮亲密度。
  752. 返回:
  753. {
  754. "heart_count": int, # 当前心数 0-3
  755. "can_light_up_today": bool, # 今天是否还能点亮一次
  756. }
  757. """
  758. today_str = date.today().isoformat()[:10]
  759. try:
  760. async with db_manager.get_session() as session:
  761. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  762. intimacy = result.scalar_one_or_none()
  763. if intimacy is None:
  764. # 没有记录时视为 3 颗心、今日尚未点亮(但前端通常会基于 heart_count<3 决定是否展示弹窗)
  765. intimacy = UserIntimacy(user_id=user_id, heart_count=3)
  766. session.add(intimacy)
  767. await session.flush()
  768. await session.commit()
  769. await session.refresh(intimacy)
  770. can_light_up_today = intimacy.heart_count < 3 and intimacy.last_light_up_date != today_str
  771. return {
  772. "heart_count": intimacy.heart_count,
  773. "can_light_up_today": can_light_up_today,
  774. }
  775. except Exception as e: # noqa: BLE001
  776. logger.error(f"get_intimacy_status 失败: user_id={user_id}, error={e}")
  777. # 兜底:认为不能点亮,避免无限点亮
  778. return {
  779. "heart_count": 3,
  780. "can_light_up_today": False,
  781. }
  782. async def register_with_phone(
  783. self,
  784. phone: str,
  785. username: str = "",
  786. user_id: Optional[str] = None,
  787. area_code: Optional[str] = None,
  788. preferred_language: Optional[str] = None,
  789. ) -> Dict:
  790. """
  791. 使用手机号注册
  792. Args:
  793. phone: 手机号
  794. username: 用户名(可选)
  795. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  796. Returns:
  797. 用户信息字典
  798. """
  799. try:
  800. lang = preferred_language if preferred_language in ("zh", "en") else None
  801. async with db_manager.get_session() as session:
  802. # 检查手机号是否已被其他用户注册
  803. result = await session.execute(
  804. select(User).where(User.phone == phone)
  805. )
  806. existing_phone_user = result.scalar_one_or_none()
  807. if existing_phone_user:
  808. # 如果提供了 user_id,检查是否是同一个用户
  809. if user_id and existing_phone_user.user_id == user_id:
  810. # 同一个用户,可能是重新注册,允许继续
  811. pass
  812. else:
  813. raise AuthError(PHONE_ALREADY_REGISTERED)
  814. # 如果提供了 user_id,使用现有用户
  815. final_user_id = None
  816. if user_id:
  817. result = await session.execute(
  818. select(User).where(User.user_id == user_id)
  819. )
  820. existing_user = result.scalar_one_or_none()
  821. if not existing_user:
  822. raise AuthError(USER_ID_NOT_FOUND)
  823. if existing_user.user_type == "registered":
  824. raise AuthError(ALREADY_REGISTERED)
  825. # 更新现有用户
  826. if not username or username.strip() == "":
  827. # 如果用户已有用户名,保留;否则生成新用户名
  828. if not existing_user.username or existing_user.username.strip() == "":
  829. username = await self._generate_temp_username(session)
  830. else:
  831. username = existing_user.username
  832. existing_user.phone = phone
  833. if area_code is not None:
  834. existing_user.area_code = area_code
  835. existing_user.username = username
  836. existing_user.user_type = "registered"
  837. existing_user.is_verified = True
  838. existing_user.registered_at = datetime.now()
  839. if lang is not None:
  840. existing_user.preferred_language = lang
  841. user = existing_user
  842. final_user_id = user_id
  843. await session.flush()
  844. # 如果用户还没有背景图片,初始化背景图片
  845. if not existing_user.current_background_image_id or existing_user.current_background_image_id == "default":
  846. await self._init_user_background_image(session, user_id)
  847. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  848. await self._init_user_avatar(session, user_id, user_type="registered")
  849. await self.ensure_user_intimacy(session, user_id, heart_count=3)
  850. await session.commit()
  851. await session.refresh(user)
  852. else:
  853. # 创建新用户(原有逻辑)
  854. new_user_id = str(uuid.uuid4())
  855. if not username or username.strip() == "":
  856. username = await self._generate_temp_username(session)
  857. user = User(
  858. user_id=new_user_id,
  859. username=username,
  860. phone=phone,
  861. area_code=area_code,
  862. user_type="registered",
  863. is_verified=True,
  864. registered_at=datetime.now(),
  865. current_background_image_id="default", # 设置默认背景图片
  866. preferred_language=lang,
  867. )
  868. session.add(user)
  869. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  870. final_user_id = new_user_id
  871. # 初始化背景图片
  872. await self._init_user_background_image(session, new_user_id)
  873. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  874. await self._init_user_avatar(session, new_user_id, user_type="registered")
  875. await self.ensure_user_intimacy(session, new_user_id, heart_count=3)
  876. await session.commit()
  877. await session.refresh(user)
  878. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  879. final_user_id = final_user_id if final_user_id else user.user_id
  880. await self.upsert_user_ui_state(
  881. user_id=final_user_id,
  882. current_avatar_id=REGISTERED_AVATAR_ID
  883. )
  884. logger.info(f"✅ 手机号注册成功: user_id={final_user_id}, area_code={area_code}, phone={phone}")
  885. return user.to_dict()
  886. except Exception as e:
  887. logger.error(f"❌ 手机号注册失败: {e}")
  888. raise
  889. async def register_with_email(
  890. self,
  891. email: str,
  892. username: str = "",
  893. user_id: Optional[str] = None,
  894. preferred_language: Optional[str] = None,
  895. ) -> Dict:
  896. """
  897. 使用邮箱注册
  898. Args:
  899. email: 邮箱
  900. username: 用户名(可选)
  901. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  902. Returns:
  903. 用户信息字典
  904. """
  905. try:
  906. lang = preferred_language if preferred_language in ("zh", "en") else None
  907. async with db_manager.get_session() as session:
  908. # 检查邮箱是否已被其他用户注册
  909. result = await session.execute(
  910. select(User).where(User.email == email)
  911. )
  912. existing_email_user = result.scalar_one_or_none()
  913. if existing_email_user:
  914. # 如果提供了 user_id,检查是否是同一个用户
  915. if user_id and existing_email_user.user_id == user_id:
  916. # 同一个用户,可能是重新注册,允许继续
  917. pass
  918. else:
  919. raise AuthError(EMAIL_ALREADY_REGISTERED)
  920. # 如果提供了 user_id,使用现有用户
  921. final_user_id = None
  922. if user_id:
  923. result = await session.execute(
  924. select(User).where(User.user_id == user_id)
  925. )
  926. existing_user = result.scalar_one_or_none()
  927. if not existing_user:
  928. raise AuthError(USER_ID_NOT_FOUND)
  929. if existing_user.user_type == "registered":
  930. raise AuthError(ALREADY_REGISTERED)
  931. # 更新现有用户
  932. if not username or username.strip() == "":
  933. # 如果用户已有用户名,保留;否则生成新用户名
  934. if not existing_user.username or existing_user.username.strip() == "":
  935. username = await self._generate_temp_username(session)
  936. else:
  937. username = existing_user.username
  938. existing_user.email = email
  939. existing_user.username = username
  940. existing_user.user_type = "registered"
  941. existing_user.is_verified = True
  942. existing_user.registered_at = datetime.now()
  943. if lang is not None:
  944. existing_user.preferred_language = lang
  945. user = existing_user
  946. final_user_id = user_id
  947. await session.flush()
  948. # 如果用户还没有背景图片,初始化背景图片
  949. if not existing_user.current_background_image_id or existing_user.current_background_image_id == "default":
  950. await self._init_user_background_image(session, user_id)
  951. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  952. await self._init_user_avatar(session, user_id, user_type="registered")
  953. await self.ensure_user_intimacy(session, user_id, heart_count=3)
  954. await session.commit()
  955. await session.refresh(user)
  956. else:
  957. # 创建新用户(原有逻辑)
  958. new_user_id = str(uuid.uuid4())
  959. if not username or username.strip() == "":
  960. username = await self._generate_temp_username(session)
  961. user = User(
  962. user_id=new_user_id,
  963. username=username,
  964. email=email,
  965. user_type="registered",
  966. is_verified=True,
  967. registered_at=datetime.now(),
  968. current_background_image_id="default", # 设置默认背景图片
  969. preferred_language=lang,
  970. )
  971. session.add(user)
  972. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  973. final_user_id = new_user_id
  974. # 初始化背景图片
  975. await self._init_user_background_image(session, new_user_id)
  976. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  977. await self._init_user_avatar(session, new_user_id, user_type="registered")
  978. await self.ensure_user_intimacy(session, new_user_id, heart_count=3)
  979. await session.commit()
  980. await session.refresh(user)
  981. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  982. final_user_id = final_user_id if final_user_id else user.user_id
  983. await self.upsert_user_ui_state(
  984. user_id=final_user_id,
  985. current_avatar_id=REGISTERED_AVATAR_ID
  986. )
  987. logger.info(f"✅ 邮箱注册成功: user_id={final_user_id}, email={email}")
  988. return user.to_dict()
  989. except Exception as e:
  990. logger.error(f"❌ 邮箱注册失败: {e}")
  991. raise
  992. async def login_with_wechat(self, openid: str, username: str = "") -> Dict:
  993. """
  994. 使用微信OpenID登录/注册
  995. Args:
  996. openid: 微信OpenID
  997. username: 用户名(可选,仅新用户时使用)
  998. Returns:
  999. 用户信息字典
  1000. """
  1001. try:
  1002. async with db_manager.get_session() as session:
  1003. # 检查是否已存在
  1004. result = await session.execute(
  1005. select(User).where(User.wechat_openid == openid)
  1006. )
  1007. existing_user = result.scalar_one_or_none()
  1008. if existing_user:
  1009. # 已存在,直接返回
  1010. logger.info(f"✅ 微信登录成功: user_id={existing_user.user_id}, openid={openid}")
  1011. return existing_user.to_dict()
  1012. # 新用户,创建账号
  1013. user_id = str(uuid.uuid4())
  1014. if not username or username.strip() == "":
  1015. username = await self._generate_temp_username(session)
  1016. user = User(
  1017. user_id=user_id,
  1018. username=username,
  1019. wechat_openid=openid,
  1020. user_type="registered",
  1021. is_verified=True,
  1022. registered_at=datetime.now(),
  1023. current_background_image_id="default", # 设置默认背景图片
  1024. )
  1025. session.add(user)
  1026. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  1027. # 初始化背景图片
  1028. await self._init_user_background_image(session, user_id)
  1029. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1030. await self._init_user_avatar(session, user_id, user_type="registered")
  1031. await session.commit()
  1032. await session.refresh(user)
  1033. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  1034. await self.upsert_user_ui_state(
  1035. user_id=user_id,
  1036. current_avatar_id=REGISTERED_AVATAR_ID
  1037. )
  1038. logger.info(f"✅ 微信注册成功: user_id={user_id}, openid={openid}")
  1039. return user.to_dict()
  1040. except Exception as e:
  1041. logger.error(f"❌ 微信登录/注册失败: {e}")
  1042. raise
  1043. async def login_with_qq(
  1044. self,
  1045. openid: str,
  1046. username: str = "",
  1047. unionid: Optional[str] = None,
  1048. user_id: Optional[str] = None,
  1049. ) -> Dict:
  1050. """
  1051. 使用QQ OpenID登录/注册
  1052. Args:
  1053. openid: QQ OpenID
  1054. username: 用户名(可选,仅新用户时使用)
  1055. unionid: QQ UnionID(可选)
  1056. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  1057. Returns:
  1058. 用户信息字典
  1059. """
  1060. try:
  1061. async with db_manager.get_session() as session:
  1062. # 优先用 openid 查询
  1063. result = await session.execute(
  1064. select(User).where(User.qq_openid == openid)
  1065. )
  1066. existing_user = result.scalar_one_or_none()
  1067. # 如果未命中且拿到了 unionid,则尝试用 unionid 查询
  1068. if not existing_user and unionid:
  1069. union_result = await session.execute(
  1070. select(User).where(User.qq_unionid == unionid)
  1071. )
  1072. existing_user = union_result.scalar_one_or_none()
  1073. # 若命中用户还未绑定 openid,则补充绑定
  1074. if existing_user and not existing_user.qq_openid:
  1075. existing_user.qq_openid = openid
  1076. session.add(existing_user)
  1077. await session.commit()
  1078. await session.refresh(existing_user)
  1079. logger.info(
  1080. f"🔁 已根据 unionid={unionid} 绑定新的 QQ openid={openid}"
  1081. )
  1082. if existing_user:
  1083. # 如果提供了 user_id,检查是否是同一个用户
  1084. if user_id and existing_user.user_id != user_id:
  1085. raise AuthError(QQ_ALREADY_BOUND)
  1086. updated = False
  1087. if unionid and not existing_user.qq_unionid:
  1088. existing_user.qq_unionid = unionid
  1089. updated = True
  1090. if updated:
  1091. session.add(existing_user)
  1092. await session.commit()
  1093. await session.refresh(existing_user)
  1094. logger.info(
  1095. f"✅ QQ登录成功: user_id={existing_user.user_id}, "
  1096. f"openid={openid}, unionid={unionid}"
  1097. )
  1098. return existing_user.to_dict()
  1099. # 如果提供了 user_id,使用现有用户
  1100. final_user_id = None
  1101. if user_id:
  1102. result = await session.execute(
  1103. select(User).where(User.user_id == user_id)
  1104. )
  1105. existing_user_by_id = result.scalar_one_or_none()
  1106. if not existing_user_by_id:
  1107. raise AuthError(USER_ID_NOT_FOUND)
  1108. if existing_user_by_id.user_type == "registered":
  1109. raise AuthError(ALREADY_REGISTERED)
  1110. # 更新现有用户
  1111. if not username or username.strip() == "":
  1112. # 如果用户已有用户名,保留;否则生成新用户名
  1113. if not existing_user_by_id.username or existing_user_by_id.username.strip() == "":
  1114. username = await self._generate_temp_username(session)
  1115. else:
  1116. username = existing_user_by_id.username
  1117. existing_user_by_id.qq_openid = openid
  1118. if unionid:
  1119. existing_user_by_id.qq_unionid = unionid
  1120. existing_user_by_id.username = username
  1121. existing_user_by_id.user_type = "registered"
  1122. existing_user_by_id.is_verified = True
  1123. existing_user_by_id.registered_at = datetime.now()
  1124. user = existing_user_by_id
  1125. final_user_id = user_id
  1126. await session.flush()
  1127. # 如果用户还没有背景图片,初始化背景图片
  1128. if not existing_user_by_id.current_background_image_id or existing_user_by_id.current_background_image_id == "default":
  1129. await self._init_user_background_image(session, user_id)
  1130. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1131. await self._init_user_avatar(session, user_id, user_type="registered")
  1132. await session.commit()
  1133. await session.refresh(user)
  1134. else:
  1135. # 新用户,创建账号
  1136. new_user_id = str(uuid.uuid4())
  1137. if not username or username.strip() == "":
  1138. username = await self._generate_temp_username(session)
  1139. user = User(
  1140. user_id=new_user_id,
  1141. username=username,
  1142. qq_openid=openid,
  1143. qq_unionid=unionid,
  1144. user_type="registered",
  1145. is_verified=True,
  1146. registered_at=datetime.now(),
  1147. current_background_image_id="default", # 设置默认背景图片
  1148. )
  1149. session.add(user)
  1150. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  1151. final_user_id = new_user_id
  1152. # 初始化背景图片
  1153. await self._init_user_background_image(session, new_user_id)
  1154. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1155. await self._init_user_avatar(session, new_user_id, user_type="registered")
  1156. await session.commit()
  1157. await session.refresh(user)
  1158. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  1159. final_user_id = final_user_id if final_user_id else user.user_id
  1160. await self.upsert_user_ui_state(
  1161. user_id=final_user_id,
  1162. current_avatar_id=REGISTERED_AVATAR_ID
  1163. )
  1164. logger.info(
  1165. f"✅ QQ注册成功: user_id={final_user_id}, openid={openid}, unionid={unionid}"
  1166. )
  1167. return user.to_dict()
  1168. except Exception as e:
  1169. logger.error(f"❌ QQ登录/注册失败: {e}")
  1170. raise
  1171. async def login_with_phone(
  1172. self,
  1173. phone: str,
  1174. area_code: Optional[str] = None,
  1175. preferred_language: Optional[str] = None,
  1176. ) -> Optional[Dict]:
  1177. """
  1178. 使用手机号登录
  1179. Args:
  1180. phone: 手机号
  1181. Returns:
  1182. 用户信息字典,如果不存在返回None
  1183. """
  1184. try:
  1185. lang = preferred_language if preferred_language in ("zh", "en") else None
  1186. async with db_manager.get_session() as session:
  1187. result = await session.execute(
  1188. select(User).where(
  1189. User.phone == phone,
  1190. *( [User.area_code == area_code] if area_code else [] )
  1191. )
  1192. )
  1193. user = result.scalar_one_or_none()
  1194. # 兼容旧数据:未存区号时再用空区号回退查询
  1195. if not user and area_code:
  1196. fallback_result = await session.execute(
  1197. select(User).where(User.phone == phone, User.area_code.is_(None))
  1198. )
  1199. user = fallback_result.scalar_one_or_none()
  1200. if user:
  1201. if lang is not None:
  1202. try:
  1203. async with db_manager.get_session() as session:
  1204. await session.execute(
  1205. update(User)
  1206. .where(User.user_id == user.user_id)
  1207. .values(preferred_language=lang)
  1208. )
  1209. await session.commit()
  1210. except Exception as update_error: # noqa: BLE001
  1211. logger.warning(
  1212. f"⚠️ 更新用户首选语言失败: user_id={user.user_id}, lang={lang}, error={update_error}"
  1213. )
  1214. logger.info(f"✅ 手机号登录成功: user_id={user.user_id}, area_code={area_code}, phone={phone}")
  1215. return user.to_dict()
  1216. return None
  1217. except Exception as e:
  1218. logger.error(f"❌ 手机号登录失败: {e}")
  1219. return None
  1220. async def login_with_email(self, email: str, preferred_language: Optional[str] = None) -> Optional[Dict]:
  1221. """
  1222. 使用邮箱登录
  1223. Args:
  1224. email: 邮箱
  1225. Returns:
  1226. 用户信息字典,如果不存在返回None
  1227. """
  1228. try:
  1229. lang = preferred_language if preferred_language in ("zh", "en") else None
  1230. async with db_manager.get_session() as session:
  1231. result = await session.execute(
  1232. select(User).where(User.email == email)
  1233. )
  1234. user = result.scalar_one_or_none()
  1235. if user:
  1236. if lang is not None:
  1237. try:
  1238. async with db_manager.get_session() as session:
  1239. await session.execute(
  1240. update(User)
  1241. .where(User.user_id == user.user_id)
  1242. .values(preferred_language=lang)
  1243. )
  1244. await session.commit()
  1245. except Exception as update_error: # noqa: BLE001
  1246. logger.warning(
  1247. f"⚠️ 更新用户首选语言失败: user_id={user.user_id}, lang={lang}, error={update_error}"
  1248. )
  1249. logger.info(f"✅ 邮箱登录成功: user_id={user.user_id}, email={email}")
  1250. return user.to_dict()
  1251. return None
  1252. except Exception as e:
  1253. logger.error(f"❌ 邮箱登录失败: {e}")
  1254. return None
  1255. async def verify_and_auth(
  1256. self,
  1257. auth_type: str,
  1258. code: str,
  1259. email: Optional[str] = None,
  1260. phone: Optional[str] = None,
  1261. area_code: Optional[str] = None,
  1262. preferred_language: Optional[str] = None,
  1263. ) -> Tuple[Dict, bool]:
  1264. """
  1265. 验证码校验通过后统一登录或注册(V2.0)。
  1266. auth_type: "email" | "phone"
  1267. 返回 (user_dict, is_new_user)
  1268. """
  1269. if auth_type == "email":
  1270. send_type = "email"
  1271. else:
  1272. send_type = "international_sms" if (area_code and area_code.strip() != "+86") else "domestic_sms"
  1273. if auth_type == "email":
  1274. if not email:
  1275. raise AuthError(EMAIL_REQUIRED)
  1276. else:
  1277. if not phone:
  1278. raise AuthError(PHONE_REQUIRED)
  1279. if not code or not code.strip():
  1280. raise AuthError(CODE_REQUIRED)
  1281. async with db_manager.get_session() as session:
  1282. if auth_type == "email":
  1283. result = await session.execute(
  1284. select(VerificationCode)
  1285. .where(
  1286. VerificationCode.email == email,
  1287. VerificationCode.send_type == send_type,
  1288. VerificationCode.code == code.strip(),
  1289. VerificationCode.is_valid == True,
  1290. )
  1291. .order_by(VerificationCode.created_at.desc())
  1292. .limit(1)
  1293. )
  1294. else:
  1295. result = await session.execute(
  1296. select(VerificationCode)
  1297. .where(
  1298. VerificationCode.phone == phone,
  1299. VerificationCode.send_type == send_type,
  1300. VerificationCode.code == code.strip(),
  1301. VerificationCode.is_valid == True,
  1302. )
  1303. .order_by(VerificationCode.created_at.desc())
  1304. .limit(1)
  1305. )
  1306. verification_code = result.scalar_one_or_none()
  1307. if not verification_code:
  1308. logger.warning(f"❌ 验证码无效: type={auth_type}, email={email}, phone={phone}")
  1309. raise AuthError(CODE_INVALID)
  1310. verification_code.is_valid = False
  1311. await session.commit()
  1312. if auth_type == "email":
  1313. user = await self.login_with_email(email, preferred_language=preferred_language)
  1314. if user:
  1315. logger.info(f"✅ verify_and_auth 邮箱登录: user_id={user['user_id']}")
  1316. return (user, False)
  1317. user = await self.register_with_email(email, preferred_language=preferred_language)
  1318. logger.info(f"✅ verify_and_auth 邮箱注册: user_id={user['user_id']}")
  1319. return (user, True)
  1320. else:
  1321. user = await self.login_with_phone(phone, area_code, preferred_language=preferred_language)
  1322. if user:
  1323. logger.info(f"✅ verify_and_auth 手机登录: user_id={user['user_id']}")
  1324. return (user, False)
  1325. user = await self.register_with_phone(phone, area_code=area_code, preferred_language=preferred_language)
  1326. logger.info(f"✅ verify_and_auth 手机注册: user_id={user['user_id']}")
  1327. return (user, True)
  1328. async def get_community_choice(self, user_id: str) -> Optional[Dict]:
  1329. """获取用户「退出早期共创社区」选项(V2.0),用于登录页回显"""
  1330. async with db_manager.get_session() as session:
  1331. result = await session.execute(
  1332. select(UserCommunityChoice).where(UserCommunityChoice.user_id == user_id)
  1333. )
  1334. row = result.scalar_one_or_none()
  1335. return row.to_dict() if row else None
  1336. async def upsert_community_choice(self, user_id: str, opted_out: bool) -> None:
  1337. """更新用户「退出早期共创社区」选项(V2.0)"""
  1338. async with db_manager.get_session() as session:
  1339. result = await session.execute(
  1340. select(UserCommunityChoice).where(UserCommunityChoice.user_id == user_id)
  1341. )
  1342. row = result.scalar_one_or_none()
  1343. if row:
  1344. row.opted_out = opted_out
  1345. else:
  1346. session.add(UserCommunityChoice(user_id=user_id, opted_out=opted_out))
  1347. await session.commit()
  1348. async def get_early_community(self, user_id: str) -> Optional[Dict]:
  1349. """获取用户早期共创状态(V2.0)"""
  1350. async with db_manager.get_session() as session:
  1351. result = await session.execute(
  1352. select(UserEarlyCommunity).where(UserEarlyCommunity.user_id == user_id)
  1353. )
  1354. row = result.scalar_one_or_none()
  1355. return row.to_dict() if row else None
  1356. async def submit_early_community(self, user_id: str, email: Optional[str] = None) -> Dict:
  1357. """提交早期共创:标记弹窗已展示;若传 email 则写入并标记已加入(V2.0)"""
  1358. async with db_manager.get_session() as session:
  1359. result = await session.execute(
  1360. select(UserEarlyCommunity).where(UserEarlyCommunity.user_id == user_id)
  1361. )
  1362. row = result.scalar_one_or_none()
  1363. now = datetime.now()
  1364. if row:
  1365. row.popup_shown = True
  1366. if email and email.strip():
  1367. row.email = email.strip()
  1368. row.joined_at = now
  1369. else:
  1370. session.add(
  1371. UserEarlyCommunity(
  1372. user_id=user_id,
  1373. email=email.strip() if email and email.strip() else None,
  1374. popup_shown=True,
  1375. joined_at=now if (email and email.strip()) else None,
  1376. )
  1377. )
  1378. await session.commit()
  1379. result2 = await session.execute(
  1380. select(UserEarlyCommunity).where(UserEarlyCommunity.user_id == user_id)
  1381. )
  1382. r = result2.scalar_one_or_none()
  1383. return r.to_dict() if r else {}
  1384. async def check_user_exists_by_qq_openid(self, openid: str, unionid: Optional[str] = None) -> bool:
  1385. """
  1386. 检查QQ OpenID对应的用户是否存在
  1387. Args:
  1388. openid: QQ OpenID
  1389. unionid: QQ UnionID(可选)
  1390. Returns:
  1391. 用户是否存在
  1392. """
  1393. try:
  1394. async with db_manager.get_session() as session:
  1395. # 优先用 openid 查询
  1396. result = await session.execute(
  1397. select(User).where(User.qq_openid == openid)
  1398. )
  1399. existing_user = result.scalar_one_or_none()
  1400. 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}")
  1401. # 如果未命中且拿到了 unionid,则尝试用 unionid 查询
  1402. if not existing_user and unionid:
  1403. union_result = await session.execute(
  1404. select(User).where(User.qq_unionid == unionid)
  1405. )
  1406. existing_user = union_result.scalar_one_or_none()
  1407. 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}")
  1408. exists = existing_user is not None
  1409. logger.info(f"🔍 [检查QQ用户] 最终结果 - openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}..., exists={exists}")
  1410. return exists
  1411. except Exception as e:
  1412. logger.error(f"❌ 检查QQ用户是否存在失败: {e}")
  1413. return False
  1414. # 全局用户服务实例
  1415. user_service = UserService()