欢迎光临
我们一直在努力

自由拖拽拼图验证码:贝塞尔拼图渲染 + XY 双向校验 + 一次性消费(三)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

字符验证码被 OCR 秒破,第三方验证码要联网要付费,而多数开源滑块实现只校验横坐标——纵向随手一放也能通过。这篇文章讲我怎么用三次贝塞尔画出带颈部的互锁拼图,用「半平面裁剪」做出凹凸相反的立体感,以及为什么「先作废再校验」这个顺序比校验算法本身更重要。


一、痛点:验证码的三重尴尬

1.1 现象

第一层:字符验证码已经失效

我们的登录页原本是 4 位数字 + 两条干扰线。上线三个月后开始出现异常:同一个 IP 在 2 分钟内提交了 3000 次登录请求,验证码全部通过。

抓下来一分析,攻击者用的是开源 OCR(ddddocr 这类),对我们的图识别率超过 92%。后来加扭曲、加噪点、加背景纹理,识别率降到 70%,但真人用户也开始抱怨「看不清」,登录转化率掉了 8 个百分点。

第二层:第三方验证码的约束

换成云端验证码,新的问题来了:

  • 内网部署的客户环境连不上外网,验证码组件直接不可用;
  • 有个客户是金融行业,合规要求「用户行为数据不得出境」,而云端验证码要把轨迹数据回传;
  • 每次验证都要走一次外部 API,登录接口的 P99 从 80ms 涨到 400ms。

第三层:自研滑块只校验横坐标

翻了几个开源实现,典型问题有两个:

// 大多数开源实现的校验逻辑
if (Math.abs(request.getLeft() – correctX) > tolerance) {
return false;
}
return true; // ← Y 完全不看

滑块只能水平拖,看似「Y 是固定的不用校验」,但实际上前端提交的 top 参数完全可控。攻击者构造请求时随便填一个值就能过。结果是把二维定位问题降成了一维猜数:X 方向约 240 种可能,配合 3 像素容差和无限重试,几乎必然命中。

更糟的是形状:直接 fillRect 挖一个正方形,四条直角边特征极强,边缘检测 + 模板匹配一步就能定位。

1.2 为什么会这样

三个问题的根因其实是同一个:把验证码当成「生成一张图」的任务,而不是「设计一个让机器难以稳定通过的交互」的任务。

  • 字符验证码:图的抗识别能力有上限,因为人眼能认的字形集合是固定的。
  • 第三方:能力外包了,但可用性和合规性也一起外包了。
  • 开源滑块:校验维度少一个,抗爆破能力就掉一个数量级;形状太规整,可被模板匹配。

1.3 我最初的三个错误做法

错误一:用圆角矩形替代方块

g.fill(new RoundRectangle2D.Double(x, y, 50, 50, 12, 12));

视觉上确实好看了,但形状仍然单一——所有验证码的滑块都是同一个圆角矩形,只是位置不同。攻击者拿到一次样本就能建模板。

错误二:只校验 X 坐标(最致命的设计缺陷)

if (Math.abs(request.getLeft() – correctX) > tolerance) return false;

我算了一下:X 方向约 235 个取值,3 像素容差让有效命中区扩大到约 7 个位置,平均 35 次尝试就能通过。而如果 Y 也参与校验,搜索空间变成两个方向的乘积——同样的尝试次数下命中概率降一个数量级以上。

最让我意外的是:滑轨交互本身就在暗示「只有 X 有意义」。用户在滑轨上只能左右移动,自然不会想到 Y 也是校验维度——而 Y 一旦只在前端「约定」而不在服务端校验,它就是形同虚设的。

错误三:校验失败后不作废验证码

// 直觉写法:全部校验通过,才作废
if (!checkPosition(...)) return false; // ← 失败时缓存还在!
if (!checkDuration(...)) return false;
if (!checkTrajectory(...)) return false;
cache.evict(id);
return true;

这个顺序的后果非常严重:同一个 id 可以无限重试。容差是 3 像素,攻击者对一个验证码提交几百次不同位置,几乎必然命中。

这三个错误的共同点:它们都不是「算法不够聪明」,而是「本该堵住的洞没堵」。验证码的强度是乘法关系,任何一项归零,整体就归零。


二、问题拆解:六个子问题

子问题约束现成方案为什么不够判定标准
① 交互方式 要让 Y 成为有效校验维度 水平滑轨天然只在一个方向移动 两个方向都是必过项
② 拼图形状 要像真拼图,且形状可参数化 方块/圆角矩形特征太强,一次取样即可建模板 有凸起与凹槽,且随尺寸自适应
③ 立体感 要能一眼看出「凹」和「凸」 单色描边会让缺口和滑块看起来一样 凹凸明暗方向相反
④ 边界可见性 浅色和深色背景下都要能看清轮廓 只有明暗的话,浅色背景上缺口会「消失」 有跨底色的通用边界
⑤ 答案保密 不能把正确坐标发给前端 下发坐标等于告诉攻击者答案 返回值只含图与尺寸
⑥ 一次性 防止无限重试 校验失败后保留缓存 = 可爆破 进入校验前就作废

这六个问题合起来意味着什么:验证码的强度 = 交互维度 × 图形抗识别能力 × 一次性约束。三者是乘法关系——任何一项归零,整体就归零。

我最初的实现里第 ① 项(Y 维度)和第 ⑥ 项(一次性)都是 0,所以前面做得再好也白搭。


这六个子问题不是并列的,而是层层递进的关系——前一项不成立,后一项做得再好也没有意义:

#mermaid-svg-HqlPTcVdeIE8zqbx{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HqlPTcVdeIE8zqbx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HqlPTcVdeIE8zqbx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HqlPTcVdeIE8zqbx .error-icon{fill:#552222;}#mermaid-svg-HqlPTcVdeIE8zqbx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HqlPTcVdeIE8zqbx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HqlPTcVdeIE8zqbx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HqlPTcVdeIE8zqbx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HqlPTcVdeIE8zqbx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HqlPTcVdeIE8zqbx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HqlPTcVdeIE8zqbx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HqlPTcVdeIE8zqbx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HqlPTcVdeIE8zqbx .marker.cross{stroke:#333333;}#mermaid-svg-HqlPTcVdeIE8zqbx svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HqlPTcVdeIE8zqbx p{margin:0;}#mermaid-svg-HqlPTcVdeIE8zqbx .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-HqlPTcVdeIE8zqbx .cluster-label text{fill:#333;}#mermaid-svg-HqlPTcVdeIE8zqbx .cluster-label span{color:#333;}#mermaid-svg-HqlPTcVdeIE8zqbx .cluster-label span p{background-color:transparent;}#mermaid-svg-HqlPTcVdeIE8zqbx .label text,#mermaid-svg-HqlPTcVdeIE8zqbx span{fill:#333;color:#333;}#mermaid-svg-HqlPTcVdeIE8zqbx .node rect,#mermaid-svg-HqlPTcVdeIE8zqbx .node circle,#mermaid-svg-HqlPTcVdeIE8zqbx .node ellipse,#mermaid-svg-HqlPTcVdeIE8zqbx .node polygon,#mermaid-svg-HqlPTcVdeIE8zqbx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HqlPTcVdeIE8zqbx .rough-node .label text,#mermaid-svg-HqlPTcVdeIE8zqbx .node .label text,#mermaid-svg-HqlPTcVdeIE8zqbx .image-shape .label,#mermaid-svg-HqlPTcVdeIE8zqbx .icon-shape .label{text-anchor:middle;}#mermaid-svg-HqlPTcVdeIE8zqbx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HqlPTcVdeIE8zqbx .rough-node .label,#mermaid-svg-HqlPTcVdeIE8zqbx .node .label,#mermaid-svg-HqlPTcVdeIE8zqbx .image-shape .label,#mermaid-svg-HqlPTcVdeIE8zqbx .icon-shape .label{text-align:center;}#mermaid-svg-HqlPTcVdeIE8zqbx .node.clickable{cursor:pointer;}#mermaid-svg-HqlPTcVdeIE8zqbx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HqlPTcVdeIE8zqbx .arrowheadPath{fill:#333333;}#mermaid-svg-HqlPTcVdeIE8zqbx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HqlPTcVdeIE8zqbx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HqlPTcVdeIE8zqbx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HqlPTcVdeIE8zqbx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HqlPTcVdeIE8zqbx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HqlPTcVdeIE8zqbx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HqlPTcVdeIE8zqbx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HqlPTcVdeIE8zqbx .cluster text{fill:#333;}#mermaid-svg-HqlPTcVdeIE8zqbx .cluster span{color:#333;}#mermaid-svg-HqlPTcVdeIE8zqbx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HqlPTcVdeIE8zqbx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HqlPTcVdeIE8zqbx rect.text{fill:none;stroke-width:0;}#mermaid-svg-HqlPTcVdeIE8zqbx .icon-shape,#mermaid-svg-HqlPTcVdeIE8zqbx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HqlPTcVdeIE8zqbx .icon-shape p,#mermaid-svg-HqlPTcVdeIE8zqbx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HqlPTcVdeIE8zqbx .icon-shape .label rect,#mermaid-svg-HqlPTcVdeIE8zqbx .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HqlPTcVdeIE8zqbx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HqlPTcVdeIE8zqbx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HqlPTcVdeIE8zqbx :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

归零

归零

① 交互方式让 Y 可操作

② 拼图形状抗模板匹配

③ 立体感凹凸可辨

④ 边界可见性跨底色通用

⑤ 答案保密坐标不下发

⑥ 一次性堵死重试

验证码强度= 维度 × 抗识别 × 一次性

三、实现:从自由拖拽到双向校验

3.1 整体结构

#mermaid-svg-sXmeTiFhxBQqZk6k{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sXmeTiFhxBQqZk6k .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sXmeTiFhxBQqZk6k .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sXmeTiFhxBQqZk6k .error-icon{fill:#552222;}#mermaid-svg-sXmeTiFhxBQqZk6k .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sXmeTiFhxBQqZk6k .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sXmeTiFhxBQqZk6k .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sXmeTiFhxBQqZk6k .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sXmeTiFhxBQqZk6k .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sXmeTiFhxBQqZk6k .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sXmeTiFhxBQqZk6k .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sXmeTiFhxBQqZk6k .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sXmeTiFhxBQqZk6k .marker.cross{stroke:#333333;}#mermaid-svg-sXmeTiFhxBQqZk6k svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sXmeTiFhxBQqZk6k p{margin:0;}#mermaid-svg-sXmeTiFhxBQqZk6k .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-sXmeTiFhxBQqZk6k .cluster-label text{fill:#333;}#mermaid-svg-sXmeTiFhxBQqZk6k .cluster-label span{color:#333;}#mermaid-svg-sXmeTiFhxBQqZk6k .cluster-label span p{background-color:transparent;}#mermaid-svg-sXmeTiFhxBQqZk6k .label text,#mermaid-svg-sXmeTiFhxBQqZk6k span{fill:#333;color:#333;}#mermaid-svg-sXmeTiFhxBQqZk6k .node rect,#mermaid-svg-sXmeTiFhxBQqZk6k .node circle,#mermaid-svg-sXmeTiFhxBQqZk6k .node ellipse,#mermaid-svg-sXmeTiFhxBQqZk6k .node polygon,#mermaid-svg-sXmeTiFhxBQqZk6k .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sXmeTiFhxBQqZk6k .rough-node .label text,#mermaid-svg-sXmeTiFhxBQqZk6k .node .label text,#mermaid-svg-sXmeTiFhxBQqZk6k .image-shape .label,#mermaid-svg-sXmeTiFhxBQqZk6k .icon-shape .label{text-anchor:middle;}#mermaid-svg-sXmeTiFhxBQqZk6k .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sXmeTiFhxBQqZk6k .rough-node .label,#mermaid-svg-sXmeTiFhxBQqZk6k .node .label,#mermaid-svg-sXmeTiFhxBQqZk6k .image-shape .label,#mermaid-svg-sXmeTiFhxBQqZk6k .icon-shape .label{text-align:center;}#mermaid-svg-sXmeTiFhxBQqZk6k .node.clickable{cursor:pointer;}#mermaid-svg-sXmeTiFhxBQqZk6k .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sXmeTiFhxBQqZk6k .arrowheadPath{fill:#333333;}#mermaid-svg-sXmeTiFhxBQqZk6k .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sXmeTiFhxBQqZk6k .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sXmeTiFhxBQqZk6k .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sXmeTiFhxBQqZk6k .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sXmeTiFhxBQqZk6k .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sXmeTiFhxBQqZk6k .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sXmeTiFhxBQqZk6k .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sXmeTiFhxBQqZk6k .cluster text{fill:#333;}#mermaid-svg-sXmeTiFhxBQqZk6k .cluster span{color:#333;}#mermaid-svg-sXmeTiFhxBQqZk6k div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sXmeTiFhxBQqZk6k .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sXmeTiFhxBQqZk6k rect.text{fill:none;stroke-width:0;}#mermaid-svg-sXmeTiFhxBQqZk6k .icon-shape,#mermaid-svg-sXmeTiFhxBQqZk6k .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sXmeTiFhxBQqZk6k .icon-shape p,#mermaid-svg-sXmeTiFhxBQqZk6k .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sXmeTiFhxBQqZk6k .icon-shape .label rect,#mermaid-svg-sXmeTiFhxBQqZk6k .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sXmeTiFhxBQqZk6k .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sXmeTiFhxBQqZk6k .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sXmeTiFhxBQqZk6k :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

前端请求验证码

CaptchaService.getCaptcha(existingId)

evict 旧 id(节省空间)

随机生成 gapX / gapY两个方向都离边 8px

CaptchaImageGenerator.generate(gapX, gapY)

加载随机背景图

scaleAndCrop 等比缩放 + 居中裁剪

createBackgroundWithGap压暗 + 凹陷明暗 + 白色内描边

createSliderImage凸起明暗 + 白色内描边

背景图 base64

滑块图 base64

ULID 生成 idcorrectX/correctY 存分布式缓存

返回图 + 尺寸(不含坐标)

用户自由拖拽到任意位置松手提交 left / top

CaptchaService.verify(request)

① 立即 evict + setUsed(true)

② X 轴校验(容差 3px)

③ Y 轴校验(容差 3px)

④ 耗时校验(300–5000ms)

⑤ 轨迹校验(匀速段占比 > 0.8)

看图要点:三个位置最关键——返回值里没有 gapX/gapY(答案只在服务端);校验阶段 evict 在所有校验之前;X 和 Y 是两个独立的必过项。

3.2 交互方式:为什么改成「自由拖拽」

先说结论:水平滑轨模式下,Y 根本不该被当作校验维度——用户没有操作它的手段,后端校验它只是「期望前端不撒谎」。要让 Y 真正有意义,交互本身必须允许 Y 变化。

所以我把它改成:拼图块单独停在背景图下方的一块面板里(像拼图托盘),用户可以往任意方向拖。

#mermaid-svg-9k7N35LE4i3OK8QJ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9k7N35LE4i3OK8QJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9k7N35LE4i3OK8QJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9k7N35LE4i3OK8QJ .error-icon{fill:#552222;}#mermaid-svg-9k7N35LE4i3OK8QJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9k7N35LE4i3OK8QJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9k7N35LE4i3OK8QJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9k7N35LE4i3OK8QJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9k7N35LE4i3OK8QJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9k7N35LE4i3OK8QJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9k7N35LE4i3OK8QJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9k7N35LE4i3OK8QJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9k7N35LE4i3OK8QJ .marker.cross{stroke:#333333;}#mermaid-svg-9k7N35LE4i3OK8QJ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9k7N35LE4i3OK8QJ p{margin:0;}#mermaid-svg-9k7N35LE4i3OK8QJ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-9k7N35LE4i3OK8QJ .cluster-label text{fill:#333;}#mermaid-svg-9k7N35LE4i3OK8QJ .cluster-label span{color:#333;}#mermaid-svg-9k7N35LE4i3OK8QJ .cluster-label span p{background-color:transparent;}#mermaid-svg-9k7N35LE4i3OK8QJ .label text,#mermaid-svg-9k7N35LE4i3OK8QJ span{fill:#333;color:#333;}#mermaid-svg-9k7N35LE4i3OK8QJ .node rect,#mermaid-svg-9k7N35LE4i3OK8QJ .node circle,#mermaid-svg-9k7N35LE4i3OK8QJ .node ellipse,#mermaid-svg-9k7N35LE4i3OK8QJ .node polygon,#mermaid-svg-9k7N35LE4i3OK8QJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9k7N35LE4i3OK8QJ .rough-node .label text,#mermaid-svg-9k7N35LE4i3OK8QJ .node .label text,#mermaid-svg-9k7N35LE4i3OK8QJ .image-shape .label,#mermaid-svg-9k7N35LE4i3OK8QJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-9k7N35LE4i3OK8QJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9k7N35LE4i3OK8QJ .rough-node .label,#mermaid-svg-9k7N35LE4i3OK8QJ .node .label,#mermaid-svg-9k7N35LE4i3OK8QJ .image-shape .label,#mermaid-svg-9k7N35LE4i3OK8QJ .icon-shape .label{text-align:center;}#mermaid-svg-9k7N35LE4i3OK8QJ .node.clickable{cursor:pointer;}#mermaid-svg-9k7N35LE4i3OK8QJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9k7N35LE4i3OK8QJ .arrowheadPath{fill:#333333;}#mermaid-svg-9k7N35LE4i3OK8QJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9k7N35LE4i3OK8QJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9k7N35LE4i3OK8QJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9k7N35LE4i3OK8QJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9k7N35LE4i3OK8QJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9k7N35LE4i3OK8QJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9k7N35LE4i3OK8QJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9k7N35LE4i3OK8QJ .cluster text{fill:#333;}#mermaid-svg-9k7N35LE4i3OK8QJ .cluster span{color:#333;}#mermaid-svg-9k7N35LE4i3OK8QJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9k7N35LE4i3OK8QJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9k7N35LE4i3OK8QJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-9k7N35LE4i3OK8QJ .icon-shape,#mermaid-svg-9k7N35LE4i3OK8QJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9k7N35LE4i3OK8QJ .icon-shape p,#mermaid-svg-9k7N35LE4i3OK8QJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9k7N35LE4i3OK8QJ .icon-shape .label rect,#mermaid-svg-9k7N35LE4i3OK8QJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9k7N35LE4i3OK8QJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9k7N35LE4i3OK8QJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9k7N35LE4i3OK8QJ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

新:自由拖拽

背景图 + 下方托盘两个独立图

任意方向拖动

松手提交 left/topX、Y 各自比对

旧:水平滑轨

背景图内嵌滑块

只能左右移动

Y 恒为固定值校验只比 X

看图要点:交互方式决定了校验维度。要让 Y 有意义,先得让 Y 可操作。

这个改动带来三个连带变化:

  • CaptchaResult 里没有任何坐标字段。水平滑轨时代可以下发 Y(因为滑块只能水平动,Y 是已知的、不算答案);自由拖拽下 Y 也是答案的一半,下发等于白送。
  • 缺口的随机范围简化了。没有「初始位置」这个概念,两个方向都只是普通边距:
  • // 自由拖拽模式下不再有"滑块初始在最左侧"的约束,两个方向都是普通边距,
    // 只保留离边 8px 的安全距离,避免拼图凸起部分贴边被画布裁切。
    int gapXMin = 8;
    int gapXMax = Math.max(gapXMin, bgW – size – 8);
    int gapYMin = 8;
    int gapYMax = Math.max(gapYMin, bgH – size – 8);

  • 前端要按原始像素换算坐标。left / top 是拼图块左上角在背景图坐标系中的位置,单位是生成时的原始像素。背景图被 CSS 缩放显示时,必须按 backgroundWidth / backgroundHeight 折算回来,否则容差判定会失真。
  • 3.3 拼图路径:用三次贝塞尔造出「颈部」

    代码坐标:CaptchaImageGenerator#createPuzzleShape

    private Shape createPuzzleShape(int x, int y, int size) {
    double r = size / 6.0; // 凹凸圆半径 —— 决定「颈部」粗细
    double baseSize = size – 2 * r; // 正方形基座
    double offset = r; // 起始偏移,确保圆弧不超出 size

    Path2D.Double path = new Path2D.Double();
    path.moveTo(x + offset, y + offset);

    // 上边:凸起(向外鼓)
    path.lineTo(x + offset + baseSize / 2 – r / 2, y + offset);
    path.curveTo(x + offset + baseSize / 2 – r, y,
    x + offset + baseSize / 2 + r, y,
    x + offset + baseSize / 2 + r / 2, y + offset);
    path.lineTo(x + offset + baseSize, y + offset);

    // 右边:凸起,下边:凹进,左边:凹进(同理,方向不同)
    // …
    path.closePath();
    return path;
    }

    这里有三个设计点值得说清楚。

    第一,r = size / 6 不是一个随便选的数。

    我试过 size/4、size/5、size/8:

    取值视觉效果问题
    size/4 凸起又大又圆 像气球,不像拼图;凸起部分占面积过大,滑块看起来畸形
    size/5 偏大 尚可,但两个凸起会互相挤压
    size/6 接近真实拼图比例 凸起半径约为边长的 1/6,视觉上最自然
    size/8 凸起细长 像十字,边缘曲线过渡生硬

    第二,curveTo 的三个控制点形成「收窄的颈部」。

    注意起点、控制点、终点的位置:

    • 起点在 center – r/2(向内收)
    • 两个控制点在 center ± r(向外鼓)
    • 终点在 center + r/2(再收回)

    如果只用半圆(arcTo),凸起会是一个饱满的半圆,缺少「颈部」的收窄感。而颈部恰恰是真实拼图的标志性特征——也是让它难以被简单模板匹配的关键。

    第三,凸起与凹进必须成对。

    上边凸起、右边凸起、下边凹进、左边凹进。这样滑块从背景图中取出的那块,能严丝合缝地嵌回缺口——如果你把四条边都做成凸起,滑块就嵌不进去了。

    还有一个隐藏约束:整个形状都不超出 size × size 的外接盒。offset = r 和 baseSize = size – 2*r 就是为此设计的——凸起向外鼓时,顶点恰好落在外接盒边界上(距边缘约 size/24 像素)。这一点直接决定了下面描边必须画在内侧。

    把上面这段路径构造画成示意图,一条边的「凸起」是这样由三段拼出来的:

    #mermaid-svg-K2oqH6lNtoInyzKE{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-K2oqH6lNtoInyzKE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-K2oqH6lNtoInyzKE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-K2oqH6lNtoInyzKE .error-icon{fill:#552222;}#mermaid-svg-K2oqH6lNtoInyzKE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-K2oqH6lNtoInyzKE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-K2oqH6lNtoInyzKE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-K2oqH6lNtoInyzKE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-K2oqH6lNtoInyzKE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-K2oqH6lNtoInyzKE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-K2oqH6lNtoInyzKE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-K2oqH6lNtoInyzKE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-K2oqH6lNtoInyzKE .marker.cross{stroke:#333333;}#mermaid-svg-K2oqH6lNtoInyzKE svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-K2oqH6lNtoInyzKE p{margin:0;}#mermaid-svg-K2oqH6lNtoInyzKE .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-K2oqH6lNtoInyzKE .cluster-label text{fill:#333;}#mermaid-svg-K2oqH6lNtoInyzKE .cluster-label span{color:#333;}#mermaid-svg-K2oqH6lNtoInyzKE .cluster-label span p{background-color:transparent;}#mermaid-svg-K2oqH6lNtoInyzKE .label text,#mermaid-svg-K2oqH6lNtoInyzKE span{fill:#333;color:#333;}#mermaid-svg-K2oqH6lNtoInyzKE .node rect,#mermaid-svg-K2oqH6lNtoInyzKE .node circle,#mermaid-svg-K2oqH6lNtoInyzKE .node ellipse,#mermaid-svg-K2oqH6lNtoInyzKE .node polygon,#mermaid-svg-K2oqH6lNtoInyzKE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-K2oqH6lNtoInyzKE .rough-node .label text,#mermaid-svg-K2oqH6lNtoInyzKE .node .label text,#mermaid-svg-K2oqH6lNtoInyzKE .image-shape .label,#mermaid-svg-K2oqH6lNtoInyzKE .icon-shape .label{text-anchor:middle;}#mermaid-svg-K2oqH6lNtoInyzKE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-K2oqH6lNtoInyzKE .rough-node .label,#mermaid-svg-K2oqH6lNtoInyzKE .node .label,#mermaid-svg-K2oqH6lNtoInyzKE .image-shape .label,#mermaid-svg-K2oqH6lNtoInyzKE .icon-shape .label{text-align:center;}#mermaid-svg-K2oqH6lNtoInyzKE .node.clickable{cursor:pointer;}#mermaid-svg-K2oqH6lNtoInyzKE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-K2oqH6lNtoInyzKE .arrowheadPath{fill:#333333;}#mermaid-svg-K2oqH6lNtoInyzKE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-K2oqH6lNtoInyzKE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-K2oqH6lNtoInyzKE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K2oqH6lNtoInyzKE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-K2oqH6lNtoInyzKE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K2oqH6lNtoInyzKE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-K2oqH6lNtoInyzKE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-K2oqH6lNtoInyzKE .cluster text{fill:#333;}#mermaid-svg-K2oqH6lNtoInyzKE .cluster span{color:#333;}#mermaid-svg-K2oqH6lNtoInyzKE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-K2oqH6lNtoInyzKE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-K2oqH6lNtoInyzKE rect.text{fill:none;stroke-width:0;}#mermaid-svg-K2oqH6lNtoInyzKE .icon-shape,#mermaid-svg-K2oqH6lNtoInyzKE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-K2oqH6lNtoInyzKE .icon-shape p,#mermaid-svg-K2oqH6lNtoInyzKE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-K2oqH6lNtoInyzKE .icon-shape .label rect,#mermaid-svg-K2oqH6lNtoInyzKE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-K2oqH6lNtoInyzKE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-K2oqH6lNtoInyzKE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-K2oqH6lNtoInyzKE :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    向内收

    向外鼓

    向外鼓

    再收回

    起点center – r/2

    控制点 1center – r

    控制点 2center + r

    终点center + r/2

    颈部收窄

    凸起顶点

    3.4 立体感:用「半平面裁剪」做出凹凸相反

    这是新版渲染里最有意思的部分,也是我推翻重写最多的地方。

    思路:光从左上来。同一束光下,「凹」和「凸」的明暗是相反的:

    区域上半部内壁下半部内壁观感
    背景缺口 压暗(背光) 微亮(受光) 凹陷
    滑块 白色高光(受光) 深色闭塞阴影(背光) 凸起

    代码里就是两次调用,参数一个 true 一个 false:

    // 背景缺口:凹陷
    g.setColor(new Color(0, 0, 0, 140));
    g.fill(puzzleShape); // 先整体压暗
    drawInnerShade(g, puzzleShape, new Color(0, 0, 0, 110), 7, true); // 上半部更暗
    drawInnerShade(g, puzzleShape, new Color(255, 255, 255, 70), 7, false); // 下半部微亮

    // 滑块:凸起(方向相反)
    drawInnerShade(g, puzzleShape, new Color(255, 255, 255, 170), 4, true); // 上半部高光
    drawInnerShade(g, puzzleShape, new Color(0, 0, 0, 110), 4, false); // 下半部阴影

    drawInnerShade 的实现是本文技术上最巧的一段:

    private void drawInnerShade(Graphics2D g, Shape shape, Color color,
    int spread, boolean upperHalf) {
    if (spread <= 0) return;

    Rectangle2D bounds = shape.getBounds2D();
    double midY = bounds.getCenterY();

    // 半平面向外各扩 1px,避免形状边界恰好落在裁剪线上被切出细缝
    Rectangle2D half = upperHalf
    ? new Rectangle2D.Double(bounds.getMinX() – 1, bounds.getMinY() – 1,
    bounds.getWidth() + 2, midY – bounds.getMinY() + 1)
    : new Rectangle2D.Double(bounds.getMinX() – 1, midY,
    bounds.getWidth() + 2, bounds.getMaxY() – midY + 1);

    Shape oldClip = g.getClip();
    g.clip(shape); // 只保留形状内部
    g.clip(half); // 再只保留上半/下半
    g.setColor(color);
    // 中心线描边会被剪切区裁掉外侧一半,因此线宽取两倍才能得到 spread 的可见厚度
    g.setStroke(new BasicStroke(spread * 2f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND));
    g.draw(shape);
    g.setClip(oldClip);
    }

    三层裁剪叠加:shape ∩ 上半平面,然后以两倍线宽沿轮廓描边——外侧一半被剪切区裁掉,可见厚度恰好是 spread。

    为什么不用 Area 求交?

    我一开始写的是这个版本:

    // ❌ 看起来等价,实际会溢出
    Area area = new Area(shape);
    area.intersect(new Area(halfRect));
    g.setStroke(new BasicStroke(spread * 2f));
    g.draw(area); // ← 描边带会跑出形状

    问题在于:BasicStroke 生成的描边带在贝塞尔凸起与直边相接的尖端处会产生自相交子路径。按非零环绕规则填充时,这些自相交区域会被判为「内部」,于是亮带溢出到形状之外——实测在拼图顶部画出过一条不属于形状的白带。

    而 g.clip(…) 是 AWT 在光栅化阶段逐像素判定的,不会出现这种几何溢出的问题。

    为什么用半平面而不是平移描边带?

    另一个我试过的方案是「把形状上下各平移一点再描边」。问题是平移对上下两条边的作用方向相同——一条边被移出形状、另一条仍留在形状内,结果一侧的明暗方向必然反掉。

    半平面裁剪由几何位置决定归属:上边永远进上半平面,下边永远进下半平面。这个思路是这套渲染能成立的关键。

    把整个「半平面裁剪」的判定流程串起来看:

    #mermaid-svg-KnZx16CJIBX8uvns{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KnZx16CJIBX8uvns .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KnZx16CJIBX8uvns .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KnZx16CJIBX8uvns .error-icon{fill:#552222;}#mermaid-svg-KnZx16CJIBX8uvns .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KnZx16CJIBX8uvns .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KnZx16CJIBX8uvns .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KnZx16CJIBX8uvns .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KnZx16CJIBX8uvns .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KnZx16CJIBX8uvns .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KnZx16CJIBX8uvns .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KnZx16CJIBX8uvns .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KnZx16CJIBX8uvns .marker.cross{stroke:#333333;}#mermaid-svg-KnZx16CJIBX8uvns svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KnZx16CJIBX8uvns p{margin:0;}#mermaid-svg-KnZx16CJIBX8uvns .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-KnZx16CJIBX8uvns .cluster-label text{fill:#333;}#mermaid-svg-KnZx16CJIBX8uvns .cluster-label span{color:#333;}#mermaid-svg-KnZx16CJIBX8uvns .cluster-label span p{background-color:transparent;}#mermaid-svg-KnZx16CJIBX8uvns .label text,#mermaid-svg-KnZx16CJIBX8uvns span{fill:#333;color:#333;}#mermaid-svg-KnZx16CJIBX8uvns .node rect,#mermaid-svg-KnZx16CJIBX8uvns .node circle,#mermaid-svg-KnZx16CJIBX8uvns .node ellipse,#mermaid-svg-KnZx16CJIBX8uvns .node polygon,#mermaid-svg-KnZx16CJIBX8uvns .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KnZx16CJIBX8uvns .rough-node .label text,#mermaid-svg-KnZx16CJIBX8uvns .node .label text,#mermaid-svg-KnZx16CJIBX8uvns .image-shape .label,#mermaid-svg-KnZx16CJIBX8uvns .icon-shape .label{text-anchor:middle;}#mermaid-svg-KnZx16CJIBX8uvns .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KnZx16CJIBX8uvns .rough-node .label,#mermaid-svg-KnZx16CJIBX8uvns .node .label,#mermaid-svg-KnZx16CJIBX8uvns .image-shape .label,#mermaid-svg-KnZx16CJIBX8uvns .icon-shape .label{text-align:center;}#mermaid-svg-KnZx16CJIBX8uvns .node.clickable{cursor:pointer;}#mermaid-svg-KnZx16CJIBX8uvns .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KnZx16CJIBX8uvns .arrowheadPath{fill:#333333;}#mermaid-svg-KnZx16CJIBX8uvns .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KnZx16CJIBX8uvns .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KnZx16CJIBX8uvns .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KnZx16CJIBX8uvns .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KnZx16CJIBX8uvns .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KnZx16CJIBX8uvns .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KnZx16CJIBX8uvns .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KnZx16CJIBX8uvns .cluster text{fill:#333;}#mermaid-svg-KnZx16CJIBX8uvns .cluster span{color:#333;}#mermaid-svg-KnZx16CJIBX8uvns div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KnZx16CJIBX8uvns .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KnZx16CJIBX8uvns rect.text{fill:none;stroke-width:0;}#mermaid-svg-KnZx16CJIBX8uvns .icon-shape,#mermaid-svg-KnZx16CJIBX8uvns .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KnZx16CJIBX8uvns .icon-shape p,#mermaid-svg-KnZx16CJIBX8uvns .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KnZx16CJIBX8uvns .icon-shape .label rect,#mermaid-svg-KnZx16CJIBX8uvns .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KnZx16CJIBX8uvns .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KnZx16CJIBX8uvns .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KnZx16CJIBX8uvns :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    true

    false

    输入:puzzleShape + spread + upperHalf

    计算 bounds 与 midY

    upperHalf ?

    半平面 = 上半矩形minY-1 到 midY

    半平面 = 下半矩形midY 到 maxY+1

    g.clip(shape)

    g.clip(half)

    以 2×spread 线宽沿轮廓描边

    外侧一半被剪切区裁掉可见厚度 = spread

    恢复旧 clip

    3.5 统一白色描边:解决「浅色背景看不见」

    只有明暗是不够的。浅色背景(比如雪景、白墙)上,半透明的压暗几乎看不出轮廓,用户根本找不到缺口在哪。

    解法是给缺口和滑块都加一条纯白内描边,宽度由 sliderBorderWidth 控制(默认 2px):

    private void drawInnerBorder(Graphics2D g, Shape shape, int width) {
    if (width <= 0) return;
    // 中心线描边会被剪切区裁掉外侧一半,因此线宽取两倍才能得到 width 的可见宽度
    g.setClip(shape);
    g.setColor(Color.WHITE);
    g.setStroke(new BasicStroke(width * 2));
    g.draw(shape);
    g.setClip(null);
    }

    三个要点:

    第一,必须是「内侧」描边。

    拼图凸起的顶点距外接盒边缘只有约 size/24 像素(50px 滑块约 2.08px)。如果按中心线描边,一半线宽会落到画布外面被裁掉——可见宽度不可控(实际只有配置值的一半),而且滑块图会溢出画布。

    内描边的做法是「先把剪切区设为形状,再以两倍线宽描边」,外侧一半被裁掉,可见宽度恰为配置值。

    第二,缺口和滑块必须用同一算法、同一宽度。

    createPuzzleShape 和 drawInnerBorder 在两张图上是同一份代码。这样把滑块左上角对齐到缺口左上角时,两张图的轮廓和描边宽度严格重合——否则会出现「描边错位」的视觉瑕疵,用户会以为没对齐。

    第三,白色在深色和浅色背景上都有效。

    • 深色背景:白色描边自己提供边界;
    • 浅色背景:缺口内部的半透明压暗提供亮度差,描边再强化轮廓。

    一条描边同时解决两种极端底色,比「按背景亮度动态选色」简单得多。

    3.6 滑块图:为什么不能画外阴影

    滑块画布就是 size × size,任何外扩描边或偏移阴影都会在画布边缘被硬裁——表现为一圈突兀的硬边,而不是柔和的高光。

    所以这里直接放弃了外阴影:

    private BufferedImage createSliderImage(BufferedImage source, int gapX, int gapY, int size) {
    // 画布尺寸恰为 size,任何外扩描边或偏移阴影都会在此被裁切
    BufferedImage slider = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
    Graphics2D g = slider.createGraphics();
    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

    Shape puzzleShape = createPuzzleShape(0, 0, size);

    // 1. 裁剪并绘制源图内容(注意坐标是 gapX, gapY —— 与缺口位置严格对应)
    g.setClip(puzzleShape);
    g.drawImage(source, 0, 0, size, size, gapX, gapY, gapX + size, gapY + size, null);
    g.setClip(null);

    // 2. 凸起立体感
    drawInnerShade(g, puzzleShape, new Color(255, 255, 255, 170), 4, true);
    drawInnerShade(g, puzzleShape, new Color(0, 0, 0, 110), 4, false);

    // 3. 内侧白色描边,与背景缺口保持一致
    drawInnerBorder(g, puzzleShape, properties.getSliderBorderWidth());

    g.dispose();
    return slider;
    }

    悬浮投影交给前端 CSS 承担:

    .puzzle-slider {
    filter: drop-shadow(2px 2px 3px rgba(0, 0, 0, 0.35));
    }

    这个分工是合理的:后端只负责「拼图块长什么样」,拖拽时的动态投影归前端。而且 drop-shadow 会沿 alpha 通道的轮廓生成投影,正好贴合拼图形状——比后端画一个矩形阴影更自然。

    画布尺寸必须恰为 size,因为前端要按左上角定位。如果画布大一点(比如为了容纳阴影而扩 4px),前端就得减去偏移量,容易出错。

    3.7 缩放 + 裁剪:不变形的关键

    代码坐标:scaleAndCrop

    private BufferedImage scaleAndCrop(BufferedImage source, int targetW, int targetH) {
    int sw = source.getWidth();
    int sh = source.getHeight();
    // 取 max:保证铺满目标区域
    double scale = Math.max((double) targetW / sw, (double) targetH / sh);
    int scaledW = (int) Math.ceil(sw * scale);
    int scaledH = (int) Math.ceil(sh * scale);

    BufferedImage scaled = new BufferedImage(scaledW, scaledH, BufferedImage.TYPE_INT_ARGB);
    Graphics2D g = scaled.createGraphics();
    g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
    RenderingHints.VALUE_INTERPOLATION_BILINEAR);
    g.drawImage(source, 0, 0, scaledW, scaledH, null);
    g.dispose();

    // 居中裁剪
    int cropX = Math.max(0, (scaledW – targetW) / 2);
    int cropY = Math.max(0, (scaledH – targetH) / 2);
    return scaled.getSubimage(cropX, cropY, targetW, targetH);
    }

    Math.max 而不是 Math.min,这是最容易写错的地方:

    • 用 Math.min:图片只铺满一个方向,另一个方向留白(或者你要拉伸填充,那就变形了)。
    • 用 Math.max:图片在某个方向溢出,裁剪掉多余部分。

    必须裁剪而不是拉伸,因为滑块是从背景图的 (gapX, gapY) 位置精确截取的:

    g.drawImage(source, 0, 0, size, size, gapX, gapY, gapX + size, gapY + size, null);

    如果背景图被拉伸过,而滑块按原始尺寸截取,两者纹理对不上——用户拖动时能看到滑块内容和缺口背景明显错位。

    3.8 三维校验:XY、耗时、轨迹

    代码坐标:CaptchaService#verify

    第一维:空间(X 和 Y 是两个独立必过项)

    if (Math.abs(request.getLeft() – data.getCorrectX()) > properties.getPositionTolerance()) {
    log.debug("位置校验失败(X轴): 用户X={}, 正确X={}", request.getLeft(), data.getCorrectX());
    return false;
    }

    // 2. Y 轴校验:自由拖拽模式下用户需同时对齐纵向,只校验 X 会让纵向随便放也能通过
    if (Math.abs(request.getTop() – data.getCorrectY()) > properties.getPositionTolerance()) {
    log.debug("位置校验失败(Y轴): 用户Y={}, 正确Y={}", request.getTop(), data.getCorrectY());
    return false;
    }

    两个方向共用同一个容差 positionTolerance = 3(像素)。

    3 像素这个值不是拍脑袋的:前端上报的是 CSS 像素,后端生成的是图片原始像素。如果背景图被缩放显示,两者会有一个比例差。3 像素正好能容忍常见的 DPR(设备像素比)舍入误差,又不至于宽到可以暴力猜。

    抗爆破能力对比(默认 300×150 背景、50px 滑块、3px 容差):

    方案有效取值数说明
    只校验 X X ∈ [8, 242] ≈ 235 个位置,容差 ±3 让命中区扩到约 7 个位置 → 约 1/34 命中率 几十次尝试即可通过
    校验 X + Y X 约 34 组有效区 × Y 约 13 组有效区 → 约 1/442 命中率 同样尝试次数下命中概率降了一个数量级以上

    第二维:耗时

    long duration = request.getDurationMs();
    if (duration < properties.getMinDurationMs() || duration > properties.getMaxDurationMs()) {
    return false;
    }

    默认区间 [300, 5000] 毫秒。

    • 下限 300ms:真人从看到图到拖到位,物理上不可能更快(视觉反应 + 鼠标移动)。
    • 上限 5000ms:超过这个时间,可能是「人工打码平台」在转人工,也可能是页面被挂起了。

    第三维:轨迹拟人性

    这一维最容易被写成「单点判定」,效果很差。我最初就是这么写的:

    // ❌ 错误做法:只要有一段匀速就拒绝
    if (isUniform(dx1, dx2)) return false;

    结果误杀率极高——真人快速拖动时,中间必然有一段近似匀速。测试时我自己拖了 20 次,被拒了 7 次。

    正确做法是统计匀速段的占比:

    private boolean isUniformLinearMotion(List<TrajectoryPoint> points) {
    if (points.size() < 4) return false;

    int uniformSegments = 0;
    int totalSegments = points.size() – 2;

    for (int i = 2; i < points.size(); i++) {
    TrajectoryPoint p0 = points.get(i – 2);
    TrajectoryPoint p1 = points.get(i – 1);
    TrajectoryPoint p2 = points.get(i);

    long dt1 = p1.getTimestamp() – p0.getTimestamp();
    long dt2 = p2.getTimestamp() – p1.getTimestamp();
    double dx1 = p1.getX() – p0.getX();
    double dx2 = p2.getX() – p1.getX();
    double dy1 = p1.getY() – p0.getY();
    double dy2 = p2.getY() – p1.getY();

    if (dt1 > 0 && dt2 > 0) {
    double ratioT = (double) dt2 / dt1;
    double ratioX = Math.abs(dx1) > 0.001 ? dx2 / dx1
    : (Math.abs(dx2) < 0.001 ? 1 : 0);
    double ratioY = Math.abs(dy1) > 0.001 ? dy2 / dy1
    : (Math.abs(dy2) < 0.001 ? 1 : 0);

    // 时间、X、Y 三个维度的相邻段比值都接近 1 → 这一段是匀速
    if (Math.abs(ratioT – 1) < 0.25
    && Math.abs(ratioX – 1) < 0.25
    && Math.abs(ratioY – 1) < 0.25) {
    uniformSegments++;
    }
    }
    }
    // 匀速段占比超过 80% 判定为机器
    return (double) uniformSegments / totalSegments > 0.8;
    }

    核心思路:真人的拖动轨迹有加速、减速、微调,匀速段占比通常在 30%–60%;而脚本用恒定速度移动,占比接近 100%。取 0.8 作为阈值,正好卡在两者之间。

    这里 Math.abs(dx1) > 0.001 的防除零处理也值得注意:如果用户在某个方向上完全没动,ratio 会变成 0/0。代码里用「两者都近似 0 则视为匀速」来处理。

    自由拖拽让这条规则更有信息量:水平滑轨时代 dy 恒为 0,ratioY 永远等于 1,等于一个恒真的条件;两个方向都能动之后,脚本要同时伪造两个方向的加减速才能骗过判定,成本更高。

    把三维校验的完整判定顺序画出来,注意「作废」永远在最前面:

    #mermaid-svg-gSYJCnwLWedVBevS{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-gSYJCnwLWedVBevS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-gSYJCnwLWedVBevS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-gSYJCnwLWedVBevS .error-icon{fill:#552222;}#mermaid-svg-gSYJCnwLWedVBevS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-gSYJCnwLWedVBevS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-gSYJCnwLWedVBevS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-gSYJCnwLWedVBevS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-gSYJCnwLWedVBevS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-gSYJCnwLWedVBevS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-gSYJCnwLWedVBevS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-gSYJCnwLWedVBevS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-gSYJCnwLWedVBevS .marker.cross{stroke:#333333;}#mermaid-svg-gSYJCnwLWedVBevS svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-gSYJCnwLWedVBevS p{margin:0;}#mermaid-svg-gSYJCnwLWedVBevS .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-gSYJCnwLWedVBevS .cluster-label text{fill:#333;}#mermaid-svg-gSYJCnwLWedVBevS .cluster-label span{color:#333;}#mermaid-svg-gSYJCnwLWedVBevS .cluster-label span p{background-color:transparent;}#mermaid-svg-gSYJCnwLWedVBevS .label text,#mermaid-svg-gSYJCnwLWedVBevS span{fill:#333;color:#333;}#mermaid-svg-gSYJCnwLWedVBevS .node rect,#mermaid-svg-gSYJCnwLWedVBevS .node circle,#mermaid-svg-gSYJCnwLWedVBevS .node ellipse,#mermaid-svg-gSYJCnwLWedVBevS .node polygon,#mermaid-svg-gSYJCnwLWedVBevS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-gSYJCnwLWedVBevS .rough-node .label text,#mermaid-svg-gSYJCnwLWedVBevS .node .label text,#mermaid-svg-gSYJCnwLWedVBevS .image-shape .label,#mermaid-svg-gSYJCnwLWedVBevS .icon-shape .label{text-anchor:middle;}#mermaid-svg-gSYJCnwLWedVBevS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-gSYJCnwLWedVBevS .rough-node .label,#mermaid-svg-gSYJCnwLWedVBevS .node .label,#mermaid-svg-gSYJCnwLWedVBevS .image-shape .label,#mermaid-svg-gSYJCnwLWedVBevS .icon-shape .label{text-align:center;}#mermaid-svg-gSYJCnwLWedVBevS .node.clickable{cursor:pointer;}#mermaid-svg-gSYJCnwLWedVBevS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-gSYJCnwLWedVBevS .arrowheadPath{fill:#333333;}#mermaid-svg-gSYJCnwLWedVBevS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-gSYJCnwLWedVBevS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-gSYJCnwLWedVBevS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gSYJCnwLWedVBevS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-gSYJCnwLWedVBevS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gSYJCnwLWedVBevS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-gSYJCnwLWedVBevS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-gSYJCnwLWedVBevS .cluster text{fill:#333;}#mermaid-svg-gSYJCnwLWedVBevS .cluster span{color:#333;}#mermaid-svg-gSYJCnwLWedVBevS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-gSYJCnwLWedVBevS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-gSYJCnwLWedVBevS rect.text{fill:none;stroke-width:0;}#mermaid-svg-gSYJCnwLWedVBevS .icon-shape,#mermaid-svg-gSYJCnwLWedVBevS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-gSYJCnwLWedVBevS .icon-shape p,#mermaid-svg-gSYJCnwLWedVBevS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-gSYJCnwLWedVBevS .icon-shape .label rect,#mermaid-svg-gSYJCnwLWedVBevS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-gSYJCnwLWedVBevS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-gSYJCnwLWedVBevS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-gSYJCnwLWedVBevS :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    否

    是

    否

    是

    否

    是

    否

    是

    verify(request)

    synchronized(data)isUsed? → 拒绝

    setUsed(true) + cache.evict(id)

    X 轴|left – correctX| ≤ 3px

    返回 false

    Y 轴|top – correctY| ≤ 3px

    耗时300–5000ms

    匀速段占比≤ 0.8

    返回 true

    3.9 先作废再校验:本文最重要的一段

    代码坐标:CaptchaService#verify

    // 升级(H-09):原版先做位置/时间/轨迹校验再 evict,导致同 ID 验证码
    // 第一次校验失败时缓存仍保留,攻击者可无限重试直到"中奖"位置。
    // 修复:进入校验逻辑前立即同步 evict + setUsed(true),保证一次性消费。
    synchronized (data) {
    if (data.isUsed()) {
    return false;
    }
    data.setUsed(true);
    cache.evict(request.getId());
    }

    // 之后才执行三维校验
    if (Math.abs(request.getLeft() – data.getCorrectX()) > properties.getPositionTolerance()) {
    return false;
    }

    为什么这个顺序不能反:

    顺序攻击者可行性成本估算(X+Y 双校验)
    先校验后作废(错误) 同一 id 可无限重试 约 1/442 命中率,几百次尝试必中
    先作废后校验(正确) 一次失败即失效,必须重新获取 每次尝试都要重新走「生成图片 + 写缓存」,成本上升两个数量级

    注意:改成 XY 双校验后,单次尝试的命中率已经从 1/34 降到 1/442,但只要不作废,重试就一定会成功。所以「一次性消费」不是锦上添花,而是把「时间换概率」这条路彻底堵死。

    synchronized (data) 的作用是堵住另一个漏洞:同一 id 的两个并发请求可能同时通过 isUsed() 检查。虽然 cache.evict 本身是原子的,但 setUsed 和 evict 之间仍有窗口。

    锁对象选择 CaptchaData 实例本身而不是 this,是为了避免把整个服务串行化——不同验证码之间应该能并发校验。

    这里有一个必须诚实的警告:synchronized (data) 只在「同一个 data 实例」上有效。如果缓存层在每次 get 时都返回新反序列化的对象(远程缓存常见),锁就失效了。这种场景下应改用 id 维度的分布式锁,或者接受「偶发两次校验」并依赖 evict 的原子性兜底。

    两种顺序的时序差异,用一张图对比最清楚:

    缓存CaptchaService攻击者缓存CaptchaService攻击者#mermaid-svg-hp0e8uUAsKl8hudb{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hp0e8uUAsKl8hudb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hp0e8uUAsKl8hudb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hp0e8uUAsKl8hudb .error-icon{fill:#552222;}#mermaid-svg-hp0e8uUAsKl8hudb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hp0e8uUAsKl8hudb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hp0e8uUAsKl8hudb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hp0e8uUAsKl8hudb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hp0e8uUAsKl8hudb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hp0e8uUAsKl8hudb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hp0e8uUAsKl8hudb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hp0e8uUAsKl8hudb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hp0e8uUAsKl8hudb .marker.cross{stroke:#333333;}#mermaid-svg-hp0e8uUAsKl8hudb svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hp0e8uUAsKl8hudb p{margin:0;}#mermaid-svg-hp0e8uUAsKl8hudb .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-hp0e8uUAsKl8hudb text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-hp0e8uUAsKl8hudb .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-hp0e8uUAsKl8hudb .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-hp0e8uUAsKl8hudb .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-hp0e8uUAsKl8hudb .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-hp0e8uUAsKl8hudb #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-hp0e8uUAsKl8hudb .sequenceNumber{fill:white;}#mermaid-svg-hp0e8uUAsKl8hudb #sequencenumber{fill:#333;}#mermaid-svg-hp0e8uUAsKl8hudb #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-hp0e8uUAsKl8hudb .messageText{fill:#333;stroke:none;}#mermaid-svg-hp0e8uUAsKl8hudb .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-hp0e8uUAsKl8hudb .labelText,#mermaid-svg-hp0e8uUAsKl8hudb .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-hp0e8uUAsKl8hudb .loopText,#mermaid-svg-hp0e8uUAsKl8hudb .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-hp0e8uUAsKl8hudb .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-hp0e8uUAsKl8hudb .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-hp0e8uUAsKl8hudb .noteText,#mermaid-svg-hp0e8uUAsKl8hudb .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-hp0e8uUAsKl8hudb .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-hp0e8uUAsKl8hudb .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-hp0e8uUAsKl8hudb .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-hp0e8uUAsKl8hudb .actorPopupMenu{position:absolute;}#mermaid-svg-hp0e8uUAsKl8hudb .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-hp0e8uUAsKl8hudb .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-hp0e8uUAsKl8hudb .actor-man circle,#mermaid-svg-hp0e8uUAsKl8hudb line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-hp0e8uUAsKl8hudb :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}❌ 先校验后作废✅ 先作废后校验verify(id, 猜测位置1)get(id) → 命中false(缓存仍在)verify(id, 猜测位置2)get(id) → 仍命中false(可无限重试)verify(id, 猜测位置N)true ← 中奖verify(id, 猜测位置1)evict(id) + setUsed(true)false(缓存已失效)verify(id, 猜测位置2)get(id) → 未命中false(必须重新申请)

    3.10 ID 与「不泄露答案」

    String id = UlidCreator.getUlid().toString();
    CaptchaData data = new CaptchaData(id, gapX, gapY, false);
    cache.put(id, data);

    为什么用 ULID 而不是自增 ID 或随机数:

    • 自增 ID:攻击者可以预测下一个有效 ID,直接构造校验请求。
    • Math.random():伪随机,可被推测;且碰撞概率随量级上升。
    • ULID:128 位,前 48 位是毫秒时间戳(有序,日志里好排查),后 80 位是随机部分(不可预测)。

    返回给前端的结构里没有任何坐标信息:

    public class CaptchaResult {
    private String id;
    private String backgroundImageBase64;
    private String sliderImageBase64;
    private int backgroundWidth;
    private int backgroundHeight;
    private int sliderSize;
    }

    这一条容易被忽略:交互方式一变,同一个返回结构就从「无害」变成了「泄露一半答案」。评估接口暴露面时,不能只看字段本身,要看它和当前交互方式的组合。

    3.11 随机源与防御性边界

    private final Random random = new SecureRandom(); // 选图用
    ...
    int gapX = ThreadLocalRandom.current().nextInt(gapXMin, gapXMax + 1); // 坐标用

    两处随机用了不同的源,这个区分是有意的:

    用途随机源理由
    选哪张背景图 SecureRandom 避免可预测的图片选择模式
    缺口坐标 ThreadLocalRandom 高频调用,SecureRandom 有锁竞争开销;坐标本身不需要密码学强度(攻击者无法观察历史坐标)

    防御性边界:

    int gapXMax = Math.max(gapXMin, bgW – size – 8);

    用 Math.max 把下界兜住。当背景图宽度过小(配置错误或极端分辨率)时,如果 bgW – size – 8 < 8,nextInt(8, gapXMax+1) 会抛 IllegalArgumentException。宁可让验证码都在同一个位置,也不能让接口 500。

    同理,drawInnerShade 和 drawInnerBorder 开头都有 if (spread <= 0) return; / if (width <= 0) return;——配置成 0 表示「不描边」,是合法配置而不是异常。


    四、权衡、边界与坑

    4.1 取舍表

    决策选择备选代价 / 边界
    交互方式 自由拖拽(X+Y) 水平滑轨(仅 X) 前端实现更复杂;但抗爆破能力强一个数量级以上
    拼图形状 三阶贝塞尔 + 颈部 方块 / 圆角矩形 代码复杂(约 40 行路径构造),但特征显著弱于规整图形
    立体感 半平面裁剪 + 内侧描边 Area 求交 / 平移描边带 代码绕,但避免了描边溢出和明暗反向
    边界可见性 统一白色内描边 按背景亮度动态选色 一条规则覆盖所有底色;但极亮白背景上会弱一些
    滑块投影 前端 CSS drop-shadow 后端画外阴影 画布必须恰为 sliderSize,后端画会被裁
    校验维度 XY + 耗时 + 轨迹 仅 XY 误杀率略升,但抗脚本能力强
    轨迹判定 匀速段占比 > 0.8 单点判定 需要至少 4 个轨迹点;脚本加抖动可绕过
    作废时机 校验前 校验后 用户体验:失败要重新拿图;但这是唯一能挡住重试的设计
    存储 分布式缓存(fate-cache) 本地 Map 依赖 Redis;换来多实例一致

    4.2 踩过的六个坑

    坑 1:Area 求交画内壁,亮带溢出到形状外

    • 现象:拼图顶部出现一条不属于形状的白色亮带。
    • 定位:BasicStroke 生成的描边带在贝塞尔凸起与直边相接的尖端会产生自相交子路径,非零环绕规则把它们判为内部。
    • 修法:改用 g.clip(shape) + g.clip(halfPlane),让光栅化阶段逐像素判定。

    坑 2:平移描边带导致一侧明暗反向

    • 现象:本想让上半部亮、下半部暗,结果一侧亮一侧暗的关系反了。
    • 定位:平移对上下两条边作用方向相同,一条被移出形状、一条仍在内。
    • 修法:改用半平面裁剪,由几何位置决定归属。

    坑 3:中心线描边被画布裁掉一半

    • 现象:配置 sliderBorderWidth = 2,实际看起来只有 1px。
    • 定位:拼图凸起顶点距外接盒边缘只有 size/24(50px 约 2.08px),中心线描边有一半落到画布外。
    • 修法:内描边——g.setClip(shape) 后以两倍线宽描边,可见宽度恰为配置值。

    坑 4:前端坐标系与服务端不一致,总是差几十像素

    • 现象:拖到肉眼可见的正确位置,后端始终返回「位置校验失败」。
    • 定位:前端按 CSS 像素上报,后端按背景图原始像素生成。背景图被缩放 1.5 倍显示时,两者差一个比例系数。
    • 修法:前端按 显示宽度 / backgroundWidth 换算后再上报。协议里明确约定「left / top 一律用原始像素坐标系」。

    坑 5:positionTolerance 调到 20px,等于没有校验

    • 现象:为了兼容缩放问题,把容差从 3 调到 20,结果安全测试时几次就猜中。
    • 定位:用「放宽容差」掩盖「坐标系不一致」这个真问题。
    • 修法:回到 3px,改前端做像素换算。容差是用来吸收误差的,不是用来吸收 bug 的。

    坑 6:Math.min 做缩放,滑块和背景错位

    • 现象:滑块拖到缺口上,纹理和背景明显对不上。
    • 定位:scale 用了 Math.min,图片只铺满一个方向,另一个方向留白;随后被拉伸填充,纹理变形。
    • 修法:改用 Math.max + 居中裁剪。

    4.3 什么时候你不该用这套方案

    第一,高价值场景(支付、改密、实名认证)不要只靠它。

    这套方案的本质是「提高攻击成本」,不是「不可破解」。真人打码平台、高级拟人脚本都能通过。高价值场景应该叠加:手机验证码、风控评分、行为采集、人工复核。

    第二,纯 API 无前端的环境不适用。

    自由拖拽的前提是有浏览器交互。如果是 App 原生环境,需要各自实现拖拽 UI,工作量不在后端这一侧。

    第三,QPS 极高的公开接口要谨慎。

    每个验证码都要生成两张 PNG(背景 + 滑块)+ base64 编码,CPU 开销不小。如果是秒杀级别的入口,应该考虑更轻量的方案(如 proof-of-work 挑战)。

    4.4 这套方案防不住什么

    必须写清楚,否则会给人错误的安全感:

    攻击方式能否防住原因
    OCR 识别字符 ✅ 防住 图形差异大,OCR 无目标可识
    模板匹配定位缺口 ⚠️ 部分防住 颈部 + 凹凸明暗提高了门槛,但卷积模型仍可识别
    只猜 X 不猜 Y ✅ 防住 Y 是独立必过项,纵向随手一放无法通过
    暴力重试同一验证码 ✅ 防住 先作废再校验
    脚本匀速拖动 ✅ 防住 匀速段占比判定(XY 双维度更有效)
    脚本加随机抖动 + 变速 ❌ 防不住 可模拟出「拟人」轨迹
    人工/众包打码平台 ❌ 完全防不住 所有维度都是真人行为
    直接调 API 绕过前端 ⚠️ 部分防住 ID 不可预测 + 一次性消费,但仍可批量申请

    结论:这是一个「性价比很高」的方案——开发成本约 2 天,能挡住 90% 的自动化脚本。但它不是安全边界,真正的安全边界在风控系统和业务规则里。


    五、可以直接抄走的清单

  • 让 Y 成为有效校验维度:交互上支持自由拖拽,后端 X、Y 各自比对,任一超差即失败。
  • 返回值不要下发缺口坐标(包括 Y),答案只放服务端缓存。
  • 拼图用 Path2D + 三次 curveTo,半径取 size / 6,四条边两凸两凹成对出现。
  • 立体感用「剪切区 = 形状 ∩ 半平面 + 两倍线宽描边」,不要用 Area 求交(会溢出)。
  • 描边必须画在内侧(setClip(shape) + 两倍线宽),否则会被画布裁掉一半。
  • 缺口与滑块用同一套形状与描边算法,保证像素级对齐。
  • 背景图缩放用 Math.max + 居中裁剪,绝不能拉伸(否则滑块纹理对不上)。
  • 滑块画布尺寸恰为 sliderSize,悬浮投影交给前端 CSS。
  • 校验必须多维度:XY 容差 3px、耗时区间 300–5000ms、轨迹匀速段占比 > 0.8。
  • 先作废再校验——这个顺序反了,前面所有工作都白费。
  • synchronized (data) 只对同一实例有效;远程缓存下改用分布式锁。
  • 验证码 ID 用 ULID,不用自增或弱随机。
  • 缺口范围用 Math.max 兜住下界,别让配置错误变成接口 500。
  • 文档里明确写「防不住打码平台」——别把成本提升说成安全保证。
  • 赞(0)
    未经允许不得转载:171主机测评 » 自由拖拽拼图验证码:贝塞尔拼图渲染 + XY 双向校验 + 一次性消费(三)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址