migrate_slot_tables_seed.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. """
  2. 迁移脚本:创建槽位化三张表,并初始化 4 个幕布的 6 槽位与商品-槽位规则。
  3. 执行:
  4. python migrations/migrate_slot_tables_seed.py
  5. 或:
  6. python migrations/migrate_slot_tables_seed.py --force
  7. """
  8. import asyncio
  9. import sys
  10. from pathlib import Path
  11. from typing import Any
  12. import aiomysql
  13. backend_dir = Path(__file__).resolve().parents[1]
  14. if str(backend_dir) not in sys.path:
  15. sys.path.insert(0, str(backend_dir))
  16. from config import settings
  17. BACKDROP_ITEM_CODES = (
  18. "fresh_green_wallpaper",
  19. "midnight_blue_wallpaper",
  20. "mocha_wallpaper",
  21. "springtime_wallpaper",
  22. )
  23. SLOT_ROWS = [
  24. ("slot_01_left_col_row2_mid", "左侧中段位", "Left Mid Slot", "floor", "none", 2, 1, 1, 1, "grid", 0, 0, None, None, 5, 10),
  25. ("slot_02_left_col_row4_5", "左下双格位", "Left Bottom Double Slot", "floor", "none", 4, 1, 2, 1, "grid", 0, 0, None, None, 6, 20),
  26. ("slot_03_center_2x3", "中部六格位", "Center 2x3 Slot", "wall", "window", 2, 2, 2, 3, "grid", 0, 0, None, None, 2, 30),
  27. ("slot_04_row4_col2", "第四行第二列位", "Row 4 Column 2 Slot", "floor", "seat", 4, 2, 1, 1, "grid", 0, 0, None, None, 7, 40),
  28. ("slot_05_row4_col3_4", "第四行右侧双格位", "Row 4 Right Double Slot", "floor", "none", 4, 3, 1, 2, "grid", 0, 0, None, None, 6, 50),
  29. ("slot_06_row4_col3_4_top_center_48x24", "右侧双格顶部小位", "Top Center Mini Slot", "floor", "none", 4, 3, 1, 2, "pixel", 72, 0, 48, 24, 8, 60),
  30. ]
  31. # item_code -> slot_codes
  32. ITEM_SLOT_RULES: dict[str, list[str]] = {
  33. "stump_stool": ["slot_04_row4_col2"],
  34. "forest_sofa": ["slot_04_row4_col2"],
  35. "vintage_table": ["slot_05_row4_col3_4"],
  36. "stump_table": ["slot_05_row4_col3_4"],
  37. "mushroom_table": ["slot_05_row4_col3_4"],
  38. "french_window": ["slot_03_center_2x3"],
  39. "garden_balcony": ["slot_03_center_2x3"],
  40. "forest_round_window": ["slot_03_center_2x3"],
  41. "terra_cotta_window": ["slot_03_center_2x3"],
  42. "coffee_mug": ["slot_06_row4_col3_4_top_center_48x24"],
  43. "snacks": ["slot_06_row4_col3_4_top_center_48x24"],
  44. "books": ["slot_06_row4_col3_4_top_center_48x24"],
  45. "energy_ball": ["slot_06_row4_col3_4_top_center_48x24"],
  46. "chinese_new_year_bloom": ["slot_02_left_col_row4_5"],
  47. "chocolate_tree": ["slot_02_left_col_row4_5"],
  48. "lilybell_lamp": ["slot_02_left_col_row4_5"],
  49. "nut_wind_bell": ["slot_01_left_col_row2_mid"],
  50. "photo_frame": ["slot_01_left_col_row2_mid"],
  51. }
  52. CREATE_SQL = """
  53. CREATE TABLE IF NOT EXISTS `chatbot_backdrop_slots` (
  54. `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  55. `backdrop_item_id` bigint(20) unsigned NOT NULL,
  56. `slot_code` varchar(64) NOT NULL,
  57. `slot_name` varchar(100) DEFAULT NULL,
  58. `slot_name_zh` varchar(100) DEFAULT NULL,
  59. `slot_name_en` varchar(100) DEFAULT NULL,
  60. `placement_type` enum('wall','floor') NOT NULL DEFAULT 'floor',
  61. `anchor_type` varchar(32) NOT NULL DEFAULT 'none',
  62. `start_row` tinyint(3) unsigned NOT NULL,
  63. `start_col` tinyint(3) unsigned NOT NULL,
  64. `size_rows` tinyint(3) unsigned NOT NULL DEFAULT '1',
  65. `size_cols` tinyint(3) unsigned NOT NULL DEFAULT '1',
  66. `render_mode` enum('grid','pixel') NOT NULL DEFAULT 'grid',
  67. `offset_x` smallint(6) NOT NULL DEFAULT '0',
  68. `offset_y` smallint(6) NOT NULL DEFAULT '0',
  69. `pixel_width` smallint(5) unsigned DEFAULT NULL,
  70. `pixel_height` smallint(5) unsigned DEFAULT NULL,
  71. `z_index` int(11) NOT NULL DEFAULT '0',
  72. `is_active` tinyint(1) NOT NULL DEFAULT '1',
  73. `sort_order` int(11) NOT NULL DEFAULT '0',
  74. `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  75. `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  76. PRIMARY KEY (`id`),
  77. UNIQUE KEY `uk_backdrop_slot_code` (`backdrop_item_id`,`slot_code`),
  78. KEY `idx_backdrop_slot_active` (`backdrop_item_id`,`is_active`)
  79. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  80. CREATE TABLE IF NOT EXISTS `shop_item_slot_rules` (
  81. `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  82. `item_id` bigint(20) unsigned NOT NULL,
  83. `backdrop_item_id` bigint(20) unsigned NOT NULL,
  84. `slot_code` varchar(64) NOT NULL,
  85. `is_default` tinyint(1) NOT NULL DEFAULT '0',
  86. `priority` int(11) NOT NULL DEFAULT '0',
  87. `is_active` tinyint(1) NOT NULL DEFAULT '1',
  88. `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  89. `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  90. PRIMARY KEY (`id`),
  91. UNIQUE KEY `uk_item_backdrop_slot` (`item_id`,`backdrop_item_id`,`slot_code`)
  92. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  93. CREATE TABLE IF NOT EXISTS `user_chatbot_slot_placements` (
  94. `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  95. `user_id` bigint(20) unsigned NOT NULL,
  96. `backdrop_item_id` bigint(20) unsigned NOT NULL,
  97. `slot_code` varchar(64) NOT NULL,
  98. `user_asset_id` bigint(20) unsigned NOT NULL,
  99. `item_id` bigint(20) unsigned NOT NULL,
  100. `z_index` int(11) NOT NULL DEFAULT '0',
  101. `image_name_snapshot` varchar(255) NOT NULL,
  102. `placed_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  103. `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  104. PRIMARY KEY (`id`),
  105. UNIQUE KEY `uk_user_backdrop_slot` (`user_id`,`backdrop_item_id`,`slot_code`)
  106. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  107. """
  108. async def migrate() -> bool:
  109. conn: aiomysql.Connection | None = None
  110. try:
  111. conn = await aiomysql.connect(
  112. host=settings.MYSQL_HOST,
  113. port=settings.MYSQL_PORT,
  114. user=settings.MYSQL_USER,
  115. password=settings.MYSQL_PASSWORD,
  116. db=settings.MYSQL_DATABASE,
  117. charset="utf8mb4",
  118. autocommit=False,
  119. )
  120. async with conn.cursor() as cursor:
  121. for stmt in [s.strip() for s in CREATE_SQL.split(";") if s.strip()]:
  122. await cursor.execute(stmt)
  123. print("✅ 表结构已创建/确认")
  124. # 兼容历史表结构:补充双语槽位名称字段
  125. await cursor.execute(
  126. """
  127. SELECT COUNT(*)
  128. FROM information_schema.columns
  129. WHERE table_schema = %s
  130. AND table_name = 'chatbot_backdrop_slots'
  131. AND column_name = 'slot_name_zh'
  132. """,
  133. (settings.MYSQL_DATABASE,),
  134. )
  135. has_slot_name_zh = (await cursor.fetchone() or [0])[0] > 0
  136. if not has_slot_name_zh:
  137. await cursor.execute(
  138. """
  139. ALTER TABLE chatbot_backdrop_slots
  140. ADD COLUMN slot_name_zh varchar(100) DEFAULT NULL
  141. """
  142. )
  143. await cursor.execute(
  144. """
  145. SELECT COUNT(*)
  146. FROM information_schema.columns
  147. WHERE table_schema = %s
  148. AND table_name = 'chatbot_backdrop_slots'
  149. AND column_name = 'slot_name_en'
  150. """,
  151. (settings.MYSQL_DATABASE,),
  152. )
  153. has_slot_name_en = (await cursor.fetchone() or [0])[0] > 0
  154. if not has_slot_name_en:
  155. await cursor.execute(
  156. """
  157. ALTER TABLE chatbot_backdrop_slots
  158. ADD COLUMN slot_name_en varchar(100) DEFAULT NULL
  159. """
  160. )
  161. await cursor.execute(
  162. f"SELECT id, item_code FROM shop_items WHERE item_code IN ({','.join(['%s']*len(BACKDROP_ITEM_CODES))})",
  163. BACKDROP_ITEM_CODES,
  164. )
  165. backdrop_rows = await cursor.fetchall()
  166. backdrop_code_to_id = {str(r[1]): int(r[0]) for r in backdrop_rows}
  167. missing_backdrops = [code for code in BACKDROP_ITEM_CODES if code not in backdrop_code_to_id]
  168. if missing_backdrops:
  169. print(f"❌ shop_items 缺少幕布 item_code: {missing_backdrops}")
  170. await conn.rollback()
  171. return False
  172. backdrop_ids = [backdrop_code_to_id[code] for code in BACKDROP_ITEM_CODES]
  173. for backdrop_id in backdrop_ids:
  174. for row in SLOT_ROWS:
  175. (
  176. slot_code,
  177. slot_name,
  178. slot_name_en,
  179. placement_type,
  180. anchor_type,
  181. start_row,
  182. start_col,
  183. size_rows,
  184. size_cols,
  185. render_mode,
  186. offset_x,
  187. offset_y,
  188. pixel_width,
  189. pixel_height,
  190. z_index,
  191. sort_order,
  192. ) = row
  193. await cursor.execute(
  194. """
  195. INSERT INTO chatbot_backdrop_slots
  196. (backdrop_item_id, slot_code, slot_name, slot_name_zh, slot_name_en, placement_type, anchor_type,
  197. start_row, start_col, size_rows, size_cols, render_mode,
  198. offset_x, offset_y, pixel_width, pixel_height, z_index, is_active, sort_order)
  199. VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,1,%s)
  200. ON DUPLICATE KEY UPDATE
  201. slot_name=VALUES(slot_name),
  202. slot_name_zh=VALUES(slot_name_zh),
  203. slot_name_en=VALUES(slot_name_en),
  204. placement_type=VALUES(placement_type),
  205. anchor_type=VALUES(anchor_type),
  206. start_row=VALUES(start_row),
  207. start_col=VALUES(start_col),
  208. size_rows=VALUES(size_rows),
  209. size_cols=VALUES(size_cols),
  210. render_mode=VALUES(render_mode),
  211. offset_x=VALUES(offset_x),
  212. offset_y=VALUES(offset_y),
  213. pixel_width=VALUES(pixel_width),
  214. pixel_height=VALUES(pixel_height),
  215. z_index=VALUES(z_index),
  216. is_active=1,
  217. sort_order=VALUES(sort_order),
  218. updated_at=NOW()
  219. """,
  220. (
  221. backdrop_id,
  222. slot_code,
  223. slot_name,
  224. slot_name,
  225. slot_name_en,
  226. placement_type,
  227. anchor_type,
  228. start_row,
  229. start_col,
  230. size_rows,
  231. size_cols,
  232. render_mode,
  233. offset_x,
  234. offset_y,
  235. pixel_width,
  236. pixel_height,
  237. z_index,
  238. sort_order,
  239. ),
  240. )
  241. print("✅ 槽位初始化完成")
  242. # 商品映射
  243. item_codes = list(ITEM_SLOT_RULES.keys())
  244. await cursor.execute(
  245. f"SELECT id, item_code FROM shop_items WHERE item_code IN ({','.join(['%s']*len(item_codes))})",
  246. tuple(item_codes),
  247. )
  248. item_rows = await cursor.fetchall()
  249. code_to_id = {r[1]: int(r[0]) for r in item_rows}
  250. missing = [c for c in item_codes if c not in code_to_id]
  251. if missing:
  252. print(f"❌ shop_items 缺少 item_code: {missing}")
  253. await conn.rollback()
  254. return False
  255. for item_code, slot_codes in ITEM_SLOT_RULES.items():
  256. item_id = code_to_id[item_code]
  257. for backdrop_id in backdrop_ids:
  258. for idx, slot_code in enumerate(slot_codes):
  259. await cursor.execute(
  260. """
  261. INSERT INTO shop_item_slot_rules
  262. (item_id, backdrop_item_id, slot_code, is_default, priority, is_active)
  263. VALUES (%s,%s,%s,%s,%s,1)
  264. ON DUPLICATE KEY UPDATE
  265. is_default=VALUES(is_default),
  266. priority=VALUES(priority),
  267. is_active=1,
  268. updated_at=NOW()
  269. """,
  270. (item_id, backdrop_id, slot_code, 1 if idx == 0 else 0, 10 + idx * 10),
  271. )
  272. print("✅ 商品-槽位规则初始化完成")
  273. await conn.commit()
  274. print("🎉 migrate_slot_tables_seed 完成")
  275. return True
  276. except Exception as exc: # noqa: BLE001
  277. print(f"❌ 迁移失败: {exc}")
  278. if conn is not None:
  279. try:
  280. await conn.rollback()
  281. except Exception:
  282. pass
  283. return False
  284. finally:
  285. if conn is not None:
  286. conn.close()
  287. if __name__ == "__main__":
  288. if len(sys.argv) > 1 and sys.argv[1] == "--force":
  289. confirm = "y"
  290. else:
  291. confirm = input("\n是否继续创建槽位表并初始化数据? (y/n): ").strip().lower()
  292. if confirm != "y":
  293. sys.exit(0)
  294. ok = asyncio.run(migrate())
  295. sys.exit(0 if ok else 1)