CMDB 资产管理(4):权限、搜索、拓扑、导入导出与 API

Django CMDB v1.0.0 教程(四):权限、资产视图、CSV 与 API

本篇是“CMDB 资产管理”连续系列的第 4 篇,承接前两篇实现教程已经完成的数据模型、Provider Adapter 与同步服务,只讨论当前 Django CMDB v1.0.0 中已经落盘并通过测试的 Web 与接口层。章节连续编号为 19—27;示例路径均从项目目录 devopsX/ 开始。

19 Django 权限与最小权限

19.1 先区分认证与授权

认证(Authentication)
回答“当前请求是谁发出的”。本项目使用 Django Session:登录成功后,浏览器持有会话 Cookie,服务端通过会话恢复 request.user。
授权(Authorization)
回答“这个用户是否允许执行当前操作”。用户已经登录,并不代表可以查看云账号、实例、同步历史或执行同步。
模型权限(Model permission)
Django 为模型生成 add、change、delete、view 权限;模型的 Meta.permissions 还能声明业务动作权限。权限由应用标签、动作代号和模型名共同组成,例如 cmdb.view_computeinstance。
最小权限(Least privilege)
只授予完成职责所需的最少权限。只读云厂商的用户不应顺带看到云账号数量;只读云账号的用户不应顺带看到实例和同步执行;只读同步执行的用户不应顺带看到实例变化。

19.2 当前模型声明了哪些业务动作权限

CloudAccount 除 Django 自动生成的权限外,增加同步与导入权限;ComputeInstance 增加导出与退役权限。下面是当前模型中的原样连续片段。

相对路径:devopsX/cmdb/models.py(当前源码第 79—92 行的连续片段)

    class Meta:
        ordering = ["provider__code", "account_key"]
        constraints = [
            models.UniqueConstraint(
                fields=["provider", "account_key"],
                name="cmdb_unique_provider_account_key",
            )
        ]
        permissions = [
            ("sync_cloudaccount", "可以同步云账号"),
            ("import_cloudaccount", "可以导入云账号"),
        ]
        verbose_name = "云账号"
        verbose_name_plural = "云账号"

第 79—86 行定义默认排序和“同一云厂商内账号键唯一”的约束;第 87—90 行声明 sync_cloudaccount 与 import_cloudaccount。执行迁移后,Django 会为这些代号创建权限记录。

相对路径:devopsX/cmdb/models.py(当前源码第 229—249 行的连续片段)

    class Meta:
        ordering = ["account", "provider_resource_id"]
        constraints = [
            models.UniqueConstraint(
                fields=["account", "provider_resource_id"],
                name="cmdb_unique_account_instance_id",
            )
        ]
        indexes = [
            models.Index(
                fields=["lifecycle_state", "normalized_status"],
                name="cmdb_inst_life_status_idx",
            ),
            models.Index(fields=["name"], name="cmdb_inst_name_idx"),
        ]
        permissions = [
            ("export_computeinstance", "可以导出计算实例"),
            ("retire_computeinstance", "可以退役计算实例"),
        ]
        verbose_name = "计算实例"
        verbose_name_plural = "计算实例"

第 229—243 行是实例排序、唯一约束和查询索引;第 244—247 行声明 export_computeinstance 与 retire_computeinstance。这些权限表达业务动作,而不是把“修改实例”泛化成所有写操作。

19.3 权限矩阵

页面或动作权限无权限结果
CMDB 首页四类读取权限至少一个四类都没有时为 403
云厂商列表cmdb.view_cloudprovider403
云账号列表与详情cmdb.view_cloudaccount403
创建云账号cmdb.add_cloudaccount403
同步云账号cmdb.sync_cloudaccount403;方法不对且权限已满足时为 405
实例列表、详情、拓扑cmdb.view_computeinstance403
保存人工标签cmdb.change_computeinstance403;仅接受 POST
退役实例cmdb.retire_computeinstance403;仅接受 POST
同步历史cmdb.view_syncrun403
导入云账号 CSVcmdb.import_cloudaccount403
导出实例 CSVcmdb.export_computeinstance403

19.4 装饰器不是导航隐藏的替代品

模板中的 perms.cmdb.<权限代号> 只负责不展示无权按钮,真正的安全边界位于视图装饰器。用户即使手工输入 URL,也必须通过 login_required 和 permission_required("权限代号", raise_exception=True)。

装饰器从下向上组装、从外向内执行。当前写法让认证先于权限,权限先于 HTTP 方法检查:匿名用户先跳登录;已登录但无权限者得到 403;权限满足但用 GET 调用只允许 POST 的动作时得到 405。

19.5 在管理后台按职责授权

  1. 以管理员进入 Django 管理后台,创建普通用户或用户组。
  2. “云厂商只读”只分配 Can view 云厂商。
  3. “云账号只读”只分配 Can view 云账号。
  4. “同步审计只读”只分配 Can view 同步执行。
  5. 运维执行角色按职责组合读取权限与 可以同步云账号,不要因为需要同步就授予超级用户。

检查点:没有任何 CMDB 权限的已登录用户访问 /cmdb/ 应看到自定义 403 页面,而不是空白首页或全部数据。

20 登录、next 安全、POST-only 与 CSRF

20.1 登录后的 next 为什么必须校验

next 表示登录成功后的回跳地址。若直接信任用户提交的完整 URL,攻击者可以构造本站登录链接,把用户登录后重定向到外部钓鱼站点,这叫开放重定向。当前实现只接受协议与主机均符合要求的地址;HTTPS 请求还要求目标同样使用 HTTPS。

相对路径:devopsX/accounts/views.py(完整文件,内容与当前源码一致)

from django.contrib import messages
from django.contrib.auth import login, logout
from django.http import HttpResponseNotAllowed
from django.shortcuts import redirect, render
from django.utils.http import url_has_allowed_host_and_scheme

from .forms import LoginForm


CMDB_VIEW_PERMISSIONS = (
    "cmdb.view_cloudprovider",
    "cmdb.view_cloudaccount",
    "cmdb.view_computeinstance",
    "cmdb.view_syncrun",
)


def _default_login_redirect(user):
    if any(user.has_perm(permission) for permission in CMDB_VIEW_PERMISSIONS):
        return "cmdb:home"
    return "accounts:home"


def home(request):
    return render(request, "accounts/home.html")


def login_view(request):
    next_url = request.POST.get("next", "") or request.GET.get("next", "")
    next_is_safe = next_url and url_has_allowed_host_and_scheme(
        next_url,
        allowed_hosts={request.get_host()},
        require_https=request.is_secure(),
    )
    if request.user.is_authenticated:
        return redirect(
            next_url if next_is_safe else _default_login_redirect(request.user)
        )

    form = LoginForm(request, data=request.POST or None)
    if request.method == "POST" and form.is_valid():
        login(request, form.get_user())
        messages.success(request, "登录成功。")
        return redirect(
            next_url if next_is_safe else _default_login_redirect(request.user)
        )

    return render(
        request,
        "accounts/login.html",
        {"form": form, "next": next_url if next_is_safe else ""},
    )


def logout_view(request):
    if request.method != "POST":
        return HttpResponseNotAllowed(["POST"])

    logout(request)
    messages.success(request, "已经退出登录。")
    return redirect("accounts:home")

逐组解释如下:

  • 导入组引入消息、登录与退出、405 响应、重定向/渲染以及 Django 的安全 URL 判定函数。
  • home 是公开入口,只渲染学习平台首页。
  • login_view 先从 POST、再从 GET 读取 next;安全判定把允许主机限制为当前请求主机,并在安全请求上要求 HTTPS。
  • 已认证用户再次打开登录页时,安全 next 才会生效,否则回到 CMDB 首页。
  • AuthenticationForm 校验身份;成功后写入 Session、显示一次性消息,并执行同样的安全回跳。
  • 渲染表单时,不安全的 next 被替换为空字符串,不会进入隐藏字段。
  • logout_view 拒绝非 POST;成功退出后清理 Session、写消息并回公开首页。

相对路径:devopsX/templates/accounts/login.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}登录 - devopsX{% endblock %}

{% block content %}
<section class="panel narrow-panel">
    <h1>登录 devopsX</h1>
    <form method="post">
        {% csrf_token %}
        {{ form.as_p }}
        {% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
        <button type="submit" class="button">登录</button>
    </form>
</section>
{% endblock %}

第 8—13 行形成 POST 表单:CSRF 令牌、认证表单、安全时才出现的隐藏 next 字段,以及提交按钮。模板不重新判断安全性,只使用视图已经净化的值。

20.2 403、405 与 CSRF 分别表示什么

结果含义典型场景
403 Forbidden服务器理解请求,但当前身份无权执行,或 CSRF 校验失败缺少模型权限;POST 缺少有效 CSRF 令牌
405 Method Not Allowed资源存在,但当前 HTTP 方法不允许用 GET 调用同步、保存标签、退役或退出
302 Redirect响应要求浏览器跳转匿名用户跳登录;成功写操作后采用 POST/Redirect/GET

CSRF(Cross-Site Request Forgery,跨站请求伪造)是借助用户现有登录 Cookie,从其他站点诱导浏览器向本系统提交写请求。Session Cookie 会自动随请求发送,所以写操作还必须验证由本站页面产生的 CSRF 令牌。

20.3 POST-only 动作与 CSRF 双重约束

同步、标签与退役都有副作用,不能由可预取、可收藏、可被爬虫访问的 GET 触发。

相对路径:devopsX/cmdb/views.py(当前源码第 166—209 行的连续片段)

@login_required
@permission_required("cmdb.sync_cloudaccount", raise_exception=True)
@require_POST
def account_sync(request, pk):
    account = get_object_or_404(
        CloudAccount.objects.select_related("provider"),
        pk=pk,
    )
    form = SyncAccountForm(request.POST)
    if not form.is_valid():
        messages.error(request, "同步参数无效。")
        return redirect("cmdb:account_detail", pk=account.pk)
    scenario = form.cleaned_data["scenario"] or "default"
    if account.provider.code != "fake":
        scenario = "default"
    try:
        sync_run = sync_account(
            account,
            requested_by=request.user,
            trigger=SyncRun.Trigger.MANUAL,
            scenario=scenario,
        )
    except ProviderError as exc:
        messages.error(request, "同步失败(%s):%s" % (exc.code, exc.message))
    else:
        if sync_run.status == SyncRun.Status.PARTIAL:
            messages.warning(
                request,
                "同步部分完成(%s):%s"
                % (sync_run.error_code, sync_run.error_message),
            )
        else:
            messages.success(
                request,
                "同步完成:发现 %s,新建 %s,更新 %s,缺失 %s,恢复 %s。"
                % (
                    sync_run.discovered_count,
                    sync_run.created_count,
                    sync_run.updated_count,
                    sync_run.missing_count,
                    sync_run.restored_count,
                ),
            )
    return redirect("cmdb:account_detail", pk=account.pk)

account_sync 的三层边界是:必须登录、必须具备同步权限、必须使用 POST。表单场景无效时不执行同步;非 Fake 云账号强制回到默认场景;Provider 错误以脱敏消息反馈;部分成功用 warning 展示错误代码和脱敏信息;完整成功展示统计,最后重定向。

相对路径:devopsX/cmdb/views.py(当前源码第 305—328 行的连续片段)

@login_required
@permission_required("cmdb.change_computeinstance", raise_exception=True)
@require_POST
def manual_tag_save(request, pk):
    instance = get_object_or_404(ComputeInstance, pk=pk)
    form = ManualTagForm(request.POST)
    if form.is_valid():
        form.save(instance)
        messages.success(request, "人工标签已保存。")
    else:
        messages.error(request, "人工标签未保存,请检查输入。")
    return redirect("cmdb:instance_detail", pk=instance.pk)


@login_required
@permission_required("cmdb.retire_computeinstance", raise_exception=True)
@require_POST
def instance_retire(request, pk):
    instance = get_object_or_404(ComputeInstance, pk=pk)
    if retire_instance(instance):
        messages.success(request, "实例已标记为退役,历史记录仍然保留。")
    else:
        messages.info(request, "实例已经是退役状态。")
    return redirect("cmdb:instance_detail", pk=instance.pk)

manual_tag_save 需要实例修改权限并只收 POST;合法表单按“实例、来源、键”更新或创建。instance_retire 使用更窄的退役权限;重复退役不会重复写历史。

相对路径:devopsX/templates/base.html(当前源码第 13—27 行的连续片段)

    <nav>
        {% if user.is_authenticated %}
            <a href="{% url 'cmdb:home' %}">CMDB</a>
            {% if perms.cmdb.view_cloudaccount %}<a href="{% url 'cmdb:account_list' %}">云账号</a>{% endif %}
            {% if perms.cmdb.view_computeinstance %}<a href="{% url 'cmdb:instance_list' %}">计算实例</a>{% endif %}
            {% if perms.cmdb.view_computeinstance %}<a href="{% url 'cmdb:topology' %}">拓扑</a>{% endif %}
            {% if perms.cmdb.view_syncrun %}<a href="{% url 'cmdb:sync_run_list' %}">同步历史</a>{% endif %}
            {% if user.is_staff %}<a href="{% url 'admin:index' %}">管理后台</a>{% endif %}
            <form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
                {% csrf_token %}
                <button type="submit" class="link-button">退出</button>
            </form>
        {% else %}
            <a href="{% url 'accounts:login' %}">登录</a>
        {% endif %}

退出操作是 POST 表单而不是链接,并包含 CSRF。其余链接是否展示由模板权限控制,但后端装饰器仍是最终裁决者。

相对路径:devopsX/cmdb/templates/cmdb/account_detail.html(当前源码第 6—15 行的连续片段)

<section class="page-heading split-heading">
    <div><p class="eyebrow">{{ account.provider.name }}</p><h1>{{ account.name }}</h1><p><code>{{ account.account_key }}</code></p></div>
    {% if perms.cmdb.sync_cloudaccount %}
    <form method="post" action="{% url 'cmdb:account_sync' account.pk %}" class="sync-form">
        {% csrf_token %}
        {% if account.provider.code == "fake" %}{{ sync_form.as_p }}{% endif %}
        <button class="button" type="submit">立即同步</button>
    </form>
    {% endif %}
</section>

账号同步按钮只对有同步权限者出现;表单使用 POST 并带 CSRF。Fake Provider 才展示教学场景选择,真实 Provider 不接受前端伪造的 Fake 场景。

相对路径:devopsX/cmdb/templates/cmdb/instance_detail.html(当前源码第 6—16 行的连续片段)

<section class="page-heading split-heading">
    <div><p class="eyebrow">{{ instance.account.provider.name }} / {{ instance.account.name }}</p><h1>{{ instance.name|default:"未命名实例" }}</h1><p><code>{{ instance.provider_resource_id }}</code></p></div>
    {% if perms.cmdb.retire_computeinstance and instance.lifecycle_state != "retired" %}<form method="post" action="{% url 'cmdb:instance_retire' instance.pk %}" onsubmit="return confirm('确认将该实例标记为退役吗?不会删除历史记录。');">{% csrf_token %}<button class="button button-danger" type="submit">标记退役</button></form>{% endif %}
</section>
<section class="detail-grid">
<article class="panel"><h2>云上属性</h2><dl class="detail-list"><dt>云账号</dt><dd><a href="{% url 'cmdb:account_detail' instance.account.pk %}">{{ instance.account.name }}</a></dd><dt>地域</dt><dd>{{ instance.region.name }}({{ instance.region.provider_resource_id }})</dd><dt>可用区</dt><dd>{{ instance.availability_zone.name|default:"未提供" }}</dd><dt>实例规格</dt><dd>{{ instance.instance_type|default:"未提供" }}</dd><dt>计算资源</dt><dd>{{ instance.vcpu }} vCPU / {{ instance.memory_mb }} MiB</dd><dt>操作系统</dt><dd>{{ instance.os_name|default:"未提供" }}</dd><dt>厂商状态</dt><dd>{{ instance.provider_status|default:"未提供" }}</dd><dt>标准状态</dt><dd>{{ instance.get_normalized_status_display }}</dd></dl></article>
<article class="panel"><h2>发现与生命周期</h2><dl class="detail-list"><dt>生命周期</dt><dd>{{ instance.get_lifecycle_state_display }}</dd><dt>私网 IP</dt><dd>{{ instance.private_ips|join:", "|default:"无" }}</dd><dt>公网 IP</dt><dd>{{ instance.public_ips|join:", "|default:"无" }}</dd><dt>云上创建</dt><dd>{{ instance.cloud_created_at|date:"Y-m-d H:i:s"|default:"未提供" }}</dd><dt>首次发现</dt><dd>{{ instance.first_seen_at|date:"Y-m-d H:i:s" }}</dd><dt>最后发现</dt><dd>{{ instance.last_seen_at|date:"Y-m-d H:i:s" }}</dd><dt>开始缺失</dt><dd>{{ instance.missing_since|date:"Y-m-d H:i:s"|default:"—" }}</dd><dt>退役时间</dt><dd>{{ instance.retired_at|date:"Y-m-d H:i:s"|default:"—" }}</dd></dl></article>
</section>
<section class="detail-grid">
<article class="panel"><h2>标签</h2><div class="tag-list">{% for tag in instance.tags.all %}<span class="tag tag-{{ tag.source }}">{{ tag.key }}={{ tag.value }} <small>{{ tag.get_source_display }}</small></span>{% empty %}<span class="muted">暂无标签。</span>{% endfor %}</div>{% if perms.cmdb.change_computeinstance %}<form method="post" action="{% url 'cmdb:manual_tag_save' instance.pk %}" class="inline-fields">{% csrf_token %}{{ tag_form.as_p }}<button class="button" type="submit">保存人工标签</button></form>{% endif %}</article>
<article class="panel"><h2>变更历史</h2>{% for change in instance.changes.all %}<details><summary>{{ change.get_action_display }} · {{ change.created_at|date:"Y-m-d H:i:s" }}</summary><p>字段:{{ change.changed_fields|join:", "|default:"无字段变化" }}</p><div class="change-grid"><div><h3>变化前</h3><pre>{{ change.before_data }}</pre></div><div><h3>变化后</h3><pre>{{ change.after_data }}</pre></div></div></details>{% empty %}<p class="muted">暂无变更记录。</p>{% endfor %}</article>

退役与人工标签表单都带 CSRF;退役按钮还要求退役权限且实例尚未退役。浏览器确认框只改善误操作体验,不构成安全边界。

20.4 自定义 403 页面

相对路径:devopsX/templates/403.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}没有权限 - devopsX{% endblock %}

{% block content %}
<section class="empty-state"><p class="eyebrow">HTTP 403</p><h1>没有权限访问</h1><p>你已经登录,但当前账号没有执行该操作所需的 Django 权限。</p>{% if perms.cmdb.view_cloudprovider or perms.cmdb.view_cloudaccount or perms.cmdb.view_computeinstance or perms.cmdb.view_syncrun %}<a class="button button-secondary" href="{% url 'cmdb:home' %}">返回 CMDB 首页</a>{% else %}<a class="button button-secondary" href="{% url 'accounts:home' %}">返回平台首页</a>{% endif %}</section>
{% endblock %}

该模板明确区分认证和授权。若用户连 CMDB 首页的四类读取权限都没有,点击返回后仍会得到 403,这是最小权限的正常结果。

20.5 本章检查点与常见错误

  • 站内 /cmdb/instances/ 作为 next 时应生效;外部地址应被忽略并回 CMDB 首页。
  • 具备权限者用 GET 访问同步、标签或退役端点应得到 405。
  • 正常页面提交应成功;删除 CSRF 令牌后用真实浏览器提交应得到 403。
  • Django 测试客户端默认不强制 CSRF,普通 Client 的成功 POST 不能单独证明浏览器 CSRF 链路正确。

21 首页、云厂商、云账号与同步详情的权限边界

21.1 权限边界必须同时约束查询与输出

只在模板里隐藏 HTML 不够稳妥。视图应先判断权限,只执行允许的统计或关联预取,再把明确的布尔标志交给模板,减少越权数据进入上下文。

21.2 首页按权限分别统计

相对路径:devopsX/cmdb/views.py(当前源码第 49—100 行的连续片段)

@login_required
def home(request):
    can_view_providers = request.user.has_perm("cmdb.view_cloudprovider")
    can_view_accounts = request.user.has_perm("cmdb.view_cloudaccount")
    can_view_instances = request.user.has_perm("cmdb.view_computeinstance")
    can_view_sync_runs = request.user.has_perm("cmdb.view_syncrun")
    if not any(
        (
            can_view_providers,
            can_view_accounts,
            can_view_instances,
            can_view_sync_runs,
        )
    ):
        raise PermissionDenied

    context = {
        "can_view_providers": can_view_providers,
        "can_view_accounts": can_view_accounts,
        "can_view_instances": can_view_instances,
        "can_view_sync_runs": can_view_sync_runs,
        "provider_count": (
            CloudProvider.objects.filter(is_active=True).count()
            if can_view_providers
            else None
        ),
        "account_count": (
            CloudAccount.objects.filter(is_active=True).count()
            if can_view_accounts
            else None
        ),
        "instance_count": (
            ComputeInstance.objects.exclude(
                lifecycle_state=ComputeInstance.LifecycleState.RETIRED
            ).count()
            if can_view_instances
            else None
        ),
        "missing_count": (
            ComputeInstance.objects.filter(
                lifecycle_state=ComputeInstance.LifecycleState.MISSING
            ).count()
            if can_view_instances
            else None
        ),
        "recent_sync_runs": (
            SyncRun.objects.select_related("account", "account__provider")[:5]
            if can_view_sync_runs
            else None
        ),
    }
    return render(request, "cmdb/home.html", context)
  • 四类读取能力分别计算,不用“已登录”替代业务授权。
  • 四类都没有时抛出 PermissionDenied。
  • 每个统计查询由对应权限保护;无权限时值为 None,不会执行计数。
  • 最近同步只在拥有同步执行读取权限时查询,并一次取出账号与云厂商。

相对路径:devopsX/cmdb/templates/cmdb/home.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}CMDB 资产管理 - devopsX{% endblock %}

{% block content %}
<section class="page-heading split-heading">
    <div>
        <p class="eyebrow">Configuration Management Database</p>
        <h1>CMDB 资产管理</h1>
        <p>统一记录云厂商、云账号、地域和计算实例,并保留同步与变更历史。</p>
    </div>
    <div class="actions">
        {% if perms.cmdb.add_cloudaccount and perms.cmdb.view_cloudaccount %}<a class="button" href="{% url 'cmdb:account_create' %}">添加云账号</a>{% endif %}
        {% if perms.cmdb.view_computeinstance %}<a class="button button-secondary" href="{% url 'cmdb:instance_list' %}">查看资产</a>{% endif %}
    </div>
</section>
<section class="stats-grid four-columns" aria-label="CMDB 统计">
    {% if can_view_providers %}<article class="stat-card"><strong>{{ provider_count }}</strong><span>启用云厂商</span></article>{% endif %}
    {% if can_view_accounts %}<article class="stat-card"><strong>{{ account_count }}</strong><span>启用云账号</span></article>{% endif %}
    {% if can_view_instances %}<article class="stat-card"><strong>{{ instance_count }}</strong><span>未退役实例</span></article>
    <article class="stat-card"><strong>{{ missing_count }}</strong><span>本次未发现</span></article>{% endif %}
</section>
{% if can_view_accounts and can_view_instances and not account_count and not instance_count %}
<section class="empty-state">
    <h2>当前没有云账号和资产</h2>
    <p>先运行 bootstrap_cmdb 创建内置云厂商,再添加不保存真实密钥的云账号。</p>
</section>
{% elif can_view_sync_runs %}
<section class="panel">
    <div class="section-heading">
        <h2>最近同步</h2>
        {% if perms.cmdb.view_syncrun %}<a href="{% url 'cmdb:sync_run_list' %}">查看全部</a>{% endif %}
    </div>
    {% if recent_sync_runs %}
        <div class="table-wrap">
            <table>
                <thead><tr><th>云账号</th><th>状态</th><th>发现</th><th>新建</th><th>更新</th><th>开始时间</th></tr></thead>
                <tbody>
                {% for sync_run in recent_sync_runs %}
                    <tr>
                        <td>{% if perms.cmdb.view_syncrun %}<a href="{% url 'cmdb:sync_run_detail' sync_run.public_id %}">{{ sync_run.account.name }}</a>{% else %}{{ sync_run.account.name }}{% endif %}</td>
                        <td><span class="badge badge-{{ sync_run.status }}">{{ sync_run.get_status_display }}</span></td>
                        <td>{{ sync_run.discovered_count }}</td>
                        <td>{{ sync_run.created_count }}</td>
                        <td>{{ sync_run.updated_count }}</td>
                        <td>{{ sync_run.started_at|date:"Y-m-d H:i:s" }}</td>
                    </tr>
                {% endfor %}
                </tbody>
            </table>
        </div>
    {% else %}
        <p class="muted">尚未执行同步。</p>
    {% endif %}
</section>
{% endif %}
{% endblock %}

统计卡片逐项受 can_view_* 控制。空状态只有在同时允许查看账号和实例时才判断;最近同步区只向同步执行读者出现。

21.3 云厂商只读者不能看到云账号数量

相对路径:devopsX/cmdb/views.py(当前源码第 103—114 行的连续片段)

@login_required
@permission_required("cmdb.view_cloudprovider", raise_exception=True)
def provider_list(request):
    providers = CloudProvider.objects.prefetch_related("accounts")
    return render(
        request,
        "cmdb/provider_list.html",
        {
            "providers": providers,
            "can_view_accounts": request.user.has_perm("cmdb.view_cloudaccount"),
        },
    )

相对路径:devopsX/cmdb/templates/cmdb/provider_list.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}云厂商 - CMDB{% endblock %}

{% block content %}
<section class="page-heading">
    <p class="eyebrow">Provider</p>
    <h1>云厂商</h1>
    <p>云厂商决定使用哪个 Provider Adapter,账号负责保存发现范围和凭据前缀。</p>
</section>
<section class="card-grid">
    {% for provider in providers %}
        <article class="panel">
            <div class="section-heading">
                <h2>{{ provider.name }}</h2>
                <span class="badge {% if provider.is_active %}badge-succeeded{% else %}badge-failed{% endif %}">{% if provider.is_active %}已启用{% else %}已停用{% endif %}</span>
            </div>
            <p><code>{{ provider.code }}</code></p>
            {% if can_view_accounts %}<p class="muted">云账号数量:{{ provider.accounts.count }}</p>{% endif %}
        </article>
    {% empty %}
        <section class="empty-state"><h2>暂无云厂商</h2><p>运行 <code>python manage.py bootstrap_cmdb</code> 创建 Fake 和阿里云。</p></section>
    {% endfor %}
</section>
{% endblock %}

视图单独传入 can_view_accounts;模板只有在为真时才渲染账号计数。因此只拥有 Provider 读取权限者看不到“云账号数量”。这是当前回归测试锁定的边界。

21.4 云账号只读者不能看到实例和同步执行

相对路径:devopsX/cmdb/views.py(当前源码第 138—163 行的连续片段)

@login_required
@permission_required("cmdb.view_cloudaccount", raise_exception=True)
def account_detail(request, pk):
    account = get_object_or_404(
        CloudAccount.objects.select_related("provider"),
        pk=pk,
    )
    can_view_instances = request.user.has_perm("cmdb.view_computeinstance")
    can_view_sync_runs = request.user.has_perm("cmdb.view_syncrun")
    context = {
        "account": account,
        "regions": account.regions.prefetch_related("availability_zones"),
        "instances": (
            account.compute_instances.select_related(
                "region",
                "availability_zone",
            )[:10]
            if can_view_instances
            else None
        ),
        "sync_runs": account.sync_runs.all()[:10] if can_view_sync_runs else None,
        "can_view_instances": can_view_instances,
        "can_view_sync_runs": can_view_sync_runs,
        "sync_form": SyncAccountForm(initial={"scenario": "default"}),
    }
    return render(request, "cmdb/account_detail.html", context)

相对路径:devopsX/cmdb/templates/cmdb/account_detail.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}{{ account.name }} - CMDB{% endblock %}

{% block content %}
<section class="page-heading split-heading">
    <div><p class="eyebrow">{{ account.provider.name }}</p><h1>{{ account.name }}</h1><p><code>{{ account.account_key }}</code></p></div>
    {% if perms.cmdb.sync_cloudaccount %}
    <form method="post" action="{% url 'cmdb:account_sync' account.pk %}" class="sync-form">
        {% csrf_token %}
        {% if account.provider.code == "fake" %}{{ sync_form.as_p }}{% endif %}
        <button class="button" type="submit">立即同步</button>
    </form>
    {% endif %}
</section>
<section class="detail-grid">
    <article class="panel"><h2>账号配置</h2><dl class="detail-list"><dt>云厂商</dt><dd>{{ account.provider.name }}</dd><dt>凭据前缀</dt><dd><code>{{ account.credential_profile|default:"未配置" }}</code></dd><dt>地域白名单</dt><dd>{{ account.region_allowlist|join:", "|default:"全部地域" }}</dd><dt>状态</dt><dd>{% if account.is_active %}已启用{% else %}已停用{% endif %}</dd><dt>最后成功同步</dt><dd>{{ account.last_successful_sync_at|date:"Y-m-d H:i:s"|default:"从未" }}</dd></dl></article>
    <article class="panel"><h2>发现范围</h2><p>地域 {{ account.regions.count }} 个</p><p>可用区 {{ account.availability_zones.count }} 个</p>{% if can_view_instances %}<p>实例 {{ account.compute_instances.count }} 台</p>{% endif %}</article>
</section>
{% if can_view_instances %}
<section class="panel">
    <div class="section-heading"><h2>实例</h2><a href="{% url 'cmdb:instance_list' %}?account={{ account.pk }}">查看全部</a></div>
    <div class="table-wrap"><table><thead><tr><th>实例 ID</th><th>名称</th><th>地域</th><th>状态</th><th>生命周期</th></tr></thead><tbody>{% for instance in instances %}<tr><td><a href="{% url 'cmdb:instance_detail' instance.pk %}"><code>{{ instance.provider_resource_id }}</code></a></td><td>{{ instance.name }}</td><td>{{ instance.region.name }}</td><td>{{ instance.get_normalized_status_display }}</td><td>{{ instance.get_lifecycle_state_display }}</td></tr>{% empty %}<tr><td colspan="5" class="muted">尚未发现实例。</td></tr>{% endfor %}</tbody></table></div>
</section>
{% endif %}
{% if can_view_sync_runs %}
<section class="panel">
    <div class="section-heading"><h2>同步历史</h2><a href="{% url 'cmdb:sync_run_list' %}">查看全部</a></div>
    <div class="table-wrap"><table><thead><tr><th>执行 ID</th><th>状态</th><th>发现</th><th>新建</th><th>更新</th><th>缺失</th><th>时间</th></tr></thead><tbody>{% for sync_run in sync_runs %}<tr><td><a href="{% url 'cmdb:sync_run_detail' sync_run.public_id %}"><code>{{ sync_run.public_id }}</code></a></td><td><span class="badge badge-{{ sync_run.status }}">{{ sync_run.get_status_display }}</span></td><td>{{ sync_run.discovered_count }}</td><td>{{ sync_run.created_count }}</td><td>{{ sync_run.updated_count }}</td><td>{{ sync_run.missing_count }}</td><td>{{ sync_run.created_at|date:"Y-m-d H:i:s" }}</td></tr>{% empty %}<tr><td colspan="7" class="muted">尚无同步历史。</td></tr>{% endfor %}</tbody></table></div>
</section>
{% endif %}
{% endblock %}
  • 账号详情本身要求账号读取权限。
  • 实例只在实例读取权限为真时查询,否则为 None。
  • 同步执行只在同步执行读取权限为真时查询,否则为 None。
  • 实例数量、实例表格和同步历史分别受对应标志保护。

所以 account-only 用户能看账号配置、地域和可用区,却看不到实例 ID、实例数量和同步执行 UUID。

21.5 同步执行只读者不能看到实例变化

相对路径:devopsX/cmdb/views.py(当前源码第 343—362 行的连续片段)

@login_required
@permission_required("cmdb.view_syncrun", raise_exception=True)
def sync_run_detail(request, public_id):
    can_view_instances = request.user.has_perm("cmdb.view_computeinstance")
    sync_runs = SyncRun.objects.select_related(
        "account",
        "account__provider",
        "requested_by",
    )
    if can_view_instances:
        sync_runs = sync_runs.prefetch_related("changes__instance")
    sync_run = get_object_or_404(sync_runs, public_id=public_id)
    return render(
        request,
        "cmdb/sync_run_detail.html",
        {
            "sync_run": sync_run,
            "can_view_instances": can_view_instances,
        },
    )

相对路径:devopsX/cmdb/templates/cmdb/sync_run_detail.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}同步 {{ sync_run.public_id }} - CMDB{% endblock %}

{% block content %}
<section class="page-heading"><p class="eyebrow">{{ sync_run.account.name }}</p><h1>同步执行详情</h1><p><code>{{ sync_run.public_id }}</code></p></section>
<section class="stats-grid"><article class="stat-card"><strong>{{ sync_run.discovered_count }}</strong><span>发现</span></article><article class="stat-card"><strong>{{ sync_run.created_count }}</strong><span>新建</span></article><article class="stat-card"><strong>{{ sync_run.updated_count }}</strong><span>更新</span></article></section>
<section class="detail-grid"><article class="panel"><h2>执行信息</h2><dl class="detail-list"><dt>状态</dt><dd><span class="badge badge-{{ sync_run.status }}">{{ sync_run.get_status_display }}</span></dd><dt>触发方式</dt><dd>{{ sync_run.get_trigger_display }}</dd><dt>请求用户</dt><dd>{{ sync_run.requested_by|default:"系统" }}</dd><dt>开始时间</dt><dd>{{ sync_run.started_at|date:"Y-m-d H:i:s" }}</dd><dt>结束时间</dt><dd>{{ sync_run.finished_at|date:"Y-m-d H:i:s"|default:"—" }}</dd><dt>错误代码</dt><dd><code>{{ sync_run.error_code|default:"—" }}</code></dd><dt>错误信息</dt><dd>{{ sync_run.error_message|default:"—" }}</dd></dl></article><article class="panel"><h2>统计</h2><dl class="detail-list"><dt>发现</dt><dd>{{ sync_run.discovered_count }}</dd><dt>新建</dt><dd>{{ sync_run.created_count }}</dd><dt>更新</dt><dd>{{ sync_run.updated_count }}</dd><dt>未变化</dt><dd>{{ sync_run.unchanged_count }}</dd><dt>缺失</dt><dd>{{ sync_run.missing_count }}</dd><dt>恢复</dt><dd>{{ sync_run.restored_count }}</dd><dt>退役</dt><dd>{{ sync_run.retired_count }}</dd></dl></article></section>
{% if can_view_instances %}<section class="panel"><h2>本次变化</h2><div class="table-wrap"><table><thead><tr><th>实例</th><th>动作</th><th>字段</th><th>时间</th></tr></thead><tbody>{% for change in sync_run.changes.all %}<tr><td><a href="{% url 'cmdb:instance_detail' change.instance.pk %}">{{ change.instance }}</a></td><td>{{ change.get_action_display }}</td><td>{{ change.changed_fields|join:", " }}</td><td>{{ change.created_at|date:"Y-m-d H:i:s" }}</td></tr>{% empty %}<tr><td colspan="4" class="muted">该次同步没有资产变化。</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
{% endblock %}

执行详情允许同步审计者读取执行本身;只有同时拥有实例读取权限时才预取变化关联,模板才渲染“本次变化”。sync-run-only 用户看不到实例名称、实例 ID 或变化明细。

21.6 边界检查表

角色应看到不应看到
云厂商只读Provider 名称、代码、启停状态云账号数量
云账号只读账号元数据、地域、可用区实例数量、实例表格、同步 UUID
同步执行只读执行状态、统计、错误代码与脱敏消息本次实例变化与实例标识
实例只读实例列表、详情、拓扑导入、同步、退役,除非另有授权

常见错误是只测试超级用户。超级用户绕过普通权限判断,无法证明最小权限边界;必须为每一种单权限角色写回归测试。

22 实例搜索、筛选、允许列表排序与分页

22.1 先定义四个概念

搜索
用一个关键词在多个文本字段中做模糊匹配。
筛选
对云厂商、账号、地域、状态和生命周期施加精确条件。
允许列表排序(allowlisted sorting)
客户端只能选择后端预先定义的排序键;不能把任意字符串直接传给 ORM 的 order_by。
分页(pagination)
把查询结果切成固定大小页面,同时保留筛选参数并容忍无效页码。

22.2 查询实现

相对路径:devopsX/cmdb/views.py(当前源码第 212—283 行的连续片段)

@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
def instance_list(request):
    instances = ComputeInstance.objects.select_related(
        "account",
        "account__provider",
        "region",
        "availability_zone",
    ).prefetch_related("tags")

    keyword = request.GET.get("q", "").strip()
    provider = request.GET.get("provider", "").strip()
    account = request.GET.get("account", "").strip()
    region = request.GET.get("region", "").strip()
    status = request.GET.get("status", "").strip()
    lifecycle = request.GET.get("lifecycle", "").strip()
    if keyword:
        instances = instances.annotate(
            private_ips_text=Cast("private_ips", output_field=TextField()),
            public_ips_text=Cast("public_ips", output_field=TextField()),
        ).filter(
            Q(provider_resource_id__icontains=keyword)
            | Q(name__icontains=keyword)
            | Q(os_name__icontains=keyword)
            | Q(private_ips_text__icontains=keyword)
            | Q(public_ips_text__icontains=keyword)
        )
    if provider:
        instances = instances.filter(account__provider__code=provider)
    if account:
        if account.isdecimal():
            instances = instances.filter(account_id=account)
        else:
            instances = instances.none()
    if region:
        if region.isdecimal():
            instances = instances.filter(region_id=region)
        else:
            instances = instances.none()
    if status:
        instances = instances.filter(normalized_status=status)
    if lifecycle:
        instances = instances.filter(lifecycle_state=lifecycle)

    ordering_map = {
        "name": "name",
        "-name": "-name",
        "status": "normalized_status",
        "-status": "-normalized_status",
        "last_seen": "last_seen_at",
        "-last_seen": "-last_seen_at",
        "created": "cloud_created_at",
        "-created": "-cloud_created_at",
    }
    selected_sort = request.GET.get("sort", "-last_seen")
    instances = instances.order_by(ordering_map.get(selected_sort, "-last_seen_at"), "id")

    paginator = Paginator(instances, 20)
    page_obj = paginator.get_page(request.GET.get("page"))
    query_without_page = request.GET.copy()
    query_without_page.pop("page", None)
    context = {
        "page_obj": page_obj,
        "providers": CloudProvider.objects.filter(is_active=True),
        "accounts": CloudAccount.objects.select_related("provider"),
        "regions": CloudRegion.objects.select_related("account"),
        "status_choices": ComputeInstance.NormalizedStatus.choices,
        "lifecycle_choices": ComputeInstance.LifecycleState.choices,
        "selected_sort": selected_sort,
        "query_without_page": query_without_page.urlencode(),
    }
    return render(request, "cmdb/instance_list.html", context)

逐组说明:

  • 基础 QuerySet 用 select_related 合并账号、Provider、地域和可用区外键查询,用 prefetch_related 批量取得标签。
  • 所有 GET 参数先取字符串并 strip,空值不参与筛选。
  • 关键词覆盖厂商实例 ID、名称、操作系统、私网 IP 和公网 IP。IP 位于 JSONField,先 Cast 为文本,再做大小写不敏感包含匹配。
  • Provider 使用稳定代码筛选;账号和地域只接受十进制主键。畸形关联 ID 会把 QuerySet 置空而不是把非法文本传给整数外键查询,因此不会触发 500;状态与生命周期使用模型枚举值。
  • ordering_map 是排序允许列表。非法 sort 不会进入 SQL 字段表达式,而是回退到 -last_seen_at。
  • 第二排序键固定为 id,避免主排序值相同时页面顺序漂移。
  • 每页 20 条;get_page 容忍非整数和越界页码。
  • 复制查询参数后删除 page,让分页链接保留搜索、筛选与排序。

22.3 筛选模板

相对路径:devopsX/cmdb/templates/cmdb/instance_list.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}计算实例 - CMDB{% endblock %}

{% block content %}
<section class="page-heading split-heading">
    <div><p class="eyebrow">Compute Instance</p><h1>计算实例</h1><p>以云账号和厂商实例 ID 作为稳定身份,不使用名称或 IP 去重。</p></div>
    {% if perms.cmdb.export_computeinstance %}<a class="button button-secondary" href="{% url 'cmdb:instance_csv_export' %}">导出 CSV</a>{% endif %}
</section>
<section class="panel">
<form method="get" class="filter-grid">
    <label>关键词<input name="q" value="{{ request.GET.q }}" placeholder="名称、ID、IP、操作系统"></label>
    <label>云厂商<select name="provider"><option value="">全部</option>{% for item in providers %}<option value="{{ item.code }}" {% if request.GET.provider == item.code %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label>
    <label>云账号<select name="account"><option value="">全部</option>{% for item in accounts %}<option value="{{ item.pk }}" {% if request.GET.account == item.pk|stringformat:"s" %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label>
    <label>地域<select name="region"><option value="">全部</option>{% for item in regions %}<option value="{{ item.pk }}" {% if request.GET.region == item.pk|stringformat:"s" %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label>
    <label>标准状态<select name="status"><option value="">全部</option>{% for value, label in status_choices %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
    <label>生命周期<select name="lifecycle"><option value="">全部</option>{% for value, label in lifecycle_choices %}<option value="{{ value }}" {% if request.GET.lifecycle == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
    <label>排序<select name="sort"><option value="-last_seen" {% if selected_sort == "-last_seen" %}selected{% endif %}>最后发现:新到旧</option><option value="last_seen" {% if selected_sort == "last_seen" %}selected{% endif %}>最后发现:旧到新</option><option value="name" {% if selected_sort == "name" %}selected{% endif %}>名称:升序</option><option value="-name" {% if selected_sort == "-name" %}selected{% endif %}>名称:降序</option><option value="status" {% if selected_sort == "status" %}selected{% endif %}>状态:升序</option></select></label>
    <div class="filter-actions"><button class="button" type="submit">筛选</button><a class="button button-secondary" href="{% url 'cmdb:instance_list' %}">重置</a></div>
</form>
</section>
<section class="panel">
    <p class="muted">共 {{ page_obj.paginator.count }} 台实例。</p>
    <div class="table-wrap"><table><thead><tr><th>实例</th><th>账号</th><th>地域 / 可用区</th><th>规格</th><th>IP</th><th>状态</th><th>生命周期</th><th>最后发现</th></tr></thead><tbody>{% for instance in page_obj %}<tr><td><a href="{% url 'cmdb:instance_detail' instance.pk %}">{{ instance.name|default:"未命名" }}</a><br><code>{{ instance.provider_resource_id }}</code></td><td>{{ instance.account.name }}</td><td>{{ instance.region.name }}<br><span class="muted">{{ instance.availability_zone.name|default:"无可用区" }}</span></td><td>{{ instance.instance_type }}<br><span class="muted">{{ instance.vcpu }} vCPU / {{ instance.memory_mb }} MiB</span></td><td>{{ instance.private_ips|join:", "|default:"—" }}<br><span class="muted">{{ instance.public_ips|join:", "|default:"—" }}</span></td><td><span class="badge badge-{{ instance.normalized_status }}">{{ instance.get_normalized_status_display }}</span></td><td><span class="badge badge-{{ instance.lifecycle_state }}">{{ instance.get_lifecycle_state_display }}</span></td><td>{{ instance.last_seen_at|date:"Y-m-d H:i:s" }}</td></tr>{% empty %}<tr><td colspan="8" class="muted">没有符合条件的实例。</td></tr>{% endfor %}</tbody></table></div>
    {% include "cmdb/_pagination.html" %}
</section>
{% endblock %}

模板按当前 GET 参数恢复输入框与下拉框。后端允许列表还支持创建时间与更多降序键;即使界面只暴露常用项,直接构造参数也只能落入后端允许列表。表格外层的 table-wrap 在窄屏上提供横向滚动。

相对路径:devopsX/cmdb/templates/cmdb/_pagination.html(完整文件,内容与当前源码一致)

{% if page_obj.paginator.num_pages > 1 %}
<nav class="pagination" aria-label="分页">
    {% if page_obj.has_previous %}<a href="?{% if query_without_page %}{{ query_without_page }}&{% endif %}page={{ page_obj.previous_page_number }}">上一页</a>{% endif %}
    <span>第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span>
    {% if page_obj.has_next %}<a href="?{% if query_without_page %}{{ query_without_page }}&{% endif %}page={{ page_obj.next_page_number }}">下一页</a>{% endif %}
</nav>
{% endif %}

分页组件只在总页数大于 1 时出现。它将已 URL 编码且移除旧页码的 query_without_page 放回链接,再附加目标页码,所以翻页不会丢掉关键词或筛选条件。

22.4 查询检查点

  • 搜索 203.0.113.10 时,Fake 数据中只返回“订单服务-演示”。
  • 传入未知排序值时,页面应正常返回,并按最后发现时间降序回退。
  • 传入 page=not-a-number 时应回到有效页面;超大页码应落到最后一页。
  • 翻页后,地址栏仍保留关键词、Provider、账号、地域、状态、生命周期与排序参数。

常见错误是把 request.GET["sort"] 直接交给 order_by。即使 ORM 不执行原始 SQL,这也会暴露未计划字段、关联排序和异常行为;允许列表更容易审计。

23 树形拓扑、无可用区实例与双来源标签

23.1 拓扑是什么

拓扑是资产之间显式关系的层级视图,而不是一张“看起来像树”的扁平表。本项目按 Provider → Account → Region → Availability Zone → Instance 展示。可用区允许为空,因此还必须把“有地域但没有可用区”的实例放入可见分支,不能让它们从树中消失。

23.2 服务端预取整棵树

相对路径:devopsX/cmdb/views.py(当前源码第 365—394 行的连续片段)

@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
def topology(request):
    zones = CloudAvailabilityZone.objects.prefetch_related(
        Prefetch(
            "compute_instances",
            queryset=ComputeInstance.objects.exclude(
                lifecycle_state=ComputeInstance.LifecycleState.RETIRED
            ).order_by("name", "provider_resource_id"),
        )
    ).order_by("provider_resource_id")
    regions = CloudRegion.objects.prefetch_related(
        Prefetch("availability_zones", queryset=zones),
        Prefetch(
            "compute_instances",
            queryset=ComputeInstance.objects.filter(
                availability_zone__isnull=True
            ).exclude(
                lifecycle_state=ComputeInstance.LifecycleState.RETIRED
            ).order_by("name", "provider_resource_id"),
            to_attr="unassigned_instances",
        ),
    ).order_by("provider_resource_id")
    accounts = CloudAccount.objects.prefetch_related(
        Prefetch("regions", queryset=regions)
    ).order_by("account_key")
    providers = CloudProvider.objects.prefetch_related(
        Prefetch("accounts", queryset=accounts)
    )
    return render(request, "cmdb/topology.html", {"providers": providers})
  • 可用区预取未退役实例,并按名称、厂商实例 ID 稳定排序。
  • 地域预取可用区;同时单独预取 availability_zone__isnull=True 的未退役实例,通过 to_attr="unassigned_instances" 存入专用属性。
  • 账号预取地域,Provider 再预取账号,避免模板逐层访问形成 N+1 查询。
  • 整个拓扑端点要求实例读取权限,因为叶子节点就是实例资产。

相对路径:devopsX/cmdb/templates/cmdb/topology.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}资产拓扑 - CMDB{% endblock %}

{% block content %}
<section class="page-heading"><p class="eyebrow">Topology</p><h1>资产拓扑</h1><p>服务端按 Provider → Account → Region → Zone → Instance 展示显式关系。</p></section>
<section class="panel topology-tree">
{% for provider in providers %}
<details open><summary><strong>{{ provider.name }}</strong> <code>{{ provider.code }}</code></summary>
    {% for account in provider.accounts.all %}
    <details open><summary><a href="{% url 'cmdb:account_detail' account.pk %}">{{ account.name }}</a> <code>{{ account.account_key }}</code></summary>
        {% for region in account.regions.all %}
        <details open><summary>{{ region.name }} <code>{{ region.provider_resource_id }}</code></summary>
            {% for zone in region.availability_zones.all %}
            <details open><summary>{{ zone.name }} <code>{{ zone.provider_resource_id }}</code></summary>
                <ul>{% for instance in zone.compute_instances.all %}<li><a href="{% url 'cmdb:instance_detail' instance.pk %}">{{ instance.name|default:instance.provider_resource_id }}</a> <span class="badge badge-{{ instance.lifecycle_state }}">{{ instance.get_lifecycle_state_display }}</span></li>{% empty %}<li class="muted">该可用区暂无实例。</li>{% endfor %}</ul>
            </details>
            {% empty %}<p class="muted">该地域暂无可用区。</p>{% endfor %}
            {% if region.unassigned_instances %}
            <details open><summary>未分配可用区</summary>
                <ul>{% for instance in region.unassigned_instances %}<li><a href="{% url 'cmdb:instance_detail' instance.pk %}">{{ instance.name|default:instance.provider_resource_id }}</a> <span class="badge badge-{{ instance.lifecycle_state }}">{{ instance.get_lifecycle_state_display }}</span></li>{% endfor %}</ul>
            </details>
            {% endif %}
        </details>
        {% empty %}<p class="muted">该账号尚未发现地域。</p>{% endfor %}
    </details>
    {% empty %}<p class="muted">该云厂商暂无账号。</p>{% endfor %}
</details>
{% empty %}<p class="muted">暂无拓扑数据。</p>{% endfor %}
</section>
{% endblock %}

模板使用原生 details/summary 形成可折叠树。第 19—23 行专门输出“未分配可用区”,这是对可空外键的业务表达,不是把数据错误地挂到任意可用区。退役实例已在查询层排除。

23.3 云厂商标签与人工标签为什么必须分来源

同一个标签键可以同时存在云厂商值和本地人工值。例如 Provider 返回 owner=provider-team,运维人员也可以维护 owner=local-team。唯一约束包含 source,所以两个值可以共存;同步只管理 Provider 来源,不能删除人工来源。

相对路径:devopsX/cmdb/models.py(当前源码第 266—295 行的连续片段)

class ComputeInstanceTag(models.Model):
    class Source(models.TextChoices):
        PROVIDER = "provider", "云厂商"
        MANUAL = "manual", "人工维护"

    instance = models.ForeignKey(
        ComputeInstance,
        on_delete=models.CASCADE,
        related_name="tags",
        verbose_name="计算实例",
    )
    key = models.CharField("键", max_length=100)
    value = models.CharField("值", max_length=255, blank=True)
    source = models.CharField("来源", max_length=20, choices=Source.choices)
    created_at = models.DateTimeField("创建时间", auto_now_add=True)
    updated_at = models.DateTimeField("更新时间", auto_now=True)

    class Meta:
        ordering = ["source", "key"]
        constraints = [
            models.UniqueConstraint(
                fields=["instance", "source", "key"],
                name="cmdb_unique_instance_tag_source_key",
            )
        ]
        verbose_name = "计算实例标签"
        verbose_name_plural = "计算实例标签"

    def __str__(self):
        return "%s=%s" % (self.key, self.value)

Source 只有 provider 与 manual 两类;唯一约束为“实例 + 来源 + 键”。删除实例会级联删除其标签,但正常同步不会删除实例。

相对路径:devopsX/cmdb/services/sync.py(当前源码第 262—286 行的连续片段)

def _provider_tag_state(tags):
    return {tag.key: tag.value for tag in sorted(tags, key=lambda tag: tag.key)}


def _upsert_provider_tags(instance, tags):
    incoming = {tag.key: tag.value for tag in tags}
    existing = set(
        ComputeInstanceTag.objects.filter(
            instance=instance,
            source=ComputeInstanceTag.Source.PROVIDER,
        ).values_list("key", flat=True)
    )
    for key in existing - set(incoming):
        ComputeInstanceTag.objects.filter(
            instance=instance,
            source=ComputeInstanceTag.Source.PROVIDER,
            key=key,
        ).delete()
    for key, value in incoming.items():
        ComputeInstanceTag.objects.update_or_create(
            instance=instance,
            source=ComputeInstanceTag.Source.PROVIDER,
            key=key,
            defaults={"value": value},
        )

_provider_tag_state 按键排序后生成稳定标签快照,供变更前后数据比较。同步写标签时先把 incoming Provider 标签转换成键值映射;只查询 source=PROVIDER 的已有键;消失的 Provider 键只从 Provider 来源中删除;其余键用 update_or_create 写回。人工标签完全不在这些查询中,因此不会被云端快照覆盖或清理。

相对路径:devopsX/cmdb/forms.py(当前源码第 69—82 行的连续片段)

class ManualTagForm(forms.Form):
    key = forms.CharField(label="标签键", max_length=100)
    value = forms.CharField(label="标签值", max_length=255, required=False)

    def clean_key(self):
        return self.cleaned_data["key"].strip()

    def save(self, instance):
        return ComputeInstanceTag.objects.update_or_create(
            instance=instance,
            source=ComputeInstanceTag.Source.MANUAL,
            key=self.cleaned_data["key"],
            defaults={"value": self.cleaned_data["value"].strip()},
        )

人工标签表单限制键和值长度,键在保存前去除首尾空白;保存时固定来源为 MANUAL。同一人工键再次提交会更新其值,而不是创建重复行。

23.4 详情页同时呈现标签、变更与退役

相对路径:devopsX/cmdb/templates/cmdb/instance_detail.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}{{ instance.name|default:instance.provider_resource_id }} - CMDB{% endblock %}

{% block content %}
<section class="page-heading split-heading">
    <div><p class="eyebrow">{{ instance.account.provider.name }} / {{ instance.account.name }}</p><h1>{{ instance.name|default:"未命名实例" }}</h1><p><code>{{ instance.provider_resource_id }}</code></p></div>
    {% if perms.cmdb.retire_computeinstance and instance.lifecycle_state != "retired" %}<form method="post" action="{% url 'cmdb:instance_retire' instance.pk %}" onsubmit="return confirm('确认将该实例标记为退役吗?不会删除历史记录。');">{% csrf_token %}<button class="button button-danger" type="submit">标记退役</button></form>{% endif %}
</section>
<section class="detail-grid">
<article class="panel"><h2>云上属性</h2><dl class="detail-list"><dt>云账号</dt><dd>{% if perms.cmdb.view_cloudaccount %}<a href="{% url 'cmdb:account_detail' instance.account.pk %}">{{ instance.account.name }}</a>{% else %}{{ instance.account.name }}{% endif %}</dd><dt>地域</dt><dd>{{ instance.region.name }}({{ instance.region.provider_resource_id }})</dd><dt>可用区</dt><dd>{{ instance.availability_zone.name|default:"未提供" }}</dd><dt>实例规格</dt><dd>{{ instance.instance_type|default:"未提供" }}</dd><dt>计算资源</dt><dd>{{ instance.vcpu }} vCPU / {{ instance.memory_mb }} MiB</dd><dt>操作系统</dt><dd>{{ instance.os_name|default:"未提供" }}</dd><dt>厂商状态</dt><dd>{{ instance.provider_status|default:"未提供" }}</dd><dt>标准状态</dt><dd>{{ instance.get_normalized_status_display }}</dd></dl></article>
<article class="panel"><h2>发现与生命周期</h2><dl class="detail-list"><dt>生命周期</dt><dd>{{ instance.get_lifecycle_state_display }}</dd><dt>私网 IP</dt><dd>{{ instance.private_ips|join:", "|default:"无" }}</dd><dt>公网 IP</dt><dd>{{ instance.public_ips|join:", "|default:"无" }}</dd><dt>云上创建</dt><dd>{{ instance.cloud_created_at|date:"Y-m-d H:i:s"|default:"未提供" }}</dd><dt>首次发现</dt><dd>{{ instance.first_seen_at|date:"Y-m-d H:i:s" }}</dd><dt>最后发现</dt><dd>{{ instance.last_seen_at|date:"Y-m-d H:i:s" }}</dd><dt>开始缺失</dt><dd>{{ instance.missing_since|date:"Y-m-d H:i:s"|default:"—" }}</dd><dt>退役时间</dt><dd>{{ instance.retired_at|date:"Y-m-d H:i:s"|default:"—" }}</dd></dl></article>
</section>
<section class="detail-grid">
<article class="panel"><h2>标签</h2><div class="tag-list">{% for tag in instance.tags.all %}<span class="tag tag-{{ tag.source }}">{{ tag.key }}={{ tag.value }} <small>{{ tag.get_source_display }}</small></span>{% empty %}<span class="muted">暂无标签。</span>{% endfor %}</div>{% if perms.cmdb.change_computeinstance %}<form method="post" action="{% url 'cmdb:manual_tag_save' instance.pk %}" class="inline-fields">{% csrf_token %}{{ tag_form.as_p }}<button class="button" type="submit">保存人工标签</button></form>{% endif %}</article>
<article class="panel"><h2>变更历史</h2>{% for change in change_page %}<details><summary>{{ change.get_action_display }} · {{ change.created_at|date:"Y-m-d H:i:s" }}</summary><p>字段:{{ change.changed_fields|join:", "|default:"无字段变化" }}</p><div class="change-grid"><div><h3>变化前</h3><pre>{{ change.before_data }}</pre></div><div><h3>变化后</h3><pre>{{ change.after_data }}</pre></div></div></details>{% empty %}<p class="muted">暂无变更记录。</p>{% endfor %}{% if change_page.paginator.num_pages > 1 %}<nav class="pagination" aria-label="变更历史分页">{% if change_page.has_previous %}<a href="?change_page={{ change_page.previous_page_number }}">上一页</a>{% endif %}<span>第 {{ change_page.number }} / {{ change_page.paginator.num_pages }} 页</span>{% if change_page.has_next %}<a href="?change_page={{ change_page.next_page_number }}">下一页</a>{% endif %}</nav>{% endif %}</article>
</section>
{% endblock %}

详情页将云上属性与生命周期分栏,将标签来源用不同 class 表达,并把人工标签表单限制在实例修改权限内。变更历史保留前后快照。退役是 POST 动作,服务层写入生命周期、退役时间与一条 RETIRED 变更;后续 Provider 再次发现该实例也不会自动恢复退役状态。

23.5 拓扑与标签检查点

  • 将一台实例的可用区置空后,拓扑中应出现“未分配可用区”,该实例仍可点击。
  • 添加人工 owner 标签,再执行不含对应 Provider 标签的同步;人工标签应保留,消失的 Provider 标签应删除。
  • 用 GET 访问标签保存和退役 URL,应得到 405;正常页面 POST 后应重定向回详情。
  • 退役实例不应出现在拓扑中;详情与历史仍然保留。

24 云账号 CSV 预览、签名、原子导入与安全导出

24.1 先定义四个安全概念

预览
先解析、规范化并校验上传文件,只展示将要写入的行,不立即修改数据库。
签名
使用服务端密钥为预览结果生成完整性凭据,确认导入时验证内容未被客户端篡改。签名不是加密,客户端仍可能看到载荷。
原子性(atomicity)
多行导入要么全部成功,要么全部回滚;后面一行失败时,前面已处理的行不能残留。
公式注入
电子表格可能把以 =、+、-、@ 等字符开头的单元格当作公式执行。导出不能只做 CSV 引号转义,还要中和公式前缀。

24.2 上传表单与确认表单

相对路径:devopsX/cmdb/forms.py(当前源码第 85—114 行的连续片段)

class CloudAccountCsvForm(forms.Form):
    csv_file = forms.FileField(
        label="云账号 CSV 文件",
        help_text="只接受 UTF-8 CSV,最大 1 MiB。",
    )

    def clean_csv_file(self):
        uploaded_file = self.cleaned_data["csv_file"]
        if uploaded_file.size > 1024 * 1024:
            raise forms.ValidationError("CSV 文件不能超过 1 MiB。")
        if not uploaded_file.name.lower().endswith(".csv"):
            raise forms.ValidationError("请选择 .csv 文件。")
        return uploaded_file


class CloudAccountCsvConfirmForm(forms.Form):
    signed_payload = forms.CharField(widget=forms.HiddenInput)


class SyncAccountForm(forms.Form):
    scenario = forms.ChoiceField(
        label="Fake 教学场景",
        required=False,
        choices=(
            ("default", "默认:发现两台实例"),
            ("empty", "完整空快照:演示缺失"),
            ("partial", "部分快照:不标记缺失"),
            ("failed", "Provider 失败"),
        ),
    )
  • 上传表单限制扩展名为 .csv、大小不超过 1 MiB。
  • 确认表单只有隐藏的 signed_payload,不信任浏览器重新提交的逐字段行数据。
  • 同一片段末尾的同步表单与 CSV 无关;它列出页面同步允许的四个 Fake 场景。

24.3 严格表头、行校验、签名与事务

相对路径:devopsX/cmdb/services/csv_io.py(完整文件,内容与当前源码一致)

import csv
import io

from django.core import signing
from django.core.exceptions import ValidationError
from django.db import connection, transaction
from django.db.models import F

from cmdb.models import CloudAccount, CloudProvider


REQUIRED_HEADERS = {"provider_code", "account_key", "name"}
OPTIONAL_HEADERS = {
    "credential_profile",
    "region_allowlist",
    "is_active",
    "sync_enabled",
}
ALLOWED_HEADERS = REQUIRED_HEADERS | OPTIONAL_HEADERS
SENSITIVE_HEADERS = {
    "access_key",
    "access_key_id",
    "access_key_secret",
    "accesskey",
    "accesskeyid",
    "accesskeysecret",
    "password",
    "secret",
    "secret_key",
    "token",
}
PAYLOAD_SALT = "cmdb-cloud-account-csv-v1"


def _parse_boolean(value, field_name, row_number):
    normalized = value.strip().lower()
    if normalized in {"", "1", "true", "yes", "y"}:
        return True
    if normalized in {"0", "false", "no", "n"}:
        return False
    raise ValueError("第 %s 行的 %s 必须是 true 或 false。" % (row_number, field_name))


def _provided_optional_fields(row):
    if "provided_fields" in row:
        return set(row["provided_fields"])
    return OPTIONAL_HEADERS & set(row)


def _validated_account(row, provider, instance=None):
    account = instance or CloudAccount(
        provider=provider,
        account_key=row["account_key"],
    )
    account.provider = provider
    account.account_key = row["account_key"]
    account.name = row["name"]
    for field_name in _provided_optional_fields(row):
        setattr(account, field_name, row[field_name])
    try:
        account.full_clean(validate_unique=False, validate_constraints=False)
    except ValidationError as exc:
        raise ValueError(";".join(exc.messages)) from exc
    return account


def parse_cloud_account_csv(uploaded_file):
    try:
        content = uploaded_file.read().decode("utf-8-sig")
    except UnicodeDecodeError as exc:
        raise ValueError("CSV 必须使用 UTF-8 编码。") from exc

    reader = csv.DictReader(io.StringIO(content))
    try:
        fieldnames = reader.fieldnames
    except csv.Error as exc:
        raise ValueError("CSV 字段过长或格式无效。") from exc
    if not fieldnames:
        raise ValueError("CSV 缺少表头。")

    normalized_headers = []
    for header in fieldnames:
        if header is None or not header.strip():
            raise ValueError("CSV 表头不能包含空字段名。")
        normalized_headers.append(header.strip().lower())
    if len(set(normalized_headers)) != len(normalized_headers):
        raise ValueError("CSV 表头不能包含重复字段。")

    headers = set(normalized_headers)
    sensitive = headers & SENSITIVE_HEADERS
    if sensitive:
        raise ValueError(
            "CSV 禁止包含密钥字段:%s。请只填写 credential_profile。"
            % ", ".join(sorted(sensitive))
        )
    unsupported = headers - ALLOWED_HEADERS
    if unsupported:
        raise ValueError(
            "CSV 包含不支持的字段:%s。"
            % ", ".join(sorted(unsupported))
        )
    missing = REQUIRED_HEADERS - headers
    if missing:
        raise ValueError("CSV 缺少字段:%s。" % ", ".join(sorted(missing)))

    providers = {
        provider.code: provider for provider in CloudProvider.objects.all()
    }
    normalized_rows = []
    errors = []
    seen_identities = set()
    try:
        raw_rows = list(reader)
    except csv.Error as exc:
        raise ValueError("CSV 字段过长或格式无效。") from exc
    for row_number, raw_row in enumerate(raw_rows, start=2):
        if None in raw_row:
            errors.append("第 %s 行:字段数量超过 CSV 表头。" % row_number)
            continue
        row = {
            key.strip().lower(): (value or "").strip()
            for key, value in raw_row.items()
        }
        if not any(row.values()):
            continue
        try:
            provider_code = row["provider_code"]
            account_key = row["account_key"]
            name = row["name"]
            if not provider_code or not account_key or not name:
                raise ValueError("provider_code、account_key 和 name 不能为空。")
            provider = providers.get(provider_code)
            if provider is None:
                raise ValueError("云厂商不存在:%s。" % provider_code)
            identity = (provider_code, account_key)
            if identity in seen_identities:
                raise ValueError(
                    "CSV 内重复云账号身份:%s / %s。"
                    % (provider_code, account_key)
                )
            region_allowlist = []
            seen_region_ids = set()
            for item in row.get("region_allowlist", "").split("|"):
                region_id = item.strip()
                if region_id and region_id not in seen_region_ids:
                    region_allowlist.append(region_id)
                    seen_region_ids.add(region_id)
            normalized_row = {
                "provider_code": provider_code,
                "account_key": account_key,
                "name": name,
                "credential_profile": row.get("credential_profile", ""),
                "region_allowlist": region_allowlist,
                "is_active": _parse_boolean(
                    row.get("is_active", "true"),
                    "is_active",
                    row_number,
                ),
                "sync_enabled": _parse_boolean(
                    row.get("sync_enabled", "true"),
                    "sync_enabled",
                    row_number,
                ),
                "provided_fields": sorted(headers & OPTIONAL_HEADERS),
            }
            _validated_account(normalized_row, provider)
            normalized_rows.append(normalized_row)
            seen_identities.add(identity)
        except ValueError as exc:
            errors.append("第 %s 行:%s" % (row_number, exc))

    if not normalized_rows and not errors:
        errors.append("CSV 没有可导入的数据行。")
    return normalized_rows, errors


def sign_rows(rows):
    return signing.dumps(rows, salt=PAYLOAD_SALT, compress=True)


def load_signed_rows(payload):
    try:
        return signing.loads(payload, salt=PAYLOAD_SALT, max_age=1800)
    except signing.BadSignature as exc:
        raise ValueError("预览数据已经失效,请重新上传 CSV。") from exc


@transaction.atomic
def import_cloud_accounts(rows):
    provider_codes = {row["provider_code"] for row in rows}
    provider_queryset = CloudProvider.objects.filter(
        code__in=provider_codes
    ).order_by("pk")
    if connection.vendor == "sqlite":
        provider_queryset.update(updated_at=F("updated_at"))
    providers = {
        provider.code: provider
        for provider in provider_queryset.select_for_update()
    }
    missing_providers = provider_codes - set(providers)
    if missing_providers:
        raise ValueError(
            "云厂商不存在:%s。" % ", ".join(sorted(missing_providers))
        )

    created_count = 0
    updated_count = 0
    for row_number, row in enumerate(rows, start=1):
        provider = providers[row["provider_code"]]
        existing = CloudAccount.objects.filter(
            provider=provider,
            account_key=row["account_key"],
        ).first()
        try:
            validated = _validated_account(row, provider, instance=existing)
        except ValueError as exc:
            raise ValueError("第 %s 行:%s" % (row_number, exc)) from exc
        provided_fields = _provided_optional_fields(row)
        defaults = {"name": validated.name}
        for field_name in provided_fields:
            defaults[field_name] = getattr(validated, field_name)
        account, created = CloudAccount.objects.update_or_create(
            provider=provider,
            account_key=validated.account_key,
            defaults=defaults,
        )
        if created:
            created_count += 1
        else:
            updated_count += 1
    return created_count, updated_count

该文件按小组解释如下:

  • 导入组使用标准库 csv/io、Django signing、模型校验与数据库事务。
  • REQUIRED_HEADERS 要求 Provider 代码、账号键、名称;可选集合明确允许凭据前缀、地域白名单和两个布尔字段。
  • SENSITIVE_HEADERS 拒绝常见 AccessKey、Secret、密码和 Token 列。CSV 只能保存环境变量前缀,不能携带凭据值。
  • _parse_boolean 规范化常见真/假值,其他文本产生带行号错误。
  • _validated_account 同时服务预览与正式导入,并调用模型 full_clean。
  • 文件以 utf-8-sig 解码,既接受普通 UTF-8,也接受 UTF-8 BOM;解码失败转为明确错误。
  • 表头先去空白并转小写,再拒绝空字段名和规范化后的重复字段,因此 name 与 Name 也算重复。
  • 随后拒绝敏感字段、未知字段和缺失必填字段;拼错的 sync_enable 不会被静默忽略。
  • DictReader 在数据列多于表头时使用 None 键;代码将其转换为逐行错误,不触发 500。
  • 空行跳过;必填值不能为空;Provider 必须存在;地域以竖线分隔并按首次出现顺序去重。
  • 预览结果用固定 salt 签名并压缩;确认时限制 1800 秒有效期。
  • import_cloud_accounts 被 transaction.atomic 包裹。Provider 先按主键排序并加行锁;SQLite 先执行无值变化的更新以取得写锁,再重读锁定结果。随后使用 update_or_create 写账号,任意一行失败都会回滚全部行。
  • 更新账号的 defaults 不包含 last_successful_sync_at,所以 CSV 更新不会清空已有成功同步时间。

24.4 两阶段导入视图

相对路径:devopsX/cmdb/views.py(当前源码第 397—445 行的连续片段)

@login_required
@permission_required("cmdb.import_cloudaccount", raise_exception=True)
def account_csv_import(request):
    preview_rows = None
    signed_payload = ""
    upload_form = CloudAccountCsvForm()
    confirm_form = CloudAccountCsvConfirmForm()

    if request.method == "POST" and request.POST.get("action") == "preview":
        upload_form = CloudAccountCsvForm(request.POST, request.FILES)
        if upload_form.is_valid():
            try:
                preview_rows, errors = parse_cloud_account_csv(
                    upload_form.cleaned_data["csv_file"]
                )
            except ValueError as exc:
                upload_form.add_error("csv_file", str(exc))
            else:
                if errors:
                    for error in errors:
                        upload_form.add_error("csv_file", error)
                else:
                    signed_payload = sign_rows(preview_rows)
                    confirm_form = CloudAccountCsvConfirmForm(
                        initial={"signed_payload": signed_payload}
                    )

    if request.method == "POST" and request.POST.get("action") == "import":
        confirm_form = CloudAccountCsvConfirmForm(request.POST)
        if confirm_form.is_valid():
            try:
                rows = load_signed_rows(confirm_form.cleaned_data["signed_payload"])
                created_count, updated_count = import_cloud_accounts(rows)
            except ValueError as exc:
                confirm_form.add_error("signed_payload", str(exc))
            else:
                messages.success(
                    request,
                    "导入完成:新建 %s,更新 %s。" % (created_count, updated_count),
                )
                return redirect("cmdb:account_list")

    context = {
        "upload_form": upload_form,
        "confirm_form": confirm_form,
        "preview_rows": preview_rows,
        "signed_payload": signed_payload,
    }
    return render(request, "cmdb/account_csv_import.html", context)

同一个 URL 通过隐藏的 action 区分 preview 与 import。预览阶段读取上传文件,错误回填到文件字段;全部行通过才签名。确认阶段只接收签名载荷,验证后调用原子导入;成功采用 POST/Redirect/GET 回账号列表,避免刷新重复提交。

相对路径:devopsX/cmdb/templates/cmdb/account_csv_import.html(完整文件,内容与当前源码一致)

{% extends "base.html" %}

{% block title %}CSV 导入云账号 - CMDB{% endblock %}

{% block content %}
<section class="page-heading"><p class="eyebrow">CSV Import</p><h1>CSV 导入云账号</h1><p>先预览、再确认导入;CSV 禁止包含密钥、密码和 Token。</p></section>
<section class="panel"><h2>1. 上传并预览</h2><p class="callout"><code>provider_code,account_key,name,credential_profile,region_allowlist,is_active,sync_enabled</code><br>地域使用竖线分隔,例如 <code>cn-hangzhou|cn-shanghai</code>。更新已有账号时,未提供的可选列保持原值;已提供的空单元格按该列的空值或默认值处理。</p><form method="post" enctype="multipart/form-data">{% csrf_token %}<input type="hidden" name="action" value="preview">{{ upload_form.as_p }}<button class="button" type="submit">预览 CSV</button></form></section>
{% if preview_rows %}<section class="panel"><h2>2. 核对并导入</h2><div class="table-wrap"><table><thead><tr><th>厂商</th><th>账号键</th><th>名称</th><th>凭据前缀</th><th>地域</th><th>启用</th><th>同步</th></tr></thead><tbody>{% for row in preview_rows %}<tr><td>{{ row.provider_code }}</td><td><code>{{ row.account_key }}</code></td><td>{{ row.name }}</td><td>{% if "credential_profile" in row.provided_fields %}<code>{{ row.credential_profile|default:"—" }}</code>{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td><td>{% if "region_allowlist" in row.provided_fields %}{{ row.region_allowlist|join:", "|default:"全部" }}{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td><td>{% if "is_active" in row.provided_fields %}{{ row.is_active|yesno:"是,否" }}{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td><td>{% if "sync_enabled" in row.provided_fields %}{{ row.sync_enabled|yesno:"是,否" }}{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div><p class="muted">新建账号未提供可选列时使用模型默认值;更新已有账号时保留数据库中的当前值。</p><form method="post">{% csrf_token %}<input type="hidden" name="action" value="import">{{ confirm_form }}<button class="button" type="submit">确认导入 {{ preview_rows|length }} 行</button></form></section>{% endif %}
{% if confirm_form.errors %}<section class="panel"><h2>导入失败</h2>{{ confirm_form.errors }}</section>{% endif %}
{% endblock %}

第一段始终显示严格列结构与上传表单;只有 preview_rows 存在时才出现核对表格和确认按钮。两个 POST 表单都带 CSRF。页面明确禁止密钥、密码与 Token。

24.5 当前严格 CSV 结构

列必填规则
provider_code是必须匹配已有 Provider 代码
account_key是非空;与 Provider 共同构成账号身份
name是非空显示名称
credential_profile否大写字母开头,只含大写字母、数字、下划线;不是凭据值
region_allowlist否地域 ID 用竖线分隔,重复值去重
is_active否支持 true/false、1/0、yes/no、y/n;空值默认真
sync_enabled否与 is_active 相同

严格模式还拒绝未知表头、空表头、重复表头、敏感表头、数据行额外列、没有有效数据行以及非 UTF-8 文件。

24.6 电子表格安全导出

相对路径:devopsX/cmdb/views.py(当前源码第 41—46 行的连续片段)

def _csv_safe(value):
    text = str(value)
    candidate = text.lstrip(" ")
    if candidate and candidate[0] in "=+-@\t\r\n":
        return "'%s" % text
    return text

相对路径:devopsX/cmdb/views.py(当前源码第 448—498 行的连续片段)

@login_required
@permission_required("cmdb.export_computeinstance", raise_exception=True)
@require_GET
def instance_csv_export(request):
    response = HttpResponse(content_type="text/csv; charset=utf-8")
    response["Content-Disposition"] = 'attachment; filename="cmdb-instances.csv"'
    response.write("")
    writer = csv.writer(response)
    writer.writerow(
        [
            "provider_code",
            "account_key",
            "instance_id",
            "name",
            "region_id",
            "zone_id",
            "status",
            "lifecycle",
            "private_ips",
            "public_ips",
            "last_seen_at",
        ]
    )
    instances = ComputeInstance.objects.select_related(
        "account",
        "account__provider",
        "region",
        "availability_zone",
    ).order_by("account__provider__code", "account__account_key", "provider_resource_id")
    for instance in instances:
        writer.writerow(
            [
                _csv_safe(value)
                for value in (
                    instance.account.provider.code,
                    instance.account.account_key,
                    instance.provider_resource_id,
                    instance.name,
                    instance.region.provider_resource_id,
                    instance.availability_zone.provider_resource_id
                    if instance.availability_zone
                    else "",
                    instance.normalized_status,
                    instance.lifecycle_state,
                    "|".join(instance.private_ips),
                    "|".join(instance.public_ips),
                    instance.last_seen_at.isoformat(),
                )
            ]
        )
    return response
  • _csv_safe 先转换成字符串,再忽略开头普通空格检查首个有效字符;遇到危险前缀就在原文本前增加单引号。
  • 危险集合包含 = + - @ 以及制表符、回车和换行。
  • 响应声明 UTF-8 CSV,并先写 BOM,改善常见桌面表格软件识别中文编码的体验。
  • csv.writer 负责字段引号与换行;每个导出值在进入 writer 前都经过 _csv_safe。
  • IP 使用竖线连接;可用区为空时导出空字符串;时间使用 ISO 8601。

常见错误是只依赖 csv.writer。CSV 引号保持文件结构,却不会阻止表格软件把单元格当公式;结构转义和公式中和是两层防护。

24.7 CSV 检查点

  • 未知、空或重复表头应在预览页报错,不进入确认步骤。
  • 数据行比表头多一列时,应显示“字段数量超过 CSV 表头”,不应返回 500。
  • 篡改或过期签名载荷时,应要求重新上传预览。
  • 两行导入的第二行校验失败时,第一行也不应留在数据库。
  • 实例名称以危险公式字符开头时,导出单元格应以前置单引号中和;文件应以 UTF-8 BOM 开始。

25 版本化 JsonResponse API、Session 认证与正文校验

25.1 API 版本与 Session 认证

API 版本化是在 URL 和响应中显式标识契约版本,使未来不兼容变更可以进入新版本而不悄悄破坏旧客户端。本项目同时使用 /api/v1/ 路径和响应字段 api_version: v1。

Session 认证表示 API 复用 Django 登录会话:浏览器登录后携带 Session Cookie,login_required 恢复用户;随后仍由模型权限授权。它不是无状态 Token API。由于 POST 仍经过 CsrfViewMiddleware,浏览器 Session API 的写请求也必须携带 CSRF 令牌。

相对路径:devopsX/cmdb/api_urls.py(完整文件,内容与当前源码一致)

from django.urls import path

from . import api_views


app_name = "api"

urlpatterns = [
    path("accounts/", api_views.account_list, name="account_list"),
    path("accounts/<int:pk>/sync/", api_views.account_sync, name="account_sync"),
    path("instances/", api_views.instance_list, name="instance_list"),
    path("instances/<int:pk>/", api_views.instance_detail, name="instance_detail"),
]

四条 v1 路由分别提供账号列表、账号同步、实例列表和实例详情;它们由主 CMDB URL 挂载到 /cmdb/api/v1/。

相对路径:devopsX/cmdb/api_views.py(完整文件,内容与当前源码一致)

import json

from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_GET, require_POST

from .models import CloudAccount, ComputeInstance, SyncRun
from .providers.base import ProviderError
from .services.sync import sync_account


API_VERSION = "v1"
CONFLICT_ERROR_CODES = {
    "ACCOUNT_DISABLED",
    "PROVIDER_DISABLED",
    "SYNC_ALREADY_RUNNING",
    "SYNC_CONFIGURATION_CHANGED",
    "SYNC_RUN_SUPERSEDED",
}
UPSTREAM_ERROR_CODES = {
    "ALIYUN_ACCESS_DENIED",
    "ALIYUN_INCOMPLETE_PAGINATION",
    "ALIYUN_INCONSISTENT_PAGINATION",
    "ALIYUN_INVALID_RESPONSE",
    "ALIYUN_INVALID_TIME",
    "ALIYUN_NETWORK_ERROR",
    "ALIYUN_REQUEST_FAILED",
    "ALIYUN_SDK_NOT_INSTALLED",
    "ALIYUN_THROTTLED",
    "DUPLICATE_INSTANCE",
    "DUPLICATE_REGION",
    "DUPLICATE_ZONE",
    "EMPTY_INSTANCE_ID",
    "EMPTY_REGION_ID",
    "EMPTY_ZONE_ID",
    "INCOMPLETE_DISCOVERY_SCOPE",
    "INSTANCE_ZONE_REGION_MISMATCH",
    "INVALID_DISCOVERY_SCOPE",
    "INVALID_NORMALIZED_STATUS",
    "OUT_OF_SCOPE_DISCOVERY_REGION",
    "PROVIDER_UNAVAILABLE",
    "UNEXPECTED_ERROR",
    "UNKNOWN_INSTANCE_REGION",
    "UNKNOWN_INSTANCE_ZONE",
    "UNKNOWN_ZONE_REGION",
}


def _provider_error_status(code):
    if code in CONFLICT_ERROR_CODES:
        return 409
    if code in UPSTREAM_ERROR_CODES:
        return 502
    return 422


def _json_response(data, status=200):
    return JsonResponse(
        data,
        status=status,
        json_dumps_params={"ensure_ascii": False},
    )


def _instance_payload(instance):
    return {
        "id": instance.id,
        "provider": instance.account.provider.code,
        "account_key": instance.account.account_key,
        "provider_resource_id": instance.provider_resource_id,
        "name": instance.name,
        "region": instance.region.provider_resource_id,
        "availability_zone": (
            instance.availability_zone.provider_resource_id
            if instance.availability_zone
            else None
        ),
        "instance_type": instance.instance_type,
        "vcpu": instance.vcpu,
        "memory_mb": instance.memory_mb,
        "os_name": instance.os_name,
        "provider_status": instance.provider_status,
        "normalized_status": instance.normalized_status,
        "lifecycle_state": instance.lifecycle_state,
        "private_ips": instance.private_ips,
        "public_ips": instance.public_ips,
        "cloud_created_at": (
            instance.cloud_created_at.isoformat() if instance.cloud_created_at else None
        ),
        "first_seen_at": instance.first_seen_at.isoformat(),
        "last_seen_at": instance.last_seen_at.isoformat(),
        "missing_since": (
            instance.missing_since.isoformat() if instance.missing_since else None
        ),
        "retired_at": instance.retired_at.isoformat() if instance.retired_at else None,
        "tags": [
            {"key": tag.key, "value": tag.value, "source": tag.source}
            for tag in instance.tags.all()
        ],
    }


@login_required
@permission_required("cmdb.view_cloudaccount", raise_exception=True)
@require_GET
def account_list(request):
    accounts = CloudAccount.objects.select_related("provider").order_by(
        "provider__code",
        "account_key",
    )
    try:
        page_size = min(max(int(request.GET.get("page_size", 50)), 1), 100)
    except ValueError:
        return _json_response(
            {"api_version": API_VERSION, "error": "page_size 必须是整数。"},
            status=400,
        )
    page_obj = Paginator(accounts, page_size).get_page(request.GET.get("page"))
    data = [
        {
            "id": account.id,
            "provider": account.provider.code,
            "account_key": account.account_key,
            "name": account.name,
            "credential_profile": account.credential_profile,
            "region_allowlist": account.region_allowlist,
            "is_active": account.is_active,
            "sync_enabled": account.sync_enabled,
            "last_successful_sync_at": (
                account.last_successful_sync_at.isoformat()
                if account.last_successful_sync_at
                else None
            ),
        }
        for account in page_obj
    ]
    return _json_response(
        {
            "api_version": API_VERSION,
            "count": page_obj.paginator.count,
            "page": page_obj.number,
            "pages": page_obj.paginator.num_pages,
            "results": data,
        }
    )


@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
@require_GET
def instance_list(request):
    instances = ComputeInstance.objects.select_related(
        "account",
        "account__provider",
        "region",
        "availability_zone",
    ).prefetch_related("tags")
    keyword = request.GET.get("q", "").strip()
    if keyword:
        instances = instances.filter(
            Q(provider_resource_id__icontains=keyword)
            | Q(name__icontains=keyword)
            | Q(os_name__icontains=keyword)
        )
    lifecycle = request.GET.get("lifecycle", "").strip()
    if lifecycle:
        instances = instances.filter(lifecycle_state=lifecycle)
    instances = instances.order_by("id")

    try:
        page_size = min(max(int(request.GET.get("page_size", 50)), 1), 100)
    except ValueError:
        return _json_response(
            {"api_version": API_VERSION, "error": "page_size 必须是整数。"},
            status=400,
        )
    page_obj = Paginator(instances, page_size).get_page(request.GET.get("page"))
    return _json_response(
        {
            "api_version": API_VERSION,
            "count": page_obj.paginator.count,
            "page": page_obj.number,
            "pages": page_obj.paginator.num_pages,
            "results": [_instance_payload(instance) for instance in page_obj],
        }
    )


@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
@require_GET
def instance_detail(request, pk):
    instance = get_object_or_404(
        ComputeInstance.objects.select_related(
            "account",
            "account__provider",
            "region",
            "availability_zone",
        ).prefetch_related("tags"),
        pk=pk,
    )
    return _json_response(
        {"api_version": API_VERSION, "result": _instance_payload(instance)}
    )


@login_required
@permission_required("cmdb.sync_cloudaccount", raise_exception=True)
@require_POST
def account_sync(request, pk):
    account = get_object_or_404(
        CloudAccount.objects.select_related("provider"),
        pk=pk,
    )
    try:
        body = json.loads(request.body or b"{}")
    except (UnicodeDecodeError, json.JSONDecodeError):
        return _json_response(
            {"api_version": API_VERSION, "error": "请求正文不是有效 JSON。"},
            status=400,
        )
    if not isinstance(body, dict):
        return _json_response(
            {"api_version": API_VERSION, "error": "请求正文必须是 JSON 对象。"},
            status=400,
        )
    scenario = body.get("scenario", "default")
    if not isinstance(scenario, str) or scenario not in {
        "default",
        "empty",
        "partial",
        "failed",
    }:
        return _json_response(
            {"api_version": API_VERSION, "error": "未知 Fake 场景。"},
            status=400,
        )
    if account.provider.code != "fake":
        scenario = "default"
    try:
        sync_run = sync_account(
            account,
            requested_by=request.user,
            trigger=SyncRun.Trigger.API,
            scenario=scenario,
        )
    except ProviderError as exc:
        return _json_response(
            {
                "api_version": API_VERSION,
                "error": exc.message,
                "error_code": exc.code,
            },
            status=_provider_error_status(exc.code),
        )
    return _json_response(
        {
            "api_version": API_VERSION,
            "result": {
                "public_id": str(sync_run.public_id),
                "status": sync_run.status,
                "discovered_count": sync_run.discovered_count,
                "created_count": sync_run.created_count,
                "updated_count": sync_run.updated_count,
                "unchanged_count": sync_run.unchanged_count,
                "missing_count": sync_run.missing_count,
                "restored_count": sync_run.restored_count,
                "error_code": sync_run.error_code,
                "error_message": sync_run.error_message,
            },
        },
        status=207 if sync_run.status == SyncRun.Status.PARTIAL else 201,
    )

完整文件逐组解释如下:

  • 导入组使用标准库 JSON、Django 认证/授权装饰器、分页、查询表达式、JsonResponse、404 与方法装饰器。
  • API_VERSION 是统一响应版本;_json_response 设置状态码并关闭 ASCII 转义,使中文错误可直接阅读。
  • _instance_payload 明确列出实例字段与标签,不把 ORM 对象或任意模型字段直接序列化;可空时间与可用区显式输出 null。
  • 账号列表要求账号读取权限,稳定排序后返回显式字段。credential_profile 只是环境变量前缀,不是凭据值。
  • 实例列表要求实例读取权限,支持 ID、名称、操作系统关键词和生命周期筛选;按 ID 稳定排序。
  • page_size 必须是整数,再限制到 1—100;无效文本返回 400,不产生 500。
  • 实例详情复用 payload 生成器并由 get_object_or_404 返回标准 404。
  • 账号同步要求同步权限与 POST。json.loads 接受 bytes;无效 UTF-8 抛 UnicodeDecodeError,语法错误抛 JSONDecodeError,当前实现同时捕获并返回 400。
  • JSON 解析成功后还必须是对象。null、数组、字符串和数字都被拒绝,避免后续 body.get 触发属性错误。
  • scenario 必须是字符串且属于四个 Fake 场景;非 Fake Provider 强制使用默认场景。
  • CONFLICT_ERROR_CODES 把账号或厂商停用、同步占用、配置变化和运行被替代映射为 409;UPSTREAM_ERROR_CODES 把阿里云请求失败、响应结构不完整、分页不一致以及通用快照完整性错误映射为 502;其余客户端或配置语义错误返回 422。
  • 账号列表和实例列表都使用 Paginator;页大小必须是整数并限制在 1—100,账号分页不会在跨页时重复或漏掉结果。
  • 完整成功返回 201;部分快照返回 207,并在结果中携带状态、错误代码、脱敏错误信息与统计。

25.2 API 状态码契约

状态场景
200列表或详情读取成功
201API 成功触发一次完整同步执行
207API 应用部分快照,返回 PARTIAL 状态和脱敏错误详情
302匿名请求被 Session 登录装饰器重定向到登录页
400page_size 非整数、JSON 非法、UTF-8 非法、正文不是对象或场景非法
403缺少模型权限,或 Session POST 未通过 CSRF
404目标账号或实例不存在
405用 GET 调用同步端点或用非 GET 调用只读端点
409账号/厂商停用、已有有效同步、配置变化或运行被替代
422不属于冲突或上游故障的客户端/账号配置语义错误
502云接口、SDK、网络、分页、响应结构或标准快照完整性失败

25.3 浏览器与测试检查点

  • 在同一浏览器登录,再打开 /cmdb/api/v1/accounts/;有账号读取权限时看到 JSON,没有权限时为 403。
  • page_size=0 被夹到 1,超过 100 被夹到 100,非整数返回 400。
  • 同步正文为损坏 JSON 或非法 UTF-8 时都返回 400 和统一错误,不泄露解码异常。
  • 正文为 null、数组、字符串、数字,或 scenario 为数组时返回 400,不触发 500。
  • 账号结果可以包含凭据环境变量前缀,但不得出现真实凭据值。

常见错误是认为“返回 JsonResponse”就自动变成 Token API,或者认为 JSON POST 不需要 CSRF。当前端点仍是 Django Session 认证模型,认证 Cookie 与 CSRF 防护必须配套理解。

26 模板、静态样式与移动端行为

26.1 基础模板承担什么职责

相对路径:devopsX/templates/base.html(完整文件,内容与当前源码一致)

{% load static %}
<!doctype html>
<html lang="zh-Hans">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}devopsX{% endblock %}</title>
    <link rel="stylesheet" href="{% static 'cmdb/style.css' %}">
</head>
<body>
<header class="site-header">
    <a class="brand" href="{% url 'accounts:home' %}">devopsX</a>
    <nav>
        {% if user.is_authenticated %}
            {% if perms.cmdb.view_cloudprovider or perms.cmdb.view_cloudaccount or perms.cmdb.view_computeinstance or perms.cmdb.view_syncrun %}<a href="{% url 'cmdb:home' %}">CMDB</a>{% endif %}
            {% if perms.cmdb.view_cloudaccount %}<a href="{% url 'cmdb:account_list' %}">云账号</a>{% endif %}
            {% if perms.cmdb.view_computeinstance %}<a href="{% url 'cmdb:instance_list' %}">计算实例</a>{% endif %}
            {% if perms.cmdb.view_computeinstance %}<a href="{% url 'cmdb:topology' %}">拓扑</a>{% endif %}
            {% if perms.cmdb.view_syncrun %}<a href="{% url 'cmdb:sync_run_list' %}">同步历史</a>{% endif %}
            {% if user.is_staff %}<a href="{% url 'admin:index' %}">管理后台</a>{% endif %}
            <form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
                {% csrf_token %}
                <button type="submit" class="link-button">退出</button>
            </form>
        {% else %}
            <a href="{% url 'accounts:login' %}">登录</a>
        {% endif %}
    </nav>
</header>
<main class="page-shell">
    {% if messages %}
        {% for message in messages %}<p class="message message-{{ message.tags|default:'info' }}">{{ message }}</p>{% endfor %}
    {% endif %}
    {% block content %}{% endblock %}
</main>
</body>
</html>
  • 文档声明、语言、UTF-8 与 viewport 位于 head;viewport 是移动端按设备宽度布局的前提。
  • 静态样式通过 Django static 标签加载,不把页面样式散落到每个模板。
  • 导航按认证状态和模型权限显示入口;管理后台入口还要求 is_staff。
  • 退出使用带 CSRF 的内联 POST 表单。
  • 消息框统一显示登录、同步、导入、标签与退役反馈。
  • 子模板只覆盖标题与内容块,避免重复页面骨架。

再次强调:导航隐藏是可用性设计,视图装饰器才是授权边界。

26.2 页面语义与复用

模板主要语义结构关键行为
devopsX/cmdb/templates/cmdb/home.html页头、统计卡片、最近同步表格按权限分别显示
devopsX/cmdb/templates/cmdb/instance_list.htmlGET 筛选表单、结果表格、分页导航保留查询条件
devopsX/cmdb/templates/cmdb/instance_detail.html定义列表、标签、details 变更记录写表单受权限与 CSRF 保护
devopsX/cmdb/templates/cmdb/topology.html嵌套 details/summary 与列表无 JavaScript 也可折叠
devopsX/cmdb/templates/cmdb/account_csv_import.html上传、预览表格、确认表单两阶段提交
devopsX/cmdb/templates/cmdb/_pagination.html分页 nav多个列表复用

26.3 当前静态样式完整文件

相对路径:devopsX/cmdb/static/cmdb/style.css(完整文件,内容与当前源码一致)

:root {
    color-scheme: light;
    font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
    color: #172033;
    background: #f4f7fb;
}

* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #f4f7fb; }
a { color: #2457d6; text-decoration: none; }
code, pre { font-family: Consolas, "SFMono-Regular", monospace; }

.site-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 24px;
    padding: 18px max(24px, calc((100vw - 1120px) / 2));
    background: #ffffff;
    border-bottom: 1px solid #dfe6f0;
}
.brand { color: #172033; font-size: 22px; font-weight: 800; }
.site-header nav { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
.inline-form { display: inline; }
.link-button { padding: 0; border: 0; color: #2457d6; background: transparent; font: inherit; cursor: pointer; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: 42px auto; }

.hero, .panel, .page-heading, .empty-state, .stat-card {
    background: #ffffff;
    border: 1px solid #dfe6f0;
    border-radius: 16px;
    box-shadow: 0 12px 32px rgba(23, 32, 51, 0.06);
}
.hero, .page-heading, .empty-state, .panel { padding: 28px; }
.page-heading { margin-bottom: 22px; }
.page-heading h1, .hero h1 { margin: 8px 0 12px; font-size: clamp(30px, 5vw, 48px); }
.page-heading p, .hero p { color: #58708f; line-height: 1.7; }
.split-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.eyebrow { margin: 0; color: #58708f; font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.button { display: inline-block; padding: 10px 16px; border: 0; border-radius: 9px; color: #fff; background: #2457d6; font: inherit; cursor: pointer; }
.button-secondary { color: #2457d6; background: #e8eefc; }
.button-danger { background: #c63b4a; }

.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 20px 0; }
.four-columns { grid-template-columns: repeat(4, 1fr); }
.stat-card { padding: 22px; }
.stat-card strong, .stat-card span { display: block; }
.stat-card strong { font-size: 34px; }
.stat-card span { margin-top: 6px; color: #58708f; }
.empty-state { text-align: center; margin: 22px 0; }
.empty-state h1, .empty-state h2 { margin-top: 0; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.section-heading h2, .panel h2 { margin-top: 0; }
.card-grid, .detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.detail-grid { margin: 18px 0; }

.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th, td { padding: 12px 10px; border-bottom: 1px solid #e6ebf2; text-align: left; vertical-align: top; }
th { color: #58708f; font-size: 13px; white-space: nowrap; }
tr:last-child td { border-bottom: 0; }
.muted { color: #71839c; }
.callout { padding: 14px 16px; border-left: 4px solid #2457d6; background: #eef3ff; line-height: 1.7; }
.message { padding: 12px 16px; border-radius: 8px; background: #e8f5ec; color: #25613b; }
.message-error { background: #fff0f0; color: #9b2737; }
.message-warning { background: #fff8e6; color: #805b00; }

.badge, .tag { display: inline-block; padding: 3px 8px; border-radius: 999px; background: #edf1f7; color: #40536d; font-size: 12px; white-space: nowrap; }
.badge-succeeded, .badge-present, .badge-running { background: #dff4e5; color: #25613b; }
.badge-partial, .badge-missing, .badge-starting, .badge-stopping { background: #fff2cf; color: #805b00; }
.badge-failed, .badge-retired, .badge-stopped { background: #ffe3e3; color: #9b2737; }
.tag { margin: 0 6px 8px 0; }
.tag-manual { background: #e8eefc; color: #2457d6; }
.tag small { margin-left: 4px; opacity: .75; }

.detail-list { display: grid; grid-template-columns: minmax(120px, .7fr) 1.3fr; gap: 11px 18px; margin: 0; }
.detail-list dt { color: #58708f; }
.detail-list dd { margin: 0; overflow-wrap: anywhere; }
.form-panel { max-width: 720px; margin: 0 auto; }
form p { margin: 16px 0; }
label { display: block; color: #40536d; font-weight: 600; }
input, select, textarea { display: block; width: 100%; margin-top: 6px; padding: 10px 12px; border: 1px solid #b9c7da; border-radius: 8px; background: #fff; color: #172033; font: inherit; }
input[type="checkbox"] { display: inline-block; width: auto; margin-right: 6px; }
.helptext { display: block; margin-top: 5px; color: #71839c; font-size: 13px; font-weight: 400; }
.errorlist { padding: 0; color: #9b2737; list-style: none; font-size: 13px; }
.filter-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); align-items: end; gap: 14px; }
.filter-actions { display: flex; gap: 8px; align-items: end; }
.sync-form { min-width: 260px; }
.sync-form p { margin: 0 0 10px; }
.sync-form label { font-size: 13px; }
.inline-fields { display: flex; align-items: end; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.inline-fields p { margin: 0; }
.inline-fields input { min-width: 160px; }

.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 22px; }
.pagination a { padding: 7px 12px; border-radius: 7px; background: #e8eefc; }
.change-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
pre { overflow-x: auto; padding: 12px; border-radius: 8px; background: #f3f6fa; font-size: 12px; white-space: pre-wrap; }
.topology-tree details { margin: 8px 0 8px 16px; padding: 8px 0 0 14px; border-left: 2px solid #dfe6f0; }
.topology-tree > details { margin-left: 0; padding-left: 0; border-left: 0; }
.topology-tree summary { cursor: pointer; line-height: 1.8; }
.topology-tree ul { margin: 8px 0; padding-left: 24px; }

@media (max-width: 900px) {
    .four-columns { grid-template-columns: repeat(2, 1fr); }
    .filter-grid { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
}
@media (max-width: 700px) {
    .site-header { align-items: flex-start; padding: 16px; }
    .page-shell { width: min(100% - 20px, 1180px); margin: 24px auto; }
    .split-heading, .detail-grid, .card-grid { display: block; }
    .split-heading .actions, .split-heading .sync-form { margin-top: 18px; }
    .stats-grid, .four-columns { grid-template-columns: 1fr; }
    .filter-grid { grid-template-columns: 1fr; }
    .hero, .page-heading, .empty-state, .panel { padding: 20px; }
    .change-grid { display: block; }
}

样式按功能组解释如下:

  • :root、通配符、body、链接与等宽字体建立全局基线。
  • 站点头部使用 Flex 并允许导航换行;页面容器使用视口相关的弹性尺寸。
  • hero、panel、页头、空状态和统计卡片共享边框、圆角与阴影。
  • split-heading、actions、button 系列组织标题与动作;危险按钮使用独立颜色。
  • 统计、卡片和详情使用 Grid;表格容器允许横向滚动,长字段不会撑破详情定义列表。
  • badge 根据同步、状态和生命周期复用语义颜色;人工标签拥有独立样式。
  • 表单控件统一触控尺寸;筛选区桌面四列,操作按钮与行内字段使用 Flex。
  • 变更前后使用双列网格;pre 支持换行和滚动;拓扑通过边线表达层级。
  • 900px 以下统计与筛选减少列数;700px 以下页头/详情/卡片改为块级,统计与筛选变单列,变更对比改为上下排列。

26.4 移动端浏览器检查点

  1. 在浏览器开发者工具切换到约 375px 宽的移动视口并刷新。
  2. 确认导航可以换行,页面没有整体横向溢出。
  3. 确认统计卡片与筛选表单为单列,按钮仍可点击。
  4. 确认多列表格只在自己的容器内横向滚动,页面标题和其他卡片不随之溢出。
  5. 确认实例详情双栏与变更前后双栏改为上下排列。
  6. 确认拓扑层级可展开、可收起,“未分配可用区”仍可见。

常见错误是为了让桌面表格完整塞入手机而缩小字体。当前实现保留可读字号,把横向滚动限制在表格容器,其他内容继续响应式重排。

27 自动化测试与浏览器验收

27.1 测试覆盖地图

测试模块当前重点
devopsX/accounts/tests.py公开首页、登录默认跳转、安全站内 next、拒绝外部 next
devopsX/cmdb/tests/test_views.py匿名跳转、首页权限、普通用户 403、三类残余读取边界、POST-only、搜索、畸形关联 ID、部分同步警告、无可用区拓扑、标签与退役
devopsX/cmdb/tests/test_csv.py敏感/未知/空/重复表头、额外列、预览签名导入、模型校验、事务回滚、保留成功同步时间、BOM 与公式中和
devopsX/cmdb/tests/test_api.pyv1 契约、权限读取、POST 同步、部分同步 207、非法 JSON、非法 UTF-8、非对象 JSON、page_size
devopsX/cmdb/tests/test_sync.py同步幂等、完整/部分快照、缺失/恢复/退役、标签隔离、范围校验、并发阻塞与陈旧执行
devopsX/cmdb/tests/test_aliyun_provider.py使用 Fake 客户端验证映射、分页、地域白名单、部分快照与脱敏错误;不是对真实阿里云的冒烟测试

27.2 先运行 Django 系统检查

在项目根目录执行;每个命令单独运行,不与迁移、启动服务或其他命令串联。

执行目录:devopsX/

python manage.py check

预期:System check identified no issues (0 silenced).

常见错误:如果生产模式环境变量尚未配置,设置模块会主动报错;在本地教学环境应使用前文已经建立的本地环境配置,不要为了通过检查而削弱生产安全默认值。

27.3 运行完整测试套件

仍在项目根目录执行。该命令会创建隔离测试数据库,不应把测试数据写入开发数据库。

执行目录:devopsX/

python manage.py test

本次实际结果:发现 105 项测试,全部通过;Django 系统检查无问题。核心输出为:

输出来源:devopsX/(命令输出)

Found 105 test(s).
System check identified no issues (0 silenced).
----------------------------------------------------------------------
Ran 105 tests

OK

耗时会随机器和数据库环境变化,因此验收重点是数量、无系统检查错误和最终 OK,不应把固定秒数写成通过条件。

27.4 分模块定位失败

权限、方法与页面边界失败时,只运行视图测试。

执行目录:devopsX/

python manage.py test cmdb.tests.test_views.CmdbViewTests

预期:该测试类通过;尤其锁定 Provider-only 不见账号数量、account-only 不见实例与同步执行、sync-run-only 不见实例变化,以及三个写动作的 POST-only 行为。

CSV 预览、表头、事务或导出失败时,只运行 CSV 测试。

执行目录:devopsX/

python manage.py test cmdb.tests.test_csv.CloudAccountCsvTests

预期:严格表头、额外列、签名导入、原子回滚、UTF-8 BOM 与公式中和全部通过。

JSON、版本、分页或 Session 权限失败时,只运行 API 测试。

执行目录:devopsX/

python manage.py test cmdb.tests.test_api.CmdbApiTests

预期:v1 列表与详情通过;同步 GET 为 405、完整成功为 201、部分同步为 207;非法 JSON、非法 UTF-8、非对象正文和非法 page_size 均为 400;上游响应结构或快照完整性失败为 502。

登录与安全回跳失败时,只运行账号视图测试。

执行目录:devopsX/

python manage.py test accounts.tests.AccountViewTests

预期:公开首页可访问;合法站内 next 生效;外部 next 被拒绝并回到 CMDB 首页。

27.5 启动本地服务器进行浏览器验收

确认系统检查与测试均通过后,在项目根目录启动开发服务器。该命令持续占用当前终端,停止时按 Ctrl+C。

执行目录:devopsX/

python manage.py runserver 127.0.0.1:8000

预期:终端显示开发服务器监听本机地址;浏览器打开站点首页可登录。若端口已占用,应停止旧进程或明确换用另一个本地端口,不要同时启动多个写同一开发数据库的服务。

27.6 最小权限浏览器验收清单

  1. 匿名访问 CMDB 首页:应跳到登录页,地址带站内 next。
  2. 无 CMDB 权限的普通用户:访问 CMDB 首页应为 403。
  3. Provider-only 用户:Provider 页面可打开,但没有“云账号数量”。
  4. account-only 用户:账号详情可打开,但没有实例数量、实例 ID、同步 UUID。
  5. sync-run-only 用户:同步详情可打开,但没有“本次变化”和实例标识。
  6. instance-only 用户:实例搜索、筛选、分页、详情和拓扑可用;同步、导入、导出或退役取决于额外权限。
  7. 对同步、标签、退役端点发 GET:权限满足时应为 405。
  8. 从正常页面 POST 同步、标签、退役:CSRF 通过后执行并重定向;缺失令牌请求应失败。
  9. CSV 导入:未知、空、重复表头和额外列均在预览阶段被拒绝;合法文件必须先预览再确认。
  10. 登录后打开 v1 API:读取结果包含 api_version;非法 JSON/UTF-8/非对象正文返回 400。
  11. 切换移动视口:筛选和卡片单列,表格局部滚动,拓扑与详情可读。

27.7 验收结论与下一篇

至此,CMDB v1.0.0 的浏览器层与 JsonResponse API 已形成可验证闭环:Session 完成认证,模型权限落实最小授权,写动作由 POST 与 CSRF 保护;资产列表具备安全排序与分页;拓扑不丢失无可用区实例;标签区分 Provider 与人工来源;CSV 导入具备严格结构、签名预览和事务原子性;CSV 导出中和电子表格公式;v1 API 对非法 UTF-8、非法 JSON 和非对象正文给出稳定 400。

本篇验证止于自动化测试和本地浏览器检查,不把 SQLite 结果外推为 MySQL 认证,也不把 Fake 阿里云客户端测试描述为真实阿里云冒烟。

继续阅读 CMDB 资产管理(5):阿里云 ECS、MySQL 与最终验收

posted @ 2026-09-16 23:37  小家电维修  阅读(7)  评论(0)    收藏  举报