# 计划执行：报告免登访问 + 操作鉴权拦截

## 文档信息

- 创建时间：2026-07-21
- 关联需求文档：`Docs/需求文档/服务监测/PRD_需求文档_报告免登访问与操作鉴权.md`
- 预计改动文件：7 个
- 预计新增文件：0 个

---

## 执行步骤

### Step 1：后端 — report_service 新增 token 生成与验证

**文件**：`skill/code/web/service_monitor/services/report_service.py`

#### 1.1 新增 token 生成逻辑

**位置**：`save()` 函数，报告数据组装处

**改动**：在保存报告时生成 `access_token` 和 `access_token_expires_at`

```python
import secrets
from datetime import timedelta

# 在 save() 函数中，组装报告数据时新增：
ACCESS_TOKEN_DAYS = 7  # token 有效期（天）

report_data["access_token"] = f"rpt_{secrets.token_hex(32)}"
report_data["access_token_expires_at"] = (
    datetime.now(SHANGHAI_TZ) + timedelta(days=ACCESS_TOKEN_DAYS)
).isoformat()
```

**说明**：
- `secrets.token_hex(32)` 生成 64 字符 hex 字符串（128 bit 熵），前缀 `rpt_` 共 68 字符
- 过期时间使用北京时间，与项目时区规范一致
- `SHANGHAI_TZ` 从 `schedule_service.py` 同步引入（或提取到公共位置）

#### 1.2 新增 token 验证函数

**位置**：`report_service.py` 新增方法

```python
def validate_access_token(self, report_id: str, token: str) -> bool:
    """验证报告访问 token 是否有效。

    Args:
        report_id: 报告 ID
        token: 访问 token（rpt_xxx 格式）

    Returns:
        True=token 有效，False=无效或过期
    """
    report = self.get_report(report_id)
    if not report:
        return False

    stored_token = report.get("access_token", "")
    if not stored_token or stored_token != token:
        return False

    expires_at_str = report.get("access_token_expires_at", "")
    if not expires_at_str:
        return False

    try:
        expires_at = datetime.fromisoformat(expires_at_str)
        if datetime.now(SHANGHAI_TZ) > expires_at:
            return False
    except (ValueError, TypeError):
        return False

    return True
```

---

### Step 2：后端 — runner_service 通知链接拼接 token

**文件**：`skill/code/web/service_monitor/services/runner_service.py`

#### 2.1 通知链接拼接 token

**位置**：`run_inspection_sync()` 函数，构建 report_url 处（L276-279）

**改前**：
```python
host = os.environ.get('EXTERNAL_HOST', 'http://192.168.5.60:8088')
report_url = f"{host}/service-monitor/report/{report_id}"
```

**改后**：
```python
host = os.environ.get('EXTERNAL_HOST', 'http://192.168.5.60:8088')
report_url = f"{host}/service-monitor/report/{report_id}"
# 拼接访问 token，实现免登查看
report_data = report_service.get_report(report_id)
if report_data and report_data.get("access_token"):
    report_url = f"{report_url}?token={report_data['access_token']}"
```

---

### Step 3：后端 — routes 支持 token 验证 + next 参数

**文件**：`skill/code/web/service_monitor/routes.py`

#### 3.1 page_report 支持 token 免登访问

**位置**：`page_report()` 函数（L173-186）

**改前**：
```python
@bp.route('/service-monitor/report/<report_id>')
def page_report(report_id):
    """报告详情页（所有登录用户可看）。"""
    if 'user' not in session:
        return redirect(url_for('auth.login'))
    user = _current_user()
    report = report_service.get_report(report_id)
    if not report:
        return redirect(url_for('service-monitor.page_index'))
    return render_template(
        'service_monitor/report.html', user=user, report=report,
        is_admin=(user.get('role') == 'admin'),
        active_menu='reports',
    )
```

**改后**：
```python
@bp.route('/service-monitor/report/<report_id>')
def page_report(report_id):
    """报告详情页（支持 token 免登访问）。"""
    token = request.args.get('token', '').strip()

    # 1. 尝试 token 免登访问
    if token and report_service.validate_access_token(report_id, token):
        report = report_service.get_report(report_id)
        if not report:
            return redirect(url_for('auth.login'))
        return render_template(
            'service_monitor/report.html',
            user=None,  # 无登录用户
            report=report,
            is_admin=False,
            active_menu='reports',
            token_auth=True,  # 标记访客模式
            access_token=token,  # 传递 token 用于导出等只读操作
        )

    # 2. 常规登录访问
    if 'user' not in session:
        # 携带 next 参数，登录后回到报告页
        next_url = request.url  # 完整 URL（含 token）
        return redirect(url_for('auth.login', next=next_url))
    user = _current_user()
    report = report_service.get_report(report_id)
    if not report:
        return redirect(url_for('service-monitor.page_index'))
    return render_template(
        'service_monitor/report.html', user=user, report=report,
        is_admin=(user.get('role') == 'admin'),
        active_menu='reports',
        token_auth=False,
    )
```

#### 3.2 报告导出 API 支持 token

**位置**：`api_export_report()` 函数

**改动**：在现有登录检查之前，增加 token 验证路径

```python
@bp.route('/api/service-monitor/reports/<report_id>/export', methods=['GET'])
def api_export_report(report_id):
    # token 免登导出（只读操作，允许访客）
    token = request.args.get('token', '').strip()
    if token and report_service.validate_access_token(report_id, token):
        # 允许导出
        fmt = request.args.get('format', 'md')
        # ... 原有导出逻辑 ...
        pass

    # 常规登录访问
    guard = _require_login_json()
    if guard:
        return guard
    # ... 原有逻辑 ...
```

#### 3.3 其他页面路由增加 token_auth 拦截

**位置**：所有 `page_*` 路由函数

**改动**：无需后端改动。token_auth 模式的拦截完全在前端实现（见 Step 5），因为：
- token_auth 用户只能看到报告页（从通知链接进入）
- 点击其他页面时前端拦截跳转登录
- 后端所有非报告页面路由已有登录检查，token_auth 用户无 session，自然会被重定向到登录页

---

### Step 4：后端 — auth 支持 next 参数回跳

**文件**：`skill/code/web/routes/auth.py`

#### 4.1 GET /login 传递 next 参数

**位置**：`login()` 函数 GET 分支（L29-33）

**改前**：
```python
if request.method == 'GET':
    if 'user' in session:
        return redirect('/')
    return render_template('login.html')
```

**改后**：
```python
if request.method == 'GET':
    if 'user' in session:
        next_url = request.args.get('next', '/')
        # 安全校验：仅允许本站相对路径
        if not next_url.startswith('/') or next_url.startswith('//'):
            next_url = '/'
        return redirect(next_url)
    next_url = request.args.get('next', '')
    return render_template('login.html', next_url=next_url)
```

#### 4.2 POST /login 返回 next 参数

**位置**：`login()` 函数 POST 成功分支（L68-71）

**改前**：
```python
return jsonify({
    'success': True,
    'user': session['user']
})
```

**改后**：
```python
next_url = request.args.get('next') or request.form.get('next') or '/'
# 安全校验：仅允许本站相对路径
if not next_url.startswith('/') or next_url.startswith('//'):
    next_url = '/'

return jsonify({
    'success': True,
    'user': session['user'],
    'next': next_url,
})
```

---

### Step 5：后端 — decorators 支持 next 参数

**文件**：`skill/code/web/decorators.py`

#### 5.1 page_login_required 携带 next

**位置**：`page_login_required()` 函数（L42-52）

**改前**：
```python
def page_login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user' not in session:
            return redirect(url_for('auth.login'))
        return f(*args, **kwargs)
    return decorated_function
```

**改后**：
```python
def page_login_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if 'user' not in session:
            next_url = request.url
            return redirect(url_for('auth.login', next=next_url))
        return f(*args, **kwargs)
    return decorated_function
```

---

### Step 6：前端 — login.html 支持 next 回跳

**文件**：`skill/code/web/templates/login.html`

#### 6.1 登录成功后跳转 next

**位置**：登录成功回调（L251-253）

**改前**：
```javascript
if (data.success) {
    window.location.href = '/';
}
```

**改后**：
```javascript
if (data.success) {
    const next = data.next || '/';
    window.location.href = next;
}
```

#### 6.2 表单提交携带 next

**位置**：fetch 请求体（L244-246）

**改后**：
```javascript
// 从 URL 参数或模板变量获取 next
const nextParam = new URLSearchParams(window.location.search).get('next') || '{{ next_url }}';

const resp = await fetch('/login' + (nextParam ? '?next=' + encodeURIComponent(nextParam) : ''), {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password, remember }),
    credentials: 'include'
});
```

---

### Step 7：前端 — report.html 访客模式适配

**文件**：`skill/code/web/templates/service_monitor/report.html`

#### 7.1 访客模式标识 + 登录入口

**位置**：报告头部区域

**改动**：在报告头部增加访客模式提示条

```html
{% if token_auth %}
<div class="guest-banner">
    <span>👤 访客模式 — 仅可查看本报告</span>
    <a href="/login?next={{ request.url | urlencode }}" class="btn-login-link">🔑 登录查看更多功能</a>
</div>
{% endif %}
```

**CSS**：
```css
.guest-banner {
    display: flex;
    align-items: center;
    justify-content: space-between;
    background: #fef3c7;
    border: 1px solid #f59e0b;
    border-radius: 8px;
    padding: 10px 16px;
    margin-bottom: 16px;
    font-size: 14px;
    color: #92400e;
}
.btn-login-link {
    background: #2563eb;
    color: #fff;
    padding: 6px 14px;
    border-radius: 6px;
    text-decoration: none;
    font-size: 13px;
    font-weight: 600;
}
```

#### 7.2 隐藏管理操作

**位置**：修复按钮、删除按钮等

**改动**：用 `token_auth` 条件隐藏

```html
{% if not token_auth and is_admin %}
<button class="btn-fix" ...>修复</button>
{% endif %}
```

#### 7.3 导出功能保留（携带 token）

**位置**：导出下拉菜单

**改动**：导出链接在 token_auth 模式下携带 token 参数

```html
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=md{% if token_auth %}&token={{ access_token }}{% endif %}">Markdown</a>
<a href="/api/service-monitor/reports/{{ report.id }}/export?format=json{% if token_auth %}&token={{ access_token }}{% endif %}">JSON</a>
```

---

### Step 8：前端 — base.html 导航拦截

**文件**：`skill/code/web/templates/service_monitor/base.html`

#### 8.1 导航链接拦截

**位置**：侧边栏/顶部导航

**改动**：在 base.html 底部增加 token_auth 拦截脚本

```html
{% if token_auth %}
<script>
// 访客模式：点击导航菜单跳转登录页
document.querySelectorAll('nav a, .sidebar a, .nav-link').forEach(link => {
    link.addEventListener('click', function(e) {
        // 允许当前报告页链接（含 token）
        if (this.href.includes('/service-monitor/report/')) {
            return;
        }
        e.preventDefault();
        if (confirm('查看更多功能请先登录，是否前往登录页？')) {
            window.location.href = '/login?next=' + encodeURIComponent(window.location.href);
        }
    });
});
</script>
{% endif %}
```

---

### Step 9：本地测试

**命令**：
```bash
cd skill/code && python -m pytest web/service_monitor/tests/ -v
cd skill/code && python -m pytest -v
```

**重点验证**：
1. `report_service.save()` 生成的报告包含 `access_token` 和 `access_token_expires_at`
2. `report_service.validate_access_token()` 正确验证/拒绝 token
3. 过期 token 被拒绝
4. 错误 token 被拒绝
5. 已有单元测试不受影响

---

### Step 10：部署验证

**命令**：
```bash
! cd deploy && SSH_PASSWORD='***' python upload_to_server.py
```

**验证步骤**：
1. 触发一次巡检，确认钉钉通知链接包含 `?token=rpt_xxx`
2. 在无登录 session 的浏览器中点击通知链接，确认可直接查看报告
3. 在报告页点击侧边栏其他模块，确认跳转登录页
4. 登录后确认回跳到报告页
5. 修改报告 JSON 中 `access_token_expires_at` 为过去时间，确认 token 失效
6. 正常登录用户访问报告页，确认功能不受影响

---

## 回滚方案

如出现异常，回滚步骤：

1. 还原各文件：
   ```bash
   git checkout HEAD~1 -- skill/code/web/service_monitor/services/report_service.py
   git checkout HEAD~1 -- skill/code/web/service_monitor/services/runner_service.py
   git checkout HEAD~1 -- skill/code/web/service_monitor/routes.py
   git checkout HEAD~1 -- skill/code/web/routes/auth.py
   git checkout HEAD~1 -- skill/code/web/decorators.py
   git checkout HEAD~1 -- skill/code/web/templates/login.html
   git checkout HEAD~1 -- skill/code/web/templates/service_monitor/report.html
   git checkout HEAD~1 -- skill/code/web/templates/service_monitor/base.html
   ```

2. 重新部署

---

## 验收清单

| 检查项 | 状态 |
|--------|------|
| report_service.save() 生成 access_token | ⬜ |
| report_service.validate_access_token() 验证逻辑 | ⬜ |
| runner_service 通知链接拼接 token | ⬜ |
| page_report 支持 token 免登访问 | ⬜ |
| 报告导出 API 支持 token | ⬜ |
| auth.py 支持 next 参数回跳 | ⬜ |
| page_login_required 携带 next 参数 | ⬜ |
| login.html 支持 next 回跳 | ⬜ |
| report.html 访客模式适配（标识/隐藏操作/导出） | ⬜ |
| base.html 导航拦截 | ⬜ |
| next 参数防开放重定向 | ⬜ |
| 本地测试通过 | ⬜ |
| 部署验证通过 | ⬜ |

---

## 执行记录

| 步骤 | 状态 | 时间 | 备注 |
|------|------|------|------|
| Step 1: report_service token 生成与验证 | 待执行 | - | - |
| Step 2: runner_service 通知链接拼接 token | 待执行 | - | - |
| Step 3: routes token 验证 + next 参数 | 待执行 | - | - |
| Step 4: auth next 参数回跳 | 待执行 | - | - |
| Step 5: decorators next 参数 | 待执行 | - | - |
| Step 6: login.html next 回跳 | 待执行 | - | - |
| Step 7: report.html 访客模式适配 | 待执行 | - | - |
| Step 8: base.html 导航拦截 | 待执行 | - | - |
| Step 9: 本地测试 | 待执行 | - | - |
| Step 10: 部署验证 | 待执行 | - | - |
