Django大结局
一、forms组件源码
"""
切入点:
form_obj.is_valid()
"""
def is_valid(self):
"""
Returns True if the form has no errors. Otherwise, False. If errors are
being ignored, returns False.
"""
return self.is_bound and not self.errors
# 如果is_valid要返回True的话 那么self.is_bound要为True self.errors要为Flase
self.is_bound = data is not None or files is not None # 只要你传值了肯定为True
@property
def errors(self):
"Returns an ErrorDict for the data provided for the form"
if self._errors is None:
self.full_clean()
return self._errors
# forms组件所有的功能基本都出自于该方法
def full_clean(self):
self._clean_fields() # 校验字段 + 局部钩子
self._clean_form() # 全局钩子
self._post_clean()
二、cookie与session
"""
发展史
1.网站都没有保存用户功能的需求 所有用户访问返回的结果都是一样的
eg:新闻、博客、文章...
2.出现了一些需要保存用户信息的网站
eg:淘宝、支付宝、京东...
以登陆功能为例:如果不保存用户登陆状态 也就意味着用户每次访问网站都需要重复的输入用户名和密码(你觉得这样的网站你还想用吗?)
当用户第一次登陆成功之后 将用户的用户名密码返回给用户浏览器 让用户浏览器保存在本地,之后访问网站的时候浏览器自动将保存在浏览器上的用户名和密码发送给服务端,服务端获取之后自动验证
早起这种方式具有非常大的安全隐患
优化:
当用户登陆成功之后,服务端产生一个随机字符串(在服务端保存数据,用kv键值对的形式),交由客户端浏览器保存
随机字符串1:用户1相关信息
随机字符串2:用户2相关信息
随机字符串3:用户3相关信息
之后访问服务端的时候,都带着该随机字符串,服务端去数据库中比对是否有对应的随机字符串从而获取到对应的用户信息
但是如果你拿到了截获到了该随机字符串,那么你就可以冒充当前用户 其实还是有安全隐患的
你要知道在web领域没有绝对的安全也没有绝对的不安全
"""
cookie
服务端保存在客户端浏览器上的信息都可以称之为cookie
它的表现形式一般都是k:v键值对(可以有多个)
session
数据是保存在服务端的并且它的表现形式一般也是k:v键值对(可以有多个)
下述内容暂时了解即可 先给我搞明白最简单的cookie与session使用再说话!
token
session虽然数据是保存在服务端的 但是禁不住数据量大
服务端不再保存数据
登陆成功之后 将一段用户信息进行加密处理(加密算法之后你公司开发知道)
将加密之后的结果拼接在信息后面 整体返回给浏览器保存
浏览器下次访问的时候带着该信息 服务端自动切去前面一段信息再次使用自己的加密算法
跟浏览器尾部的密文进行比对
jwt认证
三段信息
(后期会讲 结合django一起使用)
总结:
1.cookie就是保存在客户端浏览器上的信息
2.session就是保存在服务端上的信息
3.session是基于cookie工作的(其实大部分的保存用户状态的操作都需要使用到cookie)
三、Cookie操作
# 虽然cookie是服务端告诉客户端浏览器需要保存内容
# 但是客户端浏览器可以选择拒绝保存 如果禁止了 那么 只要是需要记录用户状态的网站登陆功能都无法使用了
# 视图函数的返回值
return HttpResponse()
return render()
return redirect()
obj1 = HttpResponse()
# 操作cookie
return obj1
obj2 = render()
# 操作cookie
return obj2
obj3 = redirect()
# 操作cookie
return obj3
# 如果你想要操作cookie,你就不得不利用obj对象
"""
设置cookie
obj.set_cookie(key,value)
获取cookie
request.COOKIES.get(key)
在设置cookie的时候可以添加一个超时时间
obj.set_cookie('username', 'jason666',max_age=3,expires=3)
max_age
expires
两者都是设置超时时间的 并且都是以秒为单位
需要注意的是 针对IE浏览器需要使用expires
主动删除cookie(注销功能)
"""
# 我们完成一个真正的登陆功能
# 校验用户是否登陆的装饰器
"""
用户如果在没有登陆的情况下想访问一个需要登陆的页面
那么先跳转到登陆页面 当用户输入正确的用户名和密码之后
应该跳转到用户之前想要访问的页面去 而不是直接写死
"""
def login_auth(func):
def inner(request,*args,**kwargs):
# print(request.path_info)
# print(request.get_full_path()) # 能够获取到用户上一次想要访问的url
target_url = request.get_full_path()
if request.COOKIES.get('username'):
return func(request,*args,**kwargs)
else:
return redirect('/login/?next=%s'%target_url)
return inner
def login(request):
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
if username == 'jason' and password == '123':
# 获取用户上一次想要访问的url
target_url = request.GET.get('next') # 这个结果可能是None
if target_url:
obj = redirect(target_url)
else:
# 保存用户登陆状态
obj = redirect('/home/')
# 让浏览器记录cookie数据
obj.set_cookie('username', 'jason666')
"""
浏览器不单单会帮你存
而且后面每次访问你的时候还会带着它过来
"""
# 跳转到一个需要用户登陆之后才能看的页面
return obj
return render(request,'login.html')
@login_auth
def home(request):
# 获取cookie信息 判断你有没有
# if request.COOKIES.get('username') == 'jason666':
# return HttpResponse("我是home页面,只有登陆的用户才能进来哟~")
# # 没有登陆应该跳转到登陆页面
# return redirect('/login/')
return HttpResponse("我是home页面,只有登陆的用户才能进来哟~")
四、session操作
"""
session数据是保存在服务端的(存?),给客户端返回的是一个随机字符串
sessionid:随机字符串
1.在默认情况下操作session的时候需要django默认的一张django_session表
数据库迁移命令
django会自己创建很多表 django_session就是其中的一张
django默认session的过期时间是14天
但是你也可以人为的修改它
设置session
request.session['key'] = value
获取session
request.session.get('key')
设置过期时间
request.session.set_expiry()
括号内可以放四种类型的参数
1.整数 多少秒
2.日期对象 到指定日期就失效
3.0 一旦当前浏览器窗口关闭立刻失效
4.不写 失效时间就取决于django内部全局session默认的失效时间
清除session
request.session.delete() # 只删服务端的 客户端的不删
request.session.flush() # 浏览器和服务端都清空(推荐使用)
session是保存在服务端的 但是session的保存位置可以有多种选择
1.MySQL
2.文件
3.redis
4.memcache
...
django_session表中的数据条数是取决于浏览器的
同一个计算机上(IP地址)同一个浏览器只会有一条数据生效
(当session过期的时候可能会出现多条数据对应一个浏览器,但是该现象不会持续很久,内部会自动识别过期的数据清除 你也可以通过代码清除)
主要是为了节省服务端数据库资源
"""
request.session['hobby'] = 'girl'
"""
内部发送了那些事
1.django内部会自动帮你生成一个随机字符串
2.django内部自动将随机字符串和对应的数据存储到django_session表中
2.1先在内存中产生操作数据的缓存
2.2在响应结果django中间件的时候才真正的操作数据库
3.将产生的随机字符串返回给客户端浏览器保存
"""
request.session.get('hobby')
"""
内部发送了那些事
1.自动从浏览器请求中获取sessionid对应的随机字符串
2.拿着该随机字符串去django_session表中查找对应的数据
3.
如果比对上了 则将对应的数据取出并以字典的形式封装到request.session中
如果比对不上 则request.session.get()返回的是None
"""
# 利用session实现登陆验证
五、CBV如何添加装饰器
from django.views import View
from django.utils.decorators import method_decorator
"""
CBV中django不建议你直接给类的方法加装饰器
无论该装饰器能都正常给你 都不建议直接加
"""
# @method_decorator(login_auth,name='get') # 方式2(可以添加多个针对不同的方法加不同的装饰器)
# @method_decorator(login_auth,name='post')
class MyLogin(View):
@method_decorator(login_auth) # 方式3:它会直接作用于当前类里面的所有的方法
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request,*args,**kwargs)
# @method_decorator(login_auth) # 方式1:指名道姓
def get(self,request):
return HttpResponse("get请求")
def post(self,request):
return HttpResponse('post请求')
六、django中间件
"""
django中间件是django的门户
1.请求来的时候需要先经过中间件才能到达真正的django后端
2.响应走的时候最后也需要经过中间件才能发送出去
django自带七个中间件
"""
django请求生命周期流程图
研究django中间件代码规律
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
class SessionMiddleware(MiddlewareMixin):
def process_request(self, request):
session_key = request.COOKIES.get(settings.SESSION_COOKIE_NAME)
request.session = self.SessionStore(session_key)
def process_response(self, request, response):
return response
class CsrfViewMiddleware(MiddlewareMixin):
def process_request(self, request):
csrf_token = self._get_token(request)
if csrf_token is not None:
# Use same token next time.
request.META['CSRF_COOKIE'] = csrf_token
def process_view(self, request, callback, callback_args, callback_kwargs):
return self._accept(request)
def process_response(self, request, response):
return response
class AuthenticationMiddleware(MiddlewareMixin):
def process_request(self, request):
request.user = SimpleLazyObject(lambda: get_user(request))
"""
django支持程序员自定义中间件并且暴露给程序员五个可以自定义的方法
1.必须掌握
process_request
process_response
2.了解即可
process_view
process_template_response
process_exception
"""
七、如何自定义中间件
"""
1.在项目名或者应用名下创建一个任意名称的文件夹
2.在该文件夹内创建一个任意名称的py文件
3.在该py文件内需要书写类(这个类必须继承MiddlewareMixin)
然后在这个类里面就可以自定义五个方法了
(这五个方法并不是全部都需要书写,用几个写几个)
4.需要将类的路径以字符串的形式注册到配置文件中才能生效
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'你自己写的中间件的路径1',
'你自己写的中间件的路径2',
'你自己写的中间件的路径3',
]
"""
"""
1.必须掌握
process_request
1.请求来的时候需要经过每一个中间件里面的process_request方法
结果的顺序是按照配置文件中注册的中间件从上往下的顺序依次执行
2.如果中间件里面没有定义该方法,那么直接跳过执行下一个中间件
3.如果该方法返回了HttpResponse对象,那么请求将不再继续往后执行
而是直接原路返回(校验失败不允许访问...)
process_request方法就是用来做全局相关的所有限制功能
process_response
1.响应走的时候需要结果每一个中间件里面的process_response方法
该方法有两个额外的参数request,response
2.该方法必须返回一个HttpResponse对象
1.默认返回的就是形参response
2.你也可以自己返回自己的
3.顺序是按照配置文件中注册了的中间件从下往上依次经过
如果你没有定义的话 直接跳过执行下一个
研究如果在第一个process_request方法就已经返回了HttpResponse对象,那么响应走的时候是经过所有的中间件里面的process_response还是有其他情况
是其他情况
就是会直接走同级别的process_reponse返回
flask框架也有一个中间件但是它的规律
只要返回数据了就必须经过所有中间件里面的类似于process_reponse方法
2.了解即可
process_view
路由匹配成功之后执行视图函数之前,会自动执行中间件里面的该放法
顺序是按照配置文件中注册的中间件从上往下的顺序依次执行
process_template_response
返回的HttpResponse对象有render属性的时候才会触发
顺序是按照配置文件中注册了的中间件从下往上依次经过
process_exception
当视图函数中出现异常的情况下触发
顺序是按照配置文件中注册了的中间件从下往上依次经过
"""
八、csrf跨站请求伪造
"""
钓鱼网站
我搭建一个跟正规网站一模一样的界面(中国银行)
用户不小心进入到了我们的网站,用户给某个人打钱
打钱的操作确确实实是提交给了中国银行的系统,用户的钱也确确实实减少了
但是唯一不同的时候打钱的账户不适用户想要打的账户变成了一个莫名其妙的账户
大学英语四六级
考之前需要学生自己网站登陆缴费
内部本质
我们在钓鱼网站的页面 针对对方账户 只给用户提供一个没有name属性的普通input框
然后我们在内部隐藏一个已经写好name和value的input框
如何规避上述问题
csrf跨站请求伪造校验
网站在给用户返回一个具有提交数据功能页面的时候会给这个页面加一个唯一标识
当这个页面朝后端发送post请求的时候 我的后端会先校验唯一标识,如果唯一标识不对直接拒绝(403 forbbiden)如果成功则正常执行
"""
九、如何符合校验
# form表单如何符合校验
<form action="" method="post">
{% csrf_token %}
<p>username:<input type="text" name="username"></p>
<p>target_user:<input type="text" name="target_user"></p>
<p>money:<input type="text" name="money"></p>
<input type="submit">
</form>
# ajax如何符合校验
// 第一种 利用标签查找获取页面上的随机字符串
{#data:{"username":'jason','csrfmiddlewaretoken':$('[name=csrfmiddlewaretoken]').val()},#}
// 第二种 利用模版语法提供的快捷书写
{#data:{"username":'jason','csrfmiddlewaretoken':'{{ csrf_token }}'},#}
// 第三种 通用方式直接拷贝js代码并应用到自己的html页面上即可
data:{"username":'jason'}
function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie !== '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = jQuery.trim(cookies[i]);
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
var csrftoken = getCookie('csrftoken');
function csrfSafeMethod(method) {
// these HTTP methods do not require CSRF protection
return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}
$.ajaxSetup({
beforeSend: function (xhr, settings) {
if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrftoken);
}
}
});
十、csrf相关装饰器
"""
1.网站整体都不校验csrf,就单单几个视图函数需要校验
2.网站整体都校验csrf,就单单几个视图函数不校验
"""
from django.views.decorators.csrf import csrf_protect,csrf_exempt
from django.utils.decorators import method_decorator
"""
csrf_protect 需要校验
针对csrf_protect符合我们之前所学的装饰器的三种玩法
csrf_exempt 忽视校验
针对csrf_exempt只能给dispatch方法加才有效
"""
# @csrf_exempt
# @csrf_protect
def transfer(request):
if request.method == 'POST':
username = request.POST.get('username')
target_user = request.POST.get('target_user')
money = request.POST.get('money')
print('%s给%s转了%s元'%(username,target_user,money))
return render(request,'transfer.html')
from django.views import View
# @method_decorator(csrf_protect,name='post') # 针对csrf_protect 第二种方式可以
# @method_decorator(csrf_exempt,name='post') # 针对csrf_exempt 第二种方式不可以
@method_decorator(csrf_exempt,name='dispatch')
class MyCsrfToken(View):
# @method_decorator(csrf_protect) # 针对csrf_protect 第三种方式可以
# @method_decorator(csrf_exempt) # 针对csrf_exempt 第三种方式可以
def dispatch(self, request, *args, **kwargs):
return super(MyCsrfToken, self).dispatch(request,*args,**kwargs)
def get(self,request):
return HttpResponse('get')
# @method_decorator(csrf_protect) # 针对csrf_protect 第一种方式可以
# @method_decorator(csrf_exempt) # 针对csrf_exempt 第一种方式不可以
def post(self,request):
return HttpResponse('post')
十一、补充知识点
# 模块:importlib
import importlib
res = 'myfile.b'
ret = importlib.import_module(res) # from myfile import b
# 该方法最小只能到py文件名
print(ret)
十二、重要思想
import settings
import importlib
def send_all(content):
for path_str in settings.NOTIFY_LIST: #'notify.email.Email'
module_path,class_name = path_str.rsplit('.',maxsplit=1)
# module_path = 'notify.email' class_name = 'Email'
# 1 利用字符串导入模块
module = importlib.import_module(module_path) # from notify import email
# 2 利用反射获取类名
cls = getattr(module,class_name) # Email、QQ、Wechat
# 3 生成类的对象
obj = cls()
# 4 利用鸭子类型直接调用send方法
obj.send(content)
十三、Auth模块
""" 其实我们在创建好一个django项目之后直接执行数据库迁移命令会自动生成很多表
django_session
auth_user
django在启动之后就可以直接访问admin路由,需要输入用户名和密码,数据参考的就是auth_user表,并且还必须是管理员用户才能进入
创建超级用户(管理员)
python3 manage.py createsuperuser
依赖于auth_user表完成用户相关的所有功能
十四、方法总结
# 1.比对用户名和密码是否正确
user_obj = auth.authenticate(request,username=username,password=password)
# 括号内必须同时传入用户名和密码
print(user_obj) # 用户对象 jason 数据不符合则返回None
print(user_obj.username) # jason
print(user_obj.password) # 密文
# 2.保存用户状态
auth.login(request,user_obj) # 类似于request.session[key] = user_obj
# 主要执行了该方法 你就可以在任何地方通过request.user获取到当前登陆的用户对象
# 3.判断当前用户是否登陆
request.user.is_authenticated()
# 4.获取当前登陆用户
request.user
# 5.校验用户是否登陆装饰器
from django.contrib.auth.decorators import login_required
# 局部配置
@login_required(login_url='/login/')
# 全局配置
LOGIN_URL = '/login/'
1.如果局部和全局都有 该听谁的?
局部 > 全局
2.局部和全局哪个好呢?
全局的好处在于无需重复写代码 但是跳转的页面却很单一
局部的好处在于不同的视图函数在用户没有登陆的情况下可以跳转到不同的页面
# 6.比对原密码
request.user.check_password(old_password)
# 7.修改密码
request.user.set_password(new_password) # 仅仅是在修改对象的属性
request.user.save() # 这一步才是真正的操作数据库
# 8.注销
auth.logout(request)
# 9.注册
# 操作auth_user表写入数据
User.objects.create(username=username,password=password) # 写入数据 不能用create 密码没有加密处理
# 创建普通用户
User.objects.create_user(username=username,password=password)
# 创建超级用户(了解):使用代码创建超级用户 邮箱是必填的 而用命令创建则可以不填
User.objects.create_superuser(username=username,email='123@qq.com',password=password)
十五、如何扩展auth_user表
from django.db import models
from django.contrib.auth.models import User,AbstractUser
# Create your models here.
# 第一种:一对一关系 不推荐
# class UserDetail(models.Model):
# phone = models.BigIntegerField()
# user = models.OneToOneField(to='User')
# 第二种:面向对象的继承
class UserInfo(AbstractUser):
"""
如果继承了AbstractUser
那么在执行数据库迁移命令的时候auth_user表就不会再创建出来了
而UserInfo表中会出现auth_user所有的字段外加自己扩展的字段
这么做的好处在于你能够直接点击你自己的表更加快速的完成操作及扩展
前提:
1.在继承之前没有执行过数据库迁移命令
auth_user没有被创建,如果当前库已经创建了那么你就重新换一个库
2.继承的类里面不要覆盖AbstractUser里面的字段名
表里面有的字段都不要动,只扩展额外字段即可
3.需要在配置文件中告诉django你要用UserInfo替代auth_user(******)
AUTH_USER_MODEL = 'app01.UserInfo'
'应用名.表名'
"""
phone = models.BigIntegerField()
"""
你如果自己写表替代了auth_user那么
auth模块的功能还是照常使用,参考的表页由原来的auth_user变成了UserInfo
我们bbs作业用户表就是用的上述方式
"""
十六、项目开发流程
# 1.需求分析
架构师+产品经理+开发者组长
在跟客户谈需求之前,会大致先了解客户的需求,然后自己先设计一套比较好写方案
在跟客户沟通交流中引导客户往我们之前想好的方案上面靠
形成一个初步的方案
# 2.项目设计
架构师干的活
编程语言选择
框架选择
数据库选择
主库:MySQL,postgreSQL,...
缓存数据库:redis、mongodb、memcache...
功能划分
将整个项目划分成几个功能模块
找组长开会
给每个组分发任务
项目报价
技术这块需要多少人,多少天(一个程序员一天1500~2000计算(大致))
产品经理公司层面 再加点钱
公司财务签字确认
公司老板签字确认
产品经理去跟客户沟通
后续需要加功能 继续加钱
# 3.分组开发
组长找组员开会,安排各自功能模块
我们其实就是在架构师设计好的框架里面填写代码而已(码畜)
我们在写代码的时候 写完需要自己先测试是否有bug
如果是一些显而易见的bug,你没有避免而是直接交给了测试部门测出来
那你可能就需要被扣绩效了(一定要跟测试小姐姐搞好关系)
薪资组成 15K(合理合规合法的避税)
底薪 10K
绩效 3K
岗位津贴 1K
生活补贴 1K
# 4.测试
测试部门测试你的代码
压力测试
...
# 5.交付上线
1.交给对方的运维人员
2.直接上线到我们的服务器上 收取维护费用
3.其他...
十七、表设计
""" 一个项目中最最最重要的不是业务逻辑的书写 而是前期的表设计,只要将表设计好了,后续的功能书写才会一帆风顺 bbs表设计 1.用户表 继承AbstractUser 扩展 phone 电话号码 avatar 用户头像 create_time 创建时间 外键字段 一对一个人站点表 2.个人站点表 site_name 站点名称 site_title 站点标题 site_theme 站点样式 3.文章标签表 name 标签名 外键字段 一对多个人站点 4.文章分类表 name 分类名 外键字段 一对多个人站点 5.文章表 title 文章标题 desc 文章简介 content 文章内容 create_time 发布时间 数据库字段设计优化(******) (虽然下述的三个字段可以从其他表里面跨表查询计算得出,但是频繁跨表效率) up_num 点赞数 down_num 点踩数 comment_num 评论数 外键字段 一对多个人站点 多对多文章标签 一对多文章分类 6.点赞点踩表 记录哪个用户给哪篇文章点了赞还是点了踩 user ForeignKey(to="User") article ForeignKey(to="Article") is_up BooleanField() 1 1 1 1 2 1 1 3 0 2 1 1 7.文章评论表 记录哪个用户给哪篇文章写了哪些评论内容 user ForeignKey(to="User") article ForeignKey(to="Article") content CharField() comment_time DateField() # 自关联 parent ForeignKey(to="Comment",null=True) # ORM专门提供的自关联写法 parent ForeignKey(to="self",null=True) id user_id article_id parent_id 1 1 1 2 2 1 1 根评论子评论的概念 根评论就是直接评论当前发布的内容的 子评论是评论别人的评论 1.PHP是世界上最牛逼的语言 1.1 python才是最牛逼的 1.2 java才是 根评论与子评论是一对多的关系 """
十八、数据库表创建及同步
"""
由于django自带的sqlite数据库对日期不敏感,所以我们换成MySQL
"""
from django.db import models
# Create your models here.
"""
先写普通字段
之后再写外键字段
"""
from django.contrib.auth.models import AbstractUser
class UserInfo(AbstractUser):
phone = models.BigIntegerField(verbose_name='手机号',null=True)
# 头像
avatar = models.FileField(upload_to='avatar/',default='avatar/default.png',verbose_name='用户头像')
"""
给avatar字段传文件对象 该文件会自动存储到avatar文件下 然后avatar字段只保存文件路径avatar/default.png
"""
create_time = models.DateField(auto_now_add=True)
blog = models.OneToOneField(to='Blog',null=True)
class Blog(models.Model):
site_name = models.CharField(verbose_name='站点名称',max_length=32)
site_title = models.CharField(verbose_name='站点标题',max_length=32)
# 简单模拟 带你认识样式内部原理的操作
site_theme = models.CharField(verbose_name='站点样式',max_length=64) # 存css/js的文件路径
class Category(models.Model):
name = models.CharField(verbose_name='文章分类',max_length=32)
blog = models.ForeignKey(to='Blog',null=True)
class Tag(models.Model):
name = models.CharField(verbose_name='文章标签',max_length=32)
blog = models.ForeignKey(to='Blog', null=True)
class Article(models.Model):
title = models.CharField(verbose_name='文章标题',max_length=64)
desc = models.CharField(verbose_name='文章简介',max_length=255)
# 文章内容有很多 一般情况下都是使用TextField
content = models.TextField(verbose_name='文章内容')
create_time = models.DateField(auto_now_add=True)
# 数据库字段设计优化
up_num = models.BigIntegerField(verbose_name='点赞数',default=0)
down_num = models.BigIntegerField(verbose_name='点踩数',default=0)
comment_num = models.BigIntegerField(verbose_name='评论数',default=0)
# 外键字段
blog = models.ForeignKey(to='Blog', null=True)
category = models.ForeignKey(to='Category',null=True)
tags = models.ManyToManyField(to='Tag',
through='Article2Tag',
through_fields=('article','tag')
)
class Article2Tag(models.Model):
article = models.ForeignKey(to='Article')
tag = models.ForeignKey(to='Tag')
class UpAndDown(models.Model):
user = models.ForeignKey(to='UserInfo')
article = models.ForeignKey(to='Article')
is_up = models.BooleanField() # 传布尔值 存0/1
class Comment(models.Model):
user = models.ForeignKey(to='UserInfo')
article = models.ForeignKey(to='Article')
content = models.CharField(verbose_name='评论内容',max_length=255)
comment_time = models.DateTimeField(verbose_name='评论时间',auto_now_add=True)
# 自关联
parent = models.ForeignKey(to='self',null=True) # 有些评论就是根评论
十九、注册功能
"""
我们之前是直接在views.py中书写的forms组件代码
但是为了接耦合 应该将所有的forms组件代码单独写到一个地方
如果你的项目至始至终只用到一个forms组件那么你可以直接建一个py文件书写即可
myforms.py
但是如果你的项目需要使用多个forms组件,那么你可以创建一个文件夹在文件夹内根据
forms组件功能的不同创建不同的py文件
myforms文件夹
regform.py
loginform.py
userform.py
orderform.py
...
"""
def register(request):
form_obj = MyRegForm()
if request.method == 'POST':
back_dic = {"code": 1000, 'msg': ''}
# 校验数据是否合法
form_obj = MyRegForm(request.POST)
# 判断数据是否合法
if form_obj.is_valid():
# print(form_obj.cleaned_data) # {'username': 'jason', 'password': '123', 'confirm_password': '123', 'email': '123@qq.com'}
clean_data = form_obj.cleaned_data # 将校验通过的数据字典赋值给一个变量
# 将字典里面的confirm_password键值对删除
clean_data.pop('confirm_password') # {'username': 'jason', 'password': '123', 'email': '123@qq.com'}
# 用户头像
file_obj = request.FILES.get('avatar')
"""针对用户头像一定要判断是否传值 不能直接添加到字典里面去"""
if file_obj:
clean_data['avatar'] = file_obj
# 直接操作数据库保存数据
models.UserInfo.objects.create_user(**clean_data)
back_dic['url'] = '/login/'
else:
back_dic['code'] = 2000
back_dic['msg'] = form_obj.errors
return JsonResponse(back_dic)
return render(request,'register.html',locals())
<script>
$("#myfile").change(function () {
// 文件阅读器对象
// 1 先生成一个文件阅读器对象
let myFileReaderObj = new FileReader();
// 2 获取用户上传的头像文件
let fileObj = $(this)[0].files[0];
// 3 将文件对象交给阅读器对象读取
myFileReaderObj.readAsDataURL(fileObj) // 异步操作 IO操作
// 4 利用文件阅读器将文件展示到前端页面 修改src属性
// 等待文件阅读器加载完毕之后再执行
myFileReaderObj.onload = function(){
$('#myimg').attr('src',myFileReaderObj.result)
}
})
$('#id_commit').click(function () {
// 发送ajax请求 我们发送的数据中即包含普通的键值也包含文件
let formDataObj = new FormData();
// 1.添加普通的键值对
{#console.log($('#myform').serializeArray()) // [{},{},{},{},{}] 只包含普通键值对#}
$.each($('#myform').serializeArray(),function (index,obj) {
{#console.log(index,obj)#} // obj = {}
formDataObj.append(obj.name,obj.value)
});
// 2.添加文件数据
formDataObj.append('avatar',$('#myfile')[0].files[0]);
// 3.发送ajax请求
$.ajax({
url:"",
type:'post',
data:formDataObj,
// 需要指定两个关键性的参数
contentType:false,
processData:false,
success:function (args) {
if (args.code==1000){
// 跳转到登陆页面
window.location.href = args.url
}else{
// 如何将对应的错误提示展示到对应的input框下面
// forms组件渲染的标签的id值都是 id_字段名
$.each(args.msg,function (index,obj) {
{#console.log(index,obj) // username ["用户名不能为空"]#}
let targetId = '#id_' + index;
$(targetId).next().text(obj[0]).parent().addClass('has-error')
})
}
}
})
})
// 给所有的input框绑定获取焦点事件
$('input').focus(function () {
// 将input下面的span标签和input外面的div标签修改内容及属性
$(this).next().text('').parent().removeClass('has-error')
})
</script>
# 扩展
"""
一般情况下我们在存储用户文件的时候为了避免文件名冲突的情况
会自己给文件名加一个前缀
uuid
随机字符串
...
"""
二十、登陆功能part1
"""
img标签的src属性
1.图片路径
2.url
3.图片的二进制数据
我们的计算机上面致所有能够输出各式各样的字体样式
内部其实对应的是一个个.ttf结尾的文件
http://www.zhaozi.cn/ai/2019/fontlist.php?ph=1&classid=32&softsq=%E5%85%8D%E8%B4%B9%E5%95%86%E7%94%A8
"""
"""
图片相关的模块
pip3 install pillow
"""
from PIL import Image,ImageDraw,ImageFont
"""
Image:生成图片
ImageDraw:能够在图片上乱涂乱画
ImageFont:控制字体样式
"""
from io import BytesIO,StringIO
"""
内存管理器模块
BytesIO:临时帮你存储数据 返回的时候数据是二进制
StringIO:临时帮你存储数据 返回的时候数据是字符串
"""
import random
def get_random():
return random.randint(0,255),random.randint(0,255),random.randint(0,255)
def get_code(request):
# 推导步骤1:直接获取后端现成的图片二进制数据发送给前端
# with open(r'static/img/111.jpg','rb') as f:
# data = f.read()
# return HttpResponse(data)
# 推导步骤2:利用pillow模块动态产生图片
# img_obj = Image.new('RGB',(430,35),'green')
# img_obj = Image.new('RGB',(430,35),get_random())
# # 先将图片对象保存起来
# with open('xxx.png','wb') as f:
# img_obj.save(f,'png')
# # 再将图片对象读取出来
# with open('xxx.png','rb') as f:
# data = f.read()
# return HttpResponse(data)
# 推导步骤3:文件存储繁琐IO操作效率低 借助于内存管理器模块
# img_obj = Image.new('RGB', (430, 35), get_random())
# io_obj = BytesIO() # 生成一个内存管理器对象 你可以看成是文件句柄
# img_obj.save(io_obj,'png')
# return HttpResponse(io_obj.getvalue()) # 从内存管理器中读取二进制的图片数据返回给前端
# 最终步骤4:写图片验证码
img_obj = Image.new('RGB', (430, 35), get_random())
img_draw = ImageDraw.Draw(img_obj) # 产生一个画笔对象
img_font = ImageFont.truetype('static/font/222.ttf',30) # 字体样式 大小
# 随机验证码 五位数的随机验证码 数字 小写字母 大写字母
code = ''
for i in range(5):
random_upper = chr(random.randint(65,90))
random_lower = chr(random.randint(97,122))
random_int = str(random.randint(0,9))
# 从上面三个里面随机选择一个
tmp = random.choice([random_lower,random_upper,random_int])
# 将产生的随机字符串写入到图片上
"""
为什么一个个写而不是生成好了之后再写
因为一个个写能够控制每个字体的间隙 而生成好之后再写的话
间隙就没法控制了
"""
img_draw.text((i*60+60,-2),tmp,get_random(),img_font)
# 拼接随机字符串
code += tmp
print(code)
# 随机验证码在登陆的视图函数里面需要用到 要比对 所以要找地方存起来并且其他视图函数也能拿到
request.session['code'] = code
io_obj = BytesIO()
img_obj.save(io_obj,'png')
return HttpResponse(io_obj.getvalue())
<script>
$("#id_img").click(function () {
// 1 先获取标签之前的src
let oldVal = $(this).attr('src');
$(this).attr('src',oldVal += '?')
})
</script>
二十一、登陆功能part2
def login(request):
if request.method == 'POST':
back_dic = {'code':1000,'msg':''}
username = request.POST.get('username')
password = request.POST.get('password')
code = request.POST.get('code')
# 1 先校验验证码是否正确 自己决定是否忽略 统一转大写或者小写再比较
if request.session.get('code').upper() == code.upper():
# 2 校验用户名和密码是否正确
user_obj = auth.authenticate(request,username=username,password=password)
if user_obj:
# 保存用户状态
auth.login(request,user_obj)
back_dic['url'] = '/home/'
else:
back_dic['code'] = 2000
back_dic['msg'] = '用户名或密码错误'
else:
back_dic['code'] = 3000
back_dic['msg'] = '验证码错误'
return JsonResponse(back_dic)
return render(request,'login.html')
二十二、首页搭建
# 1.动态展示用户名称
{% if request.user.is_authenticated %}
<li><a href="#">{{ request.user.username }}</a></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">更多操作 <span class="caret"></span></a>
<ul class="dropdown-menu">
<li><a href="#">修改密码</a></li>
<li><a href="#">修改头像</a></li>
<li><a href="#">后台管理</a></li>
<li role="separator" class="divider"></li>
<li><a href="#">退出登陆</a></li>
</ul>
</li>
{% else %}
<li><a href="{% url 'reg' %}">注册</a></li>
<li><a href="{% url 'login' %}">登陆</a></li>
{% endif %}
# 更多操作
(一)admin后台管理
"""
django给你提供了一个可视化的界面用来让你方便的对你的模型表
进行数据的增删改查操作
如果你先想要使用amdin后台管理操作模型表
你需要先注册你的模型表告诉admin你需要操作哪些表
去你的应用下的admin.py中注册你的模型表
from django.contrib import admin
from app01 import models
# Register your models here.
admin.site.register(models.UserInfo)
admin.site.register(models.Blog)
admin.site.register(models.Category)
admin.site.register(models.Tag)
admin.site.register(models.Article)
admin.site.register(models.Article2Tag)
admin.site.register(models.UpAndDown)
admin.site.register(models.Comment)
"""
# admin会给每一个注册了的模型表自动生成增删改查四条url
http://127.0.0.1:8000/admin/app01/userinfo/ 查
http://127.0.0.1:8000/admin/app01/userinfo/add/ 增
http://127.0.0.1:8000/admin/app01/userinfo/1/change/ 改
http://127.0.0.1:8000/admin/app01/userinfo/1/delete/ 删
http://127.0.0.1:8000/admin/app01/blog/ 查
http://127.0.0.1:8000/admin/app01/blog/add/ 增
http://127.0.0.1:8000/admin/app01/blog/1/change/ 改
http://127.0.0.1:8000/admin/app01/blog/1/delete/ 删
"""
关键点就在于urls.py中的第一条自带的url
前期我们需要自己手动苦逼的录入数据,自己克服一下
"""
# 1.数据绑定尤其需要注意的是用户和个人站点不要忘记绑定了
# 2.标签
# 3.标签和文章
千万不要把别人的文章绑定标签
二十三、用户头像展示
"""
1 网址所使用的静态文件默认放在static文件夹下
2 用户上传的静态文件也应该单独放在某个文件夹下
media配置
该配置可以让用户上传的所有文件都固定存放在某一个指定的文件夹下
# 配置用户上传的文件存储位置
MEDIA_ROOT = os.path.join(BASE_DIR,'media') # 文件名 随你 自己
会自动创建多级目录
如何开设后端指定文件夹资源
首先你需要自己去urls.py书写固定的代码
from django.views.static import serve
from BBS14 import settings
# 暴露后端指定文件夹资源
url(r'^media/(?P<path>.*)',serve,{'document_root':settings.MEDIA_ROOT})
"""
(一)图片防盗链
# 如何避免别的网站直接通过本网站的url访问本网站资源
# 简单的防盗
我可以做到请求来的时候先看看当前请求是从哪个网站过来的
如果是本网站那么正常访问
如果是其他网站直接拒绝
请求头里面有一个专门记录请求来自于哪个网址的参数
Referer: http://127.0.0.1:8000/xxx/
# 如何避免
1.要么修改请求头referer
2.直接写爬虫把对方网址的所有资源直接下载到我们自己的服务器上
二十四、个人站点
# 全是每个用户都可以有自己的站点样式
<link rel="stylesheet" href="/media/css/{{ blog.site_theme }}/">
id content create_time month
1 111 2020-11-11 2020-11
2 222 2020-11-12 2020-11
3 333 2020-11-13 2020-11
4 444 2020-11-14 2020-11
5 555 2020-11-15 2020-11
"""
django官网提供的一个orm语法
from django.db.models.functions import TruncMonth
-官方提供
from django.db.models.functions import TruncMonth
Sales.objects
.annotate(month=TruncMonth('timestamp')) # Truncate to month and add to select list
.values('month') # Group By month
.annotate(c=Count('id')) # Select the count of the grouping
.values('month', 'c') # (might be redundant, haven't tested) select month and count
时区问题报错
TIME_ZONE = 'Asia/Shanghai'
USE_TZ = True
"""
二十五、侧边栏筛选功能
https://www.cnblogs.com/jason/tag/Python/ 标签
https://www.cnblogs.com/jason/category/850028.html 分类
https://www.cnblogs.com/jason/archive/2016/10.html 日期
https://www.cnblogs.com/jason/tag/1/ 标签
https://www.cnblogs.com/jason/category/1 分类
https://www.cnblogs.com/jason/archive/2020-11/ 日期
def site(request,username,**kwargs):
"""
:param request:
:param username:
:param kwargs: 如果该参数有值 也就意味着需要对article_list做额外的筛选操作
:return:
"""
# 先校验当前用户名对应的个人站点是否存在
user_obj = models.UserInfo.objects.filter(username=username).first()
# 用户如果不存在应该返回一个404页面
if not user_obj:
return render(request,'errors.html')
blog = user_obj.blog
# 查询当前个人站点下的所有的文章
article_list = models.Article.objects.filter(blog=blog) # queryset对象 侧边栏的筛选其实就是对article_list再进一步筛选
if kwargs:
# print(kwargs) # {'condition': 'tag', 'param': '1'}
condition = kwargs.get('condition')
param = kwargs.get('param')
# 判断用户到底想按照哪个条件筛选数据
if condition == 'category':
article_list = article_list.filter(category_id=param)
elif condition == 'tag':
article_list = article_list.filter(tags__id=param)
else:
year,month = param.split('-') # 2020-11 [2020,11]
article_list = article_list.filter(create_time__year=year,create_time__month=month)
# 1 查询当前用户所有的分类及分类下的文章数
category_list = models.Category.objects.filter(blog=blog).annotate(count_num=Count('article__pk')).values_list('name','count_num','pk')
# print(category_list) # <QuerySet [('jason的分类一', 2), ('jason的分类二', 1), ('jason的分类三', 1)]>
# 2 查询当前用户所有的标签及标签下的文章数
tag_list = models.Tag.objects.filter(blog=blog).annotate(count_num=Count('article__pk')).values_list('name','count_num','pk')
# print(tag_list) # <QuerySet [('tank的标签一', 1), ('tank的标签二', 1), ('tank的标签三', 2)]>
# 3 按照年月统计所有的文章
date_list = models.Article.objects.filter(blog=blog).annotate(month=TruncMonth('create_time')).values('month').annotate(count_num=Count('pk')).values_list('month','count_num')
# print(date_list)
return render(request,'site.html',locals())
二十六、文章详情页
# url设计
/username/article/1
# 先验证url是否会被其他url顶替
# 文章详情页和个人站点基本一致 所以用模版继承
# 侧边栏的渲染需要传输数据才能渲染 并且该侧边栏在很多页面都需要使用
1.哪个地方用就拷贝需要的代码(不推荐 有点繁琐)
2.将侧边栏制作成inclusion_tag
"""
步骤
1.在应用下创建一个名字必须叫templatetags文件夹
2.在该文件夹内创建一个任意名称的py文件
3.在该py文件内先固定写两行代码
from django import template
register = template.Library()
# 自定义过滤器
# 自定义标签
# 自定义inclusion_tag
"""
# 自定义inclusion_tag
@register.inclusion_tag('left_menu.html')
def left_menu(username):
# 构造侧边栏需要的数据
user_obj = models.UserInfo.objects.filter(username=username).first()
blog = user_obj.blog
# 1 查询当前用户所有的分类及分类下的文章数
category_list = models.Category.objects.filter(blog=blog).annotate(count_num=Count('article__pk')).values_list(
'name', 'count_num', 'pk')
# print(category_list) # <QuerySet [('jason的分类一', 2), ('jason的分类二', 1), ('jason的分类三', 1)]>
# 2 查询当前用户所有的标签及标签下的文章数
tag_list = models.Tag.objects.filter(blog=blog).annotate(count_num=Count('article__pk')).values_list('name',
'count_num',
'pk')
# print(tag_list) # <QuerySet [('tank的标签一', 1), ('tank的标签二', 1), ('tank的标签三', 2)]>
# 3 按照年月统计所有的文章
date_list = models.Article.objects.filter(blog=blog).annotate(month=TruncMonth('create_time')).values(
'month').annotate(count_num=Count('pk')).values_list('month', 'count_num')
# print(date_list)
return locals()
二十七、文章点赞点踩
"""
浏览器上你看到的花里胡哨的页面,内部都是HTML(前端)代码
那现在我们的文章内容应该写什么??? >>> html代码
如何拷贝文章
copy outerhtml
1.拷贝文章
2.拷贝点赞点踩
1.拷贝前端点赞点踩图标 只拷了html
2.css也要拷贝
由于有图片防盗链的问题 所以将图片直接下载到本地
课下思考:
前端如何区分用户是点了赞还是点了踩
1.给标签各自绑定一个事件
两个标签对应的代码其实基本一样,仅仅是是否点赞点踩这一个参数不一样而已
2.二合一
给两个标签绑定一个事件
// 给所有的action类绑定事件
$('.action').click(function () {
alert($(this).hasClass('diggit'))
})
由于点赞点踩内部有一定的业务逻辑,所以后端单独开设视图函数处理
"""
# 个人建议:写代码先把所有正确的逻辑写完再去考虑错误的逻辑 不要试图两者兼得
import json
from django.db.models import F
def up_or_down(request):
"""
1.校验用户是否登陆
2.判断当前文章是否是当前用户自己写的(自己不能点自己的文章)
3.当前用户是否已经给当前文章点过了
4.操作数据库了
:param request:
:return:
"""
if request.is_ajax():
back_dic = {'code':1000,'msg':''}
# 1 先判断当前用户是否登陆
if request.user.is_authenticated():
article_id = request.POST.get('article_id')
is_up = request.POST.get('is_up')
# print(is_up,type(is_up)) # true <class 'str'>
is_up = json.loads(is_up) # 记得转换
# print(is_up, type(is_up)) # True <class 'bool'>
# 2 判断当前文章是否是当前用户自己写的 根据文章id查询文章对象 根据文章对象查作者 根request.user比对
article_obj = models.Article.objects.filter(pk=article_id).first()
if not article_obj.blog.userinfo == request.user:
# 3 校验当前用户是否已经点了 哪个地方记录了用户到底点没点
is_click = models.UpAndDown.objects.filter(user=request.user,article=article_obj)
if not is_click:
# 4 操作数据库 记录数据 要同步操作普通字段
# 判断当前用户点了赞还是踩 从而决定给哪个字段加一
if is_up:
# 给点赞数加一
models.Article.objects.filter(pk=article_id).update(up_num = F('up_num') + 1)
back_dic['msg'] = '点赞成功'
else:
# 给点踩数加一
models.Article.objects.filter(pk=article_id).update(down_num=F('down_num') + 1)
back_dic['msg'] = '点踩成功'
# 操作点赞点踩表
models.UpAndDown.objects.create(user=request.user,article=article_obj,is_up=is_up)
else:
back_dic['code'] = 1001
back_dic['msg'] = '你已经点过了,不能再点了' # 这里你可以做的更加的详细 提示用户到底点了赞还是点了踩
else:
back_dic['code'] = 1002
back_dic['msg'] = '你个臭不要脸的!'
else:
back_dic['code'] = 1003
back_dic['msg'] = '请先<a href="/login/">登陆</a>'
return JsonResponse(back_dic)
<script>
// 给所有的action类绑定事件
$('.action').click(function () {
{#alert($(this).hasClass('diggit'))#}
let isUp = $(this).hasClass('diggit');
let $div = $(this);
// 朝后端发送ajax请求
$.ajax({
url:'/up_or_down/',
type:'post',
data:{
'article_id':'{{ article_obj.pk }}',
'is_up':isUp,
'csrfmiddlewaretoken':'{{ csrf_token }}'
},
success:function (args) {
if(args.code == 1000){
$('#digg_tips').text(args.msg)
// 将前端的数字加一
// 先获取到之前的数字
let oldNum = $div.children().text(); // 文本 是字符类型
// 易错点
$div.children().text(Number(oldNum) + 1) // 字符串拼接了 1+1 = 11 11 + 1 = 111
}else{
$('#digg_tips').html(args.msg)
}
}
})
})
</script>
二十八、文章评论
"""
我们先写根评论
再写子评论
点击评论按钮需要将评论框里面的内容清空
根评论有两步渲染方式
1.DOM临时渲染
2.页面刷新render渲染
子评论
点击回复按钮发生了几件事
1.评论框自动聚焦
2.将回复按钮所在的那一行评论人的姓名
@username
3.评论框内部自动换行
根评论子评论都是点击一个按钮朝后端提交数据的
parent_id
根评论子评论区别在哪?
parent_id
"""
二十九、后台管理
"""
当一个文件夹下文件比较多的时候 你还可以继续创建文件夹分类处理
templates文件夹
backend文件夹
应用1文件夹
应用2文件夹
"""
三十、添加文章
有两个需要注意的问题
1.文章的简介
不能直接切去
应该先想办法获取到当前页面的文本内容之后截取150个文本字符
2.XSS攻击
针对支持用户直接编写html代码的网址
针对用户直接书写的script标签 我们需要处理
1.注视标签内部的内容
2.直接将script删除
如何解决?
我们自己的话
针对1 后端通过正则表达式筛选
针对2 首先需要确定及获取script标签
这两步都好烦 有木有人来帮我一下
beautifulsoup模块 bs4模块
专门用来帮你处理html页面内的
该模块主要用于爬虫程序
下载千万不要下错了
pip3 install beautifulsoup4
# 模块使用
soup = BeautifulSoup(content,'html.parser')
tags = soup.find_all()
# 获取所有的标签
for tag in tags:
# print(tag.name) # 获取页面所有的标签
# 针对script标签 直接删除
if tag.name == 'script':
# 删除标签
tag.decompose()
# 文章简介
# 1 先简单暴力的直接切去content 150个字符
# desc = content[0:150]
# 2 截取文本150个
desc = soup.text[0:150]
"""
当你发现一个数据处理起来不是很方便的时候
可以考虑百度搜搜有没有现成的模块帮你完成相应的功能
"""
三十一、kindeditor富文本编辑器
编辑器的种类有很多,你可以自己去网上搜索
一、编辑器上传图片
别人写好了接口 但是接口不是你自己的
你需要手动去修改
# 在使用别人的框架或者模块的时候 出现了问题不要慌 看看文档可能会有对应的处理方法
二、修改用户头像
@login_required
def set_avatar(request):
if request.method == 'POST':
file_obj = request.FILES.get('avatar')
# models.UserInfo.objects.filter(pk=request.user.pk).update(avatar=file_obj) # 不会再自动加avatar前缀
# 1.自己手动加前缀
# 2.换一种更新方式
user_obj = request.user
user_obj.avatar = file_obj
user_obj.save()
return redirect('/home/')
blog = request.user.blog
username = request.user.username
return render(request,'set_avatar.html',locals())
三十二、bbs项目总结
"""
在开发任意的web项目的时候 其实到了后期需要写的代码会越来越少
都是用已经写好的url填写到a标签href属性完成跳转即可
"""
主要功能总结
表设计 开发流程(粗燥流程 还可以细化)
注册功能
forms组件使用
头像动态展示
错误信息提示
登陆功能
图片验证码
滑动验证码
首页展示
media配置
主动暴露任意资源接口
个人站点展示
侧边栏展示
侧边栏筛选
侧边栏inclusion_tag
文章详情页
点赞点踩
评论
后台管理
"""
针对bbs需要你掌握每一个功能的书写思路 内部逻辑
之后再去敲代码熟悉 找感觉
"""