签入版本
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""运维脚本包。"""
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
健康检查脚本
|
||||
=============================================================
|
||||
|
||||
验证应用可构建、健康端点可访问。
|
||||
|
||||
用法:
|
||||
python scripts/health_check.py [--host 127.0.0.1] [--port 8021]
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
|
||||
def check_app_build() -> bool:
|
||||
"""验证应用工厂可构建。"""
|
||||
try:
|
||||
from src.wsgi import app # noqa: F401
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[FAIL] 应用构建失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def check_health_endpoint(host: str, port: int) -> bool:
|
||||
"""验证 /health 端点可访问。"""
|
||||
url = f"http://{host}:{port}/health"
|
||||
try:
|
||||
resp = requests.get(url, timeout=5)
|
||||
ok = resp.status_code == 200 and resp.json().get("code") == 0
|
||||
print(f"[{'OK' if ok else 'FAIL'}] {url} → {resp.status_code}")
|
||||
return ok
|
||||
except Exception as e:
|
||||
print(f"[FAIL] {url} 访问失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
host = "127.0.0.1"
|
||||
port = 8021
|
||||
args = sys.argv[1:]
|
||||
for i, a in enumerate(args):
|
||||
if a == "--host" and i + 1 < len(args):
|
||||
host = args[i + 1]
|
||||
if a == "--port" and i + 1 < len(args):
|
||||
port = int(args[i + 1])
|
||||
|
||||
build_ok = check_app_build()
|
||||
if build_ok:
|
||||
# 端点检查(服务器未启动时优雅降级,不阻塞构建校验)
|
||||
check_health_endpoint(host, port)
|
||||
sys.exit(0 if build_ok else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
数据库初始化脚本
|
||||
=============================================================
|
||||
|
||||
基于 SQLAlchemy 元数据创建全部数据表(等价 flask db upgrade 的建表)。
|
||||
适合从零初始化环境。
|
||||
|
||||
用法:
|
||||
python scripts/init_db.py
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
from src.app import create_app
|
||||
from src.core.database import db
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
print(f"数据库表初始化完成,共 {len(db.metadata.tables)} 张表")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,573 @@
|
||||
"""
|
||||
跨库数据迁移脚本(MySQL → PostgreSQL / SQL Server / SQLite / Oracle 等)
|
||||
=============================================================
|
||||
|
||||
把「源库」的数据整体搬到「目标库」:
|
||||
- 目标库结构用模型 metadata 生成(db.metadata.create_all,方言可移植,
|
||||
模型即 head,与 src/models_registry.py / setup_db.py 同源);
|
||||
- 数据按拓扑序逐表 SELECT ... ORDER BY id,显式带 id 批量插入,保住
|
||||
无外键裸列引用关系(user.dept_id、role_menu.role_id/menu_id 等);
|
||||
- 最后按方言重置自增序列(PG setval / MSSQL IDENTITY_INSERT + DBCC
|
||||
CHECKIDENT / Oracle 补 sequence + BEFORE INSERT 触发器;
|
||||
MySQL / SQLite 显式插 id 后自动跟随,无需处理)。
|
||||
|
||||
与 init_db.py / setup_db.py 的分工:
|
||||
- init_db.py / setup_db.py 单库初始化(当前 .env 目标库 create_all + 种子数据)
|
||||
- migrate_db.py 双库迁移(源库 → 目标库,CLI 显式参数,不依赖 .env 连接串)
|
||||
|
||||
用法:
|
||||
python scripts/migrate_db.py \
|
||||
--src-driver=mysql --src-host=127.0.0.1 --src-port=3306 \
|
||||
--src-db=djangoadmin.flask.naivevue --src-user=root --src-pass=root \
|
||||
--dst-driver=postgresql --dst-host=127.0.0.1 --dst-port=5432 \
|
||||
--dst-db=djangoadmin.flask.naivevue --dst-user=postgres --dst-pass=123456 \
|
||||
--drop-target-first
|
||||
|
||||
先干跑核对计划:加 --dry-run(只连源、逐表数行数、打印计划,不碰目标库)。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
# 将项目根加入 sys.path(脚本以 `python scripts/xxx.py` 运行时 cwd 是 scripts/)
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
# Windows 控制台默认 GBK(cp936):✓ 等字符无法编码会崩溃、中文按终端编码显示成乱码。
|
||||
# 统一把 stdout/stderr 切到 UTF-8,Git Bash / 重定向均正常;无 reconfigure 的环境跳过。
|
||||
if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
|
||||
from sqlalchemy import create_engine, inspect, select, text # noqa: E402
|
||||
from sqlalchemy.pool import NullPool # noqa: E402
|
||||
|
||||
from config import DB_PREFIX # noqa: E402
|
||||
from config.database import _normalize_driver, _sqlite_url # noqa: E402
|
||||
import src.core.database as _dbm # noqa: E402
|
||||
import src.models_registry as _models_registry # noqa: E402,F401 # 模型注册中心:填充 db.metadata
|
||||
db = _dbm.db
|
||||
|
||||
# 各驱动默认端口(sqlite 无端口概念)
|
||||
_DEFAULT_PORTS = {"mysql": 3306, "postgresql": 5432, "mssql": 1433, "oracle": 1521, "sqlite": None}
|
||||
|
||||
# 迁移顺序:基础/被引用表在前,依赖表在后。项目无外键,表间关联是裸 Integer 列
|
||||
# 由应用层维护,显式带 id 插入即保住引用关系;保序只是锦上添花(父先子后)。
|
||||
_BASE_TABLES = [
|
||||
"dept", "menu", "role", "level", "position", "city", "category",
|
||||
"dict", "config", "file_template", "param", "job", "link",
|
||||
]
|
||||
_DEP_TABLES = [
|
||||
"role_menu", "dict_item", "config_item", "job_log",
|
||||
"user", "user_role", "article", "notice", "operation_log", "login_log",
|
||||
]
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""CLI 参数:源/目标两端连接显式给出,不依赖 .env。"""
|
||||
p = argparse.ArgumentParser(description="跨库数据迁移:源库 → 目标库")
|
||||
|
||||
def add_side(side: str, default_user: str):
|
||||
p.add_argument(f"--{side}-driver", choices=("mysql", "postgresql", "mssql", "oracle", "sqlite"), default="mysql",
|
||||
help="驱动(oracle 时 --{side}-db 即 service_name;sqlite 时即数据库文件路径)")
|
||||
p.add_argument(f"--{side}-host", default="127.0.0.1")
|
||||
p.add_argument(f"--{side}-port", type=int, default=None,
|
||||
help="端口,缺省按驱动默认(mysql 3306 / pg 5432 / mssql 1433 / oracle 1521)")
|
||||
p.add_argument(f"--{side}-db", required=True, help="数据库名(sqlite 时为文件路径)")
|
||||
p.add_argument(f"--{side}-user", default=default_user)
|
||||
p.add_argument(f"--{side}-pass", default="",
|
||||
help=f"口令,留空回落环境变量 {side.upper()}_DB_PASS(避免进 shell 历史)")
|
||||
|
||||
add_side("src", "root")
|
||||
add_side("dst", "postgres")
|
||||
|
||||
p.add_argument("--drop-target-first", action="store_true", help="先删目标库再重建(默认关)")
|
||||
p.add_argument("--dry-run", action="store_true", help="只连源、逐表数行数、打印计划,不碰目标库")
|
||||
p.add_argument("--chunk-size", type=int, default=1000, help="每批插入行数(默认 1000)")
|
||||
p.add_argument("--only-tables", default="", help="逗号分隔的表名子集(全名或去掉前缀的短名),续传用")
|
||||
p.add_argument("--skip-existing", action="store_true", help="目标已等于源行数的表跳过")
|
||||
p.add_argument("--skip-verify", action="store_true", help="跳过迁移后的逐表行数校验")
|
||||
p.add_argument("--single-transaction", action="store_true", help="全部表包进一个大事务(默认每表一个事务)")
|
||||
return p
|
||||
|
||||
|
||||
# ============================================================
|
||||
# URL / 引擎
|
||||
# ============================================================
|
||||
|
||||
def make_url(driver: str, host: str, port: int, database: str, username: str, password: str) -> str:
|
||||
"""带显式参数的连接串构建器,URL 形态与 config.database._build_database_url 逐字等价
|
||||
(后者读 .env 全局,脚本端需要显式参数版)。"""
|
||||
drv = _normalize_driver(driver)
|
||||
if drv == 'sqlite':
|
||||
return _sqlite_url(database) # 此时 database 即文件路径,忽略 host/port/user/pass
|
||||
base = quote_plus(username) + ':' + quote_plus(password) + '@' + host + ':' + str(port)
|
||||
if drv == 'oracle':
|
||||
# Oracle 的"库"即连接的服务名(service_name),schema 是连接用户
|
||||
return 'oracle+oracledb://' + base + '/?service_name=' + quote_plus(database)
|
||||
base = base + '/' + database
|
||||
if drv == 'mysql':
|
||||
return 'mysql+pymysql://' + base + '?charset=utf8mb4'
|
||||
if drv == 'postgresql':
|
||||
return 'postgresql+psycopg2://' + base
|
||||
return 'mssql+pymssql://' + base + '?charset=utf8'
|
||||
|
||||
|
||||
def make_engine(driver: str, host: str, port: int, database: str, username: str, password: str):
|
||||
"""迁移专用引擎:NullPool,避免连接池把目标/源的会话状态串起来。"""
|
||||
return create_engine(make_url(driver, host, port, database, username, password), poolclass=NullPool)
|
||||
|
||||
|
||||
def _resolve_port(driver: str, port: int | None) -> int | None:
|
||||
return port if port is not None else _DEFAULT_PORTS[driver]
|
||||
|
||||
|
||||
def _fmt_duration(sec: float) -> str:
|
||||
"""耗时格式化:<1s 显示毫秒,<60s 显示秒,否则分+秒。"""
|
||||
if sec < 1:
|
||||
return f"{sec * 1000:.0f}ms"
|
||||
if sec < 60:
|
||||
return f"{sec:.1f}s"
|
||||
return f"{int(sec // 60)}m{int(sec % 60)}s"
|
||||
|
||||
|
||||
def _busy(msg: str) -> None:
|
||||
"""缓冲提示:耗时操作开始前打印一行,让用户感知进程仍在工作(而非卡死)。"""
|
||||
print(f" · {msg}...", flush=True)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def step(title: str):
|
||||
"""耗时步骤缓冲包装:进入打印"正在...(请勿中断)",退出打印"✓ 完成(耗时)"。
|
||||
|
||||
用于删库/建库/建表/大表迁移等可能耗时较长的操作,避免用户误以为进程卡死。
|
||||
通过 `with step(...) as h: h['detail'] = ...` 可给完成行追加明细(如行数/失败数)。
|
||||
"""
|
||||
t0 = time.perf_counter()
|
||||
print(f"[·] {title}...(可能耗时较长,请勿中断)", flush=True)
|
||||
holder: dict = {}
|
||||
try:
|
||||
yield holder
|
||||
finally:
|
||||
detail = f",{holder['detail']}" if holder.get('detail') else ""
|
||||
print(f"[✓] {title} 完成(耗时 {_fmt_duration(time.perf_counter() - t0)}){detail}", flush=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 建目标库(镜像 init_db.py 的分支)
|
||||
# ============================================================
|
||||
|
||||
def create_database_if_missing(driver: str, host: str, port: int, database: str,
|
||||
username: str, password: str, drop_first: bool = False,
|
||||
tables: tuple = ()):
|
||||
"""按方言连维护库建目标库(不存在才建)。sqlite 无建库概念,只确保父目录并可选删旧文件。
|
||||
|
||||
- mysql: 连无库连接 CREATE DATABASE ... utf8mb4
|
||||
- postgresql: 连 postgres 维护库(autocommit),库名含点号必须双引号
|
||||
- mssql: 连 master 维护库,标识符用方括号
|
||||
- oracle: 服务由 DBA 管理,schema 即连接用户,无需/无法"建库";仅校验连通性,
|
||||
drop_first 时尽力 DROP TABLE PURGE 清掉 tables 里已存在的项目表
|
||||
"""
|
||||
if driver == 'sqlite':
|
||||
file = Path(database).expanduser()
|
||||
if file.parent.as_posix() != '.':
|
||||
file.parent.mkdir(parents=True, exist_ok=True)
|
||||
if drop_first and file.exists():
|
||||
_busy(f"正在删除旧目标文件 {file}")
|
||||
file.unlink()
|
||||
print(f"removed existing sqlite file {file}")
|
||||
return
|
||||
|
||||
if driver == 'mysql':
|
||||
import pymysql
|
||||
_busy("正在连接 MySQL(维护实例)")
|
||||
conn = pymysql.connect(host=host, port=int(port), user=username, password=password, autocommit=True)
|
||||
cur = conn.cursor()
|
||||
if drop_first:
|
||||
_busy(f"正在删除旧目标库 `{database}`")
|
||||
cur.execute(f"DROP DATABASE IF EXISTS `{database}`")
|
||||
_busy(f"正在创建目标库 `{database}`(utf8mb4)")
|
||||
cur.execute(
|
||||
f"CREATE DATABASE IF NOT EXISTS `{database}` "
|
||||
f"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
|
||||
)
|
||||
cur.close()
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if driver == 'postgresql':
|
||||
import psycopg2
|
||||
_busy("正在连接 PostgreSQL(postgres 维护库)")
|
||||
# 连 postgres 维护库创建目标库;DB_DATABASE 含点号,标识符必须双引号
|
||||
conn = psycopg2.connect(
|
||||
host=host, port=int(port), user=username, password=password, dbname="postgres",
|
||||
)
|
||||
conn.autocommit = True
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (database,))
|
||||
exists = cur.fetchone() is not None
|
||||
if drop_first and exists:
|
||||
_busy(f'正在删除旧目标库 "{database}"(DROP ... WITH FORCE)')
|
||||
cur.execute(f'DROP DATABASE "{database}" WITH (FORCE)')
|
||||
exists = False
|
||||
if not exists:
|
||||
_busy(f'正在创建目标库 "{database}"(UTF8)')
|
||||
# 强制 UTF8 编码(TEMPLATE template0 避免继承模板库非 UTF8 编码导致 CJK 乱码)
|
||||
cur.execute(f'CREATE DATABASE "{database}" ENCODING \'UTF8\' TEMPLATE template0')
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if driver == 'oracle':
|
||||
# 服务由 DBA 管理,schema 就是连接用户;连上即视为"目标库就绪"
|
||||
import oracledb
|
||||
_busy(f"正在连接 Oracle 服务 {database}")
|
||||
conn = oracledb.connect(user=username, password=password, dsn=f"{host}:{port}/{database}")
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1 FROM dual")
|
||||
cur.fetchone()
|
||||
if drop_first:
|
||||
_busy(f"正在清理 {len(tables)} 张旧项目表")
|
||||
for t in tables:
|
||||
try:
|
||||
cur.execute(f"DROP TABLE {t} PURGE")
|
||||
except Exception:
|
||||
pass # 表不存在则跳过
|
||||
conn.close()
|
||||
print(f"[OK] Oracle 服务 {database} 连接成功(schema={username})")
|
||||
if drop_first:
|
||||
print(f" drop-first:已尽力清理 {len(tables)} 张项目表")
|
||||
return
|
||||
|
||||
# mssql:连 master 维护库创建目标库
|
||||
import pymssql
|
||||
_busy("正在连接 SQL Server(master 维护库)")
|
||||
conn = pymssql.connect(
|
||||
server=host, user=username, password=password,
|
||||
port=int(port), database="master", autocommit=True,
|
||||
)
|
||||
cur = conn.cursor()
|
||||
if drop_first:
|
||||
_busy(f"正在删除旧目标库 [{database}](SINGLE_USER + DROP)")
|
||||
cur.execute(
|
||||
f"IF DB_ID(N'{database}') IS NOT NULL "
|
||||
f"ALTER DATABASE [{database}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE"
|
||||
)
|
||||
cur.execute(f"IF DB_ID(N'{database}') IS NOT NULL DROP DATABASE [{database}]")
|
||||
_busy(f"正在创建目标库 [{database}]")
|
||||
cur.execute(f"IF DB_ID(N'{database}') IS NULL CREATE DATABASE [{database}]")
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 迁移顺序
|
||||
# ============================================================
|
||||
|
||||
def ordered_tables():
|
||||
"""按拓扑序返回要迁移的 Table 对象(base 先、dep 后;metadata 中未列出的追加末尾)。"""
|
||||
out, seen = [], set()
|
||||
for name in _BASE_TABLES + _DEP_TABLES:
|
||||
tbl = db.metadata.tables.get(DB_PREFIX + name)
|
||||
if tbl is not None:
|
||||
out.append(tbl)
|
||||
seen.add(tbl.name)
|
||||
for tbl in db.metadata.tables.values():
|
||||
if tbl.name not in seen:
|
||||
out.append(tbl)
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 泵数据
|
||||
# ============================================================
|
||||
|
||||
def sanitize(payload: list[dict], table) -> list[dict]:
|
||||
"""把驱动读回的零日期/非法日期字符串统一置 None(防御性兜底)。
|
||||
|
||||
PyMySQL 默认已把 MySQL 的 '0000-00-00 00:00:00' 转成 None;此处兜底处理
|
||||
个别驱动返回原始字符串的情况,避免 PostgreSQL/MSSQL 拒绝非法日期。
|
||||
本项目时间列全部 nullable,置 None 安全。
|
||||
"""
|
||||
for row in payload:
|
||||
for col in table.columns:
|
||||
v = row.get(col.name)
|
||||
if isinstance(v, str) and v.startswith('0000-00-00'):
|
||||
row[col.name] = None
|
||||
return payload
|
||||
|
||||
|
||||
def pump_table(src, dst, table, chunk_size: int, driver: str) -> int:
|
||||
"""逐 chunk 批量插入一张表,显式带 id 以保住裸列引用关系。
|
||||
|
||||
src/dst 为已就绪的 Connection;dst 处于调用方开启的事务内
|
||||
(默认每表一个事务;--single-transaction 时全库一个大事务)。
|
||||
mssql 必须 SET IDENTITY_INSERT ON 才能显式插自增 id。
|
||||
"""
|
||||
pk = next(iter(table.primary_key.columns))
|
||||
sel = select(*table.columns).order_by(pk) # ORDER BY id:自引用父先子后
|
||||
use_identity = (driver == 'mssql')
|
||||
total = 0
|
||||
t0 = time.perf_counter()
|
||||
if use_identity:
|
||||
dst.execute(text(f"SET IDENTITY_INSERT [{table.name}] ON"))
|
||||
try:
|
||||
result = src.execute(sel)
|
||||
while True:
|
||||
rows = result.fetchmany(chunk_size)
|
||||
if not rows:
|
||||
break
|
||||
payload = sanitize([dict(r._mapping) for r in rows], table)
|
||||
dst.execute(table.insert(), payload)
|
||||
total += len(payload)
|
||||
print(f" [{table.name}] +{len(payload)}(累计 {total})")
|
||||
finally:
|
||||
if use_identity:
|
||||
dst.execute(text(f"SET IDENTITY_INSERT [{table.name}] OFF"))
|
||||
print(f" [✓ {table.name}] 共 {total} 行(耗时 {_fmt_duration(time.perf_counter() - t0)})")
|
||||
return total
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 自增序列重置
|
||||
# ============================================================
|
||||
|
||||
def pg_reset_sql(table_name: str) -> str:
|
||||
"""PostgreSQL:把 SERIAL/IDENTITY 序列推进到 MAX(id),否则显式插 id 后新插入会主键冲突。
|
||||
|
||||
- 非空表:setval(seq, MAX(id)),is_called=true → 下一 id = MAX(id)+1
|
||||
- 空表: setval(seq, 1, false),is_called=false → 下一 id = 1
|
||||
(不能 setval(seq, 0):PG 序列最小值是 1,会抛 NumericValueOutOfRange)
|
||||
"""
|
||||
return (
|
||||
f"SELECT setval(pg_get_serial_sequence('{table_name}', 'id'), "
|
||||
f"COALESCE((SELECT MAX(id) FROM \"{table_name}\"), 1), "
|
||||
f"(SELECT MAX(id) FROM \"{table_name}\") IS NOT NULL)"
|
||||
)
|
||||
|
||||
|
||||
def mssql_reset_sql(table_name: str, max_id) -> str:
|
||||
"""SQL Server:RESEED 必须是字面量,先查 MAX(id) 再拼。空表 RESEED 0 → 下一 id=1。"""
|
||||
return f"DBCC CHECKIDENT ('{table_name}', RESEED, {max_id or 0})"
|
||||
|
||||
|
||||
def oracle_sequence_sql(table_name: str, start_with: int) -> str:
|
||||
"""Oracle:为无自增机制的 INTEGER 主键建 sequence,START WITH 决定下一 id。"""
|
||||
return f"CREATE SEQUENCE {table_name}_id_seq START WITH {start_with} INCREMENT BY 1 NOCACHE"
|
||||
|
||||
|
||||
def oracle_trigger_sql(table_name: str) -> str:
|
||||
"""Oracle:BEFORE INSERT 触发器,id 为 NULL 时从 sequence 取 NEXTVAL。"""
|
||||
seq = f"{table_name}_id_seq"
|
||||
trg = f"{table_name}_id_trg"
|
||||
return (
|
||||
f"CREATE OR REPLACE TRIGGER {trg}\n"
|
||||
f"BEFORE INSERT ON {table_name} FOR EACH ROW\n"
|
||||
f"WHEN (NEW.id IS NULL)\n"
|
||||
f"BEGIN SELECT {seq}.NEXTVAL INTO :NEW.id FROM dual; END;"
|
||||
)
|
||||
|
||||
|
||||
def ensure_oracle_autoincrement(conn, table_name: str):
|
||||
"""Oracle 迁移后补自增机制:重建 sequence(START WITH MAX(id)+1)+ BEFORE INSERT 触发器。
|
||||
|
||||
Oracle 的 create_all 对 Integer 自增主键只生成 INTEGER NOT NULL(无 identity/sequence),
|
||||
不补的话应用 save() 拿不到新 id。空表 MAX(id) 为 NULL → COALESCE 0 → START WITH 1。
|
||||
Oracle 无 DROP SEQUENCE IF EXISTS,先查 user_sequences 再删。
|
||||
"""
|
||||
max_id = conn.execute(text(f"SELECT COALESCE(MAX(id), 0) FROM {table_name}")).scalar()
|
||||
seq = f"{table_name}_id_seq"
|
||||
exists = conn.execute(text(
|
||||
"SELECT COUNT(*) FROM user_sequences WHERE sequence_name = UPPER(:n)"
|
||||
), {"n": seq}).scalar()
|
||||
if exists:
|
||||
conn.execute(text(f"DROP SEQUENCE {seq}"))
|
||||
conn.execute(text(oracle_sequence_sql(table_name, max_id + 1)))
|
||||
conn.execute(text(oracle_trigger_sql(table_name)))
|
||||
|
||||
|
||||
def reset_sequence(driver: str, conn, table):
|
||||
"""迁移后重置自增序列。MySQL / SQLite 显式插 id 后 auto_increment/rowid 自动
|
||||
跟随 MAX(id),无需处理;PostgreSQL / SQL Server / Oracle 需要。"""
|
||||
if driver == 'postgresql':
|
||||
conn.execute(text(pg_reset_sql(table.name)))
|
||||
elif driver == 'mssql':
|
||||
max_id = conn.execute(text(f"SELECT MAX(id) FROM [{table.name}]")).scalar()
|
||||
conn.execute(text(mssql_reset_sql(table.name, max_id)))
|
||||
elif driver == 'oracle':
|
||||
ensure_oracle_autoincrement(conn, table.name)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 校验
|
||||
# ============================================================
|
||||
|
||||
def verify_counts(src, dst, tables) -> list[tuple[str, int, int]]:
|
||||
"""逐表 count 对比,返回不一致的表列表((表名, 源行数, 目标行数))。"""
|
||||
failed = []
|
||||
for t in tables:
|
||||
s = src.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
|
||||
d = dst.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
|
||||
mark = "PASS" if s == d else "FAIL"
|
||||
print(f" [{t.name}] source={s} target={d} {mark}")
|
||||
if s != d:
|
||||
failed.append((t.name, s, d))
|
||||
return failed
|
||||
|
||||
|
||||
def _existing_equal(src, dst, table) -> bool:
|
||||
s = src.execute(text(f"SELECT COUNT(*) FROM {table.name}")).scalar()
|
||||
d = dst.execute(text(f"SELECT COUNT(*) FROM {table.name}")).scalar()
|
||||
return s == d
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 主流程
|
||||
# ============================================================
|
||||
|
||||
def _dry_run(src_engine, tables, dst_driver: str, dst_db: str, t_start: float) -> int:
|
||||
print("=" * 62)
|
||||
print(f"[DRY-RUN 开始] 计划迁移 {len(tables)} 张表 → {dst_driver}:{dst_db}")
|
||||
print("=" * 62)
|
||||
with src_engine.connect() as src:
|
||||
for t in tables:
|
||||
n = src.execute(text(f"SELECT COUNT(*) FROM {t.name}")).scalar()
|
||||
print(f" - {t.name}: {n} 行")
|
||||
print(f"[DRY-RUN 完成] {len(tables)} 张表核对完毕(耗时 {_fmt_duration(time.perf_counter() - t_start)}),未触碰目标库")
|
||||
return 0
|
||||
|
||||
|
||||
def run(args) -> int:
|
||||
"""执行一次迁移。返回进程退出码(0 成功 / 1 校验失败)。"""
|
||||
src_driver = _normalize_driver(args.src_driver)
|
||||
dst_driver = _normalize_driver(args.dst_driver)
|
||||
|
||||
src_engine = make_engine(
|
||||
src_driver, args.src_host, _resolve_port(src_driver, args.src_port),
|
||||
args.src_db, args.src_user, args.src_pass or os.environ.get("SRC_DB_PASS", ""),
|
||||
)
|
||||
dst_engine = make_engine(
|
||||
dst_driver, args.dst_host, _resolve_port(dst_driver, args.dst_port),
|
||||
args.dst_db, args.dst_user, args.dst_pass or os.environ.get("DST_DB_PASS", ""),
|
||||
)
|
||||
|
||||
tables = ordered_tables()
|
||||
if args.only_tables:
|
||||
wanted = {x.strip() for x in args.only_tables.split(",") if x.strip()}
|
||||
tables = [t for t in tables if t.name in wanted or t.name[len(DB_PREFIX):] in wanted]
|
||||
|
||||
try:
|
||||
# 源端缺表容错(老库可能缺整表)
|
||||
src_insp = inspect(src_engine)
|
||||
present, missing = [], []
|
||||
for t in tables:
|
||||
if src_insp.has_table(t.name):
|
||||
present.append(t)
|
||||
else:
|
||||
missing.append(t)
|
||||
for t in missing:
|
||||
print(f"[SKIP] 源库不存在表 {t.name},跳过")
|
||||
|
||||
t_start = time.perf_counter()
|
||||
if args.dry_run:
|
||||
return _dry_run(src_engine, present, dst_driver, args.dst_db, t_start)
|
||||
|
||||
print("=" * 62)
|
||||
print(f"[迁移开始] 源: {src_driver}:{args.src_db} → 目标: {dst_driver}:{args.dst_db}")
|
||||
note = "" if not missing else f",源端缺失 {len(missing)} 张已跳过"
|
||||
print(f"[迁移开始] 待迁移 {len(present)} 张表{note}")
|
||||
print("=" * 62)
|
||||
|
||||
# 1) 建目标库(删库/建库可能耗时较长,step() 会给出缓冲提示)
|
||||
with step("[1/5] 建目标库"):
|
||||
create_database_if_missing(
|
||||
dst_driver, args.dst_host, _resolve_port(dst_driver, args.dst_port),
|
||||
args.dst_db, args.dst_user, args.dst_pass or os.environ.get("DST_DB_PASS", ""),
|
||||
drop_first=args.drop_target_first,
|
||||
tables=tuple(t.name for t in present), # Oracle drop_first 时清表用
|
||||
)
|
||||
|
||||
# 2) 建结构(模型即 head,方言可移植)
|
||||
with step(f"[2/5] 建表结构({len(present)} 张表 create_all)") as h:
|
||||
db.metadata.create_all(dst_engine)
|
||||
h['detail'] = f"{len(present)} 张表"
|
||||
|
||||
# 3) 泵数据
|
||||
total_rows = 0
|
||||
with step("[3/5] 逐表迁移数据") as h:
|
||||
if args.single_transaction:
|
||||
with dst_engine.begin() as dst, src_engine.connect() as src:
|
||||
for idx, t in enumerate(present, 1):
|
||||
if args.skip_existing and _existing_equal(src, dst, t):
|
||||
print(f" [skip] {t.name}(源=目标)")
|
||||
continue
|
||||
_busy(f"[{idx}/{len(present)}] 正在迁移 {t.name}")
|
||||
total_rows += pump_table(src, dst, t, args.chunk_size, dst_driver)
|
||||
else:
|
||||
with src_engine.connect() as src:
|
||||
for idx, t in enumerate(present, 1):
|
||||
with dst_engine.begin() as dst:
|
||||
if args.skip_existing and _existing_equal(src, dst, t):
|
||||
print(f" [skip] {t.name}(源=目标)")
|
||||
continue
|
||||
_busy(f"[{idx}/{len(present)}] 正在迁移 {t.name}")
|
||||
total_rows += pump_table(src, dst, t, args.chunk_size, dst_driver)
|
||||
h['detail'] = f"共 {total_rows} 行"
|
||||
|
||||
# 4) 重置自增序列(PG/MSSQL/Oracle;MySQL/SQLite 自动跟随)
|
||||
# 单表失败不中断其余表,避免一张空表序列异常连累后续全部序列
|
||||
reset_errors = []
|
||||
if dst_driver in ("postgresql", "mssql", "oracle"):
|
||||
with step("[4/5] 重置自增序列") as h:
|
||||
for t in present:
|
||||
try:
|
||||
# 每表独立事务:Oracle 的 CREATE/DROP SEQUENCE / TRIGGER 是 DDL 会隐式提交,
|
||||
# 不能与其它表共享一个大事务
|
||||
with dst_engine.begin() as dst:
|
||||
reset_sequence(dst_driver, dst, t)
|
||||
except Exception as e:
|
||||
reset_errors.append((t.name, str(e)))
|
||||
print(f" [WARN] 序列重置失败 {t.name}: {e}")
|
||||
if reset_errors:
|
||||
h['detail'] = f"{len(reset_errors)} 张失败"
|
||||
else:
|
||||
print("[4/5 重置序列] MySQL/SQLite 自动跟随,无需处理")
|
||||
|
||||
# 5) 校验
|
||||
if not args.skip_verify:
|
||||
with step("[5/5] 逐表校验行数") as h:
|
||||
with src_engine.connect() as src, dst_engine.connect() as dst:
|
||||
failed = verify_counts(src, dst, present)
|
||||
h['detail'] = f"{len(failed)} 张表不一致" if failed else f"{len(present)} 张表全部 PASS"
|
||||
if failed:
|
||||
for name, s, d in failed:
|
||||
print(f" - {name}: source={s} target={d}")
|
||||
return 1
|
||||
else:
|
||||
print("[5/5 校验] 已跳过(--skip-verify)")
|
||||
|
||||
# 汇总
|
||||
print("=" * 62)
|
||||
print(f"[迁移完成] 成功:{total_rows} 行 / {len(present)} 张表 / 总耗时 {_fmt_duration(time.perf_counter() - t_start)}")
|
||||
print("=" * 62)
|
||||
return 0
|
||||
finally:
|
||||
src_engine.dispose()
|
||||
dst_engine.dispose()
|
||||
|
||||
|
||||
def main():
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
sys.exit(run(args))
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(130)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
种子数据脚本(幂等,可重复执行)
|
||||
=============================================================
|
||||
|
||||
在空库上初始化系统必需数据,供多数据库(mysql/pgsql/mssql/sqlite/oracle)就绪:
|
||||
- admin 超管用户(id=1,密码见 DEFAULT_PASSWORD,默认 123456)
|
||||
- 超级管理员角色 + 关联
|
||||
- 系统管理目录 + 常用管理菜单 + 查询权限节点
|
||||
- 基础字典(性别)、站点配置、系统参数
|
||||
|
||||
用法:由 scripts/setup_db.py 在 app 上下文内调用 seed_all()。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 将项目根加入 sys.path(脚本以 `python scripts/xxx.py` 运行时 cwd 是 scripts/)
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from src.core.database import db # noqa: E402
|
||||
from src.core.password import encrypt_password, generate_salt # noqa: E402
|
||||
from src.common.constants.common import DEFAULT_PASSWORD # noqa: E402
|
||||
from src.modules.system.user.repository import UserRepository # noqa: E402
|
||||
from src.modules.system.role.repository import RoleRepository # noqa: E402
|
||||
from src.modules.association.user_role.repository import UserRoleRepository # noqa: E402
|
||||
from src.modules.system.menu.repository import MenuRepository # noqa: E402
|
||||
from src.modules.dictionary.dict.repository import DictRepository # noqa: E402
|
||||
from src.modules.dictionary.dict_item.repository import DictItemRepository # noqa: E402
|
||||
from src.modules.configuration.config.repository import ConfigRepository # noqa: E402
|
||||
from src.modules.configuration.config_item.repository import ConfigItemRepository # noqa: E402
|
||||
from src.modules.param.repository import ParamRepository # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_or_create(repo, filters, defaults):
|
||||
"""幂等:按等值条件查,不存在则创建"""
|
||||
obj = repo.first_by(filters)
|
||||
if obj is None:
|
||||
obj = repo.create(**defaults)
|
||||
db.session.commit()
|
||||
logger.info(f"已创建 {repo.__name__}: {filters}")
|
||||
return obj
|
||||
|
||||
|
||||
def seed_admin_user():
|
||||
"""创建 admin 超管用户(id=1,超管绕过 user_id==1)"""
|
||||
if UserRepository.first_by({"username": "admin"}):
|
||||
logger.info("admin 用户已存在,跳过")
|
||||
return
|
||||
salt = generate_salt()
|
||||
admin = UserRepository.create(
|
||||
username="admin",
|
||||
realname="超级管理员",
|
||||
gender=1,
|
||||
avatar="",
|
||||
mobile="",
|
||||
email="",
|
||||
dept_id=0,
|
||||
level_id=0,
|
||||
position_id=0,
|
||||
province_code="",
|
||||
city_code="",
|
||||
district_code="",
|
||||
street_code="",
|
||||
address="",
|
||||
status=1,
|
||||
sort=1,
|
||||
note="系统内置超管,请登录后修改密码",
|
||||
password=encrypt_password(DEFAULT_PASSWORD, salt),
|
||||
salt=salt,
|
||||
create_user="system",
|
||||
)
|
||||
db.session.commit()
|
||||
logger.info(f"已创建 admin 用户(id={admin.id},初始密码 {DEFAULT_PASSWORD})")
|
||||
|
||||
|
||||
def seed_superadmin_role():
|
||||
"""创建超级管理员角色并关联 admin 用户"""
|
||||
role = _get_or_create(
|
||||
RoleRepository,
|
||||
{"code": "admin"},
|
||||
{"name": "超级管理员", "code": "admin", "status": 1, "sort": 1, "create_user": "system"},
|
||||
)
|
||||
admin = UserRepository.first_by({"username": "admin"})
|
||||
if admin and not UserRoleRepository.first_by({"user_id": admin.id, "role_id": role.id}):
|
||||
UserRoleRepository.create(user_id=admin.id, role_id=role.id, create_user="system")
|
||||
db.session.commit()
|
||||
logger.info("已关联 admin → 超级管理员")
|
||||
|
||||
|
||||
def seed_basic_menus():
|
||||
"""创建系统管理目录 + 常用管理菜单 + 查询权限节点"""
|
||||
if MenuRepository.first_by({"name": "系统管理", "parent_id": 0}):
|
||||
logger.info("系统管理菜单已存在,跳过")
|
||||
return
|
||||
|
||||
sys_dir = MenuRepository.create(
|
||||
name="系统管理", parent_id=0, path="/system", component="",
|
||||
permission="", type=0, status=1, target=0, sort=1, icon="setting",
|
||||
hide=0, create_user="system",
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
child_menus = [
|
||||
("用户管理", "/user", "system/user/index", "sys:user:page", 1),
|
||||
("角色管理", "/role", "system/role/index", "sys:role:page", 2),
|
||||
("菜单管理", "/menu", "system/menu/index", "sys:menu:page", 3),
|
||||
("部门管理", "/dept", "system/dept/index", "sys:dept:page", 4),
|
||||
("岗位管理", "/position", "system/position/index", "sys:position:page", 5),
|
||||
("职级管理", "/level", "system/level/index", "sys:level:page", 6),
|
||||
]
|
||||
for name, path, component, permission, sort in child_menus:
|
||||
menu = MenuRepository.create(
|
||||
name=name, parent_id=sys_dir.id, path=path, component=component,
|
||||
permission=permission, type=0, status=1, target=0, sort=sort,
|
||||
hide=0, create_user="system",
|
||||
)
|
||||
db.session.commit()
|
||||
# 查询权限节点(type=1,前端按钮/接口鉴权用)
|
||||
MenuRepository.create(
|
||||
name=f"查询{name}", parent_id=menu.id, path=f"{path}/page", component="",
|
||||
permission=permission, type=1, status=1, target=0, sort=1,
|
||||
create_user="system",
|
||||
)
|
||||
db.session.commit()
|
||||
logger.info("已创建系统管理目录与常用菜单")
|
||||
|
||||
|
||||
def seed_import_export_permissions():
|
||||
"""为存量菜单幂等补齐「导入/导出」按钮权限节点
|
||||
|
||||
背景说明:
|
||||
导入/导出 按钮在前端通过 v-perm 指令按权限码鉴权(如 sys:user:import),
|
||||
权限码必须存在于菜单表中(type=1 按钮节点),否则即使后端接口已放行、
|
||||
后端装饰器也已校验,前端按钮仍不会渲染。本函数为老库中已存在的菜单
|
||||
补建对应按钮节点,与新建库流程(seed_basic_menus)保持一致。
|
||||
|
||||
设计要点:
|
||||
1. 幂等:以「父菜单名 + type=0」定位父菜单,以「按钮名 + parent_id」判重,
|
||||
已存在的节点直接跳过,脚本可安全重复执行。
|
||||
2. 容错:父菜单缺失(如老库中未创建「职级管理」)时仅记录日志并跳过,
|
||||
不中断其余按钮节点创建。
|
||||
3. 按钮路径约定为 <父菜单路径>/import、<父菜单路径>/export,
|
||||
与后端 router 的路由保持一致,便于前端跳转与鉴权对齐。
|
||||
"""
|
||||
# 待补建的按钮节点配置:父菜单名 / 按钮名 / 路径 / 权限码 / 排序
|
||||
btn_specs = [
|
||||
("用户管理", "导入用户", "/user/import", "sys:user:import", 2),
|
||||
("用户管理", "导出用户", "/user/export", "sys:user:export", 3),
|
||||
("职级管理", "导入职级", "/level/import", "sys:level:import", 2),
|
||||
("职级管理", "导出职级", "/level/export", "sys:level:export", 3),
|
||||
]
|
||||
for menu_name, btn_name, btn_path, permission, sort in btn_specs:
|
||||
parent = MenuRepository.first_by({"name": menu_name, "type": 0})
|
||||
if not parent:
|
||||
logger.warning(f"菜单「{menu_name}」不存在,跳过权限节点 {permission}")
|
||||
continue
|
||||
if MenuRepository.first_by({"name": btn_name, "parent_id": parent.id}):
|
||||
logger.info(f"权限节点 {permission} 已存在,跳过")
|
||||
continue
|
||||
MenuRepository.create(
|
||||
name=btn_name, parent_id=parent.id, path=btn_path, component="",
|
||||
permission=permission, type=1, status=1, target=0, sort=sort,
|
||||
create_user="system",
|
||||
)
|
||||
db.session.commit()
|
||||
logger.info(f"已创建权限节点 {permission}({btn_name})")
|
||||
|
||||
|
||||
def seed_basic_dict():
|
||||
"""创建性别字典"""
|
||||
if DictRepository.first_by({"code": "gender"}):
|
||||
logger.info("性别字典已存在,跳过")
|
||||
return
|
||||
dict_obj = DictRepository.create(name="性别", code="gender", sort=1, create_user="system")
|
||||
db.session.commit()
|
||||
for name, value, sort in [("男", "1", 1), ("女", "2", 2), ("保密", "3", 3)]:
|
||||
DictItemRepository.create(
|
||||
dict_id=dict_obj.id, name=name, value=value, sort=sort, create_user="system"
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def seed_basic_config():
|
||||
"""创建站点配置"""
|
||||
if ConfigRepository.first_by({"code": "site_config"}):
|
||||
logger.info("站点配置已存在,跳过")
|
||||
return
|
||||
config = ConfigRepository.create(name="站点配置", code="site_config", sort=1, create_user="system")
|
||||
db.session.commit()
|
||||
ConfigItemRepository.create(
|
||||
config_id=config.id, name="网站名称",
|
||||
code="site_name", value="DjangoAdmin", type="text", sort=1, create_user="system",
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def seed_basic_param():
|
||||
"""创建系统参数"""
|
||||
_get_or_create(
|
||||
ParamRepository,
|
||||
{"code": "system_param"},
|
||||
{"name": "系统参数", "code": "system_param", "value": "", "type": 0, "status": 1, "sort": 1, "create_user": "system"},
|
||||
)
|
||||
|
||||
|
||||
def seed_all():
|
||||
"""执行全部种子(幂等)"""
|
||||
seed_admin_user()
|
||||
seed_superadmin_role()
|
||||
seed_basic_menus()
|
||||
# 为存量库补建导入/导出按钮权限节点,必须在菜单创建之后执行
|
||||
seed_import_export_permissions()
|
||||
seed_basic_dict()
|
||||
seed_basic_config()
|
||||
seed_basic_param()
|
||||
logger.info("种子数据初始化完成")
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
字典数据一键初始化脚本
|
||||
=============================================================
|
||||
|
||||
以本文件内置的 DICT_SEED 为数据源,把所有字典(dict + dict_item)幂等写入数据库
|
||||
(已存在的字典按 code 跳过)。
|
||||
|
||||
用法:
|
||||
python scripts/seed_dict.py
|
||||
|
||||
说明:
|
||||
- 一次性初始化工具,数据入库后可删除本文件
|
||||
- DICT_SEED 是本项目全部数据字典的唯一种子数据源(运行时展示一律走 dict_utils
|
||||
从字典表读取,勿在业务代码里再硬编码枚举)
|
||||
- 之后请在后台"字典管理"维护字典项(写入会触发 dict_utils 缓存失效,前端即时生效)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 将项目根加入 sys.path(脚本以 `python scripts/xxx.py` 运行时 cwd 是 scripts/)
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
# ============================================================
|
||||
# 系统全部数据字典种子定义(幂等初始化:已存在的 code 跳过)
|
||||
# code:字典编码(稳定业务键,如 gender、status、operation_type)
|
||||
# name:字典中文名;note:字典备注(可选)
|
||||
# items:字典项,value 为存储值(写入时统一转为字符串,dict_item.value 是 String 列),
|
||||
# name 为显示文本,顺序即 sort 排序
|
||||
# ============================================================
|
||||
|
||||
DICT_SEED = [
|
||||
{
|
||||
"code": "gender", "name": "性别", "note": "用户性别",
|
||||
"items": [
|
||||
{"value": "1", "name": "男"},
|
||||
{"value": "2", "name": "女"},
|
||||
{"value": "3", "name": "保密"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "status", "name": "状态", "note": "通用启用状态",
|
||||
"items": [
|
||||
{"value": "1", "name": "正常"},
|
||||
{"value": "2", "name": "禁用"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "param_type", "name": "参数类型",
|
||||
"items": [
|
||||
{"value": "0", "name": "系统参数"},
|
||||
{"value": "1", "name": "业务参数"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "link_type", "name": "友链类型",
|
||||
"items": [
|
||||
{"value": "1", "name": "友情链接"},
|
||||
{"value": "2", "name": "合作伙伴"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "link_form", "name": "友链形式",
|
||||
"items": [
|
||||
{"value": "1", "name": "文字链接"},
|
||||
{"value": "2", "name": "图片链接"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "config_item_type", "name": "配置项类型", "note": "配置项输入控件类型",
|
||||
"items": [
|
||||
{"value": "text", "name": "单行文本"},
|
||||
{"value": "textarea", "name": "多行文本"},
|
||||
{"value": "ueditor", "name": "富文本编辑器"},
|
||||
{"value": "date", "name": "日期"},
|
||||
{"value": "datetime", "name": "时间"},
|
||||
{"value": "number", "name": "数字"},
|
||||
{"value": "select", "name": "下拉框"},
|
||||
{"value": "radio", "name": "单选框"},
|
||||
{"value": "checkbox", "name": "复选框"},
|
||||
{"value": "image", "name": "单张图片"},
|
||||
{"value": "images", "name": "多张图片"},
|
||||
{"value": "password", "name": "密码"},
|
||||
{"value": "icon", "name": "字体图标"},
|
||||
{"value": "file", "name": "单个文件"},
|
||||
{"value": "files", "name": "多个文件"},
|
||||
{"value": "hidden", "name": "隐藏"},
|
||||
{"value": "readonly", "name": "只读文本"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "city_level", "name": "城市级别", "note": "行政区划级别",
|
||||
"items": [
|
||||
{"value": "1", "name": "省份"},
|
||||
{"value": "2", "name": "城市"},
|
||||
{"value": "3", "name": "县区"},
|
||||
{"value": "4", "name": "街道"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "notice_type", "name": "通知类型",
|
||||
"items": [
|
||||
{"value": "1", "name": "通知"},
|
||||
{"value": "2", "name": "公告"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "operation_source", "name": "操作日志来源",
|
||||
"items": [
|
||||
{"value": "0", "name": "后台系统"},
|
||||
{"value": "1", "name": "手机站"},
|
||||
{"value": "2", "name": "客户端"},
|
||||
{"value": "3", "name": "其他"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "operation_type", "name": "操作日志类型",
|
||||
"items": [
|
||||
{"value": "1", "name": "登录"},
|
||||
{"value": "2", "name": "退出"},
|
||||
{"value": "3", "name": "新增"},
|
||||
{"value": "4", "name": "修改"},
|
||||
{"value": "5", "name": "删除"},
|
||||
{"value": "6", "name": "查询"},
|
||||
{"value": "7", "name": "导出"},
|
||||
{"value": "8", "name": "导入"},
|
||||
{"value": "9", "name": "提交"},
|
||||
{"value": "10", "name": "撤销"},
|
||||
{"value": "11", "name": "审核"},
|
||||
{"value": "12", "name": "强退"},
|
||||
{"value": "13", "name": "开账号"},
|
||||
{"value": "14", "name": "清空"},
|
||||
{"value": "15", "name": "授权"},
|
||||
{"value": "16", "name": "缓存"},
|
||||
{"value": "17", "name": "生成代码"},
|
||||
{"value": "18", "name": "其他"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "login_type", "name": "登录日志类型",
|
||||
"items": [
|
||||
{"value": "1", "name": "登录"},
|
||||
{"value": "2", "name": "退出"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "article_status", "name": "文章状态",
|
||||
"items": [
|
||||
{"value": "0", "name": "正常"},
|
||||
{"value": "1", "name": "下架"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "job_log_status", "name": "任务日志状态", "note": "定时任务执行状态",
|
||||
"items": [
|
||||
{"value": "0", "name": "正常"},
|
||||
{"value": "1", "name": "失败"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "job_status", "name": "任务状态", "note": "定时任务状态",
|
||||
"items": [
|
||||
{"value": "0", "name": "未发布"},
|
||||
{"value": "1", "name": "运行中"},
|
||||
{"value": "2", "name": "暂停"},
|
||||
{"value": "3", "name": "删除"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"code": "job_execute_policy", "name": "执行策略", "note": "定时任务执行策略",
|
||||
"items": [
|
||||
{"value": "1", "name": "立即执行"},
|
||||
{"value": "2", "name": "执行一次"},
|
||||
{"value": "3", "name": "放弃执行"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_dict(app) -> int:
|
||||
"""将 DICT_SEED 幂等写入 app 绑定的数据库,返回新增字典数(可测试)。"""
|
||||
from src.core.database import db
|
||||
from src.modules.dictionary import dict_utils
|
||||
from src.modules.dictionary.dict.repository import DictRepository
|
||||
from src.modules.dictionary.dict_item.repository import DictItemRepository
|
||||
|
||||
created = 0
|
||||
with app.app_context():
|
||||
for sort, spec in enumerate(DICT_SEED, 1):
|
||||
if DictRepository.first_by({"code": spec["code"]}):
|
||||
print(f" [skip] {spec['code']}(已存在)")
|
||||
continue
|
||||
dict_obj = DictRepository.create(
|
||||
name=spec["name"], code=spec["code"],
|
||||
note=spec.get("note", ""), sort=sort, create_user="system",
|
||||
)
|
||||
for idx, item in enumerate(spec["items"], 1):
|
||||
DictItemRepository.create(
|
||||
dict_id=dict_obj.id,
|
||||
name=item["name"], value=str(item["value"]),
|
||||
sort=item.get("sort", idx), create_user="system",
|
||||
)
|
||||
db.session.commit()
|
||||
created += 1
|
||||
print(f" [ok] {spec['code']}({len(spec['items'])} 项)")
|
||||
# 失效取值缓存,保证后续读取走新数据
|
||||
dict_utils.clear_cache()
|
||||
return created
|
||||
|
||||
|
||||
def main():
|
||||
from src.app import create_app
|
||||
|
||||
print(f"开始初始化字典数据(共 {len(DICT_SEED)} 个字典)...")
|
||||
created = seed_dict(create_app())
|
||||
print(f"完成:新增 {created} 个字典,跳过 {len(DICT_SEED) - created} 个已存在")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
多数据库建表 + 种子数据脚本(方案 A)
|
||||
=============================================================
|
||||
|
||||
在任何受支持的数据库(mysql/pgsql/mssql/sqlite/oracle)上初始化系统:
|
||||
1. 按 .env 的 DB_DRIVER 连接目标库
|
||||
2. db.create_all() 建全部表(方言中立,各库通用)
|
||||
3. 写入种子数据(admin 超管、角色、基础菜单/字典/配置)
|
||||
|
||||
用法:
|
||||
# 切换数据库只需改 .env(DB_DRIVER + 账号),然后执行:
|
||||
python scripts/setup_db.py
|
||||
|
||||
# 仅补种子(表已存在)
|
||||
python -c "from scripts.setup_db import seed_only; seed_only()"
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
# 将项目根加入 sys.path(脚本以 `python scripts/xxx.py` 运行时 cwd 是 scripts/)
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
from src.app import create_app # noqa: E402
|
||||
from src.core.database import db # noqa: E402
|
||||
from config import DatabaseConfig, friendly_db_error # noqa: E402
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||
|
||||
|
||||
def setup_database():
|
||||
"""建表 + 种子(连接失败时给出友好中文提示)"""
|
||||
app = create_app()
|
||||
print(f"目标数据库: {DatabaseConfig.SQLALCHEMY_DATABASE_URI}")
|
||||
try:
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
print("已建表(create_all)")
|
||||
from scripts.seed_data import seed_all
|
||||
seed_all()
|
||||
except Exception as e:
|
||||
raise SystemExit(f"建表/初始化失败:{friendly_db_error(e)}")
|
||||
|
||||
|
||||
def seed_only():
|
||||
"""仅补种子数据(表已存在时)"""
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
from scripts.seed_data import seed_all
|
||||
seed_all()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
setup_database()
|
||||
Reference in New Issue
Block a user