diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1d3ce46 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..90aac4e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..8306744 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/zyd2025.iml b/.idea/zyd2025.iml new file mode 100644 index 0000000..78f4427 --- /dev/null +++ b/.idea/zyd2025.iml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/README.md b/src/README.md index de1958f..0da30cf 100644 --- a/src/README.md +++ b/src/README.md @@ -146,7 +146,11 @@ python manage.py runserver ## 🙏 鸣谢 +<<<<<<< HEAD +特别感谢 **JetBrains** 为本项目提供的免费开源许可证。gs ls136 syj zyd164 +======= 特别感谢 **JetBrains** 为本项目提供的免费开源许可证。gs ls syj143 zyd164 +>>>>>>> f2461baa4bb00608551433ffbaeb7a5d4a615925

diff --git a/src/blog/views.py b/src/blog/views.py index d5dc7ec..c694ced 100644 --- a/src/blog/views.py +++ b/src/blog/views.py @@ -1,379 +1,109 @@ -import logging -import os -import uuid +# hooks.py - 插件钩子管理系统 +# 提供插件钩子的注册和执行功能,是插件系统的核心组件 -from django.conf import settings -from django.core.paginator import Paginator -from django.http import HttpResponse, HttpResponseForbidden -from django.shortcuts import get_object_or_404 -from django.shortcuts import render -from django.templatetags.static import static -from django.utils import timezone -from django.utils.translation import gettext_lazy as _ -from django.views.decorators.csrf import csrf_exempt -from django.views.generic.detail import DetailView -from django.views.generic.list import ListView -from haystack.views import SearchView +import logging # 导入日志模块,用于记录钩子执行过程中的信息 -from blog.models import Article, Category, LinkShowType, Links, Tag -from comments.forms import CommentForm -from djangoblog.plugin_manage import hooks -from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME -from djangoblog.utils import cache, get_blog_setting, get_sha256 +logger = logging.getLogger(__name__) # 创建当前模块的日志记录器 -logger = logging.getLogger(__name__) +# 全局钩子注册表,存储所有已注册的钩子回调函数 +# 数据结构为字典:{钩子名称: [回调函数列表]} +_hooks = {} -class ArticleListView(ListView): - # template_name属性用于指定使用哪个模板进行渲染 - template_name = 'blog/article_index.html' - - # context_object_name属性用于给上下文变量取名(在模板中使用该名字) - context_object_name = 'article_list' - - # 页面类型,分类目录或标签列表等 - page_type = '' - paginate_by = settings.PAGINATE_BY - page_kwarg = 'page' - link_type = LinkShowType.L - - def get_view_cache_key(self): - return self.request.get['pages'] - - @property - def page_number(self): - page_kwarg = self.page_kwarg - page = self.kwargs.get( - page_kwarg) or self.request.GET.get(page_kwarg) or 1 - return page - - def get_queryset_cache_key(self): - """ - 子类重写.获得queryset的缓存key - """ - raise NotImplementedError() - - def get_queryset_data(self): - """ - 子类重写.获取queryset的数据 - """ - raise NotImplementedError() - - def get_queryset_from_cache(self, cache_key): - ''' - 缓存页面数据 - :param cache_key: 缓存key - :return: - ''' - value = cache.get(cache_key) - if value: - logger.info('get view cache.key:{key}'.format(key=cache_key)) - return value - else: - article_list = self.get_queryset_data() - cache.set(cache_key, article_list) - logger.info('set view cache.key:{key}'.format(key=cache_key)) - return article_list - - def get_queryset(self): - ''' - 重写默认,从缓存获取数据 - :return: - ''' - key = self.get_queryset_cache_key() - value = self.get_queryset_from_cache(key) - return value - - def get_context_data(self, **kwargs): - kwargs['linktype'] = self.link_type - return super(ArticleListView, self).get_context_data(**kwargs) - - -class IndexView(ArticleListView): - ''' - 首页 - ''' - # 友情链接类型 - link_type = LinkShowType.I - - def get_queryset_data(self): - article_list = Article.objects.filter(type='a', status='p') - return article_list - - def get_queryset_cache_key(self): - cache_key = 'index_{page}'.format(page=self.page_number) - return cache_key - - -class ArticleDetailView(DetailView): - ''' - 文章详情页面 - ''' - template_name = 'blog/article_detail.html' - model = Article - pk_url_kwarg = 'article_id' - context_object_name = "article" - - def get_context_data(self, **kwargs): - comment_form = CommentForm() - - article_comments = self.object.comment_list() - parent_comments = article_comments.filter(parent_comment=None) - blog_setting = get_blog_setting() - paginator = Paginator(parent_comments, blog_setting.article_comment_count) - page = self.request.GET.get('comment_page', '1') - if not page.isnumeric(): - page = 1 - else: - page = int(page) - if page < 1: - page = 1 - if page > paginator.num_pages: - page = paginator.num_pages - - p_comments = paginator.page(page) - next_page = p_comments.next_page_number() if p_comments.has_next() else None - prev_page = p_comments.previous_page_number() if p_comments.has_previous() else None - - if next_page: - kwargs[ - 'comment_next_page_url'] = self.object.get_absolute_url() + f'?comment_page={next_page}#commentlist-container' - if prev_page: - kwargs[ - 'comment_prev_page_url'] = self.object.get_absolute_url() + f'?comment_page={prev_page}#commentlist-container' - kwargs['form'] = comment_form - kwargs['article_comments'] = article_comments - kwargs['p_comments'] = p_comments - kwargs['comment_count'] = len( - article_comments) if article_comments else 0 - - kwargs['next_article'] = self.object.next_article - kwargs['prev_article'] = self.object.prev_article - - context = super(ArticleDetailView, self).get_context_data(**kwargs) - article = self.object - # Action Hook, 通知插件"文章详情已获取" - hooks.run_action('after_article_body_get', article=article, request=self.request) - # # Filter Hook, 允许插件修改文章正文 - article.body = hooks.apply_filters(ARTICLE_CONTENT_HOOK_NAME, article.body, article=article, - request=self.request) - - return context - - -class CategoryDetailView(ArticleListView): - ''' - 分类目录列表 - ''' - page_type = "分类目录归档" - - def get_queryset_data(self): - slug = self.kwargs['category_name'] - category = get_object_or_404(Category, slug=slug) - - categoryname = category.name - self.categoryname = categoryname - categorynames = list( - map(lambda c: c.name, category.get_sub_categorys())) - article_list = Article.objects.filter( - category__name__in=categorynames, status='p') - return article_list - - def get_queryset_cache_key(self): - slug = self.kwargs['category_name'] - category = get_object_or_404(Category, slug=slug) - categoryname = category.name - self.categoryname = categoryname - cache_key = 'category_list_{categoryname}_{page}'.format( - categoryname=categoryname, page=self.page_number) - return cache_key - - def get_context_data(self, **kwargs): - - categoryname = self.categoryname - try: - categoryname = categoryname.split('/')[-1] - except BaseException: - pass - kwargs['page_type'] = CategoryDetailView.page_type - kwargs['tag_name'] = categoryname - return super(CategoryDetailView, self).get_context_data(**kwargs) - - -class AuthorDetailView(ArticleListView): - ''' - 作者详情页 - ''' - page_type = '作者文章归档' - - def get_queryset_cache_key(self): - from uuslug import slugify - author_name = slugify(self.kwargs['author_name']) - cache_key = 'author_{author_name}_{page}'.format( - author_name=author_name, page=self.page_number) - return cache_key - - def get_queryset_data(self): - author_name = self.kwargs['author_name'] - article_list = Article.objects.filter( - author__username=author_name, type='a', status='p') - return article_list - - def get_context_data(self, **kwargs): - author_name = self.kwargs['author_name'] - kwargs['page_type'] = AuthorDetailView.page_type - kwargs['tag_name'] = author_name - return super(AuthorDetailView, self).get_context_data(**kwargs) - - -class TagDetailView(ArticleListView): - ''' - 标签列表页面 - ''' - page_type = '分类标签归档' - - def get_queryset_data(self): - slug = self.kwargs['tag_name'] - tag = get_object_or_404(Tag, slug=slug) - tag_name = tag.name - self.name = tag_name - article_list = Article.objects.filter( - tags__name=tag_name, type='a', status='p') - return article_list - - def get_queryset_cache_key(self): - slug = self.kwargs['tag_name'] - tag = get_object_or_404(Tag, slug=slug) - tag_name = tag.name - self.name = tag_name - cache_key = 'tag_{tag_name}_{page}'.format( - tag_name=tag_name, page=self.page_number) - return cache_key - - def get_context_data(self, **kwargs): - # tag_name = self.kwargs['tag_name'] - tag_name = self.name - kwargs['page_type'] = TagDetailView.page_type - kwargs['tag_name'] = tag_name - return super(TagDetailView, self).get_context_data(**kwargs) - - -class ArchivesView(ArticleListView): - ''' - 文章归档页面 - ''' - page_type = '文章归档' - paginate_by = None - page_kwarg = None - template_name = 'blog/article_archives.html' - - def get_queryset_data(self): - return Article.objects.filter(status='p').all() +def register(hook_name: str, callback: callable): + """ + 注册一个钩子回调函数 - def get_queryset_cache_key(self): - cache_key = 'archives' - return cache_key + 参数: + hook_name (str): 钩子名称,用于标识特定的钩子事件 + callback (callable): 回调函数,当钩子被触发时执行的函数 + 功能: + 将回调函数添加到指定钩子的回调列表中 + 如果钩子名称不存在,则创建新的钩子条目 + """ + # 检查钩子名称是否已存在于钩子注册表中 + if hook_name not in _hooks: + # 如果不存在,则创建一个新的空列表用于存储回调函数 + _hooks[hook_name] = [] -class LinkListView(ListView): - model = Links - template_name = 'blog/links_list.html' + # 将回调函数添加到对应钩子的回调列表中 + _hooks[hook_name].append(callback) - def get_queryset(self): - return Links.objects.filter(is_enable=True) + # 记录调试日志,显示已注册的钩子和回调函数名称 + logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") -class EsSearchView(SearchView): - def get_context(self): - paginator, page = self.build_page() - context = { - "query": self.query, - "form": self.form, - "page": page, - "paginator": paginator, - "suggestion": None, - } - if hasattr(self.results, "query") and self.results.query.backend.include_spelling: - context["suggestion"] = self.results.query.get_spelling_suggestion() - context.update(self.extra_context()) +def run_action(hook_name: str, *args, **kwargs): + """ + 执行一个 Action Hook(动作钩子) - return context + Action Hook 特点: + - 不需要返回值 + - 按顺序执行所有注册到该钩子上的回调函数 + - 通常用于在特定事件发生时执行副作用操作 + 参数: + hook_name (str): 要执行的钩子名称 + *args: 传递给回调函数的位置参数 + **kwargs: 传递给回调函数的关键字参数 -@csrf_exempt -def fileupload(request): + 功能: + 依次执行所有注册到指定钩子上的回调函数 + 每个回调函数独立执行,一个回调函数的异常不会影响其他回调函数的执行 """ - 该方法需自己写调用端来上传图片,该方法仅提供图床功能 - :param request: - :return: + # 检查指定的钩子是否存在已注册的回调函数 + if hook_name in _hooks: + # 记录调试日志,显示正在执行的动作钩子 + logger.debug(f"Running action hook '{hook_name}'") + + # 遍历并执行所有注册到该钩子的回调函数 + for callback in _hooks[hook_name]: + try: + # 执行回调函数,传递参数 + callback(*args, **kwargs) + except Exception as e: + # 如果回调函数执行出错,记录错误日志但继续执行其他回调函数 + logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + + +def apply_filters(hook_name: str, value, *args, **kwargs): """ - if request.method == 'POST': - sign = request.GET.get('sign', None) - if not sign: - return HttpResponseForbidden() - if not sign == get_sha256(get_sha256(settings.SECRET_KEY)): - return HttpResponseForbidden() - response = [] - for filename in request.FILES: - timestr = timezone.now().strftime('%Y/%m/%d') - imgextensions = ['jpg', 'png', 'jpeg', 'bmp'] - fname = u''.join(str(filename)) - isimage = len([i for i in imgextensions if fname.find(i) >= 0]) > 0 - base_dir = os.path.join(settings.STATICFILES, "files" if not isimage else "image", timestr) - if not os.path.exists(base_dir): - os.makedirs(base_dir) - savepath = os.path.normpath(os.path.join(base_dir, f"{uuid.uuid4().hex}{os.path.splitext(filename)[-1]}")) - if not savepath.startswith(base_dir): - return HttpResponse("only for post") - with open(savepath, 'wb+') as wfile: - for chunk in request.FILES[filename].chunks(): - wfile.write(chunk) - if isimage: - from PIL import Image - image = Image.open(savepath) - image.save(savepath, quality=20, optimize=True) - url = static(savepath) - response.append(url) - return HttpResponse(response) - - else: - return HttpResponse("only for post") - - -def page_not_found_view( - request, - exception, - template_name='blog/error_page.html'): - if exception: - logger.error(exception) - url = request.get_full_path() - return render(request, - template_name, - {'message': _('Sorry, the page you requested is not found, please click the home page to see other?'), - 'statuscode': '404'}, - status=404) - - -def server_error_view(request, template_name='blog/error_page.html'): - return render(request, - template_name, - {'message': _('Sorry, the server is busy, please click the home page to see other?'), - 'statuscode': '500'}, - status=500) - - -def permission_denied_view( - request, - exception, - template_name='blog/error_page.html'): - if exception: - logger.error(exception) - return render( - request, template_name, { - 'message': _('Sorry, you do not have permission to access this page?'), - 'statuscode': '403'}, status=403) - - -def clean_cache_view(request): - cache.clear() - return HttpResponse('ok') + 执行一个 Filter Hook(过滤器钩子) + + Filter Hook 特点: + - 需要处理并返回一个值 + - 将值依次传递给所有注册的回调函数进行处理 + - 每个回调函数的返回值作为下一个回调函数的输入 + + 参数: + hook_name (str): 要执行的钩子名称 + value: 需要被处理的初始值 + *args: 传递给回调函数的位置参数 + **kwargs: 传递给回调函数的关键字参数 + + 返回: + 处理后的最终值,经过所有回调函数处理的结果 + + 功能: + 将初始值依次传递给所有注册到指定钩子的回调函数 + 每个回调函数处理值并返回处理结果,作为下一个回调函数的输入 + 如果某个回调函数执行出错,记录错误但继续执行其他回调函数 + """ + # 检查指定的钩子是否存在已注册的回调函数 + if hook_name in _hooks: + # 记录调试日志,显示正在应用的过滤器钩子 + logger.debug(f"Applying filter hook '{hook_name}'") + + # 遍历所有注册到该钩子的回调函数 + for callback in _hooks[hook_name]: + try: + # 将当前值传递给回调函数处理,并将返回值作为新的值 + # 实现链式处理:value = callback3(callback2(callback1(value))) + value = callback(value, *args, **kwargs) + except Exception as e: + # 如果回调函数执行出错,记录错误日志但继续执行其他回调函数 + logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + + # 返回经过所有回调函数处理后的最终值 + return value diff --git a/src/deploy/docker-compose/docker-compose.es.yml b/src/deploy/docker-compose/docker-compose.es.yml index 83e35ff..e95c35a 100644 --- a/src/deploy/docker-compose/docker-compose.es.yml +++ b/src/deploy/docker-compose/docker-compose.es.yml @@ -1,48 +1,63 @@ -version: '3' +# docker-compose.es.yml - Docker Compose 配置文件,用于定义和运行多容器 Docker 应用程序 +# 此配置文件包含了 Elasticsearch、Kibana、DjangoBlog、MySQL 和 Memcached 服务 +version: '3' # 指定 Docker Compose 文件格式版本为 3 + +# 定义服务部分 services: + # Elasticsearch 服务配置 es: - image: liangliangyy/elasticsearch-analysis-ik:8.6.1 - container_name: es - restart: always + image: liangliangyy/elasticsearch-analysis-ik:8.6.1 # 使用包含 IK 分词器的 Elasticsearch 镜像 + container_name: es # 指定容器名称为 es + restart: always # 设置容器重启策略:总是重启 environment: - - discovery.type=single-node - - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + - discovery.type=single-node # 设置为单节点发现模式,适用于开发环境 + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" # 设置 JVM 堆内存大小,初始和最大堆都为 512MB ports: - - 9200:9200 + - 9200:9200 # 映射端口:主机端口 9200 映射到容器端口 9200 volumes: + # 挂载数据卷:将主机的 ./bin/datas/es/ 目录映射到容器的数据目录 - ./bin/datas/es/:/usr/share/elasticsearch/data/ + # Kibana 服务配置(Elasticsearch 的可视化工具) kibana: - image: kibana:8.6.1 - restart: always - container_name: kibana + image: kibana:8.6.1 # 使用官方 Kibana 镜像版本 8.6.1 + restart: always # 设置容器重启策略:总是重启 + container_name: kibana # 指定容器名称为 kibana ports: - - 5601:5601 + - 5601:5601 # 映射端口:主机端口 5601 映射到容器端口 5601(Kibana 默认端口) environment: + # 配置 Kibana 连接的 Elasticsearch 地址 - ELASTICSEARCH_HOSTS=http://es:9200 + # DjangoBlog 应用服务配置 djangoblog: - build: . - restart: always + build: . # 从当前目录构建 Docker 镜像 + restart: always # 设置容器重启策略:总是重启 + # 容器启动时执行的命令:运行 docker_start.sh 脚本 command: bash -c 'sh /code/djangoblog/bin/docker_start.sh' ports: - - "8000:8000" + - "8000:8000" # 映射端口:主机端口 8000 映射到容器端口 8000(Django 默认端口) volumes: + # 挂载静态文件目录:将主机的 collectedstatic 目录映射到容器内的静态文件目录 - ./collectedstatic:/code/djangoblog/collectedstatic + # 挂载上传文件目录:将主机的 uploads 目录映射到容器内的上传文件目录 - ./uploads:/code/djangoblog/uploads environment: - - DJANGO_MYSQL_DATABASE=djangoblog - - DJANGO_MYSQL_USER=root - - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E - - DJANGO_MYSQL_HOST=db - - DJANGO_MYSQL_PORT=3306 - - DJANGO_MEMCACHED_LOCATION=memcached:11211 - - DJANGO_ELASTICSEARCH_HOST=es:9200 + # 配置 Django 应用连接 MySQL 数据库的环境变量 + - DJANGO_MYSQL_DATABASE=djangoblog # 数据库名称 + - DJANGO_MYSQL_USER=root # 数据库用户名 + - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E # 数据库密码 + - DJANGO_MYSQL_HOST=db # 数据库主机地址 + - DJANGO_MYSQL_PORT=3306 # 数据库端口 + # 配置 Django 应用连接 Memcached 的环境变量 + - DJANGO_MEMCACHED_LOCATION=memcached:11211 # Memcached 地址和端口 + # 配置 Django 应用连接 Elasticsearch 的环境变量 + - DJANGO_ELASTICSEARCH_HOST=es:9200 # Elasticsearch 地址和端口 links: - - db - - memcached + # 链接到其他服务容器(已弃用,建议使用 networks) + - db # 链接到 db 服务 + - memcached # 链接到 memcached 服务 depends_on: - - db - container_name: djangoblog - + - db # 指定依赖关系:djangoblog 服务依赖于 db 服务,db 启动后才会启动 djangoblog + container_name: djangoblog # 指定容器名称为 djangoblog diff --git a/src/deploy/docker-compose/docker-compose.yml b/src/deploy/docker-compose/docker-compose.yml index 9609af3..25facd5 100644 --- a/src/deploy/docker-compose/docker-compose.yml +++ b/src/deploy/docker-compose/docker-compose.yml @@ -1,60 +1,82 @@ -version: '3' +# docker-compose.yml - Docker Compose 配置文件,用于定义和运行多容器 Docker 应用程序 +# 此配置文件包含了 MySQL、Redis、DjangoBlog、Nginx 等服务 +version: '3' # 指定 Docker Compose 文件格式版本为 3 + +# 定义服务部分 services: + # MySQL 数据库服务配置 db: - image: mysql:latest - restart: always + image: mysql:latest # 使用最新版本的 MySQL 官方镜像 + restart: always # 设置容器重启策略:总是重启 environment: - - MYSQL_DATABASE=djangoblog - - MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E + - MYSQL_DATABASE=djangoblog # 创建数据库时默认创建的数据库名称 + - MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E # 设置 MySQL root 用户密码 ports: - - 3306:3306 + - 3306:3306 # 映射端口:主机端口 3306 映射到容器端口 3306(MySQL 默认端口) volumes: + # 挂载数据卷:将主机的 ./bin/datas/mysql/ 目录映射到容器的 MySQL 数据目录 + # 用于持久化存储数据库数据,避免容器删除后数据丢失 - ./bin/datas/mysql/:/var/lib/mysql depends_on: - - redis - container_name: db + - redis # 指定依赖关系:db 服务依赖于 redis 服务 + container_name: db # 指定容器名称为 db + # DjangoBlog 应用服务配置 djangoblog: build: - context: ../../ - restart: always + context: ../../ # 指定构建上下文路径为上级目录的上级目录 + restart: always # 设置容器重启策略:总是重启 + # 容器启动时执行的命令:运行 docker_start.sh 脚本启动 Django 应用 command: bash -c 'sh /code/djangoblog/bin/docker_start.sh' ports: - - "8000:8000" + - "8000:8000" # 映射端口:主机端口 8000 映射到容器端口 8000(Django 默认端口) volumes: + # 挂载静态文件目录:将主机的 collectedstatic 目录映射到容器内的静态文件目录 - ./collectedstatic:/code/djangoblog/collectedstatic + # 挂载日志目录:将主机的 logs 目录映射到容器内的日志目录 - ./logs:/code/djangoblog/logs + # 挂载上传文件目录:将主机的 uploads 目录映射到容器内的上传文件目录 - ./uploads:/code/djangoblog/uploads environment: - - DJANGO_MYSQL_DATABASE=djangoblog - - DJANGO_MYSQL_USER=root - - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E - - DJANGO_MYSQL_HOST=db - - DJANGO_MYSQL_PORT=3306 - - DJANGO_REDIS_URL=redis:6379 + # 配置 Django 应用连接 MySQL 数据库的环境变量 + - DJANGO_MYSQL_DATABASE=djangoblog # 数据库名称 + - DJANGO_MYSQL_USER=root # 数据库用户名 + - DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E # 数据库密码 + - DJANGO_MYSQL_HOST=db # 数据库主机地址(对应 db 服务) + - DJANGO_MYSQL_PORT=3306 # 数据库端口 + # 配置 Django 应用连接 Redis 的环境变量 + - DJANGO_REDIS_URL=redis:6379 # Redis 地址和端口(对应 redis 服务) links: - - db - - redis + # 链接到其他服务容器(links 已弃用,建议使用 networks 和 depends_on) + - db # 链接到 db 服务 + - redis # 链接到 redis 服务 depends_on: - - db - container_name: djangoblog + - db # 指定依赖关系:djangoblog 服务依赖于 db 服务,db 启动后才会启动 djangoblog + container_name: djangoblog # 指定容器名称为 djangoblog + + # Nginx Web 服务器服务配置 nginx: - restart: always - image: nginx:latest + restart: always # 设置容器重启策略:总是重启 + image: nginx:latest # 使用最新版本的 Nginx 官方镜像 ports: - - "80:80" - - "443:443" + - "80:80" # 映射 HTTP 端口:主机端口 80 映射到容器端口 80 + - "443:443" # 映射 HTTPS 端口:主机端口 443 映射到容器端口 443 volumes: + # 挂载 Nginx 配置文件:将主机的 nginx.conf 映射到容器内的配置文件位置 - ./bin/nginx.conf:/etc/nginx/nginx.conf + # 挂载静态文件目录:将主机的 collectedstatic 目录映射到容器内的静态文件目录 + # 使 Nginx 可以直接提供静态文件服务 - ./collectedstatic:/code/djangoblog/collectedstatic links: - - djangoblog:djangoblog - container_name: nginx + # 链接到 djangoblog 服务容器 + - djangoblog:djangoblog # 链接到 djangoblog 服务,并设置别名为 djangoblog + container_name: nginx # 指定容器名称为 nginx + # Redis 缓存服务配置 redis: - restart: always - image: redis:latest - container_name: redis + restart: always # 设置容器重启策略:总是重启 + image: redis:latest # 使用最新版本的 Redis 官方镜像 + container_name: redis # 指定容器名称为 redis ports: - - "6379:6379" + - "6379:6379" # 映射端口:主机端口 6379 映射到容器端口 6379(Redis 默认端口) diff --git a/src/deploy/entrypoint.sh b/src/deploy/entrypoint.sh index 2fb6491..e6a2950 100644 --- a/src/deploy/entrypoint.sh +++ b/src/deploy/entrypoint.sh @@ -1,31 +1,40 @@ #!/usr/bin/env bash -NAME="djangoblog" -DJANGODIR=/code/djangoblog -USER=root -GROUP=root -NUM_WORKERS=1 -DJANGO_WSGI_MODULE=djangoblog.wsgi +# 指定脚本使用 bash 解释器执行 +# 定义环境变量 +NAME="djangoblog" # 应用名称 +DJANGODIR=/code/djangoblog # Django 项目目录路径 +USER=root # 运行进程的用户 +GROUP=root # 运行进程的用户组 +NUM_WORKERS=1 # Gunicorn 工作进程数量 +DJANGO_WSGI_MODULE=djangoblog.wsgi # Django WSGI 模块路径 +# 输出启动信息,显示当前运行用户 echo "Starting $NAME as `whoami`" +# 切换到 Django 项目目录 cd $DJANGODIR +# 设置 Python 路径,将项目目录添加到 PYTHONPATH 环境变量中 export PYTHONPATH=$DJANGODIR:$PYTHONPATH -python manage.py makemigrations && \ - python manage.py migrate && \ - python manage.py collectstatic --noinput && \ - python manage.py compress --force && \ - python manage.py build_index && \ - python manage.py compilemessages || exit 1 +# 执行 Django 管理命令,使用逻辑与运算符 (&&) 确保每一步都成功执行 +# 如果任何一步失败,使用 || exit 1 确保脚本退出 +python manage.py makemigrations && \ # 创建数据库迁移文件 + python manage.py migrate && \ # 执行数据库迁移 + python manage.py collectstatic --noinput && \ # 收集静态文件,不提示输入 + python manage.py compress --force && \ # 强制压缩静态文件(Django Compressor) + python manage.py build_index && \ # 构建搜索索引(可能用于 Elasticsearch) + python manage.py compilemessages || exit 1 # 编译国际化消息文件 +# 使用 Gunicorn 启动 Django 应用 +# exec 命令会替换当前 shell 进程,使 Gunicorn 成为容器的主进程 exec gunicorn ${DJANGO_WSGI_MODULE}:application \ ---name $NAME \ ---workers $NUM_WORKERS \ ---user=$USER --group=$GROUP \ ---bind 0.0.0.0:8000 \ ---log-level=debug \ ---log-file=- \ ---worker-class gevent \ ---threads 4 +--name $NAME \ # 设置进程名称 +--workers $NUM_WORKERS \ # 设置工作进程数量 +--user=$USER --group=$GROUP \ # 设置运行进程的用户和组 +--bind 0.0.0.0:8000 \ # 绑定地址和端口(监听所有网络接口的 8000 端口) +--log-level=debug \ # 设置日志级别为 debug +--log-file=- \ # 将日志输出到标准输出(控制台) +--worker-class gevent \ # 使用 gevent 作为工作进程类(异步 workers) +--threads 4 # 每个工作进程使用的线程数 diff --git a/src/deploy/k8s/configmap.yaml b/src/deploy/k8s/configmap.yaml index 835d4ad..9ce20a3 100644 --- a/src/deploy/k8s/configmap.yaml +++ b/src/deploy/k8s/configmap.yaml @@ -1,119 +1,196 @@ -apiVersion: v1 -kind: ConfigMap +# configmap.yaml - Kubernetes ConfigMap 资源配置文件 +# 用于存储非机密性的配置数据,以键值对的形式保存配置信息 + +# 第一个 ConfigMap:用于配置 Nginx 服务器 +apiVersion: v1 # Kubernetes API 版本 +kind: ConfigMap # 资源类型为 ConfigMap metadata: - name: web-nginx-config - namespace: djangoblog + name: web-nginx-config # ConfigMap 名称 + namespace: djangoblog # 所属命名空间 data: + # Nginx 主配置文件内容 nginx.conf: | + # 定义运行 nginx 的用户 user nginx; + # 工作进程数,auto 表示自动根据 CPU 核心数设置 worker_processes auto; + # 错误日志文件路径和级别 error_log /var/log/nginx/error.log notice; + # nginx 进程 PID 文件路径 pid /var/run/nginx.pid; + # 事件模块配置 events { + # 单个工作进程的最大并发连接数 worker_connections 1024; + # 允许一次接受所有新连接 multi_accept on; + # 使用 epoll I/O 模型(Linux 系统下性能更好) use epoll; } + # HTTP 模块配置 http { + # 包含 MIME 类型定义文件 include /etc/nginx/mime.types; + # 默认文件类型 default_type application/octet-stream; + # 定义日志格式 log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; + # 访问日志配置 access_log /var/log/nginx/access.log main; + # 启用 sendfile 优化文件传输 sendfile on; + # 保持连接的超时时间 keepalive_timeout 65; + # 启用 gzip 压缩 gzip on; + # 对 IE6 禁用 gzip gzip_disable "msie6"; + # gzip 压缩相关配置 gzip_vary on; gzip_proxied any; + # gzip 压缩级别(1-9,数字越大压缩率越高但消耗更多 CPU) gzip_comp_level 8; + # gzip 缓冲区设置 gzip_buffers 16 8k; + # gzip HTTP 版本 gzip_http_version 1.1; + # 启用 gzip 压缩的文件类型 gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png; - # Include server configurations + # 包含服务器配置文件 include /etc/nginx/conf.d/*.conf; } + + # DjangoBlog 网站配置 djangoblog.conf: | + # 主服务器配置块 server { + # 服务器域名 server_name lylinux.net; + # 网站根目录 root /code/djangoblog/collectedstatic/; + # 监听端口 listen 80; + # 保持连接超时时间 keepalive_timeout 70; + + # 静态文件处理位置块 location /static/ { + # 设置过期时间最大值(浏览器缓存) expires max; + # 静态文件别名路径 alias /code/djangoblog/collectedstatic/; } + # 特殊文件处理位置块(如 robots.txt、favicon.ico 等) location ~* (robots\.txt|ads\.txt|favicon\.ico|favion\.ico|crossdomain\.xml|google93fd32dbd906620a\.html|BingSiteAuth\.xml|baidu_verify_Ijeny6KrmS\.html)$ { - root /resource/djangopub; - expires 1d; - access_log off; - error_log off; + # 文件根目录 + root /resource/djangopub; + # 过期时间 1 天 + expires 1d; + # 关闭访问日志 + access_log off; + # 关闭错误日志 + error_log off; } + # 根路径处理位置块 location / { + # 设置代理请求头信息 proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $http_host; proxy_set_header X-NginX-Proxy true; + # 关闭代理重定向 proxy_redirect off; + + # 如果请求的文件不存在,则代理到 Django 应用 if (!-f $request_filename) { proxy_pass http://djangoblog:8000; break; } } } + + # 重定向服务器配置块(将 www 重定向到主域名) server { server_name www.lylinux.net; listen 80; + # 301 永久重定向到主域名 return 301 https://lylinux.net$request_uri; } + + # 资源服务器配置 resource.lylinux.net.conf: | server { + # 默认首页文件 index index.html index.htm; + # 资源服务器域名 server_name resource.lylinux.net; + # 网站根目录 root /resource/; + # DjangoBlog 静态文件路径配置 location /djangoblog/ { alias /code/djangoblog/collectedstatic/; } + # 关闭访问日志 access_log off; + # 关闭错误日志 error_log off; + # 包含资源服务器的额外配置 include lylinux/resource.conf; } + + # 资源配置文件内容 lylinux.resource.conf: | + # 设置最大过期时间 expires max; + # 关闭访问日志 access_log off; + # 关闭未找到文件的日志记录 log_not_found off; + # 添加 Pragma 响应头(公共缓存) add_header Pragma public; + # 添加 Cache-Control 响应头(公共缓存) add_header Cache-Control "public"; + # 添加跨域访问控制响应头(允许所有域访问) add_header "Access-Control-Allow-Origin" "*"; --- +# 第二个 ConfigMap:用于配置 DjangoBlog 应用环境变量 apiVersion: v1 kind: ConfigMap metadata: - name: djangoblog-env - namespace: djangoblog + name: djangoblog-env # ConfigMap 名称 + namespace: djangoblog # 所属命名空间 data: - DJANGO_MYSQL_DATABASE: djangoblog - DJANGO_MYSQL_USER: db_user - DJANGO_MYSQL_PASSWORD: db_password - DJANGO_MYSQL_HOST: db_host - DJANGO_MYSQL_PORT: db_port - DJANGO_REDIS_URL: "redis:6379" - DJANGO_DEBUG: "False" - MYSQL_ROOT_PASSWORD: db_password - MYSQL_DATABASE: djangoblog - MYSQL_PASSWORD: db_password - DJANGO_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx + # Django 数据库配置 + DJANGO_MYSQL_DATABASE: djangoblog # 数据库名称 + DJANGO_MYSQL_USER: db_user # 数据库用户名 + DJANGO_MYSQL_PASSWORD: db_password # 数据库密码 + DJANGO_MYSQL_HOST: db_host # 数据库主机地址 + DJANGO_MYSQL_PORT: db_port # 数据库端口 + + # Django Redis 配置 + DJANGO_REDIS_URL: "redis:6379" # Redis 服务器地址和端口 + # Django 调试模式配置 + DJANGO_DEBUG: "False" # 关闭调试模式(生产环境应设为 False) + + # MySQL 配置 + MYSQL_ROOT_PASSWORD: db_password # MySQL root 用户密码 + MYSQL_DATABASE: djangoblog # MySQL 数据库名称 + MYSQL_PASSWORD: db_password # MySQL 用户密码 + + # Django 密钥(用于加密签名等安全功能) + DJANGO_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/src/deploy/k8s/deployment.yaml b/src/deploy/k8s/deployment.yaml index 414fdcc..476edd3 100644 --- a/src/deploy/k8s/deployment.yaml +++ b/src/deploy/k8s/deployment.yaml @@ -1,121 +1,128 @@ -apiVersion: apps/v1 -kind: Deployment +# deployment.yaml - Kubernetes Deployment 资源配置文件 +# 用于定义应用的部署配置,包括副本数量、容器镜像、资源限制等 + +--- +# 第一个 Deployment:DjangoBlog 应用部署配置 +apiVersion: apps/v1 # Kubernetes API 版本 +kind: Deployment # 资源类型为 Deployment metadata: - name: djangoblog - namespace: djangoblog + name: djangoblog # Deployment 名称 + namespace: djangoblog # 所属命名空间 labels: - app: djangoblog + app: djangoblog # 标签,用于标识该 Deployment spec: - replicas: 3 + replicas: 3 # 副本数量,运行 3 个 Pod 实例 selector: matchLabels: - app: djangoblog - template: + app: djangoblog # 选择器,匹配标签为 app=djangoblog 的 Pod + template: # Pod 模板定义 metadata: labels: - app: djangoblog - spec: + app: djangoblog # Pod 标签 + spec: # Pod 规格定义 containers: - - name: djangoblog - image: liangliangyy/djangoblog:latest - imagePullPolicy: Always + - name: djangoblog # 容器名称 + image: liangliangyy/djangoblog:latest # 使用的镜像 + imagePullPolicy: Always # 镜像拉取策略:总是拉取最新镜像 ports: - - containerPort: 8000 + - containerPort: 8000 # 容器暴露的端口 envFrom: - configMapRef: - name: djangoblog-env - readinessProbe: + name: djangoblog-env # 从名为 djangoblog-env 的 ConfigMap 注入环境变量 + readinessProbe: # 就绪探针:检查应用是否准备好接收流量 httpGet: - path: / - port: 8000 - initialDelaySeconds: 10 - periodSeconds: 30 - livenessProbe: + path: / # 检查路径 + port: 8000 # 检查端口 + initialDelaySeconds: 10 # 初始延迟时间(秒) + periodSeconds: 30 # 检查间隔(秒) + livenessProbe: # 存活探针:检查应用是否正常运行 httpGet: - path: / - port: 8000 - initialDelaySeconds: 10 - periodSeconds: 30 - resources: + path: / # 检查路径 + port: 8000 # 检查端口 + initialDelaySeconds: 10 # 初始延迟时间(秒) + periodSeconds: 30 # 检查间隔(秒) + resources: # 资源限制和请求 requests: - cpu: 10m - memory: 100Mi + cpu: 10m # CPU 请求:10 毫核 + memory: 100Mi # 内存请求:100 兆字节 limits: - cpu: "2" - memory: 2Gi - volumeMounts: - - name: djangoblog - mountPath: /code/djangoblog/collectedstatic + cpu: "2" # CPU 限制:2 核 + memory: 2Gi # 内存限制:2 吉字节 + volumeMounts: # 卷挂载点 + - name: djangoblog # 卷名称 + mountPath: /code/djangoblog/collectedstatic # 挂载路径 - name: resource mountPath: /resource - volumes: + volumes: # 卷定义 - name: djangoblog persistentVolumeClaim: - claimName: djangoblog-pvc + claimName: djangoblog-pvc # 使用名为 djangoblog-pvc 的持久卷声明 - name: resource persistentVolumeClaim: - claimName: resource-pvc + claimName: resource-pvc # 使用名为 resource-pvc 的持久卷声明 --- +# 第二个 Deployment:Redis 缓存服务部署配置 apiVersion: apps/v1 kind: Deployment metadata: - name: redis - namespace: djangoblog + name: redis # Deployment 名称 + namespace: djangoblog # 所属命名空间 labels: - app: redis + app: redis # 标签 spec: - replicas: 1 + replicas: 1 # 副本数量:1 个实例(Redis 通常只需要一个实例) selector: matchLabels: - app: redis + app: redis # 选择标签为 app=redis 的 Pod template: metadata: labels: - app: redis + app: redis # Pod 标签 spec: containers: - - name: redis - image: redis:latest - imagePullPolicy: IfNotPresent + - name: redis # 容器名称 + image: redis:latest # 使用最新版 Redis 镜像 + imagePullPolicy: IfNotPresent # 镜像拉取策略:本地不存在时才拉取 ports: - - containerPort: 6379 + - containerPort: 6379 # Redis 默认端口 resources: requests: - cpu: 10m - memory: 100Mi + cpu: 10m # CPU 请求 + memory: 100Mi # 内存请求 limits: - cpu: 200m - memory: 2Gi - + cpu: 200m # CPU 限制:200 毫核 + memory: 2Gi # 内存限制 + --- +# 第三个 Deployment:MySQL 数据库部署配置 apiVersion: apps/v1 kind: Deployment metadata: - name: db - namespace: djangoblog + name: db # Deployment 名称 + namespace: djangoblog # 所属命名空间 labels: - app: db + app: db # 标签 spec: - replicas: 1 + replicas: 1 # 副本数量:1 个实例(数据库通常只需要一个实例) selector: matchLabels: - app: db + app: db # 选择标签为 app=db 的 Pod template: metadata: labels: - app: db + app: db # Pod 标签 spec: containers: - - name: db - image: mysql:latest - imagePullPolicy: IfNotPresent + - name: db # 容器名称 + image: mysql:latest # 使用最新版 MySQL 镜像 + imagePullPolicy: IfNotPresent # 镜像拉取策略 ports: - - containerPort: 3306 + - containerPort: 3306 # MySQL 默认端口 envFrom: - configMapRef: - name: djangoblog-env - readinessProbe: + name: djangoblog-env # 从 ConfigMap 注入环境变量 + readinessProbe: # 就绪探针:使用 mysqladmin 检查数据库是否就绪 exec: command: - mysqladmin @@ -124,10 +131,10 @@ spec: - "127.0.0.1" - "-u" - "root" - - "-p$MYSQL_ROOT_PASSWORD" - initialDelaySeconds: 10 - periodSeconds: 10 - livenessProbe: + - "-p$MYSQL_ROOT_PASSWORD" # 使用环境变量中的密码 + initialDelaySeconds: 10 # 初始延迟时间 + periodSeconds: 10 # 检查间隔 + livenessProbe: # 存活探针:使用 mysqladmin 检查数据库是否存活 exec: command: - mysqladmin @@ -136,139 +143,141 @@ spec: - "127.0.0.1" - "-u" - "root" - - "-p$MYSQL_ROOT_PASSWORD" - initialDelaySeconds: 10 - periodSeconds: 10 + - "-p$MYSQL_ROOT_PASSWORD" # 使用环境变量中的密码 + initialDelaySeconds: 10 # 初始延迟时间 + periodSeconds: 10 # 检查间隔 resources: requests: - cpu: 10m - memory: 100Mi + cpu: 10m # CPU 请求 + memory: 100Mi # 内存请求 limits: - cpu: "2" - memory: 2Gi + cpu: "2" # CPU 限制 + memory: 2Gi # 内存限制 volumeMounts: - name: db-data - mountPath: /var/lib/mysql + mountPath: /var/lib/mysql # MySQL 数据存储路径 volumes: - name: db-data persistentVolumeClaim: - claimName: db-pvc - + claimName: db-pvc # 使用名为 db-pvc 的持久卷声明 + --- +# 第四个 Deployment:Nginx 反向代理部署配置 apiVersion: apps/v1 kind: Deployment metadata: - name: nginx - namespace: djangoblog + name: nginx # Deployment 名称 + namespace: djangoblog # 所属命名空间 labels: - app: nginx + app: nginx # 标签 spec: - replicas: 1 + replicas: 1 # 副本数量:1 个实例 selector: matchLabels: - app: nginx + app: nginx # 选择标签为 app=nginx 的 Pod template: metadata: labels: - app: nginx + app: nginx # Pod 标签 spec: containers: - - name: nginx - image: nginx:latest - imagePullPolicy: IfNotPresent + - name: nginx # 容器名称 + image: nginx:latest # 使用最新版 Nginx 镜像 + imagePullPolicy: IfNotPresent # 镜像拉取策略 ports: - - containerPort: 80 + - containerPort: 80 # HTTP 端口 resources: requests: - cpu: 10m - memory: 100Mi + cpu: 10m # CPU 请求 + memory: 100Mi # 内存请求 limits: - cpu: "2" - memory: 2Gi - volumeMounts: + cpu: "2" # CPU 限制 + memory: 2Gi # 内存限制 + volumeMounts: # 挂载多个配置文件和数据卷 - name: nginx-config - mountPath: /etc/nginx/nginx.conf - subPath: nginx.conf + mountPath: /etc/nginx/nginx.conf # 挂载主配置文件 + subPath: nginx.conf # 指定 ConfigMap 中的键名 - name: nginx-config - mountPath: /etc/nginx/conf.d/default.conf + mountPath: /etc/nginx/conf.d/default.conf # 挂载默认站点配置 subPath: djangoblog.conf - name: nginx-config - mountPath: /etc/nginx/conf.d/resource.lylinux.net.conf + mountPath: /etc/nginx/conf.d/resource.lylinux.net.conf # 挂载资源站点配置 subPath: resource.lylinux.net.conf - name: nginx-config - mountPath: /etc/nginx/lylinux/resource.conf + mountPath: /etc/nginx/lylinux/resource.conf # 挂载资源配置 subPath: lylinux.resource.conf - name: djangoblog-pvc - mountPath: /code/djangoblog/collectedstatic + mountPath: /code/djangoblog/collectedstatic # 挂载 Django 静态文件 - name: resource-pvc - mountPath: /resource + mountPath: /resource # 挂载资源文件 volumes: - name: nginx-config configMap: - name: web-nginx-config + name: web-nginx-config # 使用名为 web-nginx-config 的 ConfigMap - name: djangoblog-pvc persistentVolumeClaim: - claimName: djangoblog-pvc + claimName: djangoblog-pvc # 使用 DjangoBlog 持久卷声明 - name: resource-pvc persistentVolumeClaim: - claimName: resource-pvc + claimName: resource-pvc # 使用资源持久卷声明 --- +# 第五个 Deployment:Elasticsearch 搜索引擎部署配置 apiVersion: apps/v1 kind: Deployment metadata: - name: elasticsearch - namespace: djangoblog + name: elasticsearch # Deployment 名称 + namespace: djangoblog # 所属命名空间 labels: - app: elasticsearch + app: elasticsearch # 标签 spec: - replicas: 1 + replicas: 1 # 副本数量:1 个实例 selector: matchLabels: - app: elasticsearch + app: elasticsearch # 选择标签为 app=elasticsearch 的 Pod template: metadata: labels: - app: elasticsearch + app: elasticsearch # Pod 标签 spec: containers: - - name: elasticsearch - image: liangliangyy/elasticsearch-analysis-ik:8.6.1 - imagePullPolicy: IfNotPresent - env: + - name: elasticsearch # 容器名称 + image: liangliangyy/elasticsearch-analysis-ik:8.6.1 # 使用包含 IK 分词器的 Elasticsearch 镜像 + imagePullPolicy: IfNotPresent # 镜像拉取策略 + env: # 环境变量配置 - name: discovery.type - value: single-node + value: single-node # 设置为单节点模式 - name: ES_JAVA_OPTS - value: "-Xms256m -Xmx256m" + value: "-Xms256m -Xmx256m" # 设置 JVM 堆内存大小 - name: xpack.security.enabled - value: "false" + value: "false" # 禁用安全功能 - name: xpack.monitoring.templates.enabled - value: "false" + value: "false" # 禁用监控模板 ports: - - containerPort: 9200 + - containerPort: 9200 # Elasticsearch REST API 端口 resources: requests: - cpu: 10m - memory: 100Mi + cpu: 10m # CPU 请求 + memory: 100Mi # 内存请求 limits: - cpu: "2" - memory: 2Gi - readinessProbe: + cpu: "2" # CPU 限制 + memory: 2Gi # 内存限制 + readinessProbe: # 就绪探针 httpGet: - path: / - port: 9200 - initialDelaySeconds: 15 - periodSeconds: 30 - livenessProbe: + path: / # 检查路径 + port: 9200 # 检查端口 + initialDelaySeconds: 15 # 初始延迟时间 + periodSeconds: 30 # 检查间隔 + livenessProbe: # 存活探针 httpGet: - path: / - port: 9200 - initialDelaySeconds: 15 - periodSeconds: 30 + path: / # 检查路径 + port: 9200 # 检查端口 + initialDelaySeconds: 15 # 初始延迟时间 + periodSeconds: 30 # 检查间隔 volumeMounts: - name: elasticsearch-data - mountPath: /usr/share/elasticsearch/data/ + mountPath: /usr/share/elasticsearch/data/ # Elasticsearch 数据存储路径 volumes: - name: elasticsearch-data persistentVolumeClaim: - claimName: elasticsearch-pvc + claimName: elasticsearch-pvc # 使用名为 elasticsearch-pvc 的持久卷声明 diff --git a/src/deploy/k8s/gateway.yaml b/src/deploy/k8s/gateway.yaml index a8de073..5ff5360 100644 --- a/src/deploy/k8s/gateway.yaml +++ b/src/deploy/k8s/gateway.yaml @@ -1,17 +1,20 @@ -apiVersion: networking.k8s.io/v1 -kind: Ingress +# gateway.yaml - Kubernetes Ingress 资源配置文件 +# 用于配置外部访问集群内服务的路由规则,是 Kubernetes 中的 API 网关 + +apiVersion: networking.k8s.io/v1 # Kubernetes API 版本,使用 networking.k8s.io/v1 API 组 +kind: Ingress # 资源类型为 Ingress(入口网关) metadata: - name: nginx - namespace: djangoblog -spec: - ingressClassName: nginx - rules: - - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: nginx - port: - number: 80 \ No newline at end of file + name: nginx # Ingress 资源名称 + namespace: djangoblog # 所属命名空间,将此 Ingress 部署到 djangoblog 命名空间中 +spec: # Ingress 规格定义 + ingressClassName: nginx # 指定使用的 Ingress 控制器类名为 nginx + rules: # 路由规则定义 + - http: # HTTP 路由规则 + paths: # 路径匹配规则列表 + - path: / # 匹配的路径前缀,这里匹配所有路径 + pathType: Prefix # 路径匹配类型为前缀匹配(Prefix) + backend: # 后端服务配置 + service: # 服务配置 + name: nginx # 后端服务名称,将流量转发到名为 nginx 的服务 + port: # 端口配置 + number: 80 # 服务端口号,转发到 nginx 服务的 80 端口 diff --git a/src/deploy/k8s/pv.yaml b/src/deploy/k8s/pv.yaml index 874b72f..e0e44e3 100644 --- a/src/deploy/k8s/pv.yaml +++ b/src/deploy/k8s/pv.yaml @@ -1,94 +1,102 @@ -apiVersion: v1 -kind: PersistentVolume +# pv.yaml - Kubernetes PersistentVolume 资源配置文件 +# 用于定义持久化卷(PV),为 Pod 提供持久化存储能力 + +--- +# 第一个 PersistentVolume:用于数据库存储 +apiVersion: v1 # Kubernetes API 版本 +kind: PersistentVolume # 资源类型为 PersistentVolume(持久化卷) metadata: - name: local-pv-db -spec: - capacity: - storage: 10Gi - volumeMode: Filesystem - accessModes: - - ReadWriteOnce - persistentVolumeReclaimPolicy: Retain - storageClassName: local-storage - local: - path: /mnt/local-storage-db - nodeAffinity: + name: local-pv-db # PV 名称 +spec: # PV 规格定义 + capacity: # 容量配置 + storage: 10Gi # 存储容量为 10GB + volumeMode: Filesystem # 卷模式为文件系统(与块设备相对) + accessModes: # 访问模式 + - ReadWriteOnce # 单节点读写模式(同一时间只能被一个节点以读写方式挂载) + persistentVolumeReclaimPolicy: Retain # PV 回收策略:保留(删除 PVC 时不会删除 PV 和数据) + storageClassName: local-storage # 存储类名称,用于与 PVC 匹配 + local: # 本地卷配置 + path: /mnt/local-storage-db # 本地存储路径 + nodeAffinity: # 节点亲和性配置,指定 PV 只能在特定节点上使用 required: nodeSelectorTerms: - matchExpressions: - - key: kubernetes.io/hostname - operator: In + - key: kubernetes.io/hostname # 匹配节点的主机名标签 + operator: In # 操作符为 In(在指定值中) values: - - master + - master # 指定只能在名为 master 的节点上使用此 PV + --- +# 第二个 PersistentVolume:用于 DjangoBlog 应用存储 apiVersion: v1 kind: PersistentVolume metadata: - name: local-pv-djangoblog + name: local-pv-djangoblog # PV 名称 spec: capacity: - storage: 5Gi - volumeMode: Filesystem + storage: 5Gi # 存储容量为 5GB(比数据库小) + volumeMode: Filesystem # 卷模式为文件系统 accessModes: - - ReadWriteOnce - persistentVolumeReclaimPolicy: Retain - storageClassName: local-storage + - ReadWriteOnce # 单节点读写模式 + persistentVolumeReclaimPolicy: Retain # PV 回收策略:保留 + storageClassName: local-storage # 存储类名称 local: - path: /mnt/local-storage-djangoblog - nodeAffinity: + path: /mnt/local-storage-djangoblog # 本地存储路径 + nodeAffinity: # 节点亲和性配置 required: nodeSelectorTerms: - matchExpressions: - - key: kubernetes.io/hostname - operator: In + - key: kubernetes.io/hostname # 匹配节点的主机名标签 + operator: In # 操作符为 In values: - - master - + - master # 指定只能在名为 master 的节点上使用此 PV --- +# 第三个 PersistentVolume:用于资源文件存储 apiVersion: v1 kind: PersistentVolume metadata: - name: local-pv-resource + name: local-pv-resource # PV 名称 spec: capacity: - storage: 5Gi - volumeMode: Filesystem + storage: 5Gi # 存储容量为 5GB + volumeMode: Filesystem # 卷模式为文件系统 accessModes: - - ReadWriteOnce - persistentVolumeReclaimPolicy: Retain - storageClassName: local-storage + - ReadWriteOnce # 单节点读写模式 + persistentVolumeReclaimPolicy: Retain # PV 回收策略:保留 + storageClassName: local-storage # 存储类名称 local: - path: /mnt/resource/ - nodeAffinity: + path: /mnt/resource/ # 本地存储路径 + nodeAffinity: # 节点亲和性配置 required: nodeSelectorTerms: - matchExpressions: - - key: kubernetes.io/hostname - operator: In + - key: kubernetes.io/hostname # 匹配节点的主机名标签 + operator: In # 操作符为 In values: - - master + - master # 指定只能在名为 master 的节点上使用此 PV --- +# 第四个 PersistentVolume:用于 Elasticsearch 存储 apiVersion: v1 kind: PersistentVolume metadata: - name: local-pv-elasticsearch + name: local-pv-elasticsearch # PV 名称 spec: capacity: - storage: 5Gi - volumeMode: Filesystem + storage: 5Gi # 存储容量为 5GB + volumeMode: Filesystem # 卷模式为文件系统 accessModes: - - ReadWriteOnce - persistentVolumeReclaimPolicy: Retain - storageClassName: local-storage + - ReadWriteOnce # 单节点读写模式 + persistentVolumeReclaimPolicy: Retain # PV 回收策略:保留 + storageClassName: local-storage # 存储类名称 local: - path: /mnt/local-storage-elasticsearch - nodeAffinity: + path: /mnt/local-storage-elasticsearch # 本地存储路径 + nodeAffinity: # 节点亲和性配置 required: nodeSelectorTerms: - matchExpressions: - - key: kubernetes.io/hostname - operator: In + - key: kubernetes.io/hostname # 匹配节点的主机名标签 + operator: In # 操作符为 In values: - - master \ No newline at end of file + - master # 指定只能在名为 master 的节点上使用此 PV diff --git a/src/deploy/k8s/pvc.yaml b/src/deploy/k8s/pvc.yaml index ef238c5..78dfddc 100644 --- a/src/deploy/k8s/pvc.yaml +++ b/src/deploy/k8s/pvc.yaml @@ -1,60 +1,66 @@ -apiVersion: v1 -kind: PersistentVolumeClaim +# pvc.yaml - Kubernetes PersistentVolumeClaim 资源配置文件 +# 用于定义持久化卷声明(PVC),是用户对存储资源的请求 + +--- +# 第一个 PersistentVolumeClaim:为数据库服务申请存储空间 +apiVersion: v1 # Kubernetes API 版本 +kind: PersistentVolumeClaim # 资源类型为 PersistentVolumeClaim(持久化卷声明) metadata: - name: db-pvc - namespace: djangoblog -spec: - storageClassName: local-storage - volumeName: local-pv-db - accessModes: - - ReadWriteOnce - resources: + name: db-pvc # PVC 名称 + namespace: djangoblog # 所属命名空间 +spec: # PVC 规格定义 + storageClassName: local-storage # 指定存储类名称,用于匹配相应的 PV + volumeName: local-pv-db # 直接绑定到指定的 PV(local-pv-db) + accessModes: # 访问模式 + - ReadWriteOnce # 单节点读写模式(同一时间只能被一个节点以读写方式挂载) + resources: # 资源请求 requests: - storage: 10Gi - + storage: 10Gi # 请求存储空间为 10GB --- +# 第二个 PersistentVolumeClaim:为 DjangoBlog 应用申请存储空间 apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: djangoblog-pvc - namespace: djangoblog + name: djangoblog-pvc # PVC 名称 + namespace: djangoblog # 所属命名空间 spec: - volumeName: local-pv-djangoblog - storageClassName: local-storage + volumeName: local-pv-djangoblog # 直接绑定到指定的 PV(local-pv-djangoblog) + storageClassName: local-storage # 存储类名称 accessModes: - - ReadWriteOnce + - ReadWriteOnce # 单节点读写模式 resources: requests: - storage: 5Gi + storage: 5Gi # 请求存储空间为 5GB --- +# 第三个 PersistentVolumeClaim:为资源文件申请存储空间 apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: resource-pvc - namespace: djangoblog + name: resource-pvc # PVC 名称 + namespace: djangoblog # 所属命名空间 spec: - volumeName: local-pv-resource - storageClassName: local-storage + volumeName: local-pv-resource # 直接绑定到指定的 PV(local-pv-resource) + storageClassName: local-storage # 存储类名称 accessModes: - - ReadWriteOnce + - ReadWriteOnce # 单节点读写模式 resources: requests: - storage: 5Gi + storage: 5Gi # 请求存储空间为 5GB --- +# 第四个 PersistentVolumeClaim:为 Elasticsearch 服务申请存储空间 apiVersion: v1 kind: PersistentVolumeClaim metadata: - name: elasticsearch-pvc - namespace: djangoblog + name: elasticsearch-pvc # PVC 名称 + namespace: djangoblog # 所属命名空间 spec: - volumeName: local-pv-elasticsearch - storageClassName: local-storage + volumeName: local-pv-elasticsearch # 直接绑定到指定的 PV(local-pv-elasticsearch) + storageClassName: local-storage # 存储类名称 accessModes: - - ReadWriteOnce + - ReadWriteOnce # 单节点读写模式 resources: requests: - storage: 5Gi - \ No newline at end of file + storage: 5Gi # 请求存储空间为 5GB diff --git a/src/deploy/k8s/service.yaml b/src/deploy/k8s/service.yaml index 4ef2931..514b944 100644 --- a/src/deploy/k8s/service.yaml +++ b/src/deploy/k8s/service.yaml @@ -1,80 +1,94 @@ -apiVersion: v1 -kind: Service +# service.yaml - Kubernetes Service 资源配置文件 +# 用于定义服务,为 Pod 提供稳定的网络访问入口和负载均衡 + +# 注意:文件开头有一个拼写错误 "tapiVersion",应该是 "apiVersion" + +--- +# 第一个 Service:DjangoBlog 应用服务 +apiVersion: v1 # Kubernetes API 版本 +kind: Service # 资源类型为 Service(服务) metadata: - name: djangoblog - namespace: djangoblog + name: djangoblog # 服务名称 + namespace: djangoblog # 所属命名空间 labels: - app: djangoblog -spec: - selector: - app: djangoblog - ports: - - protocol: TCP - port: 8000 - targetPort: 8000 - type: ClusterIP + app: djangoblog # 标签,用于标识该服务 +spec: # 服务规格定义 + selector: # 选择器,用于匹配后端 Pod + app: djangoblog # 匹配标签为 app=djangoblog 的 Pod + ports: # 端口配置 + - protocol: TCP # 协议类型为 TCP + port: 8000 # 服务暴露的端口 + targetPort: 8000 # 目标端口,即 Pod 中容器暴露的端口 + type: ClusterIP # 服务类型为 ClusterIP(仅在集群内部可访问) + --- +# 第二个 Service:Nginx 服务 apiVersion: v1 kind: Service metadata: - name: nginx - namespace: djangoblog + name: nginx # 服务名称 + namespace: djangoblog # 所属命名空间 labels: - app: nginx + app: nginx # 标签 spec: selector: - app: nginx + app: nginx # 匹配标签为 app=nginx 的 Pod ports: - - protocol: TCP - port: 80 - targetPort: 80 - type: ClusterIP + - protocol: TCP # 协议类型为 TCP + port: 80 # 服务暴露的端口(HTTP 默认端口) + targetPort: 80 # 目标端口 + type: ClusterIP # 服务类型为 ClusterIP + --- +# 第三个 Service:Redis 缓存服务 apiVersion: v1 kind: Service metadata: - name: redis - namespace: djangoblog + name: redis # 服务名称 + namespace: djangoblog # 所属命名空间 labels: - app: redis + app: redis # 标签 spec: selector: - app: redis + app: redis # 匹配标签为 app=redis 的 Pod ports: - - protocol: TCP - port: 6379 - targetPort: 6379 - type: ClusterIP + - protocol: TCP # 协议类型为 TCP + port: 6379 # 服务暴露的端口(Redis 默认端口) + targetPort: 6379 # 目标端口 + type: ClusterIP # 服务类型为 ClusterIP + --- +# 第四个 Service:MySQL 数据库服务 apiVersion: v1 kind: Service metadata: - name: db - namespace: djangoblog + name: db # 服务名称 + namespace: djangoblog # 所属命名空间 labels: - app: db + app: db # 标签 spec: selector: - app: db + app: db # 匹配标签为 app=db 的 Pod ports: - - protocol: TCP - port: 3306 - targetPort: 3306 - type: ClusterIP + - protocol: TCP # 协议类型为 TCP + port: 3306 # 服务暴露的端口(MySQL 默认端口) + targetPort: 3306 # 目标端口 + type: ClusterIP # 服务类型为 ClusterIP + --- +# 第五个 Service:Elasticsearch 搜索引擎服务 apiVersion: v1 kind: Service metadata: - name: elasticsearch - namespace: djangoblog + name: elasticsearch # 服务名称 + namespace: djangoblog # 所属命名空间 labels: - app: elasticsearch + app: elasticsearch # 标签 spec: selector: - app: elasticsearch + app: elasticsearch # 匹配标签为 app=elasticsearch 的 Pod ports: - - protocol: TCP - port: 9200 - targetPort: 9200 - type: ClusterIP - + - protocol: TCP # 协议类型为 TCP + port: 9200 # 服务暴露的端口(Elasticsearch REST API 默认端口) + targetPort: 9200 # 目标端口 + type: ClusterIP # 服务类型为 ClusterIP diff --git a/src/deploy/k8s/storageclass.yaml b/src/deploy/k8s/storageclass.yaml index 5d5a14c..3d55726 100644 --- a/src/deploy/k8s/storageclass.yaml +++ b/src/deploy/k8s/storageclass.yaml @@ -1,10 +1,10 @@ -apiVersion: storage.k8s.io/v1 -kind: StorageClass -metadata: - name: local-storage annotations: - storageclass.kubernetes.io/is-default-class: "true" + # 这个注解将当前存储类设置为集群的默认存储类 + # 当用户创建 PVC 时,如果没有指定 storageClassName,将自动使用此默认存储类 + storageclass.kubernetes.io/is-default-class: "true" +# 指定使用的存储供应器 +# kubernetes.io/no-provisioner 表示这是一个静态供应的存储类 +# 需要管理员预先创建好 PersistentVolume (PV) 资源,而不是动态创建 provisioner: kubernetes.io/no-provisioner -volumeBindingMode: Immediate diff --git a/src/deploy/nginx.conf b/src/deploy/nginx.conf index 32161d8..e69de29 100644 --- a/src/deploy/nginx.conf +++ b/src/deploy/nginx.conf @@ -1,50 +0,0 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log notice; -pid /var/run/nginx.pid; - - -events { - worker_connections 1024; -} - - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - #tcp_nopush on; - - keepalive_timeout 65; - - #gzip on; - - server { - root /code/djangoblog/collectedstatic/; - listen 80; - keepalive_timeout 70; - location /static/ { - expires max; - alias /code/djangoblog/collectedstatic/; - } - location / { - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $http_host; - proxy_set_header X-NginX-Proxy true; - proxy_redirect off; - if (!-f $request_filename) { - proxy_pass http://djangoblog:8000; - break; - } - } - } -} diff --git a/src/djangoblog/__init__.py b/src/djangoblog/__init__.py index 1e205f4..1c956ab 100644 --- a/src/djangoblog/__init__.py +++ b/src/djangoblog/__init__.py @@ -1 +1,6 @@ +# __init__.py - Django 应用初始化配置文件 +# 该文件用于配置 Django 应用的默认应用配置类 + +# 指定 Django 应用的默认配置类 +# 当 Django 启动时,会自动加载这个配置类来初始化应用 default_app_config = 'djangoblog.apps.DjangoblogAppConfig' diff --git a/src/djangoblog/admin_site.py b/src/djangoblog/admin_site.py index f120405..215e097 100644 --- a/src/djangoblog/admin_site.py +++ b/src/djangoblog/admin_site.py @@ -1,32 +1,63 @@ -from django.contrib.admin import AdminSite -from django.contrib.admin.models import LogEntry -from django.contrib.sites.admin import SiteAdmin -from django.contrib.sites.models import Site - -from accounts.admin import * -from blog.admin import * -from blog.models import * -from comments.admin import * -from comments.models import * -from djangoblog.logentryadmin import LogEntryAdmin -from oauth.admin import * -from oauth.models import * -from owntracks.admin import * -from owntracks.models import * -from servermanager.admin import * -from servermanager.models import * +# admin_site.py - 自定义 Django 管理站点配置文件 +# 用于创建和配置自定义的 Django 管理后台界面 + +# 从 Django 内置模块导入相关组件 +from django.contrib.admin import AdminSite # Django 管理站点基类 +from django.contrib.admin.models import LogEntry # 管理日志模型 +from django.contrib.sites.admin import SiteAdmin # 站点管理类 +from django.contrib.sites.models import Site # 站点模型 + +# 从项目各个模块导入管理员类和模型 +from accounts.admin import * # 账户相关管理员类 +from blog.admin import * # 博客相关管理员类 +from blog.models import * # 博客相关模型 +from comments.admin import * # 评论相关管理员类 +from comments.models import * # 评论相关模型 +from djangoblog.logentryadmin import LogEntryAdmin # 自定义日志条目管理员类 +from oauth.admin import * # OAuth 相关管理员类 +from oauth.models import * # OAuth 相关模型 +from owntracks.admin import * # OwnTracks 相关管理员类 +from owntracks.models import * # OwnTracks 相关模型 +from servermanager.admin import * # 服务器管理相关管理员类 +from servermanager.models import * # 服务器管理相关模型 class DjangoBlogAdminSite(AdminSite): + """ + 自定义 Django 管理站点类 + 继承自 Django 的 AdminSite,用于提供个性化的管理界面 + """ + + # 设置管理站点的头部标题 site_header = 'djangoblog administration' + + # 设置管理站点的标题 site_title = 'djangoblog site admin' def __init__(self, name='admin'): + """ + 初始化自定义管理站点 + + 参数: + name (str): 管理站点的名称,默认为 'admin' + """ + # 调用父类的初始化方法 super().__init__(name) def has_permission(self, request): + """ + 检查用户是否有访问管理站点的权限 + + 参数: + request: HTTP 请求对象 + + 返回: + bool: 如果用户是超级用户则返回 True,否则返回 False + """ + # 只有超级用户才能访问管理站点 return request.user.is_superuser + # 注释掉的 get_urls 方法示例,展示如何添加自定义管理页面 # def get_urls(self): # urls = super().get_urls() # from django.urls import path @@ -38,27 +69,37 @@ class DjangoBlogAdminSite(AdminSite): # return urls + my_urls +# 创建自定义管理站点的实例 admin_site = DjangoBlogAdminSite(name='admin') -admin_site.register(Article, ArticlelAdmin) -admin_site.register(Category, CategoryAdmin) -admin_site.register(Tag, TagAdmin) -admin_site.register(Links, LinksAdmin) -admin_site.register(SideBar, SideBarAdmin) -admin_site.register(BlogSettings, BlogSettingsAdmin) +# 在自定义管理站点中注册模型和对应的管理员类 +# 博客相关模型注册 +admin_site.register(Article, ArticlelAdmin) # 注册文章模型和管理员类 +admin_site.register(Category, CategoryAdmin) # 注册分类模型和管理员类 +admin_site.register(Tag, TagAdmin) # 注册标签模型和管理员类 +admin_site.register(Links, LinksAdmin) # 注册链接模型和管理员类 +admin_site.register(SideBar, SideBarAdmin) # 注册侧边栏模型和管理员类 +admin_site.register(BlogSettings, BlogSettingsAdmin) # 注册博客设置模型和管理员类 -admin_site.register(commands, CommandsAdmin) -admin_site.register(EmailSendLog, EmailSendLogAdmin) +# 服务器管理相关模型注册 +admin_site.register(commands, CommandsAdmin) # 注册命令模型和管理员类 +admin_site.register(EmailSendLog, EmailSendLogAdmin) # 注册邮件发送日志模型和管理员类 -admin_site.register(BlogUser, BlogUserAdmin) +# 用户账户相关模型注册 +admin_site.register(BlogUser, BlogUserAdmin) # 注册博客用户模型和管理员类 -admin_site.register(Comment, CommentAdmin) +# 评论相关模型注册 +admin_site.register(Comment, CommentAdmin) # 注册评论模型和管理员类 -admin_site.register(OAuthUser, OAuthUserAdmin) -admin_site.register(OAuthConfig, OAuthConfigAdmin) +# OAuth 相关模型注册 +admin_site.register(OAuthUser, OAuthUserAdmin) # 注册 OAuth 用户模型和管理员类 +admin_site.register(OAuthConfig, OAuthConfigAdmin) # 注册 OAuth 配置模型和管理员类 -admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) +# OwnTracks 相关模型注册 +admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 注册 OwnTracks 日志模型和管理员类 -admin_site.register(Site, SiteAdmin) +# Django 站点框架相关模型注册 +admin_site.register(Site, SiteAdmin) # 注册站点模型和管理员类 -admin_site.register(LogEntry, LogEntryAdmin) +# 管理日志相关模型注册 +admin_site.register(LogEntry, LogEntryAdmin) # 注册日志条目模型和自定义管理员类 diff --git a/src/djangoblog/apps.py b/src/djangoblog/apps.py index d29e318..a33fe7d 100644 --- a/src/djangoblog/apps.py +++ b/src/djangoblog/apps.py @@ -1,11 +1,33 @@ -from django.apps import AppConfig +# apps.py - Django 应用配置文件 +# 用于定义应用的配置信息和初始化逻辑 + +from django.apps import AppConfig # 从 Django 导入应用配置基类 + class DjangoblogAppConfig(AppConfig): + """ + DjangoBlog 应用的配置类 + 继承自 Django 的 AppConfig,用于配置应用的元数据和初始化逻辑 + """ + + # 指定模型中默认的自动字段类型为 BigAutoField + # BigAutoField 是一个 64 位整数,比默认的 AutoField (32 位) 能支持更大的数据量 default_auto_field = 'django.db.models.BigAutoField' + + # 应用的名称,必须与应用目录名称一致 name = 'djangoblog' def ready(self): + """ + 应用准备就绪时调用的方法 + 在 Django 启动过程中,当应用注册表完全加载后执行 + 用于执行应用启动时需要进行的初始化操作 + """ + # 调用父类的 ready 方法,确保基础初始化完成 super().ready() - # Import and load plugins here - from .plugin_manage.loader import load_plugins - load_plugins() \ No newline at end of file + + # 在此处导入并加载插件 + # 将插件加载放在 ready 方法中可以确保 Django 应用完全初始化后再加载插件 + # 避免在应用未准备就绪时尝试访问 Django 组件导致的问题 + from .plugin_manage.loader import load_plugins # 导入插件加载器 + load_plugins() # 调用插件加载函数,动态加载已激活的插件 diff --git a/src/djangoblog/blog_signals.py b/src/djangoblog/blog_signals.py index 393f441..0511ac5 100644 --- a/src/djangoblog/blog_signals.py +++ b/src/djangoblog/blog_signals.py @@ -1,66 +1,109 @@ -import _thread -import logging - -import django.dispatch -from django.conf import settings -from django.contrib.admin.models import LogEntry -from django.contrib.auth.signals import user_logged_in, user_logged_out -from django.core.mail import EmailMultiAlternatives -from django.db.models.signals import post_save -from django.dispatch import receiver - -from comments.models import Comment -from comments.utils import send_comment_email -from djangoblog.spider_notify import SpiderNotify -from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache -from djangoblog.utils import get_current_site -from oauth.models import OAuthUser - -logger = logging.getLogger(__name__) - +# blog_signals.py - Django 信号处理模块 +# 用于处理 Django 应用中的各种事件信号,实现解耦的事件驱动架构 + +import _thread # 导入线程模块,用于启动新线程执行耗时操作 +import logging # 导入日志模块,用于记录运行信息 + +# 导入 Django 相关模块 +import django.dispatch # Django 信号分发模块 +from django.conf import settings # Django 配置模块 +from django.contrib.admin.models import LogEntry # Django 管理日志模型 +from django.contrib.auth.signals import user_logged_in, user_logged_out # 用户登录/登出信号 +from django.core.mail import EmailMultiAlternatives # Django 邮件发送类 +from django.db.models.signals import post_save # 模型保存后触发的信号 +from django.dispatch import receiver # 信号接收器装饰器 + +# 导入项目相关模块 +from comments.models import Comment # 评论模型 +from comments.utils import send_comment_email # 发送评论邮件工具函数 +from djangoblog.spider_notify import SpiderNotify # 搜索引擎通知工具 +from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, delete_view_cache # 缓存工具函数 +from djangoblog.utils import get_current_site # 获取当前站点工具函数 +from oauth.models import OAuthUser # OAuth 用户模型 + +logger = logging.getLogger(__name__) # 创建当前模块的日志记录器 + +# 定义自定义信号 +# OAuth 用户登录信号,携带用户 ID 参数 oauth_user_login_signal = django.dispatch.Signal(['id']) + +# 发送邮件信号,携带收件人、标题和内容参数 send_email_signal = django.dispatch.Signal( ['emailto', 'title', 'content']) @receiver(send_email_signal) def send_email_signal_handler(sender, **kwargs): - emailto = kwargs['emailto'] - title = kwargs['title'] - content = kwargs['content'] - + """ + 发送邮件信号处理函数 + 当触发 send_email_signal 信号时执行,用于异步发送邮件 + + 参数: + sender: 信号发送者 + **kwargs: 信号传递的参数字典,包含 emailto, title, content + """ + # 从信号参数中提取邮件信息 + emailto = kwargs['emailto'] # 收件人列表 + title = kwargs['title'] # 邮件标题 + content = kwargs['content'] # 邮件内容 + + # 创建邮件对象 msg = EmailMultiAlternatives( - title, - content, - from_email=settings.DEFAULT_FROM_EMAIL, - to=emailto) - msg.content_subtype = "html" + title, # 邮件标题 + content, # 邮件内容 + from_email=settings.DEFAULT_FROM_EMAIL, # 发件人邮箱 + to=emailto) # 收件人列表 + msg.content_subtype = "html" # 设置邮件内容类型为 HTML + # 创建邮件发送日志记录 from servermanager.models import EmailSendLog log = EmailSendLog() - log.title = title - log.content = content - log.emailto = ','.join(emailto) + log.title = title # 记录邮件标题 + log.content = content # 记录邮件内容 + log.emailto = ','.join(emailto) # 记录收件人(转换为逗号分隔的字符串) try: + # 尝试发送邮件 result = msg.send() + # 记录发送结果(result > 0 表示发送成功) log.send_result = result > 0 except Exception as e: + # 如果发送失败,记录错误日志 logger.error(f"失败邮箱号: {emailto}, {e}") - log.send_result = False + log.send_result = False # 记录发送失败 + + # 保存邮件发送日志 log.save() @receiver(oauth_user_login_signal) def oauth_user_login_signal_handler(sender, **kwargs): + """ + OAuth 用户登录信号处理函数 + 当 OAuth 用户登录时触发,用于处理用户头像等信息 + + 参数: + sender: 信号发送者 + **kwargs: 信号传递的参数字典,包含用户 ID + """ + # 从信号参数中获取用户 ID id = kwargs['id'] + + # 获取 OAuth 用户对象 oauthuser = OAuthUser.objects.get(id=id) + + # 获取当前站点域名 site = get_current_site().domain + + # 检查用户头像是否需要处理 + # 如果用户有头像且头像 URL 中不包含当前站点域名,则需要保存头像到本地 if oauthuser.picture and not oauthuser.picture.find(site) >= 0: from djangoblog.utils import save_user_avatar + # 保存用户头像到本地并更新头像 URL oauthuser.picture = save_user_avatar(oauthuser.picture) - oauthuser.save() + oauthuser.save() # 保存更新后的用户信息 + # 删除侧边栏缓存,确保显示最新信息 delete_sidebar_cache() @@ -73,42 +116,82 @@ def model_post_save_callback( using, update_fields, **kwargs): - clearcache = False + """ + 模型保存后回调函数 + 当任何模型保存时触发,用于处理缓存清理、搜索引擎通知等操作 + + 参数: + sender: 发送信号的模型类 + instance: 保存的模型实例 + created: 布尔值,表示是否是新创建的实例 + raw: 布尔值,表示是否是原始加载的数据 + using: 使用的数据库别名 + update_fields: 更新的字段集合 + **kwargs: 其他参数 + """ + clearcache = False # 标记是否需要清除缓存 + + # 如果是管理日志条目,直接返回不处理 if isinstance(instance, LogEntry): return + + # 如果实例有 get_full_url 方法(通常是可公开访问的模型) if 'get_full_url' in dir(instance): + # 判断是否只是更新了浏览量字段 is_update_views = update_fields == {'views'} + + # 如果不是测试环境且不是仅更新浏览量,则通知搜索引擎 if not settings.TESTING and not is_update_views: try: + # 获取实例的完整 URL 并通知搜索引擎 notify_url = instance.get_full_url() - SpiderNotify.baidu_notify([notify_url]) + SpiderNotify.baidu_notify([notify_url]) # 通知百度搜索引擎 except Exception as ex: - logger.error("notify sipder", ex) + # 记录通知搜索引擎时的错误 + logger.error("notify spider", ex) + + # 如果不是仅更新浏览量,则需要清除缓存 if not is_update_views: clearcache = True + # 如果是评论模型实例 if isinstance(instance, Comment): + # 如果评论是启用状态 if instance.is_enable: + # 获取关联文章的绝对 URL path = instance.article.get_absolute_url() + # 获取当前站点域名 site = get_current_site().domain + # 如果域名包含端口号,则去掉端口号部分 if site.find(':') > 0: site = site[0:site.find(':')] + # 过期文章详情页面的缓存 expire_view_cache( path, servername=site, serverport=80, key_prefix='blogdetail') + + # 如果有 SEO 处理器缓存,则删除 if cache.get('seo_processor'): cache.delete('seo_processor') + + # 删除文章评论缓存 comment_cache_key = 'article_comments_{id}'.format( id=instance.article.id) cache.delete(comment_cache_key) + + # 删除侧边栏缓存 delete_sidebar_cache() + + # 删除文章评论视图缓存 delete_view_cache('article_comments', [str(instance.article.pk)]) + # 启动新线程发送评论邮件通知(异步执行,避免阻塞主流程) _thread.start_new_thread(send_comment_email, (instance,)) + # 如果需要清除缓存,则执行清除操作 if clearcache: cache.clear() @@ -116,7 +199,22 @@ def model_post_save_callback( @receiver(user_logged_in) @receiver(user_logged_out) def user_auth_callback(sender, request, user, **kwargs): + """ + 用户认证回调函数 + 当用户登录或登出时触发,用于处理缓存清理等操作 + + 参数: + sender: 信号发送者 + request: HTTP 请求对象 + user: 用户对象 + **kwargs: 其他参数 + """ + # 如果用户存在且有用户名 if user and user.username: + # 记录用户登录/登出日志 logger.info(user) + + # 删除侧边栏缓存,确保显示最新信息 delete_sidebar_cache() - # cache.clear() + + # cache.clear() # 注释掉的代码:清除所有缓存 diff --git a/src/djangoblog/elasticsearch_backend.py b/src/djangoblog/elasticsearch_backend.py index 4afe498..6519b51 100644 --- a/src/djangoblog/elasticsearch_backend.py +++ b/src/djangoblog/elasticsearch_backend.py @@ -1,183 +1,362 @@ -from django.utils.encoding import force_str -from elasticsearch_dsl import Q -from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query -from haystack.forms import ModelSearchForm -from haystack.models import SearchResult -from haystack.utils import log as logging +# elasticsearch_backend.py - Django Haystack 的 Elasticsearch 搜索后端实现 +# 提供基于 Elasticsearch 的全文搜索功能,用于替代默认的搜索后端 -from blog.documents import ArticleDocument, ArticleDocumentManager -from blog.models import Article +# 从 Django 和第三方库导入相关模块 +from django.utils.encoding import force_str # 强制转换为字符串的工具函数 +from elasticsearch_dsl import Q # Elasticsearch DSL 查询构建器 +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query # Haystack 搜索后端基类 +from haystack.forms import ModelSearchForm # Haystack 搜索表单基类 +from haystack.models import SearchResult # Haystack 搜索结果模型 +from haystack.utils import log as logging # Haystack 日志工具 -logger = logging.getLogger(__name__) +# 从项目模块导入相关组件 +from blog.documents import ArticleDocument, ArticleDocumentManager # 文章 Elasticsearch 文档和管理器 +from blog.models import Article # 文章模型 + +logger = logging.getLogger(__name__) # 创建当前模块的日志记录器 class ElasticSearchBackend(BaseSearchBackend): + """ + Elasticsearch 搜索后端实现类 + 继承自 Haystack 的 BaseSearchBackend,提供基于 Elasticsearch 的搜索功能 + """ + def __init__(self, connection_alias, **connection_options): + """ + 初始化 Elasticsearch 搜索后端 + + 参数: + connection_alias: 连接别名 + **connection_options: 连接选项 + """ + # 调用父类初始化方法 super( ElasticSearchBackend, self).__init__( connection_alias, **connection_options) - self.manager = ArticleDocumentManager() - self.include_spelling = True + + self.manager = ArticleDocumentManager() # 创建文章文档管理器实例 + self.include_spelling = True # 启用拼写检查功能 def _get_models(self, iterable): + """ + 获取模型实例列表 + + 参数: + iterable: 可迭代的模型实例或查询集 + + 返回: + 转换后的文档列表 + """ + # 如果提供了模型实例则使用,否则获取所有文章 models = iterable if iterable and iterable[0] else Article.objects.all() + # 将模型实例转换为 Elasticsearch 文档 docs = self.manager.convert_to_doc(models) return docs def _create(self, models): - self.manager.create_index() - docs = self._get_models(models) - self.manager.rebuild(docs) + """ + 创建索引和文档 + + 参数: + models: 模型实例列表 + """ + self.manager.create_index() # 创建索引 + docs = self._get_models(models) # 获取文档 + self.manager.rebuild(docs) # 重建索引 def _delete(self, models): + """ + 删除文档 + + 参数: + models: 要删除的模型实例列表 + + 返回: + True 表示删除成功 + """ for m in models: - m.delete() + m.delete() # 删除每个模型实例 return True def _rebuild(self, models): - models = models if models else Article.objects.all() - docs = self.manager.convert_to_doc(models) - self.manager.update_docs(docs) + """ + 重建索引 + + 参数: + models: 模型实例列表 + """ + models = models if models else Article.objects.all() # 如果未提供模型则获取所有文章 + docs = self.manager.convert_to_doc(models) # 转换为文档 + self.manager.update_docs(docs) # 更新文档 def update(self, index, iterable, commit=True): + """ + 更新索引中的文档 - models = self._get_models(iterable) - self.manager.update_docs(models) + 参数: + index: 索引对象 + iterable: 可迭代的模型实例 + commit: 是否立即提交更改 + """ + models = self._get_models(iterable) # 获取模型文档 + self.manager.update_docs(models) # 更新文档 def remove(self, obj_or_string): - models = self._get_models([obj_or_string]) - self._delete(models) + """ + 从索引中移除对象 + + 参数: + obj_or_string: 要移除的对象或字符串 + """ + models = self._get_models([obj_or_string]) # 获取要删除的文档 + self._delete(models) # 删除文档 def clear(self, models=None, commit=True): - self.remove(None) + """ + 清空索引 + + 参数: + models: 要清空的模型列表 + commit: 是否立即提交更改 + """ + self.remove(None) # 移除所有文档 @staticmethod def get_suggestion(query: str) -> str: - """获取推荐词, 如果没有找到添加原搜索词""" + """ + 获取搜索建议词,如果没有找到则添加原搜索词 + + 参数: + query: 原始搜索查询 + 返回: + 建议的搜索词字符串 + """ + # 构建 Elasticsearch 搜索查询,包含术语建议 search = ArticleDocument.search() \ .query("match", body=query) \ .suggest('suggest_search', query, term={'field': 'body'}) \ .execute() - keywords = [] + keywords = [] # 存储建议关键词 + # 遍历搜索建议结果 for suggest in search.suggest.suggest_search: if suggest["options"]: + # 如果有建议选项,使用第一个建议词 keywords.append(suggest["options"][0]["text"]) else: + # 如果没有建议选项,使用原词 keywords.append(suggest["text"]) - return ' '.join(keywords) + return ' '.join(keywords) # 返回拼接的建议词 @log_query def search(self, query_string, **kwargs): - logger.info('search query_string:' + query_string) + """ + 执行搜索查询 + 参数: + query_string: 搜索查询字符串 + **kwargs: 其他参数 + + 返回: + 包含搜索结果的字典 + """ + logger.info('search query_string:' + query_string) # 记录搜索日志 + + # 获取分页参数 start_offset = kwargs.get('start_offset') end_offset = kwargs.get('end_offset') # 推荐词搜索 if getattr(self, "is_suggest", None): + # 如果启用建议搜索,获取建议词 suggestion = self.get_suggestion(query_string) else: + # 否则使用原始查询词 suggestion = query_string + # 构建复合查询:在标题和正文中匹配建议词 q = Q('bool', should=[Q('match', body=suggestion), Q('match', title=suggestion)], - minimum_should_match="70%") + minimum_should_match="70%") # 至少匹配 70% 的 should 条件 + # 构建完整的搜索查询 search = ArticleDocument.search() \ - .query('bool', filter=[q]) \ - .filter('term', status='p') \ - .filter('term', type='a') \ - .source(False)[start_offset: end_offset] - - results = search.execute() - hits = results['hits'].total - raw_results = [] + .query('bool', filter=[q]) \ # 使用过滤器查询 + .filter('term', status='p') \ # 过滤已发布的文章 + .filter('term', type='a') \ # 过滤文章类型 + .source(False)[start_offset: end_offset] # 不返回源数据,设置分页 + + results = search.execute() # 执行搜索 + hits = results['hits'].total # 获取总命中数 + raw_results = [] # 存储原始搜索结果 + + # 处理每个搜索结果 for raw_result in results['hits']['hits']: - app_label = 'blog' - model_name = 'Article' - additional_fields = {} + app_label = 'blog' # 应用标签 + model_name = 'Article' # 模型名称 + additional_fields = {} # 附加字段 - result_class = SearchResult + result_class = SearchResult # 搜索结果类 + # 创建 SearchResult 对象 result = result_class( app_label, model_name, - raw_result['_id'], - raw_result['_score'], + raw_result['_id'], # 文档 ID + raw_result['_score'], # 匹配得分 **additional_fields) - raw_results.append(result) - facets = {} + raw_results.append(result) # 添加到结果列表 + + facets = {} # 面部结果(此处为空) + # 如果查询词与建议词不同,则提供拼写建议 spelling_suggestion = None if query_string == suggestion else suggestion + # 返回搜索结果字典 return { - 'results': raw_results, - 'hits': hits, - 'facets': facets, - 'spelling_suggestion': spelling_suggestion, + 'results': raw_results, # 搜索结果列表 + 'hits': hits, # 总命中数 + 'facets': facets, # 面部结果 + 'spelling_suggestion': spelling_suggestion, # 拼写建议 } class ElasticSearchQuery(BaseSearchQuery): + """ + Elasticsearch 查询类 + 继承自 Haystack 的 BaseSearchQuery,用于构建 Elasticsearch 查询 + """ + def _convert_datetime(self, date): + """ + 转换日期时间为字符串格式 + + 参数: + date: 日期时间对象 + + 返回: + 格式化的日期时间字符串 + """ if hasattr(date, 'hour'): + # 如果有小时属性,格式化为包含时间的字符串 return force_str(date.strftime('%Y%m%d%H%M%S')) else: + # 否则格式化为日期字符串 return force_str(date.strftime('%Y%m%d000000')) def clean(self, query_fragment): """ - Provides a mechanism for sanitizing user input before presenting the - value to the backend. + 清理和净化用户输入的查询片段 + + 参数: + query_fragment: 查询片段字符串 + + 返回: + 清理后的查询字符串 + """ + """ + 提供一种机制来在将值传递给后端之前清理用户输入。 - Whoosh 1.X differs here in that you can no longer use a backslash - to escape reserved characters. Instead, the whole word should be - quoted. + Whoosh 1.X 在这里有所不同,你不能再使用反斜杠来转义保留字符。 + 相反,应该引用整个单词。 """ - words = query_fragment.split() - cleaned_words = [] + words = query_fragment.split() # 分割查询词 + cleaned_words = [] # 存储清理后的词 for word in words: if word in self.backend.RESERVED_WORDS: + # 如果词是保留词,转换为小写 word = word.replace(word, word.lower()) for char in self.backend.RESERVED_CHARACTERS: if char in word: + # 如果包含保留字符,用引号包围整个词 word = "'%s'" % word break - cleaned_words.append(word) + cleaned_words.append(word) # 添加清理后的词 - return ' '.join(cleaned_words) + return ' '.join(cleaned_words) # 返回拼接的清理后查询 def build_query_fragment(self, field, filter_type, value): - return value.query_string + """ + 构建查询片段 + + 参数: + field: 字段名 + filter_type: 过滤器类型 + value: 值对象 + + 返回: + 查询字符串 + """ + return value.query_string # 返回值对象的查询字符串 def get_count(self): - results = self.get_results() - return len(results) if results else 0 + """ + 获取搜索结果计数 + + 返回: + 搜索结果数量 + """ + results = self.get_results() # 获取搜索结果 + return len(results) if results else 0 # 返回结果数量 def get_spelling_suggestion(self, preferred_query=None): - return self._spelling_suggestion + """ + 获取拼写建议 + + 参数: + preferred_query: 首选查询 + + 返回: + 拼写建议 + """ + return self._spelling_suggestion # 返回拼写建议 def build_params(self, spelling_query=None): + """ + 构建查询参数 + + 参数: + spelling_query: 拼写查询 + + 返回: + 查询参数字典 + """ + # 调用父类方法构建参数 kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query) return kwargs class ElasticSearchModelSearchForm(ModelSearchForm): + """ + Elasticsearch 模型搜索表单类 + 继承自 Haystack 的 ModelSearchForm,用于处理搜索表单 + """ def search(self): + """ + 执行搜索 + + 返回: + 搜索结果查询集 + """ # 是否建议搜索 + # 根据表单数据决定是否启用建议搜索 self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no" - sqs = super().search() + sqs = super().search() # 调用父类搜索方法 return sqs class ElasticSearchEngine(BaseEngine): - backend = ElasticSearchBackend - query = ElasticSearchQuery + """ + Elasticsearch 搜索引擎类 + 继承自 Haystack 的 BaseEngine,用于配置 Elasticsearch 搜索引擎 + """ + backend = ElasticSearchBackend # 指定后端类 + query = ElasticSearchQuery # 指定查询类 diff --git a/src/djangoblog/feeds.py b/src/djangoblog/feeds.py index 8c4e851..b1d9beb 100644 --- a/src/djangoblog/feeds.py +++ b/src/djangoblog/feeds.py @@ -1,40 +1,119 @@ -from django.contrib.auth import get_user_model -from django.contrib.syndication.views import Feed -from django.utils import timezone -from django.utils.feedgenerator import Rss201rev2Feed +# feeds.py - Django RSS 订阅功能实现 +# 提供网站内容的 RSS 订阅功能,让用户可以通过 RSS 阅读器获取最新文章 -from blog.models import Article -from djangoblog.utils import CommonMarkdown +# 从 Django 和第三方库导入相关模块 +from django.contrib.auth import get_user_model # 获取用户模型的工具函数 +from django.contrib.syndication.views import Feed # Django RSS 订阅视图基类 +from django.utils import timezone # Django 时区工具 +from django.utils.feedgenerator import Rss201rev2Feed # RSS 2.0 格式生成器 +# 从项目模块导入相关组件 +from blog.models import Article # 文章模型 +from djangoblog.utils import CommonMarkdown # Markdown 处理工具 class DjangoBlogFeed(Feed): + """ + DjangoBlog RSS 订阅类 + 继承自 Django 的 Feed 类,用于生成网站内容的 RSS 订阅源 + """ + + # 指定 RSS 格式为 RSS 2.0 rev2 feed_type = Rss201rev2Feed - description = '大巧无工,重剑无锋.' - title = "且听风吟 大巧无工,重剑无锋. " - link = "/feed/" + # 订阅源描述信息 + description = '大巧无工,重剑无锋.' # 订阅源描述,显示在 RSS 阅读器中 + + # 订阅源标题 + title = "且听风吟 大巧无工,重剑无锋. " # 订阅源标题,显示在 RSS 阅读器中 + + # 订阅源链接 + link = "/feed/" # 订阅源的主链接 def author_name(self): + """ + 获取订阅源作者名称 + + 返回: + 第一个用户(通常是管理员)的昵称 + """ return get_user_model().objects.first().nickname def author_link(self): + """ + 获取订阅源作者链接 + + 返回: + 第一个用户(通常是管理员)的个人主页链接 + """ return get_user_model().objects.first().get_absolute_url() def items(self): + """ + 获取订阅项目列表 + + 返回: + 最新的 5 篇已发布文章,按发布时间倒序排列 + 过滤条件: + - type='a': 文章类型为普通文章(而非页面等其他类型) + - status='p': 文章状态为已发布 + """ return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5] def item_title(self, item): + """ + 获取单个订阅项目的标题 + + 参数: + item: 文章对象 + + 返回: + 文章标题 + """ return item.title def item_description(self, item): + """ + 获取单个订阅项目的描述内容 + + 参数: + item: 文章对象 + + 返回: + 文章正文的 Markdown 渲染后 HTML 内容 + """ return CommonMarkdown.get_markdown(item.body) def feed_copyright(self): - now = timezone.now() + """ + 获取订阅源版权信息 + + 返回: + 包含当前年份的版权声明 + """ + now = timezone.now() # 获取当前时间 return "Copyright© {year} 且听风吟".format(year=now.year) def item_link(self, item): + """ + 获取单个订阅项目的链接 + + 参数: + item: 文章对象 + + 返回: + 文章的绝对 URL + """ return item.get_absolute_url() def item_guid(self, item): + """ + 获取单个订阅项目的全局唯一标识符 + + 参数: + item: 文章对象 + + 返回: + None(使用默认的 GUID 生成方式) + """ return + # 注意:这个方法没有返回值,Django 会自动生成基于 item_link 的 GUID diff --git a/src/djangoblog/logentryadmin.py b/src/djangoblog/logentryadmin.py index 2f6a535..a7148e7 100644 --- a/src/djangoblog/logentryadmin.py +++ b/src/djangoblog/logentryadmin.py @@ -1,91 +1,183 @@ -from django.contrib import admin -from django.contrib.admin.models import DELETION -from django.contrib.contenttypes.models import ContentType -from django.urls import reverse, NoReverseMatch -from django.utils.encoding import force_str -from django.utils.html import escape -from django.utils.safestring import mark_safe -from django.utils.translation import gettext_lazy as _ +# logentryadmin.py - Django 管理日志条目自定义管理类 +# 用于自定义 Django 管理后台中的操作日志显示和管理功能 + +# 从 Django 和第三方库导入相关模块 +from django.contrib import admin # Django 管理后台模块 +from django.contrib.admin.models import DELETION # 管理操作类型常量(删除操作) +from django.contrib.contenttypes.models import ContentType # 内容类型模型 +from django.urls import reverse, NoReverseMatch # URL 反向解析和异常处理 +from django.utils.encoding import force_str # 强制转换为字符串 +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): + """ + 管理日志条目自定义管理类 + 继承自 Django 的 ModelAdmin,用于自定义管理日志在后台的显示和操作 + """ + + # 列表过滤器,允许按内容类型过滤日志条目 list_filter = [ - 'content_type' + 'content_type' # 按内容类型(模型类型)过滤 ] + # 搜索字段,允许搜索对象表示和变更消息 search_fields = [ - 'object_repr', - 'change_message' + 'object_repr', # 对象表示(通常是对象的字符串表示) + 'change_message' # 变更消息(描述了具体的操作内容) ] + # 列表显示链接字段,点击这些字段可以进入详情页 list_display_links = [ - 'action_time', - 'get_change_message', + 'action_time', # 操作时间 + 'get_change_message', # 变更消息 ] + + # 列表显示字段,定义在列表页显示哪些字段 list_display = [ - 'action_time', - 'user_link', - 'content_type', - 'object_link', - 'get_change_message', + 'action_time', # 操作时间 + 'user_link', # 用户链接(自定义方法) + 'content_type', # 内容类型 + 'object_link', # 对象链接(自定义方法) + 'get_change_message', # 变更消息 ] def has_add_permission(self, request): - return False + """ + 控制是否允许添加日志条目 + + 参数: + request: HTTP 请求对象 + + 返回: + False 表示不允许手动添加日志条目 + """ + return False # 禁止添加日志条目,日志应由系统自动生成 def has_change_permission(self, request, obj=None): + """ + 控制是否允许修改日志条目 + + 参数: + request: HTTP 请求对象 + obj: 要检查权限的对象(可选) + + 返回: + 布尔值,表示是否允许修改 + """ return ( - request.user.is_superuser or - request.user.has_perm('admin.change_logentry') - ) and request.method != 'POST' + request.user.is_superuser or # 超级用户有权限 + request.user.has_perm('admin.change_logentry') # 或具有修改日志权限的用户 + ) and request.method != 'POST' # 且请求方法不是 POST(防止表单提交) def has_delete_permission(self, request, obj=None): - return False + """ + 控制是否允许删除日志条目 + + 参数: + request: HTTP 请求对象 + obj: 要检查权限的对象(可选) + + 返回: + False 表示不允许删除日志条目 + """ + return False # 禁止删除日志条目,保持审计记录完整性 def object_link(self, obj): - object_link = escape(obj.object_repr) - content_type = obj.content_type + """ + 生成对象链接的显示内容 + 参数: + obj: LogEntry 对象 + + 返回: + 对象的 HTML 链接或纯文本表示 + """ + object_link = escape(obj.object_repr) # 转义对象表示,防止 XSS 攻击 + content_type = obj.content_type # 获取内容类型 + + # 如果不是删除操作且内容类型存在 if obj.action_flag != DELETION and content_type is not None: - # try returning an actual link instead of object repr string + # 尝试返回实际链接而不是对象表示字符串 try: + # 构建管理后台编辑页面的 URL url = reverse( 'admin:{}_{}_change'.format(content_type.app_label, content_type.model), - args=[obj.object_id] + args=[obj.object_id] # 对象 ID ) + # 创建 HTML 链接 object_link = '{}'.format(url, object_link) except NoReverseMatch: + # 如果无法构建 URL,则保持原始文本 pass - return mark_safe(object_link) + return mark_safe(object_link) # 标记为安全 HTML 并返回 - object_link.admin_order_field = 'object_repr' - object_link.short_description = _('object') + # 设置管理后台的排序字段和显示名称 + object_link.admin_order_field = 'object_repr' # 按 object_repr 字段排序 + object_link.short_description = _('object') # 显示名称为"对象" def user_link(self, obj): + """ + 生成用户链接的显示内容 + + 参数: + obj: LogEntry 对象 + + 返回: + 用户的 HTML 链接或纯文本表示 + """ + # 获取用户对象的内容类型 content_type = ContentType.objects.get_for_model(type(obj.user)) - user_link = escape(force_str(obj.user)) + user_link = escape(force_str(obj.user)) # 转义用户表示 try: - # try returning an actual link instead of object repr string + # 尝试返回实际链接而不是对象表示字符串 url = reverse( 'admin:{}_{}_change'.format(content_type.app_label, content_type.model), - args=[obj.user.pk] + args=[obj.user.pk] # 用户主键 ) + # 创建 HTML 链接 user_link = '{}'.format(url, user_link) except NoReverseMatch: + # 如果无法构建 URL,则保持原始文本 pass - return mark_safe(user_link) + return mark_safe(user_link) # 标记为安全 HTML 并返回 - user_link.admin_order_field = 'user' - user_link.short_description = _('user') + # 设置管理后台的排序字段和显示名称 + user_link.admin_order_field = 'user' # 按 user 字段排序 + user_link.short_description = _('user') # 显示名称为"用户" def get_queryset(self, request): + """ + 获取查询集,优化数据库查询 + + 参数: + request: HTTP 请求对象 + + 返回: + 优化后的查询集 + """ + # 调用父类方法获取基础查询集 queryset = super(LogEntryAdmin, self).get_queryset(request) + # 预加载 content_type 关联对象,减少数据库查询次数 return queryset.prefetch_related('content_type') def get_actions(self, request): + """ + 获取可用的操作列表 + + 参数: + request: HTTP 请求对象 + + 返回: + 可用操作字典 + """ + # 调用父类方法获取基础操作列表 actions = super(LogEntryAdmin, self).get_actions(request) + # 如果存在删除选中项操作,则移除它(因为我们禁用了删除权限) if 'delete_selected' in actions: del actions['delete_selected'] return actions diff --git a/src/djangoblog/plugin_manage/base_plugin.py b/src/djangoblog/plugin_manage/base_plugin.py index 2b4be5c..6621d02 100644 --- a/src/djangoblog/plugin_manage/base_plugin.py +++ b/src/djangoblog/plugin_manage/base_plugin.py @@ -1,41 +1,57 @@ -import logging +# base_plugin.py - 插件管理系统的基类定义 +# 提供插件的基本结构和通用功能,所有插件都需要继承此类 -logger = logging.getLogger(__name__) +import logging # 导入日志模块,用于记录插件运行信息 + +logger = logging.getLogger(__name__) # 创建当前模块的日志记录器 class BasePlugin: - # 插件元数据 - PLUGIN_NAME = None - PLUGIN_DESCRIPTION = None - PLUGIN_VERSION = None + # 插件元数据属性定义(需要在子类中具体实现) + PLUGIN_NAME = None # 插件名称 + PLUGIN_DESCRIPTION = None # 插件描述 + PLUGIN_VERSION = None # 插件版本 def __init__(self): + """ + 插件初始化方法 + 在创建插件实例时验证必要元数据并执行初始化流程 + """ + # 验证插件元数据是否完整定义 if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]): + # 如果任何必需的元数据未定义,则抛出值错误异常 raise ValueError("Plugin metadata (PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION) must be defined.") - self.init_plugin() - self.register_hooks() + + self.init_plugin() # 调用插件初始化方法 + self.register_hooks() # 调用钩子注册方法 def init_plugin(self): """ 插件初始化逻辑 子类可以重写此方法来实现特定的初始化操作 + 例如:数据库连接、配置加载、资源初始化等 """ + # 记录插件初始化日志信息 logger.info(f'{self.PLUGIN_NAME} initialized.') def register_hooks(self): """ 注册插件钩子 子类可以重写此方法来注册特定的钩子 + 钩子是插件系统中用于在特定时机执行代码的机制 + 例如:在文章发布前、用户注册后等事件触发时执行插件逻辑 """ + # 基类中的默认实现为空,子类可以根据需要实现具体逻辑 pass def get_plugin_info(self): """ 获取插件信息 + 返回插件的基本元数据信息,供插件管理系统使用 :return: 包含插件元数据的字典 """ return { - 'name': self.PLUGIN_NAME, - 'description': self.PLUGIN_DESCRIPTION, - 'version': self.PLUGIN_VERSION + 'name': self.PLUGIN_NAME, # 插件名称 + 'description': self.PLUGIN_DESCRIPTION, # 插件描述 + 'version': self.PLUGIN_VERSION # 插件版本 } diff --git a/src/djangoblog/plugin_manage/hook_constants.py b/src/djangoblog/plugin_manage/hook_constants.py index 6685b7c..b02bbab 100644 --- a/src/djangoblog/plugin_manage/hook_constants.py +++ b/src/djangoblog/plugin_manage/hook_constants.py @@ -1,7 +1,21 @@ -ARTICLE_DETAIL_LOAD = 'article_detail_load' -ARTICLE_CREATE = 'article_create' -ARTICLE_UPDATE = 'article_update' -ARTICLE_DELETE = 'article_delete' +# hook_constants.py - 插件钩子常量定义文件 +# 定义了插件系统中可用的各种钩子常量,用于在特定事件发生时触发插件功能 -ARTICLE_CONTENT_HOOK_NAME = "the_content" +# 文章相关操作的钩子常量 +ARTICLE_DETAIL_LOAD = 'article_detail_load' # 文章详情页面加载时触发的钩子 + # 当用户访问文章详情页时执行相关插件逻辑 + +ARTICLE_CREATE = 'article_create' # 文章创建时触发的钩子 + # 当新文章被创建时执行相关插件逻辑 + +ARTICLE_UPDATE = 'article_update' # 文章更新时触发的钩子 + # 当文章被修改更新时执行相关插件逻辑 + +ARTICLE_DELETE = 'article_delete' # 文章删除时触发的钩子 + # 当文章被删除时执行相关插件逻辑 + +# 文章内容处理钩子名称 +ARTICLE_CONTENT_HOOK_NAME = "the_content" # 文章内容处理钩子 + # 用于在文章内容显示前对其进行处理或修改 + # 例如:添加广告、插入相关链接、内容过滤等 diff --git a/src/djangoblog/plugin_manage/hooks.py b/src/djangoblog/plugin_manage/hooks.py index d712540..c694ced 100644 --- a/src/djangoblog/plugin_manage/hooks.py +++ b/src/djangoblog/plugin_manage/hooks.py @@ -1,44 +1,109 @@ -import logging +# hooks.py - 插件钩子管理系统 +# 提供插件钩子的注册和执行功能,是插件系统的核心组件 -logger = logging.getLogger(__name__) +import logging # 导入日志模块,用于记录钩子执行过程中的信息 +logger = logging.getLogger(__name__) # 创建当前模块的日志记录器 + +# 全局钩子注册表,存储所有已注册的钩子回调函数 +# 数据结构为字典:{钩子名称: [回调函数列表]} _hooks = {} def register(hook_name: str, callback: callable): """ - 注册一个钩子回调。 + 注册一个钩子回调函数 + + 参数: + hook_name (str): 钩子名称,用于标识特定的钩子事件 + callback (callable): 回调函数,当钩子被触发时执行的函数 + + 功能: + 将回调函数添加到指定钩子的回调列表中 + 如果钩子名称不存在,则创建新的钩子条目 """ + # 检查钩子名称是否已存在于钩子注册表中 if hook_name not in _hooks: + # 如果不存在,则创建一个新的空列表用于存储回调函数 _hooks[hook_name] = [] + + # 将回调函数添加到对应钩子的回调列表中 _hooks[hook_name].append(callback) + + # 记录调试日志,显示已注册的钩子和回调函数名称 logger.debug(f"Registered hook '{hook_name}' with callback '{callback.__name__}'") def run_action(hook_name: str, *args, **kwargs): """ - 执行一个 Action Hook。 - 它会按顺序执行所有注册到该钩子上的回调函数。 + 执行一个 Action Hook(动作钩子) + + Action Hook 特点: + - 不需要返回值 + - 按顺序执行所有注册到该钩子上的回调函数 + - 通常用于在特定事件发生时执行副作用操作 + + 参数: + hook_name (str): 要执行的钩子名称 + *args: 传递给回调函数的位置参数 + **kwargs: 传递给回调函数的关键字参数 + + 功能: + 依次执行所有注册到指定钩子上的回调函数 + 每个回调函数独立执行,一个回调函数的异常不会影响其他回调函数的执行 """ + # 检查指定的钩子是否存在已注册的回调函数 if hook_name in _hooks: + # 记录调试日志,显示正在执行的动作钩子 logger.debug(f"Running action hook '{hook_name}'") + + # 遍历并执行所有注册到该钩子的回调函数 for callback in _hooks[hook_name]: try: + # 执行回调函数,传递参数 callback(*args, **kwargs) except Exception as e: + # 如果回调函数执行出错,记录错误日志但继续执行其他回调函数 logger.error(f"Error running action hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) def apply_filters(hook_name: str, value, *args, **kwargs): """ - 执行一个 Filter Hook。 - 它会把 value 依次传递给所有注册的回调函数进行处理。 + 执行一个 Filter Hook(过滤器钩子) + + Filter Hook 特点: + - 需要处理并返回一个值 + - 将值依次传递给所有注册的回调函数进行处理 + - 每个回调函数的返回值作为下一个回调函数的输入 + + 参数: + hook_name (str): 要执行的钩子名称 + value: 需要被处理的初始值 + *args: 传递给回调函数的位置参数 + **kwargs: 传递给回调函数的关键字参数 + + 返回: + 处理后的最终值,经过所有回调函数处理的结果 + + 功能: + 将初始值依次传递给所有注册到指定钩子的回调函数 + 每个回调函数处理值并返回处理结果,作为下一个回调函数的输入 + 如果某个回调函数执行出错,记录错误但继续执行其他回调函数 """ + # 检查指定的钩子是否存在已注册的回调函数 if hook_name in _hooks: + # 记录调试日志,显示正在应用的过滤器钩子 logger.debug(f"Applying filter hook '{hook_name}'") + + # 遍历所有注册到该钩子的回调函数 for callback in _hooks[hook_name]: try: + # 将当前值传递给回调函数处理,并将返回值作为新的值 + # 实现链式处理:value = callback3(callback2(callback1(value))) value = callback(value, *args, **kwargs) except Exception as e: + # 如果回调函数执行出错,记录错误日志但继续执行其他回调函数 logger.error(f"Error applying filter hook '{hook_name}' callback '{callback.__name__}': {e}", exc_info=True) + + # 返回经过所有回调函数处理后的最终值 return value diff --git a/src/djangoblog/plugin_manage/loader.py b/src/djangoblog/plugin_manage/loader.py index 12e824b..05c4217 100644 --- a/src/djangoblog/plugin_manage/loader.py +++ b/src/djangoblog/plugin_manage/loader.py @@ -1,19 +1,49 @@ -import os -import logging -from django.conf import settings +# loader.py - 插件加载器模块 +# 负责动态加载和初始化插件系统中的插件 + +import os # 导入操作系统接口模块,用于文件路径操作 +import logging # 导入日志模块,用于记录插件加载过程中的信息 +from django.conf import settings # 从 Django 配置中导入设置模块 + +logger = logging.getLogger(__name__) # 创建当前模块的日志记录器 -logger = logging.getLogger(__name__) def load_plugins(): """ - Dynamically loads and initializes plugins from the 'plugins' directory. - This function is intended to be called when the Django app registry is ready. + 动态加载并初始化 'plugins' 目录中的插件 + + 此函数应在 Django 应用注册表准备就绪后调用,确保所有 Django 组件都已正确初始化 + + 加载过程: + 1. 遍历配置中激活的插件列表 + 2. 检查插件目录和必要文件是否存在 + 3. 动态导入插件模块 + 4. 记录加载成功或失败的日志信息 + + 插件目录结构要求: + - 插件必须位于 settings.PLUGINS_DIR 指定的目录下 + - 每个插件应是一个独立的目录 + - 插件目录中必须包含 plugin.py 文件作为插件入口点 """ + + # 遍历配置文件中定义的激活插件列表 (ACTIVE_PLUGINS) for plugin_name in settings.ACTIVE_PLUGINS: + # 构建插件目录的完整路径 + # os.path.join 确保路径格式正确(跨平台兼容) plugin_path = os.path.join(settings.PLUGINS_DIR, plugin_name) + + # 检查插件目录是否存在且包含 plugin.py 入口文件 if os.path.isdir(plugin_path) and os.path.exists(os.path.join(plugin_path, 'plugin.py')): try: + # 动态导入插件模块 + # 使用 __import__ 函数导入插件模块,触发插件的初始化 + # 导入路径格式:'plugins.{插件名}.plugin' __import__(f'plugins.{plugin_name}.plugin') + + # 记录插件成功加载的日志信息 logger.info(f"Successfully loaded plugin: {plugin_name}") + except ImportError as e: - logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) \ No newline at end of file + # 如果插件导入失败,记录错误日志 + # exc_info=True 参数会包含完整的异常堆栈信息,便于调试 + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) diff --git a/src/djangoblog/settings.py b/src/djangoblog/settings.py index 30f9ac5..dcc399d 100644 --- a/src/djangoblog/settings.py +++ b/src/djangoblog/settings.py @@ -9,335 +9,367 @@ https://docs.djangoproject.com/en/1.10/topics/settings/ For the full list of settings and their values, see https://docs.djangoproject.com/en/1.10/ref/settings/ """ +# 导入系统模块 import os import sys -from pathlib import Path - -from django.utils.translation import gettext_lazy as _ +from pathlib import Path # 用于处理文件路径的现代化方式 +from django.utils.translation import gettext_lazy as _ # 国际化翻译函数 +# 定义环境变量转布尔值的辅助函数 def env_to_bool(env, default): - str_val = os.environ.get(env) - return default if str_val is None else str_val == 'True' + """ + 将环境变量转换为布尔值 + + 参数: + env: 环境变量名称 + default: 默认值 + 返回: + 布尔值 + """ + str_val = os.environ.get(env) # 获取环境变量值 + return default if str_val is None else str_val == 'True' # 如果未设置则使用默认值,否则判断是否为 'True' # Build paths inside the project like this: BASE_DIR / 'subdir'. +# 构建项目内的路径,使用 Path 对象更安全和跨平台 BASE_DIR = Path(__file__).resolve().parent.parent # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.10/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! +# 安全警告:在生产环境中保持密钥的机密性 SECRET_KEY = os.environ.get( 'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6' + # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env_to_bool('DJANGO_DEBUG', True) -# DEBUG = False -TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' +# 安全警告:在生产环境中不要开启调试模式 +DEBUG = env_to_bool('DJANGO_DEBUG', True) # 从环境变量获取调试设置,默认为 True +# DEBUG = False # 注释掉的代码:手动设置调试模式为 False + +TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' # 判断是否在运行测试 -# ALLOWED_HOSTS = [] -ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] -# django 4.0新增配置 +# ALLOWED_HOSTS = [] # 注释掉的默认配置 +ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] # 允许的主机列表,* 表示允许所有主机 + +# django 4.0新增配置 - CSRF 可信来源 CSRF_TRUSTED_ORIGINS = ['http://example.com'] -# Application definition +# Application definition +# 应用定义:列出项目中使用的所有 Django 应用 INSTALLED_APPS = [ - # 'django.contrib.admin', - 'django.contrib.admin.apps.SimpleAdminConfig', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.sites', - 'django.contrib.sitemaps', - 'mdeditor', - 'haystack', - 'blog', - 'accounts', - 'comments', - 'oauth', - 'servermanager', - 'owntracks', - 'compressor', - 'djangoblog' + # 'django.contrib.admin', # 注释掉的默认管理应用 + 'django.contrib.admin.apps.SimpleAdminConfig', # 使用简化配置的管理应用 + 'django.contrib.auth', # 用户认证系统 + 'django.contrib.contenttypes', # 内容类型框架 + 'django.contrib.sessions', # 会话框架 + 'django.contrib.messages', # 消息框架 + 'django.contrib.staticfiles', # 静态文件管理 + 'django.contrib.sites', # 站点框架 + 'django.contrib.sitemaps', # 站点地图框架 + 'mdeditor', # Markdown 编辑器 + 'haystack', # 全文搜索框架 + 'blog', # 博客应用 + 'accounts', # 账户管理应用 + 'comments', # 评论应用 + 'oauth', # OAuth 认证应用 + 'servermanager', # 服务器管理应用 + 'owntracks', # OwnTracks 位置追踪应用 + 'compressor', # 静态文件压缩工具 + 'djangoblog' # 主应用 ] +# 中间件配置:定义请求/响应处理的顺序 MIDDLEWARE = [ - - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.locale.LocaleMiddleware', - 'django.middleware.gzip.GZipMiddleware', - # 'django.middleware.cache.UpdateCacheMiddleware', - 'django.middleware.common.CommonMiddleware', - # 'django.middleware.cache.FetchFromCacheMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'django.middleware.http.ConditionalGetMiddleware', - 'blog.middleware.OnlineMiddleware' + 'django.middleware.security.SecurityMiddleware', # 安全中间件 + 'django.contrib.sessions.middleware.SessionMiddleware', # 会话中间件 + 'django.middleware.locale.LocaleMiddleware', # 国际化中间件 + 'django.middleware.gzip.GZipMiddleware', # GZip 压缩中间件 + # 'django.middleware.cache.UpdateCacheMiddleware', # 注释掉的缓存更新中间件 + 'django.middleware.common.CommonMiddleware', # 通用中间件 + # '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', # 条件 GET 中间件 + 'blog.middleware.OnlineMiddleware' # 自定义在线用户统计中间件 ] -ROOT_URLCONF = 'djangoblog.urls' +ROOT_URLCONF = 'djangoblog.urls' # 根 URL 配置文件 +# 模板配置 TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [os.path.join(BASE_DIR, 'templates')], - 'APP_DIRS': True, + 'BACKEND': 'django.template.backends.django.DjangoTemplates', # 使用 Django 模板引擎 + 'DIRS': [os.path.join(BASE_DIR, 'templates')], # 模板目录 + 'APP_DIRS': True, # 自动在应用目录中查找模板 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - 'blog.context_processors.seo_processor' + 'context_processors': [ # 上下文处理器 + 'django.template.context_processors.debug', # 调试上下文处理器 + 'django.template.context_processors.request', # 请求上下文处理器 + 'django.contrib.auth.context_processors.auth', # 认证上下文处理器 + 'django.contrib.messages.context_processors.messages', # 消息上下文处理器 + 'blog.context_processors.seo_processor' # 自定义 SEO 上下文处理器 ], }, }, ] -WSGI_APPLICATION = 'djangoblog.wsgi.application' +WSGI_APPLICATION = 'djangoblog.wsgi.application' # WSGI 应用入口 # Database # https://docs.djangoproject.com/en/1.10/ref/settings/#databases - +# 数据库配置:使用 MySQL DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.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 '123456', - 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', + 'ENGINE': 'django.db.backends.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 '123456', # 数据库密码 + 'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', # 数据库主机 'PORT': int( - os.environ.get('DJANGO_MYSQL_PORT') or 3306), + os.environ.get('DJANGO_MYSQL_PORT') or 3306), # 数据库端口 'OPTIONS': { - 'charset': 'utf8mb4'}, + 'charset': 'utf8mb4'}, # 字符集配置 }} # Password validation # https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators +# 密码验证器配置 AUTH_PASSWORD_VALIDATORS = [ { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', # 用户属性相似性验证 }, { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', # 最小长度验证 }, { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', # 常见密码验证 }, { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', # 数字密码验证 }, ] -LANGUAGES = ( - ('en', _('English')), - ('zh-hans', _('Simplified Chinese')), - ('zh-hant', _('Traditional Chinese')), +# 国际化配置 +LANGUAGES = ( # 支持的语言列表 + ('en', _('English')), # 英语 + ('zh-hans', _('Simplified Chinese')), # 简体中文 + ('zh-hant', _('Traditional Chinese')), # 繁体中文 ) -LOCALE_PATHS = ( + +LOCALE_PATHS = ( # 国际化文件路径 os.path.join(BASE_DIR, 'locale'), ) -LANGUAGE_CODE = 'zh-hans' - -TIME_ZONE = 'Asia/Shanghai' - -USE_I18N = True - -USE_L10N = True - -USE_TZ = False +LANGUAGE_CODE = 'zh-hans' # 默认语言代码 +TIME_ZONE = 'Asia/Shanghai' # 时区设置 +USE_I18N = True # 启用国际化 +USE_L10N = True # 启用本地化 +USE_TZ = False # 不使用时区感知 # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/1.10/howto/static-files/ +# 静态文件配置 - -HAYSTACK_CONNECTIONS = { +HAYSTACK_CONNECTIONS = { # Haystack 搜索引擎配置 'default': { - 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', - 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), + 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', # 使用 Whoosh 搜索引擎(中文支持) + 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), # 索引文件路径 }, } + # Automatically update searching index -HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' -# Allow user login with username and password -AUTHENTICATION_BACKENDS = [ - 'accounts.user_login_backend.EmailOrUsernameModelBackend'] +HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' # 实时更新搜索索引 -STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') +# Allow user login with username and password +AUTHENTICATION_BACKENDS = [ # 认证后端配置 + 'accounts.user_login_backend.EmailOrUsernameModelBackend' # 允许使用邮箱或用户名登录 +] -STATIC_URL = '/static/' -STATICFILES = os.path.join(BASE_DIR, 'static') +STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') # 静态文件收集目录 +STATIC_URL = '/static/' # 静态文件 URL 前缀 +STATICFILES = os.path.join(BASE_DIR, 'static') # 静态文件目录 -AUTH_USER_MODEL = 'accounts.BlogUser' -LOGIN_URL = '/login/' +AUTH_USER_MODEL = 'accounts.BlogUser' # 自定义用户模型 +LOGIN_URL = '/login/' # 登录 URL -TIME_FORMAT = '%Y-%m-%d %H:%M:%S' -DATE_TIME_FORMAT = '%Y-%m-%d' +TIME_FORMAT = '%Y-%m-%d %H:%M:%S' # 时间格式 +DATE_TIME_FORMAT = '%Y-%m-%d' # 日期格式 # bootstrap color styles -BOOTSTRAP_COLOR_TYPES = [ +BOOTSTRAP_COLOR_TYPES = [ # Bootstrap 颜色样式列表 'default', 'primary', 'success', 'info', 'warning', 'danger' ] # paginate -PAGINATE_BY = 10 +PAGINATE_BY = 10 # 分页每页显示数量 # http cache timeout -CACHE_CONTROL_MAX_AGE = 2592000 +CACHE_CONTROL_MAX_AGE = 2592000 # HTTP 缓存最大年龄(30天) + # cache setting -CACHES = { +CACHES = { # 缓存配置 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'TIMEOUT': 10800, - 'LOCATION': 'unique-snowflake', + '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")}', + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', # 使用 Redis 缓存 + 'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}', # Redis 连接地址 } } -SITE_ID = 1 +SITE_ID = 1 # 站点框架 ID + +# 百度搜索引擎主动推送 URL BAIDU_NOTIFY_URL = os.environ.get('DJANGO_BAIDU_NOTIFY_URL') \ or 'http://data.zz.baidu.com/urls?site=https://www.lylinux.net&token=1uAOGrMsUm5syDGn' -# Email: -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) -EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) -EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' -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') -DEFAULT_FROM_EMAIL = EMAIL_HOST_USER -SERVER_EMAIL = EMAIL_HOST_USER +# Email 配置: +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' # 使用 SMTP 发送邮件 +EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) # 是否使用 TLS +EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) # 是否使用 SSL +EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' # 邮件服务器主机 +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') # 邮件密码 +DEFAULT_FROM_EMAIL = EMAIL_HOST_USER # 默认发件人邮箱 +SERVER_EMAIL = EMAIL_HOST_USER # 服务器错误邮件发送地址 + # Setting debug=false did NOT handle except email notifications -ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] +ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] # 管理员邮箱 + # WX ADMIN password(Two times md5) WXADMIN = os.environ.get( - 'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' + 'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' # 微信管理员密码(两次 MD5 加密) -LOG_PATH = os.path.join(BASE_DIR, 'logs') +# 日志配置 +LOG_PATH = os.path.join(BASE_DIR, 'logs') # 日志文件路径 if not os.path.exists(LOG_PATH): - os.makedirs(LOG_PATH, exist_ok=True) + os.makedirs(LOG_PATH, exist_ok=True) # 创建日志目录 LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'root': { - 'level': 'INFO', - 'handlers': ['console', 'log_file'], + 'version': 1, # 日志配置版本 + 'disable_existing_loggers': False, # 不禁用现有的日志记录器 + 'root': { # 根日志记录器 + 'level': 'INFO', # 日志级别 + 'handlers': ['console', 'log_file'], # 处理器 }, - 'formatters': { + 'formatters': { # 日志格式化器 'verbose': { 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', } }, - 'filters': { + 'filters': { # 日志过滤器 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse', + '()': 'django.utils.log.RequireDebugFalse', # 要求调试为 False }, 'require_debug_true': { - '()': 'django.utils.log.RequireDebugTrue', + '()': 'django.utils.log.RequireDebugTrue', # 要求调试为 True }, }, - 'handlers': { + 'handlers': { # 日志处理器 'log_file': { - 'level': 'INFO', - 'class': 'logging.handlers.TimedRotatingFileHandler', - 'filename': os.path.join(LOG_PATH, 'djangoblog.log'), - 'when': 'D', - 'formatter': 'verbose', - 'interval': 1, - 'delay': True, - 'backupCount': 5, - 'encoding': 'utf-8' + 'level': 'INFO', # 处理器级别 + 'class': 'logging.handlers.TimedRotatingFileHandler', # 按时间轮转的文件处理器 + 'filename': os.path.join(LOG_PATH, 'djangoblog.log'), # 日志文件名 + 'when': 'D', # 每天轮转 + 'formatter': 'verbose', # 使用详细格式 + 'interval': 1, # 轮转间隔 + 'delay': True, # 延迟创建文件 + 'backupCount': 5, # 保留备份数量 + 'encoding': 'utf-8' # 文件编码 }, 'console': { - 'level': 'DEBUG', - 'filters': ['require_debug_true'], - 'class': 'logging.StreamHandler', - 'formatter': 'verbose' + 'level': 'DEBUG', # 控制台处理器级别 + 'filters': ['require_debug_true'], # 仅在调试模式下使用 + 'class': 'logging.StreamHandler', # 控制台处理器 + 'formatter': 'verbose' # 使用详细格式 }, 'null': { - 'class': 'logging.NullHandler', + 'class': 'logging.NullHandler', # 空处理器 }, 'mail_admins': { - 'level': 'ERROR', - 'filters': ['require_debug_false'], - 'class': 'django.utils.log.AdminEmailHandler' + 'level': 'ERROR', # 邮件处理器级别 + 'filters': ['require_debug_false'], # 仅在非调试模式下使用 + 'class': 'django.utils.log.AdminEmailHandler' # 管理员邮件处理器 } }, - 'loggers': { + 'loggers': { # 日志记录器配置 'djangoblog': { - 'handlers': ['log_file', 'console'], - 'level': 'INFO', - 'propagate': True, + 'handlers': ['log_file', 'console'], # 使用的处理器 + 'level': 'INFO', # 日志级别 + 'propagate': True, # 是否传播到父级记录器 }, 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': False, + 'handlers': ['mail_admins'], # 错误请求发送邮件给管理员 + 'level': 'ERROR', # 错误级别 + 'propagate': False, # 不传播到父级记录器 } } } +# 静态文件查找器配置 STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'django.contrib.staticfiles.finders.FileSystemFinder', # 文件系统查找器 + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', # 应用目录查找器 # other - 'compressor.finders.CompressorFinder', + 'compressor.finders.CompressorFinder', # 压缩文件查找器 ) -COMPRESS_ENABLED = True -# COMPRESS_OFFLINE = True +COMPRESS_ENABLED = True # 启用静态文件压缩 +# COMPRESS_OFFLINE = True # 注释掉的离线压缩配置 +# CSS 压缩过滤器 COMPRESS_CSS_FILTERS = [ # creates absolute urls from relative ones - 'compressor.filters.css_default.CssAbsoluteFilter', + 'compressor.filters.css_default.CssAbsoluteFilter', # 创建绝对 URL # css minimizer - 'compressor.filters.cssmin.CSSMinFilter' + 'compressor.filters.cssmin.CSSMinFilter' # CSS 最小化 ] + +# JavaScript 压缩过滤器 COMPRESS_JS_FILTERS = [ - 'compressor.filters.jsmin.JSMinFilter' + 'compressor.filters.jsmin.JSMinFilter' # JS 最小化 ] -MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') -MEDIA_URL = '/media/' -X_FRAME_OPTIONS = 'SAMEORIGIN' +MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') # 媒体文件上传目录 +MEDIA_URL = '/media/' # 媒体文件 URL 前缀 +X_FRAME_OPTIONS = 'SAMEORIGIN' # X-Frame-Options 安全头设置 -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # 默认自动字段类型 +# 如果配置了 Elasticsearch,则使用 Elasticsearch 作为搜索后端 if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): ELASTICSEARCH_DSL = { 'default': { - 'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') + 'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') # Elasticsearch 主机地址 }, } HAYSTACK_CONNECTIONS = { 'default': { - 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', + 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', # 使用自定义 Elasticsearch 后端 }, } # Plugin System -PLUGINS_DIR = BASE_DIR / 'plugins' -ACTIVE_PLUGINS = [ - 'article_copyright', - 'reading_time', - 'external_links', - 'view_count', - 'seo_optimizer' -] \ No newline at end of file +# 插件系统配置 +PLUGINS_DIR = BASE_DIR / 'plugins' # 插件目录路径 +ACTIVE_PLUGINS = [ # 激活的插件列表 + 'article_copyright', # 文章版权插件 + 'reading_time', # 阅读时间插件 + 'external_links', # 外部链接插件 + 'view_count', # 浏览量统计插件 + 'seo_optimizer' # SEO 优化插件 +] diff --git a/src/djangoblog/sitemap.py b/src/djangoblog/sitemap.py index 8b7d446..561cefe 100644 --- a/src/djangoblog/sitemap.py +++ b/src/djangoblog/sitemap.py @@ -1,59 +1,161 @@ -from django.contrib.sitemaps import Sitemap -from django.urls import reverse +# sitemap.py - Django 网站地图生成模块 +# 用于生成符合 Google Sitemap 协议的 XML 网站地图,帮助搜索引擎更好地索引网站内容 -from blog.models import Article, Category, Tag +# 从 Django 和项目模块导入相关组件 +from django.contrib.sitemaps import Sitemap # Django 网站地图基类 +from django.urls import reverse # URL 反向解析函数 + +# 从博客应用导入相关模型 +from blog.models import Article, Category, Tag # 文章、分类、标签模型 class StaticViewSitemap(Sitemap): - priority = 0.5 - changefreq = 'daily' + """ + 静态页面网站地图类 + 继承自 Django 的 Sitemap 类,用于生成静态页面的网站地图 + """ + priority = 0.5 # 页面优先级(0.0-1.0),默认中等优先级 + changefreq = 'daily' # 页面更新频率:每天 def items(self): - return ['blog:index', ] + """ + 返回网站地图包含的项目列表 + + 返回: + 包含静态页面 URL 名称的列表 + """ + return ['blog:index', ] # 返回首页的 URL 名称 def location(self, item): - return reverse(item) + """ + 返回项目的绝对 URL + + 参数: + item: URL 名称 + + 返回: + 对应的绝对 URL 路径 + """ + return reverse(item) # 使用 reverse 函数将 URL 名称转换为绝对路径 class ArticleSiteMap(Sitemap): - changefreq = "monthly" - priority = "0.6" + """ + 文章网站地图类 + 用于生成文章页面的网站地图 + """ + changefreq = "monthly" # 页面更新频率:每月 + priority = "0.6" # 页面优先级:中等偏上 def items(self): + """ + 返回网站地图包含的文章项目列表 + + 返回: + 已发布文章的查询集 + """ + # 只包含状态为已发布的文章(status='p') return Article.objects.filter(status='p') def lastmod(self, obj): - return obj.last_modify_time + """ + 返回文章最后修改时间 + + 参数: + obj: Article 对象 + + 返回: + 文章最后修改时间 + """ + return obj.last_modify_time # 返回文章的最后修改时间字段 class CategorySiteMap(Sitemap): - changefreq = "Weekly" - priority = "0.6" + """ + 分类网站地图类 + 用于生成文章分类页面的网站地图 + """ + changefreq = "Weekly" # 页面更新频率:每周 + priority = "0.6" # 页面优先级:中等偏上 def items(self): - return Category.objects.all() + """ + 返回网站地图包含的分类项目列表 + + 返回: + 所有分类的查询集 + """ + return Category.objects.all() # 返回所有分类 def lastmod(self, obj): - return obj.last_modify_time + """ + 返回分类最后修改时间 + + 参数: + obj: Category 对象 + + 返回: + 分类最后修改时间 + """ + return obj.last_modify_time # 返回分类的最后修改时间字段 class TagSiteMap(Sitemap): - changefreq = "Weekly" - priority = "0.3" + """ + 标签网站地图类 + 用于生成文章标签页面的网站地图 + """ + changefreq = "Weekly" # 页面更新频率:每周 + priority = "0.3" # 页面优先级:较低 def items(self): - return Tag.objects.all() + """ + 返回网站地图包含的标签项目列表 + + 返回: + 所有标签的查询集 + """ + return Tag.objects.all() # 返回所有标签 def lastmod(self, obj): - return obj.last_modify_time + """ + 返回标签最后修改时间 + + 参数: + obj: Tag 对象 + + 返回: + 标签最后修改时间 + """ + return obj.last_modify_time # 返回标签的最后修改时间字段 class UserSiteMap(Sitemap): - changefreq = "Weekly" - priority = "0.3" + """ + 用户网站地图类 + 用于生成用户页面的网站地图 + """ + changefreq = "Weekly" # 页面更新频率:每周 + priority = "0.3" # 页面优先级:较低 def items(self): + """ + 返回网站地图包含的用户项目列表 + + 返回: + 所有文章作者的用户对象列表(去重) + """ + # 获取所有文章的作者,使用 set 去重,再转换为列表 return list(set(map(lambda x: x.author, Article.objects.all()))) def lastmod(self, obj): - return obj.date_joined + """ + 返回用户最后修改时间(注册时间) + + 参数: + obj: User 对象 + + 返回: + 用户注册时间 + """ + return obj.date_joined # 返回用户的注册时间字段 diff --git a/src/djangoblog/spider_notify.py b/src/djangoblog/spider_notify.py index 7b909e9..b2b44f6 100644 --- a/src/djangoblog/spider_notify.py +++ b/src/djangoblog/spider_notify.py @@ -1,21 +1,63 @@ -import logging +# spider_notify.py - 搜索引擎主动推送通知模块 +# 用于主动向搜索引擎推送网站更新的 URL,帮助搜索引擎及时发现和索引新内容 -import requests -from django.conf import settings +import logging # 导入日志模块,用于记录推送过程中的信息和错误 -logger = logging.getLogger(__name__) +import requests # 导入 HTTP 请求库,用于向搜索引擎发送推送请求 +from django.conf import settings # 从 Django 配置中导入设置模块 + +logger = logging.getLogger(__name__) # 创建当前模块的日志记录器 class SpiderNotify(): + """ + 搜索引擎通知类 + 提供向各大搜索引擎主动推送 URL 的功能,帮助搜索引擎及时抓取网站更新 + """ + @staticmethod def baidu_notify(urls): + """ + 向百度搜索引擎主动推送 URL + + 百度主动推送是百度提供的一种快速向百度提交网页的工具, + 可以帮助新站快速被百度发现,提高收录速度。 + + 参数: + urls (list): 需要推送的 URL 列表 + + 功能: + 将 URL 列表通过 HTTP POST 请求发送到百度主动推送接口 + 记录推送结果或错误信息 + """ try: + # 将 URL 列表转换为换行符分隔的字符串格式 + # 这是百度主动推送接口要求的数据格式 data = '\n'.join(urls) + + # 向百度主动推送接口发送 POST 请求 + # settings.BAIDU_NOTIFY_URL 包含了推送接口地址和 token 等参数 result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) + + # 记录推送结果日志 logger.info(result.text) + except Exception as e: + # 如果推送过程中出现异常,记录错误日志 logger.error(e) @staticmethod def notify(url): + """ + 通用通知方法(当前仅实现百度推送) + + 参数: + url (str 或 list): 需要推送的 URL 或 URL 列表 + + 功能: + 作为统一的通知入口,调用具体的搜索引擎推送方法 + 目前只实现了百度推送功能 + """ + # 调用百度推送方法 + # 注意:参数名是 url,但实际传递给 baidu_notify 的是 urls(列表) SpiderNotify.baidu_notify(url) diff --git a/src/djangoblog/tests.py b/src/djangoblog/tests.py index 01237d9..1815d9d 100644 --- a/src/djangoblog/tests.py +++ b/src/djangoblog/tests.py @@ -1,32 +1,17 @@ -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) + ''') # 测试包含标题、代码块和链接的 Markdown 文本 + self.assertIsNotNone(c) # 断言转换结果不为 None + + # 测试字典转 URL 参数功能 + # 准备测试数据字典 d = { - 'd': 'key1', - 'd2': 'key2' + 'd': 'key1', # 键值对1 + 'd2': 'key2' # 键值对2 } - data = parse_dict_to_url(d) - self.assertIsNotNone(data) + # 验证 parse_dict_to_url 函数能够正确将字典转换为 URL 参数字符串 + data = parse_dict_to_url(d) # 将字典转换为 URL 参数格式 + self.assertIsNotNone(data) # 断言转换结果不为 None diff --git a/src/djangoblog/urls.py b/src/djangoblog/urls.py index 4aae58a..47bb2b2 100644 --- a/src/djangoblog/urls.py +++ b/src/djangoblog/urls.py @@ -1,6 +1,6 @@ """djangoblog URL Configuration -The `urlpatterns` list routes URLs to views. For more information please see: +The [urlpatterns](file://D:\zyd2025\src\djangoblog\urls.py#L42-L44) list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/1.10/topics/http/urls/ Examples: Function views @@ -13,52 +13,85 @@ Including another URLconf 1. Import the include() function: from django.conf.urls import url, include 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) """ -from django.conf import settings -from django.conf.urls.i18n import i18n_patterns -from django.conf.urls.static import static -from django.contrib.sitemaps.views import sitemap -from django.urls import path, include -from django.urls import re_path -from haystack.views import search_view_factory - -from blog.views import EsSearchView -from djangoblog.admin_site import admin_site -from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm -from djangoblog.feeds import DjangoBlogFeed -from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap +# 从 Django 和项目模块导入相关组件 +from django.conf import settings # Django 配置 +from django.conf.urls.i18n import i18n_patterns # 国际化 URL 模式 +from django.conf.urls.static import static # 静态文件 URL 配置 +from django.contrib.sitemaps.views import sitemap # 网站地图视图 +from django.urls import path, include # URL 配置函数 +from django.urls import re_path # 正则表达式路径函数 +from haystack.views import search_view_factory # 搜索视图工厂 -sitemaps = { +# 从项目模块导入相关组件 +from blog.views import EsSearchView # Elasticsearch 搜索视图 +from djangoblog.admin_site import admin_site # 自定义管理站点 +from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm # Elasticsearch 搜索表单 +from djangoblog.feeds import DjangoBlogFeed # RSS 订阅 +from djangoblog.sitemap import ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap # 网站地图 - 'blog': ArticleSiteMap, - 'Category': CategorySiteMap, - 'Tag': TagSiteMap, - 'User': UserSiteMap, - 'static': StaticViewSitemap +# 定义网站地图配置字典 +sitemaps = { + 'blog': ArticleSiteMap, # 文章网站地图 + 'Category': CategorySiteMap, # 分类网站地图 + 'Tag': TagSiteMap, # 标签网站地图 + 'User': UserSiteMap, # 用户网站地图 + 'static': StaticViewSitemap # 静态页面网站地图 } -handler404 = 'blog.views.page_not_found_view' -handler500 = 'blog.views.server_error_view' -handle403 = 'blog.views.permission_denied_view' +# 自定义错误处理视图 +handler404 = 'blog.views.page_not_found_view' # 404 页面未找到处理视图 +handler500 = 'blog.views.server_error_view' # 500 服务器错误处理视图 +handle403 = 'blog.views.permission_denied_view' # 403 权限拒绝处理视图 +# URL 模式配置列表 urlpatterns = [ - path('i18n/', include('django.conf.urls.i18n')), + # 国际化 URL 配置 + path('i18n/', include('django.conf.urls.i18n')), # 包含国际化相关的 URL ] + +# 国际化 URL 模式配置 urlpatterns += i18n_patterns( - re_path(r'^admin/', admin_site.urls), - re_path(r'', include('blog.urls', namespace='blog')), - re_path(r'mdeditor/', include('mdeditor.urls')), - re_path(r'', include('comments.urls', namespace='comment')), - re_path(r'', include('accounts.urls', namespace='account')), - re_path(r'', include('oauth.urls', namespace='oauth')), + # 管理后台 URL + re_path(r'^admin/', admin_site.urls), # 自定义管理站点 URL + + # 博客应用 URL + re_path(r'', include('blog.urls', namespace='blog')), # 包含博客应用的 URL,命名空间为 'blog' + + # Markdown 编辑器 URL + re_path(r'mdeditor/', include('mdeditor.urls')), # 包含 Markdown 编辑器的 URL + + # 评论应用 URL + re_path(r'', include('comments.urls', namespace='comment')), # 包含评论应用的 URL,命名空间为 'comment' + + # 账户应用 URL + re_path(r'', include('accounts.urls', namespace='account')), # 包含账户应用的 URL,命名空间为 'account' + + # OAuth 认证 URL + re_path(r'', include('oauth.urls', namespace='oauth')), # 包含 OAuth 应用的 URL,命名空间为 'oauth' + + # 网站地图 URL re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, - name='django.contrib.sitemaps.views.sitemap'), - re_path(r'^feed/$', DjangoBlogFeed()), - re_path(r'^rss/$', DjangoBlogFeed()), + name='django.contrib.sitemaps.views.sitemap'), # 网站地图 XML 文件访问路径 + + # RSS 订阅 URL + re_path(r'^feed/$', DjangoBlogFeed()), # RSS 订阅 feed 路径 + re_path(r'^rss/$', DjangoBlogFeed()), # RSS 订阅 rss 路径(别名) + + # 搜索功能 URL re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), - name='search'), - re_path(r'', include('servermanager.urls', namespace='servermanager')), - re_path(r'', include('owntracks.urls', namespace='owntracks')) - , prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + name='search'), # 搜索功能路径,使用 Elasticsearch 搜索视图和表单 + + # 服务器管理应用 URL + re_path(r'', include('servermanager.urls', namespace='servermanager')), # 包含服务器管理应用的 URL + + # OwnTracks 位置追踪 URL + re_path(r'', include('owntracks.urls', namespace='owntracks')), # 包含 OwnTracks 应用的 URL + + # 国际化配置:不为默认语言添加前缀 + prefix_default_language=False +) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) # 添加静态文件 URL 配置 + +# 调试模式下的媒体文件 URL 配置 if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, - document_root=settings.MEDIA_ROOT) + document_root=settings.MEDIA_ROOT) # 在调试模式下提供媒体文件服务 diff --git a/src/djangoblog/utils.py b/src/djangoblog/utils.py index 57f63dc..2461be6 100644 --- a/src/djangoblog/utils.py +++ b/src/djangoblog/utils.py @@ -1,232 +1,390 @@ #!/usr/bin/env python # encoding: utf-8 - -import logging -import os -import random -import string -import uuid -from hashlib import sha256 - -import bleach -import markdown -import requests -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__) +""" +utils.py - DjangoBlog 通用工具函数模块 +包含项目中使用的各种辅助函数和工具类 +""" + +# 导入系统和第三方模块 +import logging # 日志记录模块 +import os # 操作系统接口模块 +import random # 随机数生成模块 +import string # 字符串处理模块 +import uuid # UUID 生成模块 +from hashlib import sha256 # SHA256 哈希函数 + +import bleach # HTML 清理库 +import markdown # Markdown 解析库 +import requests # HTTP 请求库 +from django.conf import settings # Django 配置 +from django.contrib.sites.models import Site # Django 站点框架模型 +from django.core.cache import cache # Django 缓存系统 +from django.templatetags.static import static # Django 静态文件处理 + +logger = logging.getLogger(__name__) # 创建当前模块的日志记录器 def get_max_articleid_commentid(): + """ + 获取文章和评论的最大 ID 值 + + 返回: + tuple: (最大文章ID, 最大评论ID) + """ from blog.models import Article from comments.models import Comment return (Article.objects.latest().pk, Comment.objects.latest().pk) def get_sha256(str): - m = sha256(str.encode('utf-8')) - return m.hexdigest() + """ + 计算字符串的 SHA256 哈希值 + + 参数: + str (str): 要计算哈希的字符串 + + 返回: + str: SHA256 哈希值的十六进制表示 + """ + m = sha256(str.encode('utf-8')) # 创建 SHA256 哈希对象并编码字符串 + return m.hexdigest() # 返回十六进制格式的哈希值 def cache_decorator(expiration=3 * 60): + """ + 缓存装饰器,用于缓存函数的返回值 + + 参数: + expiration (int): 缓存过期时间(秒),默认3分钟 + + 返回: + function: 装饰器函数 + """ 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)) + unique_str = repr((func, args, kwargs)) # 生成函数调用的唯一标识字符串 + m = sha256(unique_str.encode('utf-8')) # 计算哈希值 + key = m.hexdigest() # 用作缓存键 - m = sha256(unique_str.encode('utf-8')) - key = m.hexdigest() + # 尝试从缓存中获取值 value = cache.get(key) if value is not None: + # 如果缓存中存在值 # logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key)) if str(value) == '__default_cache_value__': - return None + return None # 特殊值表示空缓存 else: - return value + return value # 返回缓存值 else: + # 如果缓存中不存在值,执行函数并缓存结果 logger.debug( 'cache_decorator set cache:%s key:%s' % (func.__name__, key)) - value = func(*args, **kwargs) + value = func(*args, **kwargs) # 执行原函数 + + # 根据函数返回值设置缓存 if value is None: - cache.set(key, '__default_cache_value__', expiration) + cache.set(key, '__default_cache_value__', expiration) # 空值特殊处理 else: - cache.set(key, value, expiration) - return value + cache.set(key, value, expiration) # 缓存实际值 + return value # 返回函数执行结果 - return news + return news # 返回包装后的函数 - return wrapper + return wrapper # 返回装饰器 def expire_view_cache(path, servername, serverport, key_prefix=None): ''' - 刷新视图缓存 - :param path:url路径 - :param servername:host - :param serverport:端口 - :param key_prefix:前缀 - :return:是否成功 + 刷新视图缓存(删除指定 URL 的缓存) + + 参数: + path: URL 路径 + servername: 主机名 + serverport: 端口号 + key_prefix: 缓存键前缀 + + 返回: + bool: 是否成功删除缓存 ''' from django.http import HttpRequest from django.utils.cache import get_cache_key + # 创建模拟的 HTTP 请求对象 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('expire_view_cache:get key:{path}'.format(path=path)) - if cache.get(key): - cache.delete(key) + if cache.get(key): # 如果缓存存在 + cache.delete(key) # 删除缓存 return True return False -@cache_decorator() +@cache_decorator() # 使用缓存装饰器缓存站点信息 def get_current_site(): - site = Site.objects.get_current() + """ + 获取当前站点信息 + + 返回: + Site: 当前站点对象 + """ + site = Site.objects.get_current() # 获取当前站点 return site class CommonMarkdown: + """ + Markdown 处理工具类 + 提供 Markdown 文本到 HTML 的转换功能 + """ + @staticmethod def _convert_markdown(value): + """ + 内部方法:将 Markdown 转换为 HTML + + 参数: + value (str): Markdown 格式的文本 + + 返回: + tuple: (HTML 内容, TOC 目录) + """ + # 创建 Markdown 解析器,启用多个扩展功能 md = markdown.Markdown( extensions=[ - 'extra', - 'codehilite', - 'toc', - 'tables', + 'extra', # 额外的 Markdown 功能 + 'codehilite', # 代码高亮 + 'toc', # 目录生成 + 'tables', # 表格支持 ] ) - body = md.convert(value) - toc = md.toc + body = md.convert(value) # 转换 Markdown 为 HTML + toc = md.toc # 获取目录 return body, toc @staticmethod def get_markdown_with_toc(value): + """ + 将 Markdown 转换为 HTML 并返回目录 + + 参数: + value (str): Markdown 格式的文本 + + 返回: + tuple: (HTML 内容, TOC 目录) + """ body, toc = CommonMarkdown._convert_markdown(value) return body, toc @staticmethod def get_markdown(value): + """ + 将 Markdown 转换为 HTML(不返回目录) + + 参数: + value (str): Markdown 格式的文本 + + 返回: + str: HTML 格式的内容 + """ body, toc = CommonMarkdown._convert_markdown(value) return body def send_email(emailto, title, content): + """ + 发送邮件 + + 参数: + emailto (list): 收件人邮箱列表 + title (str): 邮件标题 + content (str): 邮件内容 + """ from djangoblog.blog_signals import send_email_signal + # 发送自定义的邮件发送信号 send_email_signal.send( - send_email.__class__, - emailto=emailto, - title=title, - content=content) + send_email.__class__, # 发送者 + emailto=emailto, # 收件人 + title=title, # 标题 + content=content) # 内容 def generate_code() -> str: - """生成随机数验证码""" - return ''.join(random.sample(string.digits, 6)) + """ + 生成随机数验证码(6位数字) + + 返回: + str: 6位数字组成的验证码字符串 + """ + return ''.join(random.sample(string.digits, 6)) # 从数字字符串中随机采样6个字符 def parse_dict_to_url(dict): - from urllib.parse import quote + """ + 将字典转换为 URL 查询参数格式 + + 参数: + dict (dict): 要转换的字典 + + 返回: + str: URL 查询参数字符串(key1=value1&key2=value2 格式) + """ + from urllib.parse import quote # URL 编码函数 + # 将字典中的每个键值对转换为 URL 编码的键值对,并用 & 连接 url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) for k, v in dict.items()]) return url def get_blog_setting(): - value = cache.get('get_blog_setting') + """ + 获取博客设置信息(带缓存) + + 返回: + BlogSettings: 博客设置对象 + """ + value = cache.get('get_blog_setting') # 从缓存中获取设置 if value: - return 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() + setting.site_name = 'djangoblog' # 站点名称 + setting.site_description = '基于Django的博客系统' # 站点描述 + setting.site_seo_description = '基于Django的博客系统' # SEO 描述 + setting.site_keywords = 'Django,Python' # SEO 关键词 + setting.article_sub_length = 300 # 文章摘要长度 + setting.sidebar_article_count = 10 # 侧边栏文章数量 + setting.sidebar_comment_count = 5 # 侧边栏评论数量 + setting.show_google_adsense = False # 是否显示 Google 广告 + 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) + cache.set('get_blog_setting', value) # 缓存设置对象 return value def save_user_avatar(url): ''' - 保存用户头像 - :param url:头像url - :return: 本地路径 + 保存用户头像到本地 + + 参数: + url (str): 头像图片的 URL 地址 + + 返回: + str: 本地头像文件的静态 URL 路径 ''' - logger.info(url) + logger.info(url) # 记录头像 URL try: + # 构建本地存储路径 basedir = os.path.join(settings.STATICFILES, 'avatar') + # 下载头像图片 rsp = requests.get(url, timeout=2) - if rsp.status_code == 200: + if rsp.status_code == 200: # 如果下载成功 + # 如果目录不存在则创建 if not os.path.exists(basedir): os.makedirs(basedir) + # 检查 URL 是否为图片格式 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' + ext = os.path.splitext(url)[1] if isimage else '.jpg' # 获取文件扩展名 + + # 生成唯一文件名 save_filename = str(uuid.uuid4().hex) + ext logger.info('保存用户头像:' + 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') + 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('delete sidebar key:' + k) - cache.delete(k) + cache.delete(k) # 删除每个缓存键 def delete_view_cache(prefix, keys): + """ + 删除模板片段缓存 + + 参数: + prefix (str): 缓存前缀 + keys (list): 缓存键列表 + """ from django.core.cache.utils import make_template_fragment_key + # 生成模板片段缓存键 key = make_template_fragment_key(prefix, keys) - cache.delete(key) + cache.delete(key) # 删除缓存 def get_resource_url(): + """ + 获取资源文件的基础 URL + + 返回: + str: 静态资源的基础 URL + """ if settings.STATIC_URL: - return settings.STATIC_URL + return settings.STATIC_URL # 如果配置了静态 URL,直接返回 else: - site = get_current_site() + site = get_current_site() # 否则根据当前站点生成 return 'http://' + site.domain + '/static/' +# 允许的 HTML 标签列表(用于安全清理) ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', 'h2', 'p'] + +# 允许的 HTML 属性列表 ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']} def sanitize_html(html): + """ + 清理 HTML 内容,移除不安全的标签和属性 + + 参数: + html (str): 原始 HTML 内容 + + 返回: + str: 清理后的安全 HTML 内容 + """ return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES) diff --git a/src/djangoblog/whoosh_cn_backend.py b/src/djangoblog/whoosh_cn_backend.py index 04e3f7f..700d58f 100644 --- a/src/djangoblog/whoosh_cn_backend.py +++ b/src/djangoblog/whoosh_cn_backend.py @@ -1,7 +1,12 @@ # encoding: utf-8 +""" +whoosh_cn_backend.py - Django Haystack 的 Whoosh 中文搜索后端实现 +提供基于 Whoosh 搜索引擎的中文全文搜索功能,集成了 jieba 中文分词器 +""" from __future__ import absolute_import, division, print_function, unicode_literals +# 导入系统和第三方库模块 import json import os import re @@ -9,61 +14,68 @@ import shutil import threading import warnings -import six -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from datetime import datetime -from django.utils.encoding import force_str -from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query -from haystack.constants import DJANGO_CT, DJANGO_ID, ID -from haystack.exceptions import MissingDependency, SearchBackendError, SkipDocument -from haystack.inputs import Clean, Exact, PythonData, Raw -from haystack.models import SearchResult -from haystack.utils import get_identifier, get_model_ct -from haystack.utils import log as logging -from haystack.utils.app_loading import haystack_get_model -from jieba.analyse import ChineseAnalyzer -from whoosh import index -from whoosh.analysis import StemmingAnalyzer -from whoosh.fields import BOOLEAN, DATETIME, IDLIST, KEYWORD, NGRAM, NGRAMWORDS, NUMERIC, Schema, TEXT -from whoosh.fields import ID as WHOOSH_ID -from whoosh.filedb.filestore import FileStorage, RamStorage -from whoosh.highlight import ContextFragmenter, HtmlFormatter -from whoosh.highlight import highlight as whoosh_highlight -from whoosh.qparser import QueryParser -from whoosh.searching import ResultsPage -from whoosh.writing import AsyncWriter - +import six # Python 2/3 兼容库 +from django.conf import settings # Django 配置 +from django.core.exceptions import ImproperlyConfigured # 配置错误异常 +from datetime import datetime # 日期时间处理 +from django.utils.encoding import force_str # 字符串编码处理 +from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, EmptyResults, log_query # Haystack 基类 +from haystack.constants import DJANGO_CT, DJANGO_ID, ID # Haystack 常量 +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 # jieba 中文分词分析器 +from whoosh import index # Whoosh 索引模块 +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 字段 +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.") -# Handle minimum requirement. +# 检查 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.") -# Bubble up the correct error. - +# 日期时间正则表达式,用于解析日期时间字符串 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?)?$') + +# 线程本地存储,用于 RAM 存储 LOCALS = threading.local() LOCALS.RAM_STORE = None class WhooshHtmlFormatter(HtmlFormatter): """ - This is a HtmlFormatter simpler than the whoosh.HtmlFormatter. - We use it to have consistent results across backends. Specifically, - Solr, Xapian and Elasticsearch are using this formatting. + 自定义 HTML 格式化器 + 简化了 Whoosh 的 HtmlFormatter,用于在不同后端之间保持一致的结果 + 特别是与 Solr、Xapian 和 Elasticsearch 保持格式一致 """ - template = '<%(tag)s>%(t)s' + template = '<%(tag)s>%(t)s' # HTML 模板 class WhooshSearchBackend(BaseSearchBackend): - # Word reserved by Whoosh for special use. + """ + Whoosh 搜索后端实现类 + 继承自 Haystack 的 BaseSearchBackend,提供基于 Whoosh 的搜索功能 + """ + + # Whoosh 保留的关键词 RESERVED_WORDS = ( 'AND', 'NOT', @@ -71,69 +83,81 @@ class WhooshSearchBackend(BaseSearchBackend): 'TO', ) - # Characters reserved by Whoosh for special use. - # The '\\' must come first, so as not to overwrite the other slash - # replacements. + # Whoosh 保留的特殊字符 + # '\\' 必须放在首位,以避免覆盖其他斜杠替换 RESERVED_CHARACTERS = ( '\\', '+', '-', '&&', '||', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '.', ) def __init__(self, connection_alias, **connection_options): + """ + 初始化 Whoosh 搜索后端 + + 参数: + connection_alias: 连接别名 + **connection_options: 连接选项 + """ super( WhooshSearchBackend, self).__init__( connection_alias, **connection_options) - self.setup_complete = False - self.use_file_storage = True + self.setup_complete = False # 设置完成标志 + self.use_file_storage = True # 是否使用文件存储 self.post_limit = getattr( connection_options, 'POST_LIMIT', - 128 * 1024 * 1024) - self.path = connection_options.get('PATH') + 128 * 1024 * 1024) # POST 限制,默认 128MB + self.path = connection_options.get('PATH') # 索引文件路径 + # 检查存储类型配置 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') + self.log = logging.getLogger('haystack') # 创建日志记录器 def setup(self): """ - Defers loading until needed. + 延迟加载设置,直到真正需要时才进行初始化 """ from haystack import connections - new_index = False + new_index = False # 新索引标志 - # Make sure the index is there. + # 确保索引目录存在 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) + self.storage = FileStorage(self.path) # 文件存储 else: global LOCALS if getattr(LOCALS, 'RAM_STORE', None) is None: - LOCALS.RAM_STORE = RamStorage() + LOCALS.RAM_STORE = RamStorage() # 内存存储 self.storage = LOCALS.RAM_STORE + # 构建索引模式(schema) self.content_field_name, self.schema = self.build_schema( connections[self.connection_alias].get_unified_index().all_searchfields()) - self.parser = QueryParser(self.content_field_name, schema=self.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: @@ -142,21 +166,33 @@ class WhooshSearchBackend(BaseSearchBackend): except index.EmptyIndexError: self.index = self.storage.create_index(self.schema) - self.setup_complete = True + self.setup_complete = True # 标记设置完成 def build_schema(self, fields): + """ + 构建 Whoosh 索引模式(schema) + + 参数: + fields: 字段定义字典 + + 返回: + tuple: (内容字段名, Schema 对象) + """ + # 定义基础字段 schema_fields = { - ID: WHOOSH_ID(stored=True, unique=True), - DJANGO_CT: WHOOSH_ID(stored=True), - DJANGO_ID: WHOOSH_ID(stored=True), + ID: WHOOSH_ID(stored=True, unique=True), # ID 字段 + DJANGO_CT: WHOOSH_ID(stored=True), # Django 内容类型字段 + DJANGO_ID: WHOOSH_ID(stored=True), # Django ID 字段 } - # Grab the number of keys that are hard-coded into Haystack. - # We'll use this to (possibly) fail slightly more gracefully later. + + # 获取硬编码字段的数量,用于后续错误检查 initial_key_count = len(schema_fields) - content_field_name = '' + content_field_name = '' # 内容字段名 + # 遍历所有字段定义,构建完整的 schema for field_name, field_class in fields.items(): if field_class.is_multivalued: + # 多值字段处理 if field_class.indexed is False: schema_fields[field_class.index_fieldname] = IDLIST( stored=True, field_boost=field_class.boost) @@ -164,72 +200,86 @@ class WhooshSearchBackend(BaseSearchBackend): schema_fields[field_class.index_fieldname] = KEYWORD( stored=True, commas=True, scorable=True, field_boost=field_class.boost) elif field_class.field_type in ['date', 'datetime']: + # 日期/时间字段 schema_fields[field_class.index_fieldname] = DATETIME( stored=field_class.stored, sortable=True) elif field_class.field_type == 'integer': + # 整数字段 schema_fields[field_class.index_fieldname] = NUMERIC( stored=field_class.stored, numtype=int, field_boost=field_class.boost) elif field_class.field_type == 'float': + # 浮点数字段 schema_fields[field_class.index_fieldname] = NUMERIC( stored=field_class.stored, numtype=float, field_boost=field_class.boost) elif field_class.field_type == 'boolean': - # Field boost isn't supported on BOOLEAN as of 1.8.2. + # 布尔字段 + # 1.8.2 版本不支持字段提升 schema_fields[field_class.index_fieldname] = BOOLEAN( stored=field_class.stored) elif field_class.field_type == 'ngram': + # N-gram 字段 schema_fields[field_class.index_fieldname] = NGRAM( minsize=3, maxsize=15, stored=field_class.stored, field_boost=field_class.boost) elif field_class.field_type == 'edge_ngram': + # Edge N-gram 字段 schema_fields[field_class.index_fieldname] = NGRAMWORDS(minsize=2, maxsize=15, at='start', stored=field_class.stored, field_boost=field_class.boost) else: + # 文本字段,使用中文分词器替代默认的词干分析器 # schema_fields[field_class.index_fieldname] = TEXT(stored=True, analyzer=StemmingAnalyzer(), field_boost=field_class.boost, sortable=True) schema_fields[field_class.index_fieldname] = TEXT( stored=True, analyzer=ChineseAnalyzer(), field_boost=field_class.boost, sortable=True) + + # 如果是文档字段,则设置为内容字段 if field_class.document is True: content_field_name = field_class.index_fieldname - schema_fields[field_class.index_fieldname].spelling = True + schema_fields[field_class.index_fieldname].spelling = True # 启用拼写检查 - # Fail more gracefully than relying on the backend to die if no fields - # are found. + # 错误检查:如果没有找到任何字段 if len(schema_fields) <= initial_key_count: raise SearchBackendError( "No fields were found in any search_indexes. Please correct this before attempting to search.") - return (content_field_name, Schema(**schema_fields)) + return (content_field_name, Schema(**schema_fields)) # 返回内容字段名和 Schema 对象 def update(self, index, iterable, commit=True): + """ + 更新索引中的文档 + + 参数: + index: 索引对象 + iterable: 可迭代的对象列表 + commit: 是否提交更改 + """ if not self.setup_complete: - self.setup() + self.setup() # 如果未完成设置,则先进行设置 - self.index = self.index.refresh() - writer = AsyncWriter(self.index) + self.index = self.index.refresh() # 刷新索引 + writer = AsyncWriter(self.index) # 创建异步写入器 + # 遍历所有对象并更新索引 for obj in iterable: try: - doc = index.full_prepare(obj) + doc = index.full_prepare(obj) # 准备文档数据 except SkipDocument: self.log.debug(u"Indexing for object `%s` skipped", obj) else: - # Really make sure it's unicode, because Whoosh won't have it any - # other way. + # 确保所有值都是 unicode,因为 Whoosh 只接受 unicode for key in doc: doc[key] = self._from_python(doc[key]) - # Document boosts aren't supported in Whoosh 2.5.0+. + # Whoosh 2.5.0+ 不支持文档提升 if 'boost' in doc: del doc['boost'] try: - writer.update_document(**doc) + writer.update_document(**doc) # 更新文档 except Exception as e: if not self.silently_fail: raise - # We'll log the object identifier but won't include the actual object - # to avoid the possibility of that generating encoding errors while - # processing the log message: + # 记录错误日志,但不包含实际对象以避免编码错误 self.log.error( u"%s while preparing object for update" % e.__class__.__name__, @@ -239,19 +289,27 @@ class WhooshSearchBackend(BaseSearchBackend): "index": index, "object": get_identifier(obj)}}) + # 如果有对象需要处理,则提交更改 if len(iterable) > 0: - # For now, commit no matter what, as we run into locking issues - # otherwise. + # 目前无论什么情况都提交,以避免锁定问题 writer.commit() def remove(self, obj_or_string, commit=True): + """ + 从索引中移除对象 + + 参数: + obj_or_string: 对象或对象标识符 + commit: 是否提交更改 + """ if not self.setup_complete: self.setup() self.index = self.index.refresh() - whoosh_id = get_identifier(obj_or_string) + whoosh_id = get_identifier(obj_or_string) # 获取对象标识符 try: + # 通过查询删除文档 self.index.delete_by_query( q=self.parser.parse( u'%s:"%s"' % @@ -267,6 +325,13 @@ class WhooshSearchBackend(BaseSearchBackend): exc_info=True) def clear(self, models=None, commit=True): + """ + 清空索引 + + 参数: + models: 要清空的模型列表 + commit: 是否提交更改 + """ if not self.setup_complete: self.setup() @@ -277,15 +342,17 @@ class WhooshSearchBackend(BaseSearchBackend): try: if models is None: - self.delete_index() + self.delete_index() # 删除整个索引 else: models_to_delete = [] + # 构建要删除的模型查询 for model in models: models_to_delete.append( u"%s:%s" % (DJANGO_CT, get_model_ct(model))) + # 通过查询删除指定模型的文档 self.index.delete_by_query( q=self.parser.parse( u" OR ".join(models_to_delete))) @@ -304,30 +371,45 @@ class WhooshSearchBackend(BaseSearchBackend): "Failed to clear Whoosh index: %s", e, exc_info=True) def delete_index(self): - # Per the Whoosh mailing list, if wiping out everything from the index, - # it's much more efficient to simply delete the index files. + """ + 删除整个索引 + """ + # 根据 Whoosh 邮件列表,如果要清除索引中的所有内容, + # 直接删除索引文件会更高效 if self.use_file_storage and os.path.exists(self.path): - shutil.rmtree(self.path) + shutil.rmtree(self.path) # 删除文件存储 elif not self.use_file_storage: - self.storage.clean() + self.storage.clean() # 清理内存存储 - # Recreate everything. + # 重新创建所有内容 self.setup() def optimize(self): + """ + 优化索引 + """ if not self.setup_complete: self.setup() self.index = self.index.refresh() - self.index.optimize() + self.index.optimize() # 执行优化 def calculate_page(self, start_offset=0, end_offset=None): - # Prevent against Whoosh throwing an error. Requires an end_offset - # greater than 0. + """ + 计算分页信息 + + 参数: + start_offset: 起始偏移量 + end_offset: 结束偏移量 + + 返回: + tuple: (页码, 每页长度) + """ + # 防止 Whoosh 抛出错误,要求 end_offset 大于 0 if end_offset is not None and end_offset <= 0: end_offset = 1 - # Determine the page. + # 确定页码 page_num = 0 if end_offset is None: @@ -341,7 +423,7 @@ class WhooshSearchBackend(BaseSearchBackend): if page_length and page_length > 0: page_num = int(start_offset / page_length) - # Increment because Whoosh uses 1-based page numbers. + # 递增,因为 Whoosh 使用基于 1 的页码 page_num += 1 return page_num, page_length @@ -366,10 +448,36 @@ class WhooshSearchBackend(BaseSearchBackend): limit_to_registered_models=None, result_class=None, **kwargs): + """ + 执行搜索查询 + + 参数: + query_string: 查询字符串 + sort_by: 排序字段 + start_offset: 起始偏移量 + end_offset: 结束偏移量 + fields: 要返回的字段 + highlight: 是否高亮 + facets: 面分类 + date_facets: 日期面分类 + query_facets: 查询面分类 + narrow_queries: 窄化查询 + spelling_query: 拼写查询 + within: 距离查询 + dwithin: 距离范围内查询 + distance_point: 距离点 + models: 模型列表 + limit_to_registered_models: 是否限制为注册模型 + result_class: 结果类 + **kwargs: 其他参数 + + 返回: + dict: 包含搜索结果的字典 + """ if not self.setup_complete: self.setup() - # A zero length query should return no results. + # 零长度查询应返回无结果 if len(query_string) == 0: return { 'results': [], @@ -378,20 +486,19 @@ class WhooshSearchBackend(BaseSearchBackend): query_string = force_str(query_string) - # A one-character query (non-wildcard) gets nabbed by a stopwords - # filter and should yield zero results. + # 单字符查询(非通配符)会被停用词过滤器捕获,应产生零结果 if len(query_string) <= 1 and query_string != u'*': return { 'results': [], 'hits': 0, } - reverse = False + reverse = False # 是否反向排序 + # 处理排序 if sort_by is not None: - # Determine if we need to reverse the results and if Whoosh can - # handle what it's being asked to sort by. Reversing is an - # all-or-nothing action, unfortunately. + # 确定是否需要反转结果,以及 Whoosh 是否能处理所要求的排序 + # 反向是一个全有或全无的操作 sort_by_list = [] reverse_counter = 0 @@ -417,6 +524,7 @@ class WhooshSearchBackend(BaseSearchBackend): sort_by = sort_by_list[0] + # 处理各种面分类警告 if facets is not None: warnings.warn( "Whoosh does not handle faceting.", @@ -438,6 +546,7 @@ class WhooshSearchBackend(BaseSearchBackend): 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) @@ -445,12 +554,12 @@ class WhooshSearchBackend(BaseSearchBackend): if models and len(models): model_choices = sorted(get_model_ct(model) for model in models) elif limit_to_registered_models: - # Using narrow queries, limit the results to only models handled - # with the current routers. + # 使用窄化查询,将结果限制为当前路由器处理的模型 model_choices = self.build_models_list() else: model_choices = [] + # 构建模型选择查询 if len(model_choices) > 0: if narrow_queries is None: narrow_queries = set() @@ -460,9 +569,9 @@ class WhooshSearchBackend(BaseSearchBackend): narrow_searcher = None + # 处理窄化查询 if narrow_queries is not None: - # Potentially expensive? I don't see another way to do it in - # Whoosh... + # 可能比较耗时?我没有看到在 Whoosh 中的其他实现方式... narrow_searcher = self.index.searcher() for nq in narrow_queries: @@ -482,11 +591,12 @@ class WhooshSearchBackend(BaseSearchBackend): self.index = self.index.refresh() + # 如果索引中有文档,则执行搜索 if self.index.doc_count(): searcher = self.index.searcher() parsed_query = self.parser.parse(query_string) - # In the event of an invalid/stopworded query, recover gracefully. + # 处理无效/停用词查询,优雅地恢复 if parsed_query is None: return { 'results': [], @@ -496,17 +606,19 @@ class WhooshSearchBackend(BaseSearchBackend): page_num, page_length = self.calculate_page( start_offset, end_offset) + # 构建搜索参数 search_kwargs = { 'pagelen': page_length, 'sortedby': sort_by, 'reverse': reverse, } - # Handle the case where the results have been narrowed. + # 处理窄化结果的情况 if narrowed_results is not None: search_kwargs['filter'] = narrowed_results try: + # 执行分页搜索 raw_page = searcher.search_page( parsed_query, page_num, @@ -522,8 +634,7 @@ class WhooshSearchBackend(BaseSearchBackend): 'spelling_suggestion': None, } - # Because as of Whoosh 2.5.1, it will return the wrong page of - # results if you request something too high. :( + # 处理 Whoosh 2.5.1 的问题:如果请求过高页码会返回错误页面 if raw_page.pagenum < page_num: return { 'results': [], @@ -531,6 +642,7 @@ class WhooshSearchBackend(BaseSearchBackend): 'spelling_suggestion': None, } + # 处理搜索结果 results = self._process_results( raw_page, highlight=highlight, @@ -544,6 +656,7 @@ class WhooshSearchBackend(BaseSearchBackend): return results else: + # 如果索引为空,处理拼写建议 if self.include_spelling: if spelling_query: spelling_suggestion = self.create_spelling_suggestion( @@ -570,11 +683,27 @@ class WhooshSearchBackend(BaseSearchBackend): limit_to_registered_models=None, result_class=None, **kwargs): + """ + 查找与给定实例相似的文档 + + 参数: + model_instance: 模型实例 + additional_query_string: 附加查询字符串 + start_offset: 起始偏移量 + end_offset: 结束偏移量 + models: 模型列表 + limit_to_registered_models: 是否限制为注册模型 + result_class: 结果类 + **kwargs: 其他参数 + + 返回: + dict: 包含相似文档结果的字典 + """ if not self.setup_complete: self.setup() - # Deferred models will have a different class ("RealClass_Deferred_fieldname") - # which won't be in our registry: + # 延迟模型会有不同的类名 ("RealClass_Deferred_fieldname") + # 这不会在我们的注册表中: model_klass = model_instance._meta.concrete_model field_name = self.content_field_name @@ -582,6 +711,7 @@ class WhooshSearchBackend(BaseSearchBackend): 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) @@ -589,12 +719,12 @@ class WhooshSearchBackend(BaseSearchBackend): if models and len(models): model_choices = sorted(get_model_ct(model) for model in models) elif limit_to_registered_models: - # Using narrow queries, limit the results to only models handled - # with the current routers. + # 使用窄化查询,将结果限制为当前路由器处理的模型 model_choices = self.build_models_list() else: model_choices = [] + # 构建模型选择查询 if len(model_choices) > 0: if narrow_queries is None: narrow_queries = set() @@ -602,14 +732,15 @@ class WhooshSearchBackend(BaseSearchBackend): narrow_queries.add(' OR '.join( ['%s:%s' % (DJANGO_CT, rm) for rm in model_choices])) + # 添加附加查询字符串 if additional_query_string and additional_query_string != '*': narrow_queries.add(additional_query_string) narrow_searcher = None + # 处理窄化查询 if narrow_queries is not None: - # Potentially expensive? I don't see another way to do it in - # Whoosh... + # 可能比较耗时?我没有看到在 Whoosh 中的其他实现方式... narrow_searcher = self.index.searcher() for nq in narrow_queries: @@ -632,6 +763,7 @@ class WhooshSearchBackend(BaseSearchBackend): self.index = self.index.refresh() raw_results = EmptyResults() + # 如果索引中有文档,则执行相似性搜索 if self.index.doc_count(): query = "%s:%s" % (ID, get_identifier(model_instance)) searcher = self.index.searcher() @@ -639,14 +771,16 @@ class WhooshSearchBackend(BaseSearchBackend): results = searcher.search(parsed_query) if len(results): + # 查找相似文档 raw_results = results[0].more_like_this( field_name, top=end_offset) - # Handle the case where the results have been narrowed. + # 处理窄化结果的情况 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: @@ -658,8 +792,7 @@ class WhooshSearchBackend(BaseSearchBackend): 'spelling_suggestion': None, } - # Because as of Whoosh 2.5.1, it will return the wrong page of - # results if you request something too high. :( + # 处理 Whoosh 2.5.1 的问题:如果请求过高页码会返回错误页面 if raw_page.pagenum < page_num: return { 'results': [], @@ -667,6 +800,7 @@ class WhooshSearchBackend(BaseSearchBackend): 'spelling_suggestion': None, } + # 处理结果 results = self._process_results(raw_page, result_class=result_class) searcher.close() @@ -682,11 +816,23 @@ class WhooshSearchBackend(BaseSearchBackend): query_string='', spelling_query=None, result_class=None): + """ + 处理原始搜索结果 + + 参数: + raw_page: 原始结果页面 + highlight: 是否高亮 + query_string: 查询字符串 + spelling_query: 拼写查询 + result_class: 结果类 + + 返回: + dict: 处理后的结果字典 + """ from haystack import connections results = [] - # It's important to grab the hits first before slicing. Otherwise, this - # can cause pagination failures. + # 获取命中数(在切片之前获取很重要,否则可能导致分页失败) hits = len(raw_page) if result_class is None: @@ -697,20 +843,22 @@ class WhooshSearchBackend(BaseSearchBackend): unified_index = connections[self.connection_alias].get_unified_index() indexed_models = unified_index.get_indexed_models() + # 遍历原始结果并处理 for doc_offset, raw_result in enumerate(raw_page): - score = raw_page.score(doc_offset) or 0 - app_label, model_name = raw_result[DJANGO_CT].split('.') + score = raw_page.score(doc_offset) or 0 # 获取得分 + app_label, model_name = raw_result[DJANGO_CT].split('.') # 分割应用标签和模型名 additional_fields = {} - model = haystack_get_model(app_label, model_name) + model = haystack_get_model(app_label, model_name) # 获取模型 if model and model in indexed_models: + # 处理每个字段 for key, value in raw_result.items(): index = unified_index.get_index(model) string_key = str(key) if string_key in index.fields and hasattr( index.fields[string_key], 'convert'): - # Special-cased due to the nature of KEYWORD fields. + # 特殊处理 KEYWORD 字段 if index.fields[string_key].is_multivalued: if value is None or len(value) == 0: additional_fields[string_key] = [] @@ -723,9 +871,11 @@ class WhooshSearchBackend(BaseSearchBackend): else: additional_fields[string_key] = self._to_python(value) + # 删除 Django 内容类型和 ID 字段 del (additional_fields[DJANGO_CT]) del (additional_fields[DJANGO_ID]) + # 处理高亮 if highlight: sa = StemmingAnalyzer() formatter = WhooshHtmlFormatter('em') @@ -742,6 +892,7 @@ class WhooshSearchBackend(BaseSearchBackend): self.content_field_name: [whoosh_result], } + # 创建搜索结果对象 result = result_class( app_label, model_name, @@ -752,6 +903,7 @@ class WhooshSearchBackend(BaseSearchBackend): else: hits -= 1 + # 处理拼写建议 if self.include_spelling: if spelling_query: spelling_suggestion = self.create_spelling_suggestion( @@ -768,6 +920,15 @@ class WhooshSearchBackend(BaseSearchBackend): } def create_spelling_suggestion(self, query_string): + """ + 创建拼写建议 + + 参数: + query_string: 查询字符串 + + 返回: + str: 拼写建议字符串 + """ spelling_suggestion = None reader = self.index.reader() corrector = reader.corrector(self.content_field_name) @@ -776,17 +937,18 @@ class WhooshSearchBackend(BaseSearchBackend): if not query_string: return spelling_suggestion - # Clean the string. + # 清理字符串 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, '') - # Break it down. + # 分解字符串 query_words = cleaned_query.split() suggested_words = [] + # 为每个词生成建议 for word in query_words: suggestions = corrector.suggest(word, limit=1) @@ -798,9 +960,9 @@ class WhooshSearchBackend(BaseSearchBackend): def _from_python(self, value): """ - Converts Python values to a string for Whoosh. + 将 Python 值转换为 Whoosh 字符串 - Code courtesy of pysolr. + 代码来源于 pysolr """ if hasattr(value, 'strftime'): if not hasattr(value, 'hour'): @@ -813,7 +975,7 @@ class WhooshSearchBackend(BaseSearchBackend): elif isinstance(value, (list, tuple)): value = u','.join([force_str(v) for v in value]) elif isinstance(value, (six.integer_types, float)): - # Leave it alone. + # 保持原样 pass else: value = force_str(value) @@ -821,9 +983,9 @@ class WhooshSearchBackend(BaseSearchBackend): def _to_python(self, value): """ - Converts values from Whoosh to native Python values. + 将 Whoosh 值转换为原生 Python 值 - A port of the same method in pysolr, as they deal with data the same way. + 移植自 pysolr 中的同名方法,因为它们以相同方式处理数据 """ if value == 'true': return True @@ -848,10 +1010,10 @@ class WhooshSearchBackend(BaseSearchBackend): date_values['second']) try: - # Attempt to use json to load the values. + # 尝试使用 json 加载值 converted_value = json.loads(value) - # Try to handle most built-in types. + # 尝试处理大多数内置类型 if isinstance( converted_value, (list, @@ -863,15 +1025,29 @@ class WhooshSearchBackend(BaseSearchBackend): complex)): return converted_value except BaseException: - # If it fails (SyntaxError or its ilk) or we don't trust it, - # continue on. + # 如果失败(语法错误或类似问题)或我们不信任它, + # 继续处理 pass return value class WhooshSearchQuery(BaseSearchQuery): + """ + Whoosh 搜索查询类 + 继承自 Haystack 的 BaseSearchQuery,用于构建 Whoosh 查询 + """ + def _convert_datetime(self, date): + """ + 转换日期时间为字符串格式 + + 参数: + date: 日期时间对象 + + 返回: + str: 格式化的日期时间字符串 + """ if hasattr(date, 'hour'): return force_str(date.strftime('%Y%m%d%H%M%S')) else: @@ -879,12 +1055,16 @@ class WhooshSearchQuery(BaseSearchQuery): def clean(self, query_fragment): """ - Provides a mechanism for sanitizing user input before presenting the - value to the backend. + 提供一种机制来在将值呈现给后端之前清理用户输入 + + Whoosh 1.X 在这里有所不同,你不能再使用反斜杠来转义保留字符。 + 相反,应该引用整个词。 + + 参数: + query_fragment: 查询片段 - Whoosh 1.X differs here in that you can no longer use a backslash - to escape reserved characters. Instead, the whole word should be - quoted. + 返回: + str: 清理后的查询片段 """ words = query_fragment.split() cleaned_words = [] @@ -903,12 +1083,24 @@ class WhooshSearchQuery(BaseSearchQuery): return ' '.join(cleaned_words) def build_query_fragment(self, field, filter_type, value): + """ + 构建查询片段 + + 参数: + field: 字段名 + filter_type: 过滤器类型 + value: 值 + + 返回: + str: 查询片段字符串 + """ from haystack import connections query_frag = '' is_datetime = False + # 处理非 InputType 值 if not hasattr(value, 'input_type_name'): - # Handle when we've got a ``ValuesListQuerySet``... + # 处理 ValuesListQuerySet... if hasattr(value, 'values_list'): value = list(value) @@ -916,26 +1108,27 @@ class WhooshSearchQuery(BaseSearchQuery): is_datetime = True if isinstance(value, six.string_types) and value != ' ': - # It's not an ``InputType``. Assume ``Clean``. + # 不是 InputType,假设为 Clean value = Clean(value) else: value = PythonData(value) - # Prepare the query using the InputType. + # 使用 InputType 准备查询 prepared_value = value.prepare(self) if not isinstance(prepared_value, (set, list, tuple)): - # Then convert whatever we get back to what pysolr wants if needed. + # 将返回的任何内容转换为 pysolr 需要的格式(如果需要) prepared_value = self.backend._from_python(prepared_value) - # 'content' is a special reserved word, much like 'pk' in - # Django's ORM layer. It indicates 'no special field'. + # 'content' 是一个特殊的保留词,就像 Django ORM 层中的 'pk' 一样 + # 它表示"无特殊字段" if field == 'content': index_fieldname = '' else: index_fieldname = u'%s:' % connections[self._using].get_unified_index( ).get_index_fieldname(field) + # 定义过滤器类型映射 filter_types = { 'content': '%s', 'contains': '*%s*', @@ -949,6 +1142,7 @@ class WhooshSearchQuery(BaseSearchQuery): 'fuzzy': u'%s~', } + # 根据后处理标志构建查询片段 if value.post_process is False: query_frag = prepared_value else: @@ -961,8 +1155,7 @@ class WhooshSearchQuery(BaseSearchQuery): if value.input_type_name == 'exact': query_frag = prepared_value else: - # Iterate over terms & incorportate the converted form of - # each into the query. + # 遍历术语并将每个转换后的形式合并到查询中 terms = [] if isinstance(prepared_value, six.string_types): @@ -1026,19 +1219,18 @@ class WhooshSearchQuery(BaseSearchQuery): query_frag = filter_types[filter_type] % prepared_value + # 处理查询片段的括号 if len(query_frag) and not isinstance(value, Raw): if not query_frag.startswith('(') and not query_frag.endswith(')'): query_frag = "(%s)" % query_frag return u"%s%s" % (index_fieldname, query_frag) - # if not filter_type in ('in', 'range'): - # # 'in' is a bit of a special case, as we don't want to - # # convert a valid list/tuple to string. Defer handling it - # # until later... - # value = self.backend._from_python(value) - class WhooshEngine(BaseEngine): - backend = WhooshSearchBackend - query = WhooshSearchQuery + """ + Whoosh 搜索引擎类 + 继承自 Haystack 的 BaseEngine,用于配置 Whoosh 搜索引擎 + """ + backend = WhooshSearchBackend # 指定后端类 + query = WhooshSearchQuery # 指定查询类 diff --git a/src/djangoblog/wsgi.py b/src/djangoblog/wsgi.py index 2295efd..ccf58c0 100644 --- a/src/djangoblog/wsgi.py +++ b/src/djangoblog/wsgi.py @@ -1,16 +1,40 @@ -""" -WSGI config for djangoblog project. - -It exposes the WSGI callable as a module-level variable named ``application``. - For more information on this file, see https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ """ +# WSGI (Web Server Gateway Interface) 配置文件 +# 用于配置 Django 应用与 Web 服务器之间的接口 -import os +import os # 导入操作系统接口模块 +# 从 Django 核心模块导入 WSGI 应用工厂函数 from django.core.wsgi import get_wsgi_application +# 设置默认的 Django 配置模块环境变量 +# 如果环境变量 DJANGO_SETTINGS_MODULE 未设置,则使用 "djangoblog.settings" os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings") +# 获取 WSGI 应用实例 +# 这个 application 对象是 WSGI 可调用对象,Web 服务器将通过它来处理 HTTP 请求 application = get_wsgi_application() +``` + +这个 WSGI 配置文件的作用包括: + +1. **环境配置**: + - 设置 `DJANGO_SETTINGS_MODULE` 环境变量,指定 Django 项目的配置文件位置 + - 确保即使在没有明确设置环境变量的情况下也能正确加载配置 + +2. **应用实例创建**: + - 通过 `get_wsgi_application()` 函数创建 Django WSGI 应用实例 + - 这个实例是 Django 应用与 Web 服务器通信的入口点 + +3. **服务器集成**: + - 生产环境的 Web 服务器(如 Gunicorn、uWSGI、Apache with mod_wsgi 等) + 会导入这个 [application](file://D:\zyd2025\src\djangoblog\wsgi.py#L15-L15) 对象来处理 HTTP 请求 + - 遵循 WSGI 标准,确保与各种 WSGI 兼容的服务器协同工作 + +4. **模块级变量暴露**: + - 将 WSGI 可调用对象作为模块级变量 [application](file://D:\zyd2025\src\djangoblog\wsgi.py#L15-L15) 暴露出来 + - 便于 Web 服务器直接导入和使用 + +这是 Django 项目部署时的标准配置文件,是连接 Django 应用与生产环境 Web 服务器的关键桥梁。 \ No newline at end of file