主题
界面
主题
《项目 A:后端分层与接口设计》做好了接口;本节把它们用起来 —— 一个能真正操作的页面。
★ 本项目刻意不引入任何前端框架与构建工具 ✓,原因有三:
① 要演示的是"前后端如何配合",不是前端工程化 ✗
② 一个静态 HTML 就能覆盖:列表渲染、表单提交、错误处理、分页 ✓✓
③ 少一层构建,就少一个"本地能跑、部署挂掉"的可能 ✓★ 页面位置:labs/project-a/src/main/resources/static/index.html启动后访问:本机 18080 端口
textContent 与 innerHTML 的区别(XSS) ★★★;先看一个真实的前端安全问题 ✗✗:
// ✗ 危险:把接口返回的数据当成 HTML 拼进去
row.innerHTML = '<td>' + book.title + '</td>';
// 如果 book.title 是 "<img src=x onerror=alert(1)>"
// → 脚本被执行 = XSS ✗✗(呼应 S3-006 的"输出也要转义")
// ✓ 安全:当作纯文本
const td = document.createElement('td');
td.textContent = book.title; // ★ 浏览器不会把它当 HTML 解析★ 三个判断 ✓:
| 情况 | 建议 |
|---|---|
| 页面少(1~5 个)、交互简单 ★★★ | 原生 HTML + fetch 就够 ✓✓ |
| 需要复杂的组件复用与状态管理 | 上框架(Vue / React)✓ |
| 需要 SEO / 首屏优化 | 上 SSR 方案 ✓ |
★ 本项目属于第一种 ✓ —— 于是:
零构建、零依赖、一个文件、改完刷新即可 ✓✓
部署时随 jar 一起发(static 目录)✓★ 本项目全部用 DOM API 构建 ✓✓:
data.items.forEach(book => {
const tr = document.createElement('tr');
[book.id, book.title, book.author, book.category || '-'].forEach(value => {
const td = document.createElement('td');
td.textContent = value; // ★★ 关键:纯文本,不解析 HTML
tr.appendChild(td);
});
tbody.appendChild(tr);
});★★ 为什么这比"拼字符串"更重要 ✓✓:
接口返回的数据来自用户输入(书名、作者名都是管理员填的)✗
只要有一处用
innerHTML,就等于给了用户注入脚本的机会 ✗✗而且这种漏洞很难被测试发现(要专门构造输入)✓
★ 本项目只有一个 api() 函数 ✓✓:
async function api(path, options) {
const response = await fetch(path, Object.assign(
{ headers: { 'Content-Type': 'application/json' } }, options));
const text = await response.text();
const body = text ? JSON.parse(text) : null;
if (!response.ok) {
// ★ 后端统一返回 {code, message, timestamp} —— 这里把 message 抛出去给用户看
throw new Error((body && body.message) ? body.message : ('请求失败:HTTP ' + response.status));
}
return body;
}★★ 这样写的好处 ✓✓:
| 好处 | 说明 |
|---|---|
| 错误处理只写一次 ★★★ | 页面各处只关心成功分支 ✓✓ |
| 错误一定能被看到 ★★★ | 抛出来的 Error 会被 withBusy 捕获并展示 ✓ |
| 改后端协议只改一处 ★★ | 不用在每个调用点改 ✓ |
async function withBusy(button, action) {
button.disabled = true; // ★ 禁用按钮
try {
await action();
} catch (e) {
showMessage(e.message, true); // ★ 错误展示给用户,不只是 console
} finally {
button.disabled = false; // ★ 无论如何都要恢复
}
}★★ 两条必须做到 ✓✓:
| # | 要点 | 不做会怎样 |
|---|---|---|
| ① | 禁用按钮 | 用户连点 5 次 = 5 个请求 ✗(虽然后端有幂等,但体验差) |
| ② | finally 里恢复 | 出错后按钮永久禁用 ✗✗ |
★ 注意分工 ✓✓:
幂等靠后端(
status = 'BORROWED'条件 —— 《消息可靠性、幂等与顺序》)✓✓体验靠前端(禁用按钮 + 提示)✓
两者都要有 —— 前端拦不住 curl / 脚本的重复请求 ✗
★ 本项目的做法 ✓:
// 后端已经给了可读的 message(如"《设计模式》暂无可借副本(共 1 本)")
showMessage(e.message, true);★ 更进一步(如果要按码分支)✓:
const MESSAGES = {
NO_AVAILABLE_COPY: '这本书暂无库存,换一本试试?',
READER_SUSPENDED: '您的读者证已停用,请到前台激活',
BORROW_LIMIT_REACHED: '已达借阅上限,请先归还',
};
showMessage(MESSAGES[e.code] || e.message, true);★★ 这就是为什么错误码要稳定 ✓✓(《接口管理平台》)—— 前端按码分支,文案可以随时改 ✓
| 块 | 用到的接口 |
|---|---|
| 图书列表(搜索 + 分页) | GET /api/books |
| 图书入库 / 加副本(管理员) | POST /api/books、POST /api/books/{id}/copies |
| 借书(填 ID + 卡号) | POST /api/borrows |
| 借阅记录(按卡号查) | GET /api/readers/{cardNo}/borrows |
| 超期清单 | GET /api/borrows/overdue |
★ 列表行内还有"归还 / 续借"按钮 ✓ —— 直接调对应接口后刷新 ✓
★ 入库闭环(《项目 A:需求分析与范围定义》 验收项):入库 → 加副本 → 借出,页面上三个表单就能走完整个 管理员的日常操作 —— 无需 curl ✓(写操作同样走 withBusy 防连点 + 统一错误展示)
const td = document.createElement('td');
td.textContent = book.title;document.getElementById('btnBorrow').addEventListener('click',
() => withBusy(document.getElementById('btnBorrow'), async () => {
const bookId = Number(document.getElementById('borrowBookId').value);
const cardNo = document.getElementById('borrowCardNo').value.trim();
if (!bookId || !cardNo) { throw new Error('请填写图书 ID 与读者卡号'); }
await api('/api/borrows', { method: 'POST',
body: JSON.stringify({ bookId: bookId, cardNo: cardNo }) });
showMessage('借书成功', false);
await refreshAll(); // ★ 操作后立刻刷新,让用户看到结果
}));// ✗ XSS 风险 + 拼错一个引号就整页崩 ✗✗
tbody.innerHTML = items.map(b => '<tr><td>' + b.title + '</td></tr>').join('');代码:labs/project-a/src/main/resources/static/index.html
运行:
cd labs
.\mvnw.cmd -pl project-a spring-boot:run
# http://localhost:18080 → 页面★ 手测清单(打开页面依次做一遍)✓:
| 操作 | 期望 |
|---|---|
| 打开页面 | 看到 5 本图书,其中《Java并发编程实战》可借 1/2 ✓ |
| 搜索 "Java" | 只剩相关的两本 ✓ |
| 借书(书 ID=4,卡号 R001) | 提示"借书成功",库存 -1 ✓ |
| 借书(卡号 R003) | 提示"读者已被停用" ✓✓ |
| 借书(同一本再借一次) | 提示"还没有归还" ✓ |
| 归还 | 库存 +1 ✓ |
| 续借两次 | 第二次提示"最多续借 1 次" ✓✓ |
<img src=x onerror=alert(1)>(用 H2 控制台改数据):刷新页面看是否被转义 ✓✓;| # | 错误 | 说明 |
|---|---|---|
| 1 | 用 innerHTML 渲染接口数据 ✗✗ | XSS ✓✓ |
| 2 | 错误只在 console 里,不给用户看 ✗✗ | 用户以为没反应 ✓ |
| 3 | 写操作不禁用按钮 ✗ | 重复提交 ✓ |
| 4 | 忘记 finally 恢复按钮 ✗✗ | 出错后按钮永久禁用 ✓✓ |
| 5 | 每个调用点都写一遍错误处理 ✗ | 重复代码、容易漏 ✓ |
| 6 | 页面用了构建工具但部署时忘了打包 ✗✗ | 线上 404 ✓ |
| 7 | 把错误码硬编码成中文判断 ✗ | 后端改文案就失效 ✓ |
目标:把页面真正用起来,并验证安全与错误处理。
步骤:
<script> 的字符串,刷新看是否被转义 ★★★;验收方式:能说出"你的页面在后端挂掉时会显示什么"。
扩展任务:给页面加乐观更新(点击后立刻改 UI,不等后端返回):
td.innerHTML = book.title;有什么风险? 5. (设计)给借阅记录加"超期提醒":超期的行要标红并显示超期天数。
textContent;innerHTML ✓curl 打接口照样能重复提交 ✓✓book.title 含 <script>,会被浏览器执行 ✓✓★overdueDays()(领域对象已实现 ✓); 前端按它标红(td.className = 'warn')✓; 注意:超期天数必须由后端算(前后端各算一遍迟早不一致)✓✓textContent 安全渲染(防 XSS) ★★★;finally 恢复 ★★★;《项目 A:测试方案与分层测试》:怎么证明这些功能真的对。
暂无