db.py 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204
  1. import os
  2. import sqlite3
  3. import threading
  4. from queue import LifoQueue
  5. from typing import Any, Iterable, Optional
  6. from flask import g
  7. from werkzeug.security import generate_password_hash
  8. from .context import get_config
  9. from .core import isoformat, utcnow
  10. try:
  11. import pymysql
  12. import pymysql.cursors
  13. except Exception:
  14. pymysql = None
  15. IntegrityError = sqlite3.IntegrityError
  16. _mysql_pool_lock = threading.Lock()
  17. _mysql_pool: LifoQueue | None = None
  18. _active_backend_lock = threading.Lock()
  19. _active_backend: str | None = None
  20. _migrating_lock = threading.Lock()
  21. _migrating = False
  22. _db_switch_lock = threading.Lock()
  23. def is_migrating() -> bool:
  24. with _migrating_lock:
  25. return bool(_migrating)
  26. def _set_migrating(v: bool) -> None:
  27. global _migrating
  28. with _migrating_lock:
  29. _migrating = bool(v)
  30. def reset_mysql_pool() -> None:
  31. global _mysql_pool
  32. with _mysql_pool_lock:
  33. pool = _mysql_pool
  34. _mysql_pool = None
  35. if pool is None:
  36. return
  37. while True:
  38. try:
  39. conn = pool.get_nowait()
  40. except Exception:
  41. break
  42. try:
  43. conn.close()
  44. except Exception:
  45. pass
  46. def _sqlite_connect(path: str) -> sqlite3.Connection:
  47. conn = sqlite3.connect(path)
  48. conn.row_factory = sqlite3.Row
  49. conn.execute("PRAGMA foreign_keys=ON;")
  50. return conn
  51. def get_ctl_db() -> sqlite3.Connection:
  52. db: Optional[sqlite3.Connection] = g.get("ctl_db")
  53. if db is not None:
  54. return db
  55. config = get_config()
  56. db = _sqlite_connect(str(config.database_path))
  57. g.ctl_db = db
  58. return db
  59. def ctl_execute(sql: str, params: Iterable[Any] = ()) -> sqlite3.Cursor:
  60. db = get_ctl_db()
  61. cur = db.execute(sql, list(params))
  62. db.commit()
  63. return cur
  64. def ctl_fetch_one(sql: str, params: Iterable[Any] = ()) -> Optional[sqlite3.Row]:
  65. db = get_ctl_db()
  66. cur = db.execute(sql, list(params))
  67. return cur.fetchone()
  68. def ctl_fetch_all(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
  69. db = get_ctl_db()
  70. cur = db.execute(sql, list(params))
  71. return cur.fetchall()
  72. def _ctl_ensure_schema() -> None:
  73. config = get_config()
  74. conn = _sqlite_connect(str(config.database_path))
  75. try:
  76. conn.executescript(
  77. """
  78. CREATE TABLE IF NOT EXISTS app_settings (
  79. key TEXT PRIMARY KEY,
  80. category TEXT NOT NULL DEFAULT 'GENERAL',
  81. value TEXT NOT NULL,
  82. updated_at TEXT NOT NULL
  83. );
  84. """
  85. )
  86. cols = []
  87. try:
  88. cols = [r["name"] for r in conn.execute("PRAGMA table_info(app_settings)").fetchall()]
  89. except Exception:
  90. cols = []
  91. if "category" not in cols:
  92. conn.execute("ALTER TABLE app_settings ADD COLUMN category TEXT NOT NULL DEFAULT 'GENERAL'")
  93. conn.commit()
  94. finally:
  95. conn.close()
  96. def _ctl_get_value(key: str) -> str | None:
  97. config = get_config()
  98. conn = _sqlite_connect(str(config.database_path))
  99. try:
  100. try:
  101. row = conn.execute("SELECT value FROM app_settings WHERE key = ?", (key,)).fetchone()
  102. except Exception:
  103. return None
  104. if row is None:
  105. return None
  106. return (row["value"] or "").strip() or None
  107. finally:
  108. conn.close()
  109. def _ctl_set_value(key: str, value: str, category: str = "GENERAL") -> None:
  110. now = isoformat(utcnow())
  111. config = get_config()
  112. conn = _sqlite_connect(str(config.database_path))
  113. try:
  114. conn.execute(
  115. """
  116. INSERT INTO app_settings (key, category, value, updated_at)
  117. VALUES (?, ?, ?, ?)
  118. ON CONFLICT(key) DO UPDATE SET
  119. category = excluded.category,
  120. value = excluded.value,
  121. updated_at = excluded.updated_at
  122. """,
  123. (key, category, value, now),
  124. )
  125. conn.commit()
  126. finally:
  127. conn.close()
  128. def _ctl_delete_value(key: str) -> None:
  129. config = get_config()
  130. conn = _sqlite_connect(str(config.database_path))
  131. try:
  132. conn.execute("DELETE FROM app_settings WHERE key = ?", (key,))
  133. conn.commit()
  134. finally:
  135. conn.close()
  136. def _resolve_backend_from_control() -> str | None:
  137. v = _ctl_get_value("DB_ACTIVE")
  138. if not v:
  139. return None
  140. v2 = v.strip().lower()
  141. if v2 in {"sqlite", "mysql"}:
  142. return v2
  143. return None
  144. def get_active_backend() -> str:
  145. global _active_backend
  146. with _active_backend_lock:
  147. cached = _active_backend
  148. if cached in {"sqlite", "mysql"}:
  149. return cached
  150. v = _resolve_backend_from_control()
  151. with _active_backend_lock:
  152. _active_backend = v
  153. return v or "sqlite"
  154. def set_active_backend(backend: str) -> None:
  155. b = (backend or "").strip().lower()
  156. if b not in {"sqlite", "mysql"}:
  157. b = "sqlite"
  158. with _active_backend_lock:
  159. global _active_backend
  160. prev = _active_backend
  161. _active_backend = b
  162. if prev != b and b == "mysql":
  163. reset_mysql_pool()
  164. def get_db() -> sqlite3.Connection:
  165. db = g.get("db")
  166. if db is not None:
  167. return db
  168. config = get_config()
  169. backend = _resolve_backend_from_control()
  170. if backend is None:
  171. backend = "mysql" if (config.mysql_host or "").strip() else "sqlite"
  172. set_active_backend(backend)
  173. if backend == "mysql":
  174. host = (_ctl_get_value("MYSQL_HOST") or config.mysql_host or "").strip()
  175. user = (_ctl_get_value("MYSQL_USER") or config.mysql_user or "").strip()
  176. database = (_ctl_get_value("MYSQL_DATABASE") or config.mysql_database or "").strip()
  177. port = int((_ctl_get_value("MYSQL_PORT") or str(config.mysql_port or 3306)).strip() or 3306)
  178. password = (_ctl_get_value("MYSQL_PASSWORD") or config.mysql_password or "").strip()
  179. if not host or not user or not database:
  180. backend = "sqlite"
  181. set_active_backend("sqlite")
  182. else:
  183. if pymysql is None:
  184. raise RuntimeError("pymysql_required")
  185. global IntegrityError
  186. IntegrityError = getattr(pymysql.err, "IntegrityError", Exception)
  187. global _mysql_pool
  188. with _mysql_pool_lock:
  189. if _mysql_pool is None:
  190. size = int(os.environ.get("MYSQL_POOL_SIZE", "10"))
  191. _mysql_pool = LifoQueue(maxsize=max(1, min(size, 50)))
  192. pool = _mysql_pool
  193. conn = None
  194. try:
  195. conn = pool.get_nowait()
  196. except Exception:
  197. conn = None
  198. if conn is not None:
  199. try:
  200. if not getattr(conn, "open", True):
  201. raise RuntimeError("conn_closed")
  202. conn.ping(reconnect=True)
  203. except Exception:
  204. try:
  205. conn.close()
  206. except Exception:
  207. pass
  208. conn = None
  209. if conn is None:
  210. conn = pymysql.connect(
  211. host=host,
  212. port=port,
  213. user=user,
  214. password=password,
  215. database=database,
  216. charset="utf8mb4",
  217. cursorclass=pymysql.cursors.DictCursor,
  218. autocommit=False,
  219. )
  220. g.db = conn
  221. return conn
  222. db = sqlite3.connect(str(config.database_path))
  223. db.row_factory = sqlite3.Row
  224. db.execute("PRAGMA foreign_keys=ON;")
  225. g.db = db
  226. return db
  227. def close_db(conn) -> None:
  228. if conn is None:
  229. return
  230. if _is_mysql(conn):
  231. global _mysql_pool
  232. pool = _mysql_pool
  233. if pool is None:
  234. try:
  235. conn.close()
  236. except Exception:
  237. pass
  238. return
  239. try:
  240. if getattr(conn, "open", True):
  241. conn.rollback()
  242. except Exception:
  243. try:
  244. conn.close()
  245. except Exception:
  246. pass
  247. return
  248. try:
  249. pool.put_nowait(conn)
  250. except Exception:
  251. try:
  252. conn.close()
  253. except Exception:
  254. pass
  255. return
  256. try:
  257. conn.close()
  258. except Exception:
  259. pass
  260. def _is_mysql(conn) -> bool:
  261. if pymysql is None:
  262. return False
  263. return isinstance(conn, pymysql.connections.Connection)
  264. def _translate_sql_for_mysql(sql: str) -> str:
  265. out: list[str] = []
  266. in_single = False
  267. in_double = False
  268. i = 0
  269. while i < len(sql):
  270. ch = sql[i]
  271. if ch == "'" and not in_double:
  272. out.append(ch)
  273. in_single = not in_single
  274. i += 1
  275. continue
  276. if ch == '"' and not in_single:
  277. out.append(ch)
  278. in_double = not in_double
  279. i += 1
  280. continue
  281. if ch == "?" and not in_single and not in_double:
  282. out.append("%s")
  283. i += 1
  284. continue
  285. out.append(ch)
  286. i += 1
  287. return "".join(out)
  288. def execute(sql: str, params: Iterable[Any] = ()) -> sqlite3.Cursor:
  289. db = get_db()
  290. p = list(params)
  291. if _is_mysql(db):
  292. cur = db.cursor()
  293. cur.execute(_translate_sql_for_mysql(sql), p)
  294. db.commit()
  295. return cur
  296. cur = db.execute(sql, p)
  297. db.commit()
  298. return cur
  299. def fetch_one(sql: str, params: Iterable[Any] = ()) -> Optional[sqlite3.Row]:
  300. db = get_db()
  301. p = list(params)
  302. if _is_mysql(db):
  303. cur = db.cursor()
  304. cur.execute(_translate_sql_for_mysql(sql), p)
  305. return cur.fetchone()
  306. cur = db.execute(sql, p)
  307. return cur.fetchone()
  308. def fetch_all(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
  309. db = get_db()
  310. p = list(params)
  311. if _is_mysql(db):
  312. cur = db.cursor()
  313. cur.execute(_translate_sql_for_mysql(sql), p)
  314. return list(cur.fetchall() or [])
  315. cur = db.execute(sql, p)
  316. return cur.fetchall()
  317. def init_db() -> None:
  318. _ctl_ensure_schema()
  319. db = get_db()
  320. if _is_mysql(db):
  321. stmts = [
  322. """
  323. CREATE TABLE IF NOT EXISTS users (
  324. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  325. phone VARCHAR(32) NOT NULL,
  326. password_hash VARCHAR(255) NOT NULL,
  327. status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
  328. vip_expire_at VARCHAR(64) NULL,
  329. created_at VARCHAR(64) NOT NULL,
  330. UNIQUE KEY uk_users_phone (phone),
  331. KEY idx_users_status (status),
  332. KEY idx_users_vip_expire_at (vip_expire_at)
  333. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  334. """,
  335. """
  336. CREATE TABLE IF NOT EXISTS admin_users (
  337. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  338. username VARCHAR(64) NOT NULL,
  339. password_hash VARCHAR(255) NOT NULL,
  340. role VARCHAR(16) NOT NULL DEFAULT 'ADMIN',
  341. status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
  342. last_login_at VARCHAR(64) NULL,
  343. UNIQUE KEY uk_admin_users_username (username),
  344. KEY idx_admin_users_status (status)
  345. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  346. """,
  347. """
  348. CREATE TABLE IF NOT EXISTS plans (
  349. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  350. name VARCHAR(120) NOT NULL,
  351. duration_days INT NOT NULL,
  352. price_cents INT NOT NULL,
  353. enabled TINYINT(1) NOT NULL DEFAULT 1,
  354. sort INT NOT NULL DEFAULT 0,
  355. KEY idx_plans_enabled_sort (enabled, sort)
  356. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  357. """,
  358. """
  359. CREATE TABLE IF NOT EXISTS resources (
  360. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  361. title VARCHAR(200) NOT NULL,
  362. summary MEDIUMTEXT NOT NULL,
  363. type VARCHAR(8) NOT NULL,
  364. status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
  365. cover_url VARCHAR(500) NULL,
  366. category VARCHAR(120) NULL,
  367. tags_json TEXT NOT NULL,
  368. repo_owner VARCHAR(120) NOT NULL,
  369. repo_name VARCHAR(120) NOT NULL,
  370. repo_private TINYINT(1) NOT NULL DEFAULT 0,
  371. repo_html_url VARCHAR(500) NULL,
  372. default_ref VARCHAR(120) NOT NULL DEFAULT 'master',
  373. view_count INT NOT NULL DEFAULT 0,
  374. download_count INT NOT NULL DEFAULT 0,
  375. created_at VARCHAR(64) NOT NULL,
  376. updated_at VARCHAR(64) NOT NULL,
  377. KEY idx_resources_status_updated (status, updated_at),
  378. KEY idx_resources_status_views (status, view_count),
  379. KEY idx_resources_type_status (type, status)
  380. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  381. """,
  382. """
  383. CREATE TABLE IF NOT EXISTS orders (
  384. id VARCHAR(64) NOT NULL PRIMARY KEY,
  385. user_id BIGINT UNSIGNED NOT NULL,
  386. plan_id BIGINT UNSIGNED NOT NULL,
  387. amount_cents INT NOT NULL,
  388. status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
  389. pay_channel VARCHAR(32) NULL,
  390. pay_trade_no VARCHAR(128) NULL,
  391. created_at VARCHAR(64) NOT NULL,
  392. paid_at VARCHAR(64) NULL,
  393. plan_snapshot_json TEXT NOT NULL,
  394. KEY idx_orders_user_time (user_id, created_at),
  395. KEY idx_orders_status_time (status, created_at),
  396. KEY idx_orders_pay_trade_no (pay_trade_no)
  397. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  398. """,
  399. """
  400. CREATE TABLE IF NOT EXISTS download_logs (
  401. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  402. user_id BIGINT UNSIGNED NOT NULL,
  403. resource_id BIGINT UNSIGNED NULL,
  404. resource_title_snapshot VARCHAR(200) NOT NULL,
  405. resource_type_snapshot VARCHAR(16) NOT NULL,
  406. ref_snapshot VARCHAR(200) NOT NULL,
  407. downloaded_at VARCHAR(64) NOT NULL,
  408. ip VARCHAR(64) NULL,
  409. user_agent VARCHAR(256) NULL,
  410. KEY idx_download_logs_user_time (user_id, downloaded_at),
  411. KEY idx_download_logs_resource (resource_id)
  412. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  413. """,
  414. """
  415. CREATE TABLE IF NOT EXISTS audit_logs (
  416. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  417. actor_type VARCHAR(16) NOT NULL,
  418. actor_id BIGINT UNSIGNED NOT NULL,
  419. action VARCHAR(64) NOT NULL,
  420. resource_type VARCHAR(64) NOT NULL,
  421. resource_id VARCHAR(128) NOT NULL,
  422. before_json MEDIUMTEXT NULL,
  423. after_json MEDIUMTEXT NULL,
  424. ip VARCHAR(64) NULL,
  425. created_at VARCHAR(64) NOT NULL,
  426. KEY idx_audit_logs_time (created_at),
  427. KEY idx_audit_logs_actor (actor_type, actor_id)
  428. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  429. """,
  430. """
  431. CREATE TABLE IF NOT EXISTS user_messages (
  432. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  433. user_id BIGINT UNSIGNED NOT NULL,
  434. title VARCHAR(120) NOT NULL,
  435. content MEDIUMTEXT NOT NULL,
  436. created_at VARCHAR(64) NOT NULL,
  437. read_at VARCHAR(64) NULL,
  438. sender_type VARCHAR(16) NOT NULL DEFAULT 'SYSTEM',
  439. sender_id BIGINT UNSIGNED NULL,
  440. KEY idx_user_messages_user_time (user_id, created_at),
  441. KEY idx_user_messages_user_read (user_id, read_at),
  442. KEY idx_user_messages_sender (sender_type, created_at)
  443. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  444. """,
  445. """
  446. CREATE TABLE IF NOT EXISTS app_settings (
  447. `key` VARCHAR(200) NOT NULL PRIMARY KEY,
  448. category VARCHAR(32) NOT NULL DEFAULT 'GENERAL',
  449. value MEDIUMTEXT NOT NULL,
  450. updated_at VARCHAR(64) NOT NULL,
  451. KEY idx_app_settings_category (category)
  452. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  453. """,
  454. ]
  455. for s in stmts:
  456. cur = db.cursor()
  457. cur.execute(s)
  458. db.commit()
  459. return
  460. db.executescript(
  461. """
  462. CREATE TABLE IF NOT EXISTS users (
  463. id INTEGER PRIMARY KEY AUTOINCREMENT,
  464. phone TEXT NOT NULL UNIQUE,
  465. password_hash TEXT NOT NULL,
  466. status TEXT NOT NULL DEFAULT 'ACTIVE',
  467. vip_expire_at TEXT,
  468. created_at TEXT NOT NULL
  469. );
  470. CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
  471. CREATE INDEX IF NOT EXISTS idx_users_vip_expire_at ON users(vip_expire_at);
  472. CREATE TABLE IF NOT EXISTS admin_users (
  473. id INTEGER PRIMARY KEY AUTOINCREMENT,
  474. username TEXT NOT NULL UNIQUE,
  475. password_hash TEXT NOT NULL,
  476. role TEXT NOT NULL DEFAULT 'ADMIN',
  477. status TEXT NOT NULL DEFAULT 'ACTIVE',
  478. last_login_at TEXT
  479. );
  480. CREATE INDEX IF NOT EXISTS idx_admin_users_status ON admin_users(status);
  481. CREATE TABLE IF NOT EXISTS plans (
  482. id INTEGER PRIMARY KEY AUTOINCREMENT,
  483. name TEXT NOT NULL,
  484. duration_days INTEGER NOT NULL,
  485. price_cents INTEGER NOT NULL,
  486. enabled INTEGER NOT NULL DEFAULT 1,
  487. sort INTEGER NOT NULL DEFAULT 0
  488. );
  489. CREATE TABLE IF NOT EXISTS resources (
  490. id INTEGER PRIMARY KEY AUTOINCREMENT,
  491. title TEXT NOT NULL,
  492. summary TEXT NOT NULL DEFAULT '',
  493. type TEXT NOT NULL CHECK (type IN ('FREE', 'VIP')),
  494. status TEXT NOT NULL CHECK (status IN ('DRAFT', 'ONLINE', 'OFFLINE')) DEFAULT 'DRAFT',
  495. cover_url TEXT,
  496. category TEXT,
  497. tags_json TEXT NOT NULL DEFAULT '[]',
  498. repo_owner TEXT NOT NULL,
  499. repo_name TEXT NOT NULL,
  500. repo_private INTEGER NOT NULL DEFAULT 0,
  501. repo_html_url TEXT,
  502. default_ref TEXT NOT NULL DEFAULT 'master',
  503. view_count INTEGER NOT NULL DEFAULT 0,
  504. download_count INTEGER NOT NULL DEFAULT 0,
  505. created_at TEXT NOT NULL,
  506. updated_at TEXT NOT NULL
  507. );
  508. CREATE INDEX IF NOT EXISTS idx_resources_status_updated ON resources(status, updated_at);
  509. CREATE INDEX IF NOT EXISTS idx_resources_status_views ON resources(status, view_count);
  510. CREATE INDEX IF NOT EXISTS idx_resources_type_status ON resources(type, status);
  511. CREATE TABLE IF NOT EXISTS orders (
  512. id TEXT PRIMARY KEY,
  513. user_id INTEGER NOT NULL,
  514. plan_id INTEGER NOT NULL,
  515. amount_cents INTEGER NOT NULL,
  516. status TEXT NOT NULL CHECK (status IN ('PENDING', 'PAID', 'CLOSED', 'FAILED')) DEFAULT 'PENDING',
  517. pay_channel TEXT,
  518. pay_trade_no TEXT,
  519. created_at TEXT NOT NULL,
  520. paid_at TEXT,
  521. plan_snapshot_json TEXT NOT NULL,
  522. FOREIGN KEY(user_id) REFERENCES users(id),
  523. FOREIGN KEY(plan_id) REFERENCES plans(id)
  524. );
  525. CREATE INDEX IF NOT EXISTS idx_orders_user_time ON orders(user_id, created_at);
  526. CREATE INDEX IF NOT EXISTS idx_orders_status_time ON orders(status, created_at);
  527. CREATE INDEX IF NOT EXISTS idx_orders_pay_trade_no ON orders(pay_trade_no);
  528. CREATE TABLE IF NOT EXISTS download_logs (
  529. id INTEGER PRIMARY KEY AUTOINCREMENT,
  530. user_id INTEGER NOT NULL,
  531. resource_id INTEGER,
  532. resource_title_snapshot TEXT NOT NULL,
  533. resource_type_snapshot TEXT NOT NULL,
  534. ref_snapshot TEXT NOT NULL,
  535. downloaded_at TEXT NOT NULL,
  536. ip TEXT,
  537. user_agent TEXT
  538. );
  539. CREATE INDEX IF NOT EXISTS idx_download_logs_user_time ON download_logs(user_id, downloaded_at);
  540. CREATE INDEX IF NOT EXISTS idx_download_logs_resource ON download_logs(resource_id);
  541. CREATE TABLE IF NOT EXISTS audit_logs (
  542. id INTEGER PRIMARY KEY AUTOINCREMENT,
  543. actor_type TEXT NOT NULL,
  544. actor_id INTEGER NOT NULL,
  545. action TEXT NOT NULL,
  546. resource_type TEXT NOT NULL,
  547. resource_id TEXT NOT NULL,
  548. before_json TEXT,
  549. after_json TEXT,
  550. ip TEXT,
  551. created_at TEXT NOT NULL
  552. );
  553. CREATE TABLE IF NOT EXISTS user_messages (
  554. id INTEGER PRIMARY KEY AUTOINCREMENT,
  555. user_id INTEGER NOT NULL,
  556. title TEXT NOT NULL,
  557. content TEXT NOT NULL,
  558. created_at TEXT NOT NULL,
  559. read_at TEXT,
  560. sender_type TEXT NOT NULL DEFAULT 'SYSTEM',
  561. sender_id INTEGER
  562. );
  563. CREATE INDEX IF NOT EXISTS idx_user_messages_user_time ON user_messages(user_id, created_at);
  564. CREATE INDEX IF NOT EXISTS idx_user_messages_user_read ON user_messages(user_id, read_at);
  565. CREATE TABLE IF NOT EXISTS app_settings (
  566. key TEXT PRIMARY KEY,
  567. category TEXT NOT NULL DEFAULT 'GENERAL',
  568. value TEXT NOT NULL,
  569. updated_at TEXT NOT NULL
  570. );
  571. """
  572. )
  573. cols = [r["name"] for r in fetch_all("PRAGMA table_info(app_settings)", ())]
  574. if "category" not in cols:
  575. db.execute("ALTER TABLE app_settings ADD COLUMN category TEXT NOT NULL DEFAULT 'GENERAL'")
  576. db.execute("UPDATE app_settings SET category = 'GOGS' WHERE key LIKE 'GOGS_%'")
  577. db.execute("UPDATE app_settings SET category = 'PAYMENT' WHERE key LIKE 'PAY_%'")
  578. db.execute("UPDATE app_settings SET category = 'LLM' WHERE key LIKE 'LLM_%'")
  579. res_cols = [r["name"] for r in fetch_all("PRAGMA table_info(resources)", ())]
  580. if "repo_private" not in res_cols:
  581. db.execute("ALTER TABLE resources ADD COLUMN repo_private INTEGER NOT NULL DEFAULT 0")
  582. if "repo_html_url" not in res_cols:
  583. db.execute("ALTER TABLE resources ADD COLUMN repo_html_url TEXT")
  584. msg_cols = [r["name"] for r in fetch_all("PRAGMA table_info(user_messages)", ())]
  585. if "sender_type" not in msg_cols:
  586. db.execute("ALTER TABLE user_messages ADD COLUMN sender_type TEXT NOT NULL DEFAULT 'SYSTEM'")
  587. if "sender_id" not in msg_cols:
  588. db.execute("ALTER TABLE user_messages ADD COLUMN sender_id INTEGER")
  589. db.commit()
  590. def ensure_default_admin() -> None:
  591. existing = fetch_one("SELECT id FROM admin_users WHERE username = ?", ("admin",))
  592. if existing is not None:
  593. return
  594. default_password = os.environ.get("ADMIN_INIT_PASSWORD", "admin123")
  595. execute(
  596. "INSERT INTO admin_users (username, password_hash, role, status) VALUES (?, ?, ?, ?)",
  597. ("admin", generate_password_hash(default_password), "ADMIN", "ACTIVE"),
  598. )
  599. def ensure_default_plans() -> None:
  600. row = fetch_one("SELECT COUNT(1) AS cnt FROM plans", ())
  601. if row is not None and int(row["cnt"]) > 0:
  602. return
  603. defaults = [
  604. ("月卡", 30, 1999, 1, 30),
  605. ("季卡", 90, 4999, 1, 20),
  606. ("年卡", 365, 14999, 1, 10),
  607. ]
  608. for name, days, price_cents, enabled, sort in defaults:
  609. execute(
  610. "INSERT INTO plans (name, duration_days, price_cents, enabled, sort) VALUES (?, ?, ?, ?, ?)",
  611. (name, days, price_cents, enabled, sort),
  612. )
  613. def db_status() -> dict[str, Any]:
  614. config = get_config()
  615. backend = _resolve_backend_from_control()
  616. if backend is None:
  617. backend = "mysql" if (config.mysql_host or "").strip() else "sqlite"
  618. return {
  619. "active": backend,
  620. "migrating": is_migrating(),
  621. "mysql": {
  622. "host": (_ctl_get_value("MYSQL_HOST") or config.mysql_host or "").strip(),
  623. "port": int((_ctl_get_value("MYSQL_PORT") or str(config.mysql_port or 3306)).strip() or 3306),
  624. "user": (_ctl_get_value("MYSQL_USER") or config.mysql_user or "").strip(),
  625. "database": (_ctl_get_value("MYSQL_DATABASE") or config.mysql_database or "").strip(),
  626. "hasPassword": bool((_ctl_get_value("MYSQL_PASSWORD") or config.mysql_password or "").strip()),
  627. },
  628. }
  629. def switch_database(*, target: str, force: bool) -> dict[str, Any]:
  630. t = (target or "").strip().lower()
  631. if t not in {"sqlite", "mysql"}:
  632. raise RuntimeError("invalid_target")
  633. with _db_switch_lock:
  634. if is_migrating():
  635. raise RuntimeError("migration_running")
  636. config = get_config()
  637. cur_backend = _resolve_backend_from_control()
  638. if cur_backend is None:
  639. cur_backend = "mysql" if (config.mysql_host or "").strip() else "sqlite"
  640. if cur_backend == t:
  641. set_active_backend(cur_backend)
  642. return {"ok": True, "from": cur_backend, "to": t, "noop": True}
  643. mysql_host = (_ctl_get_value("MYSQL_HOST") or config.mysql_host or "").strip()
  644. mysql_user = (_ctl_get_value("MYSQL_USER") or config.mysql_user or "").strip()
  645. mysql_database = (_ctl_get_value("MYSQL_DATABASE") or config.mysql_database or "").strip()
  646. mysql_port = int((_ctl_get_value("MYSQL_PORT") or str(config.mysql_port or 3306)).strip() or 3306)
  647. mysql_password = (_ctl_get_value("MYSQL_PASSWORD") or config.mysql_password or "").strip()
  648. tables: list[tuple[str, list[str]]] = [
  649. ("users", ["id", "phone", "password_hash", "status", "vip_expire_at", "created_at"]),
  650. ("admin_users", ["id", "username", "password_hash", "role", "status", "last_login_at"]),
  651. ("plans", ["id", "name", "duration_days", "price_cents", "enabled", "sort"]),
  652. (
  653. "resources",
  654. [
  655. "id",
  656. "title",
  657. "summary",
  658. "type",
  659. "status",
  660. "cover_url",
  661. "category",
  662. "tags_json",
  663. "repo_owner",
  664. "repo_name",
  665. "repo_private",
  666. "repo_html_url",
  667. "default_ref",
  668. "view_count",
  669. "download_count",
  670. "created_at",
  671. "updated_at",
  672. ],
  673. ),
  674. (
  675. "orders",
  676. [
  677. "id",
  678. "user_id",
  679. "plan_id",
  680. "amount_cents",
  681. "status",
  682. "pay_channel",
  683. "pay_trade_no",
  684. "created_at",
  685. "paid_at",
  686. "plan_snapshot_json",
  687. ],
  688. ),
  689. (
  690. "download_logs",
  691. [
  692. "id",
  693. "user_id",
  694. "resource_id",
  695. "resource_title_snapshot",
  696. "resource_type_snapshot",
  697. "ref_snapshot",
  698. "downloaded_at",
  699. "ip",
  700. "user_agent",
  701. ],
  702. ),
  703. (
  704. "audit_logs",
  705. [
  706. "id",
  707. "actor_type",
  708. "actor_id",
  709. "action",
  710. "resource_type",
  711. "resource_id",
  712. "before_json",
  713. "after_json",
  714. "ip",
  715. "created_at",
  716. ],
  717. ),
  718. (
  719. "user_messages",
  720. ["id", "user_id", "title", "content", "created_at", "read_at", "sender_type", "sender_id"],
  721. ),
  722. ]
  723. def _mysql_connect() -> Any:
  724. if pymysql is None:
  725. raise RuntimeError("pymysql_required")
  726. try:
  727. return pymysql.connect(
  728. host=mysql_host,
  729. port=mysql_port,
  730. user=mysql_user,
  731. password=mysql_password,
  732. database=mysql_database,
  733. charset="utf8mb4",
  734. cursorclass=pymysql.cursors.DictCursor,
  735. autocommit=False,
  736. )
  737. except Exception as e:
  738. errno = int(getattr(e, "args", [0])[0] or 0) if getattr(e, "args", None) else 0
  739. if errno != 1049:
  740. raise RuntimeError("connect_failed")
  741. server_conn = None
  742. try:
  743. server_conn = pymysql.connect(
  744. host=mysql_host,
  745. port=mysql_port,
  746. user=mysql_user,
  747. password=mysql_password,
  748. charset="utf8mb4",
  749. cursorclass=pymysql.cursors.DictCursor,
  750. autocommit=True,
  751. )
  752. esc = mysql_database.replace("`", "``")
  753. cur = server_conn.cursor()
  754. cur.execute(
  755. f"CREATE DATABASE IF NOT EXISTS `{esc}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
  756. )
  757. except Exception:
  758. raise RuntimeError("db_create_failed")
  759. finally:
  760. if server_conn is not None:
  761. try:
  762. server_conn.close()
  763. except Exception:
  764. pass
  765. try:
  766. return pymysql.connect(
  767. host=mysql_host,
  768. port=mysql_port,
  769. user=mysql_user,
  770. password=mysql_password,
  771. database=mysql_database,
  772. charset="utf8mb4",
  773. cursorclass=pymysql.cursors.DictCursor,
  774. autocommit=False,
  775. )
  776. except Exception:
  777. raise RuntimeError("connect_failed")
  778. def _count(conn: Any, backend: str, table: str) -> int:
  779. if backend == "sqlite":
  780. row = conn.execute(f"SELECT COUNT(1) AS cnt FROM {table}").fetchone()
  781. return int(row["cnt"] if row is not None else 0)
  782. cur = conn.cursor()
  783. cur.execute(f"SELECT COUNT(1) AS cnt FROM {table}")
  784. row = cur.fetchone()
  785. return int((row or {}).get("cnt") or 0)
  786. def _wipe(conn: Any, backend: str) -> None:
  787. if backend == "sqlite":
  788. for table, _cols in tables:
  789. conn.execute(f"DELETE FROM {table}")
  790. try:
  791. conn.execute("DELETE FROM sqlite_sequence")
  792. except Exception:
  793. pass
  794. return
  795. cur = conn.cursor()
  796. cur.execute("SET FOREIGN_KEY_CHECKS=0")
  797. for table, _cols in tables:
  798. cur.execute(f"TRUNCATE TABLE {table}")
  799. cur.execute("SET FOREIGN_KEY_CHECKS=1")
  800. def _insert_many(conn: Any, backend: str, table: str, cols: list[str], rows: list[list[Any]]) -> None:
  801. if not rows:
  802. return
  803. cols_sql = ", ".join(cols)
  804. if backend == "sqlite":
  805. q = ", ".join(["?"] * len(cols))
  806. conn.executemany(f"INSERT INTO {table} ({cols_sql}) VALUES ({q})", rows)
  807. return
  808. q = ", ".join(["%s"] * len(cols))
  809. cur = conn.cursor()
  810. cur.executemany(f"INSERT INTO {table} ({cols_sql}) VALUES ({q})", rows)
  811. def _copy(src: Any, src_backend: str, dst: Any, dst_backend: str, table: str, cols: list[str]) -> int:
  812. col_sql = ", ".join(cols)
  813. if src_backend == "sqlite":
  814. cur = src.execute(f"SELECT {col_sql} FROM {table}")
  815. src_rows = cur.fetchall() or []
  816. rows = [[r[c] for c in cols] for r in src_rows]
  817. else:
  818. cur = src.cursor()
  819. cur.execute(f"SELECT {col_sql} FROM {table}")
  820. src_rows = cur.fetchall() or []
  821. rows = [[r.get(c) for c in cols] for r in src_rows]
  822. _insert_many(dst, dst_backend, table, cols, rows)
  823. return len(rows)
  824. if t == "mysql" and (not mysql_host or not mysql_user or not mysql_database):
  825. raise RuntimeError("mysql_not_configured")
  826. src_backend = cur_backend
  827. dst_backend = t
  828. src_conn = None
  829. dst_conn = None
  830. try:
  831. _set_migrating(True)
  832. _ctl_set_value("DB_MIGRATING", "1", category="DB")
  833. src_conn = _sqlite_connect(str(config.database_path)) if src_backend == "sqlite" else _mysql_connect()
  834. dst_conn = _sqlite_connect(str(config.database_path)) if dst_backend == "sqlite" else _mysql_connect()
  835. if dst_backend == "mysql":
  836. stmts = [
  837. """
  838. CREATE TABLE IF NOT EXISTS users (
  839. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  840. phone VARCHAR(32) NOT NULL,
  841. password_hash VARCHAR(255) NOT NULL,
  842. status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
  843. vip_expire_at VARCHAR(64) NULL,
  844. created_at VARCHAR(64) NOT NULL,
  845. UNIQUE KEY uk_users_phone (phone),
  846. KEY idx_users_status (status),
  847. KEY idx_users_vip_expire_at (vip_expire_at)
  848. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  849. """,
  850. """
  851. CREATE TABLE IF NOT EXISTS admin_users (
  852. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  853. username VARCHAR(64) NOT NULL,
  854. password_hash VARCHAR(255) NOT NULL,
  855. role VARCHAR(16) NOT NULL DEFAULT 'ADMIN',
  856. status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
  857. last_login_at VARCHAR(64) NULL,
  858. UNIQUE KEY uk_admin_users_username (username),
  859. KEY idx_admin_users_status (status)
  860. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  861. """,
  862. """
  863. CREATE TABLE IF NOT EXISTS plans (
  864. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  865. name VARCHAR(120) NOT NULL,
  866. duration_days INT NOT NULL,
  867. price_cents INT NOT NULL,
  868. enabled TINYINT(1) NOT NULL DEFAULT 1,
  869. sort INT NOT NULL DEFAULT 0,
  870. KEY idx_plans_enabled_sort (enabled, sort)
  871. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  872. """,
  873. """
  874. CREATE TABLE IF NOT EXISTS resources (
  875. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  876. title VARCHAR(200) NOT NULL,
  877. summary MEDIUMTEXT NOT NULL,
  878. type VARCHAR(8) NOT NULL,
  879. status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
  880. cover_url VARCHAR(500) NULL,
  881. category VARCHAR(120) NULL,
  882. tags_json TEXT NOT NULL,
  883. repo_owner VARCHAR(120) NOT NULL,
  884. repo_name VARCHAR(120) NOT NULL,
  885. repo_private TINYINT(1) NOT NULL DEFAULT 0,
  886. repo_html_url VARCHAR(500) NULL,
  887. default_ref VARCHAR(120) NOT NULL DEFAULT 'master',
  888. view_count INT NOT NULL DEFAULT 0,
  889. download_count INT NOT NULL DEFAULT 0,
  890. created_at VARCHAR(64) NOT NULL,
  891. updated_at VARCHAR(64) NOT NULL,
  892. KEY idx_resources_status_updated (status, updated_at),
  893. KEY idx_resources_status_views (status, view_count),
  894. KEY idx_resources_type_status (type, status)
  895. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  896. """,
  897. """
  898. CREATE TABLE IF NOT EXISTS orders (
  899. id VARCHAR(64) NOT NULL PRIMARY KEY,
  900. user_id BIGINT UNSIGNED NOT NULL,
  901. plan_id BIGINT UNSIGNED NOT NULL,
  902. amount_cents INT NOT NULL,
  903. status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
  904. pay_channel VARCHAR(32) NULL,
  905. pay_trade_no VARCHAR(128) NULL,
  906. created_at VARCHAR(64) NOT NULL,
  907. paid_at VARCHAR(64) NULL,
  908. plan_snapshot_json TEXT NOT NULL,
  909. KEY idx_orders_user_time (user_id, created_at),
  910. KEY idx_orders_status_time (status, created_at),
  911. KEY idx_orders_pay_trade_no (pay_trade_no)
  912. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  913. """,
  914. """
  915. CREATE TABLE IF NOT EXISTS download_logs (
  916. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  917. user_id BIGINT UNSIGNED NOT NULL,
  918. resource_id BIGINT UNSIGNED NULL,
  919. resource_title_snapshot VARCHAR(200) NOT NULL,
  920. resource_type_snapshot VARCHAR(16) NOT NULL,
  921. ref_snapshot VARCHAR(200) NOT NULL,
  922. downloaded_at VARCHAR(64) NOT NULL,
  923. ip VARCHAR(64) NULL,
  924. user_agent VARCHAR(256) NULL,
  925. KEY idx_download_logs_user_time (user_id, downloaded_at),
  926. KEY idx_download_logs_resource (resource_id)
  927. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  928. """,
  929. """
  930. CREATE TABLE IF NOT EXISTS audit_logs (
  931. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  932. actor_type VARCHAR(16) NOT NULL,
  933. actor_id BIGINT UNSIGNED NOT NULL,
  934. action VARCHAR(64) NOT NULL,
  935. resource_type VARCHAR(64) NOT NULL,
  936. resource_id VARCHAR(128) NOT NULL,
  937. before_json MEDIUMTEXT NULL,
  938. after_json MEDIUMTEXT NULL,
  939. ip VARCHAR(64) NULL,
  940. created_at VARCHAR(64) NOT NULL,
  941. KEY idx_audit_logs_time (created_at),
  942. KEY idx_audit_logs_actor (actor_type, actor_id)
  943. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  944. """,
  945. """
  946. CREATE TABLE IF NOT EXISTS user_messages (
  947. id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  948. user_id BIGINT UNSIGNED NOT NULL,
  949. title VARCHAR(120) NOT NULL,
  950. content MEDIUMTEXT NOT NULL,
  951. created_at VARCHAR(64) NOT NULL,
  952. read_at VARCHAR(64) NULL,
  953. sender_type VARCHAR(16) NOT NULL DEFAULT 'SYSTEM',
  954. sender_id BIGINT UNSIGNED NULL,
  955. KEY idx_user_messages_user_time (user_id, created_at),
  956. KEY idx_user_messages_user_read (user_id, read_at),
  957. KEY idx_user_messages_sender (sender_type, created_at)
  958. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  959. """,
  960. ]
  961. cur = dst_conn.cursor()
  962. for s in stmts:
  963. cur.execute(s)
  964. else:
  965. dst_conn.executescript(
  966. """
  967. CREATE TABLE IF NOT EXISTS users (
  968. id INTEGER PRIMARY KEY AUTOINCREMENT,
  969. phone TEXT NOT NULL UNIQUE,
  970. password_hash TEXT NOT NULL,
  971. status TEXT NOT NULL DEFAULT 'ACTIVE',
  972. vip_expire_at TEXT,
  973. created_at TEXT NOT NULL
  974. );
  975. CREATE INDEX IF NOT EXISTS idx_users_status ON users(status);
  976. CREATE INDEX IF NOT EXISTS idx_users_vip_expire_at ON users(vip_expire_at);
  977. CREATE TABLE IF NOT EXISTS admin_users (
  978. id INTEGER PRIMARY KEY AUTOINCREMENT,
  979. username TEXT NOT NULL UNIQUE,
  980. password_hash TEXT NOT NULL,
  981. role TEXT NOT NULL DEFAULT 'ADMIN',
  982. status TEXT NOT NULL DEFAULT 'ACTIVE',
  983. last_login_at TEXT
  984. );
  985. CREATE INDEX IF NOT EXISTS idx_admin_users_status ON admin_users(status);
  986. CREATE TABLE IF NOT EXISTS plans (
  987. id INTEGER PRIMARY KEY AUTOINCREMENT,
  988. name TEXT NOT NULL,
  989. duration_days INTEGER NOT NULL,
  990. price_cents INTEGER NOT NULL,
  991. enabled INTEGER NOT NULL DEFAULT 1,
  992. sort INTEGER NOT NULL DEFAULT 0
  993. );
  994. CREATE TABLE IF NOT EXISTS resources (
  995. id INTEGER PRIMARY KEY AUTOINCREMENT,
  996. title TEXT NOT NULL,
  997. summary TEXT NOT NULL DEFAULT '',
  998. type TEXT NOT NULL CHECK (type IN ('FREE', 'VIP')),
  999. status TEXT NOT NULL CHECK (status IN ('DRAFT', 'ONLINE', 'OFFLINE')) DEFAULT 'DRAFT',
  1000. cover_url TEXT,
  1001. category TEXT,
  1002. tags_json TEXT NOT NULL DEFAULT '[]',
  1003. repo_owner TEXT NOT NULL,
  1004. repo_name TEXT NOT NULL,
  1005. repo_private INTEGER NOT NULL DEFAULT 0,
  1006. repo_html_url TEXT,
  1007. default_ref TEXT NOT NULL DEFAULT 'master',
  1008. view_count INTEGER NOT NULL DEFAULT 0,
  1009. download_count INTEGER NOT NULL DEFAULT 0,
  1010. created_at TEXT NOT NULL,
  1011. updated_at TEXT NOT NULL
  1012. );
  1013. CREATE INDEX IF NOT EXISTS idx_resources_status_updated ON resources(status, updated_at);
  1014. CREATE INDEX IF NOT EXISTS idx_resources_status_views ON resources(status, view_count);
  1015. CREATE INDEX IF NOT EXISTS idx_resources_type_status ON resources(type, status);
  1016. CREATE TABLE IF NOT EXISTS orders (
  1017. id TEXT PRIMARY KEY,
  1018. user_id INTEGER NOT NULL,
  1019. plan_id INTEGER NOT NULL,
  1020. amount_cents INTEGER NOT NULL,
  1021. status TEXT NOT NULL CHECK (status IN ('PENDING', 'PAID', 'CLOSED', 'FAILED')) DEFAULT 'PENDING',
  1022. pay_channel TEXT,
  1023. pay_trade_no TEXT,
  1024. created_at TEXT NOT NULL,
  1025. paid_at TEXT,
  1026. plan_snapshot_json TEXT NOT NULL
  1027. );
  1028. CREATE INDEX IF NOT EXISTS idx_orders_user_time ON orders(user_id, created_at);
  1029. CREATE INDEX IF NOT EXISTS idx_orders_status_time ON orders(status, created_at);
  1030. CREATE INDEX IF NOT EXISTS idx_orders_pay_trade_no ON orders(pay_trade_no);
  1031. CREATE TABLE IF NOT EXISTS download_logs (
  1032. id INTEGER PRIMARY KEY AUTOINCREMENT,
  1033. user_id INTEGER NOT NULL,
  1034. resource_id INTEGER,
  1035. resource_title_snapshot TEXT NOT NULL,
  1036. resource_type_snapshot TEXT NOT NULL,
  1037. ref_snapshot TEXT NOT NULL,
  1038. downloaded_at TEXT NOT NULL,
  1039. ip TEXT,
  1040. user_agent TEXT
  1041. );
  1042. CREATE INDEX IF NOT EXISTS idx_download_logs_user_time ON download_logs(user_id, downloaded_at);
  1043. CREATE INDEX IF NOT EXISTS idx_download_logs_resource ON download_logs(resource_id);
  1044. CREATE TABLE IF NOT EXISTS audit_logs (
  1045. id INTEGER PRIMARY KEY AUTOINCREMENT,
  1046. actor_type TEXT NOT NULL,
  1047. actor_id INTEGER NOT NULL,
  1048. action TEXT NOT NULL,
  1049. resource_type TEXT NOT NULL,
  1050. resource_id TEXT NOT NULL,
  1051. before_json TEXT,
  1052. after_json TEXT,
  1053. ip TEXT,
  1054. created_at TEXT NOT NULL
  1055. );
  1056. CREATE TABLE IF NOT EXISTS user_messages (
  1057. id INTEGER PRIMARY KEY AUTOINCREMENT,
  1058. user_id INTEGER NOT NULL,
  1059. title TEXT NOT NULL,
  1060. content TEXT NOT NULL,
  1061. created_at TEXT NOT NULL,
  1062. read_at TEXT,
  1063. sender_type TEXT NOT NULL DEFAULT 'SYSTEM',
  1064. sender_id INTEGER
  1065. );
  1066. CREATE INDEX IF NOT EXISTS idx_user_messages_user_time ON user_messages(user_id, created_at);
  1067. CREATE INDEX IF NOT EXISTS idx_user_messages_user_read ON user_messages(user_id, read_at);
  1068. """
  1069. )
  1070. target_has = {table: _count(dst_conn, dst_backend, table) for table, _cols in tables}
  1071. if not force and any(v > 0 for v in target_has.values()):
  1072. raise RuntimeError("target_not_empty")
  1073. _wipe(dst_conn, dst_backend)
  1074. migrated: dict[str, int] = {}
  1075. for table, cols in tables:
  1076. migrated[table] = _copy(src_conn, src_backend, dst_conn, dst_backend, table, cols)
  1077. for table, _cols in tables:
  1078. if _count(src_conn, src_backend, table) != _count(dst_conn, dst_backend, table):
  1079. raise RuntimeError("verify_failed")
  1080. dst_conn.commit()
  1081. _ctl_set_value("DB_ACTIVE", dst_backend, category="DB")
  1082. set_active_backend(dst_backend)
  1083. return {"ok": True, "from": src_backend, "to": dst_backend, "migrated": migrated}
  1084. except Exception:
  1085. if dst_conn is not None:
  1086. try:
  1087. dst_conn.rollback()
  1088. except Exception:
  1089. pass
  1090. raise
  1091. finally:
  1092. _ctl_set_value("DB_MIGRATING", "0", category="DB")
  1093. _set_migrating(False)
  1094. if src_conn is not None:
  1095. try:
  1096. src_conn.close()
  1097. except Exception:
  1098. pass
  1099. if dst_conn is not None:
  1100. try:
  1101. dst_conn.close()
  1102. except Exception:
  1103. pass