diff --git a/src/djangoblog/djangoblog/__init__.py b/src/djangoblog/djangoblog/__init__.py new file mode 100644 index 00000000..c4944bb9 --- /dev/null +++ b/src/djangoblog/djangoblog/__init__.py @@ -0,0 +1,8 @@ +# Django应用配置指定模块 +# 该模块的主要功能是定义当前Django应用的默认配置类, +# 当Django加载应用时,会根据此配置类进行应用的初始化设置, +# 包括应用名称、信号注册、权限配置等应用级别的配置项 + +# 指定当前Django应用的默认配置类为'djangoblog.apps.DjangoblogAppConfig' +# Django在启动时会自动加载该配置类,执行其中的初始化逻辑(如ready()方法) +default_app_config = 'djangoblog.apps.DjangoblogAppConfig' \ No newline at end of file diff --git a/src/djangoblog/djangoblog/__pycache__/__init__.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 00000000..40906a49 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/__init__.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/admin_site.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/admin_site.cpython-310.pyc new file mode 100644 index 00000000..398e03fa Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/admin_site.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/apps.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/apps.cpython-310.pyc new file mode 100644 index 00000000..b4c01a7e Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/apps.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/blog_signals.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/blog_signals.cpython-310.pyc new file mode 100644 index 00000000..c54f77ce Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/blog_signals.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/elasticsearch_backend.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/elasticsearch_backend.cpython-310.pyc new file mode 100644 index 00000000..ec4ebdf1 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/elasticsearch_backend.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/feeds.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/feeds.cpython-310.pyc new file mode 100644 index 00000000..4f231cbb Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/feeds.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/logentryadmin.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/logentryadmin.cpython-310.pyc new file mode 100644 index 00000000..be411b59 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/logentryadmin.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/settings.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/settings.cpython-310.pyc new file mode 100644 index 00000000..7c9350d1 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/settings.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/sitemap.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/sitemap.cpython-310.pyc new file mode 100644 index 00000000..b2aefec5 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/sitemap.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/spider_notify.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/spider_notify.cpython-310.pyc new file mode 100644 index 00000000..d5e9f55b Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/spider_notify.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/urls.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/urls.cpython-310.pyc new file mode 100644 index 00000000..9b2bb3d7 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/urls.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/utils.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/utils.cpython-310.pyc new file mode 100644 index 00000000..b6f1d15c Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/utils.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/whoosh_cn_backend.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/whoosh_cn_backend.cpython-310.pyc new file mode 100644 index 00000000..9589c62f Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/whoosh_cn_backend.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/__pycache__/wsgi.cpython-310.pyc b/src/djangoblog/djangoblog/__pycache__/wsgi.cpython-310.pyc new file mode 100644 index 00000000..cdb3d5d6 Binary files /dev/null and b/src/djangoblog/djangoblog/__pycache__/wsgi.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/admin_site.py b/src/djangoblog/djangoblog/admin_site.py new file mode 100644 index 00000000..1f0130db --- /dev/null +++ b/src/djangoblog/djangoblog/admin_site.py @@ -0,0 +1,89 @@ +# Django博客系统的Admin配置模块 +# 该模块用于自定义Django管理后台(Admin Site),包括管理员站点的属性设置、权限控制, +# 以及注册系统中各模型到管理后台,实现对数据的可视化管理 + +# 导入Django内置的AdminSite及相关模型、管理类 +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 + +# 导入各应用的Admin配置和模型(账号、博客、评论、OAuth等模块) +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 * + + +class DjangoBlogAdminSite(AdminSite): + """ + 自定义的Django管理站点类,继承自AdminSite + 用于个性化管理后台的显示信息和权限控制 + """ + # 管理后台页面顶部的标题 + site_header = 'djangoblog administration' + # 浏览器标签页的标题 + site_title = 'djangoblog site admin' + + def __init__(self, name='admin'): + """初始化方法,调用父类构造函数""" + super().__init__(name) + + def has_permission(self, request): + """ + 重写权限检查方法,控制谁可以访问管理后台 + 仅允许超级用户(is_superuser)访问 + """ + return request.user.is_superuser + + # 以下为注释掉的自定义URL示例(如需扩展管理后台URL可启用) + # def get_urls(self): + # urls = super().get_urls() + # from django.urls import path + # from blog.views import refresh_memcache + # + # my_urls = [ + # path('refresh/', self.admin_view(refresh_memcache), name="refresh"), + # ] + # return urls + my_urls + + +# 实例化自定义的管理站点 +admin_site = DjangoBlogAdminSite(name='admin') + +# 注册博客核心模型到管理站点,关联对应的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(commands, CommandsAdmin) # 命令模型 +admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志模型 + +# 注册用户模型 +admin_site.register(BlogUser, BlogUserAdmin) # 自定义用户模型 + +# 注册评论模型 +admin_site.register(Comment, CommentAdmin) # 评论模型 + +# 注册OAuth相关模型 +admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 +admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置模型 + +# 注册位置追踪相关模型 +admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置日志模型 + +# 注册站点和日志模型(Django内置) +admin_site.register(Site, SiteAdmin) # 站点模型 +admin_site.register(LogEntry, LogEntryAdmin) # 操作日志模型 \ No newline at end of file diff --git a/src/djangoblog/djangoblog/apps.py b/src/djangoblog/djangoblog/apps.py new file mode 100644 index 00000000..6316b6c8 --- /dev/null +++ b/src/djangoblog/djangoblog/apps.py @@ -0,0 +1,29 @@ +# Django博客应用配置类模块 +# 该模块定义了Django博客应用(djangoblog)的配置类,用于设置应用的核心属性和初始化逻辑 +# 主要功能包括:指定默认的自增字段类型、定义应用名称、以及在应用就绪时加载插件 + +from django.apps import AppConfig + + +class DjangoblogAppConfig(AppConfig): + """ + Django博客应用的配置类,继承自Django的AppConfig + 用于配置应用的元数据和生命周期钩子 + """ + # 指定模型默认的自增主键字段类型为BigAutoField(支持更大范围的整数) + default_auto_field = 'django.db.models.BigAutoField' + # 应用的名称,对应项目中的应用目录名 + name = 'djangoblog' + + def ready(self): + """ + 应用就绪时执行的方法(Django生命周期钩子) + 当应用加载完成并准备好处理请求时调用,通常用于初始化操作 + """ + # 调用父类的ready()方法,确保基类的初始化逻辑执行 + super().ready() + # 导入并加载插件:在应用就绪后加载所有激活的插件 + # 从当前应用的plugin_manage.loader模块导入load_plugins函数 + from .plugin_manage.loader import load_plugins + # 执行插件加载函数,完成插件的动态导入和初始化 + load_plugins() \ No newline at end of file diff --git a/src/djangoblog/djangoblog/blog_signals.py b/src/djangoblog/djangoblog/blog_signals.py new file mode 100644 index 00000000..065de3ae --- /dev/null +++ b/src/djangoblog/djangoblog/blog_signals.py @@ -0,0 +1,177 @@ +# Django博客系统信号处理模块 +# 该模块用于注册和处理Django内置信号及自定义信号,实现事件驱动的业务逻辑 +# 核心功能包括:邮件发送、OAuth用户登录处理、模型保存后缓存清理/搜索引擎通知、用户登录登出缓存处理等 +# 通过信号机制解耦业务逻辑,当特定事件触发时自动执行对应处理函数 + +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 + +# 导入项目内部模块:评论相关、插件通知、缓存工具、站点工具、OAuth模型 +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__) + +# 自定义信号:OAuth用户登录信号,携带参数'id'(OAuthUser的主键) +oauth_user_login_signal = django.dispatch.Signal(['id']) +# 自定义信号:邮件发送信号,携带参数'emailto'(收件人列表)、'title'(邮件标题)、'content'(邮件内容) +send_email_signal = django.dispatch.Signal( + ['emailto', 'title', 'content']) + + +@receiver(send_email_signal) +def send_email_signal_handler(sender, **kwargs): + """ + 邮件发送信号的处理函数 + 当send_email_signal信号触发时,自动发送HTML格式邮件并记录发送日志 + """ + # 从信号参数中提取邮件相关信息 + emailto = kwargs['emailto'] + title = kwargs['title'] + content = kwargs['content'] + + # 构建HTML格式邮件(content_subtype设为html支持富文本) + msg = EmailMultiAlternatives( + title, + content, + from_email=settings.DEFAULT_FROM_EMAIL, # 发件人从Django配置中读取 + to=emailto) # 收件人列表 + msg.content_subtype = "html" # 指定邮件内容为HTML格式 + + # 初始化邮件发送日志模型,记录发送详情 + from servermanager.models import EmailSendLog + log = EmailSendLog() + log.title = title + log.content = content + log.emailto = ','.join(emailto) # 多个收件人用逗号拼接存储 + + try: + # 发送邮件,result为成功发送的邮件数量 + result = msg.send() + log.send_result = result > 0 # 发送数量>0表示发送成功 + except Exception as e: + # 捕获发送异常,记录错误日志 + logger.error(f"失败邮箱号: {emailto}, {e}") + log.send_result = False # 标记发送失败 + log.save() # 保存日志到数据库 + + +@receiver(oauth_user_login_signal) +def oauth_user_login_signal_handler(sender, **kwargs): + """ + OAuth用户登录信号的处理函数 + 当OAuth用户登录成功后,处理用户头像(如跨域头像本地化存储)并清理侧边栏缓存 + """ + # 从信号参数中提取OAuthUser的id,查询对应的用户实例 + id = kwargs['id'] + oauthuser = OAuthUser.objects.get(id=id) + # 获取当前站点域名(用于判断头像是否为本地地址) + site = get_current_site().domain + + # 若用户有头像且头像地址不包含当前站点域名(跨域头像),则本地化存储 + if oauthuser.picture and not oauthuser.picture.find(site) >= 0: + from djangoblog.utils import save_user_avatar + oauthuser.picture = save_user_avatar(oauthuser.picture) # 保存头像到本地并更新地址 + oauthuser.save() # 保存更新后的用户信息 + + # 清理侧边栏缓存(确保登录后侧边栏展示最新数据) + delete_sidebar_cache() + + +@receiver(post_save) +def model_post_save_callback( + sender, + instance, + created, + raw, + using, + update_fields, + **kwargs): + """ + Django模型保存后信号的处理函数(post_save) + 触发时机:任何模型执行save()方法后(新增/更新) + 主要处理:搜索引擎通知、缓存清理、评论审核通过后的联动操作 + """ + clearcache = False # 标记是否需要清理全局缓存 + + # 跳过Admin操作日志模型(LogEntry)的处理,无需触发后续逻辑 + if isinstance(instance, LogEntry): + return + + # 若模型实例有get_full_url方法(通常是博客文章等需要对外展示的模型) + if 'get_full_url' in dir(instance): + # 判断是否仅更新浏览量字段(views) + is_update_views = update_fields == {'views'} + # 非测试环境且不是仅更新浏览量时,通知搜索引擎(如百度)收录新链接 + if not settings.TESTING and not is_update_views: + try: + notify_url = instance.get_full_url() # 获取模型实例的完整访问链接 + SpiderNotify.baidu_notify([notify_url]) # 通知百度搜索引擎 + except Exception as ex: + logger.error("notify sipder", ex) # 记录搜索引擎通知失败的错误 + # 非浏览量更新时,标记需要清理缓存 + if not is_update_views: + clearcache = True + + # 若保存的是评论模型实例 + if isinstance(instance, Comment): + # 仅处理审核通过的评论(is_enable为True) + if instance.is_enable: + # 获取评论对应的文章绝对路径 + path = instance.article.get_absolute_url() + site = get_current_site().domain + # 处理带端口的域名(如localhost:8000),仅保留主域名部分 + 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() + + +@receiver(user_logged_in) +@receiver(user_logged_out) +def user_auth_callback(sender, request, user, **kwargs): + """ + 用户登录/登出信号的处理函数 + 触发时机:用户登录(user_logged_in)或登出(user_logged_out)后 + 主要处理:记录日志并清理侧边栏缓存(确保登录状态变化后展示最新数据) + """ + # 若用户存在且用户名有效 + if user and user.username: + logger.info(user) # 记录用户登录/登出日志 + delete_sidebar_cache() # 清理侧边栏缓存 + # cache.clear() # 全局缓存清理(按需启用) \ No newline at end of file diff --git a/src/djangoblog/djangoblog/elasticsearch_backend.py b/src/djangoblog/djangoblog/elasticsearch_backend.py new file mode 100644 index 00000000..2ccc3f80 --- /dev/null +++ b/src/djangoblog/djangoblog/elasticsearch_backend.py @@ -0,0 +1,183 @@ +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 + +from blog.documents import ArticleDocument, ArticleDocumentManager +from blog.models import Article + +logger = logging.getLogger(__name__) + + +class ElasticSearchBackend(BaseSearchBackend): + def __init__(self, connection_alias, **connection_options): + super( + ElasticSearchBackend, + self).__init__( + connection_alias, + **connection_options) + self.manager = ArticleDocumentManager() + self.include_spelling = True + + def _get_models(self, iterable): + models = iterable if iterable and iterable[0] else Article.objects.all() + 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) + + def _delete(self, models): + for m in models: + 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) + + def update(self, index, iterable, commit=True): + + 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) + + def clear(self, models=None, commit=True): + self.remove(None) + + @staticmethod + def get_suggestion(query: str) -> str: + """获取推荐词, 如果没有找到添加原搜索词""" + + search = ArticleDocument.search() \ + .query("match", body=query) \ + .suggest('suggest_search', query, term={'field': 'body'}) \ + .execute() + + 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) + + @log_query + def search(self, 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%") + + 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 = [] + for raw_result in results['hits']['hits']: + app_label = 'blog' + model_name = 'Article' + additional_fields = {} + + result_class = SearchResult + + result = result_class( + app_label, + model_name, + raw_result['_id'], + raw_result['_score'], + **additional_fields) + 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, + } + + +class ElasticSearchQuery(BaseSearchQuery): + def _convert_datetime(self, 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. + + 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. + """ + 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) + + return ' '.join(cleaned_words) + + def build_query_fragment(self, field, filter_type, value): + return value.query_string + + def get_count(self): + results = self.get_results() + return len(results) if results else 0 + + def get_spelling_suggestion(self, preferred_query=None): + return self._spelling_suggestion + + def build_params(self, spelling_query=None): + kwargs = super(ElasticSearchQuery, self).build_params(spelling_query=spelling_query) + return kwargs + + +class ElasticSearchModelSearchForm(ModelSearchForm): + + def search(self): + # 是否建议搜索 + self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no" + sqs = super().search() + return sqs + + +class ElasticSearchEngine(BaseEngine): + backend = ElasticSearchBackend + query = ElasticSearchQuery \ No newline at end of file diff --git a/src/djangoblog/djangoblog/feeds.py b/src/djangoblog/djangoblog/feeds.py new file mode 100644 index 00000000..ee07797f --- /dev/null +++ b/src/djangoblog/djangoblog/feeds.py @@ -0,0 +1,72 @@ +#姜雨菲:导入Django用户模型获取工具 +from django.contrib.auth import get_user_model +#姜雨菲: 导入Django的Feed基类,用于创建RSS/Atom订阅源 +from django.contrib.syndication.views import Feed +#姜雨菲: 导入时区处理工具 +from django.utils import timezone +#姜雨菲: 导入RSS 2.0版本的生成器 +from django.utils.feedgenerator import Rss201rev2Feed + +#姜雨菲: 导入博客文章模型 +from blog.models import Article +#姜雨菲: 导入自定义的Markdown处理工具 +from djangoblog.utils import CommonMarkdown + + +class DjangoBlogFeed(Feed): + """博客网站的RSS订阅源类,继承自Django的Feed基类""" + + #姜雨菲: 指定订阅源类型为RSS 2.0版本 + feed_type = Rss201rev2Feed + + # 订阅源的描述信息 + description = '大巧无工,重剑无锋.' + # 订阅源的标题 + title = "且听风吟 大巧无工,重剑无锋. " + # 订阅源的链接(相对路径) + link = "/feed/" + + def author_name(self): + """返回订阅源作者名称""" + # 获取第一个用户的昵称作为作者名 + return get_user_model().objects.first().nickname + + def author_link(self): + """返回订阅源作者的链接""" + # 获取第一个用户的绝对URL + return get_user_model().objects.first().get_absolute_url() + + def items(self): + """ + 定义订阅源包含的项目列表 + 返回最新发布的5篇文章 + """ + # 筛选类型为'article'(a)且状态为'published'(p)的文章 + # 按发布时间倒序排列,取前5篇 + return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5] + + def item_title(self, item): + """返回单个项目(文章)的标题""" + return item.title + + def item_description(self, item): + """返回单个项目(文章)的描述""" + # 将文章正文从Markdown格式转换为HTML + return CommonMarkdown.get_markdown(item.body) + + def feed_copyright(self): + """返回订阅源的版权信息""" + # 获取当前时间,并格式化版权信息 + now = timezone.now() + return "Copyright© {year} 且听风吟".format(year=now.year) + + def item_link(self, item): + """返回单个项目(文章)的链接""" + return item.get_absolute_url() + + def item_guid(self, item): + """ + 返回单个项目的唯一标识符(guid) + 此处返回空值,实际应用中通常应返回唯一标识如文章ID等 + """ + return \ No newline at end of file diff --git a/src/djangoblog/djangoblog/logentryadmin.py b/src/djangoblog/djangoblog/logentryadmin.py new file mode 100644 index 00000000..4ac7f9b8 --- /dev/null +++ b/src/djangoblog/djangoblog/logentryadmin.py @@ -0,0 +1,136 @@ +#姜雨菲: 导入Django管理后台核心模块 +from django.contrib import admin +#姜雨菲: 导入日志相关常量和模型 +from django.contrib.admin.models import DELETION # 表示"删除"操作的常量 +from django.contrib.contenttypes.models import ContentType # 内容类型模型,用于关联不同模型 +#姜雨菲: 导入URL反向解析和异常处理 +from django.urls import reverse, NoReverseMatch +# 导入字符串处理工具 +from django.utils.encoding import force_str +# 导入HTML转义工具 +from django.utils.html import escape +# 导入安全字符串标记工具(用于渲染HTML) +from django.utils.safestring import mark_safe +# 导入国际化翻译工具 +from django.utils.translation import gettext_lazy as _ + + +class LogEntryAdmin(admin.ModelAdmin): + """ + 自定义管理员日志(LogEntry)的管理类 + 用于在Django admin后台展示和管理系统操作日志 + """ + + # 列表页的筛选器:按内容类型筛选 + list_filter = [ + 'content_type' + ] + + # 搜索字段:支持按对象表示和变更消息搜索 + search_fields = [ + 'object_repr', # 对象的字符串表示 + 'change_message' # 操作变更的描述信息 + ] + + # 列表页中可点击的链接字段 + list_display_links = [ + 'action_time', # 操作时间 + 'get_change_message', # 变更消息 + ] + + # 列表页展示的字段 + list_display = [ + 'action_time', # 操作时间 + 'user_link', # 操作用户(带链接) + 'content_type', # 操作的内容类型(模型) + 'object_link', # 操作的对象(带链接) + 'get_change_message', # 变更消息 + ] + + def has_add_permission(self, request): + """禁用添加权限:不允许手动添加日志记录""" + return False + + def has_change_permission(self, request, obj=None): + """ + 限制修改权限: + - 仅超级用户或拥有change_logentry权限的用户可查看 + - 禁止POST请求(即不允许修改日志内容) + """ + return ( + request.user.is_superuser or + request.user.has_perm('admin.change_logentry') + ) and request.method != 'POST' + + def has_delete_permission(self, request, obj=None): + """禁用删除权限:不允许删除日志记录""" + return False + + def object_link(self, obj): + """ + 生成操作对象的链接(若对象存在) + 对于已删除的对象,仅显示文本;对于存在的对象,显示可点击的链接 + """ + # 先对对象的字符串表示进行HTML转义,防止XSS攻击 + object_link = escape(obj.object_repr) + # 获取操作对象的内容类型 + content_type = obj.content_type + + # 如果不是删除操作且内容类型存在,尝试生成编辑链接 + if obj.action_flag != DELETION and content_type is not None: + try: + # 反向解析对象的编辑页面URL + url = reverse( + # 生成admin的URL名称格式:app_label_model_change + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.object_id] # 传递对象ID作为参数 + ) + # 生成带链接的HTML + object_link = '{}'.format(url, object_link) + except NoReverseMatch: + # 若无法解析URL(如模型未注册到admin),则只显示文本 + pass + # 标记为安全字符串,允许Django渲染HTML + return mark_safe(object_link) + + # 配置列表页字段的排序和显示名称 + object_link.admin_order_field = 'object_repr' # 允许按对象表示排序 + object_link.short_description = _('object') # 列表页显示的列名(支持国际化) + + def user_link(self, obj): + """生成操作用户的链接(指向用户编辑页面)""" + # 获取用户模型的内容类型 + content_type = ContentType.objects.get_for_model(type(obj.user)) + # 对用户名进行HTML转义 + user_link = escape(force_str(obj.user)) + try: + # 反向解析用户编辑页面的URL + url = reverse( + 'admin:{}_{}_change'.format(content_type.app_label, + content_type.model), + args=[obj.user.pk] # 传递用户ID作为参数 + ) + # 生成带链接的HTML + user_link = '{}'.format(url, user_link) + except NoReverseMatch: + # 若无法解析URL,只显示用户名 + pass + # 标记为安全字符串,允许渲染HTML + return mark_safe(user_link) + + # 配置用户链接字段的排序和显示名称 + user_link.admin_order_field = 'user' # 允许按用户排序 + user_link.short_description = _('user') # 列表页显示的列名(支持国际化) + + def get_queryset(self, request): + """优化查询集:预加载content_type,减少数据库查询次数""" + queryset = super(LogEntryAdmin, self).get_queryset(request) + return queryset.prefetch_related('content_type') # 使用预加载优化性能 + + def get_actions(self, request): + """移除批量删除操作:不允许批量删除日志""" + actions = super(LogEntryAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + return actions \ No newline at end of file diff --git a/src/djangoblog/djangoblog/plugin_manage/__pycache__/base_plugin.cpython-310.pyc b/src/djangoblog/djangoblog/plugin_manage/__pycache__/base_plugin.cpython-310.pyc new file mode 100644 index 00000000..a7f82f33 Binary files /dev/null and b/src/djangoblog/djangoblog/plugin_manage/__pycache__/base_plugin.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/plugin_manage/__pycache__/hook_constants.cpython-310.pyc b/src/djangoblog/djangoblog/plugin_manage/__pycache__/hook_constants.cpython-310.pyc new file mode 100644 index 00000000..829013a7 Binary files /dev/null and b/src/djangoblog/djangoblog/plugin_manage/__pycache__/hook_constants.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/plugin_manage/__pycache__/hooks.cpython-310.pyc b/src/djangoblog/djangoblog/plugin_manage/__pycache__/hooks.cpython-310.pyc new file mode 100644 index 00000000..4e716db3 Binary files /dev/null and b/src/djangoblog/djangoblog/plugin_manage/__pycache__/hooks.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/plugin_manage/__pycache__/loader.cpython-310.pyc b/src/djangoblog/djangoblog/plugin_manage/__pycache__/loader.cpython-310.pyc new file mode 100644 index 00000000..1ea44af0 Binary files /dev/null and b/src/djangoblog/djangoblog/plugin_manage/__pycache__/loader.cpython-310.pyc differ diff --git a/src/djangoblog/djangoblog/plugin_manage/base_plugin.py b/src/djangoblog/djangoblog/plugin_manage/base_plugin.py new file mode 100644 index 00000000..71c51aac --- /dev/null +++ b/src/djangoblog/djangoblog/plugin_manage/base_plugin.py @@ -0,0 +1,247 @@ +import logging # 用于日志记录 +from pathlib import Path # 用于文件路径处理 + +from django.template import TemplateDoesNotExist # Django模板不存在异常 +from django.template.loader import render_to_string # 用于渲染模板为字符串 + +# 创建日志记录器,用于记录插件相关日志 +logger = logging.getLogger(__name__) + + +class BasePlugin: + """ + 插件基类,所有自定义插件需继承此类并实现特定方法。 + 提供插件元数据管理、位置渲染、模板渲染、静态资源处理等基础功能。 + """ + + # 插件元数据(子类必须重写这些属性) + PLUGIN_NAME = None # 插件名称(如"天气插件") + PLUGIN_DESCRIPTION = None # 插件描述(功能说明) + PLUGIN_VERSION = None # 插件版本(如"1.0.0") + PLUGIN_AUTHOR = None # 插件作者 + + # 插件配置(子类可根据需求重写) + SUPPORTED_POSITIONS = [] # 支持的显示位置(如['sidebar', 'footer']表示支持侧边栏和页脚) + DEFAULT_PRIORITY = 100 # 默认优先级(数字越小优先级越高,用于多个插件在同一位置排序) + POSITION_PRIORITIES = {} # 各位置的优先级(覆盖默认值,如{'sidebar': 50}表示侧边栏优先级为50) + + def __init__(self): + """初始化插件,验证元数据并设置基础属性""" + # 校验必须的元数据是否完整,不完整则抛出异常 + if not all([self.PLUGIN_NAME, self.PLUGIN_DESCRIPTION, self.PLUGIN_VERSION]): + raise ValueError("插件元数据(PLUGIN_NAME, PLUGIN_DESCRIPTION, PLUGIN_VERSION)必须定义。") + + # 设置插件路径和唯一标识 + self.plugin_dir = self._get_plugin_directory() # 插件所在目录路径 + self.plugin_slug = self._get_plugin_slug() # 插件唯一标识(默认使用目录名) + + # 初始化插件并注册钩子 + self.init_plugin() + self.register_hooks() + + def _get_plugin_directory(self): + """获取插件所在的目录路径(内部方法)""" + import inspect + # 通过inspect模块获取当前类的定义文件路径,再获取其所在目录 + plugin_file = inspect.getfile(self.__class__) + return Path(plugin_file).parent + + def _get_plugin_slug(self): + """获取插件的唯一标识符(默认使用插件目录名,内部方法)""" + return self.plugin_dir.name + + def init_plugin(self): + """ + 插件初始化逻辑(钩子方法) + 子类可重写此方法实现自定义初始化操作(如加载配置、连接数据库等) + """ + logger.info(f'{self.PLUGIN_NAME} 初始化完成。') + + def register_hooks(self): + """ + 注册插件钩子(钩子方法) + 子类可重写此方法注册自定义钩子(如响应Django信号、注册URL路由等) + """ + pass + + # === 位置渲染系统 === + def render_position_widget(self, position, context, **kwargs): + """ + 根据指定位置渲染插件组件(核心方法) + + Args: + position: 位置标识(如'sidebar'表示侧边栏) + context: 模板上下文(包含当前请求、用户等信息) + **kwargs: 额外参数(如文章ID、页面类型等) + + Returns: + dict: 包含渲染结果的字典,格式为: + {'html': 'HTML内容', 'priority': 优先级, 'plugin_name': 插件名} + 若不支持该位置或不满足显示条件,返回None + """ + # 检查当前位置是否在插件支持的位置列表中 + if position not in self.SUPPORTED_POSITIONS: + return None + + # 检查是否满足显示条件(调用should_display方法) + if not self.should_display(position, context, **kwargs): + return None + + # 动态调用对应位置的渲染方法(如position为'sidebar'则调用render_sidebar_widget) + method_name = f'render_{position}_widget' + if hasattr(self, method_name): + # 调用具体位置的渲染方法获取HTML内容 + html = getattr(self, method_name)(context, **kwargs) + if html: # 若渲染成功(有HTML内容) + # 确定当前位置的优先级(优先使用POSITION_PRIORITIES,否则用默认值) + priority = self.POSITION_PRIORITIES.get(position, self.DEFAULT_PRIORITY) + return { + 'html': html, + 'priority': priority, + 'plugin_name': self.PLUGIN_NAME + } + + return None + + def should_display(self, position, context, **kwargs): + """ + 判断插件是否应该在指定位置显示(钩子方法) + 子类可重写此方法实现条件显示逻辑(如只在特定页面/用户组显示) + + Args: + position: 位置标识 + context: 模板上下文 + **kwargs: 额外参数 + + Returns: + bool: True表示显示,False表示不显示 + """ + return True # 默认始终显示 + + # === 各位置渲染方法 - 子类需根据支持的位置重写 === + def render_sidebar_widget(self, context, **kwargs): + """渲染侧边栏组件(钩子方法),子类重写此方法实现侧边栏内容""" + return None + + def render_article_bottom_widget(self, context, **kwargs): + """渲染文章底部组件(钩子方法),子类重写此方法实现文章底部内容""" + return None + + def render_article_top_widget(self, context, **kwargs): + """渲染文章顶部组件(钩子方法),子类重写此方法实现文章顶部内容""" + return None + + def render_header_widget(self, context, **kwargs): + """渲染页头组件(钩子方法),子类重写此方法实现页头内容""" + return None + + def render_footer_widget(self, context, **kwargs): + """渲染页脚组件(钩子方法),子类重写此方法实现页脚内容""" + return None + + def render_comment_before_widget(self, context, **kwargs): + """渲染评论前组件(钩子方法),子类重写此方法实现评论区前内容""" + return None + + def render_comment_after_widget(self, context, **kwargs): + """渲染评论后组件(钩子方法),子类重写此方法实现评论区后内容""" + return None + + # === 模板系统 === + def render_template(self, template_name, context=None): + """ + 渲染插件自带的模板文件 + + Args: + template_name: 模板文件名(如"sidebar.html") + context: 模板上下文(传递给模板的变量) + + Returns: + str: 渲染后的HTML字符串(模板不存在则返回空字符串) + """ + if context is None: + context = {} # 默认为空上下文 + + # 构建模板路径:plugins/插件标识/模板名(遵循Django模板查找规则) + template_path = f"plugins/{self.plugin_slug}/{template_name}" + + try: + # 调用Django的render_to_string渲染模板 + return render_to_string(template_path, context) + except TemplateDoesNotExist: + # 模板不存在时记录警告日志 + logger.warning(f"插件模板不存在:{template_path}") + return "" + + # === 静态资源系统 === + def get_static_url(self, static_file): + """ + 获取插件静态文件的URL(如CSS、JS、图片等) + + Args: + static_file: 静态文件相对路径(如"css/style.css") + + Returns: + str: 静态文件的完整URL(如"/static/myplugin/static/myplugin/css/style.css") + """ + from django.templatetags.static import static # 导入Django的static标签 + # 构建静态文件路径:插件标识/static/插件标识/文件路径(遵循Django静态文件规则) + return static(f"{self.plugin_slug}/static/{self.plugin_slug}/{static_file}") + + def get_css_files(self): + """ + 获取插件需要加载的CSS文件列表(钩子方法) + 子类重写此方法返回CSS文件路径列表,框架会自动在页面加载这些CSS + + Returns: + list: CSS文件路径列表(如["css/style.css"]) + """ + return [] + + def get_js_files(self): + """ + 获取插件需要加载的JavaScript文件列表(钩子方法) + 子类重写此方法返回JS文件路径列表,框架会自动在页面加载这些JS + + Returns: + list: JS文件路径列表(如["js/script.js"]) + """ + return [] + + def get_head_html(self, context=None): + """ + 获取需要插入到HTML头部(
标签内)的内容(钩子方法) + 子类重写此方法返回自定义HTML(如额外的CSS链接、meta标签等) + + Returns: + str: 要插入的HTML字符串 + """ + return "" + + def get_body_html(self, context=None): + """ + 获取需要插入到HTML body底部的内容(钩子方法) + 子类重写此方法返回自定义HTML(如额外的JS脚本) + + Returns: + str: 要插入底部的HTML字符串 + """ + return "" + + def get_plugin_info(self): + """ + 获取插件的详细信息(用于插件管理、展示等) + + Returns: + dict: 包含插件元数据和配置的字典 + """ + return { + 'name': self.PLUGIN_NAME, + 'description': self.PLUGIN_DESCRIPTION, + 'version': self.PLUGIN_VERSION, + 'author': self.PLUGIN_AUTHOR, + 'slug': self.plugin_slug, + 'directory': str(self.plugin_dir), + 'supported_positions': self.SUPPORTED_POSITIONS, + 'priorities': self.POSITION_PRIORITIES + } \ No newline at end of file diff --git a/src/djangoblog/djangoblog/plugin_manage/hook_constants.py b/src/djangoblog/djangoblog/plugin_manage/hook_constants.py new file mode 100644 index 00000000..56d4d858 --- /dev/null +++ b/src/djangoblog/djangoblog/plugin_manage/hook_constants.py @@ -0,0 +1,35 @@ +# 文章相关事件常量模块 +# 该模块定义了与文章操作相关的事件常量、内容钩子常量、位置钩子常量以及资源注入钩子常量 +# 这些常量用于在系统中统一标识不同的操作事件和钩子位置,便于模块间的交互和扩展 + +# 文章操作事件常量 +# 用于标识文章详情加载事件,当加载文章详情时触发相关处理逻辑 +ARTICLE_DETAIL_LOAD = 'article_detail_load' +# 用于标识文章创建事件,当创建新文章时触发相关处理逻辑 +ARTICLE_CREATE = 'article_create' +# 用于标识文章更新事件,当更新已有文章时触发相关处理逻辑 +ARTICLE_UPDATE = 'article_update' +# 用于标识文章删除事件,当删除文章时触发相关处理逻辑 +ARTICLE_DELETE = 'article_delete' + +# 文章内容钩子常量 +# 定义文章内容处理的钩子名称,用于在文章内容渲染前后插入自定义处理逻辑 +ARTICLE_CONTENT_HOOK_NAME = "the_content" + +# 位置钩子常量字典 +# 键为位置标识,值为对应的钩子名称,用于在页面不同位置挂载自定义组件或逻辑 +POSITION_HOOKS = { + 'article_top': 'article_top_widgets', # 文章顶部位置的钩子,用于挂载顶部组件 + 'article_bottom': 'article_bottom_widgets', # 文章底部位置的钩子,用于挂载底部组件 + 'sidebar': 'sidebar_widgets', # 侧边栏位置的钩子,用于挂载侧边栏组件 + 'header': 'header_widgets', # 页头位置的钩子,用于挂载页头组件 + 'footer': 'footer_widgets', # 页脚位置的钩子,用于挂载页脚组件 + 'comment_before': 'comment_before_widgets', # 评论区之前位置的钩子,用于在评论前插入内容 + 'comment_after': 'comment_after_widgets', # 评论区之后位置的钩子,用于在评论后插入内容 +} + +# 资源注入钩子常量 +# 用于标识在HTML头部注入资源(如CSS、JS)的钩子,可通过该钩子添加头部资源 +HEAD_RESOURCES_HOOK = 'head_resources' +# 用于标识在HTML body部分注入资源(如JS)的钩子,可通过该钩子添加body资源 +BODY_RESOURCES_HOOK = 'body_resources' diff --git a/src/djangoblog/djangoblog/plugin_manage/hooks.py b/src/djangoblog/djangoblog/plugin_manage/hooks.py new file mode 100644 index 00000000..f89071ba --- /dev/null +++ b/src/djangoblog/djangoblog/plugin_manage/hooks.py @@ -0,0 +1,89 @@ +# 钩子系统核心模块 +# 该模块实现了一个轻量级的钩子(Hook)机制,支持注册回调函数、执行动作钩子(Action Hook)和过滤钩子(Filter Hook) +# 主要功能包括:管理钩子与回调函数的映射关系、按顺序执行钩子回调、处理回调执行过程中的异常并记录日志 +# 适用于需要模块解耦、灵活扩展的场景,通过钩子机制实现不同组件间的间接交互 + +import logging + +# 初始化日志记录器,用于记录钩子注册、执行过程中的调试信息和错误信息 +logger = logging.getLogger(__name__) + +# 私有字典,用于存储钩子名称与回调函数列表的映射关系 +# 键为钩子名称(字符串),值为注册到该钩子的回调函数列表 +_hooks = {} + + +def register(hook_name: str, callback: callable): + """ + 注册一个回调函数到指定的钩子上。 + 同一钩子可以注册多个回调函数,执行时将按注册顺序依次调用。 + + 参数: + hook_name: 钩子名称,用于标识一组相关的回调函数 + callback: 可调用对象(函数、方法等),将在钩子触发时执行 + """ + # 如果钩子名称不在映射表中,初始化一个空列表用于存储回调 + 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)。 + 动作钩子用于触发一系列操作,不关注返回值,按注册顺序依次执行所有回调函数。 + + 参数: + hook_name: 要执行的钩子名称 + *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)。 + 过滤钩子用于对某个值进行一系列处理,将值依次传递给所有回调函数,最终返回处理后的结果。 + + 参数: + hook_name: 要执行的钩子名称 + value: 初始值,将被回调函数依次处理 + *args: 传递给回调函数的额外位置参数 + **kwargs: 传递给回调函数的额外关键字参数 + + 返回: + 经过所有回调函数处理后的最终值 + """ + # 检查该钩子是否有注册的回调函数 + if hook_name in _hooks: + logger.debug(f"Applying filter hook '{hook_name}'") + # 遍历执行该钩子下的所有回调函数,依次处理值 + for callback in _hooks[hook_name]: + try: + # 调用回调函数,传入当前值和其他参数,并用返回值更新当前值 + 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 \ No newline at end of file diff --git a/src/djangoblog/djangoblog/plugin_manage/loader.py b/src/djangoblog/djangoblog/plugin_manage/loader.py new file mode 100644 index 00000000..63814822 --- /dev/null +++ b/src/djangoblog/djangoblog/plugin_manage/loader.py @@ -0,0 +1,124 @@ +# 插件加载与管理模块 +# 该模块提供了Django应用中插件的动态加载、注册和查询功能 +# 主要功能包括:从指定目录加载激活的插件、维护插件注册表、提供多种插件查询接口 +# 插件需符合特定结构(包含plugin.py及插件实例),通过Django配置指定激活的插件和插件目录 + +import os +import logging +from django.conf import settings + +# 初始化日志记录器,用于记录插件加载过程中的信息和错误 +logger = logging.getLogger(__name__) + +# 全局插件注册表,存储所有已成功加载的插件实例 +_loaded_plugins = [] + + +def load_plugins(): + """ + 从'plugins'目录动态加载并初始化激活的插件。 + 该函数应在Django应用注册表就绪后调用(确保Django配置已加载)。 + 加载逻辑:遍历配置中激活的插件列表,检查插件目录结构完整性,导入并初始化插件实例。 + """ + global _loaded_plugins + # 重置插件注册表,避免重复加载 + _loaded_plugins = [] + + # 遍历配置中激活的插件名称列表(settings.ACTIVE_PLUGINS定义需加载的插件) + for plugin_name in settings.ACTIVE_PLUGINS: + # 构建插件目录的绝对路径(settings.PLUGINS_DIR为插件根目录) + 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: + # 动态导入插件模块:从plugins.插件名.plugin导入模块 + # fromlist=['plugin']确保导入子模块而非父模块 + plugin_module = __import__(f'plugins.{plugin_name}.plugin', fromlist=['plugin']) + + # 检查导入的模块是否包含'plugin'属性(插件实例) + if hasattr(plugin_module, 'plugin'): + # 获取插件实例并添加到全局注册表 + plugin_instance = plugin_module.plugin + _loaded_plugins.append(plugin_instance) + # 记录成功加载日志,包含插件名称和插件定义的名称 + logger.info(f"Successfully loaded plugin: {plugin_name} - {plugin_instance.PLUGIN_NAME}") + else: + # 插件模块结构不完整(缺少plugin实例)时记录警告 + logger.warning(f"Plugin {plugin_name} does not have 'plugin' instance") + + except ImportError as e: + # 捕获导入错误(如模块不存在、依赖缺失等) + logger.error(f"Failed to import plugin: {plugin_name}", exc_info=e) + except AttributeError as e: + # 捕获属性错误(如插件实例缺少必要属性) + logger.error(f"Failed to get plugin instance: {plugin_name}", exc_info=e) + except Exception as e: + # 捕获其他未预期的错误 + logger.error(f"Unexpected error loading plugin: {plugin_name}", exc_info=e) + + +def get_loaded_plugins(): + """ + 获取所有已加载的插件实例列表。 + + 返回: + list: 包含所有成功加载的插件实例的列表 + """ + return _loaded_plugins + + +def get_plugin_by_name(plugin_name): + """ + 根据插件名称查询插件实例(实际查询的是plugin_slug属性,可能与函数名存在命名兼容)。 + + 参数: + plugin_name: 要查询的插件slug名称 + + 返回: + 匹配的插件实例,若未找到则返回None + """ + for plugin in _loaded_plugins: + if plugin.plugin_slug == plugin_name: + return plugin + return None + + +def get_plugin_by_slug(plugin_slug): + """ + 根据插件slug查询插件实例(与plugin_slug属性精确匹配)。 + + 参数: + plugin_slug: 要查询的插件slug标识 + + 返回: + 匹配的插件实例,若未找到则返回None + """ + for plugin in _loaded_plugins: + if plugin.plugin_slug == plugin_slug: + return plugin + return None + + +def get_plugins_info(): + """ + 获取所有已加载插件的信息字典列表。 + 信息由插件的get_plugin_info()方法提供,通常包含名称、描述、版本等元数据。 + + 返回: + list: 每个元素为一个插件的信息字典 + """ + return [plugin.get_plugin_info() for plugin in _loaded_plugins] + + +def get_plugins_by_position(position): + """ + 获取支持指定位置的所有插件实例(基于插件的SUPPORTED_POSITIONS属性筛选)。 + 用于在页面特定位置渲染插件内容(如侧边栏、页头等)。 + + 参数: + position: 位置标识(如'sidebar'、'header'等,对应POSITION_HOOKS中的键) + + 返回: + list: 所有支持该位置的插件实例 + """ + return [plugin for plugin in _loaded_plugins if position in plugin.SUPPORTED_POSITIONS] \ No newline at end of file diff --git a/src/djangoblog/djangoblog/settings.py b/src/djangoblog/djangoblog/settings.py new file mode 100644 index 00000000..416148e3 --- /dev/null +++ b/src/djangoblog/djangoblog/settings.py @@ -0,0 +1,384 @@ +""" +Django settings for djangoblog project. + +Generated by 'django-admin startproject' using Django 1.10.2. + +For more information on this file, see +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 _ + + +def env_to_bool(env, default): + """将环境变量值转换为布尔值的工具函数""" + str_val = os.environ.get(env) + return default if str_val is None else str_val == 'True' + + +#姜雨菲: 构建项目路径,BASE_DIR为项目根目录 +BASE_DIR = Path(__file__).resolve().parent.parent + +#姜雨菲: 快速开发设置 - 不适用于生产环境 +# 安全警告:生产环境中请保持SECRET_KEY的机密性! +SECRET_KEY = os.environ.get( + 'DJANGO_SECRET_KEY') or 'n9ceqv38)#&mwuat@(mjb_p%em$e8$qyr#fw9ot!=ba6lijx-6' +# 安全警告:生产环境中请关闭DEBUG模式 +DEBUG = env_to_bool('DJANGO_DEBUG', True) +# 测试环境标识,当执行测试命令时为True +TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test' + +# 允许访问的主机,生产环境需配置具体域名 +ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com'] +# Django 4.0新增配置,指定可信任的CSRF来源 +CSRF_TRUSTED_ORIGINS = ['http://example.com'] + +# 应用定义 +INSTALLED_APPS = [ + # 自定义的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', # 第三方登录应用 + 'servermanager', # 服务器管理应用 + 'owntracks', # 位置追踪应用 + 'compressor', # 静态文件压缩应用 + 'djangoblog' # 项目主应用 +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', # 安全中间件(处理HTTPS等安全相关) + 'django.contrib.sessions.middleware.SessionMiddleware', # 会话中间件 + 'django.middleware.locale.LocaleMiddleware', # 国际化中间件(语言切换) + 'django.middleware.gzip.GZipMiddleware', # GZip压缩中间件 + 'django.middleware.common.CommonMiddleware', # 通用中间件(处理请求/响应) + 'django.middleware.csrf.CsrfViewMiddleware', # CSRF保护中间件 + 'django.contrib.auth.middleware.AuthenticationMiddleware', # 认证中间件 + 'django.contrib.messages.middleware.MessageMiddleware', # 消息中间件 + 'django.middleware.clickjacking.XFrameOptionsMiddleware', # 点击劫持保护中间件 + 'django.middleware.http.ConditionalGetMiddleware', # 条件获取中间件(处理304响应) + 'blog.middleware.OnlineMiddleware' # 自定义在线用户统计中间件 +] + +ROOT_URLCONF = 'djangoblog.urls' # 项目URL配置入口 + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', # 模板引擎 + 'DIRS': [os.path.join(BASE_DIR, 'templates')], # 自定义模板目录 + 'APP_DIRS': True, # 是否自动搜索应用内的templates目录 + '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' # 自定义SEO上下文处理器 + ], + }, + }, +] + +WSGI_APPLICATION = 'djangoblog.wsgi.application' # WSGI应用入口 + +# 数据库配置 +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.mysql', # 使用MySQL数据库引擎 + 'NAME': 'djangoblog', # 数据库名 + 'USER': 'root', # 数据库用户名 + 'PASSWORD': '050807', # 数据库密码 + 'HOST': '127.0.0.1', # 数据库主机 + 'PORT': 3306, # 数据库端口 + } +} + +# 密码验证配置 +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# 支持的语言列表(国际化配置) +LANGUAGES = ( + ('en', _('English')), + ('zh-hans', _('Simplified Chinese')), + ('zh-hant', _('Traditional Chinese')), +) +# 语言文件路径 +LOCALE_PATHS = ( + os.path.join(BASE_DIR, 'locale'), +) + +LANGUAGE_CODE = 'zh-hans' # 默认语言 +TIME_ZONE = 'Asia/Shanghai' # 时区 +USE_I18N = True # 启用国际化 +USE_L10N = True # 启用本地化格式 +USE_TZ = False # 不使用时区感知模型 + +# 搜索框架Haystack配置 +HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.whoosh_cn_backend.WhooshEngine', # 使用Whoosh搜索引擎(中文适配版) + 'PATH': os.path.join(os.path.dirname(__file__), 'whoosh_index'), # 索引存储路径 + }, +} +HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor' # 实时信号处理器(自动更新索引) + +# 认证后端配置(支持用户名或邮箱登录) +AUTHENTICATION_BACKENDS = [ + 'accounts.user_login_backend.EmailOrUsernameModelBackend'] + +# 静态文件配置 +STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic') # 静态文件收集目录(生产环境用) +STATIC_URL = '/static/' # 静态文件URL前缀 +STATICFILES = os.path.join(BASE_DIR, 'static') # 静态文件源目录 + +# 插件静态文件目录 +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, 'plugins'), # 插件静态文件目录 +] + +AUTH_USER_MODEL = 'accounts.BlogUser' # 自定义用户模型 +LOGIN_URL = '/login/' # 登录URL + +# 时间格式定义 +TIME_FORMAT = '%Y-%m-%d %H:%M:%S' +DATE_TIME_FORMAT = '%Y-%m-%d' + +# Bootstrap颜色样式 +BOOTSTRAP_COLOR_TYPES = [ + 'default', 'primary', 'success', 'info', 'warning', 'danger' +] + +# 分页配置 +PAGINATE_BY = 10 +# HTTP缓存超时时间(秒) +CACHE_CONTROL_MAX_AGE = 2592000 + +# 缓存配置 +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', # 本地内存缓存 + 'TIMEOUT': 10800, # 缓存超时时间(秒) + 'LOCATION': 'unique-snowflake', # 缓存位置标识 + } +} +# 若存在环境变量,则使用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")}', + } + } + +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_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 # 服务器邮件发件人 +# 管理员邮件通知配置 +ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')] +# 微信管理密码(两次MD5加密) +WXADMIN = os.environ.get( + 'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7' + +# 日志配置 +LOG_PATH = os.path.join(BASE_DIR, 'logs') +if not os.path.exists(LOG_PATH): + os.makedirs(LOG_PATH, exist_ok=True) + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'root': { + 'level': 'INFO', + 'handlers': ['console', 'log_file'], + }, + 'formatters': { + 'verbose': { + 'format': '[%(asctime)s] %(levelname)s [%(name)s.%(funcName)s:%(lineno)d %(module)s] %(message)s', + } + }, + 'filters': { + 'require_debug_false': { + '()': 'django.utils.log.RequireDebugFalse', + }, + 'require_debug_true': { + '()': 'django.utils.log.RequireDebugTrue', + }, + }, + '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' + }, + 'console': { + 'level': 'DEBUG', + 'filters': ['require_debug_true'], + 'class': 'logging.StreamHandler', + 'formatter': 'verbose' + }, + 'null': { + 'class': 'logging.NullHandler', + }, + 'mail_admins': { + 'level': 'ERROR', + 'filters': ['require_debug_false'], + 'class': 'django.utils.log.AdminEmailHandler' + } + }, + 'loggers': { + 'djangoblog': { + 'handlers': ['log_file', 'console'], + 'level': 'INFO', + 'propagate': True, + }, + 'django.request': { + 'handlers': ['mail_admins'], + 'level': 'ERROR', + 'propagate': False, + } + } +} + +# 静态文件查找器(用于Compressor) +STATICFILES_FINDERS = ( + 'django.contrib.staticfiles.finders.FileSystemFinder', + 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + 'compressor.finders.CompressorFinder', +) +COMPRESS_ENABLED = True # 启用静态文件压缩 +# 根据环境变量决定是否启用离线压缩 +COMPRESS_OFFLINE = os.environ.get('COMPRESS_OFFLINE', 'False').lower() == 'true' + +COMPRESS_OUTPUT_DIR = 'compressed' # 压缩文件输出目录 +COMPRESS_CSS_HASHING_METHOD = 'mtime' # CSS哈希生成方式(基于修改时间) +COMPRESS_JS_HASHING_METHOD = 'mtime' # JS哈希生成方式(基于修改时间) + +# CSS压缩过滤器 +COMPRESS_CSS_FILTERS = [ + 'compressor.filters.css_default.CssAbsoluteFilter', # 处理CSS中的绝对URL + 'compressor.filters.cssmin.CSSCompressorFilter', # CSS压缩器 +] + +# JS压缩过滤器 +COMPRESS_JS_FILTERS = [ + 'compressor.filters.jsmin.SlimItFilter', # JS压缩器 +] + +COMPRESS_CACHE_BACKEND = 'default' # 压缩缓存后端 +COMPRESS_CACHE_KEY_FUNCTION = 'compressor.cache.simple_cachekey' # 缓存键生成函数 + +# 预编译器配置(支持SCSS/SASS) +COMPRESS_PRECOMPILERS = ( + ('text/x-scss', 'django_libsass.SassCompiler'), + ('text/x-sass', 'django_libsass.SassCompiler'), +) + +# 压缩性能优化配置 +COMPRESS_MINT_DELAY = 30 +COMPRESS_MTIME_DELAY = 10 +COMPRESS_REBUILD_TIMEOUT = 2592000 + +COMPRESS_CSS_COMPRESSOR = 'compressor.css.CssCompressor' +COMPRESS_JS_COMPRESSOR = 'compressor.js.JsCompressor' + +# 静态文件存储(带Manifest用于缓存破坏) +STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.ManifestStaticFilesStorage' + +COMPRESS_URL = STATIC_URL +COMPRESS_ROOT = STATIC_ROOT + +# 媒体文件(用户上传文件)配置 +MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') +MEDIA_URL = '/media/' + +# XFrameOptions配置(允许同域iframe) +X_FRAME_OPTIONS = 'SAMEORIGIN' + +# 安全头部配置 +SECURE_BROWSER_XSS_FILTER = True # 启用XSS过滤 +SECURE_CONTENT_TYPE_NOSNIFF = True # 禁止内容类型嗅探 +SECURE_REFERRER_POLICY = 'strict-origin-when-cross-origin' # Referrer策略 + +# 内容安全策略(CSP) +CSP_DEFAULT_SRC = ["'self'"] +CSP_SCRIPT_SRC = ["'self'", "'unsafe-inline'", "cdn.mathjax.org", "*.googleapis.com"] +CSP_STYLE_SRC = ["'self'", "'unsafe-inline'", "*.googleapis.com", "*.gstatic.com"] +CSP_IMG_SRC = ["'self'", "data:", "*.lylinux.net", "*.gravatar.com", "*.githubusercontent.com"] +CSP_FONT_SRC = ["'self'", "*.googleapis.com", "*.gstatic.com"] +CSP_CONNECT_SRC = ["'self'"] +CSP_FRAME_SRC = ["'none'"] +CSP_OBJECT_SRC = ["'none'"] + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # 默认自增字段类型 + +# 若存在环境变量,则使用Elasticsearch作为搜索后端 +if os.environ.get('DJANGO_ELASTICSEARCH_HOST'): + ELASTICSEARCH_DSL = { + 'default': { + 'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') + }, + } + HAYSTACK_CONNECTIONS = { + 'default': { + 'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine', + }, + } + +# 插件系统配置 +PLUGINS_DIR = BASE_DIR / 'plugins' +ACTIVE_PLUGINS = [ + 'article_copyright', # 文章版权插件 + 'reading_time', # 阅读时间插件 + 'external_links', # 外部链接插件 + 'view_count', # 阅读计数插件 + 'seo_optimizer', # SEO优化插件 + 'image_lazy_loading', # 图片懒加载插件 + 'article_recommendation', # 文章推荐插件 +] \ No newline at end of file diff --git a/src/djangoblog/djangoblog/sitemap.py b/src/djangoblog/djangoblog/sitemap.py new file mode 100644 index 00000000..75d22e09 --- /dev/null +++ b/src/djangoblog/djangoblog/sitemap.py @@ -0,0 +1,82 @@ +from django.contrib.sitemaps import Sitemap +from django.urls import reverse + +from blog.models import Article, Category, Tag + + +class StaticViewSitemap(Sitemap): + """ + 静态页面的站点地图类 + 用于生成网站中固定URL的页面(如首页)的站点地图条目 + """ + # 页面优先级(0.0-1.0,1.0表示最高优先级) + priority = 0.5 + # 页面内容更新频率(可选值:always, hourly, daily, weekly, monthly, yearly, never) + changefreq = 'daily' + + def items(self): + """返回需要包含在站点地图中的静态URL名称列表""" + # 这里仅包含博客首页的URL名称(对应urls.py中定义的name='blog:index') + return ['blog:index', ] + + def location(self, item): + """根据items返回的URL名称生成完整URL""" + return reverse(item) + + +class ArticleSiteMap(Sitemap): + """文章页面的站点地图类""" + 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 + + +class CategorySiteMap(Sitemap): + """分类页面的站点地图类""" + changefreq = "Weekly" # 分类页面更新频率为每周 + priority = "0.6" # 分类页面优先级 + + def items(self): + """返回所有分类对象""" + return Category.objects.all() + + def lastmod(self, obj): + """返回分类的最后修改时间""" + return obj.last_modify_time + + +class TagSiteMap(Sitemap): + """标签页面的站点地图类""" + changefreq = "Weekly" # 标签页面更新频率为每周 + priority = "0.3" # 标签页面优先级(低于文章和分类) + + def items(self): + """返回所有标签对象""" + return Tag.objects.all() + + def lastmod(self, obj): + """返回标签的最后修改时间""" + return obj.last_modify_time + + +class UserSiteMap(Sitemap): + """用户页面的站点地图类""" + changefreq = "Weekly" # 用户页面更新频率为每周 + priority = "0.3" # 用户页面优先级 + + def items(self): + """返回所有发布过文章的作者(去重处理)""" + # 通过文章作者去重,获取所有有发布文章的用户 + return list(set(map(lambda x: x.author, Article.objects.all()))) + + def lastmod(self, obj): + """返回用户的注册时间(作为最后修改时间的替代)""" + return obj.date_joined \ No newline at end of file diff --git a/src/djangoblog/djangoblog/spider_notify.py b/src/djangoblog/djangoblog/spider_notify.py new file mode 100644 index 00000000..c8f9f8c1 --- /dev/null +++ b/src/djangoblog/djangoblog/spider_notify.py @@ -0,0 +1,45 @@ +import logging + +import requests +from django.conf import settings + +# 创建当前模块的日志记录器,用于记录通知相关的日志信息 +logger = logging.getLogger(__name__) + + +class SpiderNotify(): + """ + 搜索引擎爬虫通知类 + 用于向搜索引擎(目前支持百度)提交网站URL,告知内容更新,便于爬虫抓取 + """ + + @staticmethod + def baidu_notify(urls): + """ + 向百度搜索引擎提交URL的静态方法 + 通过百度链接提交通知接口,告知百度新增/更新的页面URL + + Args: + urls (list): 需要提交的URL列表,每个元素为完整的页面URL字符串 + """ + try: + # 将URL列表用换行符拼接,符合百度接口的数据格式要求 + data = '\n'.join(urls) + # 向百度通知接口发送POST请求,提交URL数据 + result = requests.post(settings.BAIDU_NOTIFY_URL, data=data) + # 记录接口返回的响应信息(日志级别:INFO) + logger.info(result.text) + except Exception as e: + # 捕获所有异常并记录错误信息(日志级别:ERROR) + logger.error(e) + + @staticmethod + def notify(url): + """ + 通用通知入口静态方法 + 统一调用百度通知方法,便于后续扩展支持其他搜索引擎 + + Args: + url (list): 需要提交的URL列表,与baidu_notify方法的urls参数格式一致 + """ + SpiderNotify.baidu_notify(url) \ No newline at end of file diff --git a/src/djangoblog/djangoblog/tests.py b/src/djangoblog/djangoblog/tests.py new file mode 100644 index 00000000..586b837c --- /dev/null +++ b/src/djangoblog/djangoblog/tests.py @@ -0,0 +1,51 @@ +from django.test import TestCase + +#姜雨菲: 导入项目工具模块中的所有工具函数/类 +from djangoblog.utils import * + + +class DjangoBlogTest(TestCase): + """ + 博客项目核心工具类的单元测试类 + 用于验证工具函数的功能正确性 + """ + + def setUp(self): + """ + 测试前置方法 + 在每个测试方法执行前调用,可用于初始化测试数据 + 此处暂无需初始化操作,保持空实现 + """ + pass + + def test_utils(self): + """ + 测试工具函数的功能 + 包括SHA256加密、Markdown解析和字典转URL参数功能 + """ + # 测试SHA256加密函数 + md5 = get_sha256('test') # 对字符串'test'进行SHA256加密 + self.assertIsNotNone(md5) # 断言加密结果不为空 + + # 测试Markdown解析功能 + # 定义一段包含标题、代码块、链接的Markdown文本 + c = CommonMarkdown.get_markdown(''' + # Title1 + + ```python + import os + ``` + + [url](https://www.lylinux.net/) + + [ddd](http://www.baidu.com) + + + ''') + self.assertIsNotNone(c) + d = { + 'd': 'key1', + 'd2': 'key2' + } + data = parse_dict_to_url(d) + self.assertIsNotNone(data) diff --git a/src/djangoblog/djangoblog/urls.py b/src/djangoblog/djangoblog/urls.py new file mode 100644 index 00000000..9e2274d4 --- /dev/null +++ b/src/djangoblog/djangoblog/urls.py @@ -0,0 +1,89 @@ +"""djangoblog URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.10/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +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 django.http import JsonResponse +import time + +from blog.views import EsSearchView +from djangoblog.admin_site import admin_site # 自定义的admin站点 +from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm # ElasticSearch搜索表单 +from djangoblog.feeds import DjangoBlogFeed # RSS订阅源 +from djangoblog.sitemap import ( # 站点地图相关类 + ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap +) + +#姜雨菲: 站点地图配置:将不同类型的页面分别映射到对应的站点地图类 +sitemaps = { + 'blog': ArticleSiteMap, # 文章页面 + 'Category': CategorySiteMap, # 分类页面 + 'Tag': TagSiteMap, # 标签页面 + 'User': UserSiteMap, # 用户页面 + 'static': StaticViewSitemap # 静态页面 +} + +#姜雨菲: 自定义错误页面处理视图 +handler404 = 'blog.views.page_not_found_view' # 404页面未找到 +handler500 = 'blog.views.server_error_view' # 500服务器错误 +handle403 = 'blog.views.permission_denied_view' # 403权限拒绝 + + +def health_check(request): + """ + 健康检查接口 + 用于监控服务是否正常运行,简单返回服务健康状态和时间戳 + """ + return JsonResponse({ + 'status': 'healthy', # 健康状态标识 + 'timestamp': time.time() # 当前时间戳 + }) + +# 基础URL配置(不包含国际化前缀) +urlpatterns = [ + path('i18n/', include('django.conf.urls.i18n')), # 国际化配置入口 + path('health/', health_check, name='health_check'), # 健康检查接口 +] + +# 包含国际化前缀的URL配置(会自动添加语言代码前缀,如/en/、/zh-hans/) +urlpatterns += i18n_patterns( + re_path(r'^admin/', admin_site.urls), # 自定义admin后台URL + re_path(r'', include('blog.urls', namespace='blog')), # 博客应用URL,命名空间blog + re_path(r'mdeditor/', include('mdeditor.urls')), # Markdown编辑器URL + re_path(r'', include('comments.urls', namespace='comment')), # 评论应用URL,命名空间comment + re_path(r'', include('accounts.urls', namespace='account')), # 账户应用URL,命名空间account + re_path(r'', include('oauth.urls', namespace='oauth')), # 第三方登录应用URL,命名空间oauth + # 站点地图XML + re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps}, + name='django.contrib.sitemaps.views.sitemap'), + re_path(r'^feed/$', DjangoBlogFeed()), # RSS订阅源URL + re_path(r'^rss/$', DjangoBlogFeed()), # 另一个RSS订阅源URL(与feed功能相同) + # 搜索功能URL,使用自定义的EsSearchView和搜索表单 + re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm), + name='search'), + re_path(r'', include('servermanager.urls', namespace='servermanager')), # 服务器管理应用URL + re_path(r'', include('owntracks.urls', namespace='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) \ No newline at end of file diff --git a/src/djangoblog/djangoblog/utils.py b/src/djangoblog/djangoblog/utils.py new file mode 100644 index 00000000..fb8b1f72 --- /dev/null +++ b/src/djangoblog/djangoblog/utils.py @@ -0,0 +1,372 @@ +#!/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__) + + +def get_max_articleid_commentid(): + """ + 获取最新文章和评论的ID + 用于获取当前系统中最新发布的文章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): + """ + 对字符串进行SHA256加密 + :param str: 需要加密的字符串 + :return: 加密后的十六进制字符串 + """ + m = sha256(str.encode('utf-8')) + return m.hexdigest() + + +def cache_decorator(expiration=3 * 60): + """ + 缓存装饰器 + 用于缓存函数返回结果,减少重复计算,默认缓存3分钟 + :param expiration: 缓存过期时间(秒) + :return: 装饰器函数 + """ + def wrapper(func): + def news(*args, **kwargs): + try: + # 尝试从第一个参数(通常是视图实例)获取缓存键 + view = args[0] + key = view.get_cache_key() + except: + # 获取失败时自动生成缓存键 + key = None + if not key: + # 根据函数和参数生成唯一字符串,用于创建缓存键 + unique_str = repr((func, args, kwargs)) + m = sha256(unique_str.encode('utf-8')) + key = m.hexdigest() + # 尝试从缓存获取数据 + value = cache.get(key) + if value is not None: + # 缓存命中时返回结果(过滤默认占位值) + if str(value) == '__default_cache_value__': + return None + else: + return value + else: + # 缓存未命中时执行原函数并缓存结果 + logger.debug( + 'cache_decorator set cache:%s key:%s' % + (func.__name__, key)) + value = func(*args, **kwargs) + if value is None: + cache.set(key, '__default_cache_value__', expiration) + else: + cache.set(key, value, expiration) + return value + + return news + + return wrapper + + +def expire_view_cache(path, servername, serverport, key_prefix=None): + ''' + 刷新视图缓存 + 手动删除指定URL路径的视图缓存 + :param path: URL路径 + :param servername: 主机名 + :param serverport: 端口号 + :param key_prefix: 缓存键前缀 + :return: 是否刷新成功(布尔值) + ''' + from django.http import HttpRequest + from django.utils.cache import get_cache_key + + # 构造模拟请求对象 + request = HttpRequest() + request.META = {'SERVER_NAME': servername, 'SERVER_PORT': serverport} + request.path = path + + # 获取缓存键并删除缓存 + key = get_cache_key(request, key_prefix=key_prefix, cache=cache) + if key: + logger.info('expire_view_cache:get key:{path}'.format(path=path)) + if cache.get(key): + cache.delete(key) + return True + return False + + +@cache_decorator() +def get_current_site(): + """ + 获取当前站点信息(带缓存) + 从Django的Site模型获取当前站点配置,结果缓存3分钟 + :return: Site模型实例 + """ + site = Site.objects.get_current() + return site + + +class CommonMarkdown: + """ + Markdown解析工具类 + 提供Markdown文本转HTML的功能,支持代码高亮、目录生成等 + """ + @staticmethod + def _convert_markdown(value): + """ + 内部Markdown转换方法 + 配置Markdown解析器并转换文本 + :param value: Markdown格式文本 + :return: (转换后的HTML内容, 目录HTML) + """ + md = markdown.Markdown( + extensions=[ + 'extra', # 额外功能(表格、脚注等) + 'codehilite', # 代码高亮 + 'toc', # 目录生成 + 'tables', # 表格支持 + ] + ) + body = md.convert(value) + toc = md.toc + return body, toc + + @staticmethod + def get_markdown_with_toc(value): + """ + Markdown转HTML(带目录) + :param value: Markdown格式文本 + :return: (HTML内容, 目录HTML) + """ + body, toc = CommonMarkdown._convert_markdown(value) + return body, toc + + @staticmethod + def get_markdown(value): + """ + Markdown转HTML(仅内容,不含目录) + :param value: Markdown格式文本 + :return: 转换后的HTML内容 + """ + body, toc = CommonMarkdown._convert_markdown(value) + return body + + +def send_email(emailto, title, content): + """ + 发送邮件(通过信号机制) + 触发邮件发送信号,解耦邮件发送逻辑 + :param emailto: 收件人邮箱 + :param title: 邮件标题 + :param content: 邮件内容 + """ + from djangoblog.blog_signals import send_email_signal + send_email_signal.send( + send_email.__class__, + emailto=emailto, + title=title, + content=content) + + +def generate_code() -> str: + """生成6位随机数字验证码""" + return ''.join(random.sample(string.digits, 6)) + + +def parse_dict_to_url(dict): + """ + 将字典转换为URL查询字符串 + 对键值对进行URL编码,避免特殊字符问题 + :param dict: 待转换的字典 + :return: URL查询字符串(格式:key1=value1&key2=value2) + """ + from urllib.parse import quote + url = '&'.join(['{}={}'.format(quote(k, safe='/'), quote(v, safe='/')) + for k, v in dict.items()]) + return url + + +def get_blog_setting(): + """ + 获取博客系统配置(带缓存) + 从数据库获取博客全局配置,无配置时创建默认配置,结果缓存 + :return: BlogSettings模型实例 + """ + value = cache.get('get_blog_setting') + if value: + return value + else: + from blog.models import BlogSettings + # 无配置时初始化默认配置 + if not BlogSettings.objects.count(): + setting = BlogSettings() + setting.site_name = 'djangoblog' + setting.site_description = '基于Django的博客系统' + setting.site_seo_description = '基于Django的博客系统' + setting.site_keywords = 'Django,Python' + setting.article_sub_length = 300 + setting.sidebar_article_count = 10 + setting.sidebar_comment_count = 5 + setting.show_google_adsense = False + setting.open_site_comment = True + setting.analytics_code = '' + setting.beian_code = '' + setting.show_gongan_code = False + setting.comment_need_review = False + setting.save() + value = BlogSettings.objects.first() + logger.info('set cache get_blog_setting') + cache.set('get_blog_setting', value) + return value + + +def save_user_avatar(url): + ''' + 保存用户头像到本地静态文件目录 + 从URL下载头像并保存,支持常见图片格式,失败时返回默认头像 + :param url: 头像图片URL + :return: 本地头像的静态文件URL + ''' + logger.info(url) + + try: + # 定义头像保存目录 + basedir = os.path.join(settings.STATICFILES, 'avatar') + # 下载头像图片 + rsp = requests.get(url, timeout=2) + if rsp.status_code == 200: + # 目录不存在时创建 + if not os.path.exists(basedir): + os.makedirs(basedir) + + # 验证图片格式 + image_extensions = ['.jpg', '.png', 'jpeg', '.gif'] + isimage = len([i for i in image_extensions if url.endswith(i)]) > 0 + ext = os.path.splitext(url)[1] if isimage else '.jpg' + # 生成唯一文件名 + 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') + + +def delete_sidebar_cache(): + """ + 删除侧边栏缓存 + 根据LinkShowType的所有值生成缓存键并删除 + """ + 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) + + +def delete_view_cache(prefix, keys): + """ + 删除模板片段缓存 + :param prefix: 缓存前缀 + :param keys: 缓存键列表 + """ + from django.core.cache.utils import make_template_fragment_key + key = make_template_fragment_key(prefix, keys) + cache.delete(key) + + +def get_resource_url(): + """ + 获取静态资源基础URL + 优先使用settings中的STATIC_URL,无配置时使用站点域名拼接/static/ + :return: 静态资源基础URL + """ + if settings.STATIC_URL: + return settings.STATIC_URL + else: + site = get_current_site() + return 'http://' + site.domain + '/static/' + + +# HTML清理配置 - 防止XSS攻击 +# 允许的HTML标签白名单 +ALLOWED_TAGS = ['a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol', 'pre', 'strong', 'ul', 'h1', + 'h2', 'p', 'span', 'div'] + +# 允许的CSS类白名单(主要用于代码高亮) +ALLOWED_CLASSES = [ + 'codehilite', 'highlight', 'hll', 'c', 'err', 'k', 'l', 'n', 'o', 'p', 'cm', 'cp', 'c1', 'cs', + 'gd', 'ge', 'gr', 'gh', 'gi', 'go', 'gp', 'gs', 'gu', 'gt', 'kc', 'kd', 'kn', 'kp', 'kr', 'kt', + 'ld', 'm', 'mf', 'mh', 'mi', 'mo', 'na', 'nb', 'nc', 'no', 'nd', 'ni', 'ne', 'nf', 'nl', 'nn', + 'nt', 'nv', 'ow', 'w', 'mb', 'mh', 'mi', 'mo', 'sb', 'sc', 'sd', 'se', 'sh', 'si', 'sx', 's2', + 's1', 'ss', 'bp', 'vc', 'vg', 'vi', 'il' +] + +def class_filter(tag, name, value): + """ + 自定义class属性过滤器 + 只保留ALLOWED_CLASSES中的CSS类,过滤危险或未授权的类名 + :param tag: HTML标签名 + :param name: 属性名 + :param value: 属性值 + :return: 过滤后的属性值,无合法类时返回False + """ + if name == 'class': + allowed_classes = [cls for cls in value.split() if cls in ALLOWED_CLASSES] + return ' '.join(allowed_classes) if allowed_classes else False + return value + +# 允许的HTML属性白名单 +ALLOWED_ATTRIBUTES = { + 'a': ['href', 'title'], # 链接标签允许的属性 + 'abbr': ['title'], # 缩写标签允许的属性 + 'acronym': ['title'], # 首字母缩写标签允许的属性 + 'span': class_filter, # span标签的class属性使用自定义过滤器 + 'div': class_filter, # div标签的class属性使用自定义过滤器 + 'pre': class_filter, # pre标签的class属性使用自定义过滤器 + 'code': class_filter # code标签的class属性使用自定义过滤器 +} + +# 允许的URL协议白名单(防止javascript:等危险协议) +ALLOWED_PROTOCOLS = ['http', 'https', 'mailto'] + +def sanitize_html(html): + """ + 安全的HTML清理函数 + 使用bleach库过滤危险HTML内容,防止XSS攻击 + :param html: 需要清理的HTML字符串 + :return: 安全的HTML字符串 + """ + return bleach.clean( + html, + tags=ALLOWED_TAGS, # 只允许白名单中的标签 + attributes=ALLOWED_ATTRIBUTES, # 只允许白名单中的属性 + protocols=ALLOWED_PROTOCOLS, # 限制URL协议 + strip=True, # 移除不允许的标签(而非转义) + strip_comments=True # 移除HTML注释 + ) \ No newline at end of file diff --git a/src/djangoblog/djangoblog/whoosh_cn_backend.py b/src/djangoblog/djangoblog/whoosh_cn_backend.py new file mode 100644 index 00000000..b885613f --- /dev/null +++ b/src/djangoblog/djangoblog/whoosh_cn_backend.py @@ -0,0 +1,1044 @@ +# encoding: utf-8 + +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +import os +import re +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 + +try: + import whoosh +except ImportError: + raise MissingDependency( + "The 'whoosh' backend requires the installation of 'Whoosh'. Please refer to the documentation.") + +#姜雨菲: 检查Whoosh版本,要求2.5.0及以上 +if not hasattr(whoosh, '__version__') or whoosh.__version__ < (2, 5, 0): + raise MissingDependency( + "The 'whoosh' backend requires version 2.5.0 or greater.") + +#姜雨菲:日期时间格式正则表达式,用于解析Whoosh返回的日期时间字符串 +DATETIME_REGEX = re.compile( + '^(?P