models_db.py 43 KB

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