user_service.py 87 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021
  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, text
  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. from shop_item_image_utils import scene_image_name_from_shop_row
  39. logger = logging.getLogger(__name__)
  40. class AuthError(Exception):
  41. """业务校验错误,带错误码供 API 层返回 detail={"code": "XXX"}"""
  42. def __init__(self, code: str):
  43. self.code = code
  44. super().__init__(code)
  45. # 默认头像 ID
  46. DEFAULT_AVATAR_ID = "avatar_default"
  47. REGISTERED_AVATAR_ID = "avatar_registered_cap"
  48. LEVEL_THRESHOLDS_MS: Tuple[Tuple[int, int], ...] = (
  49. (5, 240_000),
  50. (4, 120_000),
  51. (3, 60_000),
  52. (2, 30_000),
  53. (1, 10_000),
  54. )
  55. class UserService:
  56. """用户管理服务"""
  57. @staticmethod
  58. def _calculate_user_level(total_ms: int) -> int:
  59. """
  60. 根据累计说话时长计算用户等级
  61. """
  62. for level, threshold in LEVEL_THRESHOLDS_MS:
  63. if total_ms >= threshold:
  64. return level
  65. return 0
  66. async def _init_user_background_image(self, session: AsyncSession, user_id: str) -> None:
  67. """
  68. 初始化用户背景图片(添加到已获得列表)
  69. Args:
  70. session: 数据库会话
  71. user_id: 用户ID
  72. """
  73. try:
  74. default_image_id = "default"
  75. # 检查是否已存在,避免重复添加
  76. result = await session.execute(
  77. select(UserBackgroundImage).where(
  78. UserBackgroundImage.user_id == user_id,
  79. UserBackgroundImage.image_id == default_image_id
  80. )
  81. )
  82. existing = result.scalar_one_or_none()
  83. if not existing:
  84. user_bg_image = UserBackgroundImage(
  85. user_id=user_id,
  86. image_id=default_image_id,
  87. obtained_type="default",
  88. obtained_source="system_init"
  89. )
  90. session.add(user_bg_image)
  91. logger.debug(f"✅ 为用户初始化背景图片: user_id={user_id}, image_id={default_image_id}")
  92. else:
  93. logger.debug(f"⏭️ 用户已拥有默认背景图片: user_id={user_id}")
  94. except Exception as e:
  95. # 背景图片初始化失败不应该影响用户创建,只记录日志
  96. logger.warning(f"⚠️ 初始化用户背景图片失败: user_id={user_id}, error={e}")
  97. async def _init_user_avatar(self, session: AsyncSession, user_id: str, user_type: str = "guest") -> None:
  98. """
  99. 初始化用户头像(添加到已获得列表)
  100. Args:
  101. session: 数据库会话
  102. user_id: 用户ID
  103. user_type: 用户类型(guest 或 registered)
  104. """
  105. try:
  106. # 游客用户只分配默认头像
  107. # 注册用户同时分配默认头像和注册赠送头像
  108. avatars_to_add = []
  109. if user_type == "registered":
  110. # 注册用户:默认头像 + 注册赠送头像
  111. avatars_to_add = [
  112. (DEFAULT_AVATAR_ID, "default", "system_init"),
  113. (REGISTERED_AVATAR_ID, "register", "register_bonus"),
  114. ]
  115. else:
  116. # 游客用户:只分配默认头像
  117. avatars_to_add = [
  118. (DEFAULT_AVATAR_ID, "default", "system_init"),
  119. ]
  120. for avatar_id, obtained_type, obtained_source in avatars_to_add:
  121. # 检查是否已存在,避免重复添加
  122. result = await session.execute(
  123. select(UserAvatar).where(
  124. UserAvatar.user_id == user_id,
  125. UserAvatar.avatar_id == avatar_id
  126. )
  127. )
  128. existing = result.scalar_one_or_none()
  129. if not existing:
  130. user_avatar = UserAvatar(
  131. user_id=user_id,
  132. avatar_id=avatar_id,
  133. obtained_type=obtained_type,
  134. obtained_source=obtained_source
  135. )
  136. session.add(user_avatar)
  137. logger.debug(f"✅ 为用户初始化头像: user_id={user_id}, avatar_id={avatar_id}, user_type={user_type}")
  138. else:
  139. logger.debug(f"⏭️ 用户已拥有头像: user_id={user_id}, avatar_id={avatar_id}")
  140. except Exception as e:
  141. # 头像初始化失败不应该影响用户创建,只记录日志
  142. logger.warning(f"⚠️ 初始化用户头像失败: user_id={user_id}, error={e}")
  143. async def _mysql_table_exists(self, session: AsyncSession, table_name: str) -> bool:
  144. try:
  145. r = await session.execute(
  146. text(
  147. """
  148. SELECT COUNT(*) AS c
  149. FROM information_schema.tables
  150. WHERE table_schema = :db AND table_name = :table
  151. """
  152. ),
  153. {"db": settings.MYSQL_DATABASE, "table": table_name},
  154. )
  155. row = r.mappings().first()
  156. return bool(row and int(row.get("c") or 0) > 0)
  157. except Exception:
  158. return False
  159. async def _mysql_column_data_type(
  160. self, session: AsyncSession, table_name: str, column_name: str
  161. ) -> str:
  162. try:
  163. r = await session.execute(
  164. text(
  165. """
  166. SELECT DATA_TYPE
  167. FROM information_schema.columns
  168. WHERE table_schema = :db
  169. AND table_name = :table
  170. AND column_name = :column
  171. """
  172. ),
  173. {"db": settings.MYSQL_DATABASE, "table": table_name, "column": column_name},
  174. )
  175. row = r.mappings().first()
  176. return str((row or {}).get("DATA_TYPE") or "")
  177. except Exception:
  178. return ""
  179. async def _chatbot_mysql_user_id_value(
  180. self, session: AsyncSession, table_name: str, user_id_uuid: str
  181. ) -> str | int | None:
  182. dt = await self._mysql_column_data_type(session, table_name, "user_id")
  183. if not dt:
  184. return None
  185. if "bigint" in dt.lower():
  186. r = await session.execute(select(User.id).where(User.user_id == user_id_uuid))
  187. rid = r.scalar_one_or_none()
  188. return int(rid) if rid is not None else None
  189. return user_id_uuid
  190. async def _init_registered_user_chatbot_shop(self, session: AsyncSession, user_id_uuid: str) -> None:
  191. """
  192. 新注册用户:赠送小木桩/落地窗/清新绿幕布商品,并写入默认幕布与 5x4 场景布局。
  193. 表或商品不存在时跳过,不阻断注册。
  194. """
  195. required = (
  196. "shop_items",
  197. "user_assets",
  198. "chatbot_layouts",
  199. "user_chatbot_backdrops",
  200. )
  201. # 显式逐个 await,避免把 await 放进生成器表达式导致的潜在迭代异常
  202. for t in required:
  203. exists = await self._mysql_table_exists(session, t)
  204. if not exists:
  205. logger.warning(
  206. "⚠️ Chatbot 注册礼包:缺少必需表,跳过 user_id=%s missing_table=%s",
  207. user_id_uuid,
  208. t,
  209. )
  210. return
  211. gift_codes = ("stump_stool", "french_window", "fresh_green_wallpaper")
  212. used_qty_by_code: Dict[str, int] = {
  213. "stump_stool": 1,
  214. "french_window": 1,
  215. "fresh_green_wallpaper": 0,
  216. }
  217. try:
  218. ua_uid = await self._chatbot_mysql_user_id_value(session, "user_assets", user_id_uuid)
  219. layout_uid = await self._chatbot_mysql_user_id_value(session, "chatbot_layouts", user_id_uuid)
  220. backdrop_uid = await self._chatbot_mysql_user_id_value(
  221. session, "user_chatbot_backdrops", user_id_uuid
  222. )
  223. has_slot_placement_table = await self._mysql_table_exists(
  224. session, "user_chatbot_slot_placements"
  225. )
  226. slot_uid = (
  227. await self._chatbot_mysql_user_id_value(
  228. session, "user_chatbot_slot_placements", user_id_uuid
  229. )
  230. if has_slot_placement_table
  231. else None
  232. )
  233. if ua_uid is None or layout_uid is None or backdrop_uid is None:
  234. return
  235. codes_in = ", ".join([f"'{c}'" for c in gift_codes])
  236. shop_res = await session.execute(
  237. text(
  238. f"""
  239. SELECT id, item_code, image_name, render_image_name, default_z_index,
  240. size_rows, size_cols, placement_type
  241. FROM shop_items
  242. WHERE item_code IN ({codes_in}) AND is_active = 1
  243. """
  244. )
  245. )
  246. shop_rows = list(shop_res.mappings().all())
  247. if len(shop_rows) < len(gift_codes):
  248. logger.warning(
  249. "⚠️ Chatbot 注册礼包:shop_items 缺少商品,跳过。已有=%s",
  250. [r["item_code"] for r in shop_rows],
  251. )
  252. return
  253. by_code: Dict[str, Any] = {str(r["item_code"]): r for r in shop_rows}
  254. for code in gift_codes:
  255. row = by_code.get(code)
  256. if not row:
  257. continue
  258. item_id = int(row["id"])
  259. owned = 1
  260. used = used_qty_by_code.get(code, 0)
  261. chk = await session.execute(
  262. text(
  263. """
  264. SELECT id FROM user_assets
  265. WHERE user_id = :uid AND item_id = :iid
  266. LIMIT 1
  267. """
  268. ),
  269. {"uid": ua_uid, "iid": item_id},
  270. )
  271. if chk.scalar_one_or_none() is None:
  272. await session.execute(
  273. text(
  274. """
  275. INSERT INTO user_assets
  276. (user_id, item_id, owned_qty, used_qty, last_acquired_at, created_at, updated_at)
  277. VALUES
  278. (:uid, :iid, :owned, :used, NOW(), NOW(), NOW())
  279. """
  280. ),
  281. {"uid": ua_uid, "iid": item_id, "owned": owned, "used": used},
  282. )
  283. backdrop_row = by_code.get("fresh_green_wallpaper")
  284. stump_row = by_code.get("stump_stool")
  285. window_row = by_code.get("french_window")
  286. if not backdrop_row or not stump_row or not window_row:
  287. return
  288. backdrop_chk = await session.execute(
  289. text(
  290. """
  291. SELECT id FROM user_chatbot_backdrops
  292. WHERE user_id = :uid
  293. LIMIT 1
  294. """
  295. ),
  296. {"uid": backdrop_uid},
  297. )
  298. if backdrop_chk.scalar_one_or_none() is None:
  299. await session.execute(
  300. text(
  301. """
  302. INSERT INTO user_chatbot_backdrops
  303. (user_id, backdrop_item_id, z_index, created_at, updated_at)
  304. VALUES
  305. (:uid, :bid, 0, NOW(), NOW())
  306. """
  307. ),
  308. {"uid": backdrop_uid, "bid": int(backdrop_row["id"])},
  309. )
  310. layout_chk = await session.execute(
  311. text(
  312. """
  313. SELECT id FROM chatbot_layouts
  314. WHERE user_id = :uid
  315. LIMIT 1
  316. """
  317. ),
  318. {"uid": layout_uid},
  319. )
  320. layout_existing_id = layout_chk.scalar_one_or_none()
  321. layout_id = int(layout_existing_id) if layout_existing_id is not None else 0
  322. await session.execute(
  323. text(
  324. """
  325. UPDATE user_assets
  326. SET used_qty = 1, updated_at = NOW()
  327. WHERE user_id = :uid AND item_id IN (:i1, :i2)
  328. """
  329. ),
  330. {
  331. "uid": ua_uid,
  332. "i1": int(stump_row["id"]),
  333. "i2": int(window_row["id"]),
  334. },
  335. )
  336. if layout_id <= 0:
  337. await session.execute(
  338. text(
  339. """
  340. INSERT INTO chatbot_layouts
  341. (user_id, grid_rows, grid_cols, cell_width, cell_height,
  342. design_width, design_height, decor_area_width, decor_area_height, version,
  343. created_at, updated_at)
  344. VALUES
  345. (:uid, 5, 4, 96, 96, 393, 852, 393, 482, 1, NOW(), NOW())
  346. """
  347. ),
  348. {"uid": layout_uid},
  349. )
  350. lid_res = await session.execute(text("SELECT LAST_INSERT_ID() AS lid"))
  351. layout_id_row = lid_res.mappings().first()
  352. layout_id = int((layout_id_row or {}).get("lid") or 0)
  353. if layout_id <= 0:
  354. logger.warning("⚠️ Chatbot 注册礼包:无法取得 layout_id,跳过后续明细")
  355. return
  356. stump_item_id = int(stump_row["id"])
  357. window_item_id = int(window_row["id"])
  358. ua_stump = await session.execute(
  359. text(
  360. "SELECT id FROM user_assets WHERE user_id = :uid AND item_id = :iid LIMIT 1"
  361. ),
  362. {"uid": ua_uid, "iid": stump_item_id},
  363. )
  364. ua_window = await session.execute(
  365. text(
  366. "SELECT id FROM user_assets WHERE user_id = :uid AND item_id = :iid LIMIT 1"
  367. ),
  368. {"uid": ua_uid, "iid": window_item_id},
  369. )
  370. stump_ua_id = ua_stump.scalar_one_or_none()
  371. window_ua_id = ua_window.scalar_one_or_none()
  372. if stump_ua_id is None or window_ua_id is None:
  373. logger.warning("⚠️ Chatbot 注册礼包:缺少 user_assets 行,跳过布局明细")
  374. return
  375. if has_slot_placement_table and slot_uid is not None:
  376. backdrop_item_id = int(backdrop_row["id"])
  377. await session.execute(
  378. text(
  379. """
  380. INSERT INTO user_chatbot_slot_placements
  381. (user_id, backdrop_item_id, slot_code, user_asset_id, item_id, z_index, image_name_snapshot, placed_at, updated_at)
  382. VALUES
  383. (:uid, :bid, 'slot_04_row4_col2', :uaid, :iid, :z, :img, NOW(), NOW())
  384. ON DUPLICATE KEY UPDATE
  385. user_asset_id = VALUES(user_asset_id),
  386. item_id = VALUES(item_id),
  387. z_index = VALUES(z_index),
  388. image_name_snapshot = VALUES(image_name_snapshot),
  389. updated_at = NOW()
  390. """
  391. ),
  392. {
  393. "uid": slot_uid,
  394. "bid": backdrop_item_id,
  395. "uaid": int(stump_ua_id),
  396. "iid": stump_item_id,
  397. "z": int(stump_row["default_z_index"] or 0),
  398. "img": scene_image_name_from_shop_row(stump_row),
  399. },
  400. )
  401. await session.execute(
  402. text(
  403. """
  404. INSERT INTO user_chatbot_slot_placements
  405. (user_id, backdrop_item_id, slot_code, user_asset_id, item_id, z_index, image_name_snapshot, placed_at, updated_at)
  406. VALUES
  407. (:uid, :bid, 'slot_03_center_2x3', :uaid, :iid, :z, :img, NOW(), NOW())
  408. ON DUPLICATE KEY UPDATE
  409. user_asset_id = VALUES(user_asset_id),
  410. item_id = VALUES(item_id),
  411. z_index = VALUES(z_index),
  412. image_name_snapshot = VALUES(image_name_snapshot),
  413. updated_at = NOW()
  414. """
  415. ),
  416. {
  417. "uid": slot_uid,
  418. "bid": backdrop_item_id,
  419. "uaid": int(window_ua_id),
  420. "iid": window_item_id,
  421. "z": int(window_row["default_z_index"] or 0),
  422. "img": scene_image_name_from_shop_row(window_row),
  423. },
  424. )
  425. elif await self._mysql_table_exists(session, "chatbot_layout_items"):
  426. # 旧模型兼容:仅在新槽位表不存在时写入旧布局明细
  427. legacy_stump_exists = await session.execute(
  428. text(
  429. """
  430. SELECT id FROM chatbot_layout_items
  431. WHERE layout_id = :lid AND item_id = :iid
  432. LIMIT 1
  433. """
  434. ),
  435. {"lid": layout_id, "iid": stump_item_id},
  436. )
  437. if legacy_stump_exists.scalar_one_or_none() is None:
  438. await session.execute(
  439. text(
  440. """
  441. INSERT INTO chatbot_layout_items
  442. (layout_id, user_asset_id, item_id, placement_type,
  443. start_row, start_col, size_rows, size_cols, z_index, image_name_snapshot, created_at)
  444. VALUES
  445. (:lid, :uaid, :iid, 'floor', 4, 2, 1, 1, :z, :img, NOW())
  446. """
  447. ),
  448. {
  449. "lid": layout_id,
  450. "uaid": int(stump_ua_id),
  451. "iid": stump_item_id,
  452. "z": int(stump_row["default_z_index"] or 0),
  453. "img": scene_image_name_from_shop_row(stump_row),
  454. },
  455. )
  456. legacy_window_exists = await session.execute(
  457. text(
  458. """
  459. SELECT id FROM chatbot_layout_items
  460. WHERE layout_id = :lid AND item_id = :iid
  461. LIMIT 1
  462. """
  463. ),
  464. {"lid": layout_id, "iid": window_item_id},
  465. )
  466. if legacy_window_exists.scalar_one_or_none() is None:
  467. await session.execute(
  468. text(
  469. """
  470. INSERT INTO chatbot_layout_items
  471. (layout_id, user_asset_id, item_id, placement_type,
  472. start_row, start_col, size_rows, size_cols, z_index, image_name_snapshot, created_at)
  473. VALUES
  474. (:lid, :uaid, :iid, 'wall', 2, 2, 2, 3, :z, :img, NOW())
  475. """
  476. ),
  477. {
  478. "lid": layout_id,
  479. "uaid": int(window_ua_id),
  480. "iid": window_item_id,
  481. "z": int(window_row["default_z_index"] or 0),
  482. "img": scene_image_name_from_shop_row(window_row),
  483. },
  484. )
  485. logger.debug(
  486. "✅ Chatbot 注册礼包:已写入幕布与默认布局 user_id=%s layout_id=%s",
  487. user_id_uuid,
  488. layout_id,
  489. )
  490. except Exception as e:
  491. logger.warning(
  492. "⚠️ 初始化 Chatbot 商店/布局失败: user_id=%s error=%s",
  493. user_id_uuid,
  494. e,
  495. )
  496. async def _generate_temp_username(self, session: AsyncSession) -> str:
  497. """
  498. 生成临时用户名(格式:User + 7位随机数字)
  499. 确保用户名不重复
  500. Args:
  501. session: 数据库会话
  502. Returns:
  503. 临时用户名
  504. """
  505. max_attempts = 100 # 最大重试次数,避免无限循环
  506. for attempt in range(max_attempts):
  507. # 生成7位随机数字(1000000-9999999)
  508. random_number = random.randint(1000000, 9999999)
  509. temp_username = f"User{random_number}"
  510. # 检查用户名是否已存在
  511. result = await session.execute(
  512. select(User).where(User.username == temp_username)
  513. )
  514. existing_user = result.scalar_one_or_none()
  515. if not existing_user:
  516. logger.debug(f"生成临时用户名: {temp_username} (尝试次数: {attempt + 1})")
  517. return temp_username
  518. # 如果100次都重复,使用时间戳的后7位作为备选方案
  519. # 使用当前时间戳的毫秒数取模,确保是7位数字
  520. for fallback_attempt in range(10): # 备选方案也最多尝试10次
  521. timestamp_suffix = int(datetime.now().timestamp() * 1000) % 10000000
  522. # 确保至少是7位数字(如果不足7位,前面补0)
  523. fallback_number = str(timestamp_suffix).zfill(7)
  524. temp_username = f"User{fallback_number}"
  525. # 再次检查是否重复
  526. result = await session.execute(
  527. select(User).where(User.username == temp_username)
  528. )
  529. existing_user = result.scalar_one_or_none()
  530. if not existing_user:
  531. logger.warning(f"⚠️ 临时用户名生成达到最大重试次数,使用备选方案: {temp_username}")
  532. return temp_username
  533. # 如果还是重复,等待1毫秒后重试(确保时间戳变化)
  534. await asyncio.sleep(0.001)
  535. # 最后的备选方案:使用UUID的十六进制转数字(取后7位)
  536. uuid_hex = uuid.uuid4().hex
  537. final_number = str(int(uuid_hex[:8], 16) % 10000000).zfill(7)
  538. temp_username = f"User{final_number}"
  539. logger.error(f"❌ 临时用户名生成异常,使用最终备选方案: {temp_username}")
  540. return temp_username
  541. async def create_guest_user(
  542. self,
  543. username: str = "",
  544. bot_name: Optional[str] = None,
  545. *,
  546. user_segment: str = "individual",
  547. org_id: Optional[str] = None,
  548. is_test_user: bool = False,
  549. region: Optional[str] = None,
  550. ) -> Dict:
  551. """
  552. 创建游客用户
  553. Args:
  554. username: 用户名(默认为空字符串,为空时自动生成临时用户名)
  555. bot_name: 用户给机器人取的名字(可选)
  556. Returns:
  557. 用户信息字典(包含user_id)
  558. """
  559. try:
  560. # 生成唯一用户ID
  561. user_id = str(uuid.uuid4())
  562. # 使用 ORM 创建用户
  563. async with db_manager.get_session() as session:
  564. # 如果用户名为空,生成临时用户名
  565. if not username or username.strip() == "":
  566. username = await self._generate_temp_username(session)
  567. logger.info(f"🔧 自动生成临时用户名: {username}")
  568. user = User(
  569. user_id=user_id,
  570. username=username,
  571. user_type="guest",
  572. user_segment=user_segment or "individual",
  573. org_id=org_id,
  574. is_test_user=is_test_user,
  575. region=region,
  576. bot_name=bot_name,
  577. current_background_image_id="default", # 设置默认背景图片
  578. )
  579. session.add(user)
  580. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  581. # 初始化背景图片
  582. await self._init_user_background_image(session, user_id)
  583. # 初始化默认头像
  584. await self._init_user_avatar(session, user_id, user_type="guest")
  585. await session.commit()
  586. await session.refresh(user)
  587. # 初始化 user_ui_states 的 current_avatar_id(游客用户使用默认头像)
  588. await self.upsert_user_ui_state(
  589. user_id=user_id,
  590. current_avatar_id=DEFAULT_AVATAR_ID
  591. )
  592. logger.info(f"✅ 创建游客用户成功: user_id={user_id}, username={username}, bot_name={bot_name}")
  593. return {
  594. "user_id": user_id,
  595. "username": username,
  596. "user_type": "guest",
  597. "bot_name": bot_name,
  598. "user_level": 0,
  599. "current_background_image_id": "default",
  600. "created_at": datetime.now().isoformat()
  601. }
  602. except Exception as e:
  603. logger.error(f"❌ 创建用户失败: {e}")
  604. raise
  605. async def get_user(self, user_id: str) -> Optional[Dict]:
  606. """
  607. 获取用户信息
  608. Args:
  609. user_id: 用户ID
  610. Returns:
  611. 用户信息字典,如果不存在返回None
  612. 包含 current_background_image_metadata 字段(背景图片的metadata配置)
  613. """
  614. try:
  615. async with db_manager.get_session() as session:
  616. result = await session.execute(
  617. select(User).where(User.user_id == user_id)
  618. )
  619. user = result.scalar_one_or_none()
  620. if user:
  621. user_dict = user.to_dict()
  622. # 如果用户有背景图片ID,查询背景图片的metadata
  623. if user.current_background_image_id:
  624. try:
  625. bg_result = await session.execute(
  626. select(BackgroundImage).where(
  627. BackgroundImage.image_id == user.current_background_image_id
  628. )
  629. )
  630. bg_image = bg_result.scalar_one_or_none()
  631. if bg_image and bg_image.metadata_json:
  632. user_dict["current_background_image_metadata"] = bg_image.metadata_json
  633. except Exception as e:
  634. logger.warning(f"⚠️ 获取背景图片metadata失败: image_id={user.current_background_image_id}, error={e}")
  635. return user_dict
  636. return None
  637. except Exception as e:
  638. logger.error(f"❌ 获取用户信息失败: user_id={user_id}, error={e}")
  639. return None
  640. async def update_user_last_active(self, user_id: str):
  641. """
  642. 更新用户最后活跃时间
  643. Args:
  644. user_id: 用户ID
  645. """
  646. try:
  647. async with db_manager.get_session() as session:
  648. await session.execute(
  649. update(User)
  650. .where(User.user_id == user_id)
  651. .values(last_active_at=func.now())
  652. )
  653. await session.commit()
  654. except Exception as e:
  655. logger.warning(f"⚠️ 更新用户活跃时间失败: user_id={user_id}, error={e}")
  656. async def update_username(self, user_id: str, username: str) -> bool:
  657. """
  658. 更新用户名
  659. Args:
  660. user_id: 用户ID
  661. username: 用户名
  662. Returns:
  663. 是否更新成功
  664. """
  665. try:
  666. async with db_manager.get_session() as session:
  667. result = await session.execute(
  668. update(User)
  669. .where(User.user_id == user_id)
  670. .values(username=username)
  671. )
  672. await session.commit()
  673. if result.rowcount > 0:
  674. logger.info(f"✅ 更新用户名成功: user_id={user_id}, username={username}")
  675. return True
  676. return False
  677. except Exception as e:
  678. logger.error(f"❌ 更新用户名失败: user_id={user_id}, error={e}")
  679. return False
  680. async def update_current_background_image_id(self, user_id: str, background_image_id: str) -> bool:
  681. """
  682. 更新用户当前使用的背景图片ID
  683. Args:
  684. user_id: 用户ID
  685. background_image_id: 背景图片ID
  686. Returns:
  687. 是否更新成功
  688. """
  689. try:
  690. async with db_manager.get_session() as session:
  691. result = await session.execute(
  692. update(User)
  693. .where(User.user_id == user_id)
  694. .values(current_background_image_id=background_image_id)
  695. )
  696. await session.commit()
  697. if result.rowcount > 0:
  698. logger.info(f"✅ 更新用户背景图片ID成功: user_id={user_id}, background_image_id={background_image_id}")
  699. return True
  700. return False
  701. except Exception as e:
  702. logger.error(f"❌ 更新用户背景图片ID失败: user_id={user_id}, error={e}")
  703. return False
  704. async def update_bot_name(self, user_id: str, bot_name: str) -> bool:
  705. """
  706. 更新用户给机器人取的名字
  707. Args:
  708. user_id: 用户ID
  709. bot_name: 机器人名字
  710. Returns:
  711. 是否更新成功
  712. """
  713. import time
  714. start_time = time.time()
  715. try:
  716. async with db_manager.get_session() as session:
  717. # 🔥 先查询当前值,避免不必要的更新
  718. query_start = time.time()
  719. result = await session.execute(
  720. select(User.bot_name).where(User.user_id == user_id)
  721. )
  722. current_bot_name = result.scalar_one_or_none()
  723. query_duration = (time.time() - query_start) * 1000
  724. # 如果值相同,直接返回(避免不必要的 UPDATE)
  725. if current_bot_name == bot_name:
  726. total_duration = (time.time() - start_time) * 1000
  727. logger.info(
  728. f"⏭️ 机器人名字未变化,跳过更新: user_id={user_id}, "
  729. f"bot_name={bot_name}, 耗时={total_duration:.1f}ms"
  730. )
  731. return True
  732. # 执行更新
  733. update_start = time.time()
  734. update_result = await session.execute(
  735. update(User)
  736. .where(User.user_id == user_id)
  737. .values(bot_name=bot_name)
  738. )
  739. await session.commit()
  740. update_duration = (time.time() - update_start) * 1000
  741. total_duration = (time.time() - start_time) * 1000
  742. if update_result.rowcount > 0:
  743. logger.info(
  744. f"✅ 更新机器人名字成功: user_id={user_id}, "
  745. f"bot_name={bot_name}, "
  746. f"查询耗时={query_duration:.1f}ms, "
  747. f"更新耗时={update_duration:.1f}ms, "
  748. f"总耗时={total_duration:.1f}ms"
  749. )
  750. return True
  751. else:
  752. logger.warning(
  753. f"⚠️ 更新机器人名字失败: 未找到用户 user_id={user_id}, "
  754. f"总耗时={total_duration:.1f}ms"
  755. )
  756. return False
  757. except asyncio.TimeoutError:
  758. total_duration = (time.time() - start_time) * 1000
  759. logger.error(
  760. f"❌ 更新机器人名字超时: user_id={user_id}, "
  761. f"耗时={total_duration:.1f}ms"
  762. )
  763. return False
  764. except Exception as e:
  765. total_duration = (time.time() - start_time) * 1000
  766. logger.error(
  767. f"❌ 更新机器人名字失败: user_id={user_id}, "
  768. f"error={e}, 耗时={total_duration:.1f}ms"
  769. )
  770. return False
  771. async def update_preferred_language(self, user_id: str, preferred_language: str) -> bool:
  772. """更新用户首选界面语言(zh/en)"""
  773. if preferred_language not in ("zh", "en"):
  774. return False
  775. try:
  776. async with db_manager.get_session() as session:
  777. result = await session.execute(
  778. update(User)
  779. .where(User.user_id == user_id)
  780. .values(preferred_language=preferred_language)
  781. )
  782. await session.commit()
  783. return result.rowcount > 0
  784. except Exception as e:
  785. logger.error(f"❌ 更新首选语言失败: user_id={user_id}, error={e}")
  786. return False
  787. async def update_speech_duration(self, user_id: str, duration_ms: int) -> Optional[Dict[str, int]]:
  788. """
  789. 更新用户累积说话时长
  790. Args:
  791. user_id: 用户ID
  792. duration_ms: 本次说话时长(毫秒)
  793. Returns:
  794. 包含 total_duration_ms 与 user_level 的字典,失败时返回 None
  795. """
  796. try:
  797. if duration_ms <= 0:
  798. return None # 时长无效,跳过更新
  799. async with db_manager.get_session() as session:
  800. result = await session.execute(
  801. select(User.total_speech_duration_ms, User.user_level)
  802. .where(User.user_id == user_id)
  803. .with_for_update()
  804. )
  805. row = result.one_or_none()
  806. if row is None:
  807. logger.warning(f"⚠️ 更新说话时长失败,未找到用户: user_id={user_id}")
  808. return None
  809. current_total = (row[0] or 0)
  810. current_level = (row[1] or 0)
  811. new_total = current_total + duration_ms
  812. new_level = self._calculate_user_level(new_total)
  813. update_payload: Dict[str, Any] = {
  814. "total_speech_duration_ms": new_total,
  815. }
  816. if new_level != current_level:
  817. update_payload["user_level"] = new_level
  818. await session.execute(
  819. update(User)
  820. .where(User.user_id == user_id)
  821. .values(**update_payload)
  822. )
  823. await session.commit()
  824. logger.info(
  825. f"✅ 更新用户说话时长成功: user_id={user_id}, "
  826. f"duration_ms={duration_ms}, total_ms={new_total}, level={new_level}"
  827. )
  828. return {
  829. "total_duration_ms": new_total,
  830. "user_level": new_level,
  831. }
  832. except Exception as e:
  833. logger.error(f"❌ 更新用户说话时长失败: user_id={user_id}, error={e}")
  834. return None
  835. async def get_user_ui_state(self, user_id: str) -> Optional[Dict[str, Any]]:
  836. """
  837. 获取用户前端可视化状态
  838. """
  839. try:
  840. async with db_manager.get_session() as session:
  841. result = await session.execute(
  842. select(UserUIState).where(UserUIState.user_id == user_id)
  843. )
  844. state = result.scalar_one_or_none()
  845. if state is None:
  846. return None
  847. return state.to_dict()
  848. except Exception as e: # noqa: BLE001
  849. logger.error(f"❌ 获取用户UI状态失败: user_id={user_id}, error={e}")
  850. return None
  851. async def upsert_user_ui_state(
  852. self,
  853. user_id: str,
  854. *,
  855. current_page: Optional[str] = None,
  856. visited_routes: Optional[list[str]] = None,
  857. has_shown_tap_to_chat: Optional[bool] = None,
  858. has_shown_cancel_tooltip: Optional[bool] = None,
  859. has_shown_send_tooltip: Optional[bool] = None,
  860. has_shown_progress_bar_tooltip: Optional[bool] = None,
  861. has_shown_mission_stone_modal: Optional[bool] = None,
  862. has_shown_conversation_stone_modal: Optional[bool] = None,
  863. has_clicked_mission_stone: Optional[bool] = None,
  864. has_clicked_conversation_stone: Optional[bool] = None,
  865. has_shown_first_shop_tooltip: Optional[bool] = None,
  866. has_clicked_shop_in_settings: Optional[bool] = None,
  867. avatar_c_unlocked: Optional[bool] = None,
  868. current_avatar_id: Optional[str] = None,
  869. has_shared_avatar: Optional[bool] = None,
  870. subtitle_on: Optional[bool] = None,
  871. sfx_enabled: Optional[bool] = None,
  872. bgm_enabled: Optional[bool] = None,
  873. ) -> Optional[Dict[str, Any]]:
  874. """
  875. 创建或更新用户前端可视化状态(部分字段更新)
  876. """
  877. try:
  878. async with db_manager.get_session() as session:
  879. result = await session.execute(
  880. select(UserUIState).where(UserUIState.user_id == user_id).with_for_update()
  881. )
  882. state: Optional[UserUIState] = result.scalar_one_or_none()
  883. if state is None:
  884. # 新建记录,仅写入非空字段
  885. data: Dict[str, Any] = {"user_id": user_id}
  886. if current_page is not None:
  887. data["current_page"] = current_page
  888. if visited_routes is not None:
  889. data["visited_routes"] = visited_routes
  890. if has_shown_tap_to_chat is not None:
  891. data["has_shown_tap_to_chat"] = has_shown_tap_to_chat
  892. if has_shown_cancel_tooltip is not None:
  893. data["has_shown_cancel_tooltip"] = has_shown_cancel_tooltip
  894. if has_shown_send_tooltip is not None:
  895. data["has_shown_send_tooltip"] = has_shown_send_tooltip
  896. if has_shown_progress_bar_tooltip is not None:
  897. data["has_shown_progress_bar_tooltip"] = has_shown_progress_bar_tooltip
  898. if has_shown_mission_stone_modal is not None:
  899. data["has_shown_mission_stone_modal"] = has_shown_mission_stone_modal
  900. if has_shown_conversation_stone_modal is not None:
  901. data["has_shown_conversation_stone_modal"] = has_shown_conversation_stone_modal
  902. if has_clicked_mission_stone is not None:
  903. data["has_clicked_mission_stone"] = has_clicked_mission_stone
  904. if has_clicked_conversation_stone is not None:
  905. data["has_clicked_conversation_stone"] = has_clicked_conversation_stone
  906. if has_shown_first_shop_tooltip is not None:
  907. data["has_shown_first_shop_tooltip"] = has_shown_first_shop_tooltip
  908. if has_clicked_shop_in_settings is not None:
  909. data["has_clicked_shop_in_settings"] = has_clicked_shop_in_settings
  910. if avatar_c_unlocked is not None:
  911. data["avatar_c_unlocked"] = avatar_c_unlocked
  912. if current_avatar_id is not None:
  913. data["current_avatar_id"] = current_avatar_id
  914. if has_shared_avatar is not None:
  915. data["has_shared_avatar"] = has_shared_avatar
  916. if subtitle_on is not None:
  917. data["subtitle_on"] = subtitle_on
  918. if sfx_enabled is not None:
  919. data["sfx_enabled"] = sfx_enabled
  920. if bgm_enabled is not None:
  921. data["bgm_enabled"] = bgm_enabled
  922. stmt = insert(UserUIState).values(**data)
  923. await session.execute(stmt)
  924. else:
  925. # 更新现有记录(仅覆盖非空字段)
  926. if current_page is not None:
  927. state.current_page = current_page
  928. if visited_routes is not None:
  929. state.visited_routes = visited_routes
  930. if has_shown_tap_to_chat is not None:
  931. state.has_shown_tap_to_chat = has_shown_tap_to_chat
  932. if has_shown_cancel_tooltip is not None:
  933. state.has_shown_cancel_tooltip = has_shown_cancel_tooltip
  934. if has_shown_send_tooltip is not None:
  935. state.has_shown_send_tooltip = has_shown_send_tooltip
  936. if has_shown_progress_bar_tooltip is not None:
  937. state.has_shown_progress_bar_tooltip = has_shown_progress_bar_tooltip
  938. if has_shown_mission_stone_modal is not None:
  939. state.has_shown_mission_stone_modal = has_shown_mission_stone_modal
  940. if has_shown_conversation_stone_modal is not None:
  941. state.has_shown_conversation_stone_modal = has_shown_conversation_stone_modal
  942. if has_clicked_mission_stone is not None:
  943. state.has_clicked_mission_stone = has_clicked_mission_stone
  944. if has_clicked_conversation_stone is not None:
  945. state.has_clicked_conversation_stone = has_clicked_conversation_stone
  946. if has_shown_first_shop_tooltip is not None:
  947. state.has_shown_first_shop_tooltip = has_shown_first_shop_tooltip
  948. if has_clicked_shop_in_settings is not None:
  949. state.has_clicked_shop_in_settings = has_clicked_shop_in_settings
  950. if avatar_c_unlocked is not None:
  951. state.avatar_c_unlocked = avatar_c_unlocked
  952. if current_avatar_id is not None:
  953. state.current_avatar_id = current_avatar_id
  954. if has_shared_avatar is not None:
  955. state.has_shared_avatar = has_shared_avatar
  956. if subtitle_on is not None:
  957. state.subtitle_on = subtitle_on
  958. if sfx_enabled is not None:
  959. state.sfx_enabled = sfx_enabled
  960. if bgm_enabled is not None:
  961. state.bgm_enabled = bgm_enabled
  962. session.add(state)
  963. await session.commit()
  964. # 重新读取最新状态
  965. result = await session.execute(
  966. select(UserUIState).where(UserUIState.user_id == user_id)
  967. )
  968. latest_state = result.scalar_one_or_none()
  969. if latest_state is None:
  970. return None
  971. return latest_state.to_dict()
  972. except Exception as e: # noqa: BLE001
  973. logger.error(f"❌ 更新用户UI状态失败: user_id={user_id}, error={e}")
  974. return None
  975. async def log_page_visit(self, user_id: Optional[str], path: str) -> bool:
  976. """记录一次页面访问(未登录时 user_id 为 None)"""
  977. try:
  978. async with db_manager.get_session() as session:
  979. stmt = insert(PageVisitLog).values(user_id=user_id, path=path[:512])
  980. await session.execute(stmt)
  981. await session.commit()
  982. return True
  983. except Exception as e: # noqa: BLE001
  984. logger.warning(f"记录页面访问失败: path={path}, error={e}")
  985. return False
  986. async def _get_last_visit_date_for_user(
  987. self,
  988. session: AsyncSession,
  989. user_id: str,
  990. on_or_after_date: Optional[date] = None,
  991. before_date: Optional[date] = None,
  992. ) -> Optional[date]:
  993. """
  994. 查询用户最后一次页面访问的日期。
  995. Args:
  996. session: 数据库会话
  997. user_id: 用户 ID
  998. on_or_after_date: 仅统计该日期及之后的访问
  999. before_date: 仅统计早于该日期(严格小于)的访问,用于排除当日访问
  1000. """
  1001. stmt = (
  1002. select(func.date(PageVisitLog.visited_at))
  1003. .where(PageVisitLog.user_id == user_id)
  1004. .order_by(desc(PageVisitLog.visited_at))
  1005. .limit(1)
  1006. )
  1007. if on_or_after_date is not None:
  1008. stmt = stmt.where(func.date(PageVisitLog.visited_at) >= on_or_after_date)
  1009. if before_date is not None:
  1010. stmt = stmt.where(func.date(PageVisitLog.visited_at) < before_date)
  1011. result = await session.execute(stmt)
  1012. val = result.scalar_one_or_none()
  1013. if val is None:
  1014. return None
  1015. if isinstance(val, date):
  1016. return val
  1017. if isinstance(val, str):
  1018. return datetime.strptime(val[:10], "%Y-%m-%d").date()
  1019. return val
  1020. async def save_user_event(self, payload: dict) -> bool:
  1021. """
  1022. 保存单条用户事件(user_events)
  1023. payload 字段应与 UserEventPayload 对齐:
  1024. - event_id, user_id, event_name, event_label, event_time, session_id,
  1025. platform, app_version, timezone_offset_minutes, properties
  1026. """
  1027. try:
  1028. async with db_manager.get_session() as session:
  1029. event_time = payload.get("event_time")
  1030. if isinstance(event_time, str):
  1031. event_time = datetime.fromisoformat(event_time.replace("Z", "+00:00"))
  1032. user_event = UserEvent(
  1033. event_id=payload["event_id"],
  1034. user_id=payload["user_id"],
  1035. event_name=payload["event_name"],
  1036. event_label=payload.get("event_label"),
  1037. event_time=event_time or datetime.utcnow(),
  1038. session_id=payload.get("session_id"),
  1039. platform=payload["platform"],
  1040. app_version=payload.get("app_version"),
  1041. timezone_offset_minutes=payload.get("timezone_offset_minutes"),
  1042. properties=payload.get("properties"),
  1043. )
  1044. session.add(user_event)
  1045. await session.commit()
  1046. return True
  1047. except Exception as e: # noqa: BLE001
  1048. logger.warning(f"记录用户事件失败: event_id={payload.get('event_id')}, error={e}")
  1049. return False
  1050. def _parse_effective_date(self) -> date:
  1051. """解析配置中的亲密度规则生效日"""
  1052. s = getattr(settings, "INTIMACY_RULE_EFFECTIVE_DATE", "2026-02-13") or "2026-02-13"
  1053. return datetime.strptime(s.strip()[:10], "%Y-%m-%d").date()
  1054. async def get_intimacy(self, user_id: str) -> Dict[str, Any]:
  1055. """
  1056. 获取用户亲密度:0-3 颗心。扣心由每日定时脚本完成,此处仅读库并计算展示用 days_since_last_visit。
  1057. 只统计 INTIMACY_RULE_EFFECTIVE_DATE 及之后的页面访问。
  1058. 返回: {"heart_count": int, "days_since_last_visit": int}
  1059. """
  1060. today = date.today()
  1061. effective = self._parse_effective_date()
  1062. try:
  1063. async with db_manager.get_session() as session:
  1064. # 所有访问记录中最晚的一次(含今天),用于判断是否为新用户
  1065. last_any_visit = await self._get_last_visit_date_for_user(
  1066. session,
  1067. user_id,
  1068. on_or_after_date=effective,
  1069. )
  1070. # 排除今天之后,获取“上一次见面”的日期
  1071. last_visit_before_today = await self._get_last_visit_date_for_user(
  1072. session,
  1073. user_id,
  1074. on_or_after_date=effective,
  1075. before_date=today,
  1076. )
  1077. if last_any_visit is None:
  1078. # 从未有过访问记录:视为首次见面
  1079. days_since = 0
  1080. elif last_visit_before_today is None:
  1081. # 只有今天的访问记录(例如久未登录后今天第一次回来):
  1082. # 文案希望展示“已经过去 X 天”,这里按 0 天处理
  1083. days_since = 0
  1084. else:
  1085. days_since = (today - last_visit_before_today).days
  1086. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  1087. intimacy = result.scalar_one_or_none()
  1088. if intimacy is None:
  1089. intimacy = UserIntimacy(user_id=user_id, heart_count=3)
  1090. session.add(intimacy)
  1091. await session.flush()
  1092. await session.commit()
  1093. await session.refresh(intimacy)
  1094. return {
  1095. "heart_count": intimacy.heart_count,
  1096. "days_since_last_visit": max(0, days_since),
  1097. }
  1098. except Exception as e: # noqa: BLE001
  1099. logger.error(f"get_intimacy 失败: user_id={user_id}, error={e}")
  1100. return {"heart_count": 3, "days_since_last_visit": 0}
  1101. async def ensure_user_intimacy(self, session: AsyncSession, user_id: str, heart_count: int = 3) -> None:
  1102. """注册时为新用户写入亲密度记录(若不存在则插入)。"""
  1103. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  1104. if result.scalar_one_or_none() is not None:
  1105. return
  1106. session.add(UserIntimacy(user_id=user_id, heart_count=heart_count))
  1107. await session.flush()
  1108. async def light_up_intimacy(self, user_id: str) -> Dict[str, Any]:
  1109. """
  1110. 点亮一颗心:heart_count = min(3, heart_count + 1)。
  1111. 规则:同一用户每天最多点亮一次(按服务端当天日期判断)。
  1112. 返回最新 heart_count。
  1113. """
  1114. try:
  1115. today_str = date.today().isoformat()[:10]
  1116. async with db_manager.get_session() as session:
  1117. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  1118. intimacy = result.scalar_one_or_none()
  1119. if intimacy is None:
  1120. # 首次点亮:给 1 颗心,并记录今天为 last_light_up_date
  1121. intimacy = UserIntimacy(user_id=user_id, heart_count=1, last_light_up_date=today_str)
  1122. session.add(intimacy)
  1123. else:
  1124. # 如果今天已经点亮过,则直接返回当前亲密度,不再增加
  1125. if intimacy.last_light_up_date == today_str:
  1126. await session.commit()
  1127. await session.refresh(intimacy)
  1128. return {"heart_count": intimacy.heart_count}
  1129. # 今日尚未点亮:不满 3 颗心时才增加一颗,并更新 last_light_up_date
  1130. if intimacy.heart_count < 3:
  1131. intimacy.heart_count = min(3, intimacy.heart_count + 1)
  1132. intimacy.last_light_up_date = today_str
  1133. await session.commit()
  1134. await session.refresh(intimacy)
  1135. return {"heart_count": intimacy.heart_count}
  1136. except Exception as e: # noqa: BLE001
  1137. logger.error(f"light_up_intimacy 失败: user_id={user_id}, error={e}")
  1138. return {"heart_count": 0}
  1139. async def get_intimacy_status(self, user_id: str) -> Dict[str, Any]:
  1140. """
  1141. 获取用户当天是否可以点亮亲密度。
  1142. 返回:
  1143. {
  1144. "heart_count": int, # 当前心数 0-3
  1145. "can_light_up_today": bool, # 今天是否还能点亮一次
  1146. }
  1147. """
  1148. today_str = date.today().isoformat()[:10]
  1149. try:
  1150. async with db_manager.get_session() as session:
  1151. result = await session.execute(select(UserIntimacy).where(UserIntimacy.user_id == user_id))
  1152. intimacy = result.scalar_one_or_none()
  1153. if intimacy is None:
  1154. # 没有记录时视为 3 颗心、今日尚未点亮(但前端通常会基于 heart_count<3 决定是否展示弹窗)
  1155. intimacy = UserIntimacy(user_id=user_id, heart_count=3)
  1156. session.add(intimacy)
  1157. await session.flush()
  1158. await session.commit()
  1159. await session.refresh(intimacy)
  1160. can_light_up_today = intimacy.heart_count < 3 and intimacy.last_light_up_date != today_str
  1161. return {
  1162. "heart_count": intimacy.heart_count,
  1163. "can_light_up_today": can_light_up_today,
  1164. }
  1165. except Exception as e: # noqa: BLE001
  1166. logger.error(f"get_intimacy_status 失败: user_id={user_id}, error={e}")
  1167. # 兜底:认为不能点亮,避免无限点亮
  1168. return {
  1169. "heart_count": 3,
  1170. "can_light_up_today": False,
  1171. }
  1172. async def register_with_phone(
  1173. self,
  1174. phone: str,
  1175. username: str = "",
  1176. user_id: Optional[str] = None,
  1177. area_code: Optional[str] = None,
  1178. preferred_language: Optional[str] = None,
  1179. ) -> Dict:
  1180. """
  1181. 使用手机号注册
  1182. Args:
  1183. phone: 手机号
  1184. username: 用户名(可选)
  1185. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  1186. Returns:
  1187. 用户信息字典
  1188. """
  1189. try:
  1190. lang = preferred_language if preferred_language in ("zh", "en") else None
  1191. async with db_manager.get_session() as session:
  1192. # 检查手机号是否已被其他用户注册
  1193. result = await session.execute(
  1194. select(User).where(User.phone == phone)
  1195. )
  1196. existing_phone_user = result.scalar_one_or_none()
  1197. if existing_phone_user:
  1198. # 如果提供了 user_id,检查是否是同一个用户
  1199. if user_id and existing_phone_user.user_id == user_id:
  1200. # 同一个用户,可能是重新注册,允许继续
  1201. pass
  1202. else:
  1203. raise AuthError(PHONE_ALREADY_REGISTERED)
  1204. # 如果提供了 user_id,使用现有用户
  1205. final_user_id = None
  1206. if user_id:
  1207. result = await session.execute(
  1208. select(User).where(User.user_id == user_id)
  1209. )
  1210. existing_user = result.scalar_one_or_none()
  1211. if not existing_user:
  1212. raise AuthError(USER_ID_NOT_FOUND)
  1213. if existing_user.user_type == "registered":
  1214. raise AuthError(ALREADY_REGISTERED)
  1215. # 更新现有用户
  1216. if not username or username.strip() == "":
  1217. # 如果用户已有用户名,保留;否则生成新用户名
  1218. if not existing_user.username or existing_user.username.strip() == "":
  1219. username = await self._generate_temp_username(session)
  1220. else:
  1221. username = existing_user.username
  1222. existing_user.phone = phone
  1223. if area_code is not None:
  1224. existing_user.area_code = area_code
  1225. existing_user.username = username
  1226. existing_user.user_type = "registered"
  1227. existing_user.is_verified = True
  1228. existing_user.registered_at = datetime.now()
  1229. if lang is not None:
  1230. existing_user.preferred_language = lang
  1231. user = existing_user
  1232. final_user_id = user_id
  1233. await session.flush()
  1234. # 如果用户还没有背景图片,初始化背景图片
  1235. if not existing_user.current_background_image_id or existing_user.current_background_image_id == "default":
  1236. await self._init_user_background_image(session, user_id)
  1237. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1238. await self._init_user_avatar(session, user_id, user_type="registered")
  1239. await self.ensure_user_intimacy(session, user_id, heart_count=3)
  1240. await self._init_registered_user_chatbot_shop(session, user_id)
  1241. await session.commit()
  1242. await session.refresh(user)
  1243. else:
  1244. # 创建新用户(原有逻辑)
  1245. new_user_id = str(uuid.uuid4())
  1246. if not username or username.strip() == "":
  1247. username = await self._generate_temp_username(session)
  1248. user = User(
  1249. user_id=new_user_id,
  1250. username=username,
  1251. phone=phone,
  1252. area_code=area_code,
  1253. user_type="registered",
  1254. is_verified=True,
  1255. registered_at=datetime.now(),
  1256. current_background_image_id="default", # 设置默认背景图片
  1257. preferred_language=lang,
  1258. )
  1259. session.add(user)
  1260. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  1261. final_user_id = new_user_id
  1262. # 初始化背景图片
  1263. await self._init_user_background_image(session, new_user_id)
  1264. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1265. await self._init_user_avatar(session, new_user_id, user_type="registered")
  1266. await self.ensure_user_intimacy(session, new_user_id, heart_count=3)
  1267. await self._init_registered_user_chatbot_shop(session, new_user_id)
  1268. await session.commit()
  1269. await session.refresh(user)
  1270. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  1271. final_user_id = final_user_id if final_user_id else user.user_id
  1272. await self.upsert_user_ui_state(
  1273. user_id=final_user_id,
  1274. current_avatar_id=REGISTERED_AVATAR_ID
  1275. )
  1276. logger.info(f"✅ 手机号注册成功: user_id={final_user_id}, area_code={area_code}, phone={phone}")
  1277. return user.to_dict()
  1278. except Exception as e:
  1279. logger.error(f"❌ 手机号注册失败: {e}")
  1280. raise
  1281. async def register_with_email(
  1282. self,
  1283. email: str,
  1284. username: str = "",
  1285. user_id: Optional[str] = None,
  1286. preferred_language: Optional[str] = None,
  1287. ) -> Dict:
  1288. """
  1289. 使用邮箱注册
  1290. Args:
  1291. email: 邮箱
  1292. username: 用户名(可选)
  1293. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  1294. Returns:
  1295. 用户信息字典
  1296. """
  1297. try:
  1298. lang = preferred_language if preferred_language in ("zh", "en") else None
  1299. async with db_manager.get_session() as session:
  1300. # 检查邮箱是否已被其他用户注册
  1301. result = await session.execute(
  1302. select(User).where(User.email == email)
  1303. )
  1304. existing_email_user = result.scalar_one_or_none()
  1305. if existing_email_user:
  1306. # 如果提供了 user_id,检查是否是同一个用户
  1307. if user_id and existing_email_user.user_id == user_id:
  1308. # 同一个用户,可能是重新注册,允许继续
  1309. pass
  1310. else:
  1311. raise AuthError(EMAIL_ALREADY_REGISTERED)
  1312. # 如果提供了 user_id,使用现有用户
  1313. final_user_id = None
  1314. if user_id:
  1315. result = await session.execute(
  1316. select(User).where(User.user_id == user_id)
  1317. )
  1318. existing_user = result.scalar_one_or_none()
  1319. if not existing_user:
  1320. raise AuthError(USER_ID_NOT_FOUND)
  1321. if existing_user.user_type == "registered":
  1322. raise AuthError(ALREADY_REGISTERED)
  1323. # 更新现有用户
  1324. if not username or username.strip() == "":
  1325. # 如果用户已有用户名,保留;否则生成新用户名
  1326. if not existing_user.username or existing_user.username.strip() == "":
  1327. username = await self._generate_temp_username(session)
  1328. else:
  1329. username = existing_user.username
  1330. existing_user.email = email
  1331. existing_user.username = username
  1332. existing_user.user_type = "registered"
  1333. existing_user.is_verified = True
  1334. existing_user.registered_at = datetime.now()
  1335. if lang is not None:
  1336. existing_user.preferred_language = lang
  1337. user = existing_user
  1338. final_user_id = user_id
  1339. await session.flush()
  1340. # 如果用户还没有背景图片,初始化背景图片
  1341. if not existing_user.current_background_image_id or existing_user.current_background_image_id == "default":
  1342. await self._init_user_background_image(session, user_id)
  1343. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1344. await self._init_user_avatar(session, user_id, user_type="registered")
  1345. await self.ensure_user_intimacy(session, user_id, heart_count=3)
  1346. await self._init_registered_user_chatbot_shop(session, user_id)
  1347. await session.commit()
  1348. await session.refresh(user)
  1349. else:
  1350. # 创建新用户(原有逻辑)
  1351. new_user_id = str(uuid.uuid4())
  1352. if not username or username.strip() == "":
  1353. username = await self._generate_temp_username(session)
  1354. user = User(
  1355. user_id=new_user_id,
  1356. username=username,
  1357. email=email,
  1358. user_type="registered",
  1359. is_verified=True,
  1360. registered_at=datetime.now(),
  1361. current_background_image_id="default", # 设置默认背景图片
  1362. preferred_language=lang,
  1363. )
  1364. session.add(user)
  1365. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  1366. final_user_id = new_user_id
  1367. # 初始化背景图片
  1368. await self._init_user_background_image(session, new_user_id)
  1369. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1370. await self._init_user_avatar(session, new_user_id, user_type="registered")
  1371. await self.ensure_user_intimacy(session, new_user_id, heart_count=3)
  1372. await self._init_registered_user_chatbot_shop(session, new_user_id)
  1373. await session.commit()
  1374. await session.refresh(user)
  1375. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  1376. final_user_id = final_user_id if final_user_id else user.user_id
  1377. await self.upsert_user_ui_state(
  1378. user_id=final_user_id,
  1379. current_avatar_id=REGISTERED_AVATAR_ID
  1380. )
  1381. logger.info(f"✅ 邮箱注册成功: user_id={final_user_id}, email={email}")
  1382. return user.to_dict()
  1383. except Exception as e:
  1384. logger.error(f"❌ 邮箱注册失败: {e}")
  1385. raise
  1386. async def login_with_wechat(self, openid: str, username: str = "") -> Dict:
  1387. """
  1388. 使用微信OpenID登录/注册
  1389. Args:
  1390. openid: 微信OpenID
  1391. username: 用户名(可选,仅新用户时使用)
  1392. Returns:
  1393. 用户信息字典
  1394. """
  1395. try:
  1396. async with db_manager.get_session() as session:
  1397. # 检查是否已存在
  1398. result = await session.execute(
  1399. select(User).where(User.wechat_openid == openid)
  1400. )
  1401. existing_user = result.scalar_one_or_none()
  1402. if existing_user:
  1403. # 已存在,直接返回
  1404. logger.info(f"✅ 微信登录成功: user_id={existing_user.user_id}, openid={openid}")
  1405. return existing_user.to_dict()
  1406. # 新用户,创建账号
  1407. user_id = str(uuid.uuid4())
  1408. if not username or username.strip() == "":
  1409. username = await self._generate_temp_username(session)
  1410. user = User(
  1411. user_id=user_id,
  1412. username=username,
  1413. wechat_openid=openid,
  1414. user_type="registered",
  1415. is_verified=True,
  1416. registered_at=datetime.now(),
  1417. current_background_image_id="default", # 设置默认背景图片
  1418. )
  1419. session.add(user)
  1420. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  1421. # 初始化背景图片
  1422. await self._init_user_background_image(session, user_id)
  1423. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1424. await self._init_user_avatar(session, user_id, user_type="registered")
  1425. await self._init_registered_user_chatbot_shop(session, user_id)
  1426. await session.commit()
  1427. await session.refresh(user)
  1428. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  1429. await self.upsert_user_ui_state(
  1430. user_id=user_id,
  1431. current_avatar_id=REGISTERED_AVATAR_ID
  1432. )
  1433. logger.info(f"✅ 微信注册成功: user_id={user_id}, openid={openid}")
  1434. return user.to_dict()
  1435. except Exception as e:
  1436. logger.error(f"❌ 微信登录/注册失败: {e}")
  1437. raise
  1438. async def login_with_qq(
  1439. self,
  1440. openid: str,
  1441. username: str = "",
  1442. unionid: Optional[str] = None,
  1443. user_id: Optional[str] = None,
  1444. ) -> Dict:
  1445. """
  1446. 使用QQ OpenID登录/注册
  1447. Args:
  1448. openid: QQ OpenID
  1449. username: 用户名(可选,仅新用户时使用)
  1450. unionid: QQ UnionID(可选)
  1451. user_id: 可选的现有用户ID,如果提供则更新该用户而不是创建新用户
  1452. Returns:
  1453. 用户信息字典
  1454. """
  1455. try:
  1456. async with db_manager.get_session() as session:
  1457. # 优先用 openid 查询
  1458. result = await session.execute(
  1459. select(User).where(User.qq_openid == openid)
  1460. )
  1461. existing_user = result.scalar_one_or_none()
  1462. # 如果未命中且拿到了 unionid,则尝试用 unionid 查询
  1463. if not existing_user and unionid:
  1464. union_result = await session.execute(
  1465. select(User).where(User.qq_unionid == unionid)
  1466. )
  1467. existing_user = union_result.scalar_one_or_none()
  1468. # 若命中用户还未绑定 openid,则补充绑定
  1469. if existing_user and not existing_user.qq_openid:
  1470. existing_user.qq_openid = openid
  1471. session.add(existing_user)
  1472. await session.commit()
  1473. await session.refresh(existing_user)
  1474. logger.info(
  1475. f"🔁 已根据 unionid={unionid} 绑定新的 QQ openid={openid}"
  1476. )
  1477. if existing_user:
  1478. # 如果提供了 user_id,检查是否是同一个用户
  1479. if user_id and existing_user.user_id != user_id:
  1480. raise AuthError(QQ_ALREADY_BOUND)
  1481. updated = False
  1482. if unionid and not existing_user.qq_unionid:
  1483. existing_user.qq_unionid = unionid
  1484. updated = True
  1485. if updated:
  1486. session.add(existing_user)
  1487. await session.commit()
  1488. await session.refresh(existing_user)
  1489. logger.info(
  1490. f"✅ QQ登录成功: user_id={existing_user.user_id}, "
  1491. f"openid={openid}, unionid={unionid}"
  1492. )
  1493. return existing_user.to_dict()
  1494. # 如果提供了 user_id,使用现有用户
  1495. final_user_id = None
  1496. if user_id:
  1497. result = await session.execute(
  1498. select(User).where(User.user_id == user_id)
  1499. )
  1500. existing_user_by_id = result.scalar_one_or_none()
  1501. if not existing_user_by_id:
  1502. raise AuthError(USER_ID_NOT_FOUND)
  1503. if existing_user_by_id.user_type == "registered":
  1504. raise AuthError(ALREADY_REGISTERED)
  1505. # 更新现有用户
  1506. if not username or username.strip() == "":
  1507. # 如果用户已有用户名,保留;否则生成新用户名
  1508. if not existing_user_by_id.username or existing_user_by_id.username.strip() == "":
  1509. username = await self._generate_temp_username(session)
  1510. else:
  1511. username = existing_user_by_id.username
  1512. existing_user_by_id.qq_openid = openid
  1513. if unionid:
  1514. existing_user_by_id.qq_unionid = unionid
  1515. existing_user_by_id.username = username
  1516. existing_user_by_id.user_type = "registered"
  1517. existing_user_by_id.is_verified = True
  1518. existing_user_by_id.registered_at = datetime.now()
  1519. user = existing_user_by_id
  1520. final_user_id = user_id
  1521. await session.flush()
  1522. # 如果用户还没有背景图片,初始化背景图片
  1523. if not existing_user_by_id.current_background_image_id or existing_user_by_id.current_background_image_id == "default":
  1524. await self._init_user_background_image(session, user_id)
  1525. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1526. await self._init_user_avatar(session, user_id, user_type="registered")
  1527. await self._init_registered_user_chatbot_shop(session, user_id)
  1528. await session.commit()
  1529. await session.refresh(user)
  1530. else:
  1531. # 新用户,创建账号
  1532. new_user_id = str(uuid.uuid4())
  1533. if not username or username.strip() == "":
  1534. username = await self._generate_temp_username(session)
  1535. user = User(
  1536. user_id=new_user_id,
  1537. username=username,
  1538. qq_openid=openid,
  1539. qq_unionid=unionid,
  1540. user_type="registered",
  1541. is_verified=True,
  1542. registered_at=datetime.now(),
  1543. current_background_image_id="default", # 设置默认背景图片
  1544. )
  1545. session.add(user)
  1546. await session.flush() # 先 flush 确保 user 有 ID,但不提交事务
  1547. final_user_id = new_user_id
  1548. # 初始化背景图片
  1549. await self._init_user_background_image(session, new_user_id)
  1550. # 初始化头像(注册用户:默认头像 + 注册赠送头像)
  1551. await self._init_user_avatar(session, new_user_id, user_type="registered")
  1552. await self._init_registered_user_chatbot_shop(session, new_user_id)
  1553. await session.commit()
  1554. await session.refresh(user)
  1555. # 初始化 user_ui_states 的 current_avatar_id(注册用户优先使用注册赠送头像)
  1556. final_user_id = final_user_id if final_user_id else user.user_id
  1557. await self.upsert_user_ui_state(
  1558. user_id=final_user_id,
  1559. current_avatar_id=REGISTERED_AVATAR_ID
  1560. )
  1561. logger.info(
  1562. f"✅ QQ注册成功: user_id={final_user_id}, openid={openid}, unionid={unionid}"
  1563. )
  1564. return user.to_dict()
  1565. except Exception as e:
  1566. logger.error(f"❌ QQ登录/注册失败: {e}")
  1567. raise
  1568. async def login_with_phone(
  1569. self,
  1570. phone: str,
  1571. area_code: Optional[str] = None,
  1572. preferred_language: Optional[str] = None,
  1573. ) -> Optional[Dict]:
  1574. """
  1575. 使用手机号登录
  1576. Args:
  1577. phone: 手机号
  1578. Returns:
  1579. 用户信息字典,如果不存在返回None
  1580. """
  1581. try:
  1582. lang = preferred_language if preferred_language in ("zh", "en") else None
  1583. async with db_manager.get_session() as session:
  1584. result = await session.execute(
  1585. select(User).where(
  1586. User.phone == phone,
  1587. *( [User.area_code == area_code] if area_code else [] )
  1588. )
  1589. )
  1590. user = result.scalar_one_or_none()
  1591. # 兼容旧数据:未存区号时再用空区号回退查询
  1592. if not user and area_code:
  1593. fallback_result = await session.execute(
  1594. select(User).where(User.phone == phone, User.area_code.is_(None))
  1595. )
  1596. user = fallback_result.scalar_one_or_none()
  1597. if user:
  1598. if lang is not None:
  1599. try:
  1600. async with db_manager.get_session() as session:
  1601. await session.execute(
  1602. update(User)
  1603. .where(User.user_id == user.user_id)
  1604. .values(preferred_language=lang)
  1605. )
  1606. await session.commit()
  1607. except Exception as update_error: # noqa: BLE001
  1608. logger.warning(
  1609. f"⚠️ 更新用户首选语言失败: user_id={user.user_id}, lang={lang}, error={update_error}"
  1610. )
  1611. logger.info(f"✅ 手机号登录成功: user_id={user.user_id}, area_code={area_code}, phone={phone}")
  1612. return user.to_dict()
  1613. return None
  1614. except Exception as e:
  1615. logger.error(f"❌ 手机号登录失败: {e}")
  1616. return None
  1617. async def login_with_email(self, email: str, preferred_language: Optional[str] = None) -> Optional[Dict]:
  1618. """
  1619. 使用邮箱登录
  1620. Args:
  1621. email: 邮箱
  1622. Returns:
  1623. 用户信息字典,如果不存在返回None
  1624. """
  1625. try:
  1626. lang = preferred_language if preferred_language in ("zh", "en") else None
  1627. async with db_manager.get_session() as session:
  1628. result = await session.execute(
  1629. select(User).where(User.email == email)
  1630. )
  1631. user = result.scalar_one_or_none()
  1632. if user:
  1633. if lang is not None:
  1634. try:
  1635. async with db_manager.get_session() as session:
  1636. await session.execute(
  1637. update(User)
  1638. .where(User.user_id == user.user_id)
  1639. .values(preferred_language=lang)
  1640. )
  1641. await session.commit()
  1642. except Exception as update_error: # noqa: BLE001
  1643. logger.warning(
  1644. f"⚠️ 更新用户首选语言失败: user_id={user.user_id}, lang={lang}, error={update_error}"
  1645. )
  1646. logger.info(f"✅ 邮箱登录成功: user_id={user.user_id}, email={email}")
  1647. return user.to_dict()
  1648. return None
  1649. except Exception as e:
  1650. logger.error(f"❌ 邮箱登录失败: {e}")
  1651. return None
  1652. async def verify_and_auth(
  1653. self,
  1654. auth_type: str,
  1655. code: str,
  1656. email: Optional[str] = None,
  1657. phone: Optional[str] = None,
  1658. area_code: Optional[str] = None,
  1659. preferred_language: Optional[str] = None,
  1660. ) -> Tuple[Dict, bool]:
  1661. """
  1662. 验证码校验通过后统一登录或注册(V2.0)。
  1663. auth_type: "email" | "phone"
  1664. 返回 (user_dict, is_new_user)
  1665. """
  1666. if auth_type == "email":
  1667. send_type = "email"
  1668. else:
  1669. send_type = "international_sms" if (area_code and area_code.strip() != "+86") else "domestic_sms"
  1670. if auth_type == "email":
  1671. if not email:
  1672. raise AuthError(EMAIL_REQUIRED)
  1673. else:
  1674. if not phone:
  1675. raise AuthError(PHONE_REQUIRED)
  1676. if not code or not code.strip():
  1677. raise AuthError(CODE_REQUIRED)
  1678. async with db_manager.get_session() as session:
  1679. if auth_type == "email":
  1680. result = await session.execute(
  1681. select(VerificationCode)
  1682. .where(
  1683. VerificationCode.email == email,
  1684. VerificationCode.send_type == send_type,
  1685. VerificationCode.code == code.strip(),
  1686. VerificationCode.is_valid == True,
  1687. )
  1688. .order_by(VerificationCode.created_at.desc())
  1689. .limit(1)
  1690. )
  1691. else:
  1692. result = await session.execute(
  1693. select(VerificationCode)
  1694. .where(
  1695. VerificationCode.phone == phone,
  1696. VerificationCode.send_type == send_type,
  1697. VerificationCode.code == code.strip(),
  1698. VerificationCode.is_valid == True,
  1699. )
  1700. .order_by(VerificationCode.created_at.desc())
  1701. .limit(1)
  1702. )
  1703. verification_code = result.scalar_one_or_none()
  1704. if not verification_code:
  1705. logger.warning(f"❌ 验证码无效: type={auth_type}, email={email}, phone={phone}")
  1706. raise AuthError(CODE_INVALID)
  1707. verification_code.is_valid = False
  1708. await session.commit()
  1709. if auth_type == "email":
  1710. user = await self.login_with_email(email, preferred_language=preferred_language)
  1711. if user:
  1712. logger.info(f"✅ verify_and_auth 邮箱登录: user_id={user['user_id']}")
  1713. return (user, False)
  1714. user = await self.register_with_email(email, preferred_language=preferred_language)
  1715. logger.info(f"✅ verify_and_auth 邮箱注册: user_id={user['user_id']}")
  1716. return (user, True)
  1717. else:
  1718. user = await self.login_with_phone(phone, area_code, preferred_language=preferred_language)
  1719. if user:
  1720. logger.info(f"✅ verify_and_auth 手机登录: user_id={user['user_id']}")
  1721. return (user, False)
  1722. user = await self.register_with_phone(phone, area_code=area_code, preferred_language=preferred_language)
  1723. logger.info(f"✅ verify_and_auth 手机注册: user_id={user['user_id']}")
  1724. return (user, True)
  1725. async def get_community_choice(self, user_id: str) -> Optional[Dict]:
  1726. """获取用户「退出早期共创社区」选项(V2.0),用于登录页回显"""
  1727. async with db_manager.get_session() as session:
  1728. result = await session.execute(
  1729. select(UserCommunityChoice).where(UserCommunityChoice.user_id == user_id)
  1730. )
  1731. row = result.scalar_one_or_none()
  1732. return row.to_dict() if row else None
  1733. async def upsert_community_choice(self, user_id: str, opted_out: bool) -> None:
  1734. """更新用户「退出早期共创社区」选项(V2.0)"""
  1735. async with db_manager.get_session() as session:
  1736. result = await session.execute(
  1737. select(UserCommunityChoice).where(UserCommunityChoice.user_id == user_id)
  1738. )
  1739. row = result.scalar_one_or_none()
  1740. if row:
  1741. row.opted_out = opted_out
  1742. else:
  1743. session.add(UserCommunityChoice(user_id=user_id, opted_out=opted_out))
  1744. await session.commit()
  1745. async def get_early_community(self, user_id: str) -> Optional[Dict]:
  1746. """获取用户早期共创状态(V2.0)"""
  1747. async with db_manager.get_session() as session:
  1748. result = await session.execute(
  1749. select(UserEarlyCommunity).where(UserEarlyCommunity.user_id == user_id)
  1750. )
  1751. row = result.scalar_one_or_none()
  1752. return row.to_dict() if row else None
  1753. async def submit_early_community(self, user_id: str, email: Optional[str] = None) -> Dict:
  1754. """提交早期共创:标记弹窗已展示;若传 email 则写入并标记已加入(V2.0)"""
  1755. async with db_manager.get_session() as session:
  1756. result = await session.execute(
  1757. select(UserEarlyCommunity).where(UserEarlyCommunity.user_id == user_id)
  1758. )
  1759. row = result.scalar_one_or_none()
  1760. now = datetime.now()
  1761. if row:
  1762. row.popup_shown = True
  1763. if email and email.strip():
  1764. row.email = email.strip()
  1765. row.joined_at = now
  1766. else:
  1767. session.add(
  1768. UserEarlyCommunity(
  1769. user_id=user_id,
  1770. email=email.strip() if email and email.strip() else None,
  1771. popup_shown=True,
  1772. joined_at=now if (email and email.strip()) else None,
  1773. )
  1774. )
  1775. await session.commit()
  1776. result2 = await session.execute(
  1777. select(UserEarlyCommunity).where(UserEarlyCommunity.user_id == user_id)
  1778. )
  1779. r = result2.scalar_one_or_none()
  1780. return r.to_dict() if r else {}
  1781. async def check_user_exists_by_qq_openid(self, openid: str, unionid: Optional[str] = None) -> bool:
  1782. """
  1783. 检查QQ OpenID对应的用户是否存在
  1784. Args:
  1785. openid: QQ OpenID
  1786. unionid: QQ UnionID(可选)
  1787. Returns:
  1788. 用户是否存在
  1789. """
  1790. try:
  1791. async with db_manager.get_session() as session:
  1792. # 优先用 openid 查询
  1793. result = await session.execute(
  1794. select(User).where(User.qq_openid == openid)
  1795. )
  1796. existing_user = result.scalar_one_or_none()
  1797. 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}")
  1798. # 如果未命中且拿到了 unionid,则尝试用 unionid 查询
  1799. if not existing_user and unionid:
  1800. union_result = await session.execute(
  1801. select(User).where(User.qq_unionid == unionid)
  1802. )
  1803. existing_user = union_result.scalar_one_or_none()
  1804. 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}")
  1805. exists = existing_user is not None
  1806. logger.info(f"🔍 [检查QQ用户] 最终结果 - openid={openid[:16]}..., unionid={unionid[:16] if unionid else None}..., exists={exists}")
  1807. return exists
  1808. except Exception as e:
  1809. logger.error(f"❌ 检查QQ用户是否存在失败: {e}")
  1810. return False
  1811. # 全局用户服务实例
  1812. user_service = UserService()