models_db.py 44 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012
  1. """
  2. SQLAlchemy 数据库模型定义
  3. """
  4. from datetime import datetime
  5. from typing import Optional
  6. from sqlalchemy import (
  7. Column,
  8. String,
  9. Integer,
  10. BigInteger,
  11. Text,
  12. Enum,
  13. Boolean,
  14. JSON,
  15. TIMESTAMP,
  16. Index,
  17. UniqueConstraint,
  18. ForeignKey,
  19. )
  20. from sqlalchemy.orm import declarative_base
  21. from sqlalchemy.sql import func
  22. PKBigInt = BigInteger().with_variant(Integer, "sqlite")
  23. Base = declarative_base()
  24. class User(Base):
  25. """用户表模型"""
  26. __tablename__ = "users"
  27. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  28. user_id = Column(String(64), unique=True, nullable=False, index=True, comment="用户唯一标识(UUID)")
  29. # 基本信息
  30. username = Column(String(100), nullable=False, comment="用户名/昵称")
  31. user_type = Column(Enum("guest", "registered", name="user_type"), default="guest", comment="用户类型")
  32. user_segment = Column(String(32), nullable=False, default="individual", comment="用户/场景分群:individual/school/internal等")
  33. org_id = Column(String(64), nullable=True, comment="组织ID(关联 organizations.org_id)")
  34. is_test_user = Column(Boolean, nullable=False, default=False, comment="是否测试账号")
  35. is_special_user = Column(Boolean, nullable=False, default=False, comment="是否特殊实验用户")
  36. special_group = Column(String(8), nullable=True, comment="特殊用户分组:A / B")
  37. region = Column(String(32), nullable=True, comment="国家/地区(如 CN、US)")
  38. bot_name = Column(String(100), nullable=True, comment="用户给机器人取的名字")
  39. # 注册信息(可选)
  40. area_code = Column(String(10), nullable=True, comment="手机区号(如 +86)")
  41. phone = Column(String(20), unique=True, nullable=True, index=True, comment="手机号")
  42. email = Column(String(200), unique=True, nullable=True, index=True, comment="邮箱")
  43. wechat_openid = Column(String(100), unique=True, nullable=True, index=True, comment="微信OpenID")
  44. qq_openid = Column(String(100), unique=True, nullable=True, index=True, comment="QQ OpenID")
  45. qq_unionid = Column(String(128), unique=True, nullable=True, index=True, comment="QQ UnionID")
  46. google_email = Column(String(200), unique=True, nullable=True, index=True, comment="Google邮箱")
  47. # 认证信息
  48. is_verified = Column(Boolean, default=False, comment="是否已认证")
  49. registered_at = Column(TIMESTAMP, nullable=True, comment="注册时间")
  50. # 统计信息
  51. total_messages = Column(Integer, default=0, comment="总消息数")
  52. total_rounds = Column(Integer, default=0, comment="总对话轮次")
  53. total_speech_duration_ms = Column(BigInteger, default=0, comment="累积说话时长(毫秒)")
  54. user_level = Column(Integer, default=0, nullable=False, comment="用户等级(0-5)")
  55. conversation_energy_stone_total = Column(
  56. Integer,
  57. default=0,
  58. nullable=False,
  59. comment="对话能量石余额(对话获得+赠送-购物消耗,展示与扣减以此为准)",
  60. )
  61. crystal_stone_total = Column(
  62. Integer,
  63. default=0,
  64. nullable=False,
  65. comment="水晶石余额(任务等获得-购物消耗,展示与扣减以此为准)",
  66. )
  67. preferred_language = Column(String(8), nullable=True, comment="首选语言(zh/en)")
  68. last_active_at = Column(TIMESTAMP, nullable=True, comment="最后活跃时间")
  69. # 背景图片
  70. current_background_image_id = Column(String(64), nullable=True, comment="当前使用的背景图片ID")
  71. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  72. updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), comment="更新时间")
  73. def to_dict(self) -> dict:
  74. """转换为字典"""
  75. return {
  76. "user_id": self.user_id,
  77. "username": self.username,
  78. "user_type": self.user_type,
  79. "user_segment": self.user_segment,
  80. "org_id": self.org_id,
  81. "is_test_user": self.is_test_user,
  82. "is_special_user": bool(self.is_special_user),
  83. "special_group": self.special_group,
  84. "region": self.region,
  85. "bot_name": self.bot_name,
  86. "area_code": self.area_code,
  87. "phone": self.phone,
  88. "email": self.email,
  89. "wechat_openid": self.wechat_openid,
  90. "qq_openid": self.qq_openid,
  91. "qq_unionid": self.qq_unionid,
  92. "google_email": self.google_email,
  93. "is_verified": self.is_verified,
  94. "registered_at": self.registered_at.isoformat() if self.registered_at else None,
  95. "total_messages": self.total_messages,
  96. "total_rounds": self.total_rounds,
  97. "total_speech_duration_ms": self.total_speech_duration_ms,
  98. "user_level": self.user_level,
  99. "conversation_energy_stone_total": self.conversation_energy_stone_total or 0,
  100. "crystal_stone_total": self.crystal_stone_total or 0,
  101. "preferred_language": self.preferred_language,
  102. "last_active_at": self.last_active_at.isoformat() if self.last_active_at else None,
  103. "current_background_image_id": self.current_background_image_id,
  104. "created_at": self.created_at.isoformat() if self.created_at else None,
  105. }
  106. class AbSubtitleSetting(Base):
  107. """A/B 组字幕开关配置表"""
  108. __tablename__ = "ab_subtitle_settings"
  109. group_key = Column(String(8), primary_key=True, comment="实验分组:A / B")
  110. subtitle_on = Column(Boolean, nullable=False, default=False, comment="字幕开关:0=关,1=开")
  111. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False, comment="创建时间")
  112. updated_at = Column(
  113. TIMESTAMP,
  114. server_default=func.current_timestamp(),
  115. onupdate=func.current_timestamp(),
  116. nullable=False,
  117. comment="更新时间",
  118. )
  119. def to_dict(self) -> dict:
  120. return {
  121. "group_key": self.group_key,
  122. "subtitle_on": 1 if self.subtitle_on else 0,
  123. "created_at": self.created_at.isoformat() if self.created_at else None,
  124. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  125. }
  126. class LlmTask(Base):
  127. """A/B 组 LLM 任务提示词表"""
  128. __tablename__ = "llm_tasks"
  129. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  130. task_name = Column(String(255), nullable=False, comment="任务名称")
  131. task_prompt = Column(Text, nullable=False, comment="任务 System Prompt 全文")
  132. lumi_first_line = Column(Text, nullable=False, comment="Lumi 首句(A/B 组 greeting TTS)")
  133. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False, comment="创建时间")
  134. updated_at = Column(
  135. TIMESTAMP,
  136. server_default=func.current_timestamp(),
  137. onupdate=func.current_timestamp(),
  138. nullable=False,
  139. comment="更新时间",
  140. )
  141. class Organization(Base):
  142. """组织表模型"""
  143. __tablename__ = "organizations"
  144. id = Column(PKBigInt, primary_key=True, autoincrement=True, comment="主键")
  145. org_id = Column(String(64), nullable=False, unique=True, index=True, comment="组织唯一标识")
  146. org_name = Column(String(200), nullable=False, comment="组织名称")
  147. org_type = Column(String(32), nullable=True, index=True, comment="组织类型:school/institution/internal等")
  148. region = Column(String(32), nullable=True, index=True, comment="组织所在地区")
  149. metadata_json = Column(JSON, nullable=True, comment="扩展信息")
  150. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False, comment="创建时间")
  151. updated_at = Column(
  152. TIMESTAMP,
  153. server_default=func.current_timestamp(),
  154. onupdate=func.current_timestamp(),
  155. nullable=False,
  156. comment="更新时间",
  157. )
  158. def to_dict(self) -> dict:
  159. return {
  160. "org_id": self.org_id,
  161. "org_name": self.org_name,
  162. "org_type": self.org_type,
  163. "region": self.region,
  164. "metadata": self.metadata_json,
  165. "created_at": self.created_at.isoformat() if self.created_at else None,
  166. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  167. }
  168. class Message(Base):
  169. """消息表模型"""
  170. __tablename__ = "messages"
  171. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  172. message_id = Column(String(64), unique=True, nullable=False, comment="消息ID")
  173. user_id = Column(String(64), nullable=False, index=True, comment="用户ID")
  174. # 消息内容
  175. role = Column(Enum("user", "assistant", "system", name="role"), nullable=False, comment="角色")
  176. content = Column(Text, nullable=False, comment="消息内容")
  177. # 轮次信息
  178. round_number = Column(Integer, nullable=False, comment="对话轮次(从1开始)")
  179. # 扩展信息
  180. emotion = Column(String(50), nullable=True, comment="情感标签")
  181. message_metadata = Column(JSON, nullable=True, comment="扩展信息(音频路径、识别耗时等)")
  182. token_count = Column(Integer, nullable=True, comment="Token数量")
  183. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), index=True, comment="创建时间")
  184. # 复合索引
  185. __table_args__ = (
  186. Index("idx_user_round", "user_id", "round_number"),
  187. )
  188. def to_dict(self) -> dict:
  189. """转换为字典"""
  190. return {
  191. "message_id": self.message_id,
  192. "user_id": self.user_id,
  193. "role": self.role,
  194. "content": self.content,
  195. "round_number": self.round_number,
  196. "emotion": self.emotion,
  197. "metadata": self.message_metadata, # 对外保持 metadata 字段名
  198. "token_count": self.token_count,
  199. "created_at": self.created_at.isoformat() if self.created_at else None,
  200. }
  201. class LLMPrompt(Base):
  202. """LLM提示词记录表模型"""
  203. __tablename__ = "llm_prompts"
  204. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  205. prompt_id = Column(String(64), unique=True, nullable=False, index=True, comment="提示词记录ID")
  206. # 关联消息表(外键关联到messages.id)
  207. message_id = Column(PKBigInt, ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True, comment="关联的消息ID")
  208. # 用户信息
  209. user_id = Column(String(64), nullable=False, index=True, comment="用户ID")
  210. round_number = Column(Integer, nullable=False, comment="对话轮次")
  211. # 提示词内容
  212. system_prompt = Column(Text, nullable=False, comment="系统提示词(完整内容)")
  213. runtime_context = Column(Text, nullable=True, comment="运行时上下文")
  214. messages_json = Column(JSON, nullable=False, comment="完整的messages数组(JSON格式)")
  215. # LLM调用参数
  216. model_name = Column(String(100), nullable=False, comment="模型名称")
  217. temperature = Column(String(20), nullable=True, comment="温度参数")
  218. max_tokens = Column(Integer, nullable=True, comment="最大Token数")
  219. stream = Column(Boolean, default=True, comment="是否流式输出")
  220. # 统计信息
  221. prompt_token_count = Column(Integer, nullable=True, comment="提示词Token数")
  222. total_token_count = Column(Integer, nullable=True, comment="总Token数(包含响应)")
  223. # 扩展信息
  224. metadata_json = Column(JSON, nullable=True, comment="扩展信息(调用耗时、错误信息等)")
  225. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), index=True, comment="创建时间")
  226. # 索引
  227. __table_args__ = (
  228. Index("idx_llm_prompt_message", "message_id"),
  229. Index("idx_llm_prompt_user_round", "user_id", "round_number"),
  230. Index("idx_llm_prompt_created", "created_at"),
  231. )
  232. def to_dict(self) -> dict:
  233. """转换为字典"""
  234. return {
  235. "prompt_id": self.prompt_id,
  236. "message_id": self.message_id,
  237. "user_id": self.user_id,
  238. "round_number": self.round_number,
  239. "system_prompt": self.system_prompt,
  240. "runtime_context": self.runtime_context,
  241. "messages": self.messages_json, # 对外保持 messages 字段名
  242. "model_name": self.model_name,
  243. "temperature": self.temperature,
  244. "max_tokens": self.max_tokens,
  245. "stream": self.stream,
  246. "prompt_token_count": self.prompt_token_count,
  247. "total_token_count": self.total_token_count,
  248. "metadata": self.metadata_json, # 对外保持 metadata 字段名
  249. "created_at": self.created_at.isoformat() if self.created_at else None,
  250. }
  251. class ConversationSummary(Base):
  252. """对话总结表模型"""
  253. __tablename__ = "conversation_summaries"
  254. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  255. summary_id = Column(String(64), unique=True, nullable=False, comment="总结ID")
  256. user_id = Column(String(64), nullable=False, index=True, comment="用户ID")
  257. # 总结范围
  258. start_round = Column(Integer, nullable=False, comment="起始轮次")
  259. end_round = Column(Integer, nullable=False, comment="结束轮次")
  260. # 总结内容
  261. summary_text = Column(Text, nullable=False, comment="总结内容")
  262. summary_type = Column(Enum("auto", "manual", name="summary_type"), default="auto", comment="总结类型")
  263. # 统计信息
  264. message_count = Column(Integer, nullable=True, comment="总结的消息数量")
  265. token_count = Column(Integer, nullable=True, comment="总结文本Token数")
  266. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  267. # 复合索引和唯一约束
  268. __table_args__ = (
  269. Index("idx_user_end_round", "user_id", "end_round"),
  270. UniqueConstraint("user_id", "start_round", "end_round", name="uk_user_round_range"),
  271. )
  272. def to_dict(self) -> dict:
  273. """转换为字典"""
  274. return {
  275. "summary_id": self.summary_id,
  276. "user_id": self.user_id,
  277. "start_round": self.start_round,
  278. "end_round": self.end_round,
  279. "summary_text": self.summary_text,
  280. "summary_type": self.summary_type,
  281. "message_count": self.message_count,
  282. "token_count": self.token_count,
  283. "created_at": self.created_at.isoformat() if self.created_at else None,
  284. }
  285. class MissionType(Base):
  286. """任务类型配置"""
  287. __tablename__ = "mission_types"
  288. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  289. type_key = Column(String(64), unique=True, nullable=False, index=True, comment="任务类型唯一标识")
  290. display_name = Column(String(100), nullable=False, comment="任务显示名称")
  291. description = Column(String(255), nullable=True, comment="类型描述")
  292. is_repeatable = Column(Boolean, default=False, comment="是否允许重复触发")
  293. is_active = Column(Boolean, default=True, comment="是否启用")
  294. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展配置")
  295. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  296. updated_at = Column(
  297. TIMESTAMP,
  298. server_default=func.current_timestamp(),
  299. onupdate=func.current_timestamp(),
  300. comment="更新时间"
  301. )
  302. __table_args__ = (
  303. Index("idx_mission_type_active", "is_active"),
  304. )
  305. class MissionEnergyStone(Base):
  306. """任务能量石(首次对话奖励)"""
  307. __tablename__ = "mission_energy_stones"
  308. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  309. stone_id = Column(String(64), unique=True, nullable=False, index=True, comment="能量石ID")
  310. user_id = Column(String(64), nullable=False, index=True, comment="用户ID")
  311. request_id = Column(String(64), nullable=True, comment="触发的请求ID")
  312. appear_reason = Column(String(64), nullable=False, default="first_dialog", comment="触发原因")
  313. mission_type = Column(String(64), nullable=False, default="first_dialog", comment="任务类型")
  314. appear_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="出现时间")
  315. clicked_at = Column(TIMESTAMP, nullable=True, comment="点击时间")
  316. auto_removed = Column(Boolean, default=False, comment="是否由系统自动消失")
  317. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展信息")
  318. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  319. updated_at = Column(
  320. TIMESTAMP,
  321. server_default=func.current_timestamp(),
  322. onupdate=func.current_timestamp(),
  323. comment="更新时间"
  324. )
  325. __table_args__ = (
  326. Index("idx_mission_stone_user", "user_id", "appear_at"),
  327. Index("idx_mission_type_user", "mission_type", "user_id"),
  328. )
  329. class ConversationEnergyStone(Base):
  330. """对话能量石(按时长发放)"""
  331. __tablename__ = "conversation_energy_stones"
  332. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  333. stone_id = Column(String(64), unique=True, nullable=False, index=True, comment="能量石ID")
  334. user_id = Column(String(64), nullable=False, index=True, comment="用户ID")
  335. request_id = Column(String(64), nullable=True, comment="触发的请求ID")
  336. threshold_index = Column(Integer, nullable=False, comment="达到的阈值序号(30秒一档)")
  337. total_duration_ms = Column(BigInteger, nullable=False, comment="达到阈值后的累计说话时长")
  338. duration_increment_ms = Column(Integer, nullable=False, comment="本次增加的说话时长")
  339. appear_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="出现时间")
  340. clicked_at = Column(TIMESTAMP, nullable=True, comment="点击时间")
  341. auto_removed = Column(Boolean, default=False, comment="是否由系统自动消失")
  342. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展信息")
  343. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  344. updated_at = Column(
  345. TIMESTAMP,
  346. server_default=func.current_timestamp(),
  347. onupdate=func.current_timestamp(),
  348. comment="更新时间"
  349. )
  350. __table_args__ = (
  351. UniqueConstraint("user_id", "threshold_index", name="uk_conversation_stone_threshold"),
  352. Index("idx_conversation_stone_user_threshold", "user_id", "threshold_index"),
  353. )
  354. class UserBonusEnergyStone(Base):
  355. """用户赠送的对话能量石数量(活动/回馈等,与 conversation_energy_stones 分开统计,展示时相加)"""
  356. __tablename__ = "user_bonus_energy_stones"
  357. user_id = Column(String(64), primary_key=True, nullable=False, comment="用户ID(关联 users.user_id)")
  358. bonus_conversation_count = Column(Integer, default=0, nullable=False, comment="赠送的对话能量石个数")
  359. updated_at = Column(
  360. TIMESTAMP,
  361. server_default=func.current_timestamp(),
  362. onupdate=func.current_timestamp(),
  363. comment="更新时间",
  364. )
  365. class UserUIState(Base):
  366. """用户前端可视化状态表(页面与提示状态)"""
  367. __tablename__ = "user_ui_states"
  368. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  369. user_id = Column(String(64), unique=True, nullable=False, index=True, comment="用户ID(关联 users.user_id)")
  370. # 页面与路由状态
  371. current_page = Column(String(64), nullable=True, comment="当前业务流程页面标识")
  372. visited_routes = Column(JSON, nullable=True, comment="已访问路由列表")
  373. # 新手引导与提示状态
  374. has_shown_tap_to_chat = Column(Boolean, default=False, nullable=False, comment="是否已展示过 TapToChat 提示")
  375. has_shown_cancel_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过取消按钮提示")
  376. has_shown_send_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过发送按钮提示")
  377. has_shown_progress_bar_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过进度条提示")
  378. # 字幕显示偏好
  379. subtitle_on = Column(Boolean, default=False, nullable=False, comment="是否开启字幕显示")
  380. # 音频偏好(短音效 / 全局背景音乐)
  381. sfx_enabled = Column(Boolean, default=True, nullable=False, comment="是否开启界面短音效")
  382. bgm_enabled = Column(Boolean, default=True, nullable=False, comment="是否开启全局背景音乐")
  383. has_shown_mission_stone_modal = Column(Boolean, default=False, nullable=False, comment="是否已展示过任务能量石弹窗")
  384. has_shown_conversation_stone_modal = Column(Boolean, default=False, nullable=False, comment="是否已展示过对话能量石弹窗")
  385. has_clicked_mission_stone = Column(Boolean, default=False, nullable=False, comment="是否已点击过任务能量石(控制光晕显示)")
  386. has_clicked_conversation_stone = Column(Boolean, default=False, nullable=False, comment="是否已点击过对话能量石(控制光晕显示)")
  387. # 首次商店引导状态
  388. has_shown_first_shop_tooltip = Column(Boolean, default=False, nullable=False, comment="是否已展示过首次商店引导 tips")
  389. has_clicked_shop_in_settings = Column(Boolean, default=False, nullable=False, comment="是否已在设置弹窗中点击过商店(控制红色点点显示)")
  390. # Avatar-C 解锁状态
  391. avatar_c_unlocked = Column(Boolean, default=False, nullable=False, comment="是否已解锁 Avatar-C")
  392. has_shared_avatar = Column(Boolean, default=False, nullable=False, comment="是否已分享头像奖励")
  393. # 当前选中的形象
  394. current_avatar_id = Column(String(64), nullable=True, comment="当前使用的形象ID(关联 avatars.avatar_id)")
  395. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  396. updated_at = Column(
  397. TIMESTAMP,
  398. server_default=func.current_timestamp(),
  399. onupdate=func.current_timestamp(),
  400. comment="更新时间",
  401. )
  402. def to_dict(self) -> dict:
  403. return {
  404. "user_id": self.user_id,
  405. "current_page": self.current_page,
  406. "visited_routes": self.visited_routes or [],
  407. "has_shown_tap_to_chat": self.has_shown_tap_to_chat,
  408. "has_shown_cancel_tooltip": self.has_shown_cancel_tooltip,
  409. "has_shown_send_tooltip": self.has_shown_send_tooltip,
  410. "has_shown_progress_bar_tooltip": self.has_shown_progress_bar_tooltip,
  411. "has_shown_mission_stone_modal": self.has_shown_mission_stone_modal,
  412. "has_shown_conversation_stone_modal": self.has_shown_conversation_stone_modal,
  413. "has_clicked_mission_stone": self.has_clicked_mission_stone,
  414. "has_clicked_conversation_stone": self.has_clicked_conversation_stone,
  415. "has_shown_first_shop_tooltip": self.has_shown_first_shop_tooltip,
  416. "has_clicked_shop_in_settings": self.has_clicked_shop_in_settings,
  417. "subtitle_on": self.subtitle_on,
  418. "sfx_enabled": self.sfx_enabled,
  419. "bgm_enabled": self.bgm_enabled,
  420. "avatar_c_unlocked": self.avatar_c_unlocked,
  421. "has_shared_avatar": self.has_shared_avatar,
  422. "current_avatar_id": self.current_avatar_id,
  423. "created_at": self.created_at.isoformat() if self.created_at else None,
  424. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  425. }
  426. class PageVisitLog(Base):
  427. """页面访问日志表(每次打开任意界面记录一条)"""
  428. __tablename__ = "page_visit_logs"
  429. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  430. user_id = Column(String(64), nullable=True, index=True, comment="用户ID(未登录则为空)")
  431. path = Column(String(512), nullable=False, index=True, comment="访问路径(如 /chatbot)")
  432. visited_at = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False, index=True, comment="访问时间")
  433. __table_args__ = (Index("idx_page_visit_visited_at", "visited_at"),)
  434. def to_dict(self) -> dict:
  435. return {
  436. "id": self.id,
  437. "user_id": self.user_id,
  438. "path": self.path,
  439. "visited_at": self.visited_at.isoformat() if self.visited_at else None,
  440. }
  441. class UserEvent(Base):
  442. """用户事件表(前端埋点:页面/按钮/操作等)"""
  443. __tablename__ = "user_events"
  444. id = Column(PKBigInt, primary_key=True, autoincrement=True, comment="主键")
  445. event_id = Column(String(64), nullable=False, unique=True, index=True, comment="事件ID(客户端或服务端生成,唯一)")
  446. user_id = Column(String(64), nullable=False, index=True, comment="用户ID(关联 users.user_id)")
  447. event_name = Column(String(64), nullable=False, index=True, comment="事件代码,如 page.chatbot__enter(遵循埋点命名规范)")
  448. event_label = Column(String(128), nullable=True, comment="事件中文名称/描述(如“进入聊天页”、“欢迎页开始按钮点击”)")
  449. event_time = Column(TIMESTAMP, nullable=False, comment="事件发生时间(UTC)")
  450. session_id = Column(String(64), nullable=True, index=True, comment="会话ID,同一次前台停留共享")
  451. platform = Column(String(16), nullable=False, comment="平台:ios/android/web")
  452. app_version = Column(String(32), nullable=True, comment="应用版本号,如 v0.1.x")
  453. timezone_offset_minutes = Column(Integer, nullable=True, comment="用户本地相对UTC的分钟偏移量(分钟)")
  454. properties = Column(JSON, nullable=True, comment="扩展属性(page、entry、button_id、duration_seconds 等)")
  455. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), nullable=False, comment="记录创建时间")
  456. __table_args__ = (
  457. Index("idx_user_event_time", "user_id", "event_time"),
  458. Index("idx_event_time_name", "event_time", "event_name"),
  459. )
  460. def to_dict(self) -> dict:
  461. return {
  462. "event_id": self.event_id,
  463. "user_id": self.user_id,
  464. "event_name": self.event_name,
  465. "event_label": self.event_label,
  466. "event_time": self.event_time.isoformat() if self.event_time else None,
  467. "session_id": self.session_id,
  468. "platform": self.platform,
  469. "app_version": self.app_version,
  470. "timezone_offset_minutes": self.timezone_offset_minutes,
  471. "properties": self.properties,
  472. "created_at": self.created_at.isoformat() if self.created_at else None,
  473. }
  474. class UserSpecialScenarioTopicLog(Base):
  475. """用户情景话题对话记录(情景对话模式:用于概率排除与不再触发)"""
  476. __tablename__ = "user_special_scenario_topic_logs"
  477. id = Column(PKBigInt, primary_key=True, autoincrement=True, comment="主键")
  478. user_id = Column(String(64), nullable=False, index=True, comment="用户ID(关联 users.user_id)")
  479. scenario_key = Column(String(32), nullable=False, index=True, comment="场景: window|reading|eating")
  480. topic_key = Column(String(32), nullable=False, index=True, comment="话题键")
  481. conversed_at = Column(TIMESTAMP, nullable=False, server_default=func.current_timestamp(), comment="该场景该话题下首次对话时间")
  482. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="记录创建时间")
  483. __table_args__ = (
  484. UniqueConstraint("user_id", "scenario_key", "topic_key", name="uk_user_scenario_topic"),
  485. Index("idx_user_scenario", "user_id", "scenario_key"),
  486. )
  487. def to_dict(self) -> dict:
  488. return {
  489. "id": self.id,
  490. "user_id": self.user_id,
  491. "scenario_key": self.scenario_key,
  492. "topic_key": self.topic_key,
  493. "conversed_at": self.conversed_at.isoformat() if self.conversed_at else None,
  494. "created_at": self.created_at.isoformat() if self.created_at else None,
  495. }
  496. class UserIntimacy(Base):
  497. """用户亲密度表(三颗心,0-3)"""
  498. __tablename__ = "user_intimacy"
  499. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  500. user_id = Column(String(64), unique=True, nullable=False, index=True, comment="用户ID(关联 users.user_id)")
  501. heart_count = Column(Integer, default=3, nullable=False, comment="当前亲密度心数 0-3")
  502. last_visit_date_at_deduction = Column(String(10), nullable=True, comment="上次扣心时所依据的最后访问日期 YYYY-MM-DD,避免重复扣")
  503. last_light_up_date = Column(String(10), nullable=True, comment="上次手动点亮日期 YYYY-MM-DD,控制每天仅点亮一次")
  504. updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp(), nullable=False, comment="更新时间")
  505. def to_dict(self) -> dict:
  506. return {
  507. "user_id": self.user_id,
  508. "heart_count": self.heart_count,
  509. "last_visit_date_at_deduction": self.last_visit_date_at_deduction,
  510. "last_light_up_date": self.last_light_up_date,
  511. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  512. }
  513. class Avatar(Base):
  514. """形象配置表"""
  515. __tablename__ = "avatars"
  516. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  517. avatar_id = Column(String(64), unique=True, nullable=False, index=True, comment="形象唯一标识")
  518. # 基本信息
  519. avatar_name = Column(String(100), nullable=False, comment="形象名称")
  520. description = Column(String(255), nullable=True, comment="形象描述")
  521. is_active = Column(Boolean, default=True, nullable=False, comment="是否启用")
  522. # 扩展信息
  523. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展配置")
  524. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  525. updated_at = Column(
  526. TIMESTAMP,
  527. server_default=func.current_timestamp(),
  528. onupdate=func.current_timestamp(),
  529. comment="更新时间",
  530. )
  531. __table_args__ = (
  532. Index("idx_avatar_is_active", "is_active"),
  533. )
  534. def to_dict(self) -> dict:
  535. return {
  536. "avatar_id": self.avatar_id,
  537. "avatar_name": self.avatar_name,
  538. "description": self.description,
  539. "is_active": self.is_active,
  540. "metadata": self.metadata_json,
  541. "created_at": self.created_at.isoformat() if self.created_at else None,
  542. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  543. }
  544. class AvatarAsset(Base):
  545. """形象动作/情绪资源表"""
  546. __tablename__ = "avatar_assets"
  547. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  548. avatar_id = Column(String(64), nullable=False, index=True, comment="形象ID")
  549. state = Column(
  550. Enum(
  551. "default",
  552. "positive",
  553. "negative",
  554. "talking",
  555. "idle",
  556. "sleep",
  557. name="avatar_state",
  558. ),
  559. nullable=False,
  560. default="default",
  561. comment="动作/情绪状态",
  562. )
  563. image_url = Column(String(500), nullable=False, comment="GIF 资源 URL")
  564. thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
  565. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展配置")
  566. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  567. updated_at = Column(
  568. TIMESTAMP,
  569. server_default=func.current_timestamp(),
  570. onupdate=func.current_timestamp(),
  571. comment="更新时间",
  572. )
  573. __table_args__ = (
  574. UniqueConstraint("avatar_id", "state", name="uk_avatar_asset_state"),
  575. Index("idx_avatar_asset_avatar_id", "avatar_id"),
  576. )
  577. def to_dict(self) -> dict:
  578. return {
  579. "id": self.id,
  580. "avatar_id": self.avatar_id,
  581. "state": self.state,
  582. "image_url": self.image_url,
  583. "thumbnail_url": self.thumbnail_url,
  584. "metadata": self.metadata_json,
  585. "created_at": self.created_at.isoformat() if self.created_at else None,
  586. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  587. }
  588. class UserAvatar(Base):
  589. """用户已拥有的形象"""
  590. __tablename__ = "user_avatars"
  591. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  592. user_id = Column(String(64), nullable=False, index=True, comment="用户ID")
  593. avatar_id = Column(String(64), nullable=False, index=True, comment="形象ID")
  594. obtained_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="获得时间")
  595. obtained_type = Column(
  596. Enum("default", "register", "level_up", "mission", "purchase", "gift", "event", name="avatar_obtained_type"),
  597. default="default",
  598. comment="获得方式",
  599. )
  600. obtained_source = Column(String(64), nullable=True, comment="获得来源(等级/任务/订单等)")
  601. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展信息")
  602. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  603. __table_args__ = (
  604. UniqueConstraint("user_id", "avatar_id", name="uk_user_avatar"),
  605. Index("idx_user_avatar_user_obtained", "user_id", "obtained_at"),
  606. )
  607. def to_dict(self) -> dict:
  608. return {
  609. "id": self.id,
  610. "user_id": self.user_id,
  611. "avatar_id": self.avatar_id,
  612. "obtained_at": self.obtained_at.isoformat() if self.obtained_at else None,
  613. "obtained_type": self.obtained_type,
  614. "obtained_source": self.obtained_source,
  615. "metadata": self.metadata_json,
  616. "created_at": self.created_at.isoformat() if self.created_at else None,
  617. }
  618. class Feedback(Base):
  619. """用户反馈表模型"""
  620. __tablename__ = "feedbacks"
  621. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  622. feedback_id = Column(String(64), unique=True, nullable=False, index=True, comment="反馈ID")
  623. user_id = Column(String(64), nullable=False, index=True, comment="用户ID")
  624. # 反馈内容
  625. content = Column(Text, nullable=False, comment="反馈内容")
  626. # 状态信息
  627. status = Column(
  628. Enum("pending", "reviewed", "resolved", "closed", name="feedback_status"),
  629. default="pending",
  630. nullable=False,
  631. comment="反馈状态"
  632. )
  633. # 扩展信息
  634. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展信息")
  635. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), index=True, comment="创建时间")
  636. updated_at = Column(
  637. TIMESTAMP,
  638. server_default=func.current_timestamp(),
  639. onupdate=func.current_timestamp(),
  640. comment="更新时间",
  641. )
  642. __table_args__ = (
  643. Index("idx_feedback_user_created", "user_id", "created_at"),
  644. )
  645. def to_dict(self) -> dict:
  646. """转换为字典"""
  647. return {
  648. "feedback_id": self.feedback_id,
  649. "user_id": self.user_id,
  650. "content": self.content,
  651. "status": self.status,
  652. "metadata": self.metadata_json,
  653. "created_at": self.created_at.isoformat() if self.created_at else None,
  654. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  655. }
  656. def _attach_metadata_alias(model_cls):
  657. """Allow using `metadata` keyword/attribute while storing in metadata_json."""
  658. original_init = model_cls.__init__
  659. def __init__(self, *args, **kwargs):
  660. metadata_value = kwargs.pop("metadata", None)
  661. original_init(self, *args, **kwargs)
  662. if metadata_value is not None:
  663. setattr(self, "metadata_json", metadata_value)
  664. model_cls.__init__ = __init__
  665. def getter(self):
  666. return getattr(self, "metadata_json")
  667. def setter(self, value):
  668. setattr(self, "metadata_json", value)
  669. model_cls.metadata = property(getter, setter)
  670. class BackgroundImage(Base):
  671. """背景图片配置表"""
  672. __tablename__ = "background_images"
  673. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  674. image_id = Column(String(64), unique=True, nullable=False, index=True, comment="背景图片唯一标识")
  675. # 基本信息
  676. image_name = Column(String(100), nullable=False, comment="背景图片名称")
  677. image_url = Column(String(500), nullable=False, comment="背景图片URL")
  678. thumbnail_url = Column(String(500), nullable=True, comment="缩略图URL")
  679. # 解锁条件
  680. unlock_type = Column(Enum("default", "level", "purchase", "mission", name="unlock_type"),
  681. default="default", comment="解锁类型")
  682. unlock_level = Column(Integer, nullable=True, comment="解锁所需等级(unlock_type=level时)")
  683. unlock_mission_type = Column(String(64), nullable=True, comment="解锁所需任务类型(unlock_type=mission时)")
  684. # 显示信息
  685. display_order = Column(Integer, default=0, comment="显示顺序")
  686. is_active = Column(Boolean, default=True, comment="是否启用")
  687. description = Column(String(255), nullable=True, comment="背景图片描述")
  688. # 扩展信息
  689. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展配置")
  690. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  691. updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(),
  692. onupdate=func.current_timestamp(), comment="更新时间")
  693. __table_args__ = (
  694. Index("idx_unlock_type", "unlock_type"),
  695. Index("idx_display_order", "display_order"),
  696. )
  697. def to_dict(self) -> dict:
  698. """转换为字典"""
  699. return {
  700. "image_id": self.image_id,
  701. "image_name": self.image_name,
  702. "image_url": self.image_url,
  703. "thumbnail_url": self.thumbnail_url,
  704. "unlock_type": self.unlock_type,
  705. "unlock_level": self.unlock_level,
  706. "unlock_mission_type": self.unlock_mission_type,
  707. "display_order": self.display_order,
  708. "is_active": self.is_active,
  709. "description": self.description,
  710. "metadata": self.metadata_json,
  711. "created_at": self.created_at.isoformat() if self.created_at else None,
  712. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  713. }
  714. class UserBackgroundImage(Base):
  715. """用户背景图片关联表(记录用户已获得的背景图片)"""
  716. __tablename__ = "user_background_images"
  717. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  718. user_id = Column(String(64), nullable=False, index=True, comment="用户ID")
  719. image_id = Column(String(64), nullable=False, index=True, comment="背景图片ID")
  720. # 获得信息
  721. obtained_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="获得时间")
  722. obtained_type = Column(Enum("default", "level_up", "purchase", "mission", "gift", name="obtained_type"),
  723. default="default", comment="获得方式")
  724. obtained_source = Column(String(64), nullable=True, comment="获得来源(任务类型、等级等)")
  725. # 扩展信息
  726. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展信息")
  727. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), comment="创建时间")
  728. __table_args__ = (
  729. UniqueConstraint("user_id", "image_id", name="uk_user_background_image"),
  730. Index("idx_user_obtained", "user_id", "obtained_at"),
  731. )
  732. def to_dict(self) -> dict:
  733. """转换为字典"""
  734. return {
  735. "id": self.id,
  736. "user_id": self.user_id,
  737. "image_id": self.image_id,
  738. "obtained_at": self.obtained_at.isoformat() if self.obtained_at else None,
  739. "obtained_type": self.obtained_type,
  740. "obtained_source": self.obtained_source,
  741. "metadata": self.metadata_json,
  742. "created_at": self.created_at.isoformat() if self.created_at else None,
  743. }
  744. class VerificationCode(Base):
  745. """验证码表模型"""
  746. __tablename__ = "verification_codes"
  747. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  748. code_id = Column(String(64), unique=True, nullable=False, index=True, comment="验证码ID")
  749. # 接收方信息(手机号或邮箱,根据发送类型二选一)
  750. area_code = Column(String(10), nullable=True, comment="手机区号(如 +86)")
  751. phone = Column(String(20), nullable=True, index=True, comment="手机号")
  752. email = Column(String(200), nullable=True, index=True, comment="邮箱")
  753. # 验证码信息
  754. code = Column(String(10), nullable=False, comment="验证码")
  755. send_type = Column(
  756. Enum("domestic_sms", "international_sms", "email", name="send_type"),
  757. nullable=False,
  758. comment="发送类型:国内短信、国外短信、邮箱"
  759. )
  760. # 状态信息
  761. is_sent = Column(Boolean, default=False, nullable=False, comment="是否已发送")
  762. is_valid = Column(Boolean, default=True, nullable=False, comment="是否有效")
  763. # 时间信息
  764. created_at = Column(TIMESTAMP, server_default=func.current_timestamp(), index=True, comment="创建时间")
  765. sent_at = Column(TIMESTAMP, nullable=True, comment="发送时间")
  766. # 扩展信息
  767. metadata_json = Column("metadata", JSON, nullable=True, comment="扩展信息(发送结果、错误信息等)")
  768. __table_args__ = (
  769. Index("idx_verification_code_phone", "phone", "is_valid", "created_at"),
  770. Index("idx_verification_code_email", "email", "is_valid", "created_at"),
  771. Index("idx_verification_code_send_type", "send_type", "is_sent", "is_valid"),
  772. )
  773. def to_dict(self) -> dict:
  774. """转换为字典"""
  775. return {
  776. "code_id": self.code_id,
  777. "area_code": self.area_code,
  778. "phone": self.phone,
  779. "email": self.email,
  780. "code": self.code,
  781. "send_type": self.send_type,
  782. "is_sent": self.is_sent,
  783. "is_valid": self.is_valid,
  784. "created_at": self.created_at.isoformat() if self.created_at else None,
  785. "sent_at": self.sent_at.isoformat() if self.sent_at else None,
  786. "metadata": self.metadata_json,
  787. }
  788. class UserCommunityChoice(Base):
  789. """用户是否退出早期共创社区(V2.0)"""
  790. __tablename__ = "user_community_choice"
  791. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  792. user_id = Column(String(64), unique=True, nullable=False, index=True, comment="用户ID")
  793. opted_out = Column(Boolean, default=False, nullable=False, comment="是否选择退出早期共创社区")
  794. updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
  795. def to_dict(self) -> dict:
  796. return {
  797. "user_id": self.user_id,
  798. "opted_out": self.opted_out,
  799. "updated_at": self.updated_at.isoformat() if self.updated_at else None,
  800. }
  801. class UserEarlyCommunity(Base):
  802. """用户早期共创社区(手机号新用户留邮箱,V2.0)"""
  803. __tablename__ = "user_early_community"
  804. id = Column(PKBigInt, primary_key=True, autoincrement=True)
  805. user_id = Column(String(64), unique=True, nullable=False, index=True, comment="用户ID")
  806. email = Column(String(200), nullable=True, comment="加入早期共创时留的邮箱")
  807. popup_shown = Column(Boolean, default=False, nullable=False, comment="是否已展示过弹窗")
  808. joined_at = Column(TIMESTAMP, nullable=True, comment="加入时间(留邮箱时)")
  809. created_at = Column(TIMESTAMP, server_default=func.current_timestamp())
  810. updated_at = Column(TIMESTAMP, server_default=func.current_timestamp(), onupdate=func.current_timestamp())
  811. def to_dict(self) -> dict:
  812. return {
  813. "user_id": self.user_id,
  814. "email": self.email,
  815. "popup_shown": self.popup_shown,
  816. "joined_at": self.joined_at.isoformat() if self.joined_at else None,
  817. "created_at": self.created_at.isoformat() if self.created_at else None,
  818. }
  819. for _model in (
  820. MissionType,
  821. MissionEnergyStone,
  822. ConversationEnergyStone,
  823. UserBonusEnergyStone,
  824. BackgroundImage,
  825. UserBackgroundImage,
  826. Avatar,
  827. AvatarAsset,
  828. UserAvatar,
  829. ):
  830. _attach_metadata_alias(_model)