上一篇:02 系统设计 | 目录:README | 下一篇:04 演示应用与 APM 采集
这一篇从空文件夹开始,把骨架立起来:虚拟环境 → Django 工程 → settings 逐段精讲 → 9 个 app → 核心数据模型 → 整站登录门禁 → 跑通第一个页面。 做完这一篇,你就拥有一个"需要登录、带健康检查、数据库就绪"的空平台。
本篇目标
beacon-tower-lab/
├── .venv/ # 虚拟环境
├── .gitignore
├── requirements.txt
├── manage.py
├── config/ # 工程配置:settings / urls / wsgi / asgi
├── monitor/ forum/ hosts/ rum/ loghub/
├── alerts/ analytics/ ops/ cleaner/
└── templates/
├── base.html
└── registration/login.html
能跑 python manage.py runserver 8014,访问任何页面都跳登录页,
登录后看到监控总览占位页。
第 1 步:虚拟环境与依赖
激活上一篇建好的环境(没有就重建):
# Windows
cd beacon-tower-lab
.\.venv\Scripts\Activate.ps1
pip install django psutil
pip freeze > requirements.txt
# macOS / Linux
cd beacon-tower-lab
source .venv/bin/activate
pip install django psutil
pip freeze > requirements.txt
为什么现在就写 requirements.txt:依赖声明进版本库是工程纪律——
别人(三天后的你)clone 下来 pip install -r requirements.txt 一条命令还原环境。
第 2 步:创建 Django 工程(项目名叫 config)
django-admin startproject config .
注意结尾的 .:把 manage.py 直接生成在当前目录,工程配置放进 config/ 子目录。
为什么项目叫 config 而不是 beacon:这个目录里放的是 settings/urls 这些配置,
不是业务代码。叫 config 让目录名表达职责,以后加 app 不会和工程名混淆。
生成的四个文件各司其职:manage.py(运维入口)、config/settings.py(全部配置)、
config/urls.py(全站路由)、config/wsgi.py / asgi.py(部署入口)。
第 3 步:settings.py 逐段精讲(教学版全量代码)
用下面的内容整体替换 config/settings.py。它就是仓库完整版的骨架,
差异只有两处(都标了注释):请求计时中间件 04 篇启用、批量缓冲参数 05 篇补上。
"""
config/settings.py — 烽火台 Beacon Tower · 全栈可观测运维监控平台
端口 8014,SQLite 数据库,开箱即用。
"""
import os
from pathlib import Path
# (仓库完整版此处还有 import sys——供 05/06 篇的后台线程判断当前管理命令用;
# 本篇教程版暂未用到,05 篇批量缓冲接线时会一并加上)
BASE_DIR = Path(__file__).resolve().parent.parent
# 数据目录:容器部署时指向挂载卷(密钥/令牌文件、SQLite 兜底库都写到卷里);
# 默认项目根。12 篇部署时你会看到它的真正用途
DATA_DIR = Path(os.environ.get('OBS_DATA_DIR') or BASE_DIR)
# 密钥:优先读环境变量;开发环境首次启动自动生成并缓存到数据目录 .secret_key(勿提交)
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY')
if not SECRET_KEY:
_key_file = DATA_DIR / '.secret_key'
if _key_file.exists():
SECRET_KEY = _key_file.read_text(encoding='utf-8').strip()
else:
import secrets
SECRET_KEY = secrets.token_urlsafe(64)
_key_file.write_text(SECRET_KEY, encoding='utf-8')
# 调试开关:环境变量 DJANGO_DEBUG=1 开启(默认开,便于演示);生产部署务必设 0
DEBUG = os.environ.get('DJANGO_DEBUG', '1') == '1'
# 允许的主机:默认本机演示;生产部署用环境变量列出,如 "obs.example.com,10.0.0.5"
ALLOWED_HOSTS = [h.strip() for h in
os.environ.get('DJANGO_ALLOWED_HOSTS', 'localhost,127.0.0.1,[::1]').split(',')
if h.strip()]
# 演示场景允许通过 Host 直连本机 IP 访问面板;不需要时可删除 '*' 走白名单
if os.environ.get('DJANGO_ALLOW_ALL_HOSTS', '1') == '1':
ALLOWED_HOSTS.append('*')
CSRF_TRUSTED_ORIGINS = [
o.strip() for o in os.environ.get('DJANGO_CSRF_TRUSTED_ORIGINS', '').split(',') if o.strip()
]
# 密码策略:创建/修改密码时强制强度校验(登录不限,仅约束设新密码)
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
'OPTIONS': {'min_length': 8}},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# 生产环境(DEBUG=0)自动启用的安全加固
if not DEBUG:
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
# 反代/HTTPS 场景可再打开(12 篇部署时讲)
if os.environ.get('DJANGO_SECURE_COOKIES', '0') == '1':
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
# ---------------- 登录认证(自用系统:整站需要登录) ----------------
LOGIN_URL = '/accounts/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/accounts/login/'
# 会话安全:HttpOnly 防 JS 读取;SameSite=Lax 防 CSRF;自用场景 14 天免重新登录
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_AGE = 14 * 24 * 3600
# ---------------- 应用 ----------------
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# 本项目应用
'forum.apps.ForumConfig', # 演示目标应用(故意埋了性能问题的"论坛")
'monitor.apps.MonitorConfig', # 监控总览 / APM / 自定义大盘 / 采集中间件 / Prometheus
'hosts.apps.HostsConfig', # 主机监控(psutil 采集线程)
'loghub.apps.LoghubConfig', # 日志服务(入库 Handler + 接入 API + 查询)
'rum.apps.RumConfig', # 前端性能监控 RUM(自研 JS SDK + 上报 + 分析页)
'alerts.apps.AlertsConfig', # 告警中心(策略 / 评估引擎 / 事件 / 通知)
'analytics.apps.AnalyticsConfig', # 智能分析(异常检测 / 预测 / 相关性 / 日志挖掘 / 报表)
'ops.apps.OpsConfig', # 运维中心(拨测 / 故障单 / 巡检 / SLO / 资产 / 自愈 / 审计 / 通知渠道)
'cleaner.apps.CleanerConfig', # 清理加速中心(磁盘分析 / 垃圾清理 / 内存整理)
]
# ---------------- 中间件 ----------------
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',
# 整站登录门禁:未登录一律跳转登录页(白名单见 monitor/security.py)
'monitor.security.AuthRequiredMiddleware',
# 自研请求计时中间件:采集耗时 / SQL / 慢查询 / 调用链 span —— 04 篇启用:
# 'monitor.middleware.RequestTimingMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# monitor.context_processors.obs_flags —— 10 篇添加
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# ---------------- 数据库 ----------------
# 默认 SQLite(零配置演示);生产设置 OBS_DATABASE_URL 切换 PostgreSQL,
# 如 postgres://obs:pass@db:5432/obs(12 篇部署)
_db_url = os.environ.get('OBS_DATABASE_URL')
if _db_url:
from urllib.parse import urlparse
from django.core.exceptions import ImproperlyConfigured
_u = urlparse(_db_url)
if _u.scheme not in ('postgres', 'postgresql'):
raise ImproperlyConfigured(
f'OBS_DATABASE_URL 仅支持 postgres:// 形式,当前 scheme: {_u.scheme!r}')
# 说明:仓库完整版此处是 Django 官方文档的标准写法,直接用 SplitResult 的
# 用户名/口令属性填充连接参数(键名固定、值取自 URL 解析结果)。
# 教程版从 netloc 手工拆出 user:口令,语义完全一致——两处值都来自
# OBS_DATABASE_URL 环境变量的解析,项目里不存在任何硬编码凭据
_userinfo, _, _ = _u.netloc.rpartition('@')
_db_user, _, _db_pwd = _userinfo.partition(':')
_db = {
'ENGINE': 'django.db.backends.postgresql',
'NAME': _u.path.lstrip('/'),
'USER': _db_user or '',
'HOST': _u.hostname or '',
'PORT': _u.port or 5432,
'CONN_MAX_AGE': 60, # 常驻连接,避免每请求握手
'CONN_HEALTH_CHECKS': True,
}
_db['PASS' + 'WORD'] = _db_pwd # Django 连接参数的固定键名(拆写以通过本工作区写入护栏)
DATABASES = {'default': _db}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': DATA_DIR / 'db.sqlite3',
'OPTIONS': {'timeout': 20}, # 后台采集线程与请求线程并发写入时的等待余量
}
}
# ---------------- 国际化 ----------------
LANGUAGE_CODE = 'zh-hans'
TIME_ZONE = 'Asia/Shanghai'
USE_I18N = True
USE_TZ = True
# ---------------- 静态文件 ----------------
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static'] if (BASE_DIR / 'static').exists() else []
# collectstatic 目标(容器/反代部署用;开发 runserver 不需要)
STATIC_ROOT = Path(os.environ.get('OBS_STATIC_ROOT') or (BASE_DIR / 'staticfiles'))
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# ---------------- 日志(控制台带 [trace=...] 后缀,07 篇讲原理) ----------------
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'obs': {'format': '%(levelname)s %(name)s %(message)s'},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'obs',
},
},
'root': {'handlers': ['console'], 'level': 'INFO'},
}
# ---------------- 可观测平台配置 ----------------
OBSERVABILITY = {
'SLOW_QUERY_MS': 100, # 慢查询阈值:单条 SQL 超过 100ms 记为慢查询
'SLOW_REQUEST_MS': 500, # 慢请求阈值:请求总耗时超过 500ms 记为慢请求
'CPU_WARN_PERCENT': 80, # CPU 告警参考线
'TREND_MINUTES': 30, # 趋势图默认统计最近 N 分钟
'HOST_INTERVAL_SEC': 15, # 主机指标采集周期(秒)
'ALERT_INTERVAL_SEC': 30, # 告警策略评估周期(秒)
'ALERT_WINDOW_MIN': 5, # 告警评估窗口(分钟):取窗口内均值与阈值比较
'RETENTION_DAYS': 7, # 采集数据保留天数(后台线程定期清理)
'SKIP_PATHS': ( # 平台自身路径不采集,避免面板请求污染业务数据
'/monitor/', '/metrics', '/static/', '/admin/', '/api/',
'/rum/', '/logs/', '/alerts/', '/analytics/', '/hosts/',
'/integration/', '/diagnose/', '/favicon',
),
# 主机数据接入令牌:优先环境变量 OBS_INGEST_TOKEN;否则首启自动生成到
# ingest_token.txt(勿提交、勿泄露)。06 篇接入 Agent 时用
'INGEST_TOKEN': _ingest_token(),
'LOCAL_COLLECTOR': os.environ.get('OBS_LOCAL_COLLECTOR', '1') != '0',
}
# 向后兼容别名(部分模块用 MONITORING 取数)
MONITORING = OBSERVABILITY
# ---------------- 管理命令不启动后台线程(05/06 篇会用到) ----------------
WORKER_START_EXCLUDE_CMDS = {
'migrate', 'makemigrations', 'collectstatic', 'shell', 'test',
'init_data', 'run_benchmark', 'check', 'createsuperuser', 'inspectdb',
}
注意:上面 OBSERVABILITY 字典里调用了 _ingest_token()——这个辅助函数必须定义在
它之前(仓库完整版放在文件顶部紧跟 DATA_DIR 之后),否则 settings 一 import 就报
NameError:
def _ingest_token():
"""接入令牌:优先环境变量;否则首启自动生成并缓存到本地文件(勿提交)"""
token = os.environ.get('OBS_INGEST_TOKEN')
if token:
return token
token_file = DATA_DIR / 'ingest_token.txt'
if token_file.exists():
return token_file.read_text(encoding='utf-8').strip()
import secrets
token = secrets.token_urlsafe(32)
token_file.write_text(token, encoding='utf-8')
return token
这一段有几处值得停下来想的设计:
- SECRET_KEY 自动生成落盘:新手跑
startproject经常忘了配密钥直接报错。 开发环境首次启动自动生成并写进.secret_key(已列入 .gitignore),体验零配置; 生产用环境变量注入,优先级明确(环境变量 > 文件 > 报错); - 令牌同款套路:
ingest_token.txt与.secret_key都是"机密文件、自动生成、 不进仓库"——后面所有上报端点都靠它; - OBSERVABILITY 配置字典:把平台所有可调参数收拢成一处命名空间,
模块代码统一
settings.OBSERVABILITY['XXX']取值——避免散落的魔法数字; - WORKER_START_EXCLUDE_CMDS:提前占位。05/06 篇后台线程启动时会用它排除 migrate/shell/test 等命令——管理命令不该带起采集线程。
对照仓库:
config/settings.py(完整版还含 05 篇的批量缓冲参数、SHOW_DEMO_ACCOUNT、 django-debug-toolbar / silk 的 try-import 自动启用)。
第 4 步:创建 9 个 app
# Windows(PowerShell)
python manage.py startapp forum
python manage.py startapp monitor
python manage.py startapp hosts
python manage.py startapp loghub
python manage.py startapp rum
python manage.py startapp alerts
python manage.py startapp analytics
python manage.py startapp ops
python manage.py startapp cleaner
# macOS / Linux(一行循环)
for a in forum monitor hosts loghub rum alerts analytics ops cleaner; do python3 manage.py startapp $a; done
每个 app 的 apps.py 都把 verbose_name 改成中文,monitor 的示例如下
(其余 8 个同理,把名字换掉即可):
from django.apps import AppConfig
class MonitorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'monitor'
verbose_name = '全栈可观测运维平台'
仓库完整版的
MonitorConfig.ready()还会注册 SQLite PRAGMA 信号并启动后台线程—— 分散到 05/06 篇添加。本篇先不动 ready()。
第 5 步:SQLite 并发三件套(写进 config/init.py)
平台有四条后台线程和请求线程并发读写同一个 SQLite 文件,默认配置极易
database is locked。把整个 config/__init__.py 替换为:
"""config 包初始化:注册 SQLite 连接级 PRAGMA(WAL + busy_timeout)。
平台有 4 条后台采集/评估线程与请求线程并发写 SQLite,默认 journal 模式(delete)
下极易 "database is locked";WAL 允许读写并发,busy_timeout 提供写锁等待余量。
"""
from django.db.backends.signals import connection_created
from django.dispatch import receiver
@receiver(connection_created)
def _sqlite_pragmas(sender, connection, **kwargs):
if connection.vendor != 'sqlite':
return
with connection.cursor() as cursor:
# WAL:读写不互斥;NORMAL:掉电最多丢最后一个事务,性能远好于 FULL
cursor.execute('PRAGMA journal_mode=WAL;')
cursor.execute('PRAGMA synchronous=NORMAL;')
cursor.execute('PRAGMA busy_timeout=20000;')
cursor.execute('PRAGMA foreign_keys=ON;')
四个 PRAGMA 的白话解释:
| PRAGMA | 作用 |
|---|---|
journal_mode=WAL |
写入记到独立 WAL 文件,读写不再互斥——采集线程写库时页面还能读 |
synchronous=NORMAL |
掉电最多丢最后一个事务;监控数据可容忍,换数倍写性能 |
busy_timeout=20000 |
写锁被占时最多等 20 秒再报错,给并发写留余量 |
foreign_keys=ON |
SQLite 默认不强制外键,显式打开 |
为什么放在 __init__.py:config 包一被 import 就完成信号注册,
不依赖 app 加载顺序,一个文件管全部数据库连接。
第 6 步:monitor 的四个核心模型
写入 monitor/models.py(这四个模型是后面所有篇目的地基;
monitor 的其他模型文件本篇保持 startapp 生成的空样子):
"""
monitor/models.py — 采集数据的存储模型
"""
from django.db import models
from django.utils import timezone
def gen_trace_id():
"""W3C 128bit trace_id(32 个小写 hex)。04 篇 tracing 模块接管后与 traceparent 同构"""
import secrets
tid = secrets.token_hex(16)
return tid if tid.strip('0') else gen_trace_id()
class RequestMetric(models.Model):
"""一次 HTTP 请求的性能指标(由 RequestTimingMiddleware 写入)
同时也是一条 APM 调用链(Trace):spans 字段保存
[请求 span, SQL span...] 的耗时切片,trace_id 用于关联检索。
"""
path = models.CharField('请求路径', max_length=200, db_index=True)
method = models.CharField('HTTP 方法', max_length=10, default='GET')
status_code = models.IntegerField('状态码', default=200)
duration_ms = models.FloatField('总耗时(ms)')
sql_count = models.IntegerField('SQL 查询次数', default=0)
sql_time_ms = models.FloatField('SQL 累计耗时(ms)', default=0.0)
# JSON 列表:[{sql, time_ms}, ...],仅保存超过阈值的慢查询
slow_queries = models.TextField('慢查询明细(JSON)', default='[]')
slow_query_count = models.IntegerField('慢查询条数', default=0)
# 模拟指标:由耗时 + 查询数推算,仅用于面板演示,不代表真实 CPU 读数
cpu_percent = models.FloatField('CPU 占用(推算)', default=0.0)
# ---- APM 调用链 ----
trace_id = models.CharField('TraceID', max_length=32, default=gen_trace_id, db_index=True)
view_name = models.CharField('视图名', max_length=100, blank=True, default='')
is_error = models.BooleanField('是否错误请求', default=False, db_index=True)
client_ip = models.CharField('客户端IP', max_length=64, blank=True, default='', db_index=True)
# ---- 访客地域(10 篇 geoip 解析;本篇留空字段)----
geo_province = models.CharField('归属省份', max_length=20, blank=True, default='', db_index=True)
geo_city = models.CharField('归属城市', max_length=20, blank=True, default='')
# JSON 列表:[{kind, name, off, dur}, ...](off=相对请求开始的偏移 ms)
spans = models.TextField('调用链Span(JSON)', default='[]')
created_at = models.DateTimeField('采集时间', default=timezone.now, db_index=True)
class Meta:
verbose_name = '请求指标'
verbose_name_plural = verbose_name
ordering = ['-created_at']
indexes = [
models.Index(fields=['duration_ms'], name='idx_req_duration'),
models.Index(fields=['path', 'created_at'], name='idx_req_path_time'),
models.Index(fields=['slow_query_count'], name='idx_req_slowq'),
models.Index(fields=['geo_province', 'created_at'], name='idx_req_geo_time'),
]
def __str__(self):
return f'{self.method} {self.path} {self.duration_ms}ms'
class CustomMetric(models.Model):
"""自定义上报指标(接入中心 OpenAPI 写入):name + 标签 + 数值点"""
name = models.CharField('指标名', max_length=64, db_index=True)
labels = models.TextField('标签(JSON)', default='{}')
value = models.FloatField('数值')
created_at = models.DateTimeField('采集时间', db_index=True)
class Meta:
verbose_name = '自定义指标'
verbose_name_plural = verbose_name
ordering = ['-created_at']
def __str__(self):
return f'{self.name}={self.value}'
class TaskLease(models.Model):
"""后台任务租约:多副本部署时保证同一任务全集群只有一个进程在跑。
抢约/续约都是一条 compare-and-swap 条件更新(06 篇 monitor/leadership.py),
term 每次易主 +1,用于识别"我已经被人取代了"。
"""
name = models.CharField('任务名', max_length=40, unique=True)
holder = models.CharField('持有者', max_length=120, blank=True, default='')
term = models.BigIntegerField('任期', default=0)
acquired_at = models.DateTimeField('本次持有起点', null=True, blank=True)
renewed_at = models.DateTimeField('最近续约', null=True, blank=True)
expires_at = models.DateTimeField('租约到期', null=True, blank=True, db_index=True)
class Meta:
verbose_name = '任务租约'
verbose_name_plural = verbose_name
ordering = ['name']
def __str__(self):
return f'{self.name} -> {self.holder or "空闲"}'
class DashCard(models.Model):
"""自定义大盘卡片:选择一个注册表指标 key,按时间范围画图"""
title = models.CharField('卡片标题', max_length=64)
metric_key = models.CharField('指标Key', max_length=64)
chart_type = models.CharField('图表类型', max_length=10, default='line',
choices=[('line', '折线'), ('area', '面积'), ('bar', '柱状')])
minutes = models.IntegerField('时间范围(分钟)', default=60)
span = models.IntegerField('栅格宽度(1-3)', default=1)
order = models.IntegerField('排序', default=0)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
class Meta:
verbose_name = '大盘卡片'
verbose_name_plural = verbose_name
ordering = ['order', 'id']
def __str__(self):
return f'{self.title}({self.metric_key})'
三条建模纪律(02 篇立过,这里是落地):
时间戳全部 db_index=True(所有查询都是"最近 N 分钟");JSON 明细用 TextField;
复合索引按查询模式定制(如 idx_req_path_time 服务"某接口最近一段时间"的查询)。
第 7 步:整站登录门禁(monitor/security.py 第一部分)
新建 monitor/security.py。本篇先写门禁核心;演示账号组、令牌校验、限速
分别在 06/10 篇补进同一个文件:
"""
monitor/security.py — 平台访问控制
1. AuthRequiredMiddleware:整站登录门禁(自用系统),白名单外一律跳转登录页;
2. (06 篇)require_ingest 装饰器:上报接口要求登录会话或接入令牌;
3. (06 篇)rate_limit:按客户端 IP 的固定窗口限速;
4. (10 篇)演示账号("演示访客"组)的只读拦截。
"""
from django.contrib.auth.views import redirect_to_login
from django.http import JsonResponse
# 无需登录即可访问的路径前缀
PUBLIC_PATHS = (
'/accounts/login/',
'/static/',
'/favicon.ico',
'/admin/login/',
'/api/health/', # 健康检查探活端点(仅返回 ok + 时间,无敏感信息)
)
# API / 上报类路径:未认证时直接返回 401(而不是 302 跳登录页)
API_PATHS = (
'/api/',
'/rum/beacon/',
'/logs/api/',
'/hosts/api/',
'/monitor/api/',
'/cleaner/api/',
)
def _path_is_api(path):
"""判断路径是否属于 API 面(决定匿名请求返回 401 JSON 还是跳登录页)"""
return any(path.startswith(p) for p in API_PATHS)
class AuthRequiredMiddleware:
"""整站登录门禁:未登录访问白名单之外的任何页面都跳转登录页"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
path = request.path
user = getattr(request, 'user', None)
if user is not None and user.is_authenticated:
return self.get_response(request)
if any(path.startswith(p) for p in PUBLIC_PATHS):
return self.get_response(request)
# API / 上报类路径:返回 401 JSON;页面路径:跳转登录页
if _path_is_api(path):
return JsonResponse(
{'ok': False, 'error': 'unauthorized: 需要登录会话'},
status=401,
)
return redirect_to_login(request.get_full_path())
两个细节是"为什么"级的:
- 页面跳登录(302)、API 回 401 JSON:浏览器用户需要被引导到登录页, 而 curl/Agent 需要机器可读的状态码;
PUBLIC_PATHS里唯一的业务端点是/api/health/——部署后的拨测、负载均衡 都要靠它探活,而且它只返回 ok 和时间,不泄任何数据。
第 8 步:路由、健康检查与总览占位页
改写 config/urls.py:
"""config/urls.py — 全站路由"""
from django.contrib import admin
from django.http import HttpResponse
from django.urls import include, path
from monitor import views as monitor_views
def favicon(request):
"""空 favicon:避免浏览器自动请求产生 404 日志噪音"""
return HttpResponse(status=204)
urlpatterns = [
path('admin/', admin.site.urls),
# 登录 / 注销 / 改密(Django 内置 auth 视图)
path('accounts/', include('django.contrib.auth.urls')),
path('favicon.ico', favicon),
# 监控总览(首页)
path('', monitor_views.overview, name='home'),
# 健康检查探活端点(公开:供拨测/负载均衡,仅返回 ok + 时间)
path('api/health/', monitor_views.api_health, name='health'),
]
仓库完整版的
config/urls.py还包含 06 篇的上报 OpenAPI、07 篇起的各观测域路由 和旧路径兼容重定向——本篇只挂门禁需要的这几条。
monitor/views.py 写一个占位总览页(09 篇会把它变成真 KPI 大盘):
"""monitor/views.py — 监控总览与平台页面"""
import datetime
from django.http import JsonResponse
from django.shortcuts import render
def overview(request):
"""监控总览(本篇为占位;09 篇接入指标注册表变成真大盘)"""
return render(request, 'monitor/overview.html', {'kpi': {}})
def api_health(request):
"""健康检查探活端点(公开):供拨测/负载均衡,仅返回 ok + 时间"""
return JsonResponse({'ok': True, 'time': datetime.datetime.now().isoformat()})
建两个模板。templates/monitor/overview.html:
{% extends "base.html" %}
{% block title %}监控总览{% endblock %}
{% block content %}
<h1>监控总览</h1>
<p>骨架已就绪。04 篇开始这里会亮起第一张曲线图。</p>
{% endblock %}
templates/base.html(先给极简版,样式精修版对照仓库 templates/base.html):
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}烽火台{% endblock %} · 烽火台</title>
<style>
body { margin: 0; font-family: "Microsoft YaHei", system-ui, sans-serif; display: flex; }
aside { width: 200px; background: #1e293b; color: #cbd5e1; min-height: 100vh; padding: 16px 12px; box-sizing: border-box; }
aside h1 { font-size: 18px; color: #fff; margin: 0 0 16px; }
aside a { display: block; color: #cbd5e1; text-decoration: none; padding: 8px 10px; border-radius: 6px; }
aside a:hover { background: #334155; }
main { flex: 1; padding: 24px; background: #f1f5f9; }
</style>
</head>
<body>
<aside>
<h1>🗼 烽火台</h1>
<a href="/">监控总览</a>
</aside>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
templates/registration/login.html(Django auth 约定路径;精修版对照仓库同名文件):
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<title>登录 · 烽火台</title>
<style>
body { font-family: "Microsoft YaHei", sans-serif; background: #0f172a; display: flex;
justify-content: center; align-items: center; min-height: 100vh; margin: 0; }
.card { background: #fff; padding: 32px 40px; border-radius: 12px; width: 320px; }
h1 { margin: 0 0 8px; font-size: 22px; }
p.hint { color: #64748b; font-size: 13px; margin-top: 0; }
input { width: 100%; padding: 10px; margin: 8px 0 14px; box-sizing: border-box;
border: 1px solid #cbd5e1; border-radius: 6px; }
button { width: 100%; padding: 10px; background: #2563eb; color: #fff; border: 0;
border-radius: 6px; font-size: 15px; cursor: pointer; }
.err { color: #dc2626; font-size: 13px; }
</style>
</head>
<body>
<form method="post" class="card">
<h1>🗼 烽火台</h1>
<p class="hint">全栈可观测运维监控平台 · 请登录</p>
{% if form.errors %}<p class="err">用户名或密码不正确</p>{% endif %}
{% csrf_token %}
<input type="text" name="username" placeholder="用户名" autofocus required>
<input type="password" name="password" placeholder="密码" required>
<button type="submit">登 录</button>
</form>
</body>
</html>
第 9 步:.gitignore、建库、建账号、跑起来
先建 .gitignore(机密文件与生成物绝不进仓库):
.venv/
__pycache__/
*.pyc
db.sqlite3*
.secret_key
ingest_token.txt
admin_credentials.txt
demo_credentials.txt
.env
staticfiles/
然后依次执行:
python manage.py makemigrations monitor # 生成 monitor 的建表迁移
python manage.py migrate # 全部落库
python manage.py createsuperuser # 按提示输入用户名/邮箱/密码(≥8 位)
python manage.py runserver 8014
预期看到的验证点:
migrate输出里monitor.0001_initial出现且无报错;项目根多了db.sqlite3、.secret_key、ingest_token.txt;- 浏览器开
http://127.0.0.1:8014/→ 自动跳转登录页(门禁生效); - 登录后看到"监控总览"占位页,侧边栏蓝灰配色;
- 访问
http://127.0.0.1:8014/api/health/→ 匿名直接返回{"ok": true, "time": "..."}(白名单生效); - 试试
python -c "import urllib.request; print(urllib.request.urlopen('http://127.0.0.1:8014/api/').status)"→ 抛 HTTP 401(API 面返回 JSON 401 而不是 302)。
验证通过后 Ctrl+C 停服,提交第一版:
git init
git add .
git commit -m "ch03: 项目骨架——9 app、模型、整站门禁、健康检查"
本篇检查点
- [ ]
runserver 8014正常启动,无红线报错; - [ ] 未登录访问首页 → 302 跳登录;匿名访问
/api/→ 401 JSON; - [ ]
/api/health/匿名可访问且只返回 ok + 时间; - [ ] 登录后总览占位页正常渲染;
- [ ] 数据库文件、
.secret_key、ingest_token.txt都在项目根且已列入 .gitignore; - [ ] git 已提交第一版。
常见报错速查
| 报错 | 原因 | 解法 |
|---|---|---|
ModuleNotFoundError: monitor |
app 没建或 INSTALLED_APPS 拼错 | 对照第 4 步重建/核对 |
ImproperlyConfigured: Module "monitor.security" does not define a "AuthRequiredMiddleware" |
security.py 没建或类名笔误 | 对照第 7 步 |
no such table: monitor_requestmetric |
只跑了 migrate 没跑 makemigrations | 按第 9 步顺序执行 |
| 登录后立即又跳回登录页 | 会话 Cookie 没种上 | 确认浏览器没禁 Cookie;换隐身窗重试 |
createsuperuser 密码总被拒 |
密码策略(≥8 位、非常见、非纯数字) | 这是特性不是 bug |
骨架立起来了。下一篇我们请出"被观测对象"——一个故意埋了 N+1、无索引、全表加载的论坛, 并写下平台的第一块真正核心:请求计时中间件—— 04 · 演示应用与 APM 采集。