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
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
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
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
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
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)
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
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
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%(tag)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
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