refactor: P3c 服务层迁入 src/modules/ → service.py(29 服务)

- 22 个单服务 + 3 组合并(config+config_item、dict+dict_item、login_log+operation_log)
- 特殊命名:job_executor→executor、scheduler→scheduler_service、
  mail_svc→mail_service、sms_svc→sms_service(含使用处标识符改写)
- 重写引用:跨服务(module 别名/子模块)、models/forms→src.modules、
  db/extensions→src.core、utils→src.common|core、R→src.core.response
- application/services/ 全部替换为 shim

【验收】全部服务文件编译通过;应用启动;pytest 5 passed;核心服务导入正常

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DjangoAdmin Dev
2026-08-07 14:18:40 +08:00
co-authored by Claude
parent 2586c0aace
commit 87aec4e26e
53 changed files with 10586 additions and 11055 deletions
+583
View File
@@ -0,0 +1,583 @@
# +======================================================================
# | DjangoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
# +----------------------------------------------------------------------
# | Copyright (c) 2021-2026 一米阳光团队 版权所有
# +----------------------------------------------------------------------
# | 开源协议: Apache-2.0
# | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
# +----------------------------------------------------------------------
# | 官方网站: https://www.djangoadmin.cn
# +----------------------------------------------------------------------
# | 研发团队: 一米阳光团队 荣誉出品
# +======================================================================
# | 版权和免责声明:
# | 一、知识产权声明
# | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
# | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
# | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
# | 二、使用限制与禁止条款
# | 您理解并同意,禁止将本产品用于以下任何行为:
# | 1. 任何违反中华人民共和国法律法规的用途;
# | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
# | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
# | 三、责任豁免
# | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
# | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
# | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
# | 四、违约与法律责任
# | 如发现任何违反本声明的行为,本团队有权:
# | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
# | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
# | 3. 保留进一步采取刑事控告等法律措施的权利。
# | 五、许可范围说明
# | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
# | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
# | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
# | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
# | 成的一切经济损失及商誉损害。
# | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
# | 许可协议》附件为准。
# +======================================================================
"""
定时任务调度核心模块
=============================================================
该模块实现了定时任务的调度核心功能。
使用APScheduler库来管理和执行定时任务。
"""
import logging
import sys
import threading
from datetime import datetime
from typing import Dict, Any, Optional
import requests
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.jobstores.base import JobLookupError
from config import INTERNAL_API_KEY, FLASK_SITE_DOMAIN
from src.common.utils.model_utils import model_to_dict
# ============================================================
# 全局变量和日志配置
# ============================================================
# 线程局部存储(用于线程间数据隔离)
_thread_local = threading.local()
logger = logging.getLogger(__name__)
# 调度器实例(延迟初始化)
_scheduler = None
# ============================================================
# 调度器实例获取函数
# ============================================================
def get_scheduler():
"""
获取调度器实例(延迟初始化)
使用延迟初始化模式,确保调度器只被创建一次。
避免在模块导入时立即创建,提高应用启动速度。
Returns:
BackgroundScheduler: APScheduler 后台调度器实例
"""
global _scheduler
if _scheduler is None:
# 创建后台调度器,设置时区为北京时间
_scheduler = BackgroundScheduler(timezone='Asia/Shanghai')
return _scheduler
# 创建全局调度器实例
scheduler = get_scheduler()
# ============================================================
# URL 构建工具函数
# ============================================================
def build_full_url(url: str) -> str:
"""
构建完整的URL
将相对路径拼接为完整的带域名的URL,
如果已经是完整URL则直接返回。
Args:
url: 原始URL(相对路径或完整URL)
Returns:
str: 完整URL
Examples:
>>> build_full_url('/job/sendSms')
'http://127.0.0.1:8021/job/sendSms'
>>> build_full_url('https://api.example.com/test')
'https://api.example.com/test'
"""
if not url:
return url
# 已经是完整URL,直接返回
if url.startswith(('http://', 'https://')):
return url
# 相对路径,拼接站点域名
if url.startswith('/'):
url = f"{FLASK_SITE_DOMAIN}{url}"
return url
# ============================================================
# 任务执行核心函数
# ============================================================
def execute_job(
job_id: int,
url: Optional[str] = None,
is_sync: int = 0,
job_trigger: Optional[str] = None,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
执行任务(调度器调用的入口函数)
这是 APScheduler 调用的核心函数,根据任务配置分发到不同的执行方式。
Args:
job_id: 任务ID
url: 任务URL(HTTP请求方式)
is_sync: 是否同步执行(1-同步,0-异步)
job_trigger: 任务触发器(内部调用方式)
params: 额外参数
Returns:
Dict: 执行结果,包含 success 和 data/error 字段
"""
try:
print(f"[scheduler] execute_job id={job_id} url={url} sync={is_sync} trigger={job_trigger} params={params}")
sys.stdout.flush()
logger.info(f"执行定时任务 [ID: {job_id}, URL: {url}, Trigger: {job_trigger}]")
# 根据任务类型分发执行
if url:
# URL方式:发送HTTP请求
result = _execute_by_url(job_id, url, is_sync, params)
elif job_trigger:
# 触发器方式:调用内部执行器
result = _execute_by_trigger(job_trigger, job_id, params)
else:
# 无效的任务配置
result = {'success': False, 'error': '任务既没有URL也没有触发器'}
return result
except Exception as e:
logger.error(f"执行任务异常 [ID: {job_id}]: {str(e)}")
return {'success': False, 'error': str(e)}
# ============================================================
# URL 方式任务执行
# ============================================================
def _execute_by_url(job_id: int, url: str, is_sync: int, params: Optional[Dict] = None) -> Dict:
"""
通过URL方式执行任务
构建请求数据并发送HTTP请求。
Args:
job_id: 任务ID
url: 目标URL
is_sync: 1-同步等待响应,0-异步不等待
params: 额外参数
Returns:
Dict: 执行结果
"""
# 构建完整URL
full_url = build_full_url(url)
# 构建请求数据
request_data = {
'job_id': job_id,
'timestamp': datetime.now().isoformat(), # 请求时间戳
'params': params or {}
}
# 根据同步/异步选择发送方式
if is_sync == 1:
return _send_sync_request(full_url, request_data, job_id)
else:
return _send_async_request(full_url, request_data, job_id)
def _send_sync_request(url: str, data: Dict, job_id: int) -> Dict:
"""
发送同步HTTP请求(等待响应)
同步发送POST请求,等待目标服务返回响应后继续执行。
Args:
url: 目标URL
data: 请求数据(JSON格式)
job_id: 任务ID(用于日志)
Returns:
Dict: 包含响应状态码和数据的执行结果
"""
try:
logger.info(f"发送同步请求 [ID: {job_id}, URL: {url}]")
# 准备请求头
headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'Scheduler/1.0',
# 添加定时任务专用密钥,用于跳过权限验证
'X-Scheduler-Key': INTERNAL_API_KEY,
}
# 发送POST请求
response = requests.post(
url,
json=data,
timeout=30, # 30秒超时
headers=headers
)
response.raise_for_status() # 非200状态码抛出异常
# 解析响应数据
try:
result_data = response.json()
except Exception:
# JSON解析失败时返回原始文本(限制长度)
result_data = {'text': response.text[:200]}
return {
'success': True,
'status_code': response.status_code,
'data': result_data
}
except requests.exceptions.RequestException as e:
return {'success': False, 'error': str(e)}
def _send_async_request(url: str, data: Dict, job_id: int) -> Dict:
"""
发送异步HTTP请求(不等待响应)
异步发送POST请求,在独立线程中执行,不等待响应结果。
Args:
url: 目标URL
data: 请求数据(JSON格式)
job_id: 任务ID(用于日志)
Returns:
Dict: 立即返回触发状态
"""
import threading
def async_request():
"""异步请求执行函数(在独立线程中运行)"""
try:
headers = {
'Content-Type': 'application/json',
'X-Scheduler-Key': INTERNAL_API_KEY,
}
resp = requests.post(url, json=data, timeout=30, headers=headers)
logger.info(f"异步任务完成 [ID: {job_id}, 状态码: {resp.status_code}]")
except Exception as e:
logger.error(f"异步任务异常 [ID: {job_id}]: {str(e)}")
# 创建并启动独立线程执行异步请求
thread = threading.Thread(target=async_request)
thread.start()
return {'success': True, 'status': 'triggered'}
def _execute_by_trigger(trigger: str, job_id: int, params: Optional[Dict] = None) -> Dict:
"""
通过触发器方式执行任务
调用内部任务执行器执行业务逻辑。
Args:
trigger: 触发器标识(任务类型)
job_id: 任务ID
params: 额外参数
Returns:
Dict: 执行结果
"""
try:
from src.modules.job.executor import job_executor
result = job_executor.execute(trigger, job_id=job_id, **(params or {}))
return {'success': True, 'trigger': trigger, 'data': result}
except Exception as e:
return {'success': False, 'error': str(e)}
# ============================================================
# 调度器任务管理函数
# ============================================================
def add_job_to_scheduler(job_dict:dict ) -> bool:
"""
添加任务到调度器
将任务配置添加到APScheduler中,设置Cron触发规则。
Args:
job_dict: 任务参数字典,包含以下字段:
- id: 任务ID
- status: 任务状态(1-启用)
- cronExpression: Cron表达式
- url: 请求URL
- isSync: 是否同步
- jobTrigger: 触发器标识
- jobName: 任务名称
Returns:
bool: 添加成功返回True,否则返回False
"""
try:
# ============================================================
# 1. 从字典中提取任务参数
# ============================================================
job_id = job_dict.get('id')
job_status = job_dict.get('status')
job_cron = job_dict.get('cronExpression')
job_url = job_dict.get('url')
job_sync = job_dict.get('isSync')
job_trigger = job_dict.get('jobTrigger')
job_name = job_dict.get('jobName')
print(f"[scheduler] add_job id={job_id} status={job_status} cron='{job_cron}'")
# ============================================================
# 2. 参数校验
# ============================================================
# 检查任务状态是否启用
if job_status != 1:
logger.debug(f"跳过添加任务: 状态不是启用 (id={job_id}, status={job_status})")
return False
# 检查Cron表达式是否为空
if not job_cron:
logger.warning(f"跳过添加任务: Cron表达式为空 (id={job_id})")
return False
# ============================================================
# 3. 验证Cron表达式并计算下次执行时间
# ============================================================
try:
trigger = CronTrigger.from_crontab(job_cron)
from datetime import datetime
next_run = trigger.get_next_fire_time(None, datetime.now())
logger.debug(f"Cron表达式有效 (id={job_id}, 下次执行: {next_run})")
if next_run is None:
logger.warning(f"无法计算下次执行时间,Cron表达式可能永远不会触发 (id={job_id})")
return False
except Exception as e:
logger.error(f"Cron表达式无效 (id={job_id}): {str(e)}")
return False
# ============================================================
# 4. 添加任务到调度器
# ============================================================
scheduler_job_id = f"job_{job_id}"
# 移除已存在的同名任务(避免重复)
try:
scheduler.remove_job(scheduler_job_id)
except JobLookupError:
pass
# 添加新任务
scheduler.add_job(
func=execute_job, # 执行函数
trigger=trigger, # Cron触发器
args=[job_id, job_url, job_sync, job_trigger, {}], # 函数参数
id=scheduler_job_id, # 任务唯一标识
name=job_name, # 任务显示名称
replace_existing=True, # 替换已存在的任务
misfire_grace_time=30, # 错过执行的宽限时间(秒)
coalesce=True # 合并错过的执行
)
logger.info(f"任务已添加到调度器 [ID: {job_id}, cron: {job_cron}]")
return True
except Exception as e:
logger.error(f"添加任务到调度器异常: {str(e)}")
return False
def remove_job_from_scheduler(job_id: int) -> bool:
"""
从调度器移除任务
Args:
job_id: 任务ID
Returns:
bool: 移除成功返回True,否则返回False
"""
try:
scheduler.remove_job(f"job_{job_id}")
return True
except JobLookupError:
# 任务不存在,视为成功
return False
except Exception as e:
logger.error(f"移除任务异常: {str(e)}")
return False
def pause_job_in_scheduler(job_id: int) -> bool:
"""
暂停调度器中的任务
Args:
job_id: 任务ID
Returns:
bool: 暂停成功返回True,否则返回False
"""
try:
scheduler.pause_job(f"job_{job_id}")
return True
except JobLookupError:
return False
except Exception as e:
logger.error(f"暂停任务异常: {str(e)}")
return False
def resume_job_in_scheduler(job_id: int) -> bool:
"""
恢复调度器中的任务
Args:
job_id: 任务ID
Returns:
bool: 恢复成功返回True,否则返回False
"""
try:
scheduler.resume_job(f"job_{job_id}")
return True
except JobLookupError:
return False
except Exception as e:
logger.error(f"恢复任务异常: {str(e)}")
return False
def run_job_once(job_id: int, params: Optional[Dict] = None) -> Dict:
"""
立即执行一次任务(不依赖调度器)
忽略Cron表达式,立即执行指定任务一次。
Args:
job_id: 任务ID
params: 执行参数
Returns:
Dict: 执行结果
"""
try:
from src.modules.job.models import Job
job = Job.query.filter(Job.id == job_id, Job.is_delete == 0).first()
if not job:
return {'success': False, 'error': '任务不存在'}
# 直接执行任务(同步方式)
return execute_job(job.id, job.url, 1, job.job_trigger, params or {})
except Exception as e:
return {'success': False, 'error': str(e)}
# ============================================================
# 调度器初始化和重载函数
# ============================================================
def init_scheduler() -> int:
"""
初始化调度器
启动调度器并从数据库加载所有启用的任务。
Returns:
int: 成功加载的任务数量
"""
try:
from src.modules.job.models import Job
# 启动调度器(如果尚未启动)
if not scheduler.running:
scheduler.start()
logger.info("调度器已启动")
# 查询所有启用的任务
jobs = Job.query.filter(
Job.status == 1,
Job.is_delete == 0
).all()
# 使用 model_to_dict 将每个 job 对象转换为字典
# 避免 SQLAlchemy Session 分离问题
job_dicts = []
for job in jobs:
job_dict = dict(model_to_dict(job))
job_dicts.append(job_dict)
# 使用字典数据添加任务到调度器
success_count = 0
for job_dict in job_dicts:
if add_job_to_scheduler(job_dict):
success_count += 1
logger.info(f"调度器初始化完成,加载 {success_count}/{len(job_dicts)} 个任务")
return success_count
except Exception as e:
logger.error(f"初始化调度器异常: {str(e)}")
return 0
def reload_scheduler() -> int:
"""
重新加载调度器
清空所有现有任务,然后重新从数据库加载。
Returns:
int: 成功加载的任务数量
"""
try:
# 清空所有现有任务
scheduler.remove_all_jobs()
# 重新初始化
return init_scheduler()
except Exception as e:
logger.error(f"重新加载调度器异常: {str(e)}")
return 0