From ab34dfd6d7d7790abac24a33bfba73efb09389f8 Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:17:38 +0800 Subject: [PATCH 01/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/feeds.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/feeds.py diff --git a/src/feeds.py b/src/feeds.py new file mode 100644 index 0000000..592c1ce --- /dev/null +++ b/src/feeds.py @@ -0,0 +1,83 @@ +# 导入必要的模块和类 +from django.contrib.auth import get_user_model # 用于获取自定义用户模型 +from django.contrib.syndication.views import Feed # Django内置的Feed基类,用于生成RSS/Atom订阅 +from django.utils import timezone # 处理时间相关操作 +from django.utils.feedgenerator import Rss201rev2Feed # RSS 2.0版本的生成器 + +from blog.models import Article # 导入博客文章模型 +from djangoblog.utils import CommonMarkdown # 导入Markdown处理工具,用于将文章内容转换为HTML + + +class DjangoBlogFeed(Feed): + """ + 自定义博客RSS订阅Feed类,继承自Django的Feed基类,用于生成博客文章的RSS订阅源 + """ + # 指定Feed类型为RSS 2.0版本(符合Rss201rev2Feed规范) + feed_type = Rss201rev2Feed + + # RSS源的描述信息(会显示在订阅源的描述中) + description = '大巧无工,重剑无锋.' + # RSS源的标题(订阅源的名称) + title = "且听风吟 大巧无工,重剑无锋. " + # RSS源的链接(通常指向网站的订阅页面) + link = "/feed/" + + def author_name(self): + """ + 定义订阅源的作者名称 + 这里取系统中第一个用户的昵称作为作者名 + """ + return get_user_model().objects.first().nickname + + def author_link(self): + """ + 定义订阅源作者的链接 + 这里取系统中第一个用户的个人主页链接(需用户模型实现get_absolute_url方法) + """ + return get_user_model().objects.first().get_absolute_url() + + def items(self): + """ + 定义订阅源包含的项目(即文章列表) + 返回条件:类型为'article'(type='a')、状态为'已发布'(status='p')的文章 + 排序方式:按发布时间倒序(最新发布的在前) + 数量限制:最多返回5篇文章 + """ + return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5] + + def item_title(self, item): + """ + 定义单个项目(文章)的标题 + 参数item:从items()方法返回的单个Article对象 + 返回文章的标题 + """ + return item.title + + def item_description(self, item): + """ + 定义单个项目(文章)的描述内容 + 使用CommonMarkdown工具将文章的Markdown格式正文转换为HTML,作为订阅中的描述 + """ + return CommonMarkdown.get_markdown(item.body) + + def feed_copyright(self): + """ + 定义订阅源的版权信息 + 格式为"Copyright© 年份 且听风吟",年份自动获取当前时间的年份 + """ + now = timezone.now() + return "Copyright© {year} 且听风吟".format(year=now.year) + + def item_link(self, item): + """ + 定义单个项目(文章)的链接 + 返回文章的绝对URL(需Article模型实现get_absolute_url方法) + """ + return item.get_absolute_url() + + def item_guid(self, item): + """ + 定义单个项目的全局唯一标识符(GUID) + 此处未实现具体逻辑,可根据需求补充(如返回文章ID或唯一URL等) + """ + return \ No newline at end of file -- 2.34.1 From b23528b15d370e5c3052465b5a465191e8f77346 Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:18:59 +0800 Subject: [PATCH 02/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/logentryadmin.py | 145 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 src/logentryadmin.py diff --git a/src/logentryadmin.py b/src/logentryadmin.py new file mode 100644 index 0000000..7dfb496 --- /dev/null +++ b/src/logentryadmin.py @@ -0,0 +1,145 @@ +# 导入Django管理后台核心模块 +from django.contrib import admin +# 导入日志相关常量和模型:DELETION表示删除操作的标记 +from django.contrib.admin.models import DELETION +# 导入ContentType模型,用于处理模型与数据库表的映射关系 +from django.contrib.contenttypes.models import ContentType +# 导入URL反向解析和异常处理 +from django.urls import reverse, NoReverseMatch +# 导入字符串处理工具 +from django.utils.encoding import force_str +# 导入HTML转义工具,防止XSS攻击 +from django.utils.html import escape +# 导入安全字符串标记工具,标记可信HTML +from django.utils.safestring import mark_safe +# 导入国际化翻译工具 +from django.utils.translation import gettext_lazy as _ + + +class LogEntryAdmin(admin.ModelAdmin): + """ + 自定义日志条目(LogEntry)的管理后台配置类 + 用于在Django admin中展示和管理系统操作日志 + """ + # 列表页的筛选器:按内容类型(关联的模型)筛选日志 + list_filter = [ + 'content_type' + ] + + # 搜索字段:支持按对象名称和操作消息搜索日志 + search_fields = [ + 'object_repr', # 对象的字符串表示 + 'change_message' # 操作描述消息 + ] + + # 列表页中可点击的链接字段 + list_display_links = [ + 'action_time', # 操作时间 + 'get_change_message', # 操作消息 + ] + + # 列表页展示的字段 + list_display = [ + 'action_time', # 操作时间 + 'user_link', # 操作用户(带链接) + 'content_type', # 关联的模型类型 + 'object_link', # 操作的对象(带链接) + 'get_change_message', # 操作消息 + ] + + def has_add_permission(self, request): + """ + 禁用添加日志的权限:日志由系统自动生成,不允许手动添加 + """ + return False + + def has_change_permission(self, request, obj=None): + """ + 限制修改日志的权限: + - 仅超级用户或拥有'admin.change_logentry'权限的用户可查看 + - 禁止POST请求(防止修改操作) + """ + return ( + request.user.is_superuser or + request.user.has_perm('admin.change_logentry') + ) and request.method != 'POST' + + def has_delete_permission(self, request, obj=None): + """ + 禁用删除日志的权限:日志需保留,不允许删除 + """ + return False + + def object_link(self, obj): + """ + 生成操作对象的链接: + - 若操作不是删除且存在关联模型,尝试生成指向该对象编辑页的链接 + - 否则显示对象的字符串表示 + """ + object_link = escape(obj.object_repr) # 转义对象名称,防止XSS + content_type = obj.content_type + + # 非删除操作且存在内容类型时尝试生成链接 + if obj.action_flag != DELETION and content_type is not None: + try: + # 反向解析对象的admin编辑页URL + url = reverse( + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.object_id] + ) + # 生成带链接的HTML + object_link = '{}'.format(url, object_link) + except NoReverseMatch: + # 解析URL失败时,仅显示对象名称 + pass + # 标记为安全HTML,避免被转义 + return mark_safe(object_link) + + # 配置列表页字段的排序和显示名称 + object_link.admin_order_field = 'object_repr' # 允许按对象名称排序 + object_link.short_description = _('object') # 字段显示名称(支持国际化) + + def user_link(self, obj): + """ + 生成操作用户的链接: + - 尝试生成指向该用户编辑页的链接 + - 否则显示用户的字符串表示 + """ + # 获取用户模型对应的ContentType + content_type = ContentType.objects.get_for_model(type(obj.user)) + user_link = escape(force_str(obj.user)) # 转义用户名 + + try: + # 反向解析用户的admin编辑页URL + url = reverse( + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.user.pk] + ) + # 生成带链接的HTML + user_link = '{}'.format(url, user_link) + except NoReverseMatch: + # 解析URL失败时,仅显示用户名 + pass + return mark_safe(user_link) + + # 配置列表页字段的排序和显示名称 + user_link.admin_order_field = 'user' # 允许按用户排序 + user_link.short_description = _('user') # 字段显示名称(支持国际化) + + def get_queryset(self, request): + """ + 优化查询集:预加载content_type关联数据,减少数据库查询次数 + """ + queryset = super(LogEntryAdmin, self).get_queryset(request) + return queryset.prefetch_related('content_type') + + def get_actions(self, request): + """ + 移除批量删除操作:日志不允许批量删除 + """ + actions = super(LogEntryAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions \ No newline at end of file -- 2.34.1 From 91f730406d7ce9b95537c665a65ca4e3b84bfbd1 Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:19:49 +0800 Subject: [PATCH 03/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/settings.py | 399 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 399 insertions(+) create mode 100644 src/settings.py diff --git a/src/settings.py b/src/settings.py new file mode 100644 index 0000000..28f916e --- /dev/null +++ b/src/settings.py @@ -0,0 +1,399 @@ +""" +Django settings for djangoblog project. +项目配置文件:包含项目核心设置、数据库、中间件、静态资源等所有全局配置 +Generated by 'django-admin startproject' using Django 1.10.2. +""" +import os +import sys +from pathlib import Path + +# 导入Django国际化翻译工具 +from django.utils.translation import gettext_lazy as _ + + +def env_to_bool(env, default): + """ + 环境变量转换工具函数:将环境变量的字符串值转为布尔值 + - 若环境变量未设置,返回默认值 + - 若环境变量存在,仅当值为'True'时返回True,其他情况返回False + """ + str_val = os.environ.get(env) + return default if str_val is None else str_val == 'True' + + +# -------------------------- 基础路径配置 -------------------------- +# 项目根目录:当前配置文件所在目录的父级目录(即项目根目录) +BASE_DIR = Path(__file__).resolve().parent.parent + + +# -------------------------- 安全与调试配置 -------------------------- +# 快速开发配置(生产环境需修改) +# SECURITY WARNING: 生产环境必须将SECRET_KEY通过环境变量配置,禁止硬编码 +SECRET_KEY = os.environ.get( + 'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6' + +# 调试模式:开发环境开启(True),生产环境必须关闭(False) +# 通过环境变量控制,默认开启调试 +DEBUG = env_to_bool('DJANGO_DEBUG', True) + +# 测试模式标识:当执行python manage.py test时,TESTING为True +TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' + +# 允许访问的主机列表:生产环境需指定具体域名,禁止使用'*'(存在安全风险) +ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] + +# Django 4.0+新增:信任的CSRF源列表,防止跨站请求伪造,生产环境需配置真实域名 +CSRF_TRUSTED_ORIGINS = ['http://example.com'] + + +# -------------------------- 应用配置 -------------------------- +INSTALLED_APPS = [ + # Django内置应用(精简版Admin,仅包含核心功能) + 'django.contrib.admin.apps.SimpleAdminConfig', + 'django.contrib.auth', # 用户认证系统 + 'django.contrib.contenttypes', # 内容类型框架(关联模型与数据表) + 'django.contrib.sessions', # 会话管理 + 'django.contrib.messages', # 消息提示系统 + 'django.contrib.staticfiles', # 静态资源管理 + 'django.contrib.sites', # 多站点支持(用于sitemap等功能) + 'django.contrib.sitemaps', # 站点地图生成 + + # 第三方应用 + 'mdeditor', # Markdown编辑器(用于文章编写) + 'haystack', # 全文搜索框架 + 'compressor', # 静态资源压缩(CSS/JS合并压缩) + + # 自定义应用 + 'blog', # 博客核心功能(文章、分类等) + 'accounts', # 用户账户管理(自定义用户模型等) + 'comments', # 评论功能 + 'oauth', # 第三方登录(如GitHub、微信等) + 'servermanager',# 服务器管理(如系统监控等) + 'owntracks', # 位置追踪(可选功能) + 'djangoblog' # 项目主应用(全局配置、工具函数等) +] + + +# -------------------------- 中间件配置 -------------------------- +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', # 安全相关中间件(防XSS、点击劫持等) + 'django.contrib.sessions.middleware.SessionMiddleware', # 会话管理 + 'django.middleware.locale.LocaleMiddleware', # 国际化中间件(多语言支持) + 'django.middleware.gzip.GZipMiddleware', # GZip压缩(减少响应体积) + # 'django.middleware.cache.UpdateCacheMiddleware', # 缓存更新(注释:当前未启用) + 'django.middleware.common.CommonMiddleware', # 通用中间件(处理URL、反向解析等) + # 'django.middleware.cache.FetchFromCacheMiddleware', # 缓存读取(注释:当前未启用) + 'django.middleware.csrf.CsrfViewMiddleware', # CSRF保护中间件 + 'django.contrib.auth.middleware.AuthenticationMiddleware', # 用户认证中间件 + 'django.contrib.messages.middleware.MessageMiddleware', # 消息提示中间件 + 'django.middleware.clickjacking.XFrameOptionsMiddleware', # 防点击劫持 + 'django.middleware.http.ConditionalGetMiddleware', # 处理HTTP条件请求(如304缓存) + 'blog.middleware.OnlineMiddleware' # 自定义中间件(跟踪用户在线状态) +] + + +# -------------------------- URL与模板配置 -------------------------- +# 项目主URL配置文件路径 +ROOT_URLCONF = 'djangoblog.urls' + +# 模板配置 +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', # Django模板引擎 + 'DIRS': [os.path.join(BASE_DIR, 'templates')], # 全局模板目录(项目根目录下的templates) + 'APP_DIRS': True, # 允许从各应用的templates目录加载模板 + 'OPTIONS': { + # 模板上下文处理器:向所有模板注入全局变量 + 'context_processors': [ + 'django.template.context_processors.debug', # 调试模式变量 + 'django.template.context_processors.request', # 请求对象(request) + 'django.contrib.auth.context_processors.auth', # 用户认证变量(user) + 'django.contrib.messages.context_processors.messages', # 消息提示变量 + 'blog.context_processors.seo_processor' # 自定义SEO处理器(注入SEO相关变量) + ], + }, + }, +] + +# WSGI应用入口(用于部署,如Gunicorn、uWSGI) +WSGI_APPLICATION = 'djangoblog.wsgi.application' + + +# -------------------------- 数据库配置 -------------------------- +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', # 数据库引擎(MySQL) + 'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog', # 数据库名 + 'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root', # 数据库用户名 + 'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'LY181828', # 数据库密码 + 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', # 数据库主机 + 'PORT': int(os.environ.get('DJANGO_MYSQL_PORT') or 3306), # 数据库端口 + 'OPTIONS': {'charset': 'utf8mb4'}, # 数据库字符集(支持emoji表情) + }} + + +# -------------------------- 密码验证配置 -------------------------- +AUTH_PASSWORD_VALIDATORS = [ + # 验证密码与用户名/邮箱是否相似 + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + # 验证密码最小长度(默认8位) + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + # 验证密码是否为常见弱密码(如123456) + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + # 验证密码是否全为数字 + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + + +# -------------------------- 国际化与时间配置 -------------------------- +# 支持的语言列表(英文、简体中文、繁体中文) +LANGUAGES = ( + ('en', _('English')), + ('zh-hans', _('Simplified Chinese')), + ('zh-hant', _('Traditional Chinese')), +) + +# 语言文件目录(存放翻译文件的路径) +LOCALE_PATHS = (os.path.join(BASE_DIR, 'locale'),) + +# 默认语言(简体中文) +LANGUAGE_CODE = 'zh-hans' + +# 时区(上海时区,与UTC时差+8) +TIME_ZONE = 'Asia/Shanghai' + +# 启用国际化 +USE_I18N = True + +# 启用本地化(日期、时间格式等) +USE_L10N = True + +# 禁用UTC时间(使用本地时间存储数据库时间) +USE_TZ = False + + +# -------------------------- 全文搜索配置(Haystack) -------------------------- +HAYSTACK_CONNECTIONS = { + 'default': { + # 搜索引擎:自定义中文Whoosh引擎(支持中文分词) + 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', + # 搜索索引存储路径(项目配置文件目录下的whoosh_index) + 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), + }, +} + +# 实时更新搜索索引:当文章新增/修改/删除时,自动更新搜索索引 +HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' + + +# -------------------------- 用户认证配置 -------------------------- +# 自定义认证后端:允许用户用用户名或邮箱登录 +AUTHENTICATION_BACKENDS = ['accounts.user_login_backend.EmailOrUsernameModelBackend'] + +# 自定义用户模型:替换Django内置的User模型(关联accounts应用的BlogUser) +AUTH_USER_MODEL = 'accounts.BlogUser' + +# 登录页面URL:未登录用户访问需认证页面时,重定向到该URL +LOGIN_URL = '/login/' + + +# -------------------------- 静态资源与媒体文件配置 -------------------------- +# 静态资源收集目录(生产环境使用python manage.py collectstatic收集后的路径) +STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') + +# 静态资源URL前缀(前端访问静态资源的路径,如http://example.com/static/) +STATIC_URL = '/static/' + +# 全局静态资源目录(项目根目录下的static文件夹) +STATICFILES = os.path.join(BASE_DIR, 'static') + +# 媒体文件(用户上传文件,如文章图片)存储目录 +MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') + +# 媒体文件URL前缀(前端访问上传文件的路径,如http://example.com/media/) +MEDIA_URL = '/media/' + +# 静态资源查找器(指定Django如何查找静态文件) +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', # 从全局STATICFILES目录查找 + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', # 从各应用的static目录查找 + 'compressor.finders.CompressorFinder', # 从compressor压缩后的目录查找 +) + + +# -------------------------- 静态资源压缩配置(Compressor) -------------------------- +# 启用压缩(生产环境建议开启,开发环境可关闭) +COMPRESS_ENABLED = True +# COMPRESS_OFFLINE = True # 离线压缩(注释:当前未启用,适合生产环境) + +# CSS压缩过滤器:1. 转换相对URL为绝对URL;2. 压缩CSS代码 +COMPRESS_CSS_FILTERS = [ + 'compressor.filters.css_default.CssAbsoluteFilter', + 'compressor.filters.cssmin.CSSMinFilter' +] + +# JS压缩过滤器:压缩JS代码 +COMPRESS_JS_FILTERS = ['compressor.filters.jsmin.JSMinFilter'] + + +# -------------------------- 缓存配置 -------------------------- +# HTTP缓存超时时间(单位:秒,2592000秒=30天) +CACHE_CONTROL_MAX_AGE = 2592000 + +# 默认缓存:本地内存缓存(适合开发环境,生产环境建议用Redis/Memcached) +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'TIMEOUT': 10800, # 缓存超时时间(3小时) + 'LOCATION': 'unique-snowflake', # 缓存实例标识(唯一即可) + } +} + +# 若环境变量配置了Redis地址,则使用Redis作为缓存(生产环境推荐) +if os.environ.get("DJANGO_REDIS_URL"): + CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}', + } + } + + +# -------------------------- 其他业务配置 -------------------------- +# 多站点支持的站点ID(默认1,与django.contrib.sites配合使用) +SITE_ID = 1 + +# 百度链接提交URL:用于向百度搜索引擎提交新链接(SEO优化) +BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') or \ + 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn' + +# 时间格式:全局日期时间显示格式 +TIME_FORMAT = '%Y-%m-%d %H:%M:%S' +DATE_TIME_FORMAT = '%Y-%m-%d' + +# Bootstrap颜色样式列表(用于文章标签、按钮等UI组件) +BOOTSTRAP_COLOR_TYPES = ['default', 'primary', 'success', 'info', 'warning', 'danger'] + +# 分页配置:每页显示的文章数量 +PAGINATE_BY = 10 + +# X-Frame-Options配置:仅允许同域嵌入iframe(防点击劫持) +X_FRAME_OPTIONS = 'SAMEORIGIN' + +# 默认自增字段类型(Django 3.2+新增,避免主键溢出) +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# 微信管理员密码(两次MD5加密,用于微信后台管理验证) +WXADMIN = os.environ.get('DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' + + +# -------------------------- Elasticsearch配置(可选) -------------------------- +# 若环境变量配置了Elasticsearch地址,则使用Elasticsearch作为搜索引擎(替代Whoosh) +if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): + ELASTICSEARCH_DSL = { + 'default': {'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')}, + } + HAYSTACK_CONNECTIONS = { + 'default': {'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine'}, + } + + +# -------------------------- 日志配置 -------------------------- +# 日志存储目录(项目根目录下的logs文件夹) +LOG_PATH = os.path.join(BASE_DIR, 'logs') +# 若目录不存在则创建(exist_ok=True避免重复创建报错) +if not os.path.exists(LOG_PATH): + os.makedirs(LOG_PATH, exist_ok=True) + +LOGGING = { + 'version': 1, # 日志配置版本(固定为1) + 'disable_existing_loggers': False, # 不禁用已存在的日志器 + 'root': { # 根日志器(所有未指定日志器的日志都会走这里) + 'level': 'INFO', # 日志级别(INFO及以上会被记录) + 'handlers': ['console', 'log_file'], # 日志处理器(控制台+文件) + }, + 'formatters': { # 日志格式 + 'verbose': { # 详细格式(包含时间、级别、模块、行号等) + 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', + } + }, + 'filters': { # 日志过滤器 + 'require_debug_false': {'()': 'django.utils.log.RequireDebugFalse'}, # 仅DEBUG=False时生效 + 'require_debug_true': {'()': 'django.utils.log.RequireDebugTrue'}, # 仅DEBUG=True时生效 + }, + 'handlers': { # 日志处理器(定义日志如何输出) + 'log_file': { # 文件处理器(按天分割日志) + 'level': 'INFO', + 'class': 'logging.handlers.TimedRotatingFileHandler', # 按时间轮转的文件处理器 + 'filename': os.path.join(LOG_PATH, 'djangoblog.log'), # 日志文件路径 + 'when': 'D', # 轮转周期(每天一个文件) + 'formatter': 'verbose', # 使用详细格式 + 'interval': 1, # 轮转间隔(1天) + 'delay': True, # 延迟创建文件(直到有日志时才创建) + 'backupCount': 5, # 保留5个备份日志文件 + 'encoding': 'utf-8' # 日志文件编码 + }, + 'console': { # 控制台处理器(仅开发环境显示) + 'level': 'DEBUG', + 'filters': ['require_debug_true'], # 仅DEBUG=True时生效 + 'class': 'logging.StreamHandler', # 输出到控制台 + 'formatter': 'verbose' + }, + 'null': {'class': 'logging.NullHandler'}, # 空处理器(丢弃日志) + 'mail_admins': { # 邮件处理器(发生ERROR时通知管理员) + 'level': 'ERROR', + 'filters': ['require_debug_false'], # 仅生产环境(DEBUG=False)生效 + 'class': 'django.utils.log.AdminEmailHandler' # 发送邮件给ADMINS列表 + } + }, + 'loggers': { # 自定义日志器(针对特定模块) + 'djangoblog': { # 项目主模块日志 + 'handlers': ['log_file', 'console'], + 'level': 'INFO', + 'propagate': True, # 是否向上传递日志(到root日志器) + }, + 'django.request': { # Django请求相关日志(如404、500错误) + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': False, # 不向上传递(避免重复记录) + } + } +} + + +# -------------------------- 邮件配置 -------------------------- +# 邮件后端(SMTP服务) +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +# 是否使用TLS加密(与SSL二选一,根据邮件服务商配置) +EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) +# 是否使用SSL加密(阿里云邮箱等常用465端口+SSL) +EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) +# 邮件服务器地址(如阿里云邮箱为smtp.mxhichina.com) +EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' +# 邮件服务器端口(SSL通常为465,TLS通常为587) +EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) +# 发送邮件的用户名(邮箱地址) +EMAIL_HOST_USER = os.environ.get('DJANGO_EMAIL_USER') +# 发送邮件的密码(邮箱授权码,非登录密码) +EMAIL_HOST_PASSWORD = os.environ.get('DJANGO_EMAIL_PASSWORD') +# 默认发件人(与EMAIL_HOST_USER一致) +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER +# 管理员邮件(与ADMINS配合使用) +SERVER_EMAIL = EMAIL_HOST_USER + +# 管理员列表:发生ERROR时会收到邮件通知 +ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] + + +# -------------------------- 插件系统配置 -------------------------- +# 插件目录(项目根目录下的plugins文件夹) +PLUGINS_DIR = BASE_DIR / 'plugins' + +# 激活的插件列表(按需启用,提供额外功能) +ACTIVE_PLUGINS = [ + 'article_copyright', # 文章版权声明 + 'reading_time', # 文章阅读时长估算 + 'external_links', # 外部链接处理(如添加nofollow) + 'view_count', # 文章阅读量统计 + 'seo_optimize' # SEO优化(如自动生成meta标签) +] \ No newline at end of file -- 2.34.1 From ef8f698fd1d9310095cd7789acad391eec6c1b7e Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:20:26 +0800 Subject: [PATCH 04/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sitemap.py | 124 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/sitemap.py diff --git a/src/sitemap.py b/src/sitemap.py new file mode 100644 index 0000000..84e9886 --- /dev/null +++ b/src/sitemap.py @@ -0,0 +1,124 @@ +# 导入Django站点地图核心类和URL反向解析工具 +from django.contrib.sitemaps import Sitemap +from django.urls import reverse + +# 导入博客相关模型(文章、分类、标签),用于生成动态页面的站点地图 +from blog.models import Article, Category, Tag + + +class StaticViewSitemap(Sitemap): + """ + 静态页面站点地图类:用于生成网站静态页面(如首页)的Sitemap条目 + Sitemap用于告知搜索引擎网站的页面结构,帮助其抓取和索引 + """ + # 页面优先级(0.0-1.0):值越高,搜索引擎认为该页面越重要 + priority = 0.5 + # 页面内容更新频率:可选值包括always、hourly、daily、weekly、monthly、yearly、never + changefreq = 'daily' + + def items(self): + """ + 定义需要包含在站点地图中的静态页面URL名称列表 + 返回的是Django URL配置中定义的'name'属性(如'blog:index'对应首页URL) + """ + return ['blog:index', ] + + def location(self, item): + """ + 为items()返回的每个URL名称,生成对应的绝对URL + 通过reverse()方法解析URL名称,获取实际访问路径(如'blog:index'解析为'/') + """ + return reverse(item) + + +class ArticleSiteMap(Sitemap): + """ + 文章页面站点地图类:用于生成所有已发布文章的Sitemap条目 + """ + # 文章页面更新频率:每月(因单篇文章发布后修改频率较低) + changefreq = "monthly" + # 文章页面优先级:0.6(高于分类/标签,低于首页) + priority = "0.6" + + def items(self): + """ + 返回所有状态为"已发布"(status='p')的文章对象 + 仅包含已发布文章,避免搜索引擎抓取草稿或未公开内容 + """ + return Article.objects.filter(status='p') + + def lastmod(self, obj): + """ + 定义每个文章页面的最后修改时间 + 取值为文章的last_modify_time字段(文章最后更新的时间) + 帮助搜索引擎识别页面是否有更新,决定是否重新抓取 + """ + return obj.last_modify_time + + +class CategorySiteMap(Sitemap): + """ + 分类页面站点地图类:用于生成所有文章分类页面的Sitemap条目 + """ + # 分类页面更新频率:每周(分类下文章新增/删除会影响分类页,频率低于文章) + changefreq = "Weekly" + # 分类页面优先级:0.6(与文章同级,高于标签/用户) + priority = "0.6" + + def items(self): + """返回所有分类对象,生成每个分类页面的Sitemap条目""" + return Category.objects.all() + + def lastmod(self, obj): + """ + 分类页面的最后修改时间:取值为分类的last_modify_time字段 + 分类信息(如分类名称)修改时,会更新该时间,提示搜索引擎重新抓取 + """ + return obj.last_modify_time + + +class TagSiteMap(Sitemap): + """ + 标签页面站点地图类:用于生成所有文章标签页面的Sitemap条目 + """ + # 标签页面更新频率:每周(标签下文章增减频率较低) + changefreq = "Weekly" + # 标签页面优先级:0.3(低于首页、文章、分类,属于次要导航页面) + priority = "0.3" + + def items(self): + """返回所有标签对象,生成每个标签页面的Sitemap条目""" + return Tag.objects.all() + + def lastmod(self, obj): + """ + 标签页面的最后修改时间:取值为标签的last_modify_time字段 + 标签信息(如标签名称)修改时更新,用于搜索引擎判断页面新鲜度 + """ + return obj.last_modify_time + + +class UserSiteMap(Sitemap): + """ + 用户页面站点地图类:用于生成所有发布过文章的用户主页的Sitemap条目 + """ + # 用户页面更新频率:每周(用户发布新文章或修改资料时才更新) + changefreq = "Weekly" + # 用户页面优先级:0.3(与标签同级,属于次要页面) + priority = "0.3" + + def items(self): + """ + 返回所有发布过文章的独特用户对象 + 1. 通过Article.objects.all()获取所有文章,提取每篇文章的author(作者) + 2. 使用map()遍历文章列表,获取作者集合;用set()去重,避免重复用户 + 3. 转换为list(),符合items()返回可迭代对象的要求 + """ + return list(set(map(lambda x: x.author, Article.objects.all()))) + + def lastmod(self, obj): + """ + 用户页面的最后修改时间:取值为用户的date_joined字段(用户注册时间) + 此处可根据需求调整(如改为用户最后发布文章时间或最后修改资料时间) + """ + return obj.date_joined \ No newline at end of file -- 2.34.1 From d2f7647727f3d5517175151b4c48caf797fcd1cf Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:21:10 +0800 Subject: [PATCH 05/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/spider_notify.py | 49 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 src/spider_notify.py diff --git a/src/spider_notify.py b/src/spider_notify.py new file mode 100644 index 0000000..65142d8 --- /dev/null +++ b/src/spider_notify.py @@ -0,0 +1,49 @@ +# 导入日志模块,用于记录通知过程中的信息和错误 +import logging + +# 导入requests库,用于发送HTTP请求(向搜索引擎提交链接) +import requests +# 导入Django项目配置,用于获取百度链接提交的URL(在settings.py中配置) +from django.conf import settings + +# 创建当前模块的日志记录器,用于输出通知相关的日志 +logger = logging.getLogger(__name__) + + +class SpiderNotify(): + """ + 搜索引擎爬虫通知类:用于向搜索引擎(当前仅百度)提交网站新链接 + 帮助搜索引擎快速发现并收录网站新增或更新的页面,提升SEO效果 + """ + + @staticmethod + def baidu_notify(urls): + """ + 向百度搜索引擎提交链接的静态方法 + 通过百度链接提交API(BAIDU_NOTIFY_URL),将新页面URL推送给百度爬虫 + + Args: + urls (list): 需要提交的URL列表(每个元素为一个页面的完整URL或相对URL) + """ + try: + # 将URL列表转换为以换行符分隔的字符串,符合百度API的提交格式要求 + data = '\n'.join(urls) + # 发送POST请求到百度链接提交URL,提交URL数据 + result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) + # 记录百度返回的响应结果(成功时包含提交状态,用于调试和审计) + logger.info(result.text) + except Exception as e: + # 捕获请求过程中的所有异常(如网络错误、API地址错误等),记录错误日志 + logger.error(e) + + @staticmethod + def notify(url): + """ + 通用通知入口静态方法:统一调用百度链接提交方法 + 此处为简化设计,后续可扩展支持其他搜索引擎(如谷歌、必应) + + Args: + url (str/list): 单个URL字符串或URL列表(最终会转为列表提交给百度) + """ + # 调用百度链接提交方法,实现URL推送 + SpiderNotify.baidu_notify(url) \ No newline at end of file -- 2.34.1 From cc1b87e7d45d58da8612b6ae9787c8f2f27726f9 Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:22:29 +0800 Subject: [PATCH 06/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tests.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/tests.py diff --git a/src/tests.py b/src/tests.py new file mode 100644 index 0000000..01237d9 --- /dev/null +++ b/src/tests.py @@ -0,0 +1,32 @@ +from django.test import TestCase + +from djangoblog.utils import * + + +class DjangoBlogTest(TestCase): + def setUp(self): + pass + + def test_utils(self): + md5 = get_sha256('test') + self.assertIsNotNone(md5) + c = CommonMarkdown.get_markdown(''' + # Title1 + + ```python + import os + ``` + + [url](https://www.lylinux.net/) + + [ddd](http://www.baidu.com) + + + ''') + self.assertIsNotNone(c) + d = { + 'd': 'key1', + 'd2': 'key2' + } + data = parse_dict_to_url(d) + self.assertIsNotNone(data) -- 2.34.1 From 29d32761cccd8ace3a3fc48514f402c05a9eb382 Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:22:57 +0800 Subject: [PATCH 07/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/urls.py | 98 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/urls.py diff --git a/src/urls.py b/src/urls.py new file mode 100644 index 0000000..ad0ad00 --- /dev/null +++ b/src/urls.py @@ -0,0 +1,98 @@ +"""djangoblog URL Configuration +项目URL路由总配置文件:定义所有URL与视图/应用的映射关系 +核心作用是将用户访问的URL地址,分发到对应的应用或视图函数处理 +""" +# 导入项目配置,用于获取静态资源、媒体文件路径等 +from django.conf import settings +# 导入国际化URL配置工具,支持多语言URL前缀(如/en/、/zh-hans/) +from django.conf.urls.i18n import i18n_patterns +# 导入静态资源URL配置工具,用于开发环境下提供静态文件访问 +from django.conf.urls.static import static +# 导入站点地图视图,用于生成sitemap.xml +from django.contrib.sitemaps.views import sitemap +# 导入URL路径配置工具(path用于固定路径,re_path支持正则匹配,include用于包含子应用URL) +from django.urls import path, include, re_path +# 导入Haystack搜索视图工厂,用于自定义搜索视图 +from haystack.views import search_view_factory + +# 导入自定义视图和配置:博客搜索视图、自定义Admin站点、ElasticSearch搜索表单 +from blog.views import EsSearchView +from djangoblog.admin_site import admin_site +from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm +# 导入RSS订阅Feed和站点地图类 +from djangoblog.feeds import DjangoBlogFeed +from djangoblog.sitemap import (ArticleSiteMap, CategorySiteMap, + StaticViewSitemap, TagSiteMap, UserSiteMap) + + +# -------------------------- 站点地图配置 -------------------------- +# 定义站点地图字典:关联不同类型页面的Sitemap类,用于生成sitemap.xml +sitemaps = { + 'blog': ArticleSiteMap, # 文章页面的站点地图 + 'Category': CategorySiteMap, # 分类页面的站点地图 + 'Tag': TagSiteMap, # 标签页面的站点地图 + 'User': UserSiteMap, # 用户主页的站点地图 + 'static': StaticViewSitemap # 静态页面(如首页)的站点地图 +} + + +# -------------------------- 自定义错误页面配置 -------------------------- +# 配置404(页面不存在)错误对应的处理视图 +handler404 = 'blog.views.page_not_found_view' +# 配置500(服务器内部错误)错误对应的处理视图 +handler500 = 'blog.views.server_error_view' +# 配置403(权限不足)错误对应的处理视图 +handle403 = 'blog.views.permission_denied_view' + + +# -------------------------- 基础URL配置 -------------------------- +# 非国际化URL列表:不随语言切换变化的URL +urlpatterns = [ + # 国际化切换入口:提供语言选择功能(如切换中英文) + path('i18n/', include('django.conf.urls.i18n')), +] + + +# -------------------------- 国际化URL配置 -------------------------- +# 国际化URL列表:会自动添加语言前缀(如/zh-hans/admin/、/en/admin/) +# prefix_default_language=False:默认语言(如中文)不添加语言前缀,保持URL简洁 +urlpatterns += i18n_patterns( + # 自定义Admin后台URL:使用项目自定义的admin_site(非Django默认Admin) + re_path(r'^admin/', admin_site.urls), + # 博客核心功能URL:包含文章列表、详情等,命名空间为'blog' + re_path(r'', include('blog.urls', namespace='blog')), + # Markdown编辑器URL:集成mdeditor插件的路由 + re_path(r'mdeditor/', include('mdeditor.urls')), + # 评论功能URL:包含评论提交、列表等,命名空间为'comment' + re_path(r'', include('comments.urls', namespace='comment')), + # 用户账户功能URL:包含登录、注册、个人中心等,命名空间为'account' + re_path(r'', include('accounts.urls', namespace='account')), + # 第三方登录URL:包含GitHub、微信等登录,命名空间为'oauth' + re_path(r'', include('oauth.urls', namespace='oauth')), + # 站点地图URL:生成sitemap.xml,供搜索引擎抓取 + re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, + name='django.contrib.sitemaps.views.sitemap'), + # RSS订阅URL:提供两种路径(/feed/和/rss/),均指向DjangoBlogFeed + re_path(r'^feed/$', DjangoBlogFeed()), + re_path(r'^rss/$', DjangoBlogFeed()), + # 搜索功能URL:使用ElasticSearch搜索视图和表单,命名空间为'search' + re_path('^search', search_view_factory( + view_class=EsSearchView, + form_class=ElasticSearchModelSearchForm + ), name='search'), + # 服务器管理功能URL:包含系统监控等,命名空间为'servermanager' + re_path(r'', include('servermanager.urls', namespace='servermanager')), + # 位置追踪功能URL:集成owntracks的路由,命名空间为'owntracks' + re_path(r'', include('owntracks.urls', namespace='owntracks')), + prefix_default_language=False # 默认语言URL不添加语言前缀 +) + + +# -------------------------- 静态资源与媒体文件URL配置 -------------------------- +# 开发环境下:添加静态文件URL映射(生产环境由Nginx/Apache处理) +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + +# DEBUG模式下(开发环境):添加媒体文件(用户上传文件)的URL映射 +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, + document_root=settings.MEDIA_ROOT) \ No newline at end of file -- 2.34.1 From 7a91f3f736d9cdad8f155a3145e6f35323bf77c3 Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:23:32 +0800 Subject: [PATCH 08/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils.py | 354 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 354 insertions(+) create mode 100644 src/utils.py diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..e416eff --- /dev/null +++ b/src/utils.py @@ -0,0 +1,354 @@ +#!/usr/bin/env python +# encoding: utf-8 + + +# 导入必要模块:日志、文件操作、随机数生成、加密、HTTP请求等 +import logging +import os +import random +import string +import uuid +from hashlib import sha256 + +# 导入第三方库:HTML过滤、Markdown转换、HTTP请求 +import bleach +import markdown +import requests +# 导入Django核心模块:配置、缓存、站点模型、静态文件工具 +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.cache import cache +from django.templatetags.static import static + +# 创建当前模块的日志记录器 +logger = logging.getLogger(__name__) + + +def get_max_articleid_commentid(): + """ + 获取最大的文章ID和评论ID + 用于系统统计或数据同步场景,快速获取最新数据的ID边界 + """ + # 延迟导入模型(避免循环导入问题) + from blog.models import Article + from comments.models import Comment + # 返回最新文章和评论的主键(ID) + return (Article.objects.latest().pk, Comment.objects.latest().pk) + + +def get_sha256(str): + """ + 对字符串进行SHA256加密 + 用于密码加密、数据校验等场景(如生成唯一标识) + """ + m = sha256(str.encode('utf-8')) # 编码为UTF-8后加密 + return m.hexdigest() # 返回十六进制加密结果 + + +def cache_decorator(expiration=3 * 60): + """ + 缓存装饰器:为函数添加缓存功能,减少重复计算或数据库查询 + 默认缓存时间为3分钟(180秒),可通过参数调整 + + Args: + expiration: 缓存过期时间(秒) + """ + + def wrapper(func): + def news(*args, **kwargs): + # 尝试从请求对象中获取缓存键(适用于视图函数) + try: + view = args[0] + key = view.get_cache_key() + except: + key = None # 非视图函数则生成自定义键 + + # 生成自定义缓存键(基于函数和参数的唯一标识) + if not key: + unique_str = repr((func, args, kwargs)) # 序列化函数和参数 + m = sha256(unique_str.encode('utf-8')) + key = m.hexdigest() # 生成唯一哈希键 + + # 从缓存获取数据 + value = cache.get(key) + if value is not None: + # 处理空值标记(避免缓存None导致的重复计算) + if str(value) == '__default_cache_value__': + return None + else: + return value # 返回缓存数据 + else: + # 缓存未命中,执行原函数并缓存结果 + logger.debug(f'cache_decorator set cache:{func.__name__} key:{key}') + value = func(*args, **kwargs) + # 缓存空值时用特殊标记,避免缓存穿透 + if value is None: + cache.set(key, '__default_cache_value__', expiration) + else: + cache.set(key, value, expiration) + return value + + return news + + return wrapper + + +def expire_view_cache(path, servername, serverport, key_prefix=None): + ''' + 主动刷新视图缓存:删除指定URL路径的缓存 + 用于数据更新后同步清理缓存,确保用户看到最新内容 + + Args: + path: URL路径(如'/article/1/') + servername: 服务器域名/主机名 + serverport: 服务器端口 + key_prefix: 缓存键前缀 + + Returns: + bool: 缓存是否成功删除 + ''' + from django.http import HttpRequest + from django.utils.cache import get_cache_key + + # 构造模拟请求对象(用于生成缓存键) + request = HttpRequest() + request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport} + request.path = path + + # 获取缓存键并删除缓存 + key = get_cache_key(request, key_prefix=key_prefix, cache=cache) + if key: + logger.info(f'expire_view_cache:get key:{path}') + if cache.get(key): + cache.delete(key) + return True + return False + + +@cache_decorator() +def get_current_site(): + """ + 获取当前站点信息(缓存装饰器确保高效获取) + 基于Django的sites框架,用于生成绝对URL等场景 + """ + site = Site.objects.get_current() + return site + + +class CommonMarkdown: + """ + Markdown转换工具类:将Markdown文本转换为HTML,并支持生成目录(TOC) + 集成代码高亮、表格等扩展功能 + """ + + @staticmethod + def _convert_markdown(value): + """内部转换方法:执行Markdown到HTML的转换,返回内容和目录""" + md = markdown.Markdown( + extensions=[ + 'extra', # 基础扩展(表格、脚注等) + 'codehilite', # 代码高亮 + 'toc', # 目录生成 + 'tables', # 表格支持 + ] + ) + body = md.convert(value) # 转换正文为HTML + toc = md.toc # 提取目录 + return body, toc + + @staticmethod + def get_markdown_with_toc(value): + """获取带目录的HTML内容""" + body, toc = CommonMarkdown._convert_markdown(value) + return body, toc + + @staticmethod + def get_markdown(value): + """仅获取转换后的HTML正文(不含目录)""" + body, toc = CommonMarkdown._convert_markdown(value) + return body + + +def send_email(emailto, title, content): + """ + 发送邮件的封装函数:通过信号机制发送邮件,解耦邮件发送逻辑 + 实际发送由信号接收者处理(如调用Django邮件后端) + """ + from djangoblog.blog_signals import send_email_signal + # 发送信号,传递邮件参数 + send_email_signal.send( + send_email.__class__, + emailto=emailto, + title=title, + content=content) + + +def generate_code() -> str: + """生成6位数字随机验证码,用于邮箱验证、登录等场景""" + return ''.join(random.sample(string.digits, 6)) # 从0-9中随机选择6个数字 + + +def parse_dict_to_url(dict): + """ + 将字典转换为URL查询参数字符串(如{'a':1,'b':2} → 'a=1&b=2') + 自动对键值进行URL编码,支持特殊字符 + + Args: + dict: 键值对字典 + + Returns: + str: URL查询参数字符串 + """ + from urllib.parse import quote + return '&'.join([ + f'{quote(k, safe="/")}={quote(v, safe="/")}' + for k, v in dict.items() + ]) + + +def get_blog_setting(): + """ + 获取博客系统设置(单例模式),并缓存结果 + 包含站点名称、描述、SEO配置等核心设置 + + Returns: + BlogSettings对象:系统设置实例 + """ + # 先从缓存获取,减少数据库查询 + value = cache.get('get_blog_setting') + if value: + return value + else: + from blog.models import BlogSettings + # 若不存在设置记录,创建默认配置 + if not BlogSettings.objects.count(): + setting = BlogSettings() + setting.site_name = 'djangoblog' + setting.site_description = '基于Django的博客系统' + setting.site_seo_description = '基于Django的博客系统' + setting.site_keywords = 'Django,Python' + setting.article_sub_length = 300 + setting.sidebar_article_count = 10 + setting.sidebar_comment_count = 5 + setting.show_google_adsense = False + setting.open_site_comment = True + setting.analytics_code = '' + setting.beian_code = '' + setting.show_gongan_code = False + setting.comment_need_review = False + setting.save() + # 获取设置并缓存 + value = BlogSettings.objects.first() + logger.info('set cache get_blog_setting') + cache.set('get_blog_setting', value) + return value + + +def save_user_avatar(url): + ''' + 保存用户头像到本地静态目录,并返回访问URL + 用于处理第三方登录(如GitHub)的头像保存 + + Args: + url: 头像的远程URL + + Returns: + str: 本地头像的静态文件URL(默认返回系统默认头像) + ''' + logger.info(url) + try: + # 定义本地保存路径(static/avatar目录) + basedir = os.path.join(settings.STATICFILES, 'avatar') + # 下载头像图片 + rsp = requests.get(url, timeout=2) + if rsp.status_code == 200: + # 创建目录(若不存在) + if not os.path.exists(basedir): + os.makedirs(basedir) + + # 验证文件类型并确定扩展名 + image_extensions = ['.jpg', '.png', 'jpeg', '.gif'] + isimage = len([i for i in image_extensions if url.endswith(i)]) > 0 + ext = os.path.splitext(url)[1] if isimage else '.jpg' + # 生成唯一文件名(UUID避免冲突) + save_filename = str(uuid.uuid4().hex) + ext + logger.info(f'保存用户头像:{basedir}{save_filename}') + # 写入文件 + with open(os.path.join(basedir, save_filename), 'wb+') as file: + file.write(rsp.content) + # 返回静态文件URL + return static('avatar/' + save_filename) + except Exception as e: + logger.error(e) + # 失败时返回默认头像 + return static('blog/img/avatar.png') + + +def delete_sidebar_cache(): + """ + 删除侧边栏缓存:当侧边栏数据(如链接、文章列表)更新时调用 + 确保用户看到最新的侧边栏内容 + """ + from blog.models import LinkShowType + # 生成所有侧边栏缓存键(基于链接展示类型) + keys = ["sidebar" + x for x in LinkShowType.values] + for k in keys: + logger.info(f'delete sidebar key:{k}') + cache.delete(k) + + +def delete_view_cache(prefix, keys): + """ + 删除模板片段缓存:用于删除指定前缀和参数的模板缓存 + 如文章详情页的评论区缓存 + + Args: + prefix: 缓存前缀(模板中定义) + keys: 缓存键参数列表 + """ + from django.core.cache.utils import make_template_fragment_key + key = make_template_fragment_key(prefix, keys) + cache.delete(key) + + +def get_resource_url(): + """ + 获取静态资源基础URL + 优先使用settings中的STATIC_URL,否则基于当前站点域名生成 + + Returns: + str: 静态资源URL前缀(如'http://example.com/static/') + """ + if settings.STATIC_URL: + return settings.STATIC_URL + else: + site = get_current_site() + return f'http://{site.domain}/static/' + + +# HTML过滤配置:限制允许的标签和属性,防止XSS攻击 +ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', + 'li', 'ol', 'pre', 'strong', 'ul', 'h1', 'h2', 'p'] +ALLOWED_ATTRIBUTES = { + 'a': ['href', 'title'], + 'abbr': ['title'], + 'acronym': ['title'] +} + + +def sanitize_html(html): + """ + 净化HTML内容:仅保留允许的标签和属性,过滤恶意代码 + 用于处理用户输入的HTML(如评论、文章内容),防止XSS攻击 + + Args: + html: 原始HTML字符串 + + Returns: + str: 净化后的安全HTML + """ + return bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES + ) \ No newline at end of file -- 2.34.1 From e0af0d00dc651e99c93af3feb458b82ff51f57d2 Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:24:17 +0800 Subject: [PATCH 09/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/whoosh_cn_backend.py | 1120 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 1120 insertions(+) create mode 100644 src/whoosh_cn_backend.py diff --git a/src/whoosh_cn_backend.py b/src/whoosh_cn_backend.py new file mode 100644 index 0000000..44964f3 --- /dev/null +++ b/src/whoosh_cn_backend.py @@ -0,0 +1,1120 @@ +# encoding: utf-8 + +from __future__ import absolute_import, division, print_function, unicode_literals + +# 导入必要模块:JSON处理、文件操作、正则、线程、警告等 +import json +import os +import re +import shutil +import threading +import warnings + +import six # 兼容Python 2/3 +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured # Django配置异常 +from datetime import datetime +from django.utils.encoding import force_str # 字符串编码处理 +# 导入Haystack核心模块:引擎、后端、查询、结果等基础类 +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query +from haystack.constants import DJANGO_CT, DJANGO_ID, ID # Haystack常量(模型类型、ID等) +from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument # Haystack异常 +from haystack.inputs import Clean, Exact, PythonData, Raw # Haystack查询输入类型 +from haystack.models import SearchResult # Haystack搜索结果模型 +from haystack.utils import get_identifier, get_model_ct # Haystack工具函数(获取唯一标识、模型类型) +from haystack.utils import log as logging # Haystack日志 +from haystack.utils.app_loading import haystack_get_model # Haystack模型加载工具 +from jieba.analyse import ChineseAnalyzer # 结巴中文分词器(用于中文搜索) +# 导入Whoosh核心模块:索引、分析器、字段、存储、高亮、查询解析、搜索结果等 +from whoosh import index +from whoosh.analysis import StemmingAnalyzer # Whoosh英文词干分析器 +from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT # Whoosh字段类型 +from whoosh.fields import ID as WHOOSH_ID # Whoosh ID字段(避免与Haystack的ID冲突) +from whoosh.filedb.filestore import FileStorage, RamStorage # Whoosh文件存储/内存存储 +from whoosh.highlight import ContextFragmenter, HtmlFormatter # Whoosh高亮相关 +from whoosh.highlight import highlight as whoosh_highlight # Whoosh高亮函数 +from whoosh.qparser import QueryParser # Whoosh查询解析器 +from whoosh.searching import ResultsPage # Whoosh分页结果 +from whoosh.writing import AsyncWriter # Whoosh异步写入器(提高写入效率) + + +# 检查Whoosh依赖是否安装 +try: + import whoosh +except ImportError: + raise MissingDependency( + "The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.") + +# 检查Whoosh版本(要求2.5.0及以上) +if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0): + raise MissingDependency( + "The 'whoosh' backend requires version 2.5.0 or greater.") + + +# 正则表达式:匹配ISO格式的日期时间字符串(用于Whoosh与Python datetime转换) +DATETIME_REGEX = re.compile( + '^(?P\d{4})-(?P\d{2})-(?P\d{2})T(?P\d{2}):(?P\d{2}):(?P\d{2})(\.\d{3,6}Z?)?$') +# 线程本地存储:用于共享内存存储(RamStorage),避免多线程冲突 +LOCALS = threading.local() +LOCALS.RAM_STORE = None + + +class WhooshHtmlFormatter(HtmlFormatter): + """ + 自定义Whoosh HTML高亮格式化器 + 简化默认格式,确保与其他搜索后端(如Solr、Elasticsearch)的高亮结果格式一致 + 使用标签包裹高亮文本(默认格式) + """ + template = '<%(tag)s>%(t)s' + + +class WhooshSearchBackend(BaseSearchBackend): + """ + Whoosh搜索后端实现类:继承自Haystack的BaseSearchBackend + 负责与Whoosh交互,实现索引创建、更新、删除、搜索等核心功能 + 支持中文分词(基于结巴分词) + """ + # Whoosh保留关键字(搜索时需特殊处理,避免语法错误) + RESERVED_WORDS = ( + 'AND', + 'NOT', + 'OR', + 'TO', + ) + + # Whoosh保留字符(搜索时需转义或处理,避免语法错误) + # '\\'需放在首位,防止覆盖其他斜杠替换 + RESERVED_CHARACTERS = ( + '\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}', + '[', ']', '^', '"', '~', '*', '?', ':', '.', + ) + + def __init__(self, connection_alias, **connection_options): + """ + 初始化Whoosh搜索后端 + :param connection_alias: 连接别名(来自Haystack配置) + :param connection_options: 连接参数(如索引路径、存储类型等) + """ + super(WhooshSearchBackend, self).__init__(connection_alias, **connection_options) + self.setup_complete = False # 初始化完成标记(延迟初始化) + self.use_file_storage = True # 默认使用文件存储(FileStorage) + # POST请求大小限制(默认128MB) + self.post_limit = getattr(connection_options, 'POST_LIMIT', 128 * 1024 * 1024) + # 索引存储路径(从配置中获取) + self.path = connection_options.get('PATH') + + # 检查存储类型:若配置为非文件存储(如内存),则使用RamStorage + if connection_options.get('STORAGE', 'file') != 'file': + self.use_file_storage = False + + # 若使用文件存储但未配置路径,抛出配置异常 + if self.use_file_storage and not self.path: + raise ImproperlyConfigured( + "You must specify a 'PATH' in your settings for connection '%s'." % connection_alias) + + # 初始化日志记录器 + self.log = logging.getLogger('haystack') + + def setup(self): + """ + 延迟初始化:创建索引存储和Schema,初始化Whoosh索引 + 避免项目启动时立即加载,仅在首次使用搜索功能时执行 + """ + from haystack import connections # 延迟导入,避免循环导入 + new_index = False # 是否为新创建的索引(首次初始化) + + # 若使用文件存储且路径不存在,创建目录并标记为新索引 + if self.use_file_storage and not os.path.exists(self.path): + os.makedirs(self.path) + new_index = True + + # 检查文件存储路径是否可写 + if self.use_file_storage and not os.access(self.path, os.W_OK): + raise IOError( + "The path to your Whoosh index '%s' is not writable for the current user/group." % self.path) + + # 初始化存储:文件存储或内存存储 + if self.use_file_storage: + self.storage = FileStorage(self.path) + else: + global LOCALS + # 内存存储共享(线程本地存储,避免多线程重复创建) + if getattr(LOCALS, 'RAM_STORE', None) is None: + LOCALS.RAM_STORE = RamStorage() + self.storage = LOCALS.RAM_STORE + + # 构建Whoosh Schema(索引结构):从Haystack统一索引获取字段 + unified_index = connections[self.connection_alias].get_unified_index() + self.content_field_name, self.schema = self.build_schema(unified_index.all_searchfields()) + # 初始化查询解析器(基于内容字段和Schema) + self.parser = QueryParser(self.content_field_name, schema=self.schema) + + # 若为新索引,创建索引;否则打开现有索引(不存在则创建) + if new_index is True: + self.index = self.storage.create_index(self.schema) + else: + try: + self.index = self.storage.open_index(schema=self.schema) + except index.EmptyIndexError: + self.index = self.storage.create_index(self.schema) + + # 标记初始化完成 + self.setup_complete = True + + def build_schema(self, fields): + """ + 构建Whoosh Schema(索引结构):将Haystack字段映射为Whoosh字段类型 + :param fields: Haystack统一索引中的所有字段(dict,key为字段名,value为字段类) + :return: (content_field_name, schema):内容字段名(主搜索字段)、Whoosh Schema对象 + """ + # 初始化Schema字段:包含Haystack默认字段(ID、模型类型、模型ID) + schema_fields = { + ID: WHOOSH_ID(stored=True, unique=True), # 文档唯一ID(Haystack标识) + DJANGO_CT: WHOOSH_ID(stored=True), # 模型类型(如blog.Article) + DJANGO_ID: WHOOSH_ID(stored=True), # 模型主键ID + } + # 初始字段数量(用于后续检查是否有有效字段) + initial_key_count = len(schema_fields) + content_field_name = '' # 主内容字段名(标记为document=True的字段) + + # 遍历Haystack字段,映射为对应的Whoosh字段 + for field_name, field_class in fields.items(): + index_fieldname = field_class.index_fieldname # 索引中的实际字段名 + # 处理多值字段(如标签、分类) + if field_class.is_multivalued: + if not field_class.indexed: + # 非索引多值字段:使用IDLIST(存储但不索引) + schema_fields[index_fieldname] = IDLIST(stored=True, field_boost=field_class.boost) + else: + # 索引多值字段:使用KEYWORD(逗号分隔,可索引、可排序) + schema_fields[index_fieldname] = KEYWORD( + stored=True, commas=True, scorable=True, field_boost=field_class.boost) + # 处理日期/日期时间字段 + elif field_class.field_type in ['date', 'datetime']: + schema_fields[index_fieldname] = DATETIME(stored=field_class.stored, sortable=True) + # 处理整数字段 + elif field_class.field_type == 'integer': + schema_fields[index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=int, field_boost=field_class.boost) + # 处理浮点数字段 + elif field_class.field_type == 'float': + schema_fields[index_fieldname] = NUMERIC( + stored=field_class.stored, numtype=float, field_boost=field_class.boost) + # 处理布尔字段 + elif field_class.field_type == 'boolean': + # Whoosh BOOLEAN字段不支持boost(2.5.0+版本) + schema_fields[index_fieldname] = BOOLEAN(stored=field_class.stored) + # 处理NGram字段(适用于模糊搜索,如拼音、部分匹配) + elif field_class.field_type == 'ngram': + schema_fields[index_fieldname] = NGRAM( + minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost) + # 处理Edge NGram字段(适用于前缀匹配,如搜索"py"匹配"Python") + elif field_class.field_type == 'edge_ngram': + schema_fields[index_fieldname] = NGRAMWORDS( + minsize=2, maxsize=15, at='start', stored=field_class.stored, field_boost=field_class.boost) + # 默认字段类型:文本字段(支持中文分词) + else: + # 替换默认的StemmingAnalyzer(英文词干)为ChineseAnalyzer(结巴中文分词) + schema_fields[index_fieldname] = TEXT( + stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True) + + # 标记主内容字段(document=True的字段,用于默认搜索) + if field_class.document is True: + content_field_name = index_fieldname + # 启用拼写检查(仅主内容字段支持) + schema_fields[index_fieldname].spelling = True + + # 检查是否有有效字段(若仅包含初始字段,说明未配置任何搜索字段) + if len(schema_fields) <= initial_key_count: + raise SearchBackendError( + "No fields were found in any search_indexes. Please correct this before attempting to search.") + + # 创建并返回Whoosh Schema + return (content_field_name, Schema(**schema_fields)) + + def update(self, index, iterable, commit=True): + """ + 更新索引:将模型对象批量添加/更新到Whoosh索引 + :param index: Haystack索引对象(对应某个模型的索引配置) + :param iterable: 模型对象迭代器(需索引的对象列表) + :param commit: 是否立即提交(此处强制提交,避免锁问题) + """ + # 若未初始化,先执行setup + if not self.setup_complete: + self.setup() + + # 刷新索引(确保获取最新状态) + self.index = self.index.refresh() + # 使用异步写入器(提高批量写入效率,避免阻塞) + writer = AsyncWriter(self.index) + + # 遍历对象,处理并写入索引 + for obj in iterable: + try: + # 准备文档数据(调用Haystack索引的full_prepare方法,处理字段值) + doc = index.full_prepare(obj) + except SkipDocument: + # 跳过无需索引的对象(如草稿文章) + self.log.debug(u"Indexing for object `%s` skipped", obj) + else: + # 转换文档值为Whoosh支持的格式(如datetime转字符串、布尔值转'true'/'false') + for key in doc: + doc[key] = self._from_python(doc[key]) + + # Whoosh 2.5.0+不支持文档级boost,删除该字段 + if 'boost' in doc: + del doc['boost'] + + try: + # 更新文档:若ID存在则更新,不存在则新增 + writer.update_document(**doc) + except Exception as e: + # 若设置为静默失败,则仅记录日志;否则抛出异常 + if not self.silently_fail: + raise + # 记录错误日志(包含对象标识,避免编码问题) + self.log.error( + u"%s while preparing object for update" % e.__class__.__name__, + exc_info=True, + extra={"data": {"index": index, "object": get_identifier(obj)}}) + + # 批量写入后强制提交(Whoosh需提交才会持久化) + if len(iterable) > 0: + writer.commit() + + def remove(self, obj_or_string, commit=True): + """ + 删除索引:从Whoosh索引中删除指定模型对象 + :param obj_or_string: 模型对象或对象唯一标识(get_identifier返回值) + :param commit: 是否立即提交(Whoosh删除后自动提交,此处参数仅为兼容) + """ + if not self.setup_complete: + self.setup() + + # 刷新索引 + self.index = self.index.refresh() + # 获取对象的唯一标识(用于Whoosh查询删除) + whoosh_id = get_identifier(obj_or_string) + + try: + # 构造查询:根据ID删除文档 + delete_query = self.parser.parse(u'%s:"%s"' % (ID, whoosh_id)) + self.index.delete_by_query(q=delete_query) + except Exception as e: + if not self.silently_fail: + raise + # 记录删除失败日志 + self.log.error( + "Failed to remove document '%s' from Whoosh: %s", + whoosh_id, e, exc_info=True) + + def clear(self, models=None, commit=True): + """ + 清空索引:删除指定模型的所有索引,或清空整个索引 + :param models: 模型列表(如[Article, Comment]),为None则清空所有 + :param commit: 是否立即提交(Whoosh删除后自动提交) + """ + if not self.setup_complete: + self.setup() + + # 刷新索引 + self.index = self.index.refresh() + + # 验证models参数是否为列表/元组 + if models is not None: + assert isinstance(models, (list, tuple)) + + try: + # 清空整个索引(效率更高:直接删除索引文件/内存存储) + if models is None: + self.delete_index() + # 仅清空指定模型的索引 + else: + models_to_delete = [] + # 遍历模型,生成模型类型查询条件(如DJANGO_CT:blog.Article) + for model in models: + models_to_delete.append(u"%s:%s" % (DJANGO_CT, get_model_ct(model))) + # 构造OR查询,删除所有匹配模型的文档 + delete_query = self.parser.parse(u" OR ".join(models_to_delete)) + self.index.delete_by_query(q=delete_query) + except Exception as e: + if not self.silently_fail: + raise + # 记录清空失败日志 + if models is not None: + self.log.error( + "Failed to clear Whoosh index of models '%s': %s", + ','.join(models_to_delete), e, exc_info=True) + else: + self.log.error("Failed to clear Whoosh index: %s", e, exc_info=True) + + def delete_index(self): + """ + 彻底删除索引:删除索引存储(文件或内存),并重新初始化 + 比clear更彻底,适用于重建索引场景 + """ + # 文件存储:删除索引目录 + if self.use_file_storage and os.path.exists(self.path): + shutil.rmtree(self.path) + # 内存存储:清空存储 + elif not self.use_file_storage: + self.storage.clean() + + # 重新初始化索引(创建新的空索引) + self.setup() + + def optimize(self): + """ + 优化索引:整理Whoosh索引文件,提高搜索效率 + Whoosh会合并小索引段,减少磁盘IO + """ + if not self.setup_complete: + self.setup() + + # 刷新并优化索引 + self.index = self.index.refresh() + self.index.optimize() + + def calculate_page(self, start_offset=0, end_offset=None): + """ + 计算分页参数:将Haystack的start/end偏移量转换为Whoosh的页码和页长 + Whoosh使用页码(1-based)和页长,而非偏移量 + :param start_offset: 起始偏移量(从0开始) + :param end_offset: 结束偏移量(不包含) + :return: (page_num, page_length):页码、页长 + """ + # 处理end_offset为0或负数的情况(避免Whoosh报错) + if end_offset is not None and end_offset <= 0: + end_offset = 1 + + # 初始化默认值 + page_num = 0 + if end_offset is None: + end_offset = 1000000 # 默认最大页长(获取所有结果) + if start_offset is None: + start_offset = 0 + + # 计算页长(end - start)和页码(start / 页长,向上取整) + page_length = end_offset - start_offset + if page_length and page_length > 0: + page_num = int(start_offset / page_length) + + # Whoosh页码为1-based,故加1 + page_num += 1 + return page_num, page_length + + @log_query + def search( + self, + query_string, + sort_by=None, + start_offset=0, + end_offset=None, + fields='', + highlight=False, + facets=None, + date_facets=None, + query_facets=None, + narrow_queries=None, + spelling_query=None, + within=None, + dwithin=None, + distance_point=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + """ + 核心搜索方法:执行查询并返回处理后的结果 + 支持分页、排序、高亮、过滤模型等功能 + :param query_string: 搜索关键词 + :param sort_by: 排序字段列表(如['-pub_time', 'title']) + :param start_offset/end_offset: 分页偏移量 + :param highlight: 是否开启结果高亮 + :param models: 限制搜索的模型列表 + :param result_class: 搜索结果类(默认SearchResult) + :return: 搜索结果字典(含results列表、hits总数、facets、拼写建议等) + """ + # 初始化检查 + if not self.setup_complete: + self.setup() + + # 空查询字符串返回空结果 + if len(query_string) == 0: + return {'results': [], 'hits': 0} + + # 转换查询字符串为Unicode(兼容Python 2) + query_string = force_str(query_string) + + # 单字符查询(非通配符)返回空结果(通常为停用词,无意义) + if len(query_string) <= 1 and query_string != u'*': + return {'results': [], 'hits': 0} + + # 处理排序:Whoosh要求所有排序字段方向一致(均升序或均降序) + reverse = False # 是否倒序(默认升序) + if sort_by is not None: + sort_by_list = [] + reverse_counter = 0 # 倒序字段计数 + + # 统计倒序字段数量 + for order_by in sort_by: + if order_by.startswith('-'): + reverse_counter += 1 + + # Whoosh不支持混合排序方向,抛出异常 + if reverse_counter and reverse_counter != len(sort_by): + raise SearchBackendError("Whoosh requires all order_by fields to use the same sort direction") + + # 提取排序字段(去掉'-'符号),确定排序方向 + for order_by in sort_by: + if order_by.startswith('-'): + sort_by_list.append(order_by[1:]) + if len(sort_by_list) == 1: + reverse = True + else: + sort_by_list.append(order_by) + if len(sort_by_list) == 1: + reverse = False + + # Whoosh仅支持单个排序字段,取第一个 + sort_by = sort_by_list[0] + + # Whoosh不支持分面搜索(facets),给出警告 + if facets is not None: + warnings.warn("Whoosh does not handle faceting.", Warning, stacklevel=2) + if date_facets is not None: + warnings.warn("Whoosh does not handle date faceting.", Warning, stacklevel=2) + if query_facets is not None: + warnings.warn("Whoosh does not handle query faceting.", Warning, stacklevel=2) + + # 处理过滤查询(narrow_queries):限制搜索结果范围 + narrowed_results = None # 过滤后的结果集 + self.index = self.index.refresh() + + # 处理模型过滤:限制仅搜索指定模型或已注册模型 + if limit_to_registered_models is None: + # 从配置获取默认值(是否仅搜索已注册模型) + limit_to_registered_models = getattr(settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + model_choices = [] + if models and len(models): + # 限制搜索指定模型(如[Article]) + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + # 限制搜索所有已注册模型(通过Haystack路由获取) + model_choices = self.build_models_list() + + # 将模型过滤添加到narrow_queries + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + # 构造OR查询:匹配任一模型类型 + model_query = ' OR '.join(['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]) + narrow_queries.add(model_query) + + # 执行过滤查询:获取符合所有narrow_queries的结果集 + narrow_searcher = None + if narrow_queries is not None: + narrow_searcher = self.index.searcher() + for nq in narrow_queries: + # 解析过滤查询并执行(获取所有匹配结果) + nq_parsed = self.parser.parse(force_str(nq)) + recent_narrowed = narrow_searcher.search(nq_parsed, limit=None) + + # 若任一过滤条件无结果,直接返回空结果 + if len(recent_narrowed) <= 0: + return {'results': [], 'hits': 0} + + # 合并过滤结果(交集) + if narrowed_results: + narrowed_results.filter(recent_narrowed) + else: + narrowed_results = recent_narrowed + + # 刷新索引,准备执行主搜索 + self.index = self.index.refresh() + + # 若索引为空,返回空结果(含拼写建议) + if not self.index.doc_count(): + spelling_suggestion = self.create_spelling_suggestion(spelling_query or query_string) if self.include_spelling else None + return {'results': [], 'hits': 0, 'spelling_suggestion': spelling_suggestion} + + # 执行主搜索 + searcher = self.index.searcher() + try: + # 解析查询字符串 + parsed_query = self.parser.parse(query_string) + except Exception: + # 无效查询(如语法错误),返回空结果 + if not self.silently_fail: + raise + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # 无效查询(如仅停用词),返回空结果 + if parsed_query is None: + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # 计算分页参数 + page_num, page_length = self.calculate_page(start_offset, end_offset) + + # 构造搜索参数 + search_kwargs = { + 'pagelen': page_length, # 页长 + 'sortedby': sort_by, # 排序字段 + 'reverse': reverse # 是否倒序 + } + # 应用过滤结果(仅返回过滤后的子集) + if narrowed_results is not None: + search_kwargs['filter'] = narrowed_results + + # 执行搜索并获取分页结果 + try: + raw_page = searcher.search_page(parsed_query, page_num, **search_kwargs) + except ValueError: + # 页码超出范围(如请求第10页但仅5页),返回空结果 + if not self.silently_fail: + raise + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # Whoosh 2.5.1+ bug:页码超出时返回错误页码,需检查 + if raw_page.pagenum < page_num: + spelling_suggestion = self.create_spelling_suggestion(spelling_query or query_string) if self.include_spelling else None + return {'results': [], 'hits': 0, 'spelling_suggestion': spelling_suggestion} + + # 处理搜索结果(转换为Haystack SearchResult,添加高亮等) + results = self._process_results( + raw_page, + highlight=highlight, + query_string=query_string, + spelling_query=spelling_query, + result_class=result_class) + + # 关闭搜索器(释放资源) + searcher.close() + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + + def more_like_this( + self, + model_instance, + additional_query_string=None, + start_offset=0, + end_offset=None, + models=None, + limit_to_registered_models=None, + result_class=None, + **kwargs): + """ + 相似结果搜索:根据指定模型对象,查找相似的文档 + 基于Whoosh的more_like_this功能,分析主内容字段的相似度 + :param model_instance: 参考模型对象(如某篇文章) + :return: 相似结果字典(结构同search方法) + """ + if not self.setup_complete: + self.setup() + + # 获取模型的实际类(排除延迟加载模型) + model_klass = model_instance._meta.concrete_model + # 主内容字段名(用于相似度分析) + field_name = self.content_field_name + # 过滤查询和结果集 + narrow_queries = set() + narrowed_results = None + self.index = self.index.refresh() + + # 处理模型过滤(同search方法) + if limit_to_registered_models is None: + limit_to_registered_models = getattr(settings, 'HAYSTACK_LIMIT_TO_REGISTERED_MODELS', True) + + model_choices = [] + if models and len(models): + model_choices = sorted(get_model_ct(model) for model in models) + elif limit_to_registered_models: + model_choices = self.build_models_list() + + # 添加模型过滤条件 + if len(model_choices) > 0: + if narrow_queries is None: + narrow_queries = set() + model_query = ' OR '.join(['%s:%s' % (DJANGO_CT, rm) for rm in model_choices]) + narrow_queries.add(model_query) + + # 添加额外过滤条件(如关键词过滤) + if additional_query_string and additional_query_string != '*': + narrow_queries.add(additional_query_string) + + # 执行过滤查询(同search方法) + narrow_searcher = None + if narrow_queries is not None: + narrow_searcher = self.index.searcher() + for nq in narrow_queries: + nq_parsed = self.parser.parse(force_str(nq)) + recent_narrowed = narrow_searcher.search(nq_parsed, limit=None) + + if len(recent_narrowed) <= 0: + return {'results': [], 'hits': 0} + + if narrowed_results: + narrowed_results.filter(recent_narrowed) + else: + narrowed_results = recent_narrowed + + # 计算分页参数 + page_num, page_length = self.calculate_page(start_offset, end_offset) + + # 刷新索引,执行相似搜索 + self.index = self.index.refresh() + raw_results = EmptyResults() # 默认空结果 + + if self.index.doc_count(): + searcher = self.index.searcher() + # 构造查询:获取参考对象的索引文档 + query = "%s:%s" % (ID, get_identifier(model_instance)) + parsed_query = self.parser.parse(query) + results = searcher.search(parsed_query) + + # 若找到参考文档,获取相似结果 + if len(results): + # 基于主内容字段查找相似文档,限制最大数量为end_offset + raw_results = results[0].more_like_this(field_name, top=end_offset) + + # 应用过滤结果 + if narrowed_results is not None and hasattr(raw_results, 'filter'): + raw_results.filter(narrowed_results) + + # 处理分页结果 + try: + raw_page = ResultsPage(raw_results, page_num, page_length) + except ValueError: + if not self.silently_fail: + raise + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # 检查页码有效性 + if raw_page.pagenum < page_num: + return {'results': [], 'hits': 0, 'spelling_suggestion': None} + + # 处理结果并关闭搜索器 + results = self._process_results(raw_page, result_class=result_class) + searcher.close() + if hasattr(narrow_searcher, 'close'): + narrow_searcher.close() + + return results + + def _process_results( + self, + raw_page, + highlight=False, + query_string='', + spelling_query=None, + result_class=None): + """ + 处理搜索结果:将Whoosh原始结果转换为Haystack SearchResult格式 + 支持高亮、字段类型转换、拼写建议等 + :param raw_page: Whoosh ResultsPage对象(分页原始结果) + :param highlight: 是否开启高亮 + :return: 处理后的结果字典 + """ + from haystack import connections # 延迟导入 + results = [] # 最终结果列表(SearchResult对象) + hits = len(raw_page) # 总命中数(当前页) + + # 结果类默认值(Haystack SearchResult) + if result_class is None: + result_class = SearchResult + + # 初始化分面和拼写建议(Whoosh不支持分面,故为空) + facets = {} + spelling_suggestion = None + # 获取Haystack统一索引和已索引模型 + unified_index = connections[self.connection_alias].get_unified_index() + indexed_models = unified_index.get_indexed_models() + + # 遍历原始结果,转换为SearchResult + for doc_offset, raw_result in enumerate(raw_page): + # 获取文档得分(相关性) + score = raw_page.score(doc_offset) or 0 + # 提取模型类型(如blog.Article)并拆分应用标签和模型名 + app_label, model_name = raw_result[DJANGO_CT].split('.') + additional_fields = {} # 额外字段(除默认字段外的其他字段) + # 加载模型类 + model = haystack_get_model(app_label, model_name) + + # 仅处理已索引的模型 + if model and model in indexed_models: + # 遍历原始结果的所有字段,转换为Python原生类型 + for key, value in raw_result.items(): + string_key = str(key) + # 获取模型对应的Haystack索引 + index = unified_index.get_index(model) + + # 若字段在索引中定义,使用索引的convert方法转换值 + if string_key in index.fields and hasattr(index.fields[string_key], 'convert'): + field = index.fields[string_key] + # 处理多值字段(如KEYWORD类型,逗号分隔字符串转列表) + if field.is_multivalued: + if value is None or len(value) == 0: + additional_fields[string_key] = [] + else: + additional_fields[string_key] = value.split(',') + else: + # 单值字段:使用索引的convert方法转换 + additional_fields[string_key] = field.convert(value) + else: + # 未定义的字段:直接转换为Python类型 + additional_fields[string_key] = self._to_python(value) + + # 删除默认字段(DJANGO_CT、DJANGO_ID),避免重复 + del additional_fields[DJANGO_CT] + del additional_fields[DJANGO_ID] + + # 处理结果高亮 + if highlight: + # 使用英文词干分析器解析查询关键词(用于高亮匹配) + sa = StemmingAnalyzer() + # 自定义高亮格式化器(标签) + formatter = WhooshHtmlFormatter('em') + # 提取查询关键词的词干(如"running"→"run") + terms = [token.text for token in sa(query_string)] + + # 对主内容字段执行高亮 + content_value = additional_fields.get(self.content_field_name, '') + whoosh_highlighted = whoosh_highlight( + content_value, + terms, + sa, + ContextFragmenter(), # 上下文片段生成器(显示关键词前后内容) + formatter + ) + # 将高亮结果添加到额外字段 + additional_fields['highlighted'] = {self.content_field_name: [whoosh_highlighted]} + + # 创建SearchResult对象并添加到结果列表 + result = result_class( + app_label, + model_name, + raw_result[DJANGO_ID], # 模型主键ID + score, + **additional_fields + ) + results.append(result) + else: + # 跳过未索引的模型,减少命中数 + hits -= 1 + + # 生成拼写建议(若开启拼写检查) + if self.include_spelling: + spelling_suggestion = self.create_spelling_suggestion(spelling_query or query_string) + + # 返回处理后的结果字典 + return { + 'results': results, + 'hits': hits, + 'facets': facets, + 'spelling_suggestion': spelling_suggestion, + } + + def create_spelling_suggestion(self, query_string): + """ + 生成拼写建议:基于Whoosh的拼写检查功能,推荐可能的正确关键词 + :param query_string: 原始查询关键词 + :return: 拼写建议字符串(如"pytho"→"python") + """ + spelling_suggestion = None + # 获取索引阅读器和拼写校正器(基于主内容字段) + reader = self.index.reader() + corrector = reader.corrector(self.content_field_name) + cleaned_query = force_str(query_string) + + # 空查询返回None + if not query_string: + return spelling_suggestion + + # 清理查询字符串:移除Whoosh保留词和字符 + for rev_word in self.RESERVED_WORDS: + cleaned_query = cleaned_query.replace(rev_word, '') + for rev_char in self.RESERVED_CHARACTERS: + cleaned_query = cleaned_query.replace(rev_char, '') + + # 拆分关键词,逐个生成建议 + query_words = cleaned_query.split() + suggested_words = [] + for word in query_words: + # 获取每个词的最佳建议(限制1个) + suggestions = corrector.suggest(word, limit=1) + if len(suggestions) > 0: + suggested_words.append(suggestions[0]) + + # 拼接建议词为字符串 + spelling_suggestion = ' '.join(suggested_words) + return spelling_suggestion + + def _from_python(self, value): + """ + Python类型转换为Whoosh支持的格式(如datetime→字符串、布尔→'true'/'false') + 参考pysolr的转换逻辑,确保兼容性 + :param value: Python原生类型值 + :return: Whoosh支持的字符串/数值类型 + """ + # 处理日期时间:转换为ISO格式字符串(Whoosh DATETIME字段支持) + if hasattr(value, 'strftime'): + # 若仅为日期(无时间),补充时间为00:00:00 + if not hasattr(value, 'hour'): + value = datetime(value.year, value.month, value.day, 0, 0, 0) + value = value.isoformat() + # 处理布尔值:转换为'true'/'false'字符串 + elif isinstance(value, bool): + value = 'true' if value else 'false' + # 处理列表/元组:转换为逗号分隔字符串(Whoosh KEYWORD字段支持) + elif isinstance(value, (list, tuple)): + value = u','.join([force_str(v) for v in value]) + # 数值类型(整数、浮点数):保持不变(Whoosh NUMERIC字段支持) + elif isinstance(value, (six.integer_types, float)): + pass + # 其他类型:转换为字符串 + else: + value = force_str(value) + return value + + def _to_python(self, value): + """ + Whoosh返回值转换为Python原生类型(如字符串→datetime、'true'→True) + 参考pysolr的转换逻辑,确保兼容性 + :param value: Whoosh返回的字符串/数值 + :return: Python原生类型值 + """ + # 处理布尔值 + if value == 'true': + return True + elif value == 'false': + return False + + # 处理日期时间字符串(匹配ISO格式) + if value and isinstance(value, six.string_types): + possible_datetime = DATETIME_REGEX.search(value) + if possible_datetime: + # 提取日期时间组件并转换为整数 + date_values = possible_datetime.groupdict() + for dk, dv in date_values.items(): + date_values[dk] = int(dv) + # 创建datetime对象 + return datetime( + date_values['year'], + date_values['month'], + date_values['day'], + date_values['hour'], + date_values['minute'], + date_values['second'] + ) + + # 尝试JSON解析(处理列表、字典等复杂类型) + try: + converted_value = json.loads(value) + # 仅保留Python内置类型(列表、元组、集合、字典、数值等) + if isinstance(converted_value, (list, tuple, set, dict, six.integer_types, float, complex)): + return converted_value + except BaseException: + # JSON解析失败(如语法错误),跳过 + pass + + # 默认返回原始值 + return value + + +class WhooshSearchQuery(BaseSearchQuery): + """ + Whoosh搜索查询类:继承自Haystack的BaseSearchQuery + 负责构建Whoosh兼容的查询字符串,处理过滤条件、排序等 + """ + def _convert_datetime(self, date): + """ + 转换日期时间为Whoosh范围查询格式(如20240520143000) + :param date: datetime/date对象 + :return: 格式化字符串 + """ + if hasattr(date, 'hour'): + # 日期时间:格式为YYYYMMDDHHMMSS + return force_str(date.strftime('%Y%m%d%H%M%S')) + else: + # 仅日期:时间部分补000000 + return force_str(date.strftime('%Y%m%d000000')) + + def clean(self, query_fragment): + """ + 清理查询片段:处理Whoosh保留词和字符,避免语法错误 + Whoosh 1.X+不支持反斜杠转义,需用引号包裹含保留字符的词 + :param query_fragment: 原始查询片段 + :return: 清理后的查询片段 + """ + words = query_fragment.split() + cleaned_words = [] + + for word in words: + # 处理保留词:转换为小写(Whoosh保留词区分大小写,小写不视为保留词) + if word in self.backend.RESERVED_WORDS: + word = word.lower() + + # 处理保留字符:若词中含保留字符,用单引号包裹 + for char in self.backend.RESERVED_CHARACTERS: + if char in word: + word = "'%s'" % word + break + + cleaned_words.append(word) + + # 拼接清理后的词为查询片段 + return ' '.join(cleaned_words) + + def build_query_fragment(self, field, filter_type, value): + """ + 构建查询片段:根据字段、过滤类型、值,生成Whoosh兼容的查询字符串 + 支持精确匹配、模糊匹配、范围查询等多种过滤类型 + :param field: 字段名(如'title'、'content') + :param filter_type: 过滤类型(如'exact'、'contains'、'range') + :param value: 过滤值(如'Python'、[2024-01-01, 2024-05-01]) + :return: 构建后的查询片段字符串 + """ + from haystack import connections # 延迟导入 + query_frag = '' # 最终查询片段 + is_datetime = False # 是否为日期时间类型 + + # 处理非InputType值(如普通字符串、列表、datetime对象) + if not hasattr(value, 'input_type_name'): + # 处理ValuesListQuerySet:转换为列表 + if hasattr(value, 'values_list'): + value = list(value) + # 检查是否为日期时间类型 + if hasattr(value, 'strftime'): + is_datetime = True + # 字符串值:默认使用Clean输入类型(清理特殊字符) + if isinstance(value, six.string_types) and value != ' ': + value = Clean(value) + # 其他类型:使用PythonData输入类型(直接传递值) + else: + value = PythonData(value) + + # 准备查询值(调用InputType的prepare方法,如Exact会添加引号) + prepared_value = value.prepare(self) + + # 转换值为Whoosh支持的格式(如列表→逗号分隔字符串) + if not isinstance(prepared_value, (set, list, tuple)): + prepared_value = self.backend._from_python(prepared_value) + + # 处理"content"字段(Haystack保留字段,代表"所有字段",无需指定字段名) + if field == 'content': + index_fieldname = '' + else: + # 获取字段在索引中的实际名称(支持字段别名) + index_fieldname = u'%s:' % connections[self._using].get_unified_index().get_index_fieldname(field) + + # Whoosh查询模板:不同过滤类型对应的查询格式 + filter_types = { + 'content': '%s', # 全文搜索(无字段名) + 'contains': '*%s*', # 包含匹配(如*Python*) + 'endswith': "*%s", # 后缀匹配(如*thon) + 'startswith': "%s*", # 前缀匹配(如Pyth*) + 'exact': '%s', # 精确匹配(如"Python") + 'gt': "{%s to}", # 大于(如{20240101 to}) + 'gte': "[%s to]", # 大于等于(如[20240101 to]) + 'lt': "{to %s}", # 小于(如{to 20240101}) + 'lte': "[to %s]", # 小于等于(如[to 20240101]) + 'fuzzy': u'%s~', # 模糊匹配(如Pytho~) + } + + # 处理无需后处理的值(如Raw输入类型,直接使用原始值) + if value.post_process is False: + query_frag = prepared_value + else: + # 处理文本匹配类过滤类型(content、contains、startswith等) + if filter_type in ['content', 'contains', 'startswith', 'endswith', 'fuzzy']: + # 精确匹配输入类型(Exact):直接使用准备好的值(含引号) + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + # 拆分值为多个术语(如空格分隔的关键词) + terms = [] + if isinstance(prepared_value, six.string_types): + possible_values = prepared_value.split(' ') + else: + # 非字符串值(如datetime):转换为Whoosh格式 + if is_datetime is True: + prepared_value = self._convert_datetime(prepared_value) + possible_values = [prepared_value] + + # 为每个术语应用过滤模板 + for possible_value in possible_values: + term = filter_types[filter_type] % self.backend._from_python(possible_value) + terms.append(term) + + # 拼接术语(单个术语直接返回,多个术语用AND连接并加括号) + if len(terms) == 1: + query_frag = terms[0] + else: + query_frag = u"(%s)" % " AND ".join(terms) + # 处理IN过滤类型(匹配多个值中的任一) + elif filter_type == 'in': + in_options = [] + for possible_value in prepared_value: + is_dt = False + # 检查是否为日期时间类型 + if hasattr(possible_value, 'strftime'): + is_dt = True + # 转换值为Whoosh格式 + pv = self.backend._from_python(possible_value) + if is_dt is True: + pv = self._convert_datetime(pv) + # 字符串值加引号,其他值直接使用 + if isinstance(pv, six.string_types) and not is_dt: + in_options.append('"%s"' % pv) + else: + in_options.append('%s' % pv) + # 用OR连接所有选项并加括号(如("a" OR "b" OR "c")) + query_frag = "(%s)" % " OR ".join(in_options) + # 处理RANGE过滤类型(范围匹配) + elif filter_type == 'range': + # 提取范围的起始和结束值 + start = self.backend._from_python(prepared_value[0]) + end = self.backend._from_python(prepared_value[1]) + # 转换日期时间类型为Whoosh格式 + if hasattr(prepared_value[0], 'strftime'): + start = self._convert_datetime(start) + if hasattr(prepared_value[1], 'strftime'): + end = self._convert_datetime(end) + # 范围查询格式(如[20240101 to 20240501]) + query_frag = u"[%s to %s]" % (start, end) + # 处理EXACT过滤类型(精确匹配) + elif filter_type == 'exact': + # 精确匹配输入类型:直接使用准备好的值 + if value.input_type_name == 'exact': + query_frag = prepared_value + else: + # 其他输入类型:转换为Exact格式(加引号) + prepared_value = Exact(prepared_value).prepare(self) + query_frag = filter_types[filter_type] % prepared_value + # 其他过滤类型(如gt、gte等) + else: + # 日期时间类型转换为Whoosh格式 + if is_datetime is True: + prepared_value = self._convert_datetime(prepared_value) + # 应用过滤模板 + query_frag = filter_types[filter_type] % prepared_value + + # 非Raw输入类型:若查询片段无括号,添加括号(确保逻辑正确) + if len(query_frag) and not isinstance(value, Raw): + if not query_frag.startswith('(') and not query_frag.endswith(')'): + query_frag = "(%s)" % query_frag + + # 拼接字段名和查询片段(如"title:(Python)") + return u"%s%s" % (index_fieldname, query_frag) + + +class WhooshEngine(BaseEngine): + """ + Whoosh搜索引擎类:继承自Haystack的BaseEngine + 绑定Whoosh搜索后端和查询类,供Haystack调用 + """ + backend = WhooshSearchBackend # 关联Whoosh搜索后端 + query = WhooshSearchQuery # 关联Whoosh搜索查询 \ No newline at end of file -- 2.34.1 From 2f9d845beab960c531bf54749e89c7224c5053a5 Mon Sep 17 00:00:00 2001 From: LY Date: Sat, 18 Oct 2025 18:24:45 +0800 Subject: [PATCH 10/10] =?UTF-8?q?ly=5F=E7=AC=AC=E4=BA=94=E5=91=A8=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/wsgi.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/wsgi.py diff --git a/src/wsgi.py b/src/wsgi.py new file mode 100644 index 0000000..103bea5 --- /dev/null +++ b/src/wsgi.py @@ -0,0 +1,28 @@ +""" +WSGI config for djangoblog project. +Django博客项目的WSGI配置文件 +WSGI(Web Server Gateway Interface)是Web服务器与Python Web应用之间的通信标准 +负责将Web服务器(如Nginx、Apache)接收的HTTP请求转发给Django应用,再将应用响应返回给服务器 + +It exposes the WSGI callable as a module-level variable named ``application``. +该文件将WSGI可调用对象(处理请求的核心入口)暴露为模块级变量,命名为`application` +Web服务器通过调用这个`application`对象与Django应用交互 + +For more information on this file, see +https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ +""" + +# 导入Python内置的os模块,用于读取环境变量、处理路径等 +import os + +# 导入Django的WSGI应用生成器:根据项目配置创建WSGI可调用对象 +from django.core.wsgi import get_wsgi_application + +# 设置Django项目的配置模块环境变量 +# 告诉Django使用哪个settings文件(此处为项目根目录下的djangoblog.settings) +# 生产环境中可通过服务器配置修改该环境变量,切换不同配置(如生产/测试配置) +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") + +# 创建WSGI应用对象:Django根据上述配置生成处理HTTP请求的核心入口 +# Web服务器(如Gunicorn、uWSGI)会加载这个`application`对象来运行项目 +application = get_wsgi_application() \ No newline at end of file -- 2.34.1