在测试管理平台里,我们每天都在和各种链接打交道:需求文档链接、设计稿链接、缺陷详情链接……但很长一段时间里,这些链接在平台里、在企业微信通知里,都是一串又长又难看的裸网址 😵。

更难受的是:把链接复制到协作文档里,粘贴出来的还是那串长网址,而不是一个人类能一眼看懂的文字标题。

这篇就来讲讲,我是怎么分两步把「链接显示成文字」这件事做完整的:第一步解决平台内外部链接的展示,第二步解决企业微信通知里链接的展示。👏


一、问题:链接为什么总是一串网址 🤔

先还原一下改造前的两个痛点场景:

场景 现象 期望
平台内 粘贴的 PRD 链接、正文里的 URL 显示成裸网址(截断加省略号) 显示成网页标题
企业微信通知 缺陷/需求/版本详情链接在通知里显示成裸网址 显示成「【缺陷】#123 标题」这样的文字

两个场景看起来不一样,但本质是同一个诉求:链接应该显示成人类可读的文字,而不是 URL 本身。

所以我把这个需求拆成了两步来做,下面分别展开。👇


二、场景一:平台内的外部链接 → 抓网页标题 🌐

平台里用户会粘贴各种外部链接:需求 PRD 链接、设计稿链接、正文里混着的 URL。这些链接的「标题」平台自己并不知道,得去目标网页抓。

1. 标题的取值优先级

抓标题不能只靠自动抓取,得给用户留一个手动纠正的口子。最终定的优先级是:

手动填写的标题 > 自动抓取的网页标题 > 网址本身

手动 > 自动 > 网址。手动标题让用户可以纠正自动抓取的结果——抓到的标题不对、太长,或者想换个更短的别名。

2. 后端:抓取服务(关键在 SSRF 防护)

后端新建一张 link_titles 表,按「项目 + URL」唯一约束,同一 URL 在不同项目里可以起不同标题。

抓取服务是整个功能里最需要谨慎的地方——让服务器去请求一个用户可控的地址,是典型的 SSRF(服务端请求伪造) 攻击面:如果用户提交一个内网地址(http://192.168.1.1、http://localhost:6379、云元数据地址 http://169.254.169.254),服务器就会变成探测内网的跳板。🚨

所以抓取必须做完整防护:

async def _is_private_host(host: str) -> bool:
"""解析 host 的所有 IP,判断是否内网/环回/链路本地/保留(防 SSRF)"""
host_l = host.lower().rstrip(".")
if host_l in _BLOCKED_HOSTS: # localhost 等
return True
if any(host_l.endswith(s) for s in _BLOCKED_HOST_SUFFIXES): # *.local / *.internal
return True
infos = await loop.getaddrinfo(host, None, type=socket.SOCK_STREAM)
for info in infos:
addr = ipaddress.ip_address(info[4][0])
if (addr.is_private or addr.is_loopback or addr.is_link_local
or addr.is_reserved or addr.is_unspecified or addr.is_multicast):
return True
return False

要点:

  • ✅ 只允许 http/https,拒绝 file、gopher 等协议
  • ✅ DNS 解析后校验 IP,拒绝内网 / 环回 / 链路本地 / 保留地址
  • ✅ 超时 5s,只读响应体前 512KB,防止被超大响应拖垮
  • ✅ 成功缓存 7 天、失败缓存 1 小时,避免对同一链接反复发请求

3. 前端:异步解析,先显示网址后替换

前端用一个 composable 管理标题状态,核心是不阻塞页面渲染:

  • 输入 URL 列表,批量发给后端解析
  • loading 态先显示网址,标题到了再替换成标题
  • 会话内内存缓存,同一 URL 不重复请求
  • hover 出「✎ 改标题」入口,可手动改名或恢复自动

三、场景二:企业微信通知里的链接 → markdown 化 💬

场景一解决了「平台内」的链接,但还有一个更隐蔽的地方没解决:企业微信通知里的链接。

1. 根因:text 消息 vs markdown 消息

排查时发现一个有意思的根因:企业微信的消息分两种类型——

消息类型 链接表现
text(纯文本) 链接只能显示裸网址,不支持 [文字](链接)
markdown 支持 [文字](链接) 语法,链接能显示成文字标题

而当时平台里除了「压力测试报告」之外,缺陷、需求、版本、催办这些通知全都在用 text 消息发,所以它们的链接永远是一串裸网址。😅

2. 为什么压力测试报告可以?

答案很简单:压力测试报告的通知从一开始就用的是 markdown 消息(send_wecom_markdown)。这就是用户反馈「除了压力测试报告,别处的链接都没法显示成文字」的根本原因——不是别处不想,而是别处压根没走 markdown。

3. 改造:把通知统一升级成 markdown

方案很清晰:把通知发送函数从 send_wecom_text 换成 send_wecom_markdown,模板里的链接改成 markdown 语法。

改造前(text):

"【缺陷 {num}】{title}\n"
# 后面再拼一个裸网址

改造后(markdown):

"[【缺陷 {num}】{title}]({link})\n"

这样企业微信里收到的就是一条带文字标题的链接,点击「【缺陷】#123 登录页崩溃」直接跳转,而不是一长串 https://xxx.com/bugs/...。✨

4. 连带问题:markdown 的 @ 需要 userid

改 markdown 后,@ 人的方式也变了:

text 消息 markdown 消息
@ 方式 手机号(mentioned_mobiles) <@userid> 语法
前提 有手机号即可 需要成员先填 userid

而 markdown 消息无法通过手机号 @,只能退回「@姓名」的纯文本提示。所以为了把 @ 也做好,我又补了一个小功能:让项目管理员也能帮成员填写 userid(之前只能系统管理员填)。


四、总结与可复用经验 📝

回头看,这两个场景虽然一个在「平台内」、一个在「企业微信」,但本质是同一件事:链接要显示成可读文字。真正值得沉淀的经验有三条:

  1. 先找参照物再动手:压力测试报告「能显示成文字」这个对比,直接暴露了根因是消息类型差异,省了大量排查时间。
  2. 服务端抓取用户可控的 URL,SSRF 防护是必修课:协议白名单 + DNS 解析后校验 IP + 超时体积限制,一个都不能少。
  3. 改消息类型会牵出连带依赖:text 的 @ 靠手机号,markdown 的 @ 靠 userid,改之前先确认成员数据是否齐备。

⚠️ 一点局限:平台内抓标题依赖「服务端能访问目标 URL」,纯内网部署时外网链接抓不到,会退回显示网址本身——这是合理的优雅降级,而不是错误。


可复用检查清单(再做「链接显示成文字」可以对照):

  • 平台内外部链接:标题优先级定为「手动 > 自动抓取 > 网址」
  • 抓取服务:只允许 http/https + DNS 解析后拒绝内网 + 超时体积限制 + 分级缓存
  • 前端:异步解析,先显示网址后替换,不阻塞渲染
  • 企业微信通知:统一走 markdown 消息,链接用 [标题](url) 语法
  • 改消息类型前,确认 @ 依赖的数据(userid / 手机号)是否齐备