init_db.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. """Initialize the 996cq MySQL database schema.
  2. Reads connection from .env (CQ2_DB_HOST/PORT/USER/PASSWORD/NAME),
  3. creates 5 tables (users, servers, zones, runs, task_logs), then verifies.
  4. Idempotent: uses ``CREATE TABLE IF NOT EXISTS`` so re-running is safe.
  5. Usage::
  6. python scripts/init_db.py # create all tables
  7. python scripts/init_db.py --drop # DROP all first, then create (DESTRUCTIVE)
  8. python scripts/init_db.py --check # only check existing tables
  9. """
  10. from __future__ import annotations
  11. import argparse
  12. import os
  13. import sys
  14. from pathlib import Path
  15. # allow running without `uv run` from anywhere
  16. _HERE = Path(__file__).resolve().parent
  17. sys.path.insert(0, str(_HERE.parent / "src"))
  18. def _load_env() -> dict[str, str]:
  19. """Minimal .env loader (avoid dependency on cq2_sdk.env_loader
  20. so this script can run before the SDK is fully wired up)."""
  21. env = {}
  22. env_path = _HERE.parent / ".env"
  23. if env_path.exists():
  24. for line in env_path.read_text(encoding="utf-8").splitlines():
  25. line = line.strip()
  26. if not line or line.startswith("#") or "=" not in line:
  27. continue
  28. k, v = line.split("=", 1)
  29. env[k.strip()] = v.strip().strip('"').strip("'")
  30. # env vars override file
  31. for k in ("CQ2_DB_HOST", "CQ2_DB_PORT", "CQ2_DB_USER", "CQ2_DB_PASSWORD", "CQ2_DB_NAME"):
  32. if k in os.environ:
  33. env[k] = os.environ[k]
  34. required = ["CQ2_DB_HOST", "CQ2_DB_PORT", "CQ2_DB_USER", "CQ2_DB_PASSWORD", "CQ2_DB_NAME"]
  35. missing = [k for k in required if k not in env]
  36. if missing:
  37. print(f"ERROR: missing env vars: {missing}")
  38. print(f" set them in .env or as shell env vars")
  39. sys.exit(1)
  40. return env
  41. DDL_STATEMENTS = [
  42. # users (no FK)
  43. """
  44. CREATE TABLE IF NOT EXISTS users (
  45. id INT PRIMARY KEY AUTO_INCREMENT,
  46. username VARCHAR(64) UNIQUE,
  47. phone VARCHAR(32) UNIQUE,
  48. password_hash VARCHAR(255),
  49. token TEXT,
  50. token_expires_at TIMESTAMP NULL,
  51. last_login_at TIMESTAMP NULL,
  52. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  53. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  54. """,
  55. # servers (no FK)
  56. """
  57. CREATE TABLE IF NOT EXISTS servers (
  58. id INT PRIMARY KEY AUTO_INCREMENT,
  59. server_name VARCHAR(128) UNIQUE,
  60. server_id INT,
  61. game_id INT DEFAULT 206923,
  62. first_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  63. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  64. """,
  65. # zones (FK -> servers)
  66. """
  67. CREATE TABLE IF NOT EXISTS zones (
  68. id INT PRIMARY KEY AUTO_INCREMENT,
  69. server_id INT NOT NULL,
  70. zone_id VARCHAR(64),
  71. zone_name VARCHAR(128),
  72. host VARCHAR(128),
  73. port INT,
  74. open_time TIMESTAMP,
  75. status VARCHAR(32),
  76. fetched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  77. UNIQUE KEY uniq_zone (server_id, zone_id, open_time),
  78. FOREIGN KEY (server_id) REFERENCES servers(id)
  79. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  80. """,
  81. # runs (FK -> users, servers, zones)
  82. """
  83. CREATE TABLE IF NOT EXISTS runs (
  84. id INT PRIMARY KEY AUTO_INCREMENT,
  85. user_id INT NOT NULL,
  86. server_id INT NOT NULL,
  87. zone_id INT NOT NULL,
  88. started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  89. completed_at TIMESTAMP NULL,
  90. current_task VARCHAR(8),
  91. status VARCHAR(32),
  92. snapshot_path VARCHAR(255),
  93. FOREIGN KEY (user_id) REFERENCES users(id),
  94. FOREIGN KEY (server_id) REFERENCES servers(id),
  95. FOREIGN KEY (zone_id) REFERENCES zones(id),
  96. INDEX idx_user_status (user_id, status)
  97. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  98. """,
  99. # task_logs (FK -> runs)
  100. # NOTE: MySQL 5.7 严格要求每个非首列 TIMESTAMP 必须显式 NULL/default,
  101. # 否则报 1067 "Invalid default value"。
  102. """
  103. CREATE TABLE IF NOT EXISTS task_logs (
  104. id INT PRIMARY KEY AUTO_INCREMENT,
  105. run_id INT NOT NULL,
  106. task_id VARCHAR(8),
  107. success TINYINT(1),
  108. error TEXT,
  109. elapsed_seconds DOUBLE,
  110. started_at TIMESTAMP NULL,
  111. finished_at TIMESTAMP NULL,
  112. FOREIGN KEY (run_id) REFERENCES runs(id),
  113. INDEX idx_run_task (run_id, task_id)
  114. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  115. """,
  116. ]
  117. EXPECTED_TABLES = {"users", "servers", "zones", "runs", "task_logs"}
  118. DROP_ORDER = ["task_logs", "runs", "zones", "servers", "users"]
  119. def main() -> int:
  120. parser = argparse.ArgumentParser(description="Initialize 996cq schema")
  121. parser.add_argument("--drop", action="store_true",
  122. help="DROP all tables first (DESTRUCTIVE)")
  123. parser.add_argument("--check", action="store_true",
  124. help="Only check existing tables, don't create")
  125. args = parser.parse_args()
  126. import pymysql # imported lazily so --help works without pymysql
  127. env = _load_env()
  128. print(f"Connecting to {env['CQ2_DB_HOST']}:{env['CQ2_DB_PORT']} "
  129. f"as {env['CQ2_DB_USER']}@{env['CQ2_DB_NAME']}")
  130. conn = pymysql.connect(
  131. host=env["CQ2_DB_HOST"],
  132. port=int(env["CQ2_DB_PORT"]),
  133. user=env["CQ2_DB_USER"],
  134. password=env["CQ2_DB_PASSWORD"],
  135. database=env["CQ2_DB_NAME"],
  136. charset="utf8mb4",
  137. connect_timeout=10,
  138. autocommit=True,
  139. )
  140. try:
  141. with conn.cursor() as cur:
  142. if args.drop:
  143. print("--drop: dropping existing tables (DESTRUCTIVE)")
  144. for tbl in DROP_ORDER:
  145. cur.execute(f"DROP TABLE IF EXISTS {tbl}")
  146. print(f" dropped: {tbl}")
  147. if args.check:
  148. cur.execute("SHOW TABLES")
  149. existing = {r[0] for r in cur.fetchall()}
  150. print(f"\n[check] existing tables: {sorted(existing)}")
  151. missing = EXPECTED_TABLES - existing
  152. if missing:
  153. print(f"[check] MISSING: {sorted(missing)}")
  154. return 1
  155. print("[check] all 5 expected tables present")
  156. return 0
  157. # Create
  158. print("\nCreating tables (idempotent via IF NOT EXISTS):")
  159. for ddl in DDL_STATEMENTS:
  160. # Get table name from DDL for nicer logging
  161. first_line = ddl.strip().splitlines()[0]
  162. tbl_name = first_line.split("EXISTS ")[1].split(" ")[0].strip("(`")
  163. cur.execute(ddl)
  164. print(f" OK: {tbl_name}")
  165. # Verify
  166. cur.execute("SHOW TABLES")
  167. existing = {r[0] for r in cur.fetchall()}
  168. print(f"\n[verify] existing tables: {sorted(existing)}")
  169. missing = EXPECTED_TABLES - existing
  170. if missing:
  171. print(f"[verify] FAILED: missing {sorted(missing)}")
  172. return 1
  173. print(f"[verify] OK: all 5 tables present")
  174. # Show schema for each
  175. print("\nSchema details:")
  176. for tbl in sorted(EXPECTED_TABLES):
  177. cur.execute(
  178. "SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY "
  179. "FROM information_schema.COLUMNS "
  180. "WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s "
  181. "ORDER BY ORDINAL_POSITION",
  182. (env["CQ2_DB_NAME"], tbl),
  183. )
  184. cols = cur.fetchall()
  185. print(f"\n [{tbl}] ({len(cols)} columns)")
  186. for c in cols:
  187. marker = " PK" if "PRI" in c[3] else (" UNI" if "UNI" in c[3] else "")
  188. print(f" {c[0]:<22} {c[1]:<28} NULL={c[2]}{marker}")
  189. print("\n[done] schema initialized.")
  190. return 0
  191. finally:
  192. conn.close()
  193. if __name__ == "__main__":
  194. sys.exit(main())