news 2026/10/31 8:00:37

开源CMS前后端分离实战:内容模型、栏目树、权限与静态化避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
开源CMS前后端分离实战:内容模型、栏目树、权限与静态化避坑

简介:这是一套免费开源的CMS内容管理系统,面向需要快速搭建网站、学习内容管理架构或进行二次开发的个人与企业用户,提供前后台代码,可用于内容发布、用户与权限管理、前台展示等常见建站场景。压缩包约13.38MB,文件总数与主要类型明细暂未提供,实际内容以压缩包为准;从描述看涉及Java技术栈与database数据库目录,适合结合MySQL等关系型数据库理解内容存储、索引优化与访问结构。已有244人学习,说明其在建站入门与CMS源码研究方面具有一定参考价值。读者可借此了解免费开源CMS的许可协议、前后台职责划分和二次开发入口,按需扩展功能、调整界面或优化性能,同时注意遵守GPL、Apache等开源许可证及商业使用条款。整体更适合具备基础Web开发知识、希望从源码层面掌握CMS运行机制的人群。

1. 装一套CMS就能把网站交给运营自己更新,这个想法我见过不止一个团队栽在上面

真正卡住人的往往不是编辑器够不够好用,而是内容模型建得对不对、栏目树撑不撑得住三级导航、发布之后前台多久能看到变化。这套开源的 cms 内容管理系统走的是前后台分离的路子:后台一套独立管理端,管内容录入、栏目维护、权限分配;前台一套展示站,管模板渲染、URL 路由和静态化。前台后台是分开的两个工程,网站代码互不干扰,本地起一遍十几分钟。

它适合中小团队官网、资讯站、企业多栏目站点,也适合想拿现成前后台代码做二次开发的开发者——增删改查、附件上传、富文本、角色权限这些脏活已经铺好了,你只需要关心业务字段和模板长什么样。内容量在百万级往上、需要复杂审批流编排的场景,这套结构会显得单薄。

2. 技术栈与目录结构:先把项目骨架摸清楚再动手

拿到一份前后台代码,很多人第一反应是找 README 里的启动命令直接跑,跑挂了再回头翻目录。我更习惯反过来:先把工程切成几块,搞清楚每块负责什么、彼此靠什么通信,再决定从哪启动。这套 CMS 的边界划得比较干净,三个工程各管一摊,读懂目录基本就懂了一半架构。

2.1 技术栈:前台为什么不做单页应用,后台为什么必须做

前台用的是 Python 的 Web 框架加模板渲染,没有上 React、Vue 那套同构方案。这个选择在内容站场景下很务实:页面结构由栏目和模板决定,交互只占很小一部分,模板直出意味着首屏没有白屏等待,搜索引擎抓取拿到的就是完整 HTML,运维上也不用为 Node 运行时单独准备一套进程。常见做法是把列表页、详情页、单页各写一个模板,栏目表里存一个template字段指过去,运营换模板不用改代码。

后台则是标准的 Vue 3 单页应用,配 Element Plus 做表单和表格,Vite 打包。后台的交互密度和前台完全不是一个量级——拖拽排序、富文本、批量操作、树形栏目选择,这些用模板渲染写起来会很痛苦。前后台通过 REST 接口通信,后台只认 JSON,不碰模板。

中间层是三样东西:MySQL 存业务数据,Redis 扛缓存和会话,Nginx 在最前面做反向代理和静态文件分发。附件不落应用服务器本地磁盘,走独立的上传目录或者对象存储,这一条决定了后面能不能水平扩容。

提示:目录里如果看到requirements.txt和package.json同时存在,说明前后端确实是两个独立依赖体系,别指望一条命令全装完。

2.2 目录结构:三个工程各自的边界

熟悉这套结构比记任何命令都管用,二次开发时你要改的文件基本都能从目录名直接定位:

cms/ ├── backend/ # 后端工程 │ ├── config/ # 全局配置:settings 分环境、根路由、wsgi │ ├── apps/ │ │ ├── content/ # 内容域:栏目、文章、标签、内容类型 │ │ ├── accounts/ # 账号域:用户、角色、权限码 │ │ ├── media_lib/ # 附件域:图片库、文件上传、缩略图 │ │ ├── site_config/ # 站点域:站点信息、导航、友情链接、SEO 默认值 │ │ └── api/ # 接口层:DRF 视图、序列化器、路由注册 │ ├── templates/ # 前台模板:list.html / detail.html / page.html │ ├── static/ # 前台静态资源:css、js、font │ ├── fixtures/ # 初始化数据:站点配置、示例栏目、默认角色 │ └── manage.py ├── admin-web/ # 后台管理端工程 │ ├── src/ │ │ ├── api/ # 接口封装,一个模块一个文件 │ │ ├── views/ # 页面:内容列表、栏目树、角色、附件库 │ │ ├── router/ # 路由与菜单配置,权限码在这里挂 │ │ └── store/ # 登录态、当前站点、字典缓存 │ └── vite.config.js └── deploy/ ├── nginx.conf # 反代规则 + 静态目录 └── docker-compose.yml # 应用、数据库、缓存三段

apps/下面按业务域切分而不是按技术层切分,这一点对二次开发很关键。你要加一个新的内容类型,改的是content/下面的一处注册,而不是在十几个views.py、models.py里到处翻。接口层单独抽成api/,好处是模型和序列化器解耦,同一个模型可以对外暴露两套字段不同的接口——后台管理用全字段,前台展示用裁剪过的字段。

templates/目录在 README 里通常提得不多,但它是前台能不能改出花样的关键。栏目表里的template字段存的是相对路径,比如list_news.html,改模板就新建一个文件把栏目指过去,不用动视图。

2.3 配置与首次启动:从解压到登录后台

配置文件走的是.env加settings分环境的组合。settings/下一般拆成base.py、dev.py、prod.py,base.py只写通用项,数据库、缓存、密钥这些都从环境变量读。这样做的好处是同一份代码在本地和服务器上只差一个.env文件,不会出现「本地能跑线上炸」的经典问题。

# 1. 后端依赖 cd backend python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt # 2. 环境变量:照 example 复制一份再改 cp .env.example .env # 至少改这几项:DB_HOST / DB_NAME / DB_USER / DB_PASSWORD / REDIS_URL / SECRET_KEY # 3. 建表并灌初始数据 python manage.py migrate python manage.py loaddata fixtures/init_site.json python manage.py createsuperuser # 4. 起后端服务 python manage.py runserver 0.0.0.0:8000

migrate负责按模型建表,loaddata把fixtures/init_site.json里的站点配置、默认角色、示例栏目导入进去——这一步别跳过,后台很多页面依赖站点配置存在,缺了会白屏。createsuperuser建的是超级管理员,权限全开,用于第一次登录。

# 后台管理端 cd admin-web npm install npm run dev # 默认 5173 端口

vite.config.js里需要一个代理,否则后台发出去的/api/xxx会打到 5173 自己身上:

// admin-web/vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { // 所有 /api 开头的请求转发给后端,避免开发期跨域 '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true, }, // 开发期直接访问后台上传的图片也要能出来 '/media': { target: 'http://127.0.0.1:8000', changeOrigin: true, }, }, }, })

changeOrigin的作用是把请求头里的 Host 改写成目标地址,后端做域名校验或者生成绝对链接时会用到。代理配好后,前端代码里所有请求都写相对路径/api/...,线上切到 Nginx 反代时一行不用改。这是我在所有前后端分离项目里都坚持的一条:前端代码里永远不出现http://localhost:8000这种硬编码。

前台访问http://127.0.0.1:8000/就能看到展示站,后台访问http://127.0.0.1:5173/,用刚才建的超级管理员登录。两边都起来之后,先别急着往里塞内容,去后台的「站点配置」把站点名、域名、SEO 默认值填一遍——后面静态化和绝对链接生成都要读这几个值。

3. 后台核心模块:内容模型、栏目树和权限三件套

后台页面看起来功能很多,剥开之后核心就三块:内容怎么存、栏目怎么组织、谁能干什么。这三块的设计质量直接决定这套 CMS 能不能撑住真实业务。我见过不少半成品项目在这三处偷懒,结果就是运营提一个「新闻和产品要用不同字段」的需求,开发得改三天。

3.1 内容模型:一张主表加 JSON 扩展字段

最朴素的做法是每种内容建一张表,新闻一张、产品一张、案例一张。等到要加「视频」类型的时候,列表页、搜索、标签、附件关联全都要复制一遍。这套 CMS 用的是主表加扩展字段的方案,所有内容共用一张表,差异部分塞进 JSON 字段。

# backend/apps/content/models.py from django.db import models from django.conf import settings class Article(models.Model): """内容主表:新闻、产品、案例、单页共用,差异字段放 extra""" STATUS_CHOICES = ( ("draft", "草稿"), ("review", "待审"), ("published", "已发布"), ("offline", "已下线"), ) # 内容类型标识,对应 content_types 注册表里的 key content_type = models.CharField("内容类型", max_length=32, db_index=True) title = models.CharField("标题", max_length=200) # slug 用于生成前台 URL,同一栏目下必须唯一 slug = models.SlugField("URL 标识", max_length=200, db_index=True) category = models.ForeignKey( "content.Category", verbose_name="主栏目", on_delete=models.PROTECT, # 栏目下还有内容时禁止删除,避免误操作丢数据 ) summary = models.TextField("摘要", max_length=500, blank=True) body = models.TextField("正文 HTML", blank=True) cover = models.CharField("封面图地址", max_length=255, blank=True) # 扩展字段:价格、规格、作者、来源等按内容类型自定义 extra = models.JSONField("扩展字段", default=dict, blank=True) status = models.CharField("状态", max_length=16, choices=STATUS_CHOICES, default="draft") is_top = models.BooleanField("置顶", default=False) sort = models.IntegerField("排序值", default=0, db_index=True) author = models.ForeignKey( settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, verbose_name="创建人", ) published_at = models.DateTimeField("发布时间", null=True, blank=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: ordering = ["-is_top", "sort", "-published_at", "-id"] indexes = [ models.Index(fields=["content_type", "status", "published_at"]), models.Index(fields=["category", "status"]), ] constraints = [ # 同一栏目下 slug 唯一,跨栏目可以重名 models.UniqueConstraint( fields=["category", "slug"], name="uniq_slug_per_category" ) ]

几个设计点值得展开说。content_type是一个字符串标识而不是外键,原因是内容类型的定义在代码里(一个 Python 字典注册表),不在数据库里,这样加类型不用改表结构。category用PROTECT而不是CASCADE,栏目删除时如果有内容挂着会直接报错——这个限制在运营手里是救命的设计,血泪经验是有人误删过一个栏目,联级下去带走两百多篇文章。

extra用 JSON 字段而不是再建一张扩展表,好处是查询和写入都只有一次,坏处是不能对扩展字段建索引。判断标准很简单:如果某个扩展字段需要高频筛选排序,就把它提升成主表字段;如果只是详情页展示,留在 JSON 里最省事。常见做法是在序列化器里按内容类型定义一份字段白名单,写 JSON 时做一次校验,防止前端塞进来乱七八糟的键。

slug的唯一约束带上category是个细节。全局唯一会逼着运营给每篇文章想一个不重名的英文串,体验很差;按栏目唯一则允许「产品A」和「新闻A」各自有一个a的 slug,URL 前缀不同不会冲突。

3.2 栏目树:用路径字段换掉递归查询

栏目要支持无限级,父子关系用parent自关联最直观,但每次查子树都要递归,页面上随便挂个导航就要打十几次库。这套代码的做法是在栏目表上多存两个字段:path存祖先链,level存层级。

# backend/apps/content/models.py class Category(models.Model): name = models.CharField("栏目名", max_length=50) slug = models.SlugField("URL 标识", max_length=80) parent = models.ForeignKey( "self", null=True, blank=True, related_name="children", on_delete=models.CASCADE, verbose_name="上级栏目", ) # 祖先链,形如 "0/3/12",根栏目为 "0" path = models.CharField("祖先路径", max_length=255, db_index=True, default="0") level = models.PositiveSmallIntegerField("层级", default=1) sort = models.IntegerField("排序值", default=0) is_nav = models.BooleanField("显示在导航", default=True) template = models.CharField("绑定模板", max_length=120, blank=True) class Meta: ordering = ["sort", "id"] def save(self, *args, **kwargs): if self.parent: self.level = self.parent.level + 1 self.path = f"{self.parent.path}/{self.parent_id}" else: self.level = 1 self.path = "0" super().save(*args, **kwargs)

save里重算path和level,保证任何一次保存之后这两个字段都是对的。要注意的是父栏目被移动时,子树的path会全部失效,需要在视图层加一个「移动后级联刷新」的处理:取出以旧路径为前缀的所有栏目,统一替换前缀。这一步少了,导航树会显示正常但列表页查不到数据,属于典型的静默故障。

查子树就变成一次前缀匹配:

# backend/apps/content/services.py def subtree_ids(category): """返回该栏目及其全部子孙栏目的 ID,用于列表页聚合内容""" if category is None: return [] prefix = f"{category.path}/{category.id}" ids = list( Category.objects .filter(models.Q(path__startswith=prefix) | models.Q(id=category.id)) .values_list("id", flat=True) ) return ids

path__startswith会命中path上的索引,一次查询拿到整棵子树,避免递归。参数category传当前栏目对象,返回的是 ID 列表,直接喂给Article.objects.filter(category_id__in=...)。这里有个边界:根栏目的path是"0",拼出来的前缀是"0/1",会不会误匹配到"0/10"?会。所以前缀后面要补一个/,即f"{category.path}/{category.id}/",或者像上面这样在存path时保证分隔符格式统一。这个坑我在两个项目里踩过,表现出来是「A 栏目的列表页混进了 B 栏目的文章」,排查起来很费时间。

导航渲染一般是取is_nav=True且level <= 3的栏目,一次性查出来在内存里拼树,不要每层查一次库。

3.3 权限:角色挂权限码,接口上挂装饰器

后台的权限体系用的是用户-角色-权限码三段式,没有做到更细的数据级权限。角色表里存一个权限码数组,接口层用装饰器校验,前端菜单也读同一份数组来决定显示哪些入口。

# backend/apps/accounts/perms.py from functools import wraps from rest_framework.exceptions import PermissionDenied def require_perm(code): """接口级权限校验,code 形如 content:article:publish""" def decorator(view_func): @wraps(view_func) def wrapper(self, request, *args, **kwargs): role = getattr(request.user, "role", None) granted = set((role.perms if role else []) or []) # 超级管理员直接放行,方便排障 if request.user.is_superuser or code in granted: return view_func(self, request, *args, **kwargs) raise PermissionDenied(f"缺少权限:{code}") return wrapper return decorator

权限码的命名统一成域:资源:动作,content:article:publish表示内容域的文章发布权。这套命名在角色配置页上按前缀分组展示,运营看起来清楚,开发加新接口时也容易判断该挂哪个码。

# backend/apps/api/views_article.py class ArticleViewSet(viewsets.ModelViewSet): queryset = Article.objects.select_related("category", "author") serializer_class = ArticleSerializer @action(methods=["post"], detail=True) @require_perm("content:article:publish") def publish(self, request, pk=None): article = self.get_object() article.status = "published" article.published_at = timezone.now() article.save(update_fields=["status", "published_at", "updated_at"]) # 发布后触发前台静态页重建,任务丢进队列异步跑 rebuild_static_page.delay(article.id) return Response({"code": 0, "msg": "ok"})

@action定义的是非标准 CRUD 的额外接口,detail=True表示路径上带主键。require_perm放在@action下面一行,顺序不能反,否则装饰器拿不到 DRF 包装后的方法。update_fields限定只写三个字段,避免并发保存时把别人改的正文覆盖掉。

前端菜单的权限控制在路由配置里:

// admin-web/src/router/index.js export const menuRoutes = [ { path: '/content/article', name: 'ArticleList', component: () => import('@/views/content/ArticleList.vue'), meta: { title: '文章管理', perm: 'content:article:list' }, }, { path: '/system/role', name: 'RoleList', component: () => import('@/views/system/RoleList.vue'), meta: { title: '角色管理', perm: 'system:role:list' }, }, ]

路由守卫在登录后拿当前用户的权限码数组,过滤掉meta.perm不在数组里的菜单。要注意这只防君子不防小人,真正的拦截必须在后端接口上做——前后端各校验一次,前端管体验,后端管安全。

4. 前台渲染链路:模板、路由、静态化三段拼起来

后台把内容存好了,前台怎么把它变成用户看到的页面,这条链路是这套 CMS 里最容易被低估的部分。很多人以为写个模板循环输出就完事了,等站点上线被搜索引擎收录一轮,才发现 URL 结构、缓存、静态化每一样都能单独出问题。

4.1 模板渲染:视图只管取数据,模板只管排版

前台视图遵循一个原则:视图里不做业务判断,只负责把数据装配成上下文丢给模板。列表页、详情页、单页三种视图结构高度相似,差别只在查询条件和模板文件。

# backend/apps/content/views_front.py from django.core.paginator import Paginator from django.shortcuts import render, get_object_or_404 from .models import Article, Category def category_list(request, slug, page=1): category = get_object_or_404(Category, slug=slug) # 取子树 ID,父栏目页面要聚合子孙栏目的内容 ids = subtree_ids(category) qs = (Article.objects .filter(category_id__in=ids, status="published") .select_related("category") .order_by("-is_top", "sort", "-published_at")) paginator = Paginator(qs, 15) # 每页 15 条 page_obj = paginator.get_page(page) # 页码越界自动回落到合法页 context = { "category": category, "page_obj": page_obj, "nav_categories": build_nav(), # 顶部导航,通常走缓存 "seo": { "title": category.name, "keywords": category.name, "description": f"{category.name}列表页", }, } # 栏目绑定的模板优先,没绑就走默认列表模板 tpl = category.template or "list.html" return render(request, tpl, context)

select_related("category")的作用是在查文章的同时把栏目一起 join 出来,模板里访问article.category.name不会再多打一次库。一页 15 条,没有这行就是 15 次额外查询,列表页慢不慢基本看这个。

Paginator.get_page相比page()的好处是页码非法时返回合法页而不是抛 404,用户手改 URL 不会看到报错页。build_nav()是我一般会加缓存的函数,导航数据一天改不了几次,每次请求都查库纯属浪费。

模板层保持简单,循环、判断、截断摘要,别在模板里做复杂计算。有一个容易忽略的点:正文是富文本 HTML,模板里输出必须标记为安全字符串,但前提是后端在写入时已经做过一次清洗,把script、onerror这类东西过滤掉。信任前端提交的富文本直接输出,是后台类项目里最常见的漏洞来源。

4.2 URL 设计:slug 拼路径,别把主键暴露出去

前台 URL 结构一般是/{栏目slug}/{内容slug}.html这种形式,栏目和内容各占一段,末尾带.html后缀伪装静态页。这套写法对搜索引擎友好,用户看着也顺眼。

# backend/config/urls_front.py from django.urls import re_path from apps.content import views_front urlpatterns = [ # 栏目列表页,支持分页 re_path(r"^(?P<slug>[-\w]+)/$", views_front.category_list, name="category"), re_path(r"^(?P<slug>[-\w]+)/page/(?P<page>\d+)/$", views_front.category_list, name="category_page"), # 内容详情页,slug 里允许出现字母数字和连字符 re_path(r"^(?P<cat_slug>[-\w]+)/(?P<slug>[-\w]+)\.html$", views_front.article_detail, name="article"), ]

用re_path而不是path,是因为 slug 的匹配规则需要自定义。这里要注意路由顺序:详情页规则要放在栏目页之后,否则news/这种路径可能被详情页规则先吃掉。page参数用命名分组接住,视图函数签名里给个默认值page=1,不带页码时也能正常渲染第一页。

还有一个细节:slug 允许中文吗?技术上可以,URL 编码之后能用,但分享出去的链接会变成一长串%E4%B8%AD%E6%96%87。我的习惯是在后台保存时做一个转换,英文和数字保留,其余字符转拼音或者直接生成时间戳后缀,保证 URL 干净。转换逻辑放在模型的save里做,或者单独抽一个工具函数,在序列化器校验时调用。

4.3 静态化与缓存:发布之后前台多久能看到

内容站的访问特点是读多写少,静态化收益很高。这套代码的做法是发布动作触发一次页面重建,把渲染结果写到磁盘上的静态目录,Nginx 优先读静态文件,没有命中再回源到应用。

# backend/apps/content/tasks.py import os from django.conf import settings from django.template.loader import render_to_string from django.utils.text import slugify from .models import Article def rebuild_article(article_id): """重建单篇内容的静态页,发布、下线、编辑后调用""" try: article = Article.objects.select_related("category").get(pk=article_id) except Article.DoesNotExist: return cat = article.category rel_path = f"{cat.slug}/{article.slug}.html" abs_path = os.path.join(settings.STATIC_SITE_ROOT, rel_path) if article.status != "published": # 下线的内容直接删掉静态文件,避免被直接访问到 if os.path.exists(abs_path): os.remove(abs_path) return html = render_to_string("detail.html", { "article": article, "category": cat, "seo": { "title": article.title, "description": article.summary or article.title, }, }, request=None) os.makedirs(os.path.dirname(abs_path), exist_ok=True) with open(abs_path, "w", encoding="utf-8") as f: f.write(html)

request=None是关键参数:传了 request,模板里生成的链接可能带上当前域名;不传则走相对路径,换域名之后老页面不会失效。STATIC_SITE_ROOT指向 Nginx 的站点根目录,写文件时用exist_ok=True建目录,避免首次发布栏目时因目录不存在报错。

这段逻辑通常是异步执行的,发布接口只负责丢任务,不阻塞后台响应。任务队列在这里不只是性能优化,更重要的是隔离失败:静态化写盘失败不应该让发布动作回滚,内容已经进库了,重试重建即可。

内容列表页的静态化更复杂一点,因为涉及分页。常见做法是只静态化前 N 页,后面的页码回源到动态渲染,因为这个区间的访问量已经很低了,全量静态化只是浪费磁盘。

5. 部署与联调避坑:五个我在真实环境里翻过的车

本地跑通和线上稳定运行之间隔着一整个部署拓扑。这套 CMS 的组件不少,Nginx、应用进程、数据库、缓存、静态目录,任何一处配置不对,症状都会以「页面打不开」这种极简形式呈现,真正的根因藏在日志里。

5.1 上线拓扑:谁管静态文件,谁管接口

先把职责分清楚,后面排查才有方向。Nginx 在最外层,负责三件事:把/api和/admin转发给应用进程、直接返回/media和静态站点目录下的文件、把/的请求按「先找静态页,找不到回源」的规则处理。

应用进程用 Gunicorn 起多个 worker,worker 数量一般是 CPU 核数乘 2 加 1,别开太多,数据库连接池会被打满。静态目录和上传目录必须挂同一个卷给 Nginx 和应用进程,否则应用写进去的文件 Nginx 读不到——这是容器化部署里最常见的一类问题。

会话和缓存走 Redis,不要用应用进程本地内存。多 worker 下本地内存会话会导致「刚登录成功,刷新一下又要登录」的玄学现象,本质是请求落到了不同 worker 上。

5.2 坑一:后台传的图片,前台全是裂图

现象:后台附件库能看到图片缩略图,前台页面里的图片全是破图,右键复制地址在浏览器里打开是 404。

原因:后台预览走的是接口返回的完整 URL 或者开发代理,前台页面里的图片地址是/media/xxx.jpg这种相对路径,Nginx 没有为这个前缀配置静态目录,请求被转发给应用进程,应用进程在DEBUG=False时不处理静态文件,直接 404。

解决:在 Nginx 配置里加一段,把上传目录直接映射出去,同时给一个较长的缓存时间:

location /media/ { alias /data/cms/media/; # 必须和应用的上传目录是同一个挂载点 expires 7d; access_log off; }

alias和root的区别要留意:alias是替换匹配到的前缀,root是拼接。写错的话路径会变成/data/cms/media/media/xxx.jpg,症状还是 404,但原因完全不同。

5.3 坑二:内容改了发布,前台还是旧的

现象:后台明明点了发布,状态也是「已发布」,前台刷新还是老内容,等几分钟才变。

原因:静态文件还在,Nginx 优先命中静态页,压根没回源。发布动作只写了数据库,没触发静态页重建,或者重建任务失败了但没人知道。

解决:两步。一是确认发布接口里确实调了重建任务,看任务队列的日志有没有执行记录;二是手动删掉对应的静态文件验证——如果删掉后前台立刻变新,说明就是静态化没刷新,问题定位在任务这一侧。生产环境我会加一个「清空站点静态缓存」的后台按钮,运营自己能操作,比等开发排查快得多。

5.4 坑三:后台菜单能看见,点进去 403

现象:用非超级管理员的账号登录,左侧菜单正常显示「角色管理」,点进去接口返回 403。

原因:前端菜单过滤用的是登录接口返回的权限码数组,后端接口用的是角色表里的perms字段,两处数据源不一致。常见情况是改了角色权限但没让用户重新登录,登录态里缓存的还是旧数组;或者新加的接口挂了权限码,但没同步加到角色的默认权限里。

解决:权限码统一从后端下发,前端不做任何本地推导。角色权限变更后强制刷新登录态,最简单的方式是让该角色下的用户下次请求时重新拉一次用户信息。新接口上线前过一遍权限清单,确认每个@require_perm的码都在角色配置页的可选项里。

5.5 坑四:富文本里的图片存了绝对地址,换域名全挂

现象:站点从测试域名切到正式域名,正文里的图片全部裂开,附件库里的图片却正常。

原因:富文本编辑器上传图片时返回的是带域名的绝对地址,直接写进了正文 HTML。换域名之后这些地址指向的还是旧域名。

解决:上传接口一律返回相对路径,前端展示时再拼当前域名。已经存了绝对地址的历史数据,跑一次批量替换的脚本,把https://old-domain.com/media/换成/media/:

# 一次性数据修复脚本,跑之前先备份 from apps.content.models import Article OLD = "https://old-domain.com/media/" articles = Article.objects.filter(body__contains=OLD) for a in articles: a.body = a.body.replace(OLD, "/media/") a.save(update_fields=["body", "updated_at"]) print(f"fixed {articles.count()} rows")

filter(body__contains=...)在大表上是全表扫描,几万条以内问题不大,量大的话按主键分批跑,每批一千条。

5.6 坑五:列表页翻到后面越来越慢

现象:第一页秒开,翻到第五十页要等好几秒。

原因:Paginator的 count 查询加上LIMIT offset, size的深分页,offset 越大扫过的行越多。加上order_by里带了is_top、sort、published_at三个字段,没有对应索引时每次都要排序。

解决:给(content_type, status, published_at)建联合索引,把order_by的字段顺序和索引对齐;分页改成基于上一页最后一条记录的游标分页,或者限制最大可翻页数——内容站很少有用户真的翻到第五十页,限制在 100 页以内基本不影响体验,还能挡住爬虫的遍历。

6. 二次开发:加一个自定义内容类型要动的几处

这套代码最值得夸的地方是内容类型用注册表管理,加一个「产品」类型不需要改表结构。我把改动位置列成一张表,照着走一遍基本不会漏:

层级位置改什么
注册content/content_types.py加一个 key 和字段定义
接口api/serializers.py按类型做扩展字段校验
后台admin-web/src/views/content/复用列表页,按类型切列
路由admin-web/src/router/index.js加菜单项和权限码
前台templates/新建一套列表和详情模板
静态化content/tasks.py确认新类型走同一条重建逻辑

注册表的写法大致是这样,字段定义用来自动生成后台表单和校验规则:

# backend/apps/content/content_types.py CONTENT_TYPES = { "article": { "label": "文章", "fields": { "source": {"type": "char", "label": "来源", "max_length": 100}, "author_name": {"type": "char", "label": "作者", "max_length": 50}, }, }, "product": { "label": "产品", "fields": { "price": {"type": "decimal", "label": "价格", "required": True}, "spec": {"type": "text", "label": "规格参数"}, "gallery": {"type": "list", "label": "图集"}, }, }, }

后台的内容编辑表单读这份定义动态渲染,前台模板按content_type判断走哪套排版,静态化任务对所有类型一视同仁。真正需要单独写的只有模板和序列化器里的特殊校验,其余全部复用。

验证顺序我的习惯是先接口后界面:用 curl 或接口调试工具创建一条产品数据,确认extra落库正确;再去后台列表页看字段是否正常展示;最后访问前台详情页,确认模板取到了extra.price这些值;发布之后检查静态目录里有没有生成对应的 HTML 文件。四步都过了,这个内容类型才算真的加完。

从那以后我每加一个内容类型,都会先把上面那张表抄在纸上,改完一处划掉一处,绝不凭记忆判断「应该改完了」。前台刷不出来的时候,第一件事不是翻模板,而是去静态目录看一眼文件在不在——十次里有八次问题就出在这一步。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 8:00:14

ExcelJS 实战:前端 Excel 导出导入、样式合并与流式处理

前端要做导出 Excel&#xff0c;十个需求里有八个最后会变成同一句话&#xff1a;能不能带上颜色、合并一下表头、顺便再加个筛选&#xff1f;纯前端能碰 Excel 的库不少&#xff0c;ExcelJS 是我这几年在 JavaScript 项目里用得最顺手的那个——它把 xlsx 当成一个有结构的对象…

作者头像 李华
网站建设 2026/10/31 8:00:12

Spring Boot微服务统一登录:JWT双令牌与网关鉴权实战

做后端的朋友大概都遇到过一个挺尴尬的阶段&#xff1a;项目最早是个单体应用&#xff0c;登录逻辑写在 Controller 里&#xff0c;一段 Session 就搞定了&#xff1b;后来业务拆分&#xff0c;用户中心、订单、库存、报表各自成了一个服务&#xff0c;前端页面也从一套变成小程…

作者头像 李华
网站建设 2026/10/31 7:59:38

深度神经网络DNN:原理、训练调优与工程落地实践

1. 从线性模型的死胡同说起&#xff1a;DNN到底补上了哪块缺口 我见过太多人第一次接触深度神经网络&#xff08;DNN&#xff09;时的两种极端反应&#xff1a;一种是觉得它神秘得不行&#xff0c;仿佛只要堆几层全连接就能自动变聪明&#xff1b;另一种是觉得它就是"调包…

作者头像 李华
网站建设 2026/10/31 7:59:34

MAT 1.11.0 专版:精准解析 JDK8 HPROF 内存泄漏

简介&#xff1a;本资源为 Eclipse Memory Analyzer Tool&#xff08;MAT&#xff09;1.11.0 独立可执行版&#xff0c;专为 Java 开发者与性能调优工程师设计&#xff0c;用于深度分析 JDK8 环境下的堆内存 dump 文件&#xff0c;快速定位内存泄漏、对象膨胀及 GC Roots 引用链…

作者头像 李华
网站建设 2026/10/31 7:57:44

APK修改工具链配置指南:从反编译到重签名避坑

简介&#xff1a;这份APK修改工具资料面向Android应用开发者、逆向工程爱好者与希望进行个性化定制的进阶用户&#xff0c;系统梳理了APK文件结构、修改流程与常用工具链。内容涵盖AndroidManifest.xml配置解析、res资源目录、classes.dex字节码、lib库文件与META-INF签名信息等…

作者头像 李华
网站建设 2026/10/31 7:56:46

Cocos Creator麻将棋牌项目:牌局逻辑、状态机与断线重连

简介&#xff1a;面向棋牌游戏开发学习者与独立开发者的《cocos creator达达麻将棋牌游戏》项目包&#xff0c;以Cocos Creator为前端引擎&#xff0c;串联JavaScript游戏逻辑、Node.js后端服务与MySQL数据存储&#xff0c;可用于理解麻将规则实现、玩家匹配、登录与数据同步等…

作者头像 李华