签入版本

This commit is contained in:
2026-09-12 17:39:13 +08:00
parent 25c8b039ac
commit 3233d221b0
871 changed files with 103088 additions and 10561 deletions
+201
View File
@@ -0,0 +1,201 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
# +======================================================================
# | 模块: 定时任务闭环演示脚本
# | 说明: 打通「调度 → 执行 → 任务日志」完整闭环
# +======================================================================
"""
定时任务闭环演示脚本
=============================================================
打通「调度 → 执行 → 任务日志」完整闭环:
1. 向数据库新增一条测试任务(job_trigger=send_sms,job_executor 内置处理器,
仅打日志返回成功,无外部依赖)
2. 启动调度器(init_scheduler)按 cron 触发执行(execute_job)
3. 执行完成写入 fastapi_job_log(record_job_log),脚本打印新增日志证据
演示结束后测试任务置为未发布(status=0),避免后续调度器自动触发;
后台改回「运行中」并配置 cron 即可复现。
用法:
python scripts/demo_job_loop.py # cron 模式:自动等到下个整分钟边界触发一次
python scripts/demo_job_loop.py --wait 30 # cron 模式:手动等待 30s
python scripts/demo_job_loop.py --once # 立即执行一次(不走 cron 调度)
python scripts/demo_job_loop.py --cleanup # 演示后软删除测试任务及其日志
"""
import argparse
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
# Windows 控制台默认 GBK(cp936):把 stdout/stderr 切到 UTF-8,避免中文打印乱码/崩溃
if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# ============================================================
# 测试任务配置
# ============================================================
TEST_JOB_NAME = "闭环测试-短信发送"
TEST_JOB_TRIGGER = "send_sms" # job_executor 内置处理器,无外部依赖
# 6 字段标准 cron:首字段为秒,每 15 秒触发一次(scheduler 已支持 5/6 字段)
TEST_CRON = "*/15 * * * * *"
# ============================================================
# 辅助函数
# ============================================================
def _insert_test_job(db):
"""新增测试任务(同名已存在则复用),返回 (job, 是否新建)"""
from modules.job.models import Job
job = db.query(Job).filter(Job.job_name == TEST_JOB_NAME, Job.is_delete == 0).first()
if job:
# 复用:确保置回运行中且 cron 正确,便于重复演示
db.query(Job).filter(Job.id == job.id).update(
{"status": 1, "cron_expression": TEST_CRON}, synchronize_session=False)
db.commit()
db.refresh(job)
return job, False
job = Job(
job_name=TEST_JOB_NAME,
job_alias="闭环演示",
job_group="演示",
job_trigger=TEST_JOB_TRIGGER,
status=1, # 运行中:调度器只加载 status==1 的任务
cron_expression=TEST_CRON,
execute_policy=1, # 立即执行
is_sync=0,
note="scripts/demo_job_loop.py 闭环演示用",
)
db.add(job)
db.commit()
return job, True
def _fetch_logs(job_id):
"""查询该任务最近的任务日志(独立新会话,避免主会话 REPEATABLE READ 快照看不到新日志)"""
from core.database import SessionLocal
from modules.job_log.models import JobLog
s = SessionLocal()
try:
return s.query(JobLog).filter(
JobLog.job_id == job_id, JobLog.is_delete == 0
).order_by(JobLog.id.desc()).limit(5).all()
finally:
s.close()
# ============================================================
# 主入口
# ============================================================
def main():
parser = argparse.ArgumentParser(description="定时任务闭环演示:调度→执行→日志")
parser.add_argument("--once", action="store_true", help="立即执行一次(不依赖 cron 调度)")
parser.add_argument("--wait", type=int, default=0,
help="cron 模式轮询超时秒数;默认0=自动 25s(cron 每 15s 触发,25s 内必有新日志)")
parser.add_argument("--cleanup", action="store_true", help="演示后软删除测试任务及其日志")
args = parser.parse_args()
from core.database import SessionLocal
db = SessionLocal()
try:
job, created = _insert_test_job(db)
print(f"测试任务: id={job.id} name={job.job_name} trigger={job.job_trigger} "
f"cron={job.cron_expression}({'新建' if created else '复用已存在'})")
before = _fetch_logs(job.id)
print(f"执行前该任务日志数: {len(before)}")
if args.once:
# 立即执行:execute_job → _record_execution_log → record_job_log(同步链路)
from modules.job.scheduler import execute_job
result = execute_job(job.id, job.url, job.is_sync, job.job_trigger, {})
print(f"execute_job 返回: {result}")
else:
# 调度器闭环:启动调度器加载 cron 任务 → 轮询等待新日志出现 → 关闭
from modules.job.scheduler import init_scheduler, shutdown_scheduler
loaded = init_scheduler()
timeout = args.wait if args.wait > 0 else 25
print(f"调度器已启动,加载 {loaded} 个启用任务(含测试任务),"
f"轮询等待新日志出现(最多 {timeout}s,cron 每 15s 触发)…")
deadline = time.time() + timeout
last_count = len(before)
while time.time() < deadline:
time.sleep(2)
if len(_fetch_logs(job.id)) > last_count:
break
shutdown_scheduler()
print("调度器已关闭")
after = _fetch_logs(job.id)
before_ids = {x.id for x in before}
new_logs = [log for log in after if log.id not in before_ids]
print(f"执行后日志数: {len(after)},本次新增 {len(new_logs)} 条")
for log in new_logs:
print(f" [job_log id={log.id}] status={log.status} message={log.job_message} "
f"consume={log.consume_time}ms exception={log.exception_info or ''}")
# 收尾:置为未发布,避免演示后调度器再次自动触发
db.query(job.__class__).filter(job.__class__.id == job.id).update(
{"status": 0}, synchronize_session=False)
db.commit()
print("演示结束,测试任务已置为未发布(status=0),后台改回运行中可复现")
if args.cleanup:
from modules.job_log.models import JobLog
db.query(JobLog).filter(JobLog.job_id == job.id, JobLog.is_delete == 0).update(
{"is_delete": 1}, synchronize_session=False)
db.query(job.__class__).filter(job.__class__.id == job.id).update(
{"is_delete": 1}, synchronize_session=False)
db.commit()
print("已软删除测试任务及其日志")
finally:
db.close()
if __name__ == "__main__":
main()
+418 -44
View File
@@ -1,11 +1,69 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
# +======================================================================
# | 模块: 数据库初始化脚本
# | 说明: 按 DB_DRIVER 分方言创建数据库并建表
# +======================================================================
"""
数据库初始化脚本
=============================================================
功能:
1. 创建数据库(默认 utf8mb4)
2. 导入 document/djangoadmin.fastapi.elevue.sql(若存在)
3. 用模型 metadata 补建 SQL 中缺失的表(如 fastapi_login_log / fastapi_operation_log)
按 DB_DRIVER 分方言创建数据库并建表:
- mysql: 创建库(utf8mb4)→ 导入 document/mysql/djangoadmin.fastapi.elevue.sql(若存在)
→ 用模型 metadata 补建缺失的表
- postgresql: 连 postgres 维护库,无则建库 → 导入 document/postgresql/ 下的 SQL(若存在)→ create_all
- mssql: 连 master 库,无则建库 → 导入 document/sqlserver/ 下的 SQL(若存在)→ create_all
- oracle: 连 service_name 校验连通(Oracle 无"建库"概念,schema 即连接用户,
需 DBA 预建)→ create_all 建表 → 自动补 sequence + BEFORE INSERT
触发器(Oracle create_all 不生成 Integer 主键自增)
- sqlite: 直接 create_all 生成数据库文件(DB_DATABASE 即数据库文件完整路径)
SQL 脚本按数据库类型分目录存放在 document/ 下:
document/mysql/djangoadmin.fastapi.elevue.sql
document/postgresql/djangoadmin.fastapi.elevue.sql
document/sqlserver/djangoadmin.fastapi.elevue.sql
document/sqlite/djangoadmin.fastapi.elevue.sql
document/oracle/djangoadmin.fastapi.elevue.sql
用法:
python scripts/init_db.py # 使用 .env 中的数据库配置
@@ -16,11 +74,35 @@ import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
# Windows 控制台默认 GBK(cp936):把 stdout/stderr 切到 UTF-8,避免中文打印乱码/崩溃
if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
from sqlalchemy import text
# ============================================================
# 辅助函数
# ============================================================
def _stamp_alembic():
"""建库完成后将库标记到 alembic 基线,后续增量走迁移。失败不影响建库"""
try:
from alembic.config import Config
from alembic import command
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
cfg = Config(os.path.join(root, "alembic.ini"))
command.stamp(cfg, "head")
print("[INFO] Alembic 版本标记完成")
except Exception as e:
print(f"[WARN] Alembic 版本标记失败(可稍后手动执行 alembic stamp head): {e}")
def split_statements(content: str):
"""按 ';\\n' 切分语句,并剥离 -- 注释行,避免注释+语句整块被跳过。"""
"""按 ';\\n' 切分语句,并剥离 -- 注释行,避免注释+语句整块被跳过"""
for block in content.split(";\n"):
body = []
for line in block.splitlines():
@@ -33,16 +115,51 @@ def split_statements(content: str):
yield stmt
def oracle_sequence_sql(table_name: str, start_with: int) -> str:
"""Oracle:为无自增机制的 INTEGER 主键建 sequence"""
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 create_all 后补自增机制:重建 sequence + BEFORE INSERT 触发器"""
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 main():
parser = argparse.ArgumentParser(description="初始化数据库")
parser.add_argument("--drop-first", action="store_true", help="先删除已存在库再重建")
args = parser.parse_args()
# 加载 .env 数据库配置
from config import DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PREFIX
from core.config import (
DB_DRIVER, DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD,
DB_PREFIX, SQLALCHEMY_DATABASE_URL,
)
import pymysql
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect
from core.database import Base
# 加载全部模型(导入 create_app 会连带导入所有模块)
@@ -50,47 +167,304 @@ def main():
create_app()
root_conn = pymysql.connect(host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME, password=DB_PASSWORD)
cur = root_conn.cursor()
if args.drop_first:
cur.execute(f"DROP DATABASE IF EXISTS `{DB_DATABASE}`")
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{DB_DATABASE}` "
f"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
)
cur.execute(f"USE `{DB_DATABASE}`")
print(f"database {DB_DATABASE} ready (prefix={DB_PREFIX})")
# ----------------------------------------------------------
# 启动日志
# ----------------------------------------------------------
print("[INFO] ============================================================")
print("[INFO] 数据库初始化开始")
print("[INFO] ============================================================")
print(f"[INFO] 检测数据库驱动: {DB_DRIVER}")
sql_path = os.path.join("document", "djangoadmin.fastapi.elevue.sql")
if os.path.exists(sql_path):
with open(sql_path, "r", encoding="utf-8") as f:
content = f.read()
done = errors = 0
for stmt in split_statements(content):
try:
cur.execute(stmt)
done += 1
except Exception as e:
errors += 1
print(f"[SKIP] {e}\n {stmt[:100]}")
root_conn.commit()
print(f"imported {done} statements, errors={errors}")
else:
print(f"!! 未找到 {sql_path},跳过 SQL 导入")
# 驱动 → 可读名称
driver_label = {
'mysql': 'MySQL', 'postgresql': 'PostgreSQL',
'mssql': 'SQL Server', 'oracle': 'Oracle', 'sqlite': 'SQLite',
}
if DB_DRIVER != 'sqlite':
print(f"[INFO] 连接 {driver_label.get(DB_DRIVER, DB_DRIVER)} 服务器: {DB_HOST}:{DB_PORT}")
print(f"[INFO] 数据库配置: {DB_USERNAME}@{DB_HOST}:{DB_PORT}/{DB_DATABASE}")
print(f"[INFO] 数据表前缀: {DB_PREFIX}")
# SQL 脚本路径:document/{driver}/djangoadmin.fastapi.elevue.sql
sql_filename = "djangoadmin.fastapi.elevue.sql"
driver_dir_map = {
'mysql': 'mysql',
'postgresql': 'postgresql',
'mssql': 'sqlserver',
'oracle': 'oracle',
'sqlite': 'sqlite',
}
sql_path = os.path.join("document", driver_dir_map.get(DB_DRIVER, DB_DRIVER), sql_filename)
# ----------------------------------------------------------
# 按驱动建库
# ----------------------------------------------------------
if DB_DRIVER == 'mysql':
import pymysql
root_conn = pymysql.connect(host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME, password=DB_PASSWORD)
cur = root_conn.cursor()
# 检查数据库是否已存在
cur.execute(f"SHOW DATABASES LIKE '{DB_DATABASE}'")
db_exists = cur.fetchone() is not None
if args.drop_first and db_exists:
print(f"[INFO] 删除已有数据库: {DB_DATABASE}")
cur.execute(f"DROP DATABASE IF EXISTS `{DB_DATABASE}`")
db_exists = False
if db_exists:
print(f"[INFO] 数据库 {DB_DATABASE} 已存在,跳过创建")
else:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{DB_DATABASE}` "
f"DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci"
)
print(f"[INFO] 创建数据库: {DB_DATABASE}")
cur.execute(f"USE `{DB_DATABASE}`")
# 导入 SQL 脚本
if os.path.exists(sql_path):
print(f"[INFO] 查找数据库脚本: {sql_path}")
print("[INFO] 导入 SQL 脚本...")
with open(sql_path, "r", encoding="utf-8") as f:
content = f.read()
done = errors = 0
for stmt in split_statements(content):
try:
cur.execute(stmt)
done += 1
except Exception as e:
errors += 1
print(f"[SKIP] {e}\n {stmt[:100]}")
root_conn.commit()
print(f"[INFO] SQL 脚本导入完成: {done} 条语句")
else:
print(f"[WARN] 未找到 SQL 脚本: {sql_path},跳过导入")
cur.close()
root_conn.close()
elif DB_DRIVER == 'postgresql':
import psycopg
# 连 postgres 维护库创建目标库;DB_DATABASE 含点号,标识符必须双引号
conn = psycopg.connect(
host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME,
password=DB_PASSWORD, dbname="postgres", autocommit=True,
)
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (DB_DATABASE,))
exists = cur.fetchone() is not None
if args.drop_first and exists:
print(f"[INFO] 删除已有数据库: {DB_DATABASE}")
cur.execute(f'DROP DATABASE "{DB_DATABASE}" WITH (FORCE)')
exists = False
if exists:
print(f"[INFO] 数据库 {DB_DATABASE} 已存在,跳过创建")
else:
cur.execute(f'CREATE DATABASE "{DB_DATABASE}"')
print(f"[INFO] 创建数据库: {DB_DATABASE}")
conn.close()
# 导入 PostgreSQL SQL 脚本
if os.path.exists(sql_path):
print(f"[INFO] 查找数据库脚本: {sql_path}")
print("[INFO] 导入 SQL 脚本...")
import psycopg as pg
pg_conn = pg.connect(
host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME,
password=DB_PASSWORD, dbname=DB_DATABASE, autocommit=True,
)
with pg_conn.cursor() as cur:
with open(sql_path, "r", encoding="utf-8") as f:
cur.execute(f.read())
pg_conn.close()
print("[INFO] SQL 脚本导入完成")
else:
print(f"[WARN] 未找到 SQL 脚本: {sql_path},跳过导入")
elif DB_DRIVER == 'mssql':
import pymssql
# 连 master 维护库创建目标库;标识符用方括号引用
conn = pymssql.connect(
server=DB_HOST, user=DB_USERNAME, password=DB_PASSWORD,
port=int(DB_PORT), database="master", autocommit=True,
)
cur = conn.cursor()
# 检查数据库是否已存在
cur.execute(f"SELECT DB_ID(N'{DB_DATABASE}')")
db_exists = cur.fetchone()[0] is not None
if args.drop_first and db_exists:
print(f"[INFO] 删除已有数据库: {DB_DATABASE}")
cur.execute(
f"ALTER DATABASE [{DB_DATABASE}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE"
)
cur.execute(f"DROP DATABASE [{DB_DATABASE}]")
db_exists = False
if db_exists:
print(f"[INFO] 数据库 {DB_DATABASE} 已存在,跳过创建")
else:
cur.execute(f"CREATE DATABASE [{DB_DATABASE}]")
print(f"[INFO] 创建数据库: {DB_DATABASE}")
conn.close()
# 导入 SQL Server SQL 脚本
if os.path.exists(sql_path):
print(f"[INFO] 查找数据库脚本: {sql_path}")
print("[INFO] 导入 SQL 脚本...")
mssql_conn = pymssql.connect(
server=DB_HOST, user=DB_USERNAME, password=DB_PASSWORD,
port=int(DB_PORT), database=DB_DATABASE, autocommit=True,
)
mssql_cur = mssql_conn.cursor()
with open(sql_path, "r", encoding="utf-8") as f:
content = f.read()
done = errors = 0
for stmt in split_statements(content):
try:
mssql_cur.execute(stmt)
done += 1
except Exception as e:
errors += 1
print(f"[SKIP] {e}\n {stmt[:100]}")
mssql_conn.commit()
mssql_conn.close()
print(f"[INFO] SQL 脚本导入完成: {done} 条语句")
else:
print(f"[WARN] 未找到 SQL 脚本: {sql_path},跳过导入")
elif DB_DRIVER == 'oracle':
# Oracle 的"库"即连接的服务名(service_name),schema 是连接用户,由 DBA 预建,
# 无"建库"概念;此处仅校验连通性,建表走下方 create_all
import oracledb
conn = oracledb.connect(
user=DB_USERNAME, password=DB_PASSWORD,
dsn=f"{DB_HOST}:{DB_PORT}/{DB_DATABASE}",
)
conn.close()
print(f"[INFO] Oracle 服务 {DB_DATABASE} 连通 (schema={DB_USERNAME})")
# 导入 Oracle SQL 脚本
if os.path.exists(sql_path):
print(f"[INFO] 查找数据库脚本: {sql_path}")
print("[INFO] 导入 SQL 脚本...")
ora_conn = oracledb.connect(
user=DB_USERNAME, password=DB_PASSWORD,
dsn=f"{DB_HOST}:{DB_PORT}/{DB_DATABASE}",
)
ora_cur = ora_conn.cursor()
with open(sql_path, "r", encoding="utf-8") as f:
content = f.read()
done = errors = 0
for stmt in split_statements(content):
try:
ora_cur.execute(stmt)
done += 1
except Exception as e:
errors += 1
print(f"[SKIP] {e}\n {stmt[:100]}")
ora_conn.commit()
ora_cur.close()
ora_conn.close()
print(f"[INFO] SQL 脚本导入完成: {done} 条语句")
else:
print(f"[WARN] 未找到 SQL 脚本: {sql_path},跳过导入")
else: # sqlite(DB_DATABASE 即数据库文件完整路径)
from pathlib import Path
file = Path(DB_DATABASE).expanduser()
print(f"[INFO] SQLite 数据库文件: {file}")
if file.parent.as_posix() != '.':
file.parent.mkdir(parents=True, exist_ok=True)
if args.drop_first and file.exists():
file.unlink()
print(f"[INFO] 删除已有数据库文件: {file}")
# 导入 SQLite SQL 脚本
if os.path.exists(sql_path):
print(f"[INFO] 查找数据库脚本: {sql_path}")
print("[INFO] 导入 SQL 脚本...")
import sqlite3 as sqlite_mod
sq_conn = sqlite_mod.connect(str(file))
with open(sql_path, "r", encoding="utf-8") as f:
content = f.read()
done = errors = 0
for stmt in split_statements(content):
try:
sq_conn.execute(stmt)
done += 1
except Exception as e:
errors += 1
print(f"[SKIP] {e}\n {stmt[:100]}")
sq_conn.commit()
sq_conn.close()
print(f"[INFO] SQL 脚本导入完成: {done} 条语句")
else:
print(f"[WARN] 未找到 SQL 脚本: {sql_path},跳过导入")
# ----------------------------------------------------------
# 用模型补建缺失表
engine = create_engine(
f"mysql+pymysql://{DB_USERNAME}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_DATABASE}?charset=utf8mb4"
)
# ----------------------------------------------------------
print("[INFO] 创建数据表...")
engine = create_engine(SQLALCHEMY_DATABASE_URL)
# 记录 create_all 前已存在的表,用于差量统计新建的表
existing_before = set(inspect(engine).get_table_names())
Base.metadata.create_all(engine)
from sqlalchemy import inspect
# 差量:打印新建的表
existing_after = set(inspect(engine).get_table_names())
newly_created = sorted(existing_after - existing_before)
if newly_created:
for t in newly_created:
print(f"[INFO] 表 {t} 创建成功")
else:
print("[INFO] 所有数据表均已存在,无需创建")
missing = set(Base.metadata.tables) - existing_after
total_tables = len(existing_after)
print(f"[INFO] 数据表创建完成: 共 {total_tables} 张表")
if missing:
print(f"[WARN] 以下表仍缺失: {sorted(missing)}")
# Oracle:create_all 对 Integer 主键只生成 INTEGER NOT NULL(无自增),
# 补 sequence + BEFORE INSERT 触发器
if DB_DRIVER == 'oracle':
print("[INFO] 补建 Oracle 自增 sequence + 触发器...")
with engine.begin() as conn:
for table_name in Base.metadata.tables:
ensure_oracle_autoincrement(conn, table_name)
print(f"[INFO] 已为 {len(Base.metadata.tables)} 张表补建自增 sequence + 触发器")
actual = set(inspect(engine).get_table_names())
missing = set(Base.metadata.tables) - actual
print(f"tables in DB: {len(actual)}; still missing: {sorted(missing)}")
engine.dispose()
cur.close()
root_conn.close()
# ----------------------------------------------------------
# Alembic 版本标记
# ----------------------------------------------------------
print("[INFO] 标记 Alembic 版本...")
_stamp_alembic()
# ----------------------------------------------------------
# 完成
# ----------------------------------------------------------
print("[INFO] ============================================================")
print("[INFO] 数据库初始化完成!")
print("[INFO] ============================================================")
if __name__ == "__main__":
+576
View File
@@ -0,0 +1,576 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
# +======================================================================
# | 模块: 跨库数据迁移脚本
# | 说明: 把源库的数据整体搬到目标库(MySQL → PostgreSQL / SQL Server / SQLite 等)
# +======================================================================
"""
跨库数据迁移脚本(MySQL → PostgreSQL / SQL Server / SQLite 等)
=============================================================
把「源库」的数据整体搬到「目标库」:
- 目标库结构用模型 metadata 生成(Base.metadata.create_all,方言可移植,
与 init_db.py / alembic 0001_baseline 同源,模型即 head,含
0002 的 city.parent_code nullable);
- 数据按拓扑序逐表 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 的分工:
- init_db.py 单库初始化(建库 + 导 MySQL dump + 补表 + alembic stamp)
- 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.fastapi.elevue --src-user=root --src-pass=root \
--dst-driver=postgresql --dst-host=127.0.0.1 --dst-port=5432 \
--dst-db=djangoadmin.fastapi.elevue --dst-user=root --dst-pass=root \
--drop-target-first
先干跑核对计划:加 --dry-run(只连源、逐表数行数、打印计划,不碰目标库)。
"""
import argparse
import os
import sys
import time
from pathlib import Path
from urllib.parse import quote_plus
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
# 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
from sqlalchemy.pool import NullPool
from core.config import DB_PREFIX
from core.config.database import _normalize_driver, _sqlite_url
from core.database import Base
# 各驱动默认端口(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",
]
# ============================================================
# CLI 参数解析
# ============================================================
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 形态与 core.config.database.build_database_url 逐字等价"""
drv = _normalize_driver(driver)
if drv == 'sqlite':
return _sqlite_url(database)
base = quote_plus(username) + ':' + quote_plus(password) + '@' + host + ':' + str(port)
if drv == 'oracle':
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+psycopg://' + 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"
_BUSY = "[·]"
_DONE = "[✓]"
def _phase_start(n: int, total: int, desc: str, hint: bool = True):
"""阶段开始横幅"""
tail = "(可能耗时较长,请勿中断)" if hint else ""
print(f"{_BUSY} [{n}/{total}] {desc}...{tail}")
def _phase_done(n: int, total: int, desc: str, t0: float, extra: str = ""):
"""阶段完成横幅"""
print(f"{_DONE} [{n}/{total}] {desc} 完成(耗时 {_fmt_duration(time.perf_counter() - t0)}){extra}")
# ============================================================
# 建目标库
# ============================================================
def create_database_if_missing(driver: str, host: str, port: int, database: str,
username: str, password: str, drop_first: bool = False,
tables: tuple = ()):
"""按方言连维护库建目标库(不存在才建)。sqlite 无建库概念,只确保父目录并可选删旧文件"""
if driver == 'sqlite':
file = Path(database).expanduser()
print(f" · 检查/准备 sqlite 文件 {file}...")
if file.parent.as_posix() != '.':
file.parent.mkdir(parents=True, exist_ok=True)
if drop_first and file.exists():
print(f" · 删除旧 sqlite 文件 {file}...")
file.unlink()
return
if driver == 'mysql':
import pymysql
print(" · 正在连接 MySQL(无库连接)...")
conn = pymysql.connect(host=host, port=int(port), user=username, password=password, autocommit=True)
cur = conn.cursor()
if drop_first:
print(f" · 正在删除旧目标库 `{database}`...")
cur.execute(f"DROP DATABASE IF EXISTS `{database}`")
print(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 psycopg
print(" · 正在连接 postgres 维护库...")
conn = psycopg.connect(
host=host, port=int(port), user=username, password=password,
dbname="postgres", 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:
print(f" · 正在删除旧目标库 \"{database}\"(WITH FORCE)...")
cur.execute(f'DROP DATABASE "{database}" WITH (FORCE)')
exists = False
if not exists:
print(f" · 正在创建目标库 \"{database}\"(UTF8)...")
cur.execute(f'CREATE DATABASE "{database}" ENCODING \'UTF8\' TEMPLATE template0')
conn.close()
return
if driver == 'oracle':
import oracledb
print(f" · 正在连接 Oracle 服务 {database}(schema={username})...")
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:
print(f" · drop-first:清理 {len(tables)} 张旧项目表(DROP TABLE ... PURGE)...")
for t in tables:
try:
cur.execute(f"DROP TABLE {t} PURGE")
except Exception:
pass
conn.close()
return
# mssql
import pymssql
print(" · 正在连接 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:
print(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}]")
print(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 = Base.metadata.tables.get(DB_PREFIX + name)
if tbl is not None:
out.append(tbl)
seen.add(tbl.name)
for tbl in Base.metadata.tables.values():
if tbl.name not in seen:
out.append(tbl)
return out
# ============================================================
# 泵数据
# ============================================================
def sanitize(payload: list[dict], table) -> list[dict]:
"""把驱动读回的零日期/非法日期字符串统一置 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 以保住裸列引用关系"""
pk = next(iter(table.primary_key.columns))
sel = select(*table.columns).order_by(pk)
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)"""
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) 再拼"""
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"""
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 迁移后补自增机制"""
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):
"""迁移后重置自增序列"""
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", ""),
)
# 加载全部模型填充 Base.metadata
from core.app import create_app
create_app()
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) 建目标库
t0 = time.perf_counter()
_phase_start(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),
)
_phase_done(1, 5, "建目标库", t0)
# 2) 建结构
t0 = time.perf_counter()
_phase_start(2, 5, f"建表结构({len(present)} 张表 create_all)")
Base.metadata.create_all(dst_engine)
_phase_done(2, 5, f"建表结构({len(present)} 张表 create_all)", t0, extra=f",{len(present)} 张表")
# 3) 逐表迁移数据
t0 = time.perf_counter()
_phase_start(3, 5, "逐表迁移数据")
total_rows = 0
n_present = len(present)
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" · [{idx}/{n_present}] 跳过 {t.name}(源=目标)")
continue
print(f" · [{idx}/{n_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" · [{idx}/{n_present}] 跳过 {t.name}(源=目标)")
continue
print(f" · [{idx}/{n_present}] 正在迁移 {t.name}...")
total_rows += pump_table(src, dst, t, args.chunk_size, dst_driver)
_phase_done(3, 5, "逐表迁移数据", t0, extra=f",共 {total_rows} 行")
# 4) 重置自增序列
reset_errors = []
t0 = time.perf_counter()
_phase_start(4, 5, "重置自增序列")
if dst_driver in ("postgresql", "mssql", "oracle"):
with dst_engine.begin() as dst:
for t in present:
try:
reset_sequence(dst_driver, dst, t)
except Exception as e:
reset_errors.append((t.name, str(e)))
print(f" · [WARN] 序列重置失败 {t.name}: {e}")
tail = f",{len(reset_errors)} 张失败" if reset_errors else ""
_phase_done(4, 5, "重置自增序列", t0, extra=tail)
else:
print(" · MySQL/SQLite 自动跟随,无需处理")
_phase_done(4, 5, "重置自增序列", t0, extra="(无需处理)")
# 5) 校验
t0 = time.perf_counter()
_phase_start(5, 5, "逐表行数校验")
if not args.skip_verify:
with src_engine.connect() as src, dst_engine.connect() as dst:
failed = verify_counts(src, dst, present)
if failed:
_phase_done(5, 5, "逐表行数校验", t0, extra=f",FAIL {len(failed)} 张")
for name, s, d in failed:
print(f" - {name}: source={s} target={d}")
return 1
_phase_done(5, 5, "逐表行数校验", t0, extra=f",{len(present)} 张表全部 PASS")
else:
print(" · 已跳过(--skip-verify)")
_phase_done(5, 5, "逐表行数校验", t0, extra="(已跳过)")
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()
+120
View File
@@ -0,0 +1,120 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
# +======================================================================
# | 模块: 富文本历史数据清洗脚本
# | 说明: 对已入库的富文本内容进行白名单清洗,移除 XSS 向量
# +======================================================================
"""
富文本历史数据清洗脚本(一次性回填)
=============================================================
背景:M-4 修复后,新增/编辑的富文本内容在落盘前会经 bleach 白名单清洗,
但修复前已入库的历史数据可能仍含 script / on* 事件 / javascript: 等 XSS 向量。
本脚本对 fastapi_notice / fastapi_article 的 content 字段重新做白名单清洗。
说明:
- 仅做"清洗",不迁移图片(图片写入时已迁移,此处复用清洗管线而非 save_content,
避免对已迁移的 [IMG_URL] 图片二次处理)。
- 幂等:清洗后再执行一遍不会产生变化。
- 建议执行前先备份数据库或导出相关表。
用法:
python scripts/sanitize_rich_text.py # 预览:仅统计需清洗行数
python scripts/sanitize_rich_text.py --apply # 实际写回数据库
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
# ============================================================
# 辅助函数
# ============================================================
def _clean(content):
from utils.rich_text import _RICH_TEXT_CLEANER, _strip_dangerous_style
cleaned = _RICH_TEXT_CLEANER.clean(content or '')
return _strip_dangerous_style(cleaned)
# ============================================================
# 主入口
# ============================================================
def main():
parser = argparse.ArgumentParser(description="富文本历史数据白名单清洗")
parser.add_argument("--apply", action="store_true", help="实际写回数据库(默认仅预览统计)")
args = parser.parse_args()
from core.config import DB_PREFIX
from core.database import engine
from sqlalchemy import text
total_changed = 0
for table in (DB_PREFIX + "notice", DB_PREFIX + "article"):
with engine.connect() as conn:
rows = conn.execute(text(f"SELECT id, content FROM {table}")).all()
dirty = 0
for row_id, content in rows:
if not content:
continue
cleaned = _clean(content)
if cleaned != content:
dirty += 1
if args.apply:
with engine.begin() as conn:
conn.execute(
text(f"UPDATE {table} SET content=:c WHERE id=:id"),
{"c": cleaned, "id": row_id},
)
total_changed += dirty
print(f"{table}: 共 {len(rows)} 行,其中 {dirty} 行含待清洗内容"
+ ("(已写回)" if args.apply else "(预览,加 --apply 写回)"))
print(f"合计待清洗 {total_changed} 行"
+ (",已完成写回" if args.apply else ",确认无误后加 --apply 执行写回"))
if __name__ == "__main__":
main()
+66 -17
View File
@@ -1,3 +1,49 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
# +======================================================================
# | 模块: 数据种子脚本
# | 说明: 重置指定用户密码为 bcrypt 哈希
# +======================================================================
"""
数据种子脚本:重置指定用户密码为 bcrypt 哈希。
@@ -10,9 +56,12 @@ import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
# ============================================================
# 主入口
# ============================================================
def main():
parser = argparse.ArgumentParser(description="重置用户密码")
parser.add_argument("username", help="用户名")
@@ -20,26 +69,26 @@ def main():
parser.add_argument("user_id", nargs="?", type=int, default=None, help="用户 ID(可选,避免同名冲突)")
args = parser.parse_args()
from config import DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME, DB_PASSWORD
from core.config import DB_PREFIX
from core.database import engine
from core.password import generate_salt, encrypt_password
import pymysql
from sqlalchemy import text
salt = generate_salt()
hashed = encrypt_password(args.password, salt)
conn = pymysql.connect(host=DB_HOST, port=int(DB_PORT), user=DB_USERNAME,
password=DB_PASSWORD, database=DB_DATABASE)
cur = conn.cursor()
if args.user_id:
cur.execute("UPDATE fastapi_user SET salt=%s, password=%s WHERE id=%s",
(salt, hashed, args.user_id))
else:
cur.execute("UPDATE fastapi_user SET salt=%s, password=%s WHERE username=%s",
(salt, hashed, args.username))
conn.commit()
print(f"updated {cur.rowcount} row(s) for username={args.username}")
cur.close()
conn.close()
# 表名由配置控制内联(受信任),值一律走命名绑定参数(各驱动通用)
with engine.begin() as conn:
if args.user_id:
result = conn.execute(
text(f"UPDATE {DB_PREFIX}user SET salt=:salt, password=:pw WHERE id=:id"),
{"salt": salt, "pw": hashed, "id": args.user_id},
)
else:
result = conn.execute(
text(f"UPDATE {DB_PREFIX}user SET salt=:salt, password=:pw WHERE username=:name"),
{"salt": salt, "pw": hashed, "name": args.username},
)
print(f"updated {result.rowcount} row(s) for username={args.username}")
if __name__ == "__main__":
+357
View File
@@ -0,0 +1,357 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 小蚂蚁云团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 小蚂蚁云团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
# +======================================================================
# | 模块: 业务字典数据初始化脚本
# | 说明: 幂等写入业务字典(dict + dict_item),按字典编码判重
# +======================================================================
"""
业务字典数据初始化脚本
=============================================================
幂等写入业务字典(dict + dict_item):按字典编码(code)判重,
已存在则跳过、不覆盖现有数据;适合全新安装或现有库增量补齐。
字典格式(code/name/note/items),字典排序与字典项排序由列表顺序自动生成:
DICT_SEED = [
{
"code": "gender", "name": "性别", "note": "用户性别",
"items": [
{"value": "1", "name": "男"},
{"value": "2", "name": "女"},
],
},
]
注意:code 必须与业务代码中 get_dict_map / get_dict_label / serialize_maps
引用的编码一致(见 src/utils/dict_util.py),改 code 需同步改业务引用。
用法:
python scripts/seed_dict.py # 使用 .env 中的数据库配置
python scripts/seed_dict.py --dry-run # 只打印将写入的字典,不落库
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
# Windows 控制台默认 GBK(cp936):把 stdout/stderr 切到 UTF-8,避免中文打印乱码/崩溃
if sys.stdout and hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
sys.stderr.reconfigure(encoding='utf-8')
# ============================================================
# 业务字典种子数据
# ============================================================
DICT_SEED = [
{
"code": "gender",
"name": "性别",
"note": "用户性别",
"items": [
{"value": "1", "name": "男"},
{"value": "2", "name": "女"},
{"value": "3", "name": "保密"},
],
},
{
"code": "dept_type",
"name": "部门类型",
"note": "部门层级类型",
"items": [
{"value": "1", "name": "公司"},
{"value": "2", "name": "子公司"},
{"value": "3", "name": "部门"},
{"value": "4", "name": "小组"},
],
},
{
"code": "article_status",
"name": "文章状态",
"note": "文章上下架状态",
"items": [
{"value": "0", "name": "正常"},
{"value": "1", "name": "下架"},
],
},
{
"code": "notice_source",
"name": "通知来源",
"note": "通知公告来源",
"items": [
{"value": "1", "name": "官方平台"},
{"value": "2", "name": "开源中国"},
{"value": "3", "name": "CSDN官方"},
{"value": "4", "name": "新浪微博"},
],
},
{
"code": "log_type",
"name": "日志类型",
"note": "登录日志类型",
"items": [
{"value": "1", "name": "登录"},
{"value": "2", "name": "退出"},
],
},
{
"code": "login_source",
"name": "登录来源",
"note": "登录入口来源",
"items": [
{"value": "0", "name": "后台"},
{"value": "1", "name": "前台"},
],
},
{
"code": "login_status",
"name": "登录状态",
"note": "登录日志状态",
"items": [
{"value": "0", "name": "正常"},
{"value": "1", "name": "异常"},
],
},
{
"code": "operation_source",
"name": "操作日志来源",
"note": "操作日志来源",
"items": [
{"value": "0", "name": "后台"},
{"value": "1", "name": "前台"},
],
},
{
"code": "operation_status",
"name": "操作日志状态",
"note": "操作日志状态",
"items": [
{"value": "0", "name": "正常"},
{"value": "1", "name": "异常"},
],
},
{
"code": "operation_type",
"name": "操作日志类型",
"note": "操作日志类型(由 URL 路径自动推断)",
"items": [
{"value": "0", "name": "其它"},
{"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": "开账号"},
],
},
{
"code": "param_type",
"name": "参数类型",
"note": "参数分类",
"items": [
{"value": "0", "name": "系统"},
{"value": "1", "name": "业务"},
],
},
{
"code": "param_status",
"name": "参数状态",
"note": "参数启用状态",
"items": [
{"value": "1", "name": "正常"},
{"value": "2", "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": "放弃执行"},
],
},
{
"code": "job_log_status",
"name": "任务日志状态",
"note": "任务执行结果状态",
"items": [
{"value": "0", "name": "正常"},
{"value": "1", "name": "失败"},
],
},
{
"code": "link_type",
"name": "友链类型",
"note": "友链分类",
"items": [
{"value": "1", "name": "友情链接"},
{"value": "2", "name": "合作伙伴"},
],
},
{
"code": "link_platform",
"name": "友链投放平台",
"note": "友链投放平台",
"items": [
{"value": "1", "name": "PC站"},
{"value": "2", "name": "WAP站"},
{"value": "3", "name": "微信小程序"},
{"value": "4", "name": "APP应用"},
],
},
{
"code": "link_form",
"name": "友链形式",
"note": "友链展示形式",
"items": [
{"value": "1", "name": "文字链接"},
{"value": "2", "name": "图片链接"},
],
},
{
"code": "city_level",
"name": "城市级别",
"note": "行政区划级别",
"items": [
{"value": "1", "name": "省份"},
{"value": "2", "name": "城市"},
{"value": "3", "name": "县区"},
{"value": "4", "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": "只读文本"},
],
},
]
# ============================================================
# 主入口
# ============================================================
def main():
parser = argparse.ArgumentParser(description="初始化业务字典数据")
parser.add_argument("--dry-run", action="store_true", help="仅打印计划,不写库")
args = parser.parse_args()
from core.database import SessionLocal
from modules.dictionary.dict.models import Dict
from modules.dictionary.dict_item.models import DictItem
db = SessionLocal()
added = skipped = 0
try:
for idx, d in enumerate(DICT_SEED):
code = d["code"]
exists = db.query(Dict).filter(Dict.code == code, Dict.is_delete == 0).first()
if exists:
skipped += 1
continue
if args.dry_run:
print(f"[DRY-RUN] 将新增字典: {code}({d['name']},{len(d['items'])} 项)")
continue
dict_obj = Dict(name=d["name"], code=code, sort=(idx + 1) * 5, note=d.get("note", ""))
db.add(dict_obj)
db.flush() # 获取自增 id 供字典项外键引用
for i, item in enumerate(d["items"]):
db.add(DictItem(
name=item["name"], value=item["value"],
dict_id=dict_obj.id, sort=(i + 1) * 5, note='',
))
added += 1
db.commit()
if not args.dry_run:
print(f"done: added={added} dicts, skipped={skipped}(已存在)")
except Exception:
db.rollback()
raise
finally:
db.close()
if __name__ == "__main__":
main()