公告栏代码示例:添加新公告与删除公告的方法

运营教程4小时前发布 jdkjadmin
0 00
公告栏代码示例:添加新公告与删除公告的方法

公告栏代码示例:添加新公告与删除公告的方法

公告栏看起来只是“发布一条内容”和“移除一条内容”,实际开发时还要考虑谁能操作、输入内容如何校验、页面如何安全展示,以及服务重启后数据是否还在。本文用 Node.js 和 Express 实现一个最小可运行的公告 API,包含读取、添加和删除公告,并通过命令行演示调用方式。

示例有意采用内存存储,便于快速运行和理解接口流程。它适合本地练习、原型验证或临时演示,不适合直接作为生产环境的数据存储方案。代码中的写权限使用 Bearer Token 保护;生产部署仍应结合实际用户系统、HTTPS、持久化数据库和审计需求进行调整。

一、适用场景与实现范围

公告栏常见于内部管理后台、学校或社团网站、产品维护通知页和运营后台。本文示例提供三个接口:

  • GET /api/announcements:读取公告列表,无需写权限令牌。
  • POST /api/announcements:新增公告,需要提供写权限令牌。
  • DELETE /api/announcements/:id:删除指定公告,需要提供写权限令牌。

公告由标题、正文、唯一 ID 和创建时间组成。服务端只接受纯文本标题和正文,不把用户提交的内容当作 HTML 执行。删除操作采用物理删除:删除后,内存列表中不再保留该条记录。若业务需要恢复、留痕或按时间查询,应改为数据库软删除或审计记录,而不是照搬这个最小示例。

二、准备运行环境

请先安装当前受支持版本的 Node.js。新建项目目录后,在目录中执行以下命令安装 Express:

npm init -y
npm install express

将下一段代码保存为 app.js。启动前必须设置环境变量 ANNOUNCEMENT_ADMIN_TOKEN,它用于保护新增和删除接口。示例要求令牌至少 32 个字符,以降低误用弱口令的风险;这不是完整的身份认证系统,也不能代替账号权限管理。

三、完整的可运行服务端代码

const express = require('express');
const crypto = require('node:crypto');

const app = express();
const port = Number(process.env.PORT || 3000);
const adminToken = process.env.ANNOUNCEMENT_ADMIN_TOKEN;

if (!adminToken || adminToken.length < 32) {
  throw new Error(
    '请设置至少 32 个字符的 ANNOUNCEMENT_ADMIN_TOKEN'
  );
}

// 演示数据只保存在当前 Node.js 进程内。
const announcements = [];

app.disable('x-powered-by');

// 限制 JSON 请求体大小,避免接口接受过大的请求体。
// 16kb 是本示例的请求体上限,不是公告正文的字符上限。
app.use(express.json({ limit: '16kb' }));

function requireAdmin(req, res, next) {
  const authorization = req.get('authorization') || '';
  const match = /^Bearer ([^ ]+)$/i.exec(authorization);

  if (!match) {
    return res.status(401).json({ error: '需要有效的 Bearer Token' });
  }

  const provided = Buffer.from(match[1]);
  const expected = Buffer.from(adminToken);

  // timingSafeEqual 要求两侧字节长度相同,因此先比较长度。
  if (
    provided.length !== expected.length ||
    !crypto.timingSafeEqual(provided, expected)
  ) {
    return res.status(401).json({ error: '需要有效的 Bearer Token' });
  }

  next();
}

function readText(value, field, maxLength) {
  if (typeof value !== 'string') {
    return { error: `${field} 必须是字符串` };
  }

  const text = value.trim();

  if (text.length === 0) {
    return { error: `${field} 不能为空` };
  }

  // 按 Unicode 码点计数,避免把一个代理对字符简单算成两个。
  if (Array.from(text).length > maxLength) {
    return { error: `${field} 最多 ${maxLength} 个字符` };
  }

  return { value: text };
}

app.get('/api/announcements', (req, res) => {
  // 返回副本并按创建时间倒序排列,避免调用方直接修改内部数组。
  const result = announcements
    .map((item) => ({ ...item }))
    .sort((a, b) => b.createdAt.localeCompare(a.createdAt));

  res.json({ announcements: result });
});

app.post('/api/announcements', requireAdmin, (req, res) => {
  const title = readText(req.body?.title, 'title', 80);
  const body = readText(req.body?.body, 'body', 5000);

  if (title.error) {
    return res.status(400).json({ error: title.error });
  }

  if (body.error) {
    return res.status(400).json({ error: body.error });
  }

  const announcement = {
    id: crypto.randomUUID(),
    title: title.value,
    body: body.value,
    createdAt: new Date().toISOString()
  };

  announcements.push(announcement);
  res.status(201).json({ announcement });
});

app.delete('/api/announcements/:id', requireAdmin, (req, res) => {
  const index = announcements.findIndex(
    (item) => item.id === req.params.id
  );

  if (index === -1) {
    return res.status(404).json({ error: '公告不存在' });
  }

  const [deleted] = announcements.splice(index, 1);
  res.json({ deleted });
});

// 将 JSON 解析错误转换为清晰的 400 响应。
app.use((err, req, res, next) => {
  if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
    return res.status(400).json({ error: '请求体不是有效的 JSON' });
  }

  if (err.type === 'entity.too.large') {
    return res.status(413).json({ error: '请求体超过 16kb 上限' });
  }

  console.error(err);
  res.status(500).json({ error: '服务器内部错误' });
});

app.listen(port, () => {
  console.log(`公告 API 正在监听 http://localhost:${port}`);
});

设置环境变量并启动服务。不同操作系统设置环境变量的命令有所不同,以下分别给出常见写法。请把示例令牌替换成随机生成的长字符串,不要把真实令牌提交到代码仓库。

# macOS / Linux
ANNOUNCEMENT_ADMIN_TOKEN='replace-with-a-long-random-secret-value' node app.js

# PowerShell
$env:ANNOUNCEMENT_ADMIN_TOKEN = 'replace-with-a-long-random-secret-value'
node app.js

启动成功后,服务默认监听本机的 3000 端口。若设置了 PORT 环境变量,则使用该端口。此示例未提供网页静态文件,所有演示通过 HTTP API 完成。

四、如何读取公告

读取接口返回 JSON 对象,公告列表位于 announcements 字段中。空列表也是有效结果:

curl http://localhost:3000/api/announcements

响应示例:

{
  "announcements": []
}

新增公告后再次请求该地址,就能看到刚创建的内容。列表按创建时间倒序排列;这个排序只在服务端返回数据时执行,不代表公告具有置顶、定时发布或过期机制。若需要这些功能,应为数据模型增加明确字段,并在查询逻辑中实现相应规则。

五、添加新公告

新增接口使用 POST 方法,要求 JSON 请求体包含字符串类型的 title 和 body。标题去除首尾空白后不能为空,最长 80 个字符;正文最长 5000 个字符。这里的长度按 JavaScript 的 Unicode 码点计数,输入先经过 trim(),所以首尾空白不计入实际保存值。

另外,Express 的 JSON 解析中间件限制整个请求体最大为 16kb。它限制的是传输的 JSON 请求体大小,和正文的 5000 字符校验是两项不同的约束。请求体超过上限时返回 413;字段不符合要求时返回 400。

将 TOKEN 替换为启动服务时设置的同一令牌:

curl -X POST http://localhost:3000/api/announcements \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer TOKEN' \
  -d '{
    "title": "系统维护通知",
    "body": "服务将于周五 20:00 至 21:00 进行维护。"
  }'

成功时返回 HTTP 201 和新公告对象,服务端负责生成 ID 和创建时间,客户端不能通过请求体指定这两个字段:

{
  "announcement": {
    "id": "服务端生成的 UUID",
    "title": "系统维护通知",
    "body": "服务将于周五 20:00 至 21:00 进行维护。",
    "createdAt": "服务端生成的 ISO 8601 时间"
  }
}

错误情况也应由调用方处理。例如缺少令牌或令牌不正确时返回 401;标题为空或字段类型不对时返回 400。生产接口还可以进一步规定字段是否允许换行、是否限制链接、是否支持 Markdown,以及是否需要对公告进行发布状态管理。这些属于业务规则,不能只依靠长度校验自动推断。

六、删除指定公告

删除时把公告 ID 放到 URL 路径中,并使用 DELETE 方法。令牌校验与新增接口相同:

curl -X DELETE \
  http://localhost:3000/api/announcements/替换为实际公告ID \
  -H 'Authorization: Bearer TOKEN'

删除成功时返回被删除的公告;若 ID 不存在,返回 HTTP 404。重复删除同一个 ID,第一次成功,后续请求会得到 404。这种“目标不存在即返回 404”的约定适合帮助调用方发现 ID 错误;如果业务更需要幂等的界面体验,也可以约定不存在时仍返回成功,但应在接口文档中保持一致。

物理删除通常不可恢复。管理后台可以在执行前显示确认提示,避免误操作;如果需要撤销、调查误删或保留法规要求的记录,应将公告标记为已删除,并记录操作人、时间和原因。只在前端弹出确认框并不构成服务端权限控制,服务端仍必须验证调用者是否有删除权限。

七、前端如何安全地展示公告

服务端将正文作为普通字符串保存并返回。前端展示时,不应把公告正文直接拼接进 innerHTML。当内容来自用户、后台输入或其他外部来源时,将其作为 HTML 插入页面可能导致跨站脚本攻击(XSS)。普通文本公告应使用 textContent 创建文本节点:

async function loadAnnouncements() {
  const response = await fetch('/api/announcements');

  if (!response.ok) {
    throw new Error(`读取失败:HTTP ${response.status}`);
  }

  const data = await response.json();
  const list = document.querySelector('#announcements');
  list.replaceChildren();

  for (const item of data.announcements) {
    const article = document.createElement('article');
    const heading = document.createElement('h2');
    const body = document.createElement('p');

    heading.textContent = item.title;
    body.textContent = item.body;

    article.append(heading, body);
    list.append(article);
  }
}

如果业务明确要求富文本,不要简单地把字符串交给 innerHTML。应选择经过维护的 HTML 清理方案,定义允许的标签、属性和链接协议,并在服务端或可信的展示边界执行清理。编码、清理和权限控制解决的是不同问题:使用 textContent 不能代替访问控制,Bearer Token 也不能代替输出安全处理。

八、权限与令牌的边界

示例用一个共享的环境变量令牌保护写接口,便于本地演示。令牌通过 Authorization 请求头传输,服务端不会把它放进公告数据。代码使用 crypto.timingSafeEqual 比较等长缓冲区,并先检查长度,这是该函数的调用要求;它不代表整个系统已具备完整的抗攻击能力。

不要把管理令牌写进公开网页的 JavaScript、浏览器应用源码或可被用户读取的配置中。浏览器里的代码和网络请求都能被使用者查看,放在前端的密钥不能作为秘密。实际管理后台应由服务端验证登录状态和角色权限,确认当前用户有发布或删除权限。若使用基于 Cookie 的登录会话,需结合部署方式评估 CSRF 防护;若使用 Bearer Token,也需妥善保管令牌并支持撤销、轮换和权限范围控制。

生产环境应通过 HTTPS 保护传输中的凭据。不要在日志、错误信息或分析事件中记录完整令牌。还要考虑请求频率限制、账号锁定或其他滥用防护,以及谁在何时新增、修改或删除了什么公告。示例没有实现这些系统能力,不能把一枚静态令牌当作通用的用户认证方案。

九、数据存储与多进程问题

本示例把公告保存在 JavaScript 数组中,因此有几个明确限制:

  • 服务进程重启后,所有公告都会丢失。
  • 多个 Node.js 实例各自维护独立数组,写入一台实例的数据不一定能在另一台读到。
  • 数据没有事务、备份、并发控制、索引或审计能力。
  • 代码没有实现分页,公告数量增长后不适合一次性返回全部记录。

需要稳定保存时,应改用数据库或其他持久化存储,并为公告设置合理的表结构、唯一 ID、创建和更新时间、发布状态及删除状态等字段。数据库操作应使用参数化查询或 ORM 的安全接口,不要把用户输入直接拼接成 SQL。若列表可能很大,应设计分页和排序规则;若需要撤销删除,应使用软删除或单独的审计记录。

十、常见错误排查

  • 启动时报令牌配置错误:检查当前终端是否设置了 ANNOUNCEMENT_ADMIN_TOKEN,以及值是否至少 32 个字符。设置变量的终端与启动进程应一致。
  • 新增或删除返回 401:检查请求是否带有 Authorization: Bearer TOKEN,Bearer 后是否有空格,令牌是否与服务启动时的值完全一致。不要把令牌误放在 JSON 请求体中。
  • 新增返回 400:确认 Content-Type 是 application/json,请求体是有效 JSON,字段名为 title 和 body,且字段值是非空字符串。
  • 大请求返回 413:检查整个请求体是否超过 Express 中间件设定的 16kb。增大上限前应评估内存与滥用风险,不要只为绕过校验而无限放宽。
  • 删除返回 404:确认路径中的 ID 是新增响应返回的完整 ID,并且服务没有重启。内存存储在重启后清空,旧 ID 自然无法找到。
  • 页面显示尖括号或换行方式不符合预期:公告正文按纯文本保存。尖括号作为字符显示是安全的默认行为;如果要保留换行,可在展示层用 CSS 的 white-space: pre-wrap,而不是把正文转换成 HTML。

十一、从示例走向实际应用

将这段代码用于真实项目之前,先明确公告的产品规则:谁可以发布、是否需要审核、何时生效、是否过期、是否支持置顶、删除后是否可恢复、不同用户是否能看到不同公告。然后把这些规则落实到服务端权限检查和数据模型中,而不是只添加前端按钮。

实现层面至少应把内存数组替换为持久化存储,为列表增加分页,为写操作记录审计信息,并为生产域名启用 HTTPS。若公告内容只能是纯文本,前端使用 textContent 是简单可靠的展示方式;若支持富文本,则需要明确的清理策略。本文的示例重点是演示新增和删除接口的基本边界,不包含账号系统、数据库迁移、限流、富文本处理或生产级部署配置,使用时应按项目的威胁模型和业务要求补齐。

© 版权声明

相关文章