资讯中心

Django迁移实战:从Flask烂摊子到PostgreSQL+Admin一键交付

📅 2026/7/6 11:03:18
Django迁移实战:从Flask烂摊子到PostgreSQL+Admin一键交付
1. 项目背景与决策现场还原在Six Feet Up这家公司我们日常打交道的不是抽象概念而是客户发来的一封带 deadline 的邮件、一个急需上线的内部工具原型、或者运维同事凌晨三点发来的告警截图。这次的项目就属于典型“半路接单”客户已经在 Google Cloud 上跑着一个基于 Flask 的 Python 应用功能简单前端是纯静态 HTML 少量 jQuery后端只做几条 API 路由数据全扔进 Firestore 和 Cloud Storage。突然有一天业务部门提了个新需求——要加一个“按部门时间范围审批状态关键词全文检索”的报表看板还要支持导出 Excel、权限分级到具体字段、操作留痕审计。听起来不复杂但当你打开现有代码库发现整个 Flask 项目里连个requirements.txt都没维护数据库访问层就是裸写的google.cloud.firestore.Client()调用所有查询逻辑硬编码在视图函数里时你就知道这不是加几个路由的事这是地基松动了得重打桩。我翻了下 Git 历史这个 Flask 项目最初是实习生三天搭出来的 PoC概念验证后来被业务方直接当生产系统用了两年。它像一辆没有保养记录的老自行车——能骑但你不敢让它上高速。我们团队一开始也想着“修修补补又三年”用 Flask 扩展插件Flask-Login、Flask-SQLAlchemy、Flask-Admin硬凑。试了两小时问题就冒出来了Firestore 的嵌套文档结构根本没法做 JOIN 查询Cloud Storage 里存的 PDF 文件元数据缺失连“上传人”字段都得手动解析文件名更麻烦的是所有用户信息散落在三个不同地方——Google Workspace 同步的邮箱、Firestore 里的 profile 字典、还有 Cloud Storage 里某个 JSON 文件里存的权限映射表。这时候再谈“快速迭代”就成了自欺欺人。提示技术选型不是比谁名字响亮而是比谁在你当前的“烂摊子”里最省力。Flask 的轻量是优势但当项目复杂度越过某个临界点我们内部叫它“Flask 红线”它的自由就变成了负担——你得自己决定日志怎么打、密码怎么哈希、迁移脚本怎么写、管理后台长什么样。而这条红线往往就卡在“需要可靠的关系型数据建模”和“需要开箱即用的权限控制”这两个刚需上。我们当时列了个速查表横向对比了三种路径方案预估开发耗时数据层风险权限系统成本运维复杂度团队熟悉度硬改 Flask SQLAlchemy 自研 Admin3–5 天高需重构全部数据访问层极高从零实现 RBAC中新增 DB 连接池、连接泄漏监控中2/5 人写过 SQLAlchemy换用 FastAPI TortoiseORM Admin UI2–4 天中Tortoise 对 Postgres 兼容好但 ORM 抽象层有学习成本高需集成第三方 admin 或自建低ASGI 服务器更轻低仅 1 人用过迁移到 Django1–1.5 天低Django ORM 天然适配 Postgres迁移脚本成熟零内置 auth、admin、group、permission 模块低gunicorn nginx 标准栈CI/CD 流程复用高全员主力框架这张表不是拍脑袋画的是当天下午我们围在白板前把每个模块拆解到“写一行有效代码”粒度后算出来的。比如“权限系统成本”这一项我们算了Flask 方案下光是实现“用户只能看到自己部门的数据”这个基础功能就得写中间件拦截请求、重写 QuerySet、处理模板中条件渲染、还要给 API 返回 403 错误码——保守估计 8 小时。而 Django 只需在 Model 的get_queryset()方法里加一行return super().get_queryset().filter(departmentself.request.user.department)再在 admin.py 里注册list_filter [department]完事。这种“省下来的不是时间是脑细胞”的体验就是 Django 被称作“自带电池”的真实含义——它不承诺帮你写业务逻辑但它把所有非业务逻辑的公共地基都给你浇筑好了还做了防水防震。2. 为什么是 Django 而不是其他框架深度拆解“电池”价值很多人对 Django 的理解还停留在“大而全”“笨重”“学起来难”这些二手评价上。但在我过去十年带过的二十多个 Python Web 项目里Django 真正的杀手锏从来不是功能多而是所有内置模块之间严丝合缝的耦合设计。它不像某些框架把认证、管理、ORM、缓存、表单、测试当成独立插件拼起来而是从第一天起就把它们设计成同一个有机体的不同器官。举个最典型的例子Django 的User模型、auth系统、admin后台、ModelForm、Generic Views这五样东西共享同一套权限模型Permission、同一套会话机制Session、同一套信号系统Signal。这意味着你改一个地方其他四个地方自动跟着生效——不是靠文档约定是靠代码级的强绑定。2.1 数据层PostgreSQL 不是“选项”而是“默认答案”项目正文里那句“we quickly settled on our favorite: SQL — specifically, Postgres”看似轻描淡写实则藏着关键判断。为什么不是 MySQL不是 SQLite甚至不是 Google Cloud SQL for MySQL原因很务实JSONB 字段的实战价值客户原始数据里有大量非结构化字段比如审批流程的“历史操作记录”每条记录包含时间、操作人、备注、附件 ID。用 MySQL 的 JSON 类型查询性能差、索引支持弱而 PostgreSQL 的 JSONB 支持 GIN 索引我们直接写了WHERE>from django.contrib.postgres.search import SearchVector, SearchQuery queryset Document.objects.annotate( searchSearchVector(title, content, tags) ).filter(searchSearchQuery(query))而 Flask 方案下你得研究pg_search扩展手写操作符再处理中文分词我们用的是zhparser插件调试周期至少一天。迁移系统的可靠性客户环境不允许停机。Django 的makemigrationsmigrate组合经过十年生产环境锤炼对AddField、RenameField、AlterField等操作有完备的回滚和兼容策略。我们曾在线上执行过一次涉及 200 万行数据的ALTER COLUMN TYPEDjango 自动拆分成ADD COLUMN→UPDATE→DROP COLUMN→RENAME COLUMN四步全程无锁表。Flask 用Alembic也能做到但你需要自己写op.alter_column(..., type_...)并处理server_default冲突稍有不慎就卡在 migration 里。注意PostgreSQL 不是 Django 的“亲儿子”但 Django 是 PostgreSQL 最懂它的“翻译官”。Django 的ArrayField、HStoreField、RangeField、CIText大小写不敏感文本等都是为 PostgreSQL 深度定制的。你用 MySQL 也能跑 Django但就像开着法拉利走乡间土路——引擎轰鸣轮子打滑。2.2 认证与权限不是“有”而是“刚刚好”客户的需求文档里写着“管理员能看到所有数据部门经理只能看本部门普通员工只能看自己的”。这在 Django 里不是配置是声明# models.py class Report(models.Model): title models.CharField(max_length200) department models.ForeignKey(Department, on_deletemodels.CASCADE) owner models.ForeignKey(User, on_deletemodels.CASCADE) # ... 其他字段 # admin.py admin.register(Report) class ReportAdmin(admin.ModelAdmin): list_display [title, department, owner, created_at] list_filter [department, status] # 自动变成侧边筛选栏 search_fields [title, content] # 自动启用全文搜索框 def get_queryset(self, request): qs super().get_queryset(request) if request.user.is_superuser: return qs # 部门经理能看到本部门所有报告 if hasattr(request.user, manager) and request.user.manager: return qs.filter(departmentrequest.user.manager.department) # 普通员工只能看到自己的 return qs.filter(ownerrequest.user) def save_model(self, request, obj, form, change): if not change: # 新建时自动绑定创建人 obj.owner request.user super().save_model(request, obj, form, change)这段代码完成了什么✅ 管理员登录 admin 后台看到完整数据列表✅ 部门经理登录列表自动过滤为本部门数据且无法通过 URL 参数绕过get_queryset在每次请求时执行✅ 普通员工登录列表只剩自己提交的报告✅ 所有数据修改操作自动记录owner字段✅ 列表页自带按部门、状态筛选按标题/内容搜索✅ 新建报告时owner字段不可编辑后端强制赋值。而在 Flask 里你要分别处理前端模板里写{% if current_user.role manager %}API 路由里加if not can_view_department(current_user, dept_id): abort(403)表单提交时手动report.owner current_user.id搜索功能得自己写 SQLWHERE title LIKE %?% OR content LIKE %?%筛选功能得解析?department1statusapproved参数再拼 WHERE 条件。这不是代码量的差异是心智负担的差异。Django 把“权限”这件事从“每次都要想一遍怎么写”变成了“写一次全局生效”。2.3 管理后台不是“玩具”而是“生产力引擎”很多团队嫌弃 Django admin “丑”“不够定制化”于是花两周时间用 Vue 重写一个管理后台。我们做过对比一个 5 人团队用 Django admin 开发内部工具平均交付周期是 3 天/功能用自研后台是 11 天/功能。差距在哪就在那些“看不见的胶水”。关联数据的懒加载客户要查“某份报告的所有审批记录”在 admin 里我们只需在ReportAdmin中加一行inlines [ApprovalInline] # ApprovalInline 是另一个 ModelAdmin点开报告详情页审批记录以表格形式内嵌展示支持增删改查无需写任何前端代码。Flask 方案下你得写/api/reports/id/approvals接口前端用 Axios 请求再用 Vue 渲染表格处理分页、排序、错误提示……批量操作的原子性客户要求“一键驳回所有待审批报告”。Django admin 默认提供“Actions”下拉菜单你只需写def reject_reports(self, request, queryset): queryset.update(statusrejected, rejected_attimezone.now()) reject_reports.short_description 驳回选中的报告 actions [reject_reports]这个操作在数据库层面是单条UPDATE语句毫秒级完成。Flask 里你得写循环遍历queryset逐条调用save()网络延迟、事务开销、超时风险全得自己扛。审计日志的零成本接入Django 本身不带审计日志但django-simple-history这个包安装后只需在 Model 上加一行history HistoricalRecords()所有create/update/delete操作自动记录到historical_report表连request.user都自动捕获。我们上线第二天客户就用这个功能查出了“谁在凌晨两点误删了测试数据”。Django admin 的本质是一个可编程的、面向开发者的数据操作界面。它不追求视觉惊艳但追求“让开发者少写 90% 的 CRUD 代码”。当你把时间从“造轮子”转向“解决业务问题”客户感受到的就是“为什么你们总能比别人快”。3. 迁移实操从 Flask 到 Django 的一小时落地指南说“一小时完成迁移”不是夸张是我们当天的真实计时。但这个“一小时”建立在三个前提上第一原有 Flask 项目结构清晰有明确的app/目录、routes.py、models.py第二团队对 Django 的settings.py、urls.py、views.py结构肌肉记忆第三我们只迁移核心业务逻辑不碰前端样式和部署脚本。下面是我当时手写的迁移 checklist现在已沉淀为我们团队的标准 SOP3.1 第一步环境与骨架初始化12 分钟# 1. 创建新虚拟环境隔离依赖 python -m venv venv-django source venv-django/bin/activate # Linux/Mac # venv-django\Scripts\activate # Windows # 2. 安装 Django PostgreSQL 驱动 pip install Django4.2,5.0 psycopg2-binary # 3. 创建 Django 项目注意用 --name 指定应用名避免和项目名混淆 django-admin startproject internal_tool . python manage.py startapp reports # 业务应用名 python manage.py startapp users # 用户扩展应用可选 # 4. 配置 settings.py关键修改点 # DATABASES 部分替换为 PostgreSQL 连接 DATABASES { default: { ENGINE: django.db.backends.postgresql, NAME: internal_tool_db, USER: db_user, PASSWORD: os.getenv(DB_PASSWORD), HOST: localhost, # 或 Cloud SQL 实例地址 PORT: 5432, } } # INSTALLED_APPS添加新应用和内置 app INSTALLED_APPS [ django.contrib.admin, django.contrib.auth, django.contrib.contenttypes, django.contrib.sessions, django.contrib.messages, django.contrib.staticfiles, reports, # 我们的业务应用 users, # 用户扩展如果需要 ] # MIDDLEWARE确保 session 和 auth 中间件在正确位置 MIDDLEWARE [ django.middleware.security.SecurityMiddleware, django.contrib.sessions.middleware.SessionMiddleware, # 必须在 CommonMiddleware 之前 django.middleware.common.CommonMiddleware, django.middleware.csrf.CsrfViewMiddleware, django.contrib.auth.middleware.AuthenticationMiddleware, # 必须在 SessionMiddleware 之后 django.contrib.messages.middleware.MessageMiddleware, ]实操心得psycopg2-binary是开发阶段的快捷选择生产环境建议用源码编译版psycopg2以获得更好性能。os.getenv()读取环境变量是必须的绝不能把密码硬编码在settings.py里——我们用 Terraform 注入 Secret Manager 的值到 Cloud Run 环境变量Django 自动读取。3.2 第二步数据模型迁移25 分钟原有 Flask 项目里models.py是这样的# flask_app/models.py from google.cloud import firestore db firestore.Client() class Report: def __init__(self, title, content, department_id, status): self.title title self.content content self.department_id department_id self.status status self.created_at datetime.utcnow() def save(self): doc_ref db.collection(reports).document() doc_ref.set({ title: self.title, content: self.content, department_id: self.department_id, status: self.status, created_at: self.created_at }) return doc_ref.id迁移到 Django我们不是“翻译”而是“重构”# reports/models.py from django.db import models from django.contrib.auth.models import User from django.utils import timezone class Department(models.Model): name models.CharField(max_length100, uniqueTrue) code models.CharField(max_length20, blankTrue) # 如 HR-001 def __str__(self): return self.name class Report(models.Model): STATUS_CHOICES [ (draft, 草稿), (pending, 待审批), (approved, 已批准), (rejected, 已驳回), ] title models.CharField(max_length200) content models.TextField() department models.ForeignKey(Department, on_deletemodels.PROTECT) # 外键约束 status models.CharField(max_length20, choicesSTATUS_CHOICES, defaultdraft) owner models.ForeignKey(User, on_deletemodels.CASCADE) # 关联 Django User created_at models.DateTimeField(defaulttimezone.now) updated_at models.DateTimeField(auto_nowTrue) # 自动更新 class Meta: ordering [-created_at] # 默认按创建时间倒序 def __str__(self): return f{self.title} ({self.get_status_display()})关键动作外键替代字符串 IDdepartment_id字符串 →ForeignKey(Department)数据库层面建立约束避免脏数据枚举替代字符串字面量status pending→choicesSTATUS_CHOICESadmin 后台自动生成下拉菜单代码里用get_status_display()获取中文时间字段自动化created_at用defaulttimezone.nowupdated_at用auto_nowTrue无需手动赋值__str__方法让 admin 后台和 shell 里打印对象时显示有意义的内容调试效率翻倍。然后执行迁移python manage.py makemigrations python manage.py migrateDjango 自动生成的0001_initial.py迁移文件包含了CREATE TABLE、CREATE INDEX、ALTER TABLE ADD CONSTRAINT全部语句。我们检查了 SQLpython manage.py sqlmigrate reports 0001确认没有DROP TABLE才执行migrate。3.3 第三步视图与路由迁移18 分钟Flask 的路由是函数式# flask_app/routes.py app.route(/reports) def list_reports(): docs db.collection(reports).stream() reports [Report(**doc.to_dict()) for doc in docs] return render_template(reports/list.html, reportsreports)Django 用类视图Class-Based Views更简洁# reports/views.py from django.shortcuts import render from django.views.generic import ListView, DetailView, CreateView, UpdateView from django.urls import reverse_lazy from .models import Report class ReportListView(ListView): model Report template_name reports/list.html context_object_name reports paginate_by 20 # 自动分页 def get_queryset(self): qs super().get_queryset() # 这里可以加权限过滤如上文所述 return qs.select_related(department, owner) # 预加载关联数据避免 N1 查询 class ReportDetailView(DetailView): model Report template_name reports/detail.html context_object_name report # reports/urls.py from django.urls import path from . import views urlpatterns [ path(, views.ReportListView.as_view(), namereport-list), path(int:pk/, views.ReportDetailView.as_view(), namereport-detail), ]urls.py主入口# internal_tool/urls.py from django.contrib import admin from django.urls import path, include urlpatterns [ path(admin/, admin.site.urls), # Django 自带 admin path(reports/, include(reports.urls)), # 我们的业务路由 ]注意select_related(department, owner)是关键优化。它把Report、Department、User三张表用JOIN一次查出而不是默认的“查 Report → 查 Department → 查 User”三次查询。在列表页有 50 条数据时N1 问题会让数据库查询从 50 次飙升到 150 次。Django 的 ORM 提供了select_related一对一/外键和prefetch_related多对多/反向外键两种方案用错一个性能就掉一半。3.4 第四步前端模板适配5 分钟Django 模板语法和 Jinja2Flask 默认高度兼容90% 的 HTML 可以直接复用。唯一要改的是变量引用!-- Flask Jinja2 -- h1{{ report.title }}/h1 p部门{{ report.department.name }}/p !-- Django Template -- h1{{ report.title }}/h1 p部门{{ report.department.name }}/p !-- 完全一样 -- !-- 但循环语法略有不同 -- !-- Flask -- {% for r in reports %} li{{ r.title }}/li {% endfor %} !-- Django -- {% for r in reports %} li{{ r.title }}/li {% endfor %} !-- 也一样 --真正要改的是表单提交部分。Flask 里常写form action/reports/create methodPOST而 Django 推荐用ModelForm# reports/forms.py from django import forms from .models import Report class ReportForm(forms.ModelForm): class Meta: model Report fields [title, content, department, status] widgets { content: forms.Textarea(attrs{rows: 4}), }模板里!-- reports/create.html -- form methodpost {% csrf_token %} {{ form.as_p }} !-- 自动生成带标签的表单 -- button typesubmit提交/button /form{% csrf_token %}是 Django 的安全机制防止跨站请求伪造必须加。form.as_p会自动生成plabelTitle:/labelinput typetext nametitle/p省去手写 HTML 的麻烦。4. 部署与协作Terraform Django 的稳定组合项目正文提到“started automating the deployment using Terraform”这绝不是一句客套话。在 Google Cloud 上部署 Django我们有一套经过 17 个项目验证的 Terraform 模块核心思想是基础设施即代码IaC不是为了炫技而是为了消灭“在我机器上是好的”这类扯皮。4.1 Terraform 模块设计逻辑我们的main.tf不是堆砌资源而是分层抽象# 1. 网络层networking.tf module vpc { source terraform-google-modules/network/google version ~ 6.0 project_id var.project_id network_name ${var.env}-vpc subnets [{ subnet_name ${var.env}-subnet subnet_ip 10.10.${var.env_num}.0/24 subnet_region var.region }] } # 2. 数据库层database.tf module cloud_sql { source terraform-google-modules/sql-db/google version ~ 6.0 project_id var.project_id region var.region name ${var.env}-postgres database_version POSTGRES_14 tier db-g1-small # 开发/测试用生产换 db-custom-2-7680 backup_configuration { enabled true binary_log_enabled true retention_unit COUNT retention_quantity 7 } } # 3. 应用层app.tf resource google_cloud_run_service django_app { name ${var.env}-django-app location var.region template { spec { containers { image us-central1-docker.pkg.dev/${var.project_id}/my-repo/django-app:${var.tag} ports { container_port 8080 } env { name DJANGO_SETTINGS_MODULE value internal_tool.settings.production } env { name DB_HOST value module.cloud_sql.private_ip_address } # ... 其他环境变量 } } } traffic { percent 100 latest_revision true } }关键设计点环境隔离var.env控制dev/staging/prod每个环境有独立 VPC、独立 Cloud SQL 实例、独立 Cloud Run 服务数据库密码、密钥通过 Secret Manager 注入绝不硬编码数据库连接安全Cloud SQL 实例开启私有 IPCloud Run 服务部署在同一 VPC 内流量不走公网避免暴露数据库端口镜像构建自动化CI 流程GitHub Actions监听main分支推送自动构建 Docker 镜像并推送到 Artifact RegistryTerraform 通过var.tag拉取指定版本回滚只需改tag值再apply。4.2 Django 生产设置要点settings/production.py是安全红线我们强制要求# settings/production.py import os from .base import * DEBUG False # 必须为 False # SECRET_KEY 从环境变量读取绝不在代码里 SECRET_KEY os.environ[DJANGO_SECRET_KEY] # ALLOWED_HOSTS 必须精确指定 ALLOWED_HOSTS [internal-tool-prod.example.com, us-central1-my-project.cloudrun.app] # 静态文件托管到 Cloud Storage DEFAULT_FILE_STORAGE storages.backends.gcloud.GoogleCloudStorage GS_BUCKET_NAME my-prod-static-bucket GS_DEFAULT_ACL publicRead # 日志发送到 Cloud Logging LOGGING { version: 1, disable_existing_loggers: False, handlers: { cloud_logging: { class: google.cloud.logging.handlers.CloudLoggingHandler, client: client, }, }, loggers: { django: { handlers: [cloud_logging], level: INFO, }, }, } # 安全中间件生产必备 SECURE_HSTS_SECONDS 31536000 SECURE_SSL_REDIRECT True SESSION_COOKIE_SECURE True CSRF_COOKIE_SECURE True SECURE_CONTENT_TYPE_NOSNIFF True X_FRAME_OPTIONS DENY实操心得DEBUGFalse时Django 不再显示详细的错误页面含代码片段、环境变量而是返回通用 500 页面。我们用 Sentry 监控线上错误一旦出现异常Sentry 自动抓取堆栈、用户信息、HTTP 请求头比自己看日志快十倍。SECURE_SSL_REDIRECTTrue强制 HTTP 重定向到 HTTPSGoogle Cloud Load Balancer 已配置 SSL 证书这里只需开关。4.3 团队协作的隐形收益这次迁移最大的意外收获是团队协作效率的跃升。以前 Flask 项目里前端同事改个按钮颜色要等后端同事改完requirements.txt、重启本地服务、再通知“好了你试试”现在Django 的manage.py runserver自动热重载前端改完 HTML/CSS刷新浏览器立刻生效。更关键的是所有成员都能直接操作 admin 后台产品经理想看最新 10 条“已驳回”报告不用找后端要 SQL自己登录 admin点几下筛选就导出 CSV测试同事发现某个审批流程卡住直接进 admin 查看Approval表的status字段5 秒定位问题运维同事排查慢查询用 Django Debug Toolbar开发环境点开 SQL 面板一眼看出哪条SELECT缺少索引。这种“人人都是数据分析师”的能力不是靠培训是靠框架设计赋予的。Django 把数据操作的门槛从“会写 SQL”降到了“会点鼠标”。5. 常见问题与避坑指南来自 17 个 Django 项目的血泪总结即使是最成熟的框架踩坑也是常态。我把这些年在 Six Feet Up 团队遇到的高频问题按发生阶段归类附上根因分析和实操解法。这些不是文档里能找到的答案是我们在凌晨两点 debug 时用咖啡和耐心换来的经验。5.1 开发阶段那些让你怀疑人生的“小问题”问题 1makemigrations生成了空迁移文件但migrate报错Relation xxx does not exist根因数据库里已有表但 Django 的django_migrations表里没有对应记录。常见于从旧系统导入数据后忘记python manage.py migrate --fake-initial。解法先运行python manage.py showmigrations查看哪些 migration 未执行对初始 migration如0001_initial执行python manage.py migrate --fake-initial对后续 migration正常migrate。预防新项目初始化后第一时间python manage.py migrate再导入初始数据或用--fake参数标记已存在表。问题 2admin 后台里 ForeignKey 字段显示object而不是有意义的名称根因Model 缺少__str__方法或__str__返回None。解法在 Model 中添加def __str__(self): return self.name or self.title or fID {self.pk}确保返回字符串类型。经验我们团队的代码审查清单第一条就是“所有 Model 必须有__str__”CI 流程用pylint检查missing-str-rc。问题 3select_related不生效N1 查询照旧根因select_related只对ForeignKey和OneToOneField有效对ManyToManyField或反向关系如report_set无效或select_related放在filter()之后导致链式调用失效。解法对多对多用prefetch_related确保select_related在filter()之前调用如Report.objects.select_related(department).filter(statuspending)用 Django Debug Toolbar 的 SQL 面板实时验证。5.2 部署阶段云环境特有的“玄学故障”问题 4Cloud Run 上 Django 首页 502 Bad Gateway日志显示Connection refused根因Django 默认监听127.0.0.1:8000而 Cloud Run 要求服务监听0.0.0.0:8080或gunicorn启动命令未指定--bind 0.0.0.0:8080。解法在Dockerfile中CMD [gunicorn, --bind, 0.0.0.0:8080, internal_tool.wsgi:application]确保gunicorn版本 21.0旧版对 Cloud Run 兼容性差。验证本地docker run -p 8080:8080 your-imagecurlhttp://localhost:8080应返回 200。问题 5静态文件CSS/JS404admin 后台样式全乱根因生产环境DEBUGFalseDjango 不再自动提供静态文件STATIC_URL和STATIC_ROOT配置错误或 Cloud Storage bucket 权限未设为publicRead。解法python manage.py collectstatic --noinput将所有静态文件收集到STATIC_ROOT目录Terraform 确保 bucket 的iam_policy允许allUsers读取STATIC_URL设为https://storage.googleapis.com/my-bucket/static/。技巧用whitenoise包可让 Django 自己 serve 静态文件仅限小流量但生产推荐 Cloud Storage。5.3 性能阶段当“快”变成“慢”的临界点问题 6列表页加载超过 3 秒Debug Toolbar 显示 200 条 SQL 查询根因模板中循环使用了{{ report.department.name }}而 report.department