Compare commits

..

5 Commits

@ -1,66 +1,47 @@
from django.contrib import admin # 导入Django管理后台模块
from django.urls import reverse # 导入reverse函数用于生成URL
from django.utils.html import format_html # 导入HTML格式化函数用于生成HTML标签
from django.utils.translation import gettext_lazy as _ # 导入翻译函数,用于国际化
from django.contrib import admin
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
def disable_commentstatus(modeladmin, request, queryset):
# 批量禁用选中的评论将is_enable设为False
queryset.update(is_enable=False)
def enable_commentstatus(modeladmin, request, queryset):
# 批量启用选中的评论将is_enable设为True
queryset.update(is_enable=True)
# 为批量操作设置显示名称(支持国际化)
disable_commentstatus.short_description = _('Disable comments')
enable_commentstatus.short_description = _('Enable comments')
class CommentAdmin(admin.ModelAdmin):
# 管理后台列表页每页显示20条记录
list_per_page = 20
# 列表页显示的字段
list_display = (
'id', # 评论ID
'body', # 评论内容
'link_to_userinfo', # 评论作者(带链接)
'link_to_article', # 关联文章(带链接)
'is_enable', # 是否启用
'creation_time' # 创建时间
)
# 列表页中可点击跳转详情页的字段
'id',
'body',
'link_to_userinfo',
'link_to_article',
'is_enable',
'creation_time')
list_display_links = ('id', 'body', 'is_enable')
# 右侧筛选器,按是否启用筛选
list_filter = ('is_enable',)
# 编辑页排除的字段(不允许手动编辑)
exclude = ('creation_time', 'last_modify_time')
# 注册批量操作函数
actions = [disable_commentstatus, enable_commentstatus]
def link_to_userinfo(self, obj):
# 生成评论作者的管理后台编辑链接
# 获取用户模型的app标签和模型名称
info = (obj.author._meta.app_label, obj.author._meta.model_name)
# 生成用户编辑页的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.author.id,))
# 返回带链接的HTML显示昵称或邮箱
return format_html(
u'<a href="%s">%s</a>' %
(link, obj.author.nickname if obj.author.nickname else obj.author.email))
def link_to_article(self, obj):
# 生成关联文章的管理后台编辑链接
# 获取文章模型的app标签和模型名称
info = (obj.article._meta.app_label, obj.article._meta.model_name)
# 生成文章编辑页的URL
link = reverse('admin:%s_%s_change' % info, args=(obj.article.id,))
# 返回带链接的HTML显示文章标题
return format_html(
u'<a href="%s">%s</a>' % (link, obj.article.title))
# 设置自定义字段在列表页的显示名称(支持国际化)
link_to_userinfo.short_description = _('User')
link_to_article.short_description = _('Article')
link_to_article.short_description = _('Article')

@ -1,4 +1,5 @@
from django.apps import AppConfig # 导入Django的应用配置基类
from django.apps import AppConfig
class CommentsConfig(AppConfig): # 定义评论应用的配置类继承自AppConfig
name = 'comments' # 指定应用的名称为'comments'Django通过此名称识别该应用
class CommentsConfig(AppConfig):
name = 'comments'

@ -1,15 +1,13 @@
from django import forms # 导入Django表单基础模块
from django.forms import ModelForm # 导入模型表单类,用于基于模型创建表单
from django import forms
from django.forms import ModelForm
from .models import Comment # 从当前应用导入Comment模型
from .models import Comment
class CommentForm(ModelForm): # 定义评论表单类继承自ModelForm
# 定义父评论ID字段用于处理评论回复功能
# 使用HiddenInput小部件前端隐藏非必填顶级评论不需要父评论ID
class CommentForm(ModelForm):
parent_comment_id = forms.IntegerField(
widget=forms.HiddenInput, required=False)
class Meta: # 元数据配置
model = Comment # 指定表单关联的模型为Comment
fields = ['body'] # 表单包含的字段仅包含评论内容字段body
class Meta:
model = Comment
fields = ['body']

@ -1,37 +1,38 @@
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.conf import settings # 导入Django项目配置用于获取用户模型等设置
from django.db import migrations, models # 导入迁移和模型模块,用于定义数据库迁移操作
import django.db.models.deletion # 导入外键删除行为处理模块,定义外键删除策略
import django.utils.timezone # 导入时区工具,处理时间字段默认值
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration): # 定义迁移类,包含数据库迁移操作
initial = True # 标记为初始迁移(该模型的首次迁移)
class Migration(migrations.Migration):
dependencies = [ # 迁移依赖:执行当前迁移前需完成的迁移
('blog', '0001_initial'), # 依赖blog应用的0001_initial迁移确保Article模型存在
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型的可交换迁移(支持自定义用户模型)
initial = True
dependencies = [
('blog', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [ # 迁移操作列表:当前迁移需执行的数据库操作
migrations.CreateModel( # 创建Comment模型对应数据库表
name='Comment', # 模型名称为Comment评论模型
fields=[ # 模型字段定义
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), # 自增主键,自动创建,作为表的唯一标识
('body', models.TextField(max_length=300, verbose_name='正文')), # 评论正文字段文本类型最大300字符
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')), # 创建时间字段,默认值为当前时间
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')), # 最后修改时间字段,默认值为当前时间
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')), # 评论显示开关默认显示True
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')), # 外键关联Article级联删除文章删则评论删
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')), # 外键关联用户模型,级联删除(用户删则评论删)
('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')), # 自关联外键(支持评论回复),允许为空
operations = [
migrations.CreateModel(
name='Comment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('body', models.TextField(max_length=300, verbose_name='正文')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
('last_mod_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='修改时间')),
('is_enable', models.BooleanField(default=True, verbose_name='是否显示')),
('article', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='文章')),
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='作者')),
('parent_comment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='上级评论')),
],
options={ # 模型元数据配置
'verbose_name': '评论', # 模型单数显示名称
'verbose_name_plural': '评论', # 模型复数显示名称
'ordering': ['-id'], # 默认排序按id降序最新评论在前
'get_latest_by': 'id', # 获取最新记录时依据id字段
options={
'verbose_name': '评论',
'verbose_name_plural': '评论',
'ordering': ['-id'],
'get_latest_by': 'id',
},
),
]
]

@ -1,17 +1,18 @@
# Generated by Django 4.1.7 on 2023-04-24 13:48
from django.db import migrations, models # 导入Django迁移和模型模块用于数据库结构变更
from django.db import migrations, models
class Migration(migrations.Migration): # 定义迁移类,包含数据库变更操作
dependencies = [ # 迁移依赖需先执行comments应用的0001_initial迁移
class Migration(migrations.Migration):
dependencies = [
('comments', '0001_initial'),
]
operations = [ # 迁移操作列表:当前需要执行的数据库变更
migrations.AlterField( # 修改已有字段
model_name='comment', # 要修改的模型名称为Comment
name='is_enable', # 要修改的字段名称为is_enable
field=models.BooleanField(default=False, verbose_name='是否显示'), # 将字段默认值从True改为False评论默认不显示
operations = [
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='是否显示'),
),
]
]

@ -1,59 +1,60 @@
# Generated by Django 4.2.5 on 2023-09-06 13:13
from django.conf import settings # 导入Django项目配置用于获取用户模型设置
from django.db import migrations, models # 导入迁移和模型模块,用于数据库结构变更
import django.db.models.deletion # 导入外键删除行为处理模块
import django.utils.timezone # 导入时区工具,处理时间字段默认值
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration): # 定义迁移类,包含数据库变更操作
dependencies = [ # 迁移依赖:执行当前迁移前需完成的迁移
migrations.swappable_dependency(settings.AUTH_USER_MODEL), # 依赖用户模型的可交换迁移
('blog', '0005_alter_article_options_alter_category_options_and_more'), # 依赖blog应用的指定迁移
('comments', '0002_alter_comment_is_enable'), # 依赖comments应用的0002迁移
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('blog', '0005_alter_article_options_alter_category_options_and_more'),
('comments', '0002_alter_comment_is_enable'),
]
operations = [ # 迁移操作列表:当前需要执行的数据库变更
migrations.AlterModelOptions( # 修改模型的元数据配置
name='comment', # 目标模型为Comment
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'}, # 将显示名称改为英文
),
migrations.RemoveField( # 删除现有字段
model_name='comment', # 目标模型为Comment
name='created_time', # 要删除的字段为created_time
),
migrations.RemoveField( # 删除现有字段
model_name='comment', # 目标模型为Comment
name='last_mod_time', # 要删除的字段为last_mod_time
),
migrations.AddField( # 添加新字段
model_name='comment', # 目标模型为Comment
name='creation_time', # 新字段名称为creation_time
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'), # 时间字段,默认当前时间,显示名称为英文
),
migrations.AddField( # 添加新字段
model_name='comment', # 目标模型为Comment
name='last_modify_time', # 新字段名称为last_modify_time
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'), # 时间字段,默认当前时间,显示名称为英文
),
migrations.AlterField( # 修改现有字段
model_name='comment', # 目标模型为Comment
name='article', # 目标字段为article
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'), # 将显示名称改为英文
),
migrations.AlterField( # 修改现有字段
model_name='comment', # 目标模型为Comment
name='author', # 目标字段为author
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'), # 将显示名称改为英文
),
migrations.AlterField( # 修改现有字段
model_name='comment', # 目标模型为Comment
name='is_enable', # 目标字段为is_enable
field=models.BooleanField(default=False, verbose_name='enable'), # 将显示名称改为英文"enable"
),
migrations.AlterField( # 修改现有字段
model_name='comment', # 目标模型为Comment
name='parent_comment', # 目标字段为parent_comment
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'), # 将显示名称改为英文
),
]
operations = [
migrations.AlterModelOptions(
name='comment',
options={'get_latest_by': 'id', 'ordering': ['-id'], 'verbose_name': 'comment', 'verbose_name_plural': 'comment'},
),
migrations.RemoveField(
model_name='comment',
name='created_time',
),
migrations.RemoveField(
model_name='comment',
name='last_mod_time',
),
migrations.AddField(
model_name='comment',
name='creation_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='creation time'),
),
migrations.AddField(
model_name='comment',
name='last_modify_time',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='last modify time'),
),
migrations.AlterField(
model_name='comment',
name='article',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='blog.article', verbose_name='article'),
),
migrations.AlterField(
model_name='comment',
name='author',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author'),
),
migrations.AlterField(
model_name='comment',
name='is_enable',
field=models.BooleanField(default=False, verbose_name='enable'),
),
migrations.AlterField(
model_name='comment',
name='parent_comment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='comments.comment', verbose_name='parent comment'),
),
]

@ -1,47 +1,39 @@
from django.conf import settings # 导入Django项目设置用于获取用户模型
from django.db import models # 导入Django模型模块用于定义数据模型
from django.utils.timezone import now # 导入当前时间工具,用于时间字段默认值
from django.utils.translation import gettext_lazy as _ # 导入翻译函数,支持国际化
from django.conf import settings
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from blog.models import Article # 从blog应用导入Article模型用于关联评论和文章
from blog.models import Article
# Create your models here.
class Comment(models.Model):
# 评论内容字段文本类型最大长度300字符显示名称为"正文"
body = models.TextField('正文', max_length=300)
# 评论创建时间字段,使用国际化显示名称,默认值为当前时间
creation_time = models.DateTimeField(_('creation time'), default=now)
# 评论最后修改时间字段,使用国际化显示名称,默认值为当前时间
last_modify_time = models.DateTimeField(_('last modify time'), default=now)
# 外键关联到用户模型,使用国际化显示名称,级联删除(用户删除则评论删除)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
verbose_name=_('author'),
on_delete=models.CASCADE)
# 外键关联到文章模型,使用国际化显示名称,级联删除(文章删除则评论删除)
article = models.ForeignKey(
Article,
verbose_name=_('article'),
on_delete=models.CASCADE)
# 自关联外键,用于实现评论回复功能,允许为空,级联删除
parent_comment = models.ForeignKey(
'self',
verbose_name=_('parent comment'),
blank=True,
null=True,
on_delete=models.CASCADE)
# 评论是否启用的开关,布尔类型,默认不启用,不允许为空
is_enable = models.BooleanField(_('enable'),
default=False, blank=False, null=False)
class Meta:
ordering = ['-id'] # 默认排序方式按ID降序最新评论在前
verbose_name = _('comment') # 模型单数显示名称(国际化)
verbose_name_plural = verbose_name # 模型复数显示名称(与单数相同)
get_latest_by = 'id' # 获取最新记录时依据ID字段
ordering = ['-id']
verbose_name = _('comment')
verbose_name_plural = verbose_name
get_latest_by = 'id'
def __str__(self):
# 模型实例的字符串表示,返回评论内容
return self.body
return self.body

@ -1,33 +1,30 @@
from django import template # 导入Django模板模块用于创建自定义模板标签
from django import template
register = template.Library() # 创建模板标签注册器,用于注册自定义标签
register = template.Library()
@register.simple_tag # 将函数注册为简单模板标签
@register.simple_tag
def parse_commenttree(commentlist, comment):
"""获得当前评论子评论的列表
用法: {% parse_commenttree article_comments comment as childcomments %}
"""
datas = [] # 用于存储子评论的列表
datas = []
def parse(c): # 定义递归函数,用于递归获取所有子评论
# 筛选出当前评论的直接子评论(已启用状态)
def parse(c):
childs = commentlist.filter(parent_comment=c, is_enable=True)
for child in childs: # 遍历直接子评论
datas.append(child) # 将子评论添加到列表
parse(child) # 递归处理子评论的子评论(嵌套评论)
for child in childs:
datas.append(child)
parse(child)
parse(comment) # 从当前评论开始递归获取所有子评论
return datas # 返回所有子评论列表
parse(comment)
return datas
@register.inclusion_tag('comments/tags/comment_item.html') # 将函数注册为包含标签,指定模板文件
@register.inclusion_tag('comments/tags/comment_item.html')
def show_comment_item(comment, ischild):
"""评论展示标签"""
# 根据是否为子评论设置深度(用于前端样式区分,如缩进)
"""评论"""
depth = 1 if ischild else 2
# 返回上下文数据供模板comment_item.html使用
return {
'comment_item': comment, # 当前评论对象
'depth': depth # 评论深度(用于样式控制)
}
'comment_item': comment,
'depth': depth
}

@ -1,61 +1,109 @@
from django.test import Client, RequestFactory, TransactionTestCase # 导入Django测试相关类
from django.urls import reverse # 导入reverse函数用于生成URL
from django.test import Client, RequestFactory, TransactionTestCase
from django.urls import reverse
from accounts.models import BlogUser # 从accounts应用导入BlogUser模型用户模型
from blog.models import Category, Article # 从blog应用导入分类和文章模型
from comments.models import Comment # 导入评论模型
from comments.templatetags.comment_tags import * # 导入评论相关的模板标签
from djangoblog.utils import get_max_articleid_commentid # 导入工具函数
from accounts.models import BlogUser
from blog.models import Category, Article
from comments.models import Comment
from comments.templatetags.comments_tags import *
from djangoblog.utils import get_max_articleid_commentid
# Create your tests here.
class CommentsTest(TransactionTestCase): # 定义评论测试类,继承事务测试类(支持数据库事务回滚)
def setUp(self): # 测试前的初始化方法,每个测试方法执行前都会调用
self.client = Client() # 创建测试客户端,用于模拟用户请求
self.factory = RequestFactory() # 创建请求工厂,用于构造请求对象
# 配置博客评论设置
class CommentsTest(TransactionTestCase):
def setUp(self):
self.client = Client()
self.factory = RequestFactory()
from blog.models import BlogSettings
value = BlogSettings()
value.comment_need_review = True # 设置评论需要审核
value.comment_need_review = True
value.save()
# 创建超级用户(测试用)
self.user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1")
def update_article_comment_status(self, article): # 辅助方法:更新文章所有评论为启用状态
comments = article.comment_set.all() # 获取文章的所有评论
for comment in comments: # 遍历评论
comment.is_enable = True # 设置为启用
comment.save() # 保存更改
def update_article_comment_status(self, article):
comments = article.comment_set.all()
for comment in comments:
comment.is_enable = True
comment.save()
def test_validate_comment(self): # 测试评论验证功能
# 用户登录
def test_validate_comment(self):
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 创建测试分类
category = Category()
category.name = "categoryccc"
category.save()
# 创建测试文章
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = self.user # 设置作者为测试用户
article.category = category # 设置分类
article.type = 'a' # 文章类型(假设'a'表示普通文章)
article.status = 'p' # 发布状态(假设'p'表示已发布)
article.author = self.user
article.category = category
article.type = 'a'
article.status = 'p'
article.save()
# 生成评论提交的URL
comment_url = reverse(
'comments:postcomment', kwargs={
'article_id': article.id}) # 传入文章ID参数
'article_id': article.id})
# 发送评论提交请求代码不完整后续应补充POST数据和断言
response = self.client.post(comment_url,
response = self.client.post(comment_url,
{
'body': '123ffffffffff'
})
self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 0)
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 1)
response = self.client.post(comment_url,
{
'body': '123ffffffffff',
})
self.assertEqual(response.status_code, 302)
article = Article.objects.get(pk=article.pk)
self.update_article_comment_status(article)
self.assertEqual(len(article.comment_list()), 2)
parent_comment_id = article.comment_list()[0].id
response = self.client.post(comment_url,
{
'body': '''
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
''',
'parent_comment_id': parent_comment_id
})
self.assertEqual(response.status_code, 302)
self.update_article_comment_status(article)
article = Article.objects.get(pk=article.pk)
self.assertEqual(len(article.comment_list()), 3)
comment = Comment.objects.get(id=parent_comment_id)
tree = parse_commenttree(article.comment_list(), comment)
self.assertEqual(len(tree), 1)
data = show_comment_item(comment, True)
self.assertIsNotNone(data)
s = get_max_articleid_commentid()
self.assertIsNotNone(s)
from comments.utils import send_comment_email
send_comment_email(comment)

@ -1,12 +1,11 @@
from django.urls import path # 导入Django的路径函数用于定义URL路由
from django.urls import path
from . import views # 从当前应用导入视图模块
from . import views
app_name = "comments" # 定义应用的命名空间用于模板中URL反向解析
urlpatterns = [ # URL模式列表定义URL与视图的映射关系
app_name = "comments"
urlpatterns = [
path(
'article/<int:article_id>/postcomment', # URL路径包含文章ID参数整数类型
views.CommentPostView.as_view(), # 关联的视图类使用as_view()方法转换为可调用视图
name='postcomment' # 该URL的名称用于反向解析
),
]
'article/<int:article_id>/postcomment',
views.CommentPostView.as_view(),
name='postcomment'),
]

@ -1,45 +1,28 @@
import logging # 导入日志模块,用于记录程序运行中的日志信息
import logging
from django.utils.translation import gettext_lazy as _ # 导入翻译函数,支持国际化文本
from django.utils.translation import gettext_lazy as _
from djangoblog.utils import get_current_site # 从自定义工具模块导入获取当前站点域名的函数
from djangoblog.utils import send_email # 从自定义工具模块导入发送邮件的函数
from djangoblog.utils import get_current_site
from djangoblog.utils import send_email
logger = logging.getLogger(__name__) # 创建当前模块的日志记录器,用于记录该模块的日志
logger = logging.getLogger(__name__)
def send_comment_email(comment):
"""
发送评论相关邮件
1. 向评论作者发送评论成功的感谢邮件
2. 若当前评论是回复有父评论向父评论作者发送回复通知邮件
"""
# 获取当前网站的域名(用于拼接文章链接)
site = get_current_site().domain
# 邮件主题:评论感谢(支持国际化)
subject = _('Thanks for your comment')
# 拼接评论对应的文章访问链接HTTPS协议
article_url = f"https://{site}{comment.article.get_absolute_url()}"
# 构建给评论作者的HTML格式邮件内容支持国际化通过占位符注入动态数据
html_content = _("""<p>Thank you very much for your comments on this site</p>
You can visit <a href="%(article_url)s" rel="bookmark">%(article_title)s</a>
to review your comments,
Thank you again!
<br />
If the link above cannot be opened, please copy this link to your browser.
%(article_url)s""") % {
'article_url': article_url, # 文章访问链接
'article_title': comment.article.title # 文章标题
}
# 评论作者的邮箱(收件人)
%(article_url)s""") % {'article_url': article_url, 'article_title': comment.article.title}
tomail = comment.author.email
# 调用发送邮件函数,向评论作者发送感谢邮件
send_email([tomail], subject, html_content)
try:
# 判断当前评论是否有父评论(即是否是回复评论)
if comment.parent_comment:
# 构建给父评论作者的HTML格式邮件内容回复通知支持国际化
html_content = _("""Your comment on <a href="%(article_url)s" rel="bookmark">%(article_title)s</a><br/> has
received a reply. <br/> %(comment_body)s
<br/>
@ -47,16 +30,9 @@ def send_comment_email(comment):
<br/>
If the link above cannot be opened, please copy this link to your browser.
%(article_url)s
""") % {
'article_url': article_url, # 文章访问链接
'article_title': comment.article.title, # 文章标题
'comment_body': comment.parent_comment.body # 父评论的内容(供作者识别)
}
# 父评论作者的邮箱(收件人)
""") % {'article_url': article_url, 'article_title': comment.article.title,
'comment_body': comment.parent_comment.body}
tomail = comment.parent_comment.author.email
# 调用发送邮件函数,向父评论作者发送回复通知邮件
send_email([tomail], subject, html_content)
# 捕获发送回复邮件过程中的异常(避免单个邮件发送失败影响整体流程)
except Exception as e:
# 记录异常日志(便于问题排查)
logger.error(e)
logger.error(e)

@ -1,76 +1,63 @@
# Create your views here.
from django.core.exceptions import ValidationError # 导入验证异常类,用于处理验证错误
from django.http import HttpResponseRedirect # 导入HTTP重定向类用于页面跳转
from django.shortcuts import get_object_or_404 # 导入获取对象或返回404的工具函数
from django.utils.decorators import method_decorator # 导入方法装饰器工具,用于为类视图方法添加装饰器
from django.views.decorators.csrf import csrf_protect # 导入CSRF保护装饰器防止跨站请求伪造
from django.views.generic.edit import FormView # 导入表单视图基类,用于处理表单提交逻辑
from django.core.exceptions import ValidationError
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
from django.views.generic.edit import FormView
from accounts.models import BlogUser # 从accounts应用导入用户模型
from blog.models import Article # 从blog应用导入文章模型
from .forms import CommentForm # 从当前应用导入评论表单
from .models import Comment # 从当前应用导入评论模型
from accounts.models import BlogUser
from blog.models import Article
from .forms import CommentForm
from .models import Comment
class CommentPostView(FormView):
"""评论提交视图类,处理评论发布功能"""
form_class = CommentForm # 指定使用的表单类为CommentForm
template_name = 'blog/article_detail.html' # 指定表单验证失败时渲染的模板
form_class = CommentForm
template_name = 'blog/article_detail.html'
@method_decorator(csrf_protect) # 为dispatch方法添加CSRF保护
@method_decorator(csrf_protect)
def dispatch(self, *args, **kwargs):
# 调用父类的dispatch方法处理请求分发
return super(CommentPostView, self).dispatch(*args, **kwargs)
def get(self, request, *args, **kwargs):
"""处理GET请求重定向到文章详情页的评论区"""
article_id = self.kwargs['article_id'] # 从URL参数中获取文章ID
article = get_object_or_404(Article, pk=article_id) # 获取对应的文章对象不存在则返回404
url = article.get_absolute_url() # 获取文章的绝对URL
return HttpResponseRedirect(url + "#comments") # 重定向到文章详情页的评论区锚点
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
url = article.get_absolute_url()
return HttpResponseRedirect(url + "#comments")
def form_invalid(self, form):
"""处理表单验证失败的情况"""
article_id = self.kwargs['article_id'] # 获取文章ID
article = get_object_or_404(Article, pk=article_id) # 获取文章对象
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
# 渲染文章详情页,传递错误的表单和文章对象(用于显示错误信息)
return self.render_to_response({
'form': form, # 验证失败的表单(包含错误信息)
'article': article # 文章对象
'form': form,
'article': article
})
def form_valid(self, form):
"""处理表单验证通过后的逻辑:保存评论并跳转"""
user = self.request.user # 获取当前登录用户
author = BlogUser.objects.get(pk=user.pk) # 获取用户对应的BlogUser对象
article_id = self.kwargs['article_id'] # 获取文章ID
article = get_object_or_404(Article, pk=article_id) # 获取文章对象
"""提交的数据验证合法后的逻辑"""
user = self.request.user
author = BlogUser.objects.get(pk=user.pk)
article_id = self.kwargs['article_id']
article = get_object_or_404(Article, pk=article_id)
# 检查文章是否允许评论(评论状态为关闭或文章状态为草稿则不允许评论)
if article.comment_status == 'c' or article.status == 'c':
raise ValidationError("该文章评论已关闭.") # 抛出验证异常
comment = form.save(False) # 不立即保存表单数据,返回评论对象
comment.article = article # 设置评论关联的文章
# 获取博客设置,判断评论是否需要审核
raise ValidationError("该文章评论已关闭.")
comment = form.save(False)
comment.article = article
from djangoblog.utils import get_blog_setting
settings = get_blog_setting()
if not settings.comment_need_review: # 如果不需要审核
comment.is_enable = True # 直接设置评论为启用状态
if not settings.comment_need_review:
comment.is_enable = True
comment.author = author
comment.author = author # 设置评论的作者
# 处理回复功能如果存在父评论ID则设置父评论
if form.cleaned_data['parent_comment_id']:
parent_comment = Comment.objects.get(
pk=form.cleaned_data['parent_comment_id']) # 获取父评论对象
comment.parent_comment = parent_comment # 设置当前评论的父评论
comment.save(True) # 保存评论到数据库
pk=form.cleaned_data['parent_comment_id'])
comment.parent_comment = parent_comment
# 重定向到文章详情页的当前评论位置(带锚点)
comment.save(True)
return HttpResponseRedirect(
"%s#div-comment-%d" %
(article.get_absolute_url(), comment.pk)) # 拼接URL包含评论ID锚点
(article.get_absolute_url(), comment.pk))

@ -1,52 +1,48 @@
# Docker Compose配置文件版本为3指定兼容的Compose语法版本
version: '3'
# 定义所有服务(容器)
services:
# 1. Elasticsearch服务用于全文搜索功能集成IK中文分词器
es:
image: liangliangyy/elasticsearch-analysis-ik:8.6.1 # 使用带IK分词器的ES镜像版本8.6.1
container_name: es # 容器名称固定为"es",便于管理
restart: always # 容器退出后自动重启(确保服务持续运行)
environment: # 环境变量配置
- discovery.type=single-node # 单节点模式(无需集群,适合测试/小型部署)
- "ES_JAVA_OPTS=-Xms512m -Xmx512m" # 设置JVM内存大小初始/最大均为512M避免内存溢出
ports: # 端口映射主机9200端口 → 容器9200端口ES默认API端口
image: liangliangyy/elasticsearch-analysis-ik:8.6.1
container_name: es
restart: always
environment:
- discovery.type=single-node
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ports:
- 9200:9200
volumes: # 数据卷挂载持久化ES数据
- ./bin/datas/es/:/usr/share/elasticsearch/data/ # 主机目录 → 容器内ES数据存储目录
volumes:
- ./bin/datas/es/:/usr/share/elasticsearch/data/
# 2. Kibana服务ES的可视化管理工具用于操作/监控ES
kibana:
image: kibana:8.6.1 # Kibana镜像版本需与ES一致8.6.1
restart: always # 容器退出后自动重启
container_name: kibana # 容器名称固定为"kibana"
ports: # 端口映射主机5601端口 → 容器5601端口Kibana默认Web端口
image: kibana:8.6.1
restart: always
container_name: kibana
ports:
- 5601:5601
environment: # 环境变量配置指定关联的ES地址
- ELASTICSEARCH_HOSTS=http://es:9200 # 指向同网络内的"es"服务(容器间通过服务名通信)
environment:
- ELASTICSEARCH_HOSTS=http://es:9200
# 3. Django博客服务核心应用服务
djangoblog:
build: . # 基于当前目录的Dockerfile构建镜像不使用现成镜像需本地有Dockerfile
restart: always # 容器退出后自动重启
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh' # 容器启动后执行的命令:运行启动脚本
ports: # 端口映射主机8000端口 → 容器8000端口Django默认开发服务器端口
build: .
restart: always
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
ports:
- "8000:8000"
volumes: # 数据卷挂载:持久化应用数据/静态资源
- ./collectedstatic:/code/djangoblog/collectedstatic # 主机静态资源目录 → 容器内静态资源目录Nginx可直接访问
- ./uploads:/code/djangoblog/uploads # 主机上传文件目录 → 容器内上传文件目录(如博客图片)
environment: # 环境变量配置Django应用的关键参数数据库、缓存、ES等
- DJANGO_MYSQL_DATABASE=djangoblog # Django连接的MySQL数据库名
- DJANGO_MYSQL_USER=root # MySQL用户名
- DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E # MySQL密码
- DJANGO_MYSQL_HOST=db # MySQL服务地址指向同网络内的"db"服务需额外配置db服务
- DJANGO_MYSQL_PORT=3306 # MySQL端口
- DJANGO_MEMCACHED_LOCATION=memcached:11211 # Memcached缓存地址指向同网络内的"memcached"服务,需额外配置)
- DJANGO_ELASTICSEARCH_HOST=es:9200 # ES服务地址指向同网络内的"es"服务)
links: # 显式链接到其他服务已逐步被depends_on替代此处用于兼容
- db # 链接到MySQL服务
- memcached # 链接到Memcached服务
depends_on: # 服务依赖启动djangoblog前先启动db服务确保数据库就绪
volumes:
- ./collectedstatic:/code/djangoblog/collectedstatic
- ./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
links:
- db
container_name: djangoblog # 容器名称固定为"djangoblog"
- memcached
depends_on:
- db
container_name: djangoblog

@ -1,67 +1,60 @@
# Docker Compose配置文件版本为3指定Compose语法版本
version: '3'
# 定义所有服务(容器)
services:
# 1. MySQL数据库服务存储应用数据
db:
image: mysql:latest # 使用最新版MySQL镜像
restart: always # 容器退出后自动重启(确保服务持续运行)
environment: # 环境变量配置(数据库初始化参数)
- MYSQL_DATABASE=djangoblog # 自动创建的数据库名称
- MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E # MySQL root用户密码
ports: # 端口映射主机3306端口 → 容器3306端口MySQL默认端口
image: mysql:latest
restart: always
environment:
- MYSQL_DATABASE=djangoblog
- MYSQL_ROOT_PASSWORD=DjAnGoBlOg!2!Q@W#E
ports:
- 3306:3306
volumes: # 数据卷挂载持久化MySQL数据
- ./bin/datas/mysql/:/var/lib/mysql # 主机目录 → 容器内MySQL数据存储目录
depends_on: # 服务依赖启动db前先启动redis可能用于数据库缓存等场景
volumes:
- ./bin/datas/mysql/:/var/lib/mysql
depends_on:
- redis
container_name: db # 容器名称固定为"db"
container_name: db
# 2. Django博客应用服务核心应用
djangoblog:
build: # 构建配置
context: ../../ # 指定Dockerfile所在的上下文目录上级目录的上级目录
restart: always # 容器退出后自动重启
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh' # 启动命令:执行应用启动脚本
ports: # 端口映射主机8000端口 → 容器8000端口Django应用端口
build:
context: ../../
restart: always
command: bash -c 'sh /code/djangoblog/bin/docker_start.sh'
ports:
- "8000:8000"
volumes: # 数据卷挂载:持久化应用数据和配置
- ./collectedstatic:/code/djangoblog/collectedstatic # 静态资源目录供Nginx访问
- ./logs:/code/djangoblog/logs # 应用日志目录
- ./uploads:/code/djangoblog/uploads # 用户上传文件目录(如图片)
environment: # 环境变量配置(应用连接参数)
- DJANGO_MYSQL_DATABASE=djangoblog # 数据库名称与db服务对应
- DJANGO_MYSQL_USER=root # 数据库用户名
- DJANGO_MYSQL_PASSWORD=DjAnGoBlOg!2!Q@W#E # 数据库密码与db服务对应
- DJANGO_MYSQL_HOST=db # 数据库服务地址(指向同网络内的"db"服务)
- DJANGO_MYSQL_PORT=3306 # 数据库端口
- DJANGO_REDIS_URL=redis:6379 # Redis服务地址指向同网络内的"redis"服务)
links: # 显式链接到其他服务(用于容器间通信)
- db # 链接到MySQL服务
- redis # 链接到Redis服务
depends_on: # 服务依赖启动djangoblog前先启动db服务确保数据库就绪
volumes:
- ./collectedstatic:/code/djangoblog/collectedstatic
- ./logs:/code/djangoblog/logs
- ./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
links:
- db
container_name: djangoblog # 容器名称固定为"djangoblog"
# 3. Nginx服务反向代理和静态资源服务
- redis
depends_on:
- db
container_name: djangoblog
nginx:
restart: always # 容器退出后自动重启
image: nginx:latest # 使用最新版Nginx镜像
ports: # 端口映射HTTP(80)和HTTPS(443)端口
restart: always
image: nginx:latest
ports:
- "80:80"
- "443:443"
volumes: # 数据卷挂载Nginx配置和静态资源
- ./bin/nginx.conf:/etc/nginx/nginx.conf # 主机Nginx配置文件 → 容器内Nginx配置文件
- ./collectedstatic:/code/djangoblog/collectedstatic # 静态资源目录与djangoblog服务共享
links: # 链接到djangoblog服务实现反向代理
- djangoblog:djangoblog # 将djangoblog服务映射为"djangoblog"主机名
container_name: nginx # 容器名称固定为"nginx"
volumes:
- ./bin/nginx.conf:/etc/nginx/nginx.conf
- ./collectedstatic:/code/djangoblog/collectedstatic
links:
- djangoblog:djangoblog
container_name: nginx
# 4. Redis服务缓存服务用于提升应用性能
redis:
restart: always # 容器退出后自动重启
image: redis:latest # 使用最新版Redis镜像
container_name: redis # 容器名称固定为"redis"
ports: # 端口映射主机6379端口 → 容器6379端口Redis默认端口
- "6379:6379"
restart: always
image: redis:latest
container_name: redis
ports:
- "6379:6379"

@ -1,124 +1,119 @@
apiVersion: v1 # Kubernetes API版本v1为稳定版本
kind: ConfigMap # 资源类型为ConfigMap用于存储非敏感配置数据
apiVersion: v1
kind: ConfigMap
metadata:
name: web-nginx-config # ConfigMap名称标识该Nginx配置资源
namespace: djangoblog # 所属命名空间用于资源隔离对应djangoblog应用
data: # 配置数据,键为文件名,值为文件内容
nginx.conf: | # Nginx主配置文件
user nginx; # Nginx进程运行的用户
worker_processes auto; # 工作进程数auto表示按CPU核心数自动分配
error_log /var/log/nginx/error.log notice; # 错误日志路径及级别notice级别
pid /var/run/nginx.pid; # Nginx进程PID文件路径
name: web-nginx-config
namespace: djangoblog
data:
nginx.conf: |
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events { # 事件处理配置块
worker_connections 1024; # 每个工作进程最大连接数
multi_accept on; # 允许工作进程同时接受多个新连接
use epoll; # 使用epoll I/O模型Linux下高效事件驱动模型
events {
worker_connections 1024;
multi_accept on;
use epoll;
}
http { # HTTP核心配置块
include /etc/nginx/mime.types; # 引入MIME类型映射文件识别文件类型
default_type application/octet-stream; # 默认MIME类型未知类型时使用
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 定义日志格式命名为main
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; # 访问日志路径使用main格式
sendfile on; # 启用sendfile系统调用高效传输文件
keepalive_timeout 65; # 长连接超时时间65秒
gzip on; # 启用gzip压缩减少传输数据量
gzip_disable "msie6"; # 对IE6浏览器禁用gzip兼容性处理
access_log /var/log/nginx/access.log main;
# gzip压缩补充配置
gzip_vary on; # 启用Vary: Accept-Encoding响应头告知代理缓存压缩/非压缩版本)
gzip_proxied any; # 对所有代理请求启用压缩
gzip_comp_level 8; # 压缩级别1-98为较高压缩率
gzip_buffers 16 8k; # 压缩缓冲区大小16个8k缓冲区
gzip_http_version 1.1; # 仅对HTTP/1.1及以上版本启用压缩
# 需压缩的文件类型文本、JS、CSS、图片等
sendfile on;
keepalive_timeout 65;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 8;
gzip_buffers 16 8k;
gzip_http_version 1.1;
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 /etc/nginx/conf.d/*.conf; # 引入其他服务器配置文件
# Include server configurations
include /etc/nginx/conf.d/*.conf;
}
djangoblog.conf: | # lylinux.net域名的Nginx站点配置
server { # 处理lylinux.net域名的服务配置
server_name lylinux.net; # 绑定的主域名
root /code/djangoblog/collectedstatic/; # 网站根目录(静态文件目录)
listen 80; # 监听80端口HTTP
keepalive_timeout 70; # 该站点长连接超时时间
location /static/ { # 处理静态文件请求
expires max; # 静态文件缓存有效期设为最大(长期缓存)
alias /code/djangoblog/collectedstatic/; # 静态文件实际路径
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、网站验证文件等
location ~* (robots\.txt|ads\.txt|favicon\.ico|favion\.ico|crossdomain\.xml|google93fd32dbd906620a\.html|BingSiteAuth\.xml|baidu_verify_Ijeny6KrmS\.html)$ {
root /resource/djangopub; # 这些文件的根目录
expires 1d; # 缓存1天
access_log off; # 关闭访问日志
error_log off; # 关闭错误日志
root /resource/djangopub;
expires 1d;
access_log off;
error_log off;
}
location / { # 处理其他所有请求反向代理到Django
# 设置代理请求头(传递客户端信息给后端)
proxy_set_header X-Real-IP $remote_addr; # 客户端真实IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 代理链IP列表
proxy_set_header Host $http_host; # 原始请求Host
proxy_set_header X-NginX-Proxy true; # 标识经Nginx代理
proxy_redirect off; # 禁用代理重定向
# 若请求文件不存在反向代理到Django服务djangoblog为K8s内部服务名
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;
}
}
}
server { # 处理www.lylinux.net域名重定向配置
server_name www.lylinux.net; # 绑定的www子域名
listen 80; # 监听80端口
return 301 https://lylinux.net$request_uri; # 永久重定向到主域名HTTPS地址
server {
server_name www.lylinux.net;
listen 80;
return 301 https://lylinux.net$request_uri;
}
resource.lylinux.net.conf: | # resource.lylinux.net子域名的配置资源服务器
resource.lylinux.net.conf: |
server {
index index.html index.htm; # 默认索引文件
server_name resource.lylinux.net; # 绑定的资源子域名
root /resource/; # 资源文件根目录
index index.html index.htm;
server_name resource.lylinux.net;
root /resource/;
location /djangoblog/ { # 映射Django静态文件路径
alias /code/djangoblog/collectedstatic/; # 实际静态文件路径
location /djangoblog/ {
alias /code/djangoblog/collectedstatic/;
}
access_log off; # 关闭访问日志
error_log off; # 关闭错误日志
include lylinux/resource.conf; # 引入通用资源配置
access_log off;
error_log off;
include lylinux/resource.conf;
}
lylinux.resource.conf: | # 通用资源配置(被资源服务器引用)
expires max; # 资源缓存有效期设为最大
access_log off; # 关闭访问日志
log_not_found off; # 关闭文件未找到的错误日志
add_header Pragma public; # 缓存控制头(告知客户端可缓存)
add_header Cache-Control "public"; # 缓存控制头(公开可缓存)
add_header "Access-Control-Allow-Origin" "*"; # 允许跨域访问(所有域名)
lylinux.resource.conf: |
expires max;
access_log off;
log_not_found off;
add_header Pragma public;
add_header Cache-Control "public";
add_header "Access-Control-Allow-Origin" "*";
---
apiVersion: v1 # Kubernetes API版本
kind: ConfigMap # 资源类型为ConfigMap存储环境变量
apiVersion: v1
kind: ConfigMap
metadata:
name: djangoblog-env # ConfigMap名称标识Django环境变量配置
namespace: djangoblog # 所属命名空间(与应用一致)
data: # 环境变量键值对
DJANGO_MYSQL_DATABASE: djangoblog # Django连接的MySQL数据库名
DJANGO_MYSQL_USER: db_user # MySQL登录用户名
DJANGO_MYSQL_PASSWORD: db_password # MySQL登录密码
DJANGO_MYSQL_HOST: db_host # MySQL服务地址K8s内部服务名或IP
DJANGO_MYSQL_PORT: db_port # MySQL服务端口
DJANGO_REDIS_URL: "redis:6379" # Redis服务地址及端口
DJANGO_DEBUG: "False" # Django调试模式生产环境关闭
MYSQL_ROOT_PASSWORD: db_password # MySQL root用户密码用于初始化
MYSQL_DATABASE: djangoblog # 初始化的MySQL数据库名
MYSQL_PASSWORD: db_password # MySQL普通用户密码与Django配置一致
DJANGO_SECRET_KEY: xxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Django加密密钥用于会话、CSRF等
name: djangoblog-env
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

@ -1,161 +1,132 @@
# 第一部分Django 博客应用部署配置
# apiVersion 指定 Kubernetes API 版本apps/v1 是 Deployment 资源的稳定版本
apiVersion: apps/v1
# kind 定义资源类型为 Deployment用于管理Pod的创建和扩展
kind: Deployment
metadata:
# Deployment 的名称
name: djangoblog
# 部署所在的命名空间(用于资源隔离)
namespace: djangoblog
# 为 Deployment 添加标签(用于筛选和关联资源)
labels:
app: djangoblog
spec:
# 副本数:指定运行的 Pod 数量为 3 个(实现高可用)
replicas: 3
# 选择器:用于匹配要管理的 Pod 标签(必须与下面 template.metadata.labels 一致)
selector:
matchLabels:
app: djangoblog
# Pod 模板:定义要创建的 Pod 的规格
template:
metadata:
# Pod 的标签(与上面的 selector.matchLabels 对应)
labels:
app: djangoblog
spec:
# 容器列表:一个 Pod 可以包含多个容器,这里定义应用容器
containers:
- name: djangoblog # 容器名称
# 容器使用的镜像Django 博客应用镜像)
- name: djangoblog
image: liangliangyy/djangoblog:latest
# 镜像拉取策略Always 表示每次都从仓库拉取最新镜像
imagePullPolicy: Always
# 容器暴露的端口Django 应用默认运行在 8000 端口)
ports:
- containerPort: 8000
# 从配置映射ConfigMap中注入环境变量
envFrom:
- configMapRef:
name: djangoblog-env # 引用的 ConfigMap 名称
# 就绪探针:判断容器是否已准备好接收请求(服务发现会依赖此状态)
name: djangoblog-env
readinessProbe:
httpGet: # 通过 HTTP 请求检查就绪状态
path: / # 检查的路径(应用根目录)
port: 8000 # 检查的端口
initialDelaySeconds: 10 # 容器启动后延迟 10 秒开始首次检查
periodSeconds: 30 # 每隔 30 秒检查一次
# 存活探针:判断容器是否存活,若失败会重启容器
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
livenessProbe:
httpGet: # 通过 HTTP 请求检查存活状态
path: / # 检查的路径
port: 8000 # 检查的端口
initialDelaySeconds: 10 # 容器启动后延迟 10 秒开始首次检查
periodSeconds: 30 # 每隔 30 秒检查一次
# 资源限制:控制容器对 CPU 和内存的使用
httpGet:
path: /
port: 8000
initialDelaySeconds: 10
periodSeconds: 30
resources:
requests: # 资源请求(调度时的最小需求)
cpu: 10m # 10 毫核 CPU1核=1000m
memory: 100Mi # 100 兆内存
limits: # 资源限制(容器最大可使用的资源)
cpu: "2" # 2 核 CPU
memory: 2Gi # 2 吉内存
# 卷挂载:将持久卷挂载到容器内的指定路径
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
- name: djangoblog # 引用下面 volumes 中定义的卷名称
mountPath: /code/djangoblog/collectedstatic # 容器内的挂载路径Django 静态文件目录)
- name: resource # 引用资源卷
mountPath: /resource # 容器内的资源文件目录
# 卷定义:声明需要挂载的持久卷
- name: djangoblog
mountPath: /code/djangoblog/collectedstatic
- name: resource
mountPath: /resource
volumes:
- name: djangoblog # 卷名称(与上面 volumeMounts.name 对应)
persistentVolumeClaim: # 使用持久卷声明PVC
claimName: djangoblog-pvc # 引用的 PVC 名称(需提前创建)
- name: resource # 资源卷名称
- name: djangoblog
persistentVolumeClaim:
claimName: resource-pvc # 资源对应的 PVC 名称
claimName: djangoblog-pvc
- name: resource
persistentVolumeClaim:
claimName: resource-pvc
# 第二部分Redis 缓存服务部署配置
--- # 分隔符:用于在一个文件中定义多个 Kubernetes 资源
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis # Redis 部署名称
namespace: djangoblog # 同属 djangoblog 命名空间
name: redis
namespace: djangoblog
labels:
app: redis # Redis 标签
app: redis
spec:
replicas: 1 # Redis 单副本(简单部署,生产环境可能需要集群)
replicas: 1
selector:
matchLabels:
app: redis # 匹配 Redis Pod 标签
app: redis
template:
metadata:
labels:
app: redis # Pod 标签
app: redis
spec:
containers:
- name: redis # 容器名称
image: redis:latest # Redis 官方最新镜像
# 镜像拉取策略IfNotPresent 表示本地有则使用本地镜像,否则拉取
- name: redis
image: redis:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 6379 # Redis 默认端口
# 资源限制Redis 对资源需求较低)
- containerPort: 6379
resources:
requests:
cpu: 10m
memory: 100Mi
limits:
cpu: 200m # 限制最大 200 毫核 CPU
cpu: 200m
memory: 2Gi
# 第三部分MySQL 数据库部署配置
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: db # 数据库部署名称
name: db
namespace: djangoblog
labels:
app: db # 数据库标签
app: db
spec:
replicas: 1 # 数据库单副本(生产环境需考虑主从或集群)
replicas: 1
selector:
matchLabels:
app: db # 匹配数据库 Pod 标签
app: db
template:
metadata:
labels:
app: db # Pod 标签
app: db
spec:
containers:
- name: db # 容器名称
image: mysql:latest # MySQL 官方最新镜像
- name: db
image: mysql:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3306 # MySQL 默认端口
# 从 ConfigMap 注入环境变量(如数据库密码、用户名等)
- containerPort: 3306
envFrom:
- configMapRef:
name: djangoblog-env # 复用 Django 应用的环境变量配置
# 就绪探针:通过执行 mysqladmin ping 检查数据库是否就绪
name: djangoblog-env
readinessProbe:
exec: # 执行命令检查
exec:
command:
- mysqladmin
- ping
- "-h"
- "127.0.0.1" # 数据库主机(容器内本地)
- "127.0.0.1"
- "-u"
- "root" # 用户名
- "-p$MYSQL_ROOT_PASSWORD" # 密码(从环境变量获取)
initialDelaySeconds: 10 # 延迟 10 秒检查
periodSeconds: 10 # 每 10 秒检查一次
# 存活探针:同就绪探针,确保数据库存活
- "root"
- "-p$MYSQL_ROOT_PASSWORD"
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
exec:
command:
@ -168,7 +139,6 @@ spec:
- "-p$MYSQL_ROOT_PASSWORD"
initialDelaySeconds: 10
periodSeconds: 10
# 资源限制(数据库对资源需求较高)
resources:
requests:
cpu: 10m
@ -176,42 +146,38 @@ spec:
limits:
cpu: "2"
memory: 2Gi
# 挂载数据库数据目录(持久化存储,避免数据丢失)
volumeMounts:
- name: db-data # 引用数据卷
mountPath: /var/lib/mysql # MySQL 数据存储路径
- name: db-data
mountPath: /var/lib/mysql
volumes:
- name: db-data # 数据卷名称
- name: db-data
persistentVolumeClaim:
claimName: db-pvc # 数据库对应的 PVC 名称
# 第四部分Nginx 反向代理部署配置
claimName: db-pvc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx # Nginx 部署名称
name: nginx
namespace: djangoblog
labels:
app: nginx # Nginx 标签
app: nginx
spec:
replicas: 1 # Nginx 单副本
replicas: 1
selector:
matchLabels:
app: nginx # 匹配 Nginx Pod 标签
app: nginx
template:
metadata:
labels:
app: nginx # Pod 标签
app: nginx
spec:
containers:
- name: nginx # 容器名称
image: nginx:latest # Nginx 官方最新镜像
- name: nginx
image: nginx:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 80 # Nginx 默认端口
# 资源限制
- containerPort: 80
resources:
requests:
cpu: 10m
@ -219,82 +185,67 @@ spec:
limits:
cpu: "2"
memory: 2Gi
# 卷挂载:挂载配置文件和静态资源
volumeMounts:
# 挂载 Nginx 主配置文件subPath 表示只挂载单个文件,而非目录)
- name: nginx-config
mountPath: /etc/nginx/nginx.conf
subPath: nginx.conf
# 挂载默认站点配置
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: djangoblog.conf
# 挂载资源站点配置
- name: nginx-config
mountPath: /etc/nginx/conf.d/resource.lylinux.net.conf
subPath: resource.lylinux.net.conf
# 挂载额外的资源配置
- name: nginx-config
mountPath: /etc/nginx/lylinux/resource.conf
subPath: lylinux.resource.conf
# 挂载 Django 静态文件目录(与 Django 应用共享存储)
- name: djangoblog-pvc
mountPath: /code/djangoblog/collectedstatic
# 挂载资源文件目录
- name: resource-pvc
mountPath: /resource
volumes:
# Nginx 配置卷:通过 ConfigMap 挂载配置文件(避免在镜像中硬编码配置)
- name: nginx-config
configMap:
name: web-nginx-config # 引用的 ConfigMap 名称
# 挂载 Django 静态文件对应的 PVC
name: web-nginx-config
- name: djangoblog-pvc
persistentVolumeClaim:
claimName: djangoblog-pvc
# 挂载资源文件对应的 PVC
- name: resource-pvc
persistentVolumeClaim:
claimName: resource-pvc
# 第五部分Elasticsearch 搜索引擎部署配置
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: elasticsearch # ES 部署名称
name: elasticsearch
namespace: djangoblog
labels:
app: elasticsearch # ES 标签
app: elasticsearch
spec:
replicas: 1 # ES 单节点(生产环境需集群)
replicas: 1
selector:
matchLabels:
app: elasticsearch # 匹配 ES Pod 标签
app: elasticsearch
template:
metadata:
labels:
app: elasticsearch # Pod 标签
app: elasticsearch
spec:
containers:
- name: elasticsearch # 容器名称
# 带 IK 分词器的 ES 镜像(适用于中文搜索)
- name: elasticsearch
image: liangliangyy/elasticsearch-analysis-ik:8.6.1
imagePullPolicy: IfNotPresent
# ES 环境变量配置
env:
- name: discovery.type # 单节点模式(无需集群发现)
- name: discovery.type
value: single-node
- name: ES_JAVA_OPTS # JVM 内存配置(根据需求调整)
- name: ES_JAVA_OPTS
value: "-Xms256m -Xmx256m"
- name: xpack.security.enabled # 关闭安全验证(简化部署)
- name: xpack.security.enabled
value: "false"
- name: xpack.monitoring.templates.enabled # 关闭监控模板
- name: xpack.monitoring.templates.enabled
value: "false"
ports:
- containerPort: 9200 # ES HTTP 接口端口
# 资源限制ES 对内存需求较高)
- containerPort: 9200
resources:
requests:
cpu: 10m
@ -302,25 +253,22 @@ spec:
limits:
cpu: "2"
memory: 2Gi
# 就绪探针:检查 ES 是否就绪
readinessProbe:
httpGet:
path: / # ES 健康检查路径
path: /
port: 9200
initialDelaySeconds: 15 # 延迟 15 秒ES 启动较慢)
initialDelaySeconds: 15
periodSeconds: 30
# 存活探针:检查 ES 是否存活
livenessProbe:
httpGet:
path: /
port: 9200
initialDelaySeconds: 15
periodSeconds: 30
# 挂载 ES 数据目录(持久化存储索引数据)
volumeMounts:
- name: elasticsearch-data
mountPath: /usr/share/elasticsearch/data/ # ES 数据存储路径
mountPath: /usr/share/elasticsearch/data/
volumes:
- name: elasticsearch-data
persistentVolumeClaim:
claimName: elasticsearch-pvc # ES 对应的 PVC 名称
- name: elasticsearch-data
persistentVolumeClaim:
claimName: elasticsearch-pvc

@ -1,31 +1,17 @@
# Ingress 资源配置(用于管理外部访问集群内服务的规则)
# apiVersion 指定 Kubernetes API 版本networking.k8s.io/v1 是 Ingress 的稳定版本
apiVersion: networking.k8s.io/v1
# kind 定义资源类型为 Ingress用于配置外部访问规则
kind: Ingress
metadata:
# Ingress 资源的名称
name: nginx
# 所属命名空间(与前面的部署资源保持一致,确保资源在同一命名空间内可访问)
namespace: djangoblog
spec:
# 指定 Ingress 控制器的类别(需提前部署对应类别的 Ingress Controller这里使用 nginx 类型)
ingressClassName: nginx
# 访问规则定义(外部请求如何路由到集群内的服务)
rules:
# 未指定 host 表示匹配所有未被其他规则匹配的主机(可理解为默认规则)
- http:
# HTTP 协议的路由规则
paths:
# 路径规则:匹配以 / 开头的所有请求(即所有路径)
- path: /
# 路径匹配类型Prefix 表示前缀匹配(/ 会匹配所有路径)
pathType: Prefix
# 后端服务配置:请求转发到哪个服务
backend:
service:
# 目标服务的名称(需提前创建名为 nginx 的 Service关联到 nginx 部署的 Pod
name: nginx
# 目标服务的端口号(对应 nginx 服务暴露的 80 端口)
port:
number: 80

@ -1,44 +1,40 @@
# 第一部分数据库MySQL专用持久卷配置
apiVersion: v1 # PV 资源使用的 Kubernetes API 版本
kind: PersistentVolume # 资源类型为持久卷PV用于提供集群级别的存储资源
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-db # PV 的名称需唯一这里明确关联数据库db
name: local-pv-db
spec:
capacity: # 定义 PV 的存储容量
storage: 10Gi # 分配 10GiB 存储空间(数据库通常需要较大空间)
volumeMode: Filesystem # 卷模式Filesystem 表示以文件系统形式挂载(另一种是 Block 块设备)
accessModes: # 访问模式:定义 PV 可被如何访问
- ReadWriteOnce # 仅允许单个节点以读写方式挂载(适合数据库等需独占写入的场景)
persistentVolumeReclaimPolicy: Retain # 回收策略Retain 表示 PV 被释放后保留数据,需手动清理
storageClassName: local-storage # 存储类名称,用于与 PersistentVolumeClaimPVC匹配
local: # 声明为本地存储(使用节点上的本地磁盘,非分布式存储)
path: /mnt/local-storage-db # 本地存储的实际路径(需在对应节点上提前创建该目录)
nodeAffinity: # 节点亲和性:限制 PV 只能被特定节点使用(本地存储必须配置)
required: # 强制要求:必须满足以下条件才能使用该 PV
capacity:
storage: 10Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/local-storage-db
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions: # 匹配规则
- key: kubernetes.io/hostname # 匹配节点的主机名标签
operator: In # 操作符In 表示值在指定列表中
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- master # 仅允许主机名为 "master" 的节点使用该 PV
# 第二部分Django 应用静态文件专用持久卷配置
--- # 分隔符:用于在一个文件中定义多个资源
- master
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-djangoblog # PV 名称,关联 Django 应用
name: local-pv-djangoblog
spec:
capacity:
storage: 5Gi # 分配 5GiB 存储空间(静态文件需求较小)
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce # 单节点读写(静态文件通常由单节点写入,多节点读取可考虑 ReadOnlyMany
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage # 与前面的 PV 共用同一存储类
storageClassName: local-storage
local:
path: /mnt/local-storage-djangoblog # Django 静态文件的本地存储路径
path: /mnt/local-storage-djangoblog
nodeAffinity:
required:
nodeSelectorTerms:
@ -46,25 +42,24 @@ spec:
- key: kubernetes.io/hostname
operator: In
values:
- master # 同样限制在 "master" 节点
- master
# 第三部分:资源文件专用持久卷配置
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-resource # PV 名称,关联通用资源文件
name: local-pv-resource
spec:
capacity:
storage: 5Gi # 分配 5GiB 存储空间
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/resource/ # 资源文件(如上传的图片、附件等)的本地存储路径
path: /mnt/resource/
nodeAffinity:
required:
nodeSelectorTerms:
@ -72,25 +67,23 @@ spec:
- key: kubernetes.io/hostname
operator: In
values:
- master # 限制在 "master" 节点
- master
# 第四部分Elasticsearch 搜索引擎专用持久卷配置
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: local-pv-elasticsearch # PV 名称,关联 Elasticsearch
name: local-pv-elasticsearch
spec:
capacity:
storage: 5Gi # 分配 5GiB 存储空间(用于存储 ES 索引数据)
storage: 5Gi
volumeMode: Filesystem
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: local-storage
local:
path: /mnt/local-storage-elasticsearch # ES 数据的本地存储路径
path: /mnt/local-storage-elasticsearch
nodeAffinity:
required:
nodeSelectorTerms:
@ -98,4 +91,4 @@ spec:
- key: kubernetes.io/hostname
operator: In
values:
- master # 限制在 "master" 节点
- master

@ -1,66 +1,60 @@
# 第一部分数据库MySQL持久卷声明PVC
# PVC 用于向 Kubernetes 请求存储资源,需与 PV 匹配后才能供 Pod 使用
apiVersion: v1 # PVC 资源对应的 Kubernetes API 版本
kind: PersistentVolumeClaim # 资源类型为持久卷声明PVC
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: db-pvc # PVC 名称,需与数据库 Deployment 中引用的 PVC 名称一致
namespace: djangoblog # 所属命名空间,与数据库 Deployment、对应 PV 保持一致(资源隔离)
name: db-pvc
namespace: djangoblog
spec:
storageClassName: local-storage # 存储类名称,必须与目标 PV 的 storageClassName 完全匹配(用于筛选 PV
volumeName: local-pv-db # 显式指定绑定的 PV 名称(强制绑定,非必填;不指定则按条件自动匹配)
accessModes: # 访问模式,需与目标 PV 的 accessModes 兼容(否则无法绑定)
- ReadWriteOnce # 单节点读写模式,与数据库 PV 的访问模式一致(满足数据库独占写入需求)
resources: # 存储资源请求,定义需要的存储容量
storageClassName: local-storage
volumeName: local-pv-db
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi # 请求 10GiB 存储空间,需小于或等于目标 PV 的 capacity此处与 db PV 容量完全匹配)
storage: 10Gi
# 第二部分Django 应用静态文件持久卷声明PVC
--- # 资源分隔符,用于在单个文件中定义多个 Kubernetes 资源
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: djangoblog-pvc # PVC 名称,需与 Django Deployment 中 volumeMounts 引用的 PVC 名称一致
name: djangoblog-pvc
namespace: djangoblog
spec:
volumeName: local-pv-djangoblog # 显式绑定 Django 应用专用 PV
storageClassName: local-storage # 与 Django 应用 PV 的存储类一致
volumeName: local-pv-djangoblog
storageClassName: local-storage
accessModes:
- ReadWriteOnce # 单节点读写,与 Django 应用 PV 访问模式匹配
- ReadWriteOnce
resources:
requests:
storage: 5Gi # 请求 5GiB 存储空间,与 Django 应用 PV 容量一致(用于存储静态文件)
storage: 5Gi
# 第三部分资源文件如上传附件、图片持久卷声明PVC
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: resource-pvc # PVC 名称,需与 Django、Nginx Deployment 中引用的资源卷 PVC 名称一致
name: resource-pvc
namespace: djangoblog
spec:
volumeName: local-pv-resource # 显式绑定资源文件专用 PV
storageClassName: local-storage # 与资源文件 PV 的存储类一致
volumeName: local-pv-resource
storageClassName: local-storage
accessModes:
- ReadWriteOnce # 单节点读写,与资源文件 PV 访问模式匹配
- ReadWriteOnce
resources:
requests:
storage: 5Gi # 请求 5GiB 存储空间,与资源文件 PV 容量一致
storage: 5Gi
# 第四部分Elasticsearch搜索引擎持久卷声明PVC
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: elasticsearch-pvc # PVC 名称,需与 Elasticsearch Deployment 中引用的 PVC 名称一致
name: elasticsearch-pvc
namespace: djangoblog
spec:
volumeName: local-pv-elasticsearch # 显式绑定 Elasticsearch 专用 PV
storageClassName: local-storage # 与 Elasticsearch PV 的存储类一致
volumeName: local-pv-elasticsearch
storageClassName: local-storage
accessModes:
- ReadWriteOnce # 单节点读写,与 Elasticsearch PV 访问模式匹配
- ReadWriteOnce
resources:
requests:
storage: 5Gi # 请求 5GiB 存储空间,与 Elasticsearch PV 容量一致(用于存储索引数据)
storage: 5Gi

@ -1,93 +1,80 @@
# 第一部分Django 应用服务Service
# Service 用于为集群内的 Pod 提供稳定网络访问地址,实现 Pod 访问的负载均衡和服务发现
apiVersion: v1 # Service 资源对应的 Kubernetes API 版本
kind: Service # 资源类型为 Service
apiVersion: v1
kind: Service
metadata:
name: djangoblog # Service 名称,需与其他组件(如 Nginx 配置)中引用的服务名一致
namespace: djangoblog # 所属命名空间,与 Django Deployment、其他组件保持一致资源隔离
name: djangoblog
namespace: djangoblog
labels:
app: djangoblog # 服务标签,用于筛选和管理服务资源
app: djangoblog
spec:
selector: # 标签选择器:通过标签匹配要管理的 Pod必须与 Django Pod 的标签一致)
selector:
app: djangoblog
ports: # 端口配置:定义服务暴露的端口与 Pod 端口的映射关系
- protocol: TCP # 网络协议,默认 TCP常用还有 UDP
port: 8000 # 服务暴露给集群内部的端口(其他组件通过此端口访问该服务)
targetPort: 8000 # 服务转发请求到 Pod 的目标端口(需与 Django 容器暴露的端口一致)
type: ClusterIP # 服务类型ClusterIP 表示仅在集群内部暴露服务,外部无法直接访问(适合内部组件通信)
# 第二部分Nginx 服务Service
--- # 资源分隔符,用于在单个文件中定义多个 Kubernetes 资源
ports:
- protocol: TCP
port: 8000
targetPort: 8000
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: nginx # Service 名称,需与 Ingress 配置中引用的服务名一致
name: nginx
namespace: djangoblog
labels:
app: nginx
spec:
selector:
app: nginx # 匹配 Nginx Pod 的标签
app: nginx
ports:
- protocol: TCP
port: 80 # 服务暴露的端口Ingress 转发请求到该端口)
targetPort: 80 # 转发到 Nginx 容器暴露的 80 端口
type: ClusterIP # 集群内部访问(外部通过 Ingress 间接访问 Nginx 服务)
# 第三部分Redis 缓存服务Service
port: 80
targetPort: 80
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: redis # Service 名称,需与 Django 应用配置中访问 Redis 的服务名一致
name: redis
namespace: djangoblog
labels:
app: redis
spec:
selector:
app: redis # 匹配 Redis Pod 的标签
app: redis
ports:
- protocol: TCP
port: 6379 # 服务暴露的端口Redis 默认端口)
targetPort: 6379 # 转发到 Redis 容器暴露的 6379 端口
type: ClusterIP # 仅集群内部访问(缓存服务无需外部暴露)
# 第四部分MySQL 数据库服务Service
port: 6379
targetPort: 6379
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: db # Service 名称,需与 Django 应用配置中访问数据库的服务名一致
name: db
namespace: djangoblog
labels:
app: db
spec:
selector:
app: db # 匹配 MySQL Pod 的标签
app: db
ports:
- protocol: TCP
port: 3306 # 服务暴露的端口MySQL 默认端口)
targetPort: 3306 # 转发到 MySQL 容器暴露的 3306 端口
type: ClusterIP # 仅集群内部访问(数据库服务禁止外部直接访问,保障安全)
# 第五部分Elasticsearch 搜索引擎服务Service
port: 3306
targetPort: 3306
type: ClusterIP
---
apiVersion: v1
kind: Service
metadata:
name: elasticsearch # Service 名称,需与 Django 应用配置中访问 ES 的服务名一致
name: elasticsearch
namespace: djangoblog
labels:
app: elasticsearch
spec:
selector:
app: elasticsearch # 匹配 Elasticsearch Pod 的标签
app: elasticsearch
ports:
- protocol: TCP
port: 9200 # 服务暴露的端口ES HTTP 接口默认端口)
targetPort: 9200 # 转发到 ES 容器暴露的 9200 端口
type: ClusterIP # 仅集群内部访问(搜索引擎无需外部直接暴露)
port: 9200
targetPort: 9200
type: ClusterIP

@ -1,20 +1,10 @@
# StorageClass 资源配置(用于定义存储资源的类型和动态供应策略)
# apiVersion 指定 Kubernetes API 版本storage.k8s.io/v1 是 StorageClass 的稳定版本
apiVersion: storage.k8s.io/v1
# kind 定义资源类型为 StorageClass用于统一管理存储资源的属性
kind: StorageClass
metadata:
# StorageClass 的名称,需与前面 PV 和 PVC 中指定的 storageClassName 一致
name: local-storage
# 注解:设置为默认存储类(当 PVC 未指定 storageClassName 时,自动使用此存储类)
annotations:
storageclass.kubernetes.io/is-default-class: "true"
spec:
# 存储供应器:指定用于动态创建 PV 的插件(此处使用 no-provisioner 表示不支持动态供应)
# 因为前面的 PV 是手动创建的本地存储,无需动态生成,所以使用此供应器
provisioner: kubernetes.io/no-provisioner
# 卷绑定模式Immediate 表示 PVC 创建后立即尝试绑定可用的 PV不等待 Pod 调度)
# 对于本地存储,若使用 WaitForFirstConsumer 模式会更合适(等待 Pod 调度后再绑定对应节点的 PV
# 此处配置为 Immediate需确保 PV 已提前创建且满足 PVC 条件
volumeBindingMode: Immediate
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: Immediate

@ -1,82 +1,50 @@
# Nginx 核心配置文件,用于处理静态资源和反向代理请求到 Django 应用
# 全局配置段:设置 Nginx 整体运行参数
nginx; # 标识该文件为 Nginx 配置文件(固定起始标识)
# 工作进程数auto 表示自动根据服务器 CPU 核心数分配(优化并发性能)
user nginx;
worker_processes auto;
# 错误日志配置指定日志路径和日志级别notice 级别记录重要信息,不冗余)
error_log /var/log/nginx/error.log notice;
# PID 文件路径:存储 Nginx 主进程 ID用于管理 Nginx 进程(如重启、停止)
pid /var/run/nginx.pid;
# 事件模块配置:控制 Nginx 网络连接相关参数
events {
# 单个工作进程允许的最大并发连接数1024 为基础值,可根据服务器性能调整)
worker_connections 1024;
}
# HTTP 模块配置:处理 HTTP 请求的核心配置,包含全局规则和虚拟主机
http {
# 引入 MIME 类型映射文件:定义不同文件后缀对应的 Content-Type .html 对应 text/html
include /etc/nginx/mime.types;
# 默认 MIME 类型:当文件类型未匹配时,默认使用二进制流类型(避免浏览器直接解析未知文件)
default_type application/octet-stream;
# 日志格式定义:自定义访问日志的记录字段,命名为 main
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
# 访问日志配置:使用上面定义的 main 格式,指定日志存储路径
access_log /var/log/nginx/access.log main;
# 开启高效文件传输模式:使用内核零拷贝技术,提升静态文件传输效率
sendfile on;
# tcp_nopush on; # 可选配置:开启后会累积数据包再发送,适合大文件传输,默认关闭
#tcp_nopush on;
# 长连接超时时间:客户端与 Nginx 保持连接的最长时间65 秒,超时后自动断开)
keepalive_timeout 65;
# gzip on; # 可选配置:开启 Gzip 压缩,减少传输带宽,默认关闭
#gzip on;
# 虚拟主机配置:定义一个具体的站点规则(处理 80 端口的 HTTP 请求)
server {
# 站点根目录:默认请求的文件查找路径(此处指向 Django 静态文件目录)
root /code/djangoblog/collectedstatic/;
# 监听端口:该虚拟主机处理 80 端口的请求Nginx 默认 HTTP 端口)
listen 80;
# 长连接超时时间:覆盖 HTTP 模块的全局配置,仅作用于当前虚拟主机
keepalive_timeout 70;
# 静态资源路径规则:匹配以 /static/ 开头的请求(处理 Django 静态文件)
location /static/ {
# 缓存控制:设置静态文件缓存时间为最大(浏览器会长期缓存,减少重复请求)
expires max;
# 路径别名:将 /static/ 请求映射到实际的静态文件目录(与 root 配合确保路径正确)
alias /code/djangoblog/collectedstatic/;
}
# 默认路径规则:匹配所有未被上面规则匹配的请求(转发到 Django 应用)
location / {
# 转发请求头:传递客户端真实 IP Django否则 Django 会认为请求来自 Nginx
proxy_set_header X-Real-IP $remote_addr;
# 转发请求头:传递客户端 IP 列表(适用于多层代理场景)
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 转发请求头:传递原始请求的 Host 头(确保 Django 正确识别请求域名)
proxy_set_header Host $http_host;
# 转发标识:告诉 Django 请求来自 Nginx 代理
proxy_set_header X-NginX-Proxy true;
# 关闭重定向处理:禁止 Nginx 自动修改 Django 返回的重定向地址
proxy_redirect off;
# 条件判断:如果请求的文件在 Nginx 本地不存在(非静态文件)
if (!-f $request_filename) {
# 反向代理:将请求转发到 Django 服务(通过 Kubernetes Service 名称 djangoblog 8000 端口)
proxy_pass http://djangoblog:8000;
break; # 跳出条件判断,不再执行后续规则
break;
}
}
}
}
}

@ -1,3 +1 @@
# Django 应用的默认配置指定
# 作用:告诉 Django 当该应用被加载时,应使用哪个配置类进行初始化
default_app_config = 'djangoblog.apps.DjangoblogAppConfig'

@ -1,90 +1,64 @@
# 导入 Django 内置的 AdminSite 基础类(后台管理站点核心类)
from django.contrib.admin import AdminSite
# 导入日志记录模型(用于记录后台操作日志)
from django.contrib.admin.models import LogEntry
# 导入站点管理相关的默认 admin 类和模型Django 内置的站点管理功能)
from django.contrib.sites.admin import SiteAdmin
from django.contrib.sites.models import Site
# 导入各应用自定义的 admin 配置和模型(将各模块的后台管理逻辑聚合到此处)
from accounts.admin import * # 用户账户相关的 admin 配置
from blog.admin import * # 博客核心(文章、分类等)的 admin 配置
from blog.models import * # 博客核心模型
from comments.admin import * # 评论相关的 admin 配置
from comments.models import *# 评论模型
# 导入自定义的日志条目 admin 配置(扩展日志展示功能)
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相关的 admin 配置
from oauth.models import * # OAuth 相关模型
from owntracks.admin import *# 位置追踪OwnTracks相关的 admin 配置
from owntracks.models import *# 位置追踪模型
from servermanager.admin import *# 服务器管理相关的 admin 配置
from servermanager.models import *# 服务器管理模型
from oauth.admin import *
from oauth.models import *
from owntracks.admin import *
from owntracks.models import *
from servermanager.admin import *
from servermanager.models import *
# 自定义后台管理站点类(继承自 Django 内置的 AdminSite
class DjangoBlogAdminSite(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=True访问后台
return request.user.is_superuser
# 以下为注释掉的自定义 URL 示例(可扩展后台功能)
# def get_urls(self):
# # 先获取父类默认的 URL 配置
# urls = super().get_urls()
# from django.urls import path
# # 导入自定义视图(例如刷新缓存的视图)
# from blog.views import refresh_memcache
#
# # 定义自定义 URL 规则(如添加一个 /admin/refresh/ 路径)
# my_urls = [
# path('refresh/', self.admin_view(refresh_memcache), name="refresh"),
# ]
# # 合并默认 URL 和自定义 URL自定义 URL 优先级更高)
# return urls + my_urls
# 实例化自定义的后台管理站点(名称为 'admin',与默认后台路径保持一致)
admin_site = DjangoBlogAdminSite(name='admin')
# 注册模型与对应的 admin 配置到自定义后台站点(实现各模型在后台的管理界面)
# 博客核心模型注册
admin_site.register(Article, ArticlelAdmin) # 文章模型 + 其admin配置
admin_site.register(Category, CategoryAdmin) # 分类模型 + 其admin配置
admin_site.register(Tag, TagAdmin) # 标签模型 + 其admin配置
admin_site.register(Links, LinksAdmin) # 友情链接模型 + 其admin配置
admin_site.register(SideBar, SideBarAdmin) # 侧边栏模型 + 其admin配置
admin_site.register(BlogSettings, BlogSettingsAdmin) # 博客设置模型 + 其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配置
admin_site.register(EmailSendLog, EmailSendLogAdmin) # 邮件发送日志 + 其admin配置
admin_site.register(commands, CommandsAdmin)
admin_site.register(EmailSendLog, EmailSendLogAdmin)
# 用户模型注册
admin_site.register(BlogUser, BlogUserAdmin) # 自定义用户模型 + 其admin配置
admin_site.register(BlogUser, BlogUserAdmin)
# 评论模型注册
admin_site.register(Comment, CommentAdmin) # 评论模型 + 其admin配置
admin_site.register(Comment, CommentAdmin)
# OAuth 相关模型注册
admin_site.register(OAuthUser, OAuthUserAdmin) # OAuth用户模型 + 其admin配置
admin_site.register(OAuthConfig, OAuthConfigAdmin) # OAuth配置 + 其admin配置
admin_site.register(OAuthUser, OAuthUserAdmin)
admin_site.register(OAuthConfig, OAuthConfigAdmin)
# 位置追踪模型注册
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin) # 位置日志 + 其admin配置
admin_site.register(OwnTrackLog, OwnTrackLogsAdmin)
# Django 内置站点模型注册(使用默认的 SiteAdmin 配置)
admin_site.register(Site, SiteAdmin)
# 后台操作日志模型注册(使用自定义的 LogEntryAdmin 配置,增强日志展示)
admin_site.register(LogEntry, LogEntryAdmin)
admin_site.register(LogEntry, LogEntryAdmin)

@ -1,20 +1,11 @@
# 导入 Django 的应用配置基类(所有应用配置类需继承此类)
from django.apps import AppConfig
# 自定义应用配置类(用于 djangoblog 应用的初始化设置)
class DjangoblogAppConfig(AppConfig):
# 定义模型主键的默认类型:使用 BigAutoField自增 BigInteger 类型)
# 替代旧版的 AutoField自增 Integer支持更大范围的主键值
default_auto_field = 'django.db.models.BigAutoField'
# 应用的名称(必须与项目中 INSTALLED_APPS 配置的名称一致)
name = 'djangoblog'
# 应用就绪方法:当 Django 加载完所有应用后自动调用(用于初始化操作)
def ready(self):
# 调用父类的 ready 方法,确保基础初始化逻辑执行
super().ready()
# 导入并加载插件(应用启动时加载所有注册的插件)
# 注意:避免在模块顶部导入,防止 Django 初始化时循环导入问题
# Import and load plugins here
from .plugin_manage.loader import load_plugins
# 执行插件加载函数(例如注册钩子、初始化插件功能等)
load_plugins()
load_plugins()

@ -1,11 +1,7 @@
# 导入线程模块:用于异步执行耗时操作(如发送邮件,避免阻塞主流程)
import _thread
# 导入日志模块:记录操作日志和错误信息
import logging
# 导入 Django 信号核心类:用于定义和处理自定义信号
import django.dispatch
# 导入 Django 配置、模型、工具类:支撑信号处理中的业务逻辑
from django.conf import settings
from django.contrib.admin.models import LogEntry
from django.contrib.auth.signals import user_logged_in, user_logged_out
@ -13,7 +9,6 @@ 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
@ -21,73 +16,54 @@ from djangoblog.utils import cache, expire_view_cache, delete_sidebar_cache, del
from djangoblog.utils import get_current_site
from oauth.models import OAuthUser
# 初始化日志对象:用于记录当前模块的日志(如邮件发送失败、爬虫通知错误)
logger = logging.getLogger(__name__)
# 定义自定义信号第三方登录OAuth成功后触发的信号携带用户ID参数
oauth_user_login_signal = django.dispatch.Signal(['id'])
# 定义自定义信号:发送邮件的信号,携带收件人、标题、内容参数
send_email_signal = django.dispatch.Signal(
['emailto', 'title', 'content'])
# 信号接收器:监听 send_email_signal 信号,触发邮件发送逻辑
@receiver(send_email_signal)
def send_email_signal_handler(sender, **kwargs):
# 从信号参数中提取邮件相关信息
emailto = kwargs['emailto'] # 收件人列表
title = kwargs['title'] # 邮件标题
content = kwargs['content'] # 邮件内容HTML格式
emailto = kwargs['emailto']
title = kwargs['title']
content = kwargs['content']
# 构建 HTML 格式邮件:支持富文本内容
msg = EmailMultiAlternatives(
title,
content,
from_email=settings.DEFAULT_FROM_EMAIL, # 发件人(从项目配置中获取)
from_email=settings.DEFAULT_FROM_EMAIL,
to=emailto)
msg.content_subtype = "html" # 声明邮件内容为 HTML 类型
msg.content_subtype = "html"
# 记录邮件发送日志到数据库
from servermanager.models import EmailSendLog
log = EmailSendLog()
log.title = title
log.content = content
log.emailto = ','.join(emailto) # 收件人列表转字符串存储
log.emailto = ','.join(emailto)
try:
# 发送邮件:返回成功发送的邮件数量
result = msg.send()
log.send_result = result > 0 # 发送成功标记(数量>0即为成功
log.send_result = result > 0
except Exception as e:
# 捕获发送异常,记录错误日志
logger.error(f"失败邮箱号: {emailto}, {e}")
log.send_result = False # 标记发送失败
finally:
# 保存日志记录到数据库
log.save()
log.send_result = False
log.save()
# 信号接收器:监听 oauth_user_login_signal 信号,处理 OAuth 登录后的逻辑
@receiver(oauth_user_login_signal)
def oauth_user_login_signal_handler(sender, **kwargs):
# 从信号参数中提取 OAuth 用户ID
id = kwargs['id']
# 获取对应的 OAuth 用户对象
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() # 保存更新后的用户信息
oauthuser.picture = save_user_avatar(oauthuser.picture)
oauthuser.save()
# 删除侧边栏缓存:用户登录状态变化可能影响侧边栏内容(如显示登录用户信息)
delete_sidebar_cache()
# 信号接收器:监听所有模型的 post_save 信号(模型保存后触发)
@receiver(post_save)
def model_post_save_callback(
sender,
@ -97,74 +73,50 @@ def model_post_save_callback(
using,
update_fields,
**kwargs):
# 标记是否需要清理缓存
clearcache = False
# 跳过 Admin 操作日志LogEntry的处理避免日志保存时触发不必要的逻辑
if isinstance(instance, LogEntry):
return
# 处理有 "get_full_url" 方法的模型(如 Article 文章模型)
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]) # 调用百度爬虫通知接口
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
# 处理 Comment 评论模型的保存逻辑
if isinstance(instance, Comment):
# 仅处理已启用的评论is_enable=True
if instance.is_enable:
# 获取评论所属文章的访问路径
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 处理器缓存:评论变化可能影响页面 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()
# 信号接收器同时监听用户登录user_logged_in和登出user_logged_out信号
@receiver(user_logged_in)
@receiver(user_logged_out)
def user_auth_callback(sender, request, user, **kwargs):
# 若用户存在且用户名有效(排除异常情况)
if user and user.username:
# 记录用户登录/登出日志
logger.info(user)
# 清理侧边栏缓存:登录状态变化可能影响侧边栏(如显示/隐藏用户菜单)
delete_sidebar_cache()
# cache.clear() # 注释:若需全局清缓存可启用,当前仅清理侧边栏缓存
# cache.clear()

@ -1,184 +1,150 @@
# 导入 Django 字符串处理工具:确保字符串编码兼容
from django.utils.encoding import force_str
# 导入 Elasticsearch DSL 工具:构建 Elasticsearch 查询语句
from elasticsearch_dsl import Q
# 导入 Haystack 核心类:实现自定义搜索后端、查询和引擎
from haystack.backends import BaseEngine, BaseSearchBackend, BaseSearchQuery, log_query
from haystack.forms import ModelSearchForm # Haystack 基础搜索表单
from haystack.models import SearchResult # Haystack 搜索结果封装类
from haystack.utils import log as logging # Haystack 日志工具
from haystack.forms import ModelSearchForm
from haystack.models import SearchResult
from haystack.utils import log as logging
# 导入项目自定义的 Elasticsearch 文档和管理器:关联博客文章模型
from blog.documents import ArticleDocument, ArticleDocumentManager
from blog.models import Article # 博客核心文章模型
from blog.models import Article
# 初始化日志对象:记录搜索相关日志(如查询语句、错误信息)
logger = logging.getLogger(__name__)
# 自定义 Elasticsearch 搜索后端:实现 Haystack 与 Elasticsearch 的底层交互
class ElasticSearchBackend(BaseSearchBackend):
def __init__(self, connection_alias, **connection_options):
# 调用父类构造方法,初始化 Haystack 基础搜索后端
super(
ElasticSearchBackend,
self).__init__(
connection_alias,
**connection_options)
# 初始化文章文档管理器:负责 Elasticsearch 索引的创建、更新、删除
self.manager = ArticleDocumentManager()
# 启用拼写建议功能:用于返回搜索关键词的推荐词
self.include_spelling = True
# 辅助方法:将模型实例转换为 Elasticsearch 文档Document
def _get_models(self, iterable):
# 若传入空列表,默认获取所有文章;否则使用传入的模型实例
models = iterable if iterable and iterable[0] else Article.objects.all()
# 通过文档管理器将模型转换为 Elasticsearch 可识别的文档
docs = self.manager.convert_to_doc(models)
return docs
# 初始化索引:创建 Elasticsearch 索引并批量添加文档
def _create(self, models):
self.manager.create_index() # 创建 Elasticsearch 索引结构
docs = self._get_models(models) # 转换模型为文档
self.manager.rebuild(docs) # 批量写入文档到索引
self.manager.create_index()
docs = self._get_models(models)
self.manager.rebuild(docs)
# 删除索引中的文档:根据模型实例删除对应 Elasticsearch 记录
def _delete(self, models):
for m in models:
m.delete() # 调用文档的 delete 方法,删除 Elasticsearch 中的对应记录
m.delete()
return True
# 重建索引:全量更新 Elasticsearch 中的文档(覆盖旧数据)
def _rebuild(self, models):
# 若未指定模型,默认获取所有文章
models = models if models else Article.objects.all()
docs = self._get_models(models) # 转换模型为文档
self.manager.update_docs(docs) # 批量更新文档到索引
docs = self.manager.convert_to_doc(models)
self.manager.update_docs(docs)
# Haystack 标准方法:增量更新索引(更新指定模型对应的文档)
def update(self, index, iterable, commit=True):
models = self._get_models(iterable) # 转换模型为文档
self.manager.update_docs(models) # 增量更新文档
# Haystack 标准方法:移除单个模型对应的索引记录
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) # 删除文档
models = self._get_models([obj_or_string])
self._delete(models)
# Haystack 标准方法:清空索引(删除所有相关记录)
def clear(self, models=None, commit=True):
self.remove(None) # 调用 remove 方法清空索引
self.remove(None)
@staticmethod
def get_suggestion(query: str) -> str:
"""
生成搜索关键词的推荐词基于 Elasticsearch 拼写建议功能
若未找到推荐词返回原查询词
"""
# 构建 Elasticsearch 查询:匹配文章内容,并启用拼写建议
"""获取推荐词, 如果没有找到添加原搜索词"""
search = ArticleDocument.search() \
.query("match", body=query) \
.suggest('suggest_search', query, term={'field': 'body'}) \
.execute() # 执行查询
.execute()
keywords = []
# 提取 Elasticsearch 返回的建议词
for suggest in search.suggest.suggest_search:
if suggest["options"]: # 若有推荐词,取第一个
if suggest["options"]:
keywords.append(suggest["options"][0]["text"])
else: # 若无推荐词,保留原查询词
else:
keywords.append(suggest["text"])
return ' '.join(keywords) # 拼接推荐词为字符串返回
return ' '.join(keywords)
# Haystack 核心搜索方法:执行搜索并返回结果(带日志记录装饰器)
@log_query
def search(self, query_string, **kwargs):
logger.info('search query_string:' + query_string) # 记录查询关键词
logger.info('search query_string:' + query_string)
# 获取分页参数:起始偏移量和结束偏移量(用于分页)
start_offset = kwargs.get('start_offset', 0)
end_offset = kwargs.get('end_offset') # 若为 NoneElasticsearch 会返回默认数量结果
start_offset = kwargs.get('start_offset')
end_offset = kwargs.get('end_offset')
# 生成推荐词:根据 is_suggest 标识判断是否需要拼写建议
# 推荐词搜索
if getattr(self, "is_suggest", None):
suggestion = self.get_suggestion(query_string)
else:
suggestion = query_string # 不需要建议则使用原查询词
suggestion = query_string
# 构建 Elasticsearch 查询条件(布尔查询)
# 1. 匹配条件:标题或内容包含推荐词,匹配度最低 70%
q = Q('bool',
should=[Q('match', body=suggestion), Q('match', title=suggestion)],
minimum_should_match="70%")
# 构建完整搜索请求:
# - 过滤条件:使用上面的 q 匹配结果且文章状态为“已发布”status='p'、类型为“文章”type='a'
# - 不返回文档源数据source=False仅获取 ID 和得分,减少数据传输
# - 分页:按 start_offset 和 end_offset 截取结果
search = ArticleDocument.search() \
.query('bool', filter=[q]) \
.filter('term', status='p') \
.filter('term', type='a') \
.source(False)[start_offset: end_offset]
# 执行搜索,获取 Elasticsearch 返回结果
results = search.execute()
hits = results['hits'].total # 匹配到的总结果数
raw_results = [] # 存储 Haystack 标准格式的搜索结果
# 解析 Elasticsearch 原始结果,封装为 Haystack 的 SearchResult 格式
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 = {}
# 实例化 SearchResult封装应用名、模型名、文档ID、匹配得分等信息
result_class = SearchResult
result = result_class(
app_label,
model_name,
raw_result['_id'], # Elasticsearch 中文档的 ID
raw_result['_score'], # 匹配得分(用于排序)
raw_result['_id'],
raw_result['_score'],
**additional_fields)
raw_results.append(result)
# 搜索结果元数据:分面(无分面需求,留空)、拼写建议
facets = {}
# 若推荐词与原查询词不同,返回推荐词;否则为 None
spelling_suggestion = None if query_string == suggestion else suggestion
# 返回 Haystack 标准格式的搜索结果
return {
'results': raw_results, # 封装后的搜索结果列表
'hits': hits, # 总匹配数
'facets': facets, # 分面数据(空)
'spelling_suggestion': spelling_suggestion, # 拼写建议
'results': raw_results,
'hits': hits,
'facets': facets,
'spelling_suggestion': spelling_suggestion,
}
# 自定义 Elasticsearch 查询类:处理查询参数解析、格式清洗等
class ElasticSearchQuery(BaseSearchQuery):
# 转换日期格式:适配 Elasticsearch 的日期查询需求
def _convert_datetime(self, date):
if hasattr(date, 'hour'): # 若为datetime含时分秒格式化为年月日时分秒
if hasattr(date, 'hour'):
return force_str(date.strftime('%Y%m%d%H%M%S'))
else: # 若为date仅年月日补全时分秒为000000
else:
return force_str(date.strftime('%Y%m%d000000'))
# 清洗查询词:处理 Haystack 保留词和特殊字符,避免查询语法错误
def clean(self, query_fragment):
words = query_fragment.split() # 拆分查询词为单词列表
"""
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:
# 处理 Haystack 保留词(如 AND、OR转为小写避免语法冲突
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
@ -186,39 +152,32 @@ class ElasticSearchQuery(BaseSearchQuery):
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
# 获取搜索结果总数:通过 get_results 结果长度计算
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
# 自定义搜索表单:扩展 Haystack 基础表单,支持“是否启用拼写建议”的控制
class ElasticSearchModelSearchForm(ModelSearchForm):
def search(self):
# 根据请求参数is_suggest设置后端是否启用拼写建议
# 若 is_suggest = "no",则不启用;否则启用
# 是否建议搜索
self.searchqueryset.query.backend.is_suggest = self.data.get("is_suggest") != "no"
# 调用父类 search 方法,执行搜索并返回结果
sqs = super().search()
return sqs
# 自定义 Elasticsearch 搜索引擎:关联后端和查询类,供 Haystack 调用
class ElasticSearchEngine(BaseEngine):
backend = ElasticSearchBackend # 绑定自定义搜索后端
query = ElasticSearchQuery # 绑定自定义查询类
backend = ElasticSearchBackend
query = ElasticSearchQuery

@ -1,61 +1,40 @@
# 导入 Django 用户模型工具:获取当前项目的用户模型(支持自定义用户模型)
from django.contrib.auth import get_user_model
# 导入 Django 内置的 Feed 基类:用于快速实现 RSS/Atom 订阅功能
from django.contrib.syndication.views import Feed
# 导入 Django 时间工具:处理时区和当前时间
from django.utils import timezone
# 导入 RSS 2.0 格式生成器:指定 Feed 输出格式为 RSS 2.0 标准
from django.utils.feedgenerator import Rss201rev2Feed
# 导入博客核心模型和工具:关联文章数据及 Markdown 解析
from blog.models import Article
from djangoblog.utils import CommonMarkdown # 自定义 Markdown 解析工具(将文章内容转为 HTML
from djangoblog.utils import CommonMarkdown
# 自定义 RSS Feed 类:继承 Django 内置 Feed 类,实现博客文章的订阅功能
class DjangoBlogFeed(Feed):
# 指定 Feed 生成器类型:使用 RSS 2.0 标准格式(最常用的 RSS 版本)
feed_type = Rss201rev2Feed
# Feed 描述信息:显示在订阅源的说明中
description = '大巧无工,重剑无锋.'
# Feed 标题:订阅源的名称(通常为博客名称)
title = "且听风吟 大巧无工,重剑无锋. "
# Feed 的链接:订阅源自身的 URL通常指向博客首页或 Feed 专属页面)
link = "/feed/"
# 订阅源作者名称:从系统第一个用户的昵称获取(适合个人博客)
def author_name(self):
return get_user_model().objects.first().nickname
# 订阅源作者链接:指向作者的个人页面(通过用户模型的 get_absolute_url 方法获取)
def author_link(self):
return get_user_model().objects.first().get_absolute_url()
# 订阅源包含的项目(文章):定义要展示在 Feed 中的内容
def items(self):
# 筛选条件类型为文章type='a'、状态为已发布status='p'
# 排序规则:按发布时间倒序(最新发布的文章在前)
# 数量限制:只显示最新的 5 篇文章
return Article.objects.filter(type='a', status='p').order_by('-pub_time')[:5]
# 单个项目(文章)的标题:使用文章自身的标题
def item_title(self, item):
return item.title
# 单个项目(文章)的描述:将 Markdown 格式的文章内容转为 HTML 后展示
def item_description(self, item):
return CommonMarkdown.get_markdown(item.body) # 调用工具类解析 Markdown
return CommonMarkdown.get_markdown(item.body)
# 订阅源的版权信息:动态生成包含当前年份的版权声明
def feed_copyright(self):
now = timezone.now() # 获取当前时间(带时区)
return "Copyright© {year} 且听风吟".format(year=now.year) # 格式化版权信息
now = timezone.now()
return "Copyright© {year} 且听风吟".format(year=now.year)
# 单个项目(文章)的链接:指向文章详情页(通过文章模型的 get_absolute_url 方法获取)
def item_link(self, item):
return item.get_absolute_url()
# 单个项目文章的唯一标识GUID此处留空Django 会默认使用 item_link 作为 GUID
def item_guid(self, item):
return
return

@ -1,37 +1,27 @@
# 导入 Django Admin 核心模块:用于自定义后台管理界面
from django.contrib import admin
# 导入 Admin 日志相关常量和模型:处理日志操作类型(如删除)
from django.contrib.admin.models import DELETION
from django.contrib.contenttypes.models import ContentType
# 导入 Django URL 和字符串处理工具:生成反向链接、处理编码和转义
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 _
# 自定义 LogEntry Admin 类:用于在 Django 后台管理 Admin 操作日志(记录用户对模型的增删改操作)
class LogEntryAdmin(admin.ModelAdmin):
# 列表页筛选器:按“内容类型”(即操作的模型,如 Article、Comment筛选日志
list_filter = [
'content_type'
]
# 列表页搜索框:支持按“对象名称”(如文章标题)和“操作描述”(如“修改了标题”)搜索
search_fields = [
'object_repr',
'change_message'
]
# 列表页可点击的链接:点击“操作时间”或“操作描述”可进入日志详情页
list_display_links = [
'action_time',
'get_change_message',
]
# 列表页展示的字段:操作时间、操作用户(带链接)、操作模型、操作对象(带链接)、操作描述
list_display = [
'action_time',
'user_link',
@ -40,81 +30,62 @@ class LogEntryAdmin(admin.ModelAdmin):
'get_change_message',
]
# 权限控制:禁止添加日志(日志由系统自动生成,不允许手动添加)
def has_add_permission(self, request):
return False
# 权限控制:仅允许超级用户或拥有“修改日志”权限的用户查看/修改日志,且禁止 POST 请求(避免提交修改)
def has_change_permission(self, request, obj=None):
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):
# 转义对象名称(避免 XSS 攻击)
object_link = escape(obj.object_repr)
# 获取操作对象的内容类型(即所属模型)
content_type = obj.content_type
# 若操作不是“删除”DELETION且内容类型存在排除异常情况
if obj.action_flag != DELETION and content_type is not None:
# try returning an actual link instead of object repr string
try:
# 生成对象编辑页的 URL格式admin/应用名/模型名/change/对象ID/
url = reverse(
'admin:{}_{}_change'.format(content_type.app_label,
content_type.model),
args=[obj.object_id]
)
# 将对象名称转为链接(点击跳转到编辑页)
object_link = '<a href="{}">{}</a>'.format(url, object_link)
except NoReverseMatch:
# 若无法生成链接(如模型未注册到 Admin则保留纯文本名称
pass
# 标记为安全 HTML告诉 Django 无需转义,避免链接被当作文本显示)
return mark_safe(object_link)
# 配置自定义字段的排序和显示名称
object_link.admin_order_field = 'object_repr' # 支持按“对象名称”排序
object_link.short_description = _('object') # 列表页字段显示名称(支持翻译)
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))
# 转义用户名(避免 XSS 攻击)
user_link = escape(force_str(obj.user))
try:
# 生成用户编辑页的 URL
# 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]
)
# 将用户名转为链接(点击跳转到用户编辑页)
user_link = '<a href="{}">{}</a>'.format(url, user_link)
except NoReverseMatch:
# 若无法生成链接(如用户模型未注册到 Admin则保留纯文本用户名
pass
return mark_safe(user_link)
# 配置自定义字段的排序和显示名称
user_link.admin_order_field = 'user' # 支持按“用户”排序
user_link.short_description = _('user') # 列表页字段显示名称(支持翻译)
user_link.admin_order_field = 'user'
user_link.short_description = _('user')
# 优化查询性能:预加载“内容类型”关联数据(避免列表页加载时产生大量数据库查询)
def get_queryset(self, request):
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
del actions['delete_selected']
return actions

@ -1,11 +1,7 @@
# 文章相关操作的标识常量:用于统一管理操作类型,避免硬编码字符串导致的不一致问题
# 场景:可用于日志记录、统计分析、权限校验等,通过常量标识具体操作
ARTICLE_DETAIL_LOAD = 'article_detail_load' # 文章详情页加载操作标识(如用户访问某篇文章详情时使用)
ARTICLE_CREATE = 'article_create' # 文章创建操作标识(如用户发布新文章时使用)
ARTICLE_UPDATE = 'article_update' # 文章更新操作标识(如用户编辑已发布文章时使用)
ARTICLE_DELETE = 'article_delete' # 文章删除操作标识(如用户删除某篇文章时使用)
ARTICLE_DETAIL_LOAD = 'article_detail_load'
ARTICLE_CREATE = 'article_create'
ARTICLE_UPDATE = 'article_update'
ARTICLE_DELETE = 'article_delete'
ARTICLE_CONTENT_HOOK_NAME = "the_content"
# 文章内容钩子Hook名称常量用于定义文章内容处理的钩子函数/扩展点名称
# 场景:在 Django 等框架中,可通过钩子机制对文章内容进行自定义处理(如过滤敏感词、添加水印、解析 markdown 等)
# 例如:注册名为 "the_content" 的钩子函数,在文章内容渲染前自动执行处理逻辑
ARTICLE_CONTENT_HOOK_NAME = "the_content"

@ -13,371 +13,331 @@ import os
import sys
from pathlib import Path
# 导入 Django 国际化工具:用于多语言文本翻译
from django.utils.translation import gettext_lazy as _
# 自定义工具函数:将环境变量转为布尔值(处理配置的灵活性)
def env_to_bool(env, default):
str_val = os.environ.get(env) # 从环境变量获取值
# 若环境变量未设置则返回默认值,否则判断字符串是否为'True'
str_val = os.environ.get(env)
return default if str_val is None else str_val == 'True'
# 项目根目录:定位到 settings.py 所在目录的父目录(项目根路径)
# Build paths inside the project like this: BASE_DIR / 'subdir'.
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/
# 安全密钥用于加密会话、CSRF 令牌等敏感数据(生产环境需通过环境变量设置,避免硬编码)
# 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 # 生产环境手动关闭调试的示例
# 测试模式标识:判断是否正在执行测试命令(如 python manage.py test
# DEBUG = False
TESTING = len(sys.argv) > 1 and sys.argv[1] == 'test'
# 允许访问的主机:生产环境需指定具体域名,开发环境用'*'允许所有主机(存在安全风险,生产禁用)
# ALLOWED_HOSTS = [] # 生产环境初始空配置(需补充具体域名)
# ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*', '127.0.0.1', 'example.com']
# Django 4.0+ 新增配置:信任的 CSRF 来源(避免跨域 CSRF 验证失败,生产环境需指定真实域名)
# django 4.0新增配置
CSRF_TRUSTED_ORIGINS = ['http://example.com']
# Application definition
# Application definition已安装的应用Django 内置应用 + 第三方应用 + 自定义应用)
INSTALLED_APPS = [
# Django 内置 Admin 应用使用精简版配置SimpleAdminConfig减少不必要功能
# 'django.contrib.admin', # 完整版 Admin 配置(此处注释,使用精简版)
# 'django.contrib.admin',
'django.contrib.admin.apps.SimpleAdminConfig',
'django.contrib.auth', # 认证与授权系统
'django.contrib.contenttypes', # 内容类型框架(关联模型与权限)
'django.contrib.sessions', # 会话管理(用户登录状态保持)
'django.contrib.messages', # 消息提示系统(如登录成功提示)
'django.contrib.staticfiles', # 静态文件管理CSS/JS/图片)
'django.contrib.sites', # 多站点支持(用于 RSS、OAuth 等功能)
'django.contrib.sitemaps', # 站点地图生成(利于 SEO
'mdeditor', # 第三方应用Markdown 编辑器(用于文章编写)
'haystack', # 第三方应用:搜索框架(对接 Whoosh/Elasticsearch
'blog', # 自定义应用:博客核心功能(文章、分类等)
'accounts', # 自定义应用:用户账户管理(登录、注册等)
'comments', # 自定义应用:评论功能
'oauth', # 自定义应用:第三方登录(如 GitHub、微博
'servermanager', # 自定义应用:服务器管理(命令执行、日志记录)
'owntracks', # 自定义应用位置追踪OwnTracks 数据管理)
'compressor', # 第三方应用静态文件压缩CSS/JS 压缩,提升加载速度)
'djangoblog' # 自定义应用:项目核心配置(如信号、插件)
'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'
]
# 中间件:处理请求/响应的钩子(按顺序执行,影响请求流程)
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', # 安全相关中间件HTTPS、XSS 防护等)
'django.contrib.sessions.middleware.SessionMiddleware', # 会话管理中间件
'django.middleware.locale.LocaleMiddleware', # 国际化中间件(处理多语言切换)
'django.middleware.gzip.GZipMiddleware', # GZip 压缩中间件(减少响应体积)
# 'django.middleware.cache.UpdateCacheMiddleware', # 缓存更新中间件(注释:按需启用)
'django.middleware.common.CommonMiddleware', # 通用中间件(处理 URL 重定向、404 等)
# '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', # 条件请求中间件(缓存协商,减少重复请求)
'blog.middleware.OnlineMiddleware' # 自定义中间件:用户在线状态管理
'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'
]
# 根 URL 配置:指定项目的主 URL 路由文件
ROOT_URLCONF = 'djangoblog.urls'
# 模板配置:定义模板引擎、路径及上下文处理器
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates', # 使用 Django 内置模板引擎
'DIRS': [os.path.join(BASE_DIR, 'templates')], # 全局模板目录(项目根目录下的 templates
'APP_DIRS': True, # 允许从各应用的 templates 目录加载模板
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
# 上下文处理器:向所有模板注入全局变量(如用户信息、请求对象)
'context_processors': [
'django.template.context_processors.debug', # 调试模式变量(如 DEBUG
'django.template.context_processors.request', # 请求对象request
'django.contrib.auth.context_processors.auth', # 认证相关变量(如 user
'django.contrib.messages.context_processors.messages', # 消息变量(如 messages
'blog.context_processors.seo_processor' # 自定义上下文处理器:注入 SEO 相关数据
'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'
],
},
},
]
# WSGI 应用:指定项目的 WSGI 入口文件(用于部署,如 Gunicorn、uWSGI
WSGI_APPLICATION = 'djangoblog.wsgi.application'
# Database数据库配置使用 MySQL 数据库)
# Database
# https://docs.djangoproject.com/en/1.10/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql', # 数据库引擎MySQL
'NAME': os.environ.get('DJANGO_MYSQL_DATABASE') or 'djangoblog', # 数据库名(优先从环境变量获取)
'USER': os.environ.get('DJANGO_MYSQL_USER') or 'root', # 数据库用户名
'PASSWORD': os.environ.get('DJANGO_MYSQL_PASSWORD') or 'root', # 数据库密码
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1', # 数据库主机(默认本地)
'PORT': int(os.environ.get('DJANGO_MYSQL_PORT') or 3306), # 数据库端口(默认 3306
'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 'root',
'HOST': os.environ.get('DJANGO_MYSQL_HOST') or '127.0.0.1',
'PORT': int(
os.environ.get('DJANGO_MYSQL_PORT') or 3306),
'OPTIONS': {
'charset': 'utf8mb4'}, # 数据库字符集(支持 emoji 表情)
'charset': 'utf8mb4'},
}}
# Password validation密码验证规则确保用户密码强度
# Password validation
# https://docs.djangoproject.com/en/1.10/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
# 验证密码与用户属性(如用户名、邮箱)的相似度
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
# 验证密码最小长度(默认 8 位)
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
# 验证密码是否在常见弱密码列表中
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
# 验证密码是否纯数字
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization国际化配置多语言支持
# https://docs.djangoproject.com/en/1.10/topics/i18n/
LANGUAGES = (
('en', _('English')), # 英语
('zh-hans', _('Simplified Chinese')), # 简体中文
('zh-hant', _('Traditional Chinese')), # 繁体中文
('en', _('English')),
('zh-hans', _('Simplified Chinese')),
('zh-hant', _('Traditional Chinese')),
)
# 翻译文件目录:指定多语言翻译文件(.po/.mo的存放路径
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 # 禁用 UTC 时间(使用本地时区存储时间,避免时区转换问题)
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/
# Search搜索框架配置Haystack + Whoosh/Elasticsearch
HAYSTACK_CONNECTIONS = {
'default': {
# 搜索引擎:默认使用 Whoosh轻量级全文搜索引擎适合中小项目
'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'
# Authentication认证配置自定义登录逻辑
# 允许用户通过“用户名”或“邮箱”登录(默认仅支持用户名)
# Allow user login with username and password
AUTHENTICATION_BACKENDS = [
'accounts.user_login_backend.EmailOrUsernameModelBackend']
# 自定义用户模型:使用 accounts 应用的 BlogUser 模型(替代 Django 内置 User 模型)
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
STATIC_URL = '/static/'
STATICFILES = os.path.join(BASE_DIR, 'static')
AUTH_USER_MODEL = 'accounts.BlogUser'
# 登录 URL未登录用户访问需认证页面时重定向到该 URL
LOGIN_URL = '/login/'
TIME_FORMAT = '%Y-%m-%d %H:%M:%S'
DATE_TIME_FORMAT = '%Y-%m-%d'
# Custom Settings自定义业务配置
TIME_FORMAT = '%Y-%m-%d %H:%M:%S' # 时间显示格式(如 2024-05-20 14:30:00
DATE_TIME_FORMAT = '%Y-%m-%d' # 日期显示格式(如 2024-05-20
BOOTSTRAP_COLOR_TYPES = [ # Bootstrap 颜色样式(用于前端组件,如标签、按钮)
# bootstrap color styles
BOOTSTRAP_COLOR_TYPES = [
'default', 'primary', 'success', 'info', 'warning', 'danger'
]
PAGINATE_BY = 10 # 分页大小:每页显示 10 条数据(如文章列表、评论列表)
CACHE_CONTROL_MAX_AGE = 2592000 # HTTP 缓存有效期30 天(单位:秒)
# Cache缓存配置默认本地内存缓存支持 Redis 扩展)
# paginate
PAGINATE_BY = 10
# http cache timeout
CACHE_CONTROL_MAX_AGE = 2592000
# cache setting
CACHES = {
'default': {
# 本地内存缓存(适合开发环境,生产环境建议用 Redis/Memcached
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
'TIMEOUT': 10800, # 缓存有效期3 小时(单位:秒)
'LOCATION': 'unique-snowflake', # 缓存实例标识(避免多实例冲突)
'TIMEOUT': 10800,
'LOCATION': 'unique-snowflake',
}
}
# 若环境变量指定 Redis 地址,则使用 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")}', # Redis 连接地址
'LOCATION': f'redis://{os.environ.get("DJANGO_REDIS_URL")}',
}
}
# Site & SEO站点与 SEO 配置)
SITE_ID = 1 # 站点 ID多站点配置时区分不同站点单站点固定为 1
# 百度主动推送 URL用于文章发布后主动通知百度收录提升 SEO 效率)
SITE_ID = 1
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' # SMTP 邮件后端
EMAIL_USE_TLS = env_to_bool('DJANGO_EMAIL_TLS', False) # 是否启用 TLS 加密(与 SSL 二选一)
EMAIL_USE_SSL = env_to_bool('DJANGO_EMAIL_SSL', True) # 是否启用 SSL 加密(默认启用,端口通常为 465
EMAIL_HOST = os.environ.get('DJANGO_EMAIL_HOST') or 'smtp.mxhichina.com' # SMTP 服务器地址(默认阿里云)
EMAIL_PORT = int(os.environ.get('DJANGO_EMAIL_PORT') or 465) # SMTP 端口SSL 通常为 465TLS 通常为 587
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 # 服务器错误通知发件人邮箱
# 管理员邮箱:生产环境错误(如 500 异常)会发送到此邮箱
# 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
# Setting debug=false did NOT handle except email notifications
ADMINS = [('admin', os.environ.get('DJANGO_ADMIN_EMAIL') or 'admin@admin.com')]
# 微信管理员密码:二次 MD5 加密(用于微信后台管理验证,具体业务自定义)
# WX ADMIN password(Two times md5)
WXADMIN = os.environ.get(
'DJANGO_WXADMIN_PASSWORD') or '995F03AC401D6CABABAEF756FC4D43C7'
# Logging日志配置记录系统操作、错误信息
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)
LOGGING = {
'version': 1, # 日志配置版本(固定为 1
'disable_existing_loggers': False, # 不禁用已存在的日志器
'root': { # 根日志器(所有未指定日志器的日志都会走这里)
'level': 'INFO', # 日志级别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': { # 日志过滤器:按条件过滤日志
'require_debug_false': { # 仅当 DEBUG=False 时生效(生产环境)
'filters': {
'require_debug_false': {
'()': 'django.utils.log.RequireDebugFalse',
},
'require_debug_true': { # 仅当 DEBUG=True 时生效(开发环境)
'require_debug_true': {
'()': 'django.utils.log.RequireDebugTrue',
},
},
'handlers': { # 日志处理器:定义日志的输出方式
'log_file': { # 文件处理器:按时间轮转切割日志
'handlers': {
'log_file': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler', # 按时间轮转的处理器
'filename': os.path.join(LOG_PATH, 'djangoblog.log'), # 日志文件路径
'when': 'D', # 轮转周期每天Day
'formatter': 'verbose', # 使用 verbose 格式
'interval': 1, # 轮转间隔1 个周期1 天)
'delay': True, # 延迟创建文件(直到有日志才创建)
'backupCount': 5, # 保留日志备份数5 天
'encoding': 'utf-8' # 日志文件编码
'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': { # 控制台处理器:开发环境输出到终端
'console': {
'level': 'DEBUG',
'filters': ['require_debug_true'], # 仅开发环境生效
'filters': ['require_debug_true'],
'class': 'logging.StreamHandler',
'formatter': 'verbose'
},
'null': { # 空处理器:用于屏蔽不需要的日志
'null': {
'class': 'logging.NullHandler',
},
'mail_admins': { # 邮件处理器:生产环境错误发送到管理员邮箱
'level': 'ERROR', # 仅 ERROR 级别日志触发
'filters': ['require_debug_false'], # 仅生产环境生效
'mail_admins': {
'level': 'ERROR',
'filters': ['require_debug_false'],
'class': 'django.utils.log.AdminEmailHandler'
}
},
'loggers': { # 自定义日志器:针对特定模块配置日志
'djangoblog': { # 项目核心模块日志器
'handlers': ['log_file', 'console'], # 输出到文件和控制台
'loggers': {
'djangoblog': {
'handlers': ['log_file', 'console'],
'level': 'INFO',
'propagate': True, # 是否向上传递到根日志器(此处开启)
'propagate': True,
},
'django.request': { # Django 请求模块日志器(记录请求相关错误)
'handlers': ['mail_admins'], # 错误发送到管理员邮箱
'django.request': {
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': False, # 不向上传递(避免重复记录)
'propagate': False,
}
}
}
# Static Files静态文件配置CSS/JS/图片等)
# 静态文件收集目录:执行 collectstatic 命令后,静态文件会汇总到此处(生产环境使用)
STATIC_ROOT = os.path.join(BASE_DIR, 'collectedstatic')
# 静态文件 URL 前缀:前端通过 /static/ 访问静态文件
STATIC_URL = '/static/'
# 全局静态文件目录:项目根目录下的 static 目录(存放全局静态文件)
STATICFILES = os.path.join(BASE_DIR, 'static')
# 静态文件查找器:指定 Django 查找静态文件的路径(内置 + 第三方)
STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', # 查找全局 STATICFILES 目录
'django.contrib.staticfiles.finders.AppDirectoriesFinder', # 查找各应用的 static 目录
'compressor.finders.CompressorFinder', # 查找压缩后的静态文件(第三方 compressor 应用)
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
# other
'compressor.finders.CompressorFinder',
)
COMPRESS_ENABLED = True
# COMPRESS_OFFLINE = True
# 静态文件压缩配置compressor 应用)
COMPRESS_ENABLED = True # 启用压缩(生产环境建议开启,提升加载速度)
# COMPRESS_OFFLINE = True # 离线压缩(预先生成压缩文件,生产环境推荐,此处注释按需启用)
# CSS 压缩过滤器:绝对路径处理 + 代码压缩
COMPRESS_CSS_FILTERS = [
# creates absolute urls from relative ones
'compressor.filters.css_default.CssAbsoluteFilter',
# css minimizer
'compressor.filters.cssmin.CSSMinFilter'
]
# JS 压缩过滤器:代码压缩
COMPRESS_JS_FILTERS = [
'compressor.filters.jsmin.JSMinFilter'
]
# Media Files媒体文件配置用户上传的文件如文章图片、头像
# 媒体文件存储目录:项目根目录下的 uploads 目录
MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads')
# 媒体文件 URL 前缀:前端通过 /media/ 访问上传的文件
MEDIA_URL = '/media/'
# X-Frame-Options防止点击劫持SAMEORIGIN仅允许同域页面嵌入当前页面
X_FRAME_OPTIONS = 'SAMEORIGIN'
# 默认模型主键类型Django 3.2+ 新增,指定模型默认主键为 BigAutoField64 位自增整数)
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Elasticsearch 配置(按需启用:若环境变量指定 Elasticsearch 地址,则使用 Elasticsearch 替代 Whoosh
if os.environ.get('DJANGO_ELASTICSEARCH_HOST'):
ELASTICSEARCH_DSL = {
'default': {
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST') # Elasticsearch 连接地址
'hosts': os.environ.get('DJANGO_ELASTICSEARCH_HOST')
},
}
# 替换 Haystack 搜索引擎为自定义的 Elasticsearch 引擎
HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'djangoblog.elasticsearch_backend.ElasticSearchEngine',
},
}
# Plugin System插件系统配置自定义插件功能
PLUGINS_DIR = BASE_DIR / 'plugins' # 插件目录:项目根目录下的 plugins 目录
# 激活的插件列表:指定需要加载的插件(如文章版权、阅读时长统计等)
# Plugin System
PLUGINS_DIR = BASE_DIR / 'plugins'
ACTIVE_PLUGINS = [
'article_copyright', # 文章版权插件
'reading_time', # 阅读时长统计插件
'external_links', # 外部链接处理插件
'view_count', # 文章阅读量统计插件
'seo_optimizer' # SEO 优化插件
'article_copyright',
'reading_time',
'external_links',
'view_count',
'seo_optimizer'
]

@ -1,88 +1,59 @@
# 导入 Django 内置站点地图基类:用于快速实现标准化 sitemap.xml
from django.contrib.sitemaps import Sitemap
# 导入 URL 反向解析工具:生成页面的绝对 URL适配路由命名
from django.urls import reverse
# 导入博客核心模型:用于生成动态内容(文章、分类、标签)的站点地图
from blog.models import Article, Category, Tag
# 静态页面站点地图类:处理无数据库关联的静态页面(如博客首页)
class StaticViewSitemap(Sitemap):
# 优先级0.5(取值 0.0-1.0,值越高搜索引擎越优先抓取,静态页优先级中等)
priority = 0.5
# 更新频率daily每天更新适合内容相对稳定但可能微调的静态页
changefreq = 'daily'
# 定义要包含的静态页面:返回路由名称列表(对应 urls.py 中命名的路由)
def items(self):
return ['blog:index', ] # 此处仅包含博客首页(路由名为 'blog:index'
return ['blog:index', ]
# 生成静态页面的 URL通过路由名称反向解析为绝对路径
def location(self, item):
return reverse(item) # item 为 items() 返回的路由名称,如 'blog:index'
return reverse(item)
# 文章站点地图类处理博客文章的动态页面核心内容SEO 关键)
class ArticleSiteMap(Sitemap):
# 更新频率monthly每月更新适合文章发布后较少修改的场景
changefreq = "monthly"
# 优先级0.6(高于静态页和分类/标签,文章是站点核心内容,优先抓取)
priority = "0.6"
# 定义要包含的文章:仅筛选“已发布”状态的文章(排除草稿、私有文章)
def items(self):
return Article.objects.filter(status='p')
# 文章最后更新时间:用于搜索引擎判断内容是否更新,提升抓取效率
def lastmod(self, obj):
return obj.last_modify_time # 引用文章模型的“最后修改时间”字段
return obj.last_modify_time
# 分类站点地图类:处理文章分类页面(聚合类内容,辅助 SEO
class CategorySiteMap(Sitemap):
# 更新频率Weekly每周更新分类内容更新频率低于文章
changefreq = "Weekly"
# 优先级0.6(与文章同级,分类页是重要的内容聚合入口)
priority = "0.6"
# 定义要包含的分类:获取所有分类(无论是否有文章,确保分类页被收录)
def items(self):
return Category.objects.all()
# 分类最后更新时间:判断分类下内容是否有变化(如新增/修改文章)
def lastmod(self, obj):
return obj.last_modify_time # 引用分类模型的“最后修改时间”字段
return obj.last_modify_time
# 标签站点地图类:处理文章标签页面(细分内容聚合,补充 SEO 覆盖)
class TagSiteMap(Sitemap):
# 更新频率Weekly每周更新标签内容更新频率与分类一致
changefreq = "Weekly"
# 优先级0.3(低于文章和分类,标签页是辅助导航入口,优先级较低)
priority = "0.3"
# 定义要包含的标签:获取所有标签(确保所有标签页被搜索引擎收录)
def items(self):
return Tag.objects.all()
# 标签最后更新时间:判断标签下内容是否有变化
def lastmod(self, obj):
return obj.last_modify_time # 引用标签模型的“最后修改时间”字段
return obj.last_modify_time
# 用户站点地图类:处理文章作者页面(展示用户发布的所有文章,提升作者页曝光)
class UserSiteMap(Sitemap):
# 更新频率Weekly每周更新用户发布文章频率通常较低
changefreq = "Weekly"
# 优先级0.3(与标签同级,作者页是辅助内容入口)
priority = "0.3"
# 定义要包含的用户:获取所有发布过文章的作者(去重,避免重复收录同一用户)
def items(self):
# 1. 从所有文章中提取作者字段 → 2. 转集合去重 → 3. 转列表返回
return list(set(map(lambda x: x.author, Article.objects.all())))
# 用户页面最后更新时间:用用户“注册时间”替代(简化逻辑,也可改为用户最新文章发布时间)
def lastmod(self, obj):
return obj.date_joined # 引用用户模型的“注册时间”字段
return obj.date_joined

@ -1,43 +1,21 @@
# 导入日志模块:记录推送过程的成功信息与错误,便于排查问题
import logging
# 导入 HTTP 请求库:用于向百度搜索引擎接口发送 POST 请求
import requests
# 导入 Django 项目配置:获取百度主动推送的接口 URL从 settings.py 读取)
from django.conf import settings
# 初始化日志对象:指定日志归属为当前模块,便于区分不同模块的日志
logger = logging.getLogger(__name__)
# 搜索引擎推送工具类:封装向百度等搜索引擎主动提交页面的逻辑
class SpiderNotify():
@staticmethod
def baidu_notify(urls):
"""
向百度搜索引擎主动推送页面基于百度站长平台的链接提交接口
目的让百度快速发现新页面缩短收录周期提升 SEO 效率
Args:
urls: 待推送的 URL 列表 ['https://xxx.com/article/1/', 'https://xxx.com/article/2/']
"""
try:
# 格式化请求数据:百度接口要求 URL 以换行符(\n分隔拼接成字符串
data = '\n'.join(urls)
# 发送 POST 请求:调用 settings 中配置的百度推送接口 URL
result = requests.post(settings.BAIDU_NOTIFY_URL, data=data)
# 记录推送结果:将百度返回的响应文本(如成功/失败数量)写入日志
logger.info(result.text)
except Exception as e:
# 捕获异常(如网络错误、接口超时等),记录错误信息到日志
logger.error(e)
@staticmethod
def notify(url):
"""
通用推送方法统一入口当前仅调用百度推送可扩展支持其他搜索引擎
Args:
url: 待推送的 URL支持单个 URL URL 列表此处兼容百度推送的列表格式
"""
# 调用百度推送方法,实现页面提交
SpiderNotify.baidu_notify(url)
SpiderNotify.baidu_notify(url)

@ -1,29 +1,32 @@
# 导入 Django 内置的测试基类:提供单元测试所需的基础功能(如断言、测试环境初始化)
from django.test import TestCase
# 导入项目自定义工具模块测试其中的工具函数如加密、Markdown解析、字典转URL参数
from djangoblog.utils import *
# 自定义测试类:继承 TestCase用于测试 djangoblog 项目的工具函数功能
class DjangoBlogTest(TestCase):
# 测试前置方法:在每个测试方法(以 test_ 开头)执行前自动调用
# 用于初始化测试数据、配置测试环境等(此处暂无需初始化,留空)
def setUp(self):
pass
# 核心测试方法:测试 utils 模块中的多个工具函数(命名以 test_ 开头Django 会自动识别执行)
def test_utils(self):
# 1. 测试 SHA256 加密函数get_sha256
# 对字符串 'test' 进行 SHA256 加密,获取加密结果
md5 = get_sha256('test') # 注意:函数名是 get_sha256实际功能是 SHA256 加密(非 MD5可能是命名习惯
# 断言:加密结果不为空(验证函数能正常返回加密值,未抛出异常)
md5 = get_sha256('test')
self.assertIsNotNone(md5)
# 2. 测试 Markdown 解析函数CommonMarkdown.get_markdown
# 定义一段包含标题、Python代码块、超链接的 Markdown 文本
c = CommonMarkdown.get_markdown('''
# Title1 # 一级标题
# Title1
```python
import os
```
[url](https://www.lylinux.net/)
[ddd](http://www.baidu.com)
```python # Python 代码块
import os
''')
self.assertIsNotNone(c)
d = {
'd': 'key1',
'd2': 'key2'
}
data = parse_dict_to_url(d)
self.assertIsNotNone(data)

@ -13,88 +13,52 @@ 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'))
"""
# 导入项目配置:用于获取静态文件/媒体文件路径、DEBUG 状态等
from django.conf import settings
# 导入国际化路由工具:生成带语言前缀的路由(如 /en/admin/、/zh-hans/blog/
from django.conf.urls.i18n import i18n_patterns
# 导入静态文件路由工具:开发环境下提供静态文件访问(生产环境需 Nginx 处理)
from django.conf.urls.static import static
# 导入站点地图视图:关联站点地图配置,生成 sitemap.xml
from django.contrib.sitemaps.views import sitemap
# 导入 URL 路由组件path 用于精确匹配include 用于引入子应用路由
from django.urls import path, include
# 导入 re_path支持正则表达式匹配 URL适配复杂路由场景
from django.urls import re_path
# 导入 Haystack 搜索视图工厂:用于自定义搜索视图和表单
from haystack.views import search_view_factory
# 导入项目自定义视图和配置:关联核心功能路由
from blog.views import EsSearchView # 自定义 Elasticsearch 搜索视图
from djangoblog.admin_site import admin_site # 自定义后台管理站点(替代默认 admin
from djangoblog.elasticsearch_backend import ElasticSearchModelSearchForm # 自定义搜索表单
from djangoblog.feeds import DjangoBlogFeed # RSS 订阅 Feed 视图
from djangoblog.sitemap import ( # 站点地图配置类
ArticleSiteMap, CategorySiteMap, StaticViewSitemap, TagSiteMap, UserSiteMap
)
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
# 站点地图聚合配置:将各类型站点地图归类,用于生成统一的 sitemap.xml
sitemaps = {
'blog': ArticleSiteMap, # 文章站点地图
'Category': CategorySiteMap, # 分类站点地图
'Tag': TagSiteMap, # 标签站点地图
'User': UserSiteMap, # 作者站点地图
'static': StaticViewSitemap # 静态页面站点地图
'blog': ArticleSiteMap,
'Category': CategorySiteMap,
'Tag': TagSiteMap,
'User': UserSiteMap,
'static': StaticViewSitemap
}
# 自定义错误页面路由:指定 404/500/403 错误时跳转的视图
handler404 = 'blog.views.page_not_found_view' # 404 页面未找到
handler500 = 'blog.views.server_error_view' # 500 服务器内部错误
handle403 = 'blog.views.permission_denied_view'# 403 权限拒绝(注意变量名应为 handler403此处可能是笔误
handler404 = 'blog.views.page_not_found_view'
handler500 = 'blog.views.server_error_view'
handle403 = 'blog.views.permission_denied_view'
# 基础 URL 路由:不包含语言前缀的公共路由
urlpatterns = [
# 国际化路由:提供语言切换功能(如 /i18n/setlang/ 接口)
path('i18n/', include('django.conf.urls.i18n')),
]
# 带语言前缀的路由:通过 i18n_patterns 自动添加语言前缀(如 /zh-hans/、/en/
# prefix_default_language=False默认语言不显示前缀如中文默认不显示 /zh-hans/,直接用根路径)
urlpatterns += i18n_patterns(
# 1. 后台管理路由:使用自定义的 admin_site替代默认 admin访问路径如 /admin/
re_path(r'^admin/', admin_site.urls),
# 2. 博客核心路由:引入 blog 应用的子路由,命名空间为 'blog'(路由名如 blog:index
re_path(r'', include('blog.urls', namespace='blog')),
# 3. Markdown 编辑器路由:引入 mdeditor 第三方应用的路由,用于文章编辑时的 Markdown 预览
re_path(r'mdeditor/', include('mdeditor.urls')),
# 4. 评论路由:引入 comments 应用的子路由,命名空间为 'comment'
re_path(r'', include('comments.urls', namespace='comment')),
# 5. 用户账户路由:引入 accounts 应用的子路由(登录、注册、个人中心),命名空间为 'account'
re_path(r'', include('accounts.urls', namespace='account')),
# 6. 第三方登录路由:引入 oauth 应用的子路由GitHub、微博登录命名空间为 'oauth'
re_path(r'', include('oauth.urls', namespace='oauth')),
# 7. 站点地图路由:生成 sitemap.xml供搜索引擎抓取访问路径 /sitemap.xml
re_path(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
name='django.contrib.sitemaps.views.sitemap'),
# 8. RSS 订阅路由:提供两种访问路径(/feed/ 和 /rss/),均指向 DjangoBlogFeed 视图
re_path(r'^feed/$', DjangoBlogFeed()),
re_path(r'^rss/$', DjangoBlogFeed()),
# 9. 搜索路由使用自定义的搜索视图EsSearchView和表单ElasticSearchModelSearchForm
# 访问路径如 /search?q=关键词,命名为 'search'
re_path('^search', search_view_factory(
view_class=EsSearchView,
form_class=ElasticSearchModelSearchForm
), name='search'),
# 10. 服务器管理路由:引入 servermanager 应用的子路由(命令执行、日志查看),命名空间为 'servermanager'
re_path('^search', search_view_factory(view_class=EsSearchView, form_class=ElasticSearchModelSearchForm),
name='search'),
re_path(r'', include('servermanager.urls', namespace='servermanager')),
# 11. 位置追踪路由:引入 owntracks 应用的子路由(位置数据查看),命名空间为 'owntracks'
re_path(r'', include('owntracks.urls', namespace='owntracks')),
prefix_default_language=False # 默认语言不显示语言前缀
)
# 静态文件路由开发环境下DEBUG=True通过 Django 提供静态文件访问(生产环境需注释,用 Nginx 处理)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
# 媒体文件路由:仅在 DEBUG=True开发环境时生效提供用户上传文件的访问如 /media/avatar.jpg
re_path(r'', include('owntracks.urls', namespace='owntracks'))
, prefix_default_language=False) + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL,
document_root=settings.MEDIA_ROOT)
document_root=settings.MEDIA_ROOT)

@ -2,173 +2,105 @@
# encoding: utf-8
import logging # 日志模块:记录操作信息和错误
import os # 系统操作模块:处理文件路径、目录创建等
import random # 随机数模块:生成验证码等随机内容
import string # 字符串模块:提供数字、字母等常量
import uuid # 唯一模块:生成唯一标识符(用于头像文件名)
from hashlib import sha256 # 加密模块:提供 SHA256 加密算法
import bleach # HTML 清理模块:过滤不安全的 HTML 标签(防 XSS 攻击)
import markdown # Markdown 解析模块:将 Markdown 文本转为 HTML
import requests # HTTP 请求模块:下载网络资源(如用户头像)
from django.conf import settings # Django 配置:获取项目设置(如静态文件路径)
from django.contrib.sites.models import Site # 站点模型:获取当前站点信息(域名等)
from django.core.cache import cache # 缓存模块:操作 Django 缓存(获取/设置/删除)
from django.templatetags.static import static # 静态文件工具:生成静态文件的 URL
# 初始化日志对象:指定日志归属为当前模块,便于日志分类
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用于数据统计或初始化
Returns:
tuple: (最大文章 ID, 最大评论 ID)
"""
# 延迟导入模型:避免循环导入问题(工具模块可能被模型模块引用)
from blog.models import Article
from comments.models import Comment
# 返回最新文章和评论的主键ID
return (Article.objects.latest().pk, Comment.objects.latest().pk)
def get_sha256(str):
"""
对字符串进行 SHA256 加密用于密码加密唯一标识生成等
Args:
str: 待加密的字符串
Returns:
str: 加密后的 64 位十六进制字符串
"""
# 创建 SHA256 加密对象,需先将字符串转为字节流(指定编码 utf-8
m = sha256(str.encode('utf-8'))
# 返回十六进制加密结果
return m.hexdigest()
def cache_decorator(expiration=3 * 60):
"""
缓存装饰器装饰函数将函数返回值缓存指定时间默认 3 分钟
作用减少重复计算或数据库查询提升性能
Args:
expiration: 缓存有效期默认 3 分钟
Returns:
装饰器函数包装原函数实现缓存逻辑
"""
def wrapper(func):
def news(*args, **kwargs):
# 尝试生成缓存键(优先使用视图对象的 get_cache_key 方法)
try:
view = args[0] # 若第一个参数是视图对象
key = view.get_cache_key() # 使用视图自带的缓存键
view = args[0]
key = view.get_cache_key()
except:
key = None # 非视图函数,需自定义缓存键
# 若未生成缓存键,则基于函数和参数生成唯一键
key = None
if not key:
# 将函数、参数转为字符串,确保唯一性
unique_str = repr((func, args, kwargs))
# 对字符串进行 SHA256 加密,生成固定长度的缓存键
m = sha256(unique_str.encode('utf-8'))
key = m.hexdigest()
# 尝试从缓存获取数据
value = cache.get(key)
if value is not None:
# 缓存命中:返回缓存值(处理 None 的特殊标记)
# logger.info('cache_decorator get cache:%s key:%s' % (func.__name__, key))
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)
# 缓存结果(用特殊标记表示 None避免缓存不生效
if value is None:
cache.set(key, '__default_cache_value__', expiration)
else:
cache.set(key, value, expiration)
return value
return news
return wrapper
def expire_view_cache(path, servername, serverport, key_prefix=None):
'''
主动刷新指定 URL 路径的视图缓存用于数据更新后清理旧缓存
Args:
path: URL 路径 '/article/1/'
servername: 服务器域名 'www.example.com'
serverport: 服务器端口 80
key_prefix: 缓存键前缀与视图缓存配置一致
Returns:
bool: 缓存是否成功删除
刷新视图缓存
:param path:url路径
:param servername:host
:param serverport:端口
:param key_prefix:前缀
:return:是否成功
'''
from django.http import HttpRequest # 延迟导入:避免启动时依赖冲突
from django.utils.cache import get_cache_key # 获取视图缓存键的工具
# 构造模拟请求对象(用于生成缓存键)
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() # 应用缓存装饰器:缓存当前站点信息(默认 3 分钟)
@cache_decorator()
def get_current_site():
"""
获取当前站点信息域名等从缓存获取以减少数据库查询
Returns:
Site: Django Site 模型实例
"""
site = Site.objects.get_current()
return site
class CommonMarkdown:
"""
Markdown 解析工具类 Markdown 文本转为 HTML并支持提取目录TOC
"""
@staticmethod
def _convert_markdown(value):
"""
内部方法执行 Markdown 转换返回 HTML 内容和目录
Args:
value: Markdown 格式的文本
Returns:
tuple: (转换后的 HTML 内容, 目录 HTML)
"""
# 初始化 Markdown 解析器,启用扩展:
# - extra: 支持表格、脚注等扩展语法
# - codehilite: 代码高亮
# - toc: 生成目录
# - tables: 表格支持extra 已包含,此处冗余可能为兼容)
md = markdown.Markdown(
extensions=[
'extra',
@ -177,227 +109,124 @@ class CommonMarkdown:
'tables',
]
)
body = md.convert(value) # 转换文本为 HTML
toc = md.toc # 提取目录 HTML
body = md.convert(value)
toc = md.toc
return body, toc
@staticmethod
def get_markdown_with_toc(value):
"""
获取带目录的 Markdown 转换结果
Args:
value: Markdown 文本
Returns:
tuple: (HTML 内容, 目录 HTML)
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body, toc
@staticmethod
def get_markdown(value):
"""
获取仅包含 HTML 内容的转换结果忽略目录
Args:
value: Markdown 文本
Returns:
str: 转换后的 HTML 内容
"""
body, toc = CommonMarkdown._convert_markdown(value)
return body
def send_email(emailto, title, content):
"""
发送邮件通过信号机制触发解耦发送逻辑
Args:
emailto: 收件人列表 ['user@example.com']
title: 邮件标题
content: 邮件内容HTML 格式
"""
# 延迟导入信号:避免循环导入
from djangoblog.blog_signals import send_email_signal
# 发送信号,由信号接收器(如 send_email_signal_handler处理实际发送
send_email_signal.send(
send_email.__class__, # 信号发送者(此处用当前函数的类)
send_email.__class__,
emailto=emailto,
title=title,
content=content)
def generate_code() -> str:
"""
生成 6 位数字验证码用于邮箱验证登录验证码等
Returns:
str: 6 位数字字符串
"""
# 从数字字符集中随机选择 6 个,拼接为字符串
"""生成随机数验证码"""
return ''.join(random.sample(string.digits, 6))
def parse_dict_to_url(dict):
"""
将字典转换为 URL 参数字符串 {'a':1, 'b':2} 'a=1&b=2'
Args:
dict: 键值对字典
Returns:
str: URL 编码后的参数字符串
"""
from urllib.parse import quote # 延迟导入:避免启动依赖
# 对键和值进行 URL 编码(保留 '/' 不编码),再拼接为 "k=v&k2=v2" 格式
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():
"""
获取博客系统设置如站点名称描述等优先从缓存获取
Returns:
BlogSettings: 博客设置模型实例
"""
# 尝试从缓存获取
value = cache.get('get_blog_setting')
if value:
return value
else:
# 延迟导入模型:避免循环导入
from blog.models import BlogSettings
# 若数据库中无设置记录,初始化默认设置
if not BlogSettings.objects.count():
setting = BlogSettings()
setting.site_name = 'djangoblog' # 站点名称
setting.site_description = '基于Django的博客系统' # 站点描述
setting.site_seo_description = '基于Django的博客系统' # 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 # 是否显示谷歌广告
setting.open_site_comment = True # 是否开启评论功能
setting.analytics_code = '' # 统计代码(如百度统计)
setting.beian_code = '' # 备案号
setting.show_gongan_code = False # 是否显示公安备案
setting.comment_need_review = False # 评论是否需要审核
setting.save() # 保存默认设置
# 从数据库获取设置
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_decorator 的有效期,或依赖全局缓存配置)
cache.set('get_blog_setting', value)
return value
def save_user_avatar(url):
'''
下载并保存用户头像到本地用于第三方登录时的头像同步
Args:
url: 头像的网络 URL
Returns:
str: 本地头像的静态文件 URL '/static/avatar/xxx.jpg'
保存用户头像
:param url:头像url
:return: 本地路径
'''
logger.info(url) # 记录头像 URL
logger.info(url)
try:
# 本地头像存储目录(静态文件目录下的 avatar 文件夹)
basedir = os.path.join(settings.STATICFILES, 'avatar')
# 发送 HTTP 请求下载头像(超时 2 秒)
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
# 提取文件扩展名,默认为 .jpg
ext = os.path.splitext(url)[1] if isimage else '.jpg'
# 生成唯一文件名UUID 避免冲突)
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():
"""
删除侧边栏相关缓存当侧边栏内容更新时调用如新增文章评论
"""
from blog.models import LinkShowType # 延迟导入:避免循环依赖
# 侧边栏缓存键格式为 "sidebar + 链接类型值"(如 sidebar0、sidebar1
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):
"""
删除指定模板片段的缓存用于模板中用 {% cache %} 标签缓存的内容
Args:
prefix: 缓存前缀与模板中 {% cache %} 标签的前缀一致
keys: 缓存键的参数列表与模板中 {% cache %} 标签的参数一致
"""
from django.core.cache.utils import make_template_fragment_key # 生成模板缓存键的工具
# 生成模板片段的缓存键
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用于动态生成资源路径
Returns:
str: 静态资源 URL 前缀 'http://example.com/static/'
"""
if settings.STATIC_URL:
return settings.STATIC_URL
else:
# 若未配置 STATIC_URL从当前站点域名生成
site = get_current_site()
return 'http://' + site.domain + '/static/'
# HTML 清理配置:允许的标签和属性(防止 XSS 攻击)
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']
}
'h2', 'p']
ALLOWED_ATTRIBUTES = {'a': ['href', 'title'], 'abbr': ['title'], 'acronym': ['title']}
def sanitize_html(html):
"""
清理 HTML 内容仅保留允许的标签和属性 XSS 攻击
Args:
html: 原始 HTML 字符串
Returns:
str: 清理后的安全 HTML 字符串
"""
return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)
return bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRIBUTES)

@ -6,18 +6,11 @@ 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/
"""
# 导入系统模块:用于设置环境变量
import os
# 导入 Django WSGI 核心函数:生成符合 WSGI 标准的应用对象
from django.core.wsgi import get_wsgi_application
# 设置 Django 项目的配置模块环境变量
# 作用:告诉 Django 启动时加载哪个配置文件(此处为项目根目录下的 djangoblog.settings
# 部署时可通过修改该值切换配置(如 djangoblog.settings.production 对应生产环境配置)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djangoblog.settings")
# 生成 WSGI 应用对象
# 作用:将 Django 项目包装为 WSGI 兼容的应用,供 WSGI 服务器(如 Gunicorn、uWSGI调用
# 该对象是 Django 与 Web 服务器交互的核心入口
application = get_wsgi_application()
application = get_wsgi_application()

@ -1,17 +1,7 @@
from django.contrib import admin
# 注册模型到Django管理后台使模型数据可通过后台界面管理
# Register your models here.
# 定义OwnTrackLogs模型的管理类用于配置模型在admin后台的展示和操作方式
class OwnTrackLogsAdmin(admin.ModelAdmin):
# 此处为管理类的配置区域目前未添加任何自定义配置pass表示空实现
# 可根据需求添加如下常见配置:
# list_display = ('tid', 'lat', 'lon', 'creation_time') # 列表页显示的字段
# list_filter = ('tid',) # 可用于过滤的字段
# search_fields = ('tid',) # 可搜索的字段
# ordering = ('-creation_time',) # 默认排序方式(按创建时间降序)
pass
# 注意:需要导入对应的模型才能完成注册,例如:
# from .models import OwnTrackLog
# admin.site.register(OwnTrackLog, OwnTrackLogsAdmin)

@ -2,14 +2,4 @@ from django.apps import AppConfig
class OwntracksConfig(AppConfig):
# 定义Django应用的配置类用于配置应用的元数据和行为
# 指定应用的名称,这是应用的唯一标识
# 在Django项目中通过该名称引用此应用如在INSTALLED_APPS中注册、迁移依赖等
name = 'owntracks'
# 可选配置(当前未设置):
# verbose_name应用的可读名称用于在admin后台等位置显示
# 例如verbose_name = '位置追踪日志'
# default_auto_field指定模型默认的主键字段类型
# 例如default_auto_field = 'django.db.models.BigAutoField'

@ -1,56 +1,31 @@
# 由 Django 4.1.7 于 2023年3月2日 07:14 自动生成
# 该文件是Django的数据迁移文件用于定义数据库模型结构的变更
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.db import migrations, models
import django.utils.timezone
class Migration(migrations.Migration):
# 标记为初始迁移(首次创建模型时使用)
initial = True
# 依赖的其他迁移文件,初始迁移无依赖
dependencies = [
]
# 迁移操作列表
operations = [
# 创建名为'OwnTrackLog'的数据模型
migrations.CreateModel(
name='OwnTrackLog',
fields=[
# 自增主键字段类型为BigAutoField大整数自增
('id', models.BigAutoField(
auto_created=True, # 自动创建
primary_key=True, # 作为主键
serialize=False, # 不序列化
verbose_name='ID' # 字段显示名称
)),
# 用户标识字段字符串类型最大长度100
('tid', models.CharField(
max_length=100,
verbose_name='用户' # 显示名称为“用户”
)),
# 纬度字段,浮点型
('lat', models.FloatField(
verbose_name='纬度' # 显示名称为“纬度”
)),
# 经度字段,浮点型
('lon', models.FloatField(
verbose_name='经度' # 显示名称为“经度”
)),
# 创建时间字段,默认值为当前时间(时区感知)
('created_time', models.DateTimeField(
default=django.utils.timezone.now, # 默认值为当前时间
verbose_name='创建时间' # 显示名称为“创建时间”
)),
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('tid', models.CharField(max_length=100, verbose_name='用户')),
('lat', models.FloatField(verbose_name='纬度')),
('lon', models.FloatField(verbose_name='经度')),
('created_time', models.DateTimeField(default=django.utils.timezone.now, verbose_name='创建时间')),
],
# 模型的额外配置选项
options={
'verbose_name': 'OwnTrackLogs', # 模型的单数显示名称
'verbose_name_plural': 'OwnTrackLogs', # 模型的复数显示名称(此处与单数相同)
'ordering': ['created_time'], # 默认排序方式:按创建时间升序
'get_latest_by': 'created_time', # 指定通过created_time字段获取最新记录
'verbose_name': 'OwnTrackLogs',
'verbose_name_plural': 'OwnTrackLogs',
'ordering': ['created_time'],
'get_latest_by': 'created_time',
},
),
]

@ -1,31 +1,22 @@
# 由 Django 4.2.5 于 2023年9月6日 13:19 自动生成
# 该文件是Django的数据迁移文件用于修改已存在的数据模型结构
# Generated by Django 4.2.5 on 2023-09-06 13:19
from django.db import migrations
class Migration(migrations.Migration):
# 依赖的迁移文件:依赖于'owntracks'应用下的0001_initial迁移
# 意味着执行当前迁移前必须先执行0001_initial迁移
dependencies = [
('owntracks', '0001_initial'),
]
# 迁移操作列表
operations = [
# 修改OwnTrackLog模型的配置选项
migrations.AlterModelOptions(
name='owntracklog', # 目标模型名称
# 更新后的模型选项:
# - 按'creation_time'字段获取最新记录(原先是'created_time'
# - 按'creation_time'字段升序排序(原先是'created_time'
# - 单数和复数显示名称保持不变
name='owntracklog',
options={'get_latest_by': 'creation_time', 'ordering': ['creation_time'], 'verbose_name': 'OwnTrackLogs', 'verbose_name_plural': 'OwnTrackLogs'},
),
# 重命名OwnTrackLog模型的字段
migrations.RenameField(
model_name='owntracklog', # 目标模型名称
old_name='created_time', # 原字段名
new_name='creation_time', # 新字段名
model_name='owntracklog',
old_name='created_time',
new_name='creation_time',
),
]

@ -4,26 +4,17 @@ from django.utils.timezone import now
# Create your models here.
# 定义OwnTrackLog模型用于存储用户的位置追踪日志数据
class OwnTrackLog(models.Model):
# 用户标识字段字符串类型最大长度100不允许为空显示名称为“用户”
tid = models.CharField(max_length=100, null=False, verbose_name='用户')
# 纬度字段:浮点型,显示名称为“纬度”
lat = models.FloatField(verbose_name='纬度')
# 经度字段:浮点型,显示名称为“经度”
lon = models.FloatField(verbose_name='经度')
# 创建时间字段DateTime类型显示名称为“创建时间”默认值为当前时间带时区
creation_time = models.DateTimeField('创建时间', default=now)
# 定义模型实例的字符串表示形式返回用户标识tid
def __str__(self):
return self.tid
# 模型的元数据配置
class Meta:
ordering = ['creation_time'] # 默认按创建时间升序排序
verbose_name = "OwnTrackLogs" # 模型的单数显示名称
verbose_name_plural = verbose_name # 模型的复数显示名称(与单数相同)
get_latest_by = 'creation_time' # 指定通过creation_time字段获取最新记录
ordering = ['creation_time']
verbose_name = "OwnTrackLogs"
verbose_name_plural = verbose_name
get_latest_by = 'creation_time'

@ -8,85 +8,57 @@ from .models import OwnTrackLog
# Create your tests here.
# 定义测试类继承自Django的TestCase用于测试OwnTrackLog相关功能
class OwnTrackLogTest(TestCase):
# 测试前的初始化方法,会在每个测试方法执行前调用
def setUp(self):
# 创建一个测试客户端用于模拟HTTP请求
self.client = Client()
# 创建一个请求工厂,用于构造更复杂的请求对象(本测试中未实际使用)
self.factory = RequestFactory()
# 核心测试方法测试OwnTrackLog的相关接口和功能
def test_own_track_log(self):
# 1. 测试正常提交位置数据
# 构造符合要求的测试数据包含tid、lat、lon三个必要字段
o = {
'tid': 12,
'lat': 123.123,
'lon': 134.341
}
# 模拟POST请求提交数据到日志记录接口
self.client.post(
'/owntracks/logtracks', # 请求的URL
json.dumps(o), # 将数据序列化为JSON字符串
content_type='application/json' # 指定内容类型为JSON
)
# 验证数据库中是否成功创建了一条记录
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json')
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
# 2. 测试提交不完整数据缺少lon字段
o = {
'tid': 12,
'lat': 123.123 # 缺少经度lon字段
'lat': 123.123
}
# 再次发送POST请求
self.client.post(
'/owntracks/logtracks',
json.dumps(o),
content_type='application/json'
)
# 验证数据库记录数未增加(因数据不完整未创建新记录)
content_type='application/json')
length = len(OwnTrackLog.objects.all())
self.assertEqual(length, 1)
# 3. 测试未登录状态访问受保护页面
# 访问地图展示页面
rsp = self.client.get('/owntracks/show_maps')
# 验证未登录时被重定向状态码302
self.assertEqual(rsp.status_code, 302)
# 4. 创建超级用户并登录,测试登录后访问功能
# 创建超级用户
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1"
)
password="liangliangyy1")
# 使用测试客户端登录
self.client.login(username='liangliangyy1', password='liangliangyy1')
# 手动创建一条位置记录
s = OwnTrackLog()
s.tid = 12
s.lon = 123.234
s.lat = 34.234
s.save()
# 测试登录后访问各接口是否正常状态码200
rsp = self.client.get('/owntracks/show_dates')
self.assertEqual(rsp.status_code, 200)
rsp = self.client.get('/owntracks/show_maps')
self.assertEqual(rsp.status_code, 200)
rsp = self.client.get('/owntracks/get_datas')
self.assertEqual(rsp.status_code, 200)
# 测试带日期参数的接口请求
rsp = self.client.get('/owntracks/get_datas?date=2018-02-26')
self.assertEqual(rsp.status_code, 200)

@ -1,27 +1,12 @@
from django.urls import path
# 导入当前应用owntracks的views模块用于关联URL与视图函数
from . import views
# 定义应用的命名空间为"owntracks"
# 作用在使用reverse()或模板中引用URL时可通过"owntracks:URL名称"的格式精准定位避免不同应用间URL名称冲突
app_name = "owntracks"
# 定义URL路由列表将URL路径与对应的视图函数绑定
urlpatterns = [
# 1. 位置日志提交接口接收POST请求存储位置数据
# 路径:/owntracks/logtracks关联视图函数manage_owntrack_logURL名称为logtracks
path('owntracks/logtracks', views.manage_owntrack_log, name='logtracks'),
# 2. 地图展示页面:展示位置数据的地图视图
# 路径:/owntracks/show_maps关联视图函数show_mapsURL名称为show_maps
path('owntracks/show_maps', views.show_maps, name='show_maps'),
# 3. 数据查询接口:获取位置日志数据(支持带日期参数筛选)
# 路径:/owntracks/get_datas关联视图函数get_datasURL名称为get_datas
path('owntracks/get_datas', views.get_datas, name='get_datas'),
# 4. 日期列表页面:展示有位置日志的日期列表
# 路径:/owntracks/show_dates关联视图函数show_log_datesURL名称为show_dates
path('owntracks/show_dates', views.show_log_dates, name='show_dates')
]

@ -16,110 +16,86 @@ from django.views.decorators.csrf import csrf_exempt
from .models import OwnTrackLog
# 初始化日志记录器,用于记录视图中的操作和错误信息
logger = logging.getLogger(__name__)
# 处理位置日志数据提交的视图函数禁用CSRF保护方便外部设备提交数据
@csrf_exempt
def manage_owntrack_log(request):
try:
# 解析请求体中的JSON数据
s = json.loads(request.read().decode('utf-8'))
# 提取必要的字段(用户标识、纬度、经度)
tid = s['tid']
lat = s['lat']
lon = s['lon']
# 记录日志信息
logger.info(
'tid:{tid}.lat:{lat}.lon:{lon}'.format(
tid=tid, lat=lat, lon=lon))
# 验证字段不为空
if tid and lat and lon:
# 创建并保存位置记录
m = OwnTrackLog()
m.tid = tid
m.lat = lat
m.lon = lon
m.save()
return HttpResponse('ok') # 成功响应
return HttpResponse('ok')
else:
return HttpResponse('data error') # 数据不完整错误
return HttpResponse('data error')
except Exception as e:
# 记录异常信息
logger.error(e)
return HttpResponse('error') # 异常响应
return HttpResponse('error')
# 显示地图页面的视图函数,要求用户登录且为超级用户
@login_required
def show_maps(request):
if request.user.is_superuser:
# 获取默认日期当前UTC日期或请求中的日期参数
defaultdate = str(datetime.datetime.now(timezone.utc).date())
date = request.GET.get('date', defaultdate)
# 传递日期参数到模板
context = {
'date': date
}
return render(request, 'owntracks/show_maps.html', context)
else:
# 非超级用户拒绝访问
from django.http import HttpResponseForbidden
return HttpResponseForbidden()
# 显示日志日期列表的视图函数,要求用户登录
@login_required
def show_log_dates(request):
# 获取所有记录的创建时间,提取日期并去重排序
dates = OwnTrackLog.objects.values_list('creation_time', flat=True)
results = list(sorted(set(map(lambda x: x.strftime('%Y-%m-%d'), dates))))
# 传递日期列表到模板
context = {
'results': results
}
return render(request, 'owntracks/show_log_dates.html', context)
# 将GPS坐标转换为高德地图坐标的工具函数
def convert_to_amap(locations):
convert_result = []
it = iter(locations) # 创建迭代器
it = iter(locations)
# 每次处理30个坐标高德API限制
item = list(itertools.islice(it, 30))
while item:
# 拼接坐标字符串(格式:"lon1,lat1;lon2,lat2"
datas = ';'.join(
set(map(lambda x: str(x.lon) + ',' + str(x.lat), item)))
# 高德坐标转换API参数
key = '8440a376dfc9743d8924bf0ad141f28e' # 高德API密钥
key = '8440a376dfc9743d8924bf0ad141f28e'
api = 'http://restapi.amap.com/v3/assistant/coordinate/convert'
query = {
'key': key,
'locations': datas,
'coordsys': 'gps' # 源坐标系统为GPS
'coordsys': 'gps'
}
# 调用高德API进行坐标转换
rsp = requests.get(url=api, params=query)
result = json.loads(rsp.text)
if "locations" in result:
convert_result.append(result['locations'])
# 处理下一批坐标
item = list(itertools.islice(it, 30))
# 拼接所有转换结果
return ";".join(convert_result)
# 获取位置数据的视图函数,要求用户登录
@login_required
def get_datas(request):
# 确定查询日期默认今天可通过date参数指定
now = django.utils.timezone.now().replace(tzinfo=timezone.utc)
querydate = django.utils.timezone.datetime(
now.year, now.month, now.day, 0, 0, 0)
@ -127,32 +103,25 @@ def get_datas(request):
date = list(map(lambda x: int(x), request.GET.get('date').split('-')))
querydate = django.utils.timezone.datetime(
date[0], date[1], date[2], 0, 0, 0)
# 计算查询日期的结束时间次日0点
nextdate = querydate + datetime.timedelta(days=1)
# 查询该日期范围内的所有位置记录
models = OwnTrackLog.objects.filter(
creation_time__range=(querydate, nextdate))
result = list()
if models and len(models):
# 按用户标识tid分组
for tid, item in groupby(
sorted(models, key=lambda k: k.tid), key=lambda k: k.tid):
d = dict()
d["name"] = tid # 用户名
d["name"] = tid
paths = list()
# 注释掉的代码:使用高德转换后的坐标
# 使用高德转换后的经纬度
# locations = convert_to_amap(
# sorted(item, key=lambda x: x.creation_time))
# for i in locations.split(';'):
# paths.append(i.split(','))
# 当前使用GPS原始坐标按创建时间排序
# 使用GPS原始经纬度
for location in sorted(item, key=lambda x: x.creation_time):
paths.append([str(location.lon), str(location.lat)])
d["path"] = paths # 位置路径
d["path"] = paths
result.append(d)
# 返回JSON格式的位置数据
return JsonResponse(result, safe=False)

@ -4,37 +4,29 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ArticleCopyrightPlugin(BasePlugin):
# 插件基本信息定义
PLUGIN_NAME = '文章结尾版权声明' # 插件名称,用于在管理界面显示
PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。' # 插件功能描述
PLUGIN_VERSION = '0.2.0' # 插件版本号
PLUGIN_AUTHOR = 'liangliangyy' # 插件作者
PLUGIN_NAME = '文章结尾版权声明'
PLUGIN_DESCRIPTION = '一个在文章正文末尾添加版权声明的插件。'
PLUGIN_VERSION = '0.2.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 2. 实现钩子注册方法,用于将插件功能绑定到系统钩子
# 2. 实现 register_hooks 方法,专门用于注册钩子
def register_hooks(self):
# 将当前插件的add_copyright_to_content方法注册到文章内容钩子
# 当系统触发ARTICLE_CONTENT_HOOK_NAME钩子时会自动执行该方法
# 在这里将插件的方法注册到指定的钩子上
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_copyright_to_content)
def add_copyright_to_content(self, content, *args, **kwargs):
"""
具体的插件功能实现在文章内容末尾添加版权声明
该方法会被注册到文章内容处理的钩子上接收原始内容并返回处理后的内容
这个方法会被注册到 'the_content' 过滤器钩子上
它接收原始内容并返回添加了版权信息的新内容
"""
# 从关键字参数中获取当前文章对象
article = kwargs.get('article')
# 如果没有文章对象(如非文章场景),直接返回原始内容
if not article:
return content
# 构造版权声明内容,包含文章作者信息
copyright_info = f"\n<hr><p>本文由 {article.author.username} 原创,转载请注明出处。</p>"
# 将版权声明追加到原始内容末尾并返回
return content + copyright_info
# 3. 实例化插件
# 实例化时会自动调用父类BasePlugin的__init__方法
# 父类初始化过程中会调用当前类的register_hooks方法完成钩子注册
# 从而使插件功能生效
# 3. 实例化插件。
# 这会自动调用 BasePlugin.__init__然后 BasePlugin.__init__ 会调用我们上面定义的 register_hooks 方法。
plugin = ArticleCopyrightPlugin()

@ -6,51 +6,43 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ExternalLinksPlugin(BasePlugin):
# 插件元信息定义,用于系统识别和管理插件
PLUGIN_NAME = '外部链接处理器' # 插件名称,显示在插件管理界面
PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。' # 功能描述
PLUGIN_VERSION = '0.1.0' # 插件版本号
PLUGIN_AUTHOR = 'liangliangyy' # 插件作者
PLUGIN_NAME = '外部链接处理器'
PLUGIN_DESCRIPTION = '自动为文章中的外部链接添加 target="_blank" 和 rel="noopener noreferrer" 属性。'
PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 注册钩子:将插件功能绑定到文章内容处理钩子
def register_hooks(self):
# 当系统触发ARTICLE_CONTENT_HOOK_NAME文章内容钩子执行process_external_links方法
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.process_external_links)
# 核心功能:处理文章中的外部链接,添加安全属性
def process_external_links(self, content, *args, **kwargs):
# 导入工具函数,获取当前网站的域名(用于判断是否为外部链接)
from djangoblog.utils import get_current_site
site_domain = get_current_site().domain
# 正则表达式:匹配文章中的<a>标签,捕获 href 属性值及标签前后内容
# 匹配规则:<a ... href="链接地址" .../>,不区分大小写
# 正则表达式查找所有 <a> 标签
link_pattern = re.compile(r'(<a\s+(?:[^>]*?\s+)?href=")([^"]*)(".*?/a>)', re.IGNORECASE)
# 替换函数:对匹配到的<a>标签进行处理
def replacer(match):
# 解构匹配结果group(1)=<a ... href="group(2)=链接URLgroup(3)=".../a>
# match.group(1) 是 <a ... href="
# match.group(2) 是链接 URL
# match.group(3) 是 ">...</a>
href = match.group(2)
full_a_tag = match.group(0)
# 跳过已包含target属性的链接避免重复添加
if 'target=' in full_a_tag.lower():
return full_a_tag
# 如果链接已经有 target 属性,则不处理
if 'target=' in match.group(0).lower():
return match.group(0)
# 解析链接URL提取域名netloc
# 解析链接
parsed_url = urlparse(href)
# 判断是否为外部链接:有域名(非相对路径)且域名不等于当前网站域名
# 如果链接是外部的 (有域名且域名不等于当前网站域名)
if parsed_url.netloc and parsed_url.netloc != site_domain:
# 为外部链接添加 target="_blank"(新窗口打开)和 rel="noopener noreferrer"(安全防护)
# 添加 target 和 rel 属性
return f'{match.group(1)}{href}" target="_blank" rel="noopener noreferrer"{match.group(3)}'
# 内部链接(相对路径或同域名):返回原标签,不做修改
return full_a_tag
# 否则返回原样
return match.group(0)
# 用replacer函数替换content中所有匹配的<a>标签,返回处理后的内容
return link_pattern.sub(replacer, content)
# 实例化插件:自动触发父类初始化,完成钩子注册,使插件生效
plugin = ExternalLinksPlugin()

@ -1,4 +1,4 @@
import math
import math
import re
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
@ -6,44 +6,38 @@ from djangoblog.plugin_manage.hook_constants import ARTICLE_CONTENT_HOOK_NAME
class ReadingTimePlugin(BasePlugin):
# 插件元信息:定义插件的基础标识与说明
PLUGIN_NAME = '阅读时间预测' # 插件名称,用于插件管理界面展示
PLUGIN_DESCRIPTION = '估算文章阅读时间并显示在文章开头。' # 插件功能描述
PLUGIN_VERSION = '0.1.0' # 插件版本号
PLUGIN_AUTHOR = 'liangliangyy' # 插件作者
PLUGIN_NAME = '阅读时间预测'
PLUGIN_DESCRIPTION = '估算文章阅读时间并显示在文章开头。'
PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 钩子注册:将插件功能绑定到文章内容处理钩子
def register_hooks(self):
# 当系统处理文章内容触发ARTICLE_CONTENT_HOOK_NAME钩子执行add_reading_time方法
hooks.register(ARTICLE_CONTENT_HOOK_NAME, self.add_reading_time)
def add_reading_time(self, content, *args, **kwargs):
"""
核心功能计算文章阅读时间将结果添加到内容开头
计算阅读时间并添加到内容开头
"""
# 1. 清理内容移除HTML标签避免标签干扰字数统计再去除首尾空白字符
clean_content = re.sub(r'<[^>]*>', '', content) # 匹配所有<...>格式的HTML标签并删除
clean_content = clean_content.strip() # 去除文本前后的空格、换行等空白字符
# 移除HTML标签和空白字符以获得纯文本
clean_content = re.sub(r'<[^>]*>', '', content)
clean_content = clean_content.strip()
# 2. 统计有效字数:支持中英文混合计数
# 正则匹配规则:匹配单个中文字符([\u4e00-\u9fa5])或连续的非中文字符(视为英文单词,\w+
# 文和英文单词混合计数的一个简单方法
# 匹配中文字符或连续的非中文字符(视为单词)
words = re.findall(r'[\u4e00-\u9fa5]|\w+', clean_content)
word_count = len(words) # 统计匹配到的字符/单词总数
word_count = len(words)
# 3. 计算阅读时间:按平均阅读速度估
reading_speed = 200 # 设定平均阅读速度每分钟200字中英文通用参考值
reading_minutes = math.ceil(word_count / reading_speed) # 向上取整避免0分钟的情况
# 4. 处理边界值若计算结果小于1分钟强制显示为1分钟符合用户认知
# 按平均每分钟200字的速度计
reading_speed = 200
reading_minutes = math.ceil(word_count / reading_speed)
# 如果阅读时间少于1分钟则显示为1分钟
if reading_minutes < 1:
reading_minutes = 1
# 5. 构造阅读时间的HTML片段设置浅灰色文字斜体样式不干扰正文阅读
reading_time_html = f'<p style="color: #888;"><em>预计阅读时间:{reading_minutes} 分钟</em></p>'
# 6. 将阅读时间片段添加到文章内容开头,返回处理后的完整内容
return reading_time_html + content
# 实例化插件自动触发父类BasePlugin的初始化逻辑完成钩子注册使插件在系统中生效
plugin = ReadingTimePlugin()
plugin = ReadingTimePlugin()

@ -1,6 +1,6 @@
import json
from django.utils.html import strip_tags
from django.template.defaultfiltersfilters import truncatewords
from django.template.defaultfilters import truncatewords
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
from blog.models import Article, Category, Tag
@ -8,29 +8,22 @@ from djangoblog.utils import get_blog_setting
class SeoOptimizerPlugin(BasePlugin):
# 插件元信息定义
PLUGIN_NAME = 'SEO 优化器' # 插件名称
PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。' # 功能描述
PLUGIN_VERSION = '0.2.0' # 版本号
PLUGIN_AUTHOR = 'liuangliangyy' # 作者
PLUGIN_NAME = 'SEO 优化器'
PLUGIN_DESCRIPTION = '为文章、页面等提供 SEO 优化,动态生成 meta 标签和 JSON-LD 结构化数据。'
PLUGIN_VERSION = '0.2.0'
PLUGIN_AUTHOR = 'liuangliangyy'
# 注册钩子将SEO生成逻辑绑定到'head_meta'钩子(页面头部元信息钩子)
def register_hooks(self):
hooks.register('head_meta', self.dispatch_seo_generation)
# 生成文章页面的SEO数据
def _get_article_seo_data(self, context, request, blog_setting):
# 从上下文获取文章对象验证是否为Article实例
article = context.get('article')
if not isinstance(article, Article):
return None
# 提取文章描述移除HTML标签截取前150字符
description = strip_tags(article.body)[:150]
# 提取关键词(标签名称组合,默认使用网站关键词)
keywords = ",".join([tag.name for tag in article.tags.all()]) or blog_setting.site_keywords
# 生成Open Graph社交分享meta标签
meta_tags = f'''
<meta property="og:type" content="article"/>
<meta property="og:title" content="{article.title}"/>
@ -41,58 +34,49 @@ class SeoOptimizerPlugin(BasePlugin):
<meta property="article:author" content="{article.author.username}"/>
<meta property="article:section" content="{article.category.name}"/>
'''
# 为每个标签添加meta标签
for tag in article.tags.all():
meta_tags += f'<meta property="article:tag" content="{tag.name}"/>'
meta_tags += f'<meta property="og:site_name" content="{blog_setting.site_name}"/>'
# 生成JSON-LD结构化数据供搜索引擎解析的标准化数据
structured_data = {
"@context": "https://schema.org",
"@type": "Article",
"mainEntityOfPage": {"@type": "WebPage", "@id": request.build_absolute_uri()},
"headline": article.title,
"description": description,
"image": request.build_absolute_uri(article.get_first_image_url()), # 文章首图
"image": request.build_absolute_uri(article.get_first_image_url()),
"datePublished": article.pub_time.isoformat(),
"dateModified": article.last_modify_time.isoformat(),
"author": {"@type": "Person", "name": article.author.username},
"publisher": {"@type": "Organization", "name": blog_setting.site_name}
}
# 若没有图片则移除image字段
if not structured_data.get("image"):
del structured_data["image"]
return {
"title": f"{article.title} | {blog_setting.site_name}", # 页面标题(含网站名)
"title": f"{article.title} | {blog_setting.site_name}",
"description": description,
"keywords": keywords,
"meta_tags": meta_tags,
"json_ld": structured_data
}
# 生成分类页面的SEO数据
def _get_category_seo_data(self, context, request, blog_setting):
# 从上下文获取分类名称
category_name = context.get('tag_name')
if not category_name:
return None
# 查询分类对象
category = Category.objects.filter(name=category_name).first()
if not category:
return None
# 构造页面标题、描述和关键词
title = f"{category.name} | {blog_setting.site_name}"
description = strip_tags(category.name) or blog_setting.site_description
keywords = category.name
# 生成面包屑导航的JSON-LD数据提升页面结构可读性
breadcrumb_items = [
{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')},
{"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()}
]
# BreadcrumbList structured data for category page
breadcrumb_items = [{"@type": "ListItem", "position": 1, "name": "首页", "item": request.build_absolute_uri('/')}]
breadcrumb_items.append({"@type": "ListItem", "position": 2, "name": category.name, "item": request.build_absolute_uri()})
structured_data = {
"@context": "https://schema.org",
@ -104,13 +88,12 @@ class SeoOptimizerPlugin(BasePlugin):
"title": title,
"description": description,
"keywords": keywords,
"meta_tags": "", # 分类页暂不添加额外meta标签
"meta_tags": "",
"json_ld": structured_data
}
# 生成默认页面如首页的SEO数据
def _get_default_seo_data(self, context, request, blog_setting):
# 生成网站级JSON-LD数据含搜索功能描述
# Homepage and other default pages
structured_data = {
"@context": "https://schema.org",
"@type": "WebSite",
@ -118,44 +101,36 @@ class SeoOptimizerPlugin(BasePlugin):
"potentialAction": {
"@type": "SearchAction",
"target": f"{request.build_absolute_uri('/search/')}?q={{search_term_string}}",
"query-input": "required name=search_term_string" # 声明搜索框参数
"query-input": "required name=search_term_string"
}
}
return {
"title": f"{blog_setting.site_name} | {blog_setting.site_description}", # 首页标题
"title": f"{blog_setting.site_name} | {blog_setting.site_description}",
"description": blog_setting.site_description,
"keywords": blog_setting.site_keywords,
"meta_tags": "",
"json_ld": structured_data
}
# 分发SEO数据生成逻辑根据页面类型调用对应生成方法
def dispatch_seo_generation(self, metas, context):
# 从上下文获取请求对象
request = context.get('request')
if not request:
return metas
# 获取当前视图名称(判断页面类型)
view_name = request.resolver_match.view_name
# 获取博客全局设置
blog_setting = get_blog_setting()
# 根据不同页面类型生成对应SEO数据
seo_data = None
if view_name == 'blog:detailbyid': # 文章详情页
if view_name == 'blog:detailbyid':
seo_data = self._get_article_seo_data(context, request, blog_setting)
elif view_name == 'blog:category_detail': # 分类详情页
elif view_name == 'blog:category_detail':
seo_data = self._get_category_seo_data(context, request, blog_setting)
# 若未匹配到特定页面类型使用默认SEO数据
if not seo_data:
seo_data = self._get_default_seo_data(context, request, blog_setting)
# 生成JSON-LD脚本标签
json_ld_script = f'<script type="application/ld+json">{json.dumps(seo_data.get("json_ld", {}), ensure_ascii=False, indent=4)}</script>'
# 组合所有SEO标签并返回
return f"""
<title>{seo_data.get("title", "")}</title>
<meta name="description" content="{seo_data.get("description", "")}">
@ -164,6 +139,4 @@ class SeoOptimizerPlugin(BasePlugin):
{json_ld_script}
"""
# 实例化插件,自动注册钩子使其生效
plugin = SeoOptimizerPlugin()

@ -1,25 +1,18 @@
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage.base_plugin import BasePlugin
from djangoblog.plugin_manage import hooks
class ViewCountPlugin(BasePlugin):
# 插件元信息定义
PLUGIN_NAME = '文章浏览次数统计' # 插件名称,用于管理界面展示
PLUGIN_DESCRIPTION = '统计文章的浏览次数' # 插件功能描述
PLUGIN_VERSION = '0.1.0' # 插件版本号
PLUGIN_AUTHOR = 'liangliangyy' # 插件作者
PLUGIN_NAME = '文章浏览次数统计'
PLUGIN_DESCRIPTION = '统计文章的浏览次数'
PLUGIN_VERSION = '0.1.0'
PLUGIN_AUTHOR = 'liangliangyy'
# 注册钩子:将统计逻辑绑定到文章内容获取后的钩子
def register_hooks(self):
# 当系统触发'after_article_body_get'钩子文章内容加载完成后执行record_view方法
hooks.register('after_article_body_get', self.record_view)
# 核心功能:记录文章浏览次数
def record_view(self, article, *args, **kwargs):
# 调用文章对象的viewed()方法,实现浏览次数+1的逻辑
# 注viewed()方法需在Article模型中预先定义通常包含计数器自增和保存操作
article.viewed()
# 实例化插件:自动触发钩子注册,使插件生效
plugin = ViewCountPlugin()
plugin = ViewCountPlugin()

@ -5,66 +5,28 @@ from djangoblog.utils import cache
class MemcacheStorage(SessionStorage):
"""
基于Memcache的微信WeRoBot会话存储实现用于在微信机器人开发中持久 持久化存储用户会话数据如对话状态临时信息等
"""
def __init__(self, prefix='ws_'):
"""
初始化会话存储
:param prefix: 缓存键的前缀用于区分不同类型的缓存数据默认'ws_'WeChat Session缩写
"""
self.prefix = prefix # 缓存键前缀
self.cache = cache # 引入Django项目中的缓存实例通常配置为Memcache
self.prefix = prefix
self.cache = cache
@property
def is_available(self):
"""
检查缓存存储是否可用
:return: 布尔值True表示可用False表示不可用
"""
test_value = "1"
# 尝试写入测试数据
self.set('check_available', value=test_value)
# 读取测试数据并对比,验证缓存读写功能是否正常
return test_value == self.get('check_available')
value = "1"
self.set('checkavaliable', value=value)
return value == self.get('checkavaliable')
def key_name(self, s):
"""
生成带前缀的缓存键名避免不同类型数据的键冲突
:param s: 原始键名如用户ID
:return: 带前缀的完整键名'ws_user123'
"""
return f'{self.prefix}{s}'
return '{prefix}{s}'.format(prefix=self.prefix, s=s)
def get(self, id):
"""
从缓存中获取会话数据
:param id: 会话ID通常为用户标识如OpenID
:return: 反序列化后的会话数据字典
"""
# 生成带前缀的键名
cache_key = self.key_name(id)
# 从缓存读取数据默认返回空JSON字符串'{}'
session_json = self.cache.get(cache_key) or '{}'
# 将JSON字符串反序列化为Python字典
id = self.key_name(id)
session_json = self.cache.get(id) or '{}'
return json_loads(session_json)
def set(self, id, value):
"""
将会话数据存入缓存
:param id: 会话ID
:param value: 要存储的会话数据字典类型
"""
cache_key = self.key_name(id)
# 将数据字典序列化为JSON字符串后存入缓存
self.cache.set(cache_key, json_dumps(value))
id = self.key_name(id)
self.cache.set(id, json_dumps(value))
def delete(self, id):
"""
从缓存中删除会话数据
:param id: 会话ID
"""
cache_key = self.key_name(id)
# 删除指定键的缓存数据
self.cache.delete(cache_key)
id = self.key_name(id)
self.cache.delete(id)

@ -2,36 +2,18 @@ from django.contrib import admin
# Register your models here.
# 定义Commands模型在Admin后台的管理配置类
class CommandsAdmin(admin.ModelAdmin):
# 配置列表页显示的字段:命令标题、命令内容、命令描述
# 作用在Admin后台查看Commands列表时直接展示这三个核心字段无需点击进入详情页
list_display = ('title', 'command', 'describe')
# 定义EmailSendLog模型在Admin后台的管理配置类
class EmailSendLogAdmin(admin.ModelAdmin):
# 配置列表页显示的字段:邮件标题、收件人、发送结果、创建时间
# 作用:快速预览邮件发送的关键信息,尤其是发送结果(成功/失败)和时间
list_display = ('title', 'emailto', 'send_result', 'creation_time')
# 配置详情页的只读字段:用户无法修改这些字段的值
# 作用:邮件发送记录属于日志类数据,通常不允许手动编辑,确保数据真实性
readonly_fields = (
'title', # 邮件标题
'emailto', # 收件人
'send_result', # 发送结果True/False
'creation_time',# 记录创建时间
'content' # 邮件内容
)
'title',
'emailto',
'send_result',
'creation_time',
'content')
# 重写添加权限方法禁止在Admin后台手动添加邮件发送记录
# 作用:邮件发送记录应由系统自动生成(如发送邮件时触发),避免手动录入虚假日志
def has_add_permission(self, request):
return False
# 注意需补充模型注册代码才能在Admin后台显示示例如下
# from .models import commands, EmailSendLog
# admin.site.register(commands, CommandsAdmin)
# admin.site.register(EmailSendLog, EmailSendLogAdmin)

@ -5,51 +5,23 @@ from blog.models import Article, Category
class BlogApi:
def __init__(self):
# 初始化Haystack的搜索查询集SearchQuerySet
self.searchqueryset = SearchQuerySet()
# 执行空关键词自动查询,初始化查询集(无实际筛选,仅构建基础查询对象)
self.searchqueryset.auto_query('')
# 定义默认最大返回数量最多返回8条数据
self.__max_takecount__ = 8
def search_articles(self, query):
"""
根据关键词搜索文章
:param query: 搜索关键词
:return: 匹配的文章查询集最多8条
"""
# 基于关键词执行自动查询Haystack会处理分词、索引匹配等逻辑
sqs = self.searchqueryset.auto_query(query)
# 加载关联的完整模型数据(避免后续访问关联字段时触发额外数据库查询)
sqs = sqs.load_all()
# 返回前N条结果N=__max_takecount__
return sqs[:self.__max_takecount__]
def get_category_lists(self):
"""
获取所有文章分类
:return: 所有Category模型对象的查询集
"""
# 查询并返回所有分类(无数量限制,因分类通常数量较少)
return Category.objects.all()
def get_category_articles(self, categoryname):
"""
根据分类名称获取该分类下的文章
:param categoryname: 分类名称
:return: 该分类下的文章查询集最多8条无匹配时返回None
"""
# 筛选分类名称匹配的文章通过外键category关联查询
articles = Article.objects.filter(category__name=categoryname)
# 若有匹配文章返回前8条无匹配则返回None
if articles:
return articles[:self.__max_takecount__]
return None
def get_recent_articles(self):
"""
获取最新发布的文章
:return: 最新的文章查询集最多8条
"""
# 查询所有文章默认按主键降序通常主键自增等价于按发布时间降序返回前8条
return Article.objects.all()[:self.__max_takecount__]
return Article.objects.all()[:self.__max_takecount__]

@ -3,100 +3,62 @@ import os
import openai
# 注意原代码导入可能存在拼写问题推测应为从servermanager.models导入Commands模型首字母通常大写
from servermanager.models import commands
# 初始化日志记录器,用于记录操作日志和错误信息
logger = logging.getLogger(__name__)
# 从环境变量获取OpenAI API密钥并配置
openai.api_key = os.environ.get('OPENAI_API_KEY')
# 若环境变量中配置了HTTP代理为OpenAI客户端设置代理
if os.environ.get('HTTP_PROXY'):
openai.proxy = os.environ.get('HTTP_PROXY')
class ChatGPT:
"""封装OpenAI GPT-3.5-turbo模型的对话功能"""
@staticmethod
def chat(prompt):
"""
调用GPT-3.5-turbo模型生成对话响应
:param prompt: 用户输入的提示词
:return: 模型生成的响应内容出错时返回错误提示
"""
try:
# 调用OpenAI的ChatCompletion接口使用gpt-3.5-turbo模型
completion = openai.ChatCompletion.create(
model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}] # 构造用户角色的对话消息
)
# 提取并返回模型生成的内容取第一个选项的message内容
completion = openai.ChatCompletion.create(model="gpt-3.5-turbo",
messages=[{"role": "user", "content": prompt}])
return completion.choices[0].message.content
except Exception as e:
# 记录异常信息到日志
logger.error(e)
# 返回友好的错误提示给用户
return "服务器出错了"
class CommandHandler:
"""处理命令查询、执行与帮助信息展示的类"""
def __init__(self):
"""初始化加载所有已配置的命令从commands模型查询"""
self.commands = commands.objects.all()
def run(self, title):
"""
根据命令标题查找并执行对应的命令
:param title: 用户输入的命令标题不区分大小写
:return: 命令执行结果未找到命令时返回帮助提示
运行命令
:param title: 命令
:return: 返回命令执行结果
"""
# 过滤出标题(不区分大小写)匹配的命令
cmd = list(
filter(
lambda x: x.title.upper() == title.upper(),
self.commands
)
)
# 若找到匹配命令,执行命令;否则返回未找到提示
self.commands))
if cmd:
return self.__run_command__(cmd[0].command)
else:
return "未找到相关命令请输入hepme获得帮助。" # 注意:推测"hepme"应为"help"的笔误
return "未找到相关命令请输入hepme获得帮助。"
def __run_command__(self, cmd):
"""
私有方法执行系统命令并返回结果
:param cmd: 要执行的系统命令字符串
:return: 命令执行输出结果出错时返回错误提示
"""
try:
# 使用os.popen执行命令并读取输出os.popen安全性较低不建议执行用户输入的未知命令
res = os.popen(cmd).read()
return res
except BaseException:
return '命令执行出错!'
def get_help(self):
"""
生成所有命令的帮助信息
:return: 包含命令标题和描述的字符串每行一条命令
"""
rsp = ''
# 遍历所有命令,拼接"命令标题:命令描述"格式的帮助信息
for cmd in self.commands:
rsp += '{c}:{d}\n'.format(c=cmd.title, d=cmd.describe)
return rsp
# 主程序入口测试ChatGPT类功能
if __name__ == '__main__':
# 实例化ChatGPT对象
chatbot = ChatGPT()
# 定义测试提示词生成1000字关于AI的论文
prompt = "写一篇1000字关于AI的论文"
# 调用chat方法并打印结果原代码缺少右括号此处已补充
print(chatbot.chat(prompt))

@ -2,17 +2,4 @@ from django.apps import AppConfig
class ServermanagerConfig(AppConfig):
# 定义Django应用“servermanager”的配置类用于管理应用的元数据和初始化行为
# 指定应用的唯一标识名称,必须与应用目录名一致
# 项目中通过该名称引用此应用如在INSTALLED_APPS注册、迁移命令指定应用等
name = 'servermanager'
# 可选扩展配置(当前未设置,可根据需求添加):
# 1. 应用的可读名称用于Admin后台等界面显示默认显示“servermanager”
# verbose_name = '服务器管理'
# 2. 模型默认主键字段类型Django 3.2+推荐显式指定,避免版本兼容问题)
# default_auto_field = 'django.db.models.BigAutoField'
# 3. 应用就绪时的初始化操作(如注册信号、加载扩展功能等)
# def ready(self):
# import servermanager.signals # 导入信号模块

@ -1,73 +1,45 @@
# 由 Django 4.1.7 于 2023年3月2日 07:14 自动生成
# 该文件是Django的数据迁移文件用于初始化创建两个核心数据模型的数据库表结构
# Generated by Django 4.1.7 on 2023-03-02 07:14
from django.db import migrations, models
class Migration(migrations.Migration):
# 标记为初始迁移(首次创建模型时使用,无前置迁移依赖)
initial = True
# 依赖的其他迁移文件:初始迁移无依赖,为空列表
dependencies = [
]
# 迁移操作列表:包含两个模型的创建操作
operations = [
# 1. 创建 "commands" 模型(用于存储预设系统命令)
migrations.CreateModel(
name='commands',
fields=[
# 自增主键字段:大整数类型,自动创建,作为主键
('id', models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID'
)),
# 命令标题字段字符串类型最大长度300用于标识命令如"查看日志"
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=300, verbose_name='命令标题')),
# 命令内容字段字符串类型最大长度2000存储实际执行的系统命令如"ls /var/log"
('command', models.CharField(max_length=2000, verbose_name='命令')),
# 命令描述字段字符串类型最大长度300说明命令功能如"查看系统日志目录内容"
('describe', models.CharField(max_length=300, verbose_name='命令描述')),
# 创建时间字段:自动记录模型创建时的时间,后续不自动更新
('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
# 修改时间字段:自动记录模型每次更新时的时间
('last_mod_time', models.DateTimeField(auto_now=True, verbose_name='修改时间')),
],
# 模型元配置
options={
'verbose_name': '命令', # 模型单数显示名称如Admin后台中
'verbose_name_plural': '命令', # 模型复数显示名称(与单数一致,避免中文复数歧义)
'verbose_name': '命令',
'verbose_name_plural': '命令',
},
),
# 2. 创建 "EmailSendLog" 模型(用于记录邮件发送历史)
migrations.CreateModel(
name='EmailSendLog',
fields=[
# 自增主键字段:大整数类型,自动创建,作为主键
('id', models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID'
)),
# 收件人字段字符串类型最大长度300存储收件人邮箱可多个用逗号分隔
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('emailto', models.CharField(max_length=300, verbose_name='收件人')),
# 邮件标题字段字符串类型最大长度2000存储邮件主题
('title', models.CharField(max_length=2000, verbose_name='邮件标题')),
# 邮件内容字段:文本类型,存储邮件正文(支持长文本,无长度限制)
('content', models.TextField(verbose_name='邮件内容')),
# 发送结果字段布尔类型默认值False未成功标记邮件是否发送成功
('send_result', models.BooleanField(default=False, verbose_name='结果')),
# 创建时间字段:自动记录邮件发送记录创建时的时间
('created_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
],
# 模型元配置
options={
'verbose_name': '邮件发送log', # 模型单数显示名称
'verbose_name_plural': '邮件发送log', # 模型复数显示名称
'ordering': ['-created_time'], # 默认排序:按创建时间降序(最新记录在前)
'verbose_name': '邮件发送log',
'verbose_name_plural': '邮件发送log',
'ordering': ['-created_time'],
},
),
]

@ -1,41 +1,32 @@
# 由 Django 4.2.5 于 2023年9月6日 13:19 自动生成
# 该文件是Django的数据迁移文件用于修改已存在的模型字段名和配置属于数据库结构的迭代更新
# Generated by Django 4.2.5 on 2023-09-06 13:19
from django.db import migrations
class Migration(migrations.Migration):
# 依赖的迁移文件:依赖于'servermanager'应用下的初始迁移0001_initial
# 意味着执行当前迁移前必须先完成0001_initial迁移即创建commands和EmailSendLog模型的表结构
dependencies = [
('servermanager', '0001_initial'),
]
# 迁移操作列表:包含模型配置修改和字段重命名两类操作
operations = [
# 1. 修改EmailSendLog模型的配置选项
migrations.AlterModelOptions(
name='emailsendlog', # 目标模型名称
# 更新后的模型选项:
# - 排序规则改为按'creation_time'降序(原先是按'created_time'降序,因字段重命名同步调整)
# - 单数和复数显示名称保持不变(仍为"邮件发送log"
name='emailsendlog',
options={'ordering': ['-creation_time'], 'verbose_name': '邮件发送log', 'verbose_name_plural': '邮件发送log'},
),
# 2. 重命名commands模型的"created_time"字段为"creation_time"
migrations.RenameField(
model_name='commands', # 目标模型名称
old_name='created_time', # 原字段名
new_name='creation_time', # 新字段名
model_name='commands',
old_name='created_time',
new_name='creation_time',
),
# 3. 重命名commands模型的"last_mod_time"字段为"last_modify_time"
migrations.RenameField(
model_name='commands', # 目标模型名称
old_name='last_mod_time', # 原字段名(简写形式)
new_name='last_modify_time', # 新字段名(完整形式,统一命名风格)
model_name='commands',
old_name='last_mod_time',
new_name='last_modify_time',
),
# 4. 重命名EmailSendLog模型的"created_time"字段为"creation_time"
migrations.RenameField(
model_name='emailsendlog', # 目标模型名称
old_name='created_time', # 原字段名
new_name='creation_time', # 新字段名
model_name='emailsendlog',
old_name='created_time',
new_name='creation_time',
),
]
]

@ -3,51 +3,31 @@ from django.db import models
# Create your models here.
class commands(models.Model):
"""
存储预设系统命令的模型用于管理可执行的系统指令
"""
# 命令标题:用于标识命令(如"查看系统状态"字符串类型最大长度300
title = models.CharField('命令标题', max_length=300)
# 命令内容:实际执行的系统命令字符串(如"df -h"最大长度2000
command = models.CharField('命令', max_length=2000)
# 命令描述:说明命令的功能和用途,方便管理员理解
describe = models.CharField('命令描述', max_length=300)
# 创建时间:自动记录命令添加时间,创建时自动填充,后续不更新
creation_time = models.DateTimeField('创建时间', auto_now_add=True)
# 修改时间:自动记录命令最后一次修改时间,每次保存时更新
last_modify_time = models.DateTimeField('修改时间', auto_now=True)
# 定义模型实例的字符串表示形式,返回命令标题
def __str__(self):
return self.title
# 模型元数据配置
class Meta:
verbose_name = '命令' # 模型的单数显示名称
verbose_name_plural = verbose_name # 复数显示名称与单数一致
verbose_name = '命令'
verbose_name_plural = verbose_name
class EmailSendLog(models.Model):
"""
记录邮件发送历史的日志模型用于追踪邮件发送状态
"""
# 收件人存储收件人邮箱地址多个邮箱用逗号分隔最大长度300
emailto = models.CharField('收件人', max_length=300)
# 邮件标题存储邮件的主题最大长度2000
title = models.CharField('邮件标题', max_length=2000)
# 邮件内容:存储邮件正文,文本类型(无长度限制)
content = models.TextField('邮件内容')
# 发送结果布尔值标记邮件是否发送成功默认False未成功
send_result = models.BooleanField('结果', default=False)
# 创建时间:自动记录日志创建时间(即邮件发送时间)
creation_time = models.DateTimeField('创建时间', auto_now_add=True)
# 定义模型实例的字符串表示形式,返回邮件标题
def __str__(self):
return self.title
# 模型元数据配置
class Meta:
verbose_name = '邮件发送log' # 模型的单数显示名称
verbose_name_plural = verbose_name # 复数显示名称与单数一致
ordering = ['-creation_time'] # 默认按创建时间降序排序(最新记录在前)
verbose_name = '邮件发送log'
verbose_name_plural = verbose_name
ordering = ['-creation_time']

@ -13,222 +13,175 @@ from servermanager.api.blogapi import BlogApi
from servermanager.api.commonapi import ChatGPT, CommandHandler
from .MemcacheStorage import MemcacheStorage
# 初始化微信机器人WeRoBot
# 从环境变量获取Token默认值为'lylinux';启用会话功能以保存用户状态
robot = WeRoBot(
token=os.environ.get('DJANGO_WEROBOT_TOKEN') or 'lylinux',
enable_session=True
)
# 配置会话存储优先使用Memcache失败则降级为文件存储
robot = WeRoBot(token=os.environ.get('DJANGO_WEROBOT_TOKEN')
or 'lylinux', enable_session=True)
memstorage = MemcacheStorage()
if memstorage.is_available: # 检查Memcache是否可用
if memstorage.is_available:
robot.config['SESSION_STORAGE'] = memstorage
else:
# 清理旧的文件存储,避免残留数据
session_file = os.path.join(settings.BASE_DIR, 'werobot_session')
if os.path.exists(session_file):
os.remove(session_file)
# 使用文件存储会话数据适合开发或Memcache不可用场景
if os.path.exists(os.path.join(settings.BASE_DIR, 'werobot_session')):
os.remove(os.path.join(settings.BASE_DIR, 'werobot_session'))
robot.config['SESSION_STORAGE'] = FileStorage(filename='werobot_session')
# 初始化依赖组件
blogapi = BlogApi() # 博客数据接口(文章搜索、分类查询等)
cmd_handler = CommandHandler() # 系统命令处理(执行预设命令)
logger = logging.getLogger(__name__) # 日志记录器
blogapi = BlogApi()
cmd_handler = CommandHandler()
logger = logging.getLogger(__name__)
def convert_to_article_reply(articles, message):
"""
将文章列表转换为微信公众号的图文消息回复格式
:param articles: 文章对象列表
:param message: 微信接收的消息对象用于构建回复
:return: ArticlesReply 图文回复对象
"""
reply = ArticlesReply(message=message)
# 导入自定义模板标签,用于截取文章内容作为描述
from blog.templatetags.blog_tags import truncatechars_content
for post in articles:
# 正则提取文章正文中的第一张图片(作为图文消息封面)
imgs = re.findall(r'(?:http\:|https\:)?\/\/.*\.(?:png|jpg)', post.body)
imgurl = imgs[0] if imgs else '' # 无图片则用空字符串
# 构建单条图文信息
imgurl = ''
if imgs:
imgurl = imgs[0]
article = Article(
title=post.title, # 文章标题
description=truncatechars_content(post.body), # 截取内容作为描述
img=imgurl, # 封面图片URL
url=post.get_full_url() # 文章详情页URL
title=post.title,
description=truncatechars_content(post.body),
img=imgurl,
url=post.get_full_url()
)
reply.add_article(article) # 添加到图文回复中
reply.add_article(article)
return reply
# ------------------------------ 微信消息处理过滤器 ------------------------------
@robot.filter(re.compile(r"^\?.*")) # 匹配以"?"开头的消息(文章搜索)
@robot.filter(re.compile(r"^\?.*"))
def search(message, session):
"""处理文章搜索:输入“?关键词”返回匹配的图文消息"""
searchstr = message.content.replace('?', '') # 提取关键词(去除开头的"?"
result = blogapi.search_articles(searchstr) # 调用博客接口搜索文章
s = message.content
searchstr = str(s).replace('?', '')
result = blogapi.search_articles(searchstr)
if result:
# 将搜索结果SearchQuerySet转换为文章对象列表
articles = [x.object for x in result]
return convert_to_article_reply(articles, message) # 返回图文回复
articles = list(map(lambda x: x.object, result))
reply = convert_to_article_reply(articles, message)
return reply
else:
return '没有找到相关文章。' # 无结果提示
return '没有找到相关文章。'
@robot.filter(re.compile(r'^category\s*$', re.I)) # 匹配"category"(不区分大小写)
@robot.filter(re.compile(r'^category\s*$', re.I))
def category(message, session):
"""获取所有文章分类输入“category”返回分类列表"""
categorys = blogapi.get_category_lists() # 调用接口获取所有分类
# 拼接分类名称为字符串如“Python,Java,前端”)
category_names = ','.join([x.name for x in categorys])
return f'所有文章分类目录:{category_names}'
categorys = blogapi.get_category_lists()
content = ','.join(map(lambda x: x.name, categorys))
return '所有文章分类目录:' + content
@robot.filter(re.compile(r'^recent\s*$', re.I)) # 匹配"recent"(不区分大小写)
@robot.filter(re.compile(r'^recent\s*$', re.I))
def recents(message, session):
"""获取最新文章输入“recent”返回最新文章的图文消息"""
articles = blogapi.get_recent_articles() # 调用接口获取最新文章
articles = blogapi.get_recent_articles()
if articles:
return convert_to_article_reply(articles, message) # 返回图文回复
reply = convert_to_article_reply(articles, message)
return reply
else:
return "暂时还没有文章" # 无文章提示
return "暂时还没有文章"
@robot.filter(re.compile('^help$', re.I)) # 匹配"help"(不区分大小写)
@robot.filter(re.compile('^help$', re.I))
def help(message, session):
"""获取帮助输入“help”返回功能说明"""
return '''欢迎关注!
默认会与图灵机器人聊天~~
你可以通过下面这些命令来获得信息
1. ?关键字 搜索文章?python
2. category 获得文章分类目录
3. category-*** 获得该分类下的文章如category-python
4. recent 获得最新文章
5. help 获得帮助
6. weather:城市 获得天气如weather:西安
7. idcard:号码 获得身份证信息如idcard:61048119xxxxxxxxxx
8. music:歌名 音乐搜索如music:阴天快乐
PS: 以上标点符号不支持中文标点~~
'''
@robot.filter(re.compile(r'^weather\:.*$', re.I)) # 匹配"weather:城市"格式
默认会与图灵机器人聊天~~
你可以通过下面这些命令来获得信息
?关键字搜索文章.
?python.
category获得文章分类目录及文章数.
category-***获得该分类目录文章
如category-python
recent获得最新文章
help获得帮助.
weather:获得天气
如weather:西安
idcard:获得身份证信息
如idcard:61048119xxxxxxxxxx
music:音乐搜索
如music:阴天快乐
PS:以上标点符号都不支持中文标点~~
'''
@robot.filter(re.compile(r'^weather\:.*$', re.I))
def weather(message, session):
"""天气查询(待开发):返回“建设中”提示"""
return "建设中..."
@robot.filter(re.compile(r'^idcard\:.*$', re.I)) # 匹配"idcard:号码"格式
@robot.filter(re.compile(r'^idcard\:.*$', re.I))
def idcard(message, session):
"""身份证信息查询(待开发):返回“建设中”提示"""
return "建设中..."
# ------------------------------ 默认消息处理器 ------------------------------
@robot.handler # 未被上述过滤器匹配的消息,进入此默认处理器
@robot.handler
def echo(message, session):
"""默认消息处理转发给MessageHandler处理用户状态管理、管理员命令等"""
handler = MessageHandler(message, session)
return handler.handler()
# ------------------------------ 用户状态与命令管理 ------------------------------
class MessageHandler:
"""处理用户消息的核心类:管理用户状态(普通用户/管理员)、执行管理员命令"""
def __init__(self, message, session):
self.message = message # 微信消息对象
self.session = session # 会话存储(保存用户状态)
self.userid = message.source # 用户唯一标识微信OpenID
# 从会话中加载用户状态用jsonpickle反序列化
userid = message.source
self.message = message
self.session = session
self.userid = userid
try:
user_info_json = session[self.userid]
self.userinfo = jsonpickle.decode(user_info_json)
except Exception:
# 会话中无用户状态,初始化新的用户信息
self.userinfo = WxUserInfo()
info = session[userid]
self.userinfo = jsonpickle.decode(info)
except Exception as e:
userinfo = WxUserInfo()
self.userinfo = userinfo
@property
def is_admin(self):
"""判断当前用户是否处于“管理员模式”"""
return self.userinfo.isAdmin
@property
def is_password_set(self):
"""判断管理员是否已通过密码验证"""
return self.userinfo.isPasswordSet
def save_session(self):
"""将用户状态序列化后保存到会话"""
user_info_json = jsonpickle.encode(self.userinfo)
self.session[self.userid] = user_info_json
info = jsonpickle.encode(self.userinfo)
self.session[self.userid] = info
def handler(self):
"""核心处理逻辑:根据用户状态分发消息处理"""
user_input = self.message.content # 用户输入的内容
info = self.message.content
# 1. 管理员退出已验证的管理员输入“EXIT”退出管理员模式
if self.is_admin and self.is_password_set and user_input.upper() == 'EXIT':
self.userinfo = WxUserInfo() # 重置用户状态为普通用户
if self.userinfo.isAdmin and info.upper() == 'EXIT':
self.userinfo = WxUserInfo()
self.save_session()
return "退出成功"
# 2. 进入管理员模式普通用户输入“ADMIN”触发管理员验证流程
if user_input.upper() == 'ADMIN' and not self.is_admin:
self.userinfo.isAdmin = True # 标记为管理员模式
if info.upper() == 'ADMIN':
self.userinfo.isAdmin = True
self.save_session()
return "输入管理员密码"
# 3. 管理员密码验证:处于管理员模式但未验证密码
if self.is_admin and not self.is_password_set:
# 获取正确密码(测试环境用'123'正式环境用settings中的WXADMIN
correct_passwd = '123' if settings.TESTING else settings.WXADMIN
# 密码加密比对两次SHA256加密避免明文传输风险
input_passwd = get_sha256(get_sha256(user_input)).upper()
if input_passwd == correct_passwd.upper():
self.userinfo.isPasswordSet = True # 标记为已验证
if self.userinfo.isAdmin and not self.userinfo.isPasswordSet:
passwd = settings.WXADMIN
if settings.TESTING:
passwd = '123'
if passwd.upper() == get_sha256(get_sha256(info)).upper():
self.userinfo.isPasswordSet = True
self.save_session()
return "验证通过,请输入命令或执行代码:输入helpme获得帮助"
return "验证通过,请输入命令或者要执行的命令代码:输入helpme获得帮助"
else:
# 密码错误次数限制3次后重置管理员模式
self.userinfo.Count += 1
if self.userinfo.Count >= 3:
self.userinfo = WxUserInfo() # 重置为普通用户
self.userinfo = WxUserInfo()
self.save_session()
return "超过验证次数,已退出管理员模式"
return "超过验证次数"
self.userinfo.Count += 1
self.save_session()
return f"验证失败(剩余{3 - self.userinfo.Count}次),请重新输入管理员密码:"
# 4. 管理员命令执行:已验证的管理员输入命令
if self.is_admin and self.is_password_set:
# 确认执行命令若之前已输入命令且当前输入“Y”则执行
if self.userinfo.Command != '' and user_input.upper() == 'Y':
return "验证失败,请重新输入管理员密码:"
if self.userinfo.isAdmin and self.userinfo.isPasswordSet:
if self.userinfo.Command != '' and info.upper() == 'Y':
return cmd_handler.run(self.userinfo.Command)
# 查看帮助输入“helpme”返回命令列表
elif user_input.upper() == 'HELPME':
return cmd_handler.get_help()
# 暂存命令:输入新命令,提示确认
else:
self.userinfo.Command = user_input
if info.upper() == 'HELPME':
return cmd_handler.get_help()
self.userinfo.Command = info
self.save_session()
return f"确认执行命令:{user_input}输入Y执行"
return "确认执行: " + info + " 命令?"
# 5. 普通用户默认转发给ChatGPT生成回复
return ChatGPT.chat(user_input)
return ChatGPT.chat(info)
class WxUserInfo:
"""用户状态类:存储用户是否为管理员、密码验证状态、命令暂存等信息"""
class WxUserInfo():
def __init__(self):
self.isAdmin = False # 是否处于管理员模式(默认否)
self.isPasswordSet = False # 是否已通过密码验证(默认否)
self.Count = 0 # 密码错误次数默认0
self.Command = '' # 暂存的管理员命令(默认空)
self.isAdmin = False
self.isPasswordSet = False
self.Count = 0
self.Command = ''

@ -1,6 +1,6 @@
from django.test import Client, RequestFactory, TestCase
from django.utils import timezone
from werobot.messages.messages.messages import TextMessage
from werobot.messages.messages import TextMessage
from accounts.models import BlogUser
from blog.models import Category, Article
@ -12,100 +12,68 @@ from .robot import search, category, recents
# Create your tests here.
class ServerManagerTest(TestCase):
"""测试servermanager应用的核心功能包括ChatGPT调用、文章查询、命令执行和消息处理逻辑"""
def setUp(self):
"""测试前的初始化工作:创建测试客户端、请求工厂,为后续测试准备基础数据"""
self.client = Client() # Django测试客户端用于模拟HTTP请求
self.factory = RequestFactory() # 请求工厂,用于构建测试请求对象
self.client = Client()
self.factory = RequestFactory()
def test_chat_gpt(self):
"""测试ChatGPT接口调用是否正常返回结果"""
content = ChatGPT.chat("你好") # 调用ChatGPT发送简单问候
self.assertIsNotNone(content) # 断言返回结果不为空(验证接口可用)
content = ChatGPT.chat("你好")
self.assertIsNotNone(content)
def test_validate_comment(self):
"""综合测试:文章搜索、分类查询、命令执行、消息处理器等功能"""
# 1. 创建测试用户(超级管理员)并登录
user = BlogUser.objects.create_superuser(
email="liangliangyy1@gmail.com",
username="liangliangyy1",
password="liangliangyy1"
)
self.client.login(username='liangliangyy1', password='liangliangyy1') # 模拟登录
# 2. 创建测试分类和文章(用于后续搜索、分类查询测试)
test_category = Category(name="categoryccc")
test_category.save() # 保存分类到数据库
test_article = Article(
title="nicetitleccc",
body="nicecontentccc",
author=user,
category=test_category,
type='a', # 假设'a'表示文章类型
status='p' # 假设'p'表示已发布
)
test_article.save() # 保存文章到数据库
# 3. 测试文章搜索功能
# 创建模拟文本消息(内容为"nice",用于搜索)
search_msg = TextMessage([])
search_msg.content = "nice"
search_rsp = search(search_msg, None) # 调用搜索函数
# (此处未断言搜索结果,因实际结果依赖搜索配置,仅验证无异常)
# 4. 测试分类查询功能
category_rsp = category(None, None) # 调用分类查询函数
self.assertIsNotNone(category_rsp) # 断言返回结果不为空
# 5. 测试最新文章查询功能
recents_rsp = recents(None, None) # 调用最新文章查询函数
self.assertTrue(recents_rsp != '暂时还没有文章') # 断言返回结果不是"无文章"提示
# 6. 测试命令执行功能
# 创建测试命令
test_cmd = commands(
title="test",
command="ls", # 简单的系统命令(列出目录内容)
describe="test"
)
test_cmd.save() # 保存命令到数据库
cmd_handler = CommandHandler()
cmd_rsp = cmd_handler.run('test') # 执行"test"命令
self.assertIsNotNone(cmd_rsp) # 断言命令执行结果不为空
# 7. 测试消息处理器MessageHandler的各种场景
# 模拟用户消息(来源为'u',内容为'test'
msg = TextMessage([])
msg.source = 'u'
msg.content = 'test'
msg_handler = MessageHandler(msg, {}) # 初始化消息处理器(空会话)
# 7.1 测试普通消息处理(非管理员模式)
msg_handler.handler() # 处理内容为'test'的消息
# 7.2 测试命令确认(假设已处于管理员模式,输入'y'确认执行)
msg.content = 'y'
msg_handler.handler()
# 7.3 测试待开发功能(身份证查询)
msg.content = 'idcard:12321233'
msg_handler.handler()
# 7.4 测试待开发功能(天气查询)
msg.content = 'weather:上海'
msg_handler.handler()
# 7.5 测试进入管理员模式(输入'admin'
msg.content = 'admin'
msg_handler.handler()
# 7.6 测试管理员密码验证(输入'123'
msg.content = '123'
msg_handler.handler()
# 7.7 测试退出管理员模式(输入'exit'
msg.content = 'exit'
msg_handler.handler()
password="liangliangyy1")
self.client.login(username='liangliangyy1', password='liangliangyy1')
c = Category()
c.name = "categoryccc"
c.save()
article = Article()
article.title = "nicetitleccc"
article.body = "nicecontentccc"
article.author = user
article.category = c
article.type = 'a'
article.status = 'p'
article.save()
s = TextMessage([])
s.content = "nice"
rsp = search(s, None)
rsp = category(None, None)
self.assertIsNotNone(rsp)
rsp = recents(None, None)
self.assertTrue(rsp != '暂时还没有文章')
cmd = commands()
cmd.title = "test"
cmd.command = "ls"
cmd.describe = "test"
cmd.save()
cmdhandler = CommandHandler()
rsp = cmdhandler.run('test')
self.assertIsNotNone(rsp)
s.source = 'u'
s.content = 'test'
msghandler = MessageHandler(s, {})
# msghandler.userinfo.isPasswordSet = True
# msghandler.userinfo.isAdmin = True
msghandler.handler()
s.content = 'y'
msghandler.handler()
s.content = 'idcard:12321233'
msghandler.handler()
s.content = 'weather:上海'
msghandler.handler()
s.content = 'admin'
msghandler.handler()
s.content = '123'
msghandler.handler()
s.content = 'exit'
msghandler.handler()

@ -1,15 +1,10 @@
from django.urls import path
from werobot.contrib.django import make_view
# 导入微信机器人实例已在robot.py中初始化配置
from .robot import robot
# 定义应用的命名空间用于在模板和反向解析中标识该应用的URL
app_name = "servermanager"
# URL路由配置将微信机器人视图绑定到指定路径
urlpatterns = [
# 将robot实例通过make_view转换为Django视图绑定到'/robot'路径
# 微信公众号服务器会将用户消息POST到该路径由robot实例处理
path(r'robot', make_view(robot)),
]

Loading…
Cancel
Save