migrate_add_avatars.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. """
  2. 数据库迁移脚本:创建形象相关表,并为 user_ui_states 添加 current_avatar_id
  3. 运行: python migrate_add_avatars.py
  4. """
  5. import asyncio
  6. import sys
  7. from typing import Any
  8. import aiomysql
  9. from config import settings
  10. DEFAULT_AVATAR_ID = "avatar_default"
  11. REGISTERED_AVATAR_ID = "avatar_registered_cap"
  12. # 默认形象 GIF 资源
  13. DEFAULT_AVATAR_ASSETS = [
  14. (DEFAULT_AVATAR_ID, "default", "https://www.lyrria.com/img/01-Default.gif"),
  15. (DEFAULT_AVATAR_ID, "positive", "https://www.lyrria.com/img/04-Positive-emotion.gif"),
  16. (DEFAULT_AVATAR_ID, "negative", "https://www.lyrria.com/img/05-Negative-emotion.gif"),
  17. (DEFAULT_AVATAR_ID, "talking", "https://www.lyrria.com/img/06-talking.gif"),
  18. ]
  19. # 注册赠送形象 GIF 资源
  20. REGISTERED_AVATAR_ASSETS = [
  21. (
  22. REGISTERED_AVATAR_ID,
  23. "default",
  24. "https://www.lyrria.com/img/01-Default-Registered.gif",
  25. ),
  26. (
  27. REGISTERED_AVATAR_ID,
  28. "positive",
  29. "https://www.lyrria.com/img/04-Positive-emotion-Registered.gif",
  30. ),
  31. (
  32. REGISTERED_AVATAR_ID,
  33. "negative",
  34. "https://www.lyrria.com/img/05-Negative-emotion-Registered.gif",
  35. ),
  36. (
  37. REGISTERED_AVATAR_ID,
  38. "talking",
  39. "https://www.lyrria.com/img/06-talking-Registered.gif",
  40. ),
  41. ]
  42. # 统一的字符序(用于跨表 join 避免字符集/排序规则不一致报错)
  43. COLLATE_NAME = "utf8mb4_unicode_ci"
  44. async def migrate_create_avatar_tables() -> bool:
  45. """创建形象相关表,并初始化默认数据"""
  46. try:
  47. conn: aiomysql.Connection = await aiomysql.connect(
  48. host=settings.MYSQL_HOST,
  49. port=settings.MYSQL_PORT,
  50. user=settings.MYSQL_USER,
  51. password=settings.MYSQL_PASSWORD,
  52. db=settings.MYSQL_DATABASE,
  53. charset="utf8mb4",
  54. )
  55. async with conn.cursor() as cursor:
  56. # 1. avatars 表
  57. await cursor.execute(
  58. """
  59. SELECT COUNT(*) FROM information_schema.tables
  60. WHERE table_schema = %s AND table_name = 'avatars'
  61. """,
  62. (settings.MYSQL_DATABASE,),
  63. )
  64. exists_row: Any = await cursor.fetchone()
  65. avatars_exists = bool(exists_row and exists_row[0] > 0)
  66. if not avatars_exists:
  67. print("🔄 创建 avatars 表...")
  68. await cursor.execute(
  69. """
  70. CREATE TABLE avatars (
  71. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
  72. avatar_id VARCHAR(64) NOT NULL UNIQUE COMMENT '形象唯一标识',
  73. avatar_name VARCHAR(100) NOT NULL COMMENT '形象名称',
  74. description VARCHAR(255) NULL COMMENT '形象描述',
  75. is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
  76. metadata JSON NULL COMMENT '扩展配置',
  77. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  78. updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  79. INDEX idx_avatar_is_active (is_active),
  80. INDEX idx_avatar_id (avatar_id)
  81. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='形象配置表';
  82. """
  83. )
  84. print("✅ avatars 表创建完成")
  85. else:
  86. print("✅ avatars 表已存在")
  87. # 2. avatar_assets 表
  88. await cursor.execute(
  89. """
  90. SELECT COUNT(*) FROM information_schema.tables
  91. WHERE table_schema = %s AND table_name = 'avatar_assets'
  92. """,
  93. (settings.MYSQL_DATABASE,),
  94. )
  95. exists_row = await cursor.fetchone()
  96. avatar_assets_exists = bool(exists_row and exists_row[0] > 0)
  97. if not avatar_assets_exists:
  98. print("🔄 创建 avatar_assets 表...")
  99. await cursor.execute(
  100. """
  101. CREATE TABLE avatar_assets (
  102. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
  103. avatar_id VARCHAR(64) NOT NULL COMMENT '形象ID',
  104. state ENUM('default', 'positive', 'negative', 'talking', 'idle', 'sleep') NOT NULL DEFAULT 'default' COMMENT '动作/情绪状态',
  105. image_url VARCHAR(500) NOT NULL COMMENT 'GIF 资源 URL',
  106. thumbnail_url VARCHAR(500) NULL COMMENT '缩略图URL',
  107. metadata JSON NULL COMMENT '扩展配置',
  108. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  109. updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  110. UNIQUE KEY uk_avatar_asset_state (avatar_id, state),
  111. INDEX idx_avatar_asset_avatar_id (avatar_id)
  112. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='形象动作资源表';
  113. """
  114. )
  115. print("✅ avatar_assets 表创建完成")
  116. else:
  117. print("✅ avatar_assets 表已存在")
  118. # 3. user_avatars 表
  119. await cursor.execute(
  120. """
  121. SELECT COUNT(*) FROM information_schema.tables
  122. WHERE table_schema = %s AND table_name = 'user_avatars'
  123. """,
  124. (settings.MYSQL_DATABASE,),
  125. )
  126. exists_row = await cursor.fetchone()
  127. user_avatars_exists = bool(exists_row and exists_row[0] > 0)
  128. if not user_avatars_exists:
  129. print("🔄 创建 user_avatars 表...")
  130. await cursor.execute(
  131. """
  132. CREATE TABLE user_avatars (
  133. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
  134. user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
  135. avatar_id VARCHAR(64) NOT NULL COMMENT '形象ID',
  136. obtained_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '获得时间',
  137. obtained_type ENUM('default', 'register', 'level_up', 'mission', 'purchase', 'gift', 'event') NOT NULL DEFAULT 'default' COMMENT '获得方式',
  138. obtained_source VARCHAR(64) NULL COMMENT '获得来源(等级/任务/订单等)',
  139. metadata JSON NULL COMMENT '扩展信息',
  140. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  141. UNIQUE KEY uk_user_avatar (user_id, avatar_id),
  142. INDEX idx_user_avatar_user_obtained (user_id, obtained_at),
  143. INDEX idx_user_avatar_avatar_id (avatar_id)
  144. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户已拥有的形象';
  145. """
  146. )
  147. print("✅ user_avatars 表创建完成")
  148. else:
  149. print("✅ user_avatars 表已存在")
  150. # 4. user_ui_states 表添加 current_avatar_id
  151. await cursor.execute(
  152. """
  153. SELECT COUNT(*) FROM information_schema.columns
  154. WHERE table_schema = %s
  155. AND table_name = 'user_ui_states'
  156. AND column_name = 'current_avatar_id'
  157. """,
  158. (settings.MYSQL_DATABASE,),
  159. )
  160. exists_row = await cursor.fetchone()
  161. column_exists = bool(exists_row and exists_row[0] > 0)
  162. if not column_exists:
  163. print("🔄 为 user_ui_states 添加 current_avatar_id 字段...")
  164. await cursor.execute(
  165. """
  166. ALTER TABLE user_ui_states
  167. ADD COLUMN current_avatar_id VARCHAR(64) NULL COMMENT '当前使用的形象ID(关联 avatars.avatar_id)'
  168. AFTER has_shared_avatar
  169. """
  170. )
  171. print("✅ current_avatar_id 字段添加完成")
  172. else:
  173. print("✅ current_avatar_id 字段已存在")
  174. await conn.commit()
  175. # 5. 初始化 avatars 数据
  176. print("🔄 初始化 avatars 基础数据...")
  177. await cursor.execute(
  178. """
  179. INSERT IGNORE INTO avatars (avatar_id, avatar_name, description, is_active)
  180. VALUES
  181. (%s, %s, %s, 1),
  182. (%s, %s, %s, 1)
  183. """,
  184. (
  185. DEFAULT_AVATAR_ID,
  186. "默认形象",
  187. "基础形象(未注册/通用)",
  188. REGISTERED_AVATAR_ID,
  189. "注册赠送形象",
  190. "注册奖励形象(带帽子)",
  191. ),
  192. )
  193. await conn.commit()
  194. print("✅ avatars 基础数据初始化完成")
  195. # 6. 初始化 avatar_assets 数据
  196. print("🔄 初始化 avatar_assets 数据...")
  197. asset_rows = DEFAULT_AVATAR_ASSETS + REGISTERED_AVATAR_ASSETS
  198. for avatar_id, state, image_url in asset_rows:
  199. await cursor.execute(
  200. """
  201. INSERT IGNORE INTO avatar_assets (avatar_id, state, image_url)
  202. VALUES (%s, %s, %s)
  203. """,
  204. (avatar_id, state, image_url),
  205. )
  206. await conn.commit()
  207. print(f"✅ 已初始化 {len(asset_rows)} 条 avatar_assets 数据")
  208. # 7. 为所有用户分配默认形象
  209. print("🔄 为用户添加默认形象拥有记录...")
  210. await cursor.execute(
  211. """
  212. INSERT IGNORE INTO user_avatars (user_id, avatar_id, obtained_type, obtained_source)
  213. SELECT user_id, %s, 'default', 'system_init'
  214. FROM users
  215. """,
  216. (DEFAULT_AVATAR_ID,),
  217. )
  218. await conn.commit()
  219. print(f"✅ 默认形象写入 user_avatars,受影响行数: {cursor.rowcount}")
  220. # 8. 为注册用户分配注册赠送形象
  221. print("🔄 为注册用户添加注册赠送形象...")
  222. await cursor.execute(
  223. """
  224. INSERT IGNORE INTO user_avatars (user_id, avatar_id, obtained_type, obtained_source)
  225. SELECT user_id, %s, 'register', 'register_bonus'
  226. FROM users
  227. WHERE user_type = 'registered'
  228. """,
  229. (REGISTERED_AVATAR_ID,),
  230. )
  231. await conn.commit()
  232. print(f"✅ 注册赠送形象写入 user_avatars,受影响行数: {cursor.rowcount}")
  233. # 9. 设置当前形象(注册用户优先注册赠送,其余默认)
  234. print("🔄 设置 user_ui_states.current_avatar_id ...")
  235. # 注意:部分历史表的字符集/排序规则不一致,使用显式 COLLATE 避免 1267 错误
  236. await cursor.execute(
  237. f"""
  238. UPDATE user_ui_states ui
  239. JOIN users u
  240. ON ui.user_id COLLATE {COLLATE_NAME} = u.user_id COLLATE {COLLATE_NAME}
  241. SET ui.current_avatar_id = %s
  242. WHERE ui.current_avatar_id IS NULL
  243. AND u.user_type = 'registered'
  244. """,
  245. (REGISTERED_AVATAR_ID,),
  246. )
  247. await conn.commit()
  248. await cursor.execute(
  249. f"""
  250. UPDATE user_ui_states
  251. SET current_avatar_id = %s
  252. WHERE current_avatar_id IS NULL
  253. """,
  254. (DEFAULT_AVATAR_ID,),
  255. )
  256. await conn.commit()
  257. print("✅ current_avatar_id 初始化完成")
  258. conn.close()
  259. return True
  260. except Exception:
  261. import traceback
  262. traceback.print_exc()
  263. return False
  264. if __name__ == "__main__":
  265. if len(sys.argv) > 1 and sys.argv[1] == "--force":
  266. confirm = "y"
  267. else:
  268. confirm = input("\n是否继续执行迁移? (y/n): ")
  269. if confirm.lower() != "y":
  270. sys.exit(0)
  271. ok = asyncio.run(migrate_create_avatar_tables())
  272. if not ok:
  273. sys.exit(1)