<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>豆子工具 on 豆子技术站</title><link>https://blog.91demo.top/categories/%E8%B1%86%E5%AD%90%E5%B7%A5%E5%85%B7/</link><description>Recent content in 豆子工具 on 豆子技术站</description><generator>Hugo -- 0.155.1</generator><language>zh-cn</language><lastBuildDate>Sun, 24 May 2026 06:40:16 +0800</lastBuildDate><atom:link href="https://blog.91demo.top/categories/%E8%B1%86%E5%AD%90%E5%B7%A5%E5%85%B7/index.xml" rel="self" type="application/rss+xml"/><item><title>从 wasm-bindgen 到 C-ABI 写微信小程序WASM：一次真实的 WASM 落地复盘</title><link>https://blog.91demo.top/wx-wasm/</link><pubDate>Sun, 24 May 2026 06:40:16 +0800</pubDate><guid>https://blog.91demo.top/wx-wasm/</guid><description>&lt;blockquote&gt;
&lt;p&gt;在写完 ESP8266 IoT 架构之后，我就在想一件事情：既然代码开源，IoT 网关岂不是谁都能连？&lt;br&gt;
于是我试图在微信小程序端做文章。但开源的小程序不能硬编码密钥，也不能仅靠接口鉴权。&lt;br&gt;
我想到了很早之前预研的 WebAssembly（WASM）。配合后端防护，可以大幅提升攻击者的成本。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="一背景与目标"&gt;一、背景与目标&lt;/h2&gt;
&lt;p&gt;实现的功能非常简单：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;传入 &lt;strong&gt;小程序 APPID + 设备 ID + 时间戳&lt;/strong&gt;，使用 &lt;strong&gt;Ed25519&lt;/strong&gt; 签名，返回 Token。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;以前在微信小程序调通过 &lt;code&gt;WxWebAssembly&lt;/code&gt;（简称 wxwasm），但当时主要处理 Int 类型，这次需要返回字符串。&lt;/p&gt;
&lt;p&gt;当我拿出以前的预研代码进行修改时，我才意识到：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;“能用 Rust 写 WASM”&lt;/strong&gt; 和 &lt;strong&gt;“能在微信小程序里稳定运行”&lt;/strong&gt;，完全是两回事。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id="二第一阶段理想很丰满--rust--wasm-bindgen"&gt;二、第一阶段：理想很丰满 —— Rust + wasm-bindgen&lt;/h2&gt;
&lt;p&gt;最初的环境是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Rust &lt;strong&gt;1.95&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wasm-bindgen&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;wasm-pack&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="cargotoml"&gt;Cargo.toml&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[&lt;span style="color:#a6e22e"&gt;dependencies&lt;/span&gt;]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;wasm-bindgen&lt;/span&gt; = &lt;span style="color:#e6db74"&gt;&amp;#34;0.2&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;ed25519-dalek&lt;/span&gt; = { &lt;span style="color:#a6e22e"&gt;version&lt;/span&gt; = &lt;span style="color:#e6db74"&gt;&amp;#34;2.1&amp;#34;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;features&lt;/span&gt; = [&lt;span style="color:#e6db74"&gt;&amp;#34;rand_core&amp;#34;&lt;/span&gt;] }
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;hex&lt;/span&gt; = &lt;span style="color:#e6db74"&gt;&amp;#34;0.4&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="librs"&gt;lib.rs&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-rust" data-lang="rust"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;[wasm_bindgen]
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;pub&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;fn&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;generate_token&lt;/span&gt;(appid: &lt;span style="color:#66d9ef"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;str&lt;/span&gt;, device_id: &lt;span style="color:#66d9ef"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;str&lt;/span&gt;, timestamp: &lt;span style="color:#66d9ef"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#66d9ef"&gt;str&lt;/span&gt;) -&amp;gt; Result&lt;span style="color:#f92672"&gt;&amp;lt;&lt;/span&gt;String, JsValue&lt;span style="color:#f92672"&gt;&amp;gt;&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// Ed25519 签名逻辑
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;编译很顺利（就是有点慢 😭），本地 &lt;code&gt;test.html&lt;/code&gt; 测试一切正常。&lt;/p&gt;</description></item><item><title>对于微信小程序的一些回忆和思考</title><link>https://blog.91demo.top/mp-story/</link><pubDate>Fri, 24 Apr 2026 07:22:34 +0000</pubDate><guid>https://blog.91demo.top/mp-story/</guid><description>&lt;p&gt;有些事，不做笔记真的意识不到已经过去了这么久。&lt;/p&gt;
&lt;p&gt;最近在整理博客内容时，我翻到了&lt;strong&gt;小程序&lt;/strong&gt;的最早版本记录。那是 2018 年，最初的它简陋得甚至有点滑稽：整个页面只有一个按钮，点一下，生成一个随机数。&lt;/p&gt;
&lt;h2 id="一豆子工具小程序"&gt;一、豆子工具小程序&lt;/h2&gt;
&lt;p&gt;这个小程序项目名称“Wander”，它的小程序名称换了很多，只有到微信开始要求备案和实名后，才开始真正的确定下来。&lt;/p&gt;
&lt;p&gt;这几年里，我断断续续的维护着这个项目，是由于自己没事的时候喜欢做一些东西，当忙起来时又会忘记了它，当有新的想法时，会改变它，然后更换微信小程序名称。印象深刻的有几次：1，随机数，纯粹是为了小程序上线，为了学习小程序，做了最简单的示例，然后上线了。2，图片集，制作了一个图片列表，然后展示图片，图片存储在云服务上，那是还是免费，并且提供两个环境，为了学习云存储，云函数。3，找厕所，这是我最费心最耗时最吃力的一次改进，为了学习地图map，最后没有完成放弃了。4，自用工具，音频格式转换，图片格式转换，MD5哈希等小工具，从这之后应该算是入门了吧，毕竟以前是自学，现在因为有了工作经历，感觉自己进步了，思维和技巧不在停留在简单的示例教程上。5，工具+项目，以前自学的一些项目以及现在新做的模块都包含进了这个小程序，工具还是自己在用，项目呢，可以用来分享给有需要的人。&lt;/p&gt;
&lt;p&gt;在这几年里，我在这款小程序上倾注了太多的时间和精力，看起来是总在变化，没有一个明确的目标，但是现在回忆总结一下，还有在一个圆内，只不过半径大了一些而已。它像是我个人开发者生涯的一个“活化石”，记录了我每一个阶段遇到的问题和想出的方案。&lt;/p&gt;
&lt;p&gt;总结了最近几年开发过和上线了的一些内容。现在的“豆子工具【已备案】”已经从当年的随机数生成器，进化成了一个覆盖网络、多媒体、开发调试的全能工具箱：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;算法&lt;/strong&gt;：随机数生成，MD5，SHA256，RSA，AES，Base64，URL编码等。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;网络&lt;/strong&gt;：获取公网IP地址和归属地，获取 WIFI 公网地址、云厂商安全组管理、查服务端口、域名证书检测，局域网TCP、UDP、WebSocket调试，ESP网络配置等。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;多媒体&lt;/strong&gt;：音频格式转换（m4a 转 mp3）、图片格式转换（png 转 webp）、二维码识别与生成，图片拼接等。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;系统功能&lt;/strong&gt;：邮件通知、微信服务通知、微信聊天分享，打开文档，导出文档等。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;小程序一直在迭代中，这中间的很多功能因为自身原因或者微信审核政策的原因，遗憾地消失在了版本更迭中。&lt;/p&gt;
&lt;h2 id="二豆子碎片小程序"&gt;二、豆子碎片小程序&lt;/h2&gt;
&lt;p&gt;这个小程序项目名称“Visit”，它的微信小程序名称也是变更了多次，只有在备案时才确定了下来。不过它的内容现在已经融入了豆子工具v11.0.0版本中。只是为了维护方便。&lt;/p&gt;
&lt;p&gt;这个Visit小程序在Wander小程序一年后注册，刚开始做的功能就是浏览 Go Web 框架 Beego 的教程。当时，正在自学 Beego，而 Beego 的网站时常打不开，就想做一个小程序，可以浏览 Beego 内容。刚做出来的一段时间，因为新鲜度小火了一把。&lt;/p&gt;
&lt;p&gt;后来又切换到唐诗主题，当时自己费劲的采集了小学的古诗词作为内容源。印象最深的是添加拼音，当时没有AI，从网上搜索和自己琢磨。最后还是放弃了，一是因为素材的原因，没有解析，没有音频，界面美观一般（回忆来看）。到备案的时候，卡住了，个人主体不允许出现诗词这些关键字。要改名字了，我觉得内容不符合，就换内容了，对于我来说，名字需要和内容匹配，心里才会舒服。当时为了选内容而发愁，因为不想放弃这个小程序。想了几天后，觉得把自己的开发经验做成笔记吧。因为碎片化，所以叫豆子碎片。添加豆子前缀，是因为当时备案时，需要区分小程序名称。觉得豆子比较简单上口。那段时间学了towxml，将内容使用Markdown来编写。&lt;/p&gt;
&lt;p&gt;这之后，虽然也改过游戏关卡，作品集，模块集市。但都是通过Markdown来编写和展示内容。&lt;/p&gt;
&lt;h2 id="三思考现在与未来"&gt;三、思考现在与未来&lt;/h2&gt;
&lt;p&gt;今年已经是2026年，AI相当发达，我以前收录的代码片段和想法思考，AI已经分分钟替代，并且做的更好。现在的小程序在备案和实名后，自己的小程序更是无人问津。到了今天，已经没有精力再去想尝鲜了，为了一个新出的功能或者想法去搞好几天去研究了。总结了小程序最近几年的变更，虽然内容一直在变，但是自己在技术上并没有新的突破，依旧是列表详情布局，依旧是本地和后端联动。以前围绕着微信小程序为中心，当抬高视野时，发现局限性很大，小程序很多内容是不能做的，很多功能是做不了的。当前的豆子工具是功能小程序，已经可用的功能如音频格式转换，图片格式转换继续保留。豆子碎片当前的模块集市依旧保留，它是一个项目列表，可以查看详情并可获取到源码和实用客户端工具。所以可以将豆子碎片和豆子工具的内容合并到一起，减少小程序的维护，可以有更多的精力去做一些其他事情。2026年已经计划在博客中写文章了，已经不再使用小程序记笔记，因为小程序的缺陷很明显，特别对于长篇文章。那么未来就可以这样，首页网站可以做Web实验性项目，博客中写项目实现和想法，豆子工具中做小程序最合适的工具和上架模块源码，公众号主要用来引流和发布一些比较好的博客文章，或者发布一些工具更新，或者一些好的开源项目。以后的内容可以深入一些，实用一些，比如以前的模块，现在可以做一个产品。然后继续新的方向，桌面客户端工具和嵌入式开发尝试，继续深入Rust和Go。&lt;/p&gt;</description></item><item><title>基于 Go + 小程序构建“口袋指令中心”，实现远程发布控制</title><link>https://blog.91demo.top/bag-script/</link><pubDate>Wed, 04 Feb 2026 18:00:00 +0800</pubDate><guid>https://blog.91demo.top/bag-script/</guid><description>&lt;p&gt;我日常涉及 Hugo 博客发布、客户端打包、Nginx 运维等多种重复性脚本。每次都要 SSH 连服务器并执行命令，操作链过长，也不方便，特别是在身边没有电脑的情况。&lt;/p&gt;
&lt;p&gt;曾经想过使用HTML 远程调用，又担心安全，使用了微信公众号发消息，也特适合这个场景，但是因为微信更新，私信入口现在非常难进入。所以就想能否通过自己的小程序实现远程控制和鉴权。构建一个通用的执行引擎，通过小程序远程触发，且具备零前端修改（免于小程序上线审核）的扩展能力。&lt;/p&gt;
&lt;h2 id="系统架构设计"&gt;系统架构设计&lt;/h2&gt;
&lt;p&gt;为了实现“明天增加脚本，小程序不发版”的目标，采用了“配置驱动” 模式。即“配置在云端，指令在指尖”。通过将业务逻辑（脚本路径与名称）完全从前端小程序中解耦，实现一套代码支持无限扩展的运维能力。&lt;/p&gt;
&lt;h3 id="核心流程"&gt;核心流程&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;后端 (Go)：维护一个脚本配置列表（数据库或配置文件）。&lt;/li&gt;
&lt;li&gt;前端 (小程序)：启动时请求后端接口，拉取可用脚本列表。&lt;/li&gt;
&lt;li&gt;触发：使用时选择脚本名称，点击执行。&lt;/li&gt;
&lt;li&gt;鉴权：后端校验小程序 OpenID，仅允许本人指令生效。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="技术实现"&gt;技术实现&lt;/h2&gt;
&lt;h3 id="系统分为三层确保安全性与扩展性的统一"&gt;系统分为三层，确保安全性与扩展性的统一：&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;配置层 (Go Config)：在服务器端定义脚本的 ID、名称和实际路径。&lt;/li&gt;
&lt;li&gt;鉴权层 (WeChat Auth)：利用微信小程序 OpenID 建立强一致性的身份白名单。&lt;/li&gt;
&lt;li&gt;展示层 (Mini Program UI)：动态拉取后端配置，仅负责“展示列表”与“触发指令”。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="技术实现方案"&gt;技术实现方案&lt;/h3&gt;
&lt;p&gt;A. 后端：动态脚本引擎 (Go)&lt;/p&gt;
&lt;p&gt;后端不再硬编码脚本路径，而是定义一个结构体：&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// 脚本任务定义&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;type&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;ScriptTask&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;struct&lt;/span&gt; {
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;ID&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;string&lt;/span&gt; &lt;span style="color:#e6db74"&gt;`json:&amp;#34;id&amp;#34;`&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 前端传递的任务标识&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Name&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;string&lt;/span&gt; &lt;span style="color:#e6db74"&gt;`json:&amp;#34;name&amp;#34;`&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 小程序界面显示的文字&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Command&lt;/span&gt; &lt;span style="color:#66d9ef"&gt;string&lt;/span&gt; &lt;span style="color:#e6db74"&gt;`json:&amp;#34;-&amp;#34;`&lt;/span&gt; &lt;span style="color:#75715e"&gt;// 实际执行的脚本路径 (对前端保密)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// 示例配置（可存放在 JSON 文件或数据库中）&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;var&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;tasks&lt;/span&gt; = []&lt;span style="color:#a6e22e"&gt;ScriptTask&lt;/span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#a6e22e"&gt;ID&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;hugo-post&amp;#34;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;Name&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;发布 Hugo 文章&amp;#34;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;Command&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;/scripts/deploy_hugo.sh&amp;#34;&lt;/span&gt;},
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#a6e22e"&gt;ID&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;build-client&amp;#34;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;Name&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;构建客户端&amp;#34;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;Command&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;/scripts/build_mole_go.sh&amp;#34;&lt;/span&gt;},
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; {&lt;span style="color:#a6e22e"&gt;ID&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;nginx-restart&amp;#34;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;Name&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;重启 Nginx 服务&amp;#34;&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;Command&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;systemctl restart nginx&amp;#34;&lt;/span&gt;},
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;}
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;对外接口定义：&lt;/p&gt;</description></item><item><title>实战笔记：实现一个语音验证码远程呼叫版本</title><link>https://blog.91demo.top/voice-call/</link><pubDate>Tue, 13 Jan 2026 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/voice-call/</guid><description>&lt;p&gt;在实现了主动使用VOIP客户端拨打&lt;code&gt;8000&lt;/code&gt;号码播报语音验证码的功能后，我发现了一个最大的缺点，就是这需要用户主动去操作。这对于想使用API集成的第三方应用来说根本无法实现。&lt;/p&gt;
&lt;p&gt;在思考之后，我决定实现一个可以调用API就呼叫VOIP客户端的版本，当呼叫用户并且用户接通后，自动播报语音验证码的功能。当实现这个功能后，它的好处是显而易见的。比如，可以集成到嵌入式，集成到第三方网站，直接调用API就可以呼叫出去。&lt;/p&gt;
&lt;p&gt;那么该如何实现它呢？&lt;/p&gt;
&lt;h2 id="一-系统原理"&gt;一、 系统原理&lt;/h2&gt;
&lt;p&gt;传统的拨号方案（Dialplan）是静态的，而 ARI 允许我们动态控制。整个“API 触发呼叫并播报”的流程如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;触发阶段：第三方系统通过 API 向 Go 服务发送呼叫请求（包含目标 ID 和验证码）。&lt;/li&gt;
&lt;li&gt;呼叫发起（Originate）：Go 服务调用 Asterisk ARI 的 /channels 接口。此时 Asterisk 会尝试向 PJSIP 终端（或通过中继向手机）发起呼叫。&lt;/li&gt;
&lt;li&gt;接通监听（Stasis Start）：一旦用户接起电话，该通道会被移交给一个名为 Stasis 的应用。此时 Go 服务会收到一个“通道已接通”的 WebSocket 事件。&lt;/li&gt;
&lt;li&gt;语音合成与播放：Go 服务识别到接通后，调用播报指令（可以播放预录音文件，或对接 TTS 引擎生成的语音流）。&lt;/li&gt;
&lt;li&gt;挂断处理：播报完毕后，服务发送挂断指令，释放资源。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="二系统架构"&gt;二、系统架构&lt;/h2&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;[第三方API] --&amp;gt; [Go 后端服务] --(REST API)--&amp;gt; [Asterisk ARI]
| |
(WebSocket) (PJSIP/IMS)
| |
[接通状态回调] &amp;lt;--- [用户终端接听]
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="三-核心代码实现-golang"&gt;三、 核心代码实现 (Golang)&lt;/h2&gt;
&lt;p&gt;这里使用了 GitHub 上的 &lt;code&gt;go-ari&lt;/code&gt; 库。&lt;/p&gt;
&lt;h3 id="1-初始化-ari-客户端"&gt;1. 初始化 ARI 客户端&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#f92672"&gt;import&lt;/span&gt; (
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;github.com/v5&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#e6db74"&gt;&amp;#34;github.com/v5/client/native&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;)
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;// 连接到 Asterisk ARI&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;cl&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;err&lt;/span&gt; &lt;span style="color:#f92672"&gt;:=&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;native&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Connect&lt;/span&gt;(&lt;span style="color:#f92672"&gt;&amp;amp;&lt;/span&gt;&lt;span style="color:#a6e22e"&gt;native&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;Options&lt;/span&gt;{
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Application&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;voice-verify&amp;#34;&lt;/span&gt;, &lt;span style="color:#75715e"&gt;// 必须与 asterisk.conf 配置一致&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Username&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;admin&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;Password&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;password&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt; &lt;span style="color:#a6e22e"&gt;URL&lt;/span&gt;: &lt;span style="color:#e6db74"&gt;&amp;#34;http://localhost:8088/ari&amp;#34;&lt;/span&gt;,
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;})
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;h3 id="2-实现呼叫并播报语音验证码"&gt;2. 实现呼叫并播报语音验证码&lt;/h3&gt;
&lt;p&gt;这是核心逻辑：接收参数 -&amp;gt; 发起呼叫 -&amp;gt; 监听接通 -&amp;gt; 播放语音。&lt;/p&gt;</description></item><item><title>实战笔记：实现一个语音验证码自助拨打版本</title><link>https://blog.91demo.top/voice-code/</link><pubDate>Fri, 19 Aug 2022 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/voice-code/</guid><description>&lt;p&gt;自助语音验证码是通过拨打VOIP电话自动获取验证码，验证码可用来豆子笔记网站认证。这个纯粹是自己的兴趣驱动的。掌握了此项技术，你也可以应用于其它地方。&lt;/p&gt;
&lt;p&gt;我是使用Asterisk实现的，Asterisk 是一个流行的开源通信平台，它提供了构建各种通信应用的灵活而强大的解决方案。它被广泛应用于企业内部电话系统、呼叫中心、语音邮件等场景。Asterisk 支持多种通信协议，包括 SIP、H.323、MGCP 和 SCCP，并且能够与传统的 PSTN 线路深度兼容。此外，Asterisk 还支持通过 Inter-Asterisk eXchange (IAX)协议进行语音 IP 传输，允许数据和语音同时在网络上传输。&lt;/p&gt;
&lt;h2 id="自助语音验证码使用说明"&gt;自助语音验证码使用说明&lt;/h2&gt;
&lt;p&gt;你需要准备：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;软电话，或者支持 VOIP 的电话机&lt;/li&gt;
&lt;li&gt;AST 账户，AST 账户从豆子工具小程序中获取，功能菜单为获取 AST 账户。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;注意：该功能目前已经下架，这里仅作为备忘记录。&lt;/p&gt;
&lt;p&gt;首先，使用获取到的账户配置你的电话。然后拨打 8000，即可自动获取验证码。如果您有什么问题，欢迎关注公众号【技术源泉】私信我。&lt;/p&gt;
&lt;h2 id="技术实现"&gt;技术实现&lt;/h2&gt;
&lt;p&gt;它的工作原理是：用户通过 VOIP 电话连接到 Asterisk，当拨打固定的数字 8000时，Asterisk 将调用 AGI 接口获取服务端的API生成验证码，然后将获取到的验证码拆分通过语音文件播报给用户，播报完毕后自动挂断。&lt;/p&gt;
&lt;p&gt;我们根据自助语音验证码原理，将自助验证码实现分解为以下几个技术要点，只要我们解决以下技术要点，就可以实现自助语音验证码。&lt;/p&gt;
&lt;p&gt;技术要点：&lt;/p&gt;
&lt;p&gt;1，如何播放音频文件？音频文件从哪里来？如何播放动态数据？&lt;br&gt;
2，如何存储用户信息？如何使用数据库存储用户信息？&lt;br&gt;
3，如何获取验证码？&lt;/p&gt;
&lt;p&gt;下面内容是我对各个技术点的对应解决方案。&lt;/p&gt;
&lt;p&gt;1，Asterisk 自带 Playback 应用，可以通过它播放音频文件。音频文件需要我们提前录制好，并且转换为对应的音频格式，最简单的方法就是使用手机上的录音机。当在拨号计划中多次执行 Playback 应用，Asterisk 会将音频流自动连接起来。所以我们可以使用循环多次执行 Playback 应用即可。&lt;br&gt;
2，Asterisk 使用配置文件写入 SIP 用户信息，但当写入新的 SIP 用户信息后，需要重新加载配置文件。为了方便和第三方对接，我们推荐使用数据库。Asterisk 支持数据库，并且可以实时获取用户信息。&lt;br&gt;
3，Asterisk 支持 AGI 接口，我们可以使用 AGI 获取第三方应用的验证码，获取后和提前录制好的文件结合起来进行播放。&lt;/p&gt;
&lt;p&gt;当掌握了这些技术点后，我们就可以灵活应用到其它解决方案。&lt;/p&gt;
&lt;p&gt;下面我们来看具体实现：&lt;br&gt;
1，音频文件处理。我们使用手机上的录音机来录制音频文件。Android 录音机录制的音频文件格式为 mp3，如果是 amr 格式，请使用豆子工具音频格式转换功能，转成 mp3 格式文件。IOS 录音机录制的音频文件格式为 m4a，请使用豆子工具音频格式转换功能，转成 mp3 格式文件。我们还需要使用 ffmpeg 将 mp3 文件转成 g711a 格式文件。这个 mp3 转 g711a 功能也可以在豆子工具中使用。&lt;/p&gt;</description></item><item><title>实战笔记：当我想查端口却没装 Telnet 时，我决定自己写个工具</title><link>https://blog.91demo.top/check-port/</link><pubDate>Mon, 05 Feb 2018 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/check-port/</guid><description>&lt;p&gt;作为开发者，你一定遇到过这种“抓狂”的时刻：&lt;/p&gt;
&lt;p&gt;某次我急需确认服务器上的一个端口是否正常开启，习惯性地打开 Windows 命令行准备敲下 &lt;code&gt;telnet&lt;/code&gt; 命令。结果弹出的却是：“telnet 不是内部或外部命令”。&lt;/p&gt;
&lt;p&gt;由于系统刚重装，这个组件还没勾选。当我从控制面板找到它、安装、甚至可能还要重启系统时，这套复杂的操作让我陷入了反思：&lt;strong&gt;为了检测一个端口，至于这么麻烦吗？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;更进一步想，如果我手头没有电脑，只有一部手机，我该如何快速判断服务器防火墙是不是忘了开？&lt;/p&gt;
&lt;h3 id="痛点环境依赖与生态限制"&gt;痛点：环境依赖与生态限制&lt;/h3&gt;
&lt;p&gt;在实现这个功能之前，我有过两个思考维度：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;环境依赖&lt;/strong&gt;：无论是 Windows 的 Telnet 还是 Linux 的 &lt;code&gt;nc&lt;/code&gt;，都依赖当前操作系统的环境配置。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;小程序限制&lt;/strong&gt;：微信小程序虽然有网络请求能力，但它必须配置服务器域名白名单。如果你想直接用小程序前端去探测一个随机的 IP 或端口，微信的底层安全机制是不允许的。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="方案go-后端代劳小程序只做遥控器"&gt;方案：Go 后端代劳，小程序只做“遥控器”&lt;/h3&gt;
&lt;p&gt;为了绕过这些限制，我在“豆子工具”里实现了一个&lt;strong&gt;远程端口检测&lt;/strong&gt;功能。它的逻辑非常直接且高效：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;前端交互&lt;/strong&gt;：用户只需在小程序里输入目标服务器的 &lt;strong&gt;IP/域名&lt;/strong&gt; 和 &lt;strong&gt;端口号&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后端核心&lt;/strong&gt;：数据提交到我用 &lt;strong&gt;Go&lt;/strong&gt; 编写的后端服务。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;模拟连接&lt;/strong&gt;：后端服务代替用户执行 TCP 连接探测。Go 语言的 &lt;code&gt;net.DialTimeout&lt;/code&gt; 在这里非常实用，可以精准控制探测时间，避免由于网络超时导致的页面死等。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结果反馈&lt;/strong&gt;：后端将“连接成功”或“连接失败”的结果返回给小程序展示。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img alt="服务端口检测" loading="lazy" src="https://blog.91demo.top/images/wander/tool-chkport.webp"&gt;&lt;/p&gt;
&lt;h3 id="价值随时随地的运维眼"&gt;价值：随时随地的“运维眼”&lt;/h3&gt;
&lt;p&gt;自从这个功能上线后，我解决了很多尴尬的场景：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;排查防火墙&lt;/strong&gt;：在云厂商后台改了安全组策略后，掏出手机点一下，秒知是否生效。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;现场交付&lt;/strong&gt;：在客户现场没有电脑时，快速确认后端服务是否已经拉起。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;零部署成本&lt;/strong&gt;：再也不用担心当前电脑有没有装 Telnet 或 Netcat。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="技术实现"&gt;技术实现&lt;/h3&gt;
&lt;p&gt;这里的难点在于服务器端实现，小程序端仅仅提交FORM表单就可以了。下面我们看看服务端的Go的核心实现：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;// 检测端口是否被打开？
func IsPortOpened(ip string, port int) bool {
addr := net.JoinHostPort(ip, fmt.Sprintf(&amp;#34;%d&amp;#34;, port))
conn, err := net.DialTimeout(&amp;#34;tcp&amp;#34;, addr, time.Second*3)
if err != nil {
return false
}
if conn != nil {
conn.Close()
return true
}
return false
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;这里仅能探测TCP端口，UDP端口我还没有思路，等有思路了我再在这篇文章里补充。&lt;/p&gt;</description></item><item><title>实战笔记：为了管好那堆记不住的密码，我给自己写了个全加密密码本</title><link>https://blog.91demo.top/tool-pwbook/</link><pubDate>Thu, 11 Jan 2018 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/tool-pwbook/</guid><description>&lt;p&gt;虽然现在的 App 普遍支持手机号或微信一键登录，但对于开发者来说，GitHub、服务器、各类海外服务的登录依然离不开“邮箱+密码”的传统模式。&lt;/p&gt;
&lt;p&gt;我曾经历过一段漫长的“密码管理进化史”：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Excel 时代&lt;/strong&gt;：最早用一个加密的 Excel 表格记录，在电脑端还凑合。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Flutter App 时代&lt;/strong&gt;：为了手机查看方便，我曾写过一个简单的 Flutter App，支持指纹查看，但功能简陋，只有新增功能，连“修改”功能都没有。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;直到有一次，GitHub 要求我重新登录，由于我频繁重置密码且新旧密码不能重复，导致我彻底记混了。在另一台电脑前尝试了无数次失败后，我意识到：&lt;strong&gt;我需要一个足够安全、随时可用、功能完整的个人密码管理工具。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;于是，“豆子工具”里的&lt;strong&gt;小程序版密码本&lt;/strong&gt;诞生了。&lt;/p&gt;
&lt;p&gt;&lt;img alt="我的密码本" loading="lazy" src="https://blog.91demo.top/images/wander/tool-pwbook.webp"&gt;&lt;/p&gt;
&lt;h3 id="1-设计核心绝对的隐私与自由"&gt;1. 设计核心：绝对的隐私与自由&lt;/h3&gt;
&lt;p&gt;在设计之初，我就定下了两个原则：&lt;strong&gt;不联网、重加密。&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;纯离线运行&lt;/strong&gt;：为了打消用户（包括我自己）对隐私的顾虑，我砍掉了所有联网备份功能。所有的密码数据仅存储在手机本地，备份只能通过聊天文件导出。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;三重加密体系&lt;/strong&gt;：这是我花费心血最多的地方。主密码（Master Password）不存储在设备上。
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;第一步&lt;/strong&gt;：用主密码解密 &lt;strong&gt;RSA 私钥&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第二步&lt;/strong&gt;：用私钥解密 &lt;strong&gt;AES 密钥&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;第三步&lt;/strong&gt;：用 AES 密钥解密具体的 &lt;strong&gt;JSON 序列化记录&lt;/strong&gt;。&lt;br&gt;
这种混合加密机制确保了即使手机丢失，只要主密码不泄露，数据依然是安全的。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-攻克开发中的大山"&gt;2. 攻克开发中的“大山”&lt;/h3&gt;
&lt;p&gt;这个功能的开发过程远比我想象中痛苦。尤其是在小程序环境下处理&lt;strong&gt;文件缓存、二进制流转换和加解密逻辑&lt;/strong&gt;，经常一个 Bug 就要调试好几天。&lt;/p&gt;
&lt;p&gt;中途我好几次想过放弃，觉得用 Excel 也可以凑合。但每次想到反复重置密码的痛苦，还是咬牙坚持了下来。为了稳定性，我将原本不稳定的二进制存储改为了 UTF-8 编码的 JSON 序列化，最终实现了丝滑的操作体验，这都是心血的教训。&lt;/p&gt;
&lt;h3 id="3-功能完善不仅仅是存一下"&gt;3. 功能完善：不仅仅是“存一下”&lt;/h3&gt;
&lt;p&gt;相比之前的 Flutter 版本，这次我补全了所有短板：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;增删改查&lt;/strong&gt;：支持搜索功能，输入标题就能快速定位账号。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;备份恢复&lt;/strong&gt;：加入了完整的备份机制，更换手机时可以轻松迁移数据。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;批量操作&lt;/strong&gt;：支持导入和导出，方便从其他平台平替过来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;密码提示&lt;/strong&gt;：为了防范“忘记主密码”这个终极灾难，我加入了密码提示功能（建议设一个只有自己懂的暗语）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="4-字段灵活满足多样需求"&gt;4. 字段灵活，满足多样需求&lt;/h3&gt;
&lt;p&gt;每一条记录都包含了：&lt;strong&gt;标题、URL、用户名、密码、备注&lt;/strong&gt;。&lt;br&gt;
无论是一个服务器的 SSH 密码，还是一个冷门网站的登录信息，都能井井有条地分类存放。&lt;/p&gt;
&lt;h3 id="5-核心代码展示"&gt;5. 核心代码展示&lt;/h3&gt;
&lt;p&gt;纯小程序本地原生实现，首先是密码本初始化，它会根据用户输入的主密码，生成密码本文件。&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;// 第一次使用设置方法
async setupFirstTime(masterPassword, passwordHint) {
try {
// 1. 检查是否已有密码本
if (!passwordManager.hasPasswordBook()) {
console.log(&amp;#39;1. 创建空密码本...&amp;#39;);
const createResult = await passwordManager.createEmptyPasswordBook(masterPassword, passwordHint);
if (createResult.success) {
console.log(&amp;#39;✓&amp;#39;, createResult.message);
} else {
console.log(&amp;#39;创建失败:&amp;#39;, createResult.error);
wx.showToast({
title: createResult.error,
icon: &amp;#39;none&amp;#39;
});
return;
}
}
} catch (err) {
console.log(&amp;#39;创建密码本错误，&amp;#39;, err)
}
// 保存主密码安装和提示
wx.setStorageSync(&amp;#39;pwbookInstall&amp;#39;, new Date().toLocaleString());
wx.setStorageSync(&amp;#39;passwordHint&amp;#39;, passwordHint || &amp;#39;&amp;#39;);
wx.setStorageSync(&amp;#39;pwbookBakTime&amp;#39;, getSecTs());
this.setData({
isFirstTime: false,
isUnlocked: true,
showPasswordDialog: false
});
wx.showToast({
title: &amp;#39;初始化成功&amp;#39;,
icon: &amp;#39;success&amp;#39;
});
this.loadPasswordList();
},
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;创建密码本文件核心代码：&lt;/p&gt;</description></item><item><title>实战笔记：为了不再漏掉任何一个域名到期提醒，我做了个自动化检测工具</title><link>https://blog.91demo.top/check-domain/</link><pubDate>Wed, 10 Jan 2018 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/check-domain/</guid><description>&lt;p&gt;记录如何通过 Go 语言实现多域名 SSL 证书到期自动巡检，并通过钉钉/企业微信机器人实现精准预警。&lt;/p&gt;
&lt;p&gt;在运维工作中，有一类事故极其低级却又杀伤力巨大：&lt;strong&gt;SSL 证书过期。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="痛点被动挨打的救火模式"&gt;痛点：被动挨打的“救火”模式&lt;/h3&gt;
&lt;p&gt;由于业务需要，我手里管理着大量客户的域名。每个客户购买证书的渠道各异（阿里云、腾讯云或其他厂商），证书下来后通过微信或邮件发给运维，再手动配置到服务器上。&lt;/p&gt;
&lt;p&gt;这套流程在客户少的时候还算正常。但随着客户增多，问题出现了：&lt;strong&gt;证书到期时间不一，全靠人工记忆。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;总会有那么一两次，因为忙碌或者交接疏忽，某个域名证书悄悄过期了。直到客户反馈 APP 无法访问、浏览器弹出红色的安全告警，我们才急忙去“救火”。这种被动的局面不仅影响专业度，也给客户带来了实际损失。&lt;/p&gt;
&lt;h3 id="方案主动出击的自动化巡检"&gt;方案：主动出击的自动化巡检&lt;/h3&gt;
&lt;p&gt;为了彻底根治这个“心病”，我决定做一个自动化的域名证书检测工具。&lt;/p&gt;
&lt;p&gt;我的设想很简单：&lt;strong&gt;变“人找信息”为“信息找人”。&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id="1-实现原理"&gt;1. 实现原理&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;配置简单化&lt;/strong&gt;：将所有需要监测的域名汇总成一个 &lt;code&gt;txt&lt;/code&gt; 文件，每行一个域名，管理起来极其方便。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;核心引擎（Go）&lt;/strong&gt;：使用 Go 语言开发后端服务，利用 &lt;code&gt;cron&lt;/code&gt; 库开启定时任务，设定每天固定时间（如凌晨 4 点）执行一次巡检。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;检测逻辑&lt;/strong&gt;：程序自动循环读取域名列表，通过 TLS 握手获取证书的有效载荷，计算当前的剩余天数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;精准预警&lt;/strong&gt;：我设定了一个“7天阈值”。一旦发现有域名将在 7 天内过期，程序会立即将这些域名汇总，并通过企微或者钉钉送达。&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="2-消息触达为什么选择机器人"&gt;2. 消息触达：为什么选择机器人？&lt;/h4&gt;
&lt;p&gt;在小程序中，邮箱属于敏感隐私资料，审核往往比较严格。为了避开这个麻烦，同时也为了让通知更具实时性，我选择了&lt;strong&gt;钉钉机器人&lt;/strong&gt;和&lt;strong&gt;企业微信机器人&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;管理员或运营人员只需将机器人的 &lt;code&gt;Webhook&lt;/code&gt; 地址配置好，每天早上一上班，就能在手机上收到一份清晰的到期清单。&lt;/p&gt;
&lt;p&gt;&lt;img alt="域名证书检测" loading="lazy" src="https://blog.91demo.top/images/wander/tool-chkdomain.webp"&gt;&lt;/p&gt;
&lt;h3 id="价值买到了最宝贵的时间"&gt;价值：买到了最宝贵的“时间”&lt;/h3&gt;
&lt;p&gt;这个功能上线后，我们最直接的收获就是**“沟通时间”**：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;提前预判&lt;/strong&gt;：有了 7 天的缓冲期，运营人员可以气定神闲地与客户沟通续费。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;提前操作&lt;/strong&gt;：运维人员有了充裕的时间更换新证书，彻底杜绝了“半夜修证书”的尴尬。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="技术实现"&gt;技术实现&lt;/h3&gt;
&lt;p&gt;整个域名检测分为两部分：小程序端和服务端，小程序端负责提交域名信息以及Webhook（钉钉、企微）信息。以及查看导入的域名。服务端需要存储域名以及Webhook。并在每天的固定时刻进行域名巡检，当域名证书有效期小于7天时，将发送通知给管理员。&lt;/p&gt;
&lt;p&gt;小程序端主要是常规的信息提交，我们来看下后端的Go代码：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;// 域名证书检测计划任务，每天固定6点执行
func CronCertCheck() {
logger.Info(&amp;#34;启动域名证书检测计划任务&amp;#34;)
// 获取用户webhook，有webhook则进行域名检测
webhooks, err := dao.GetAllWebhook()
if err != nil {
logger.Error(&amp;#34;计划任务域名证书检测加载Webhooks错误，&amp;#34;, err)
return
}
// 循环扫描
for _, v := range webhooks {
uid := v.Uid
url := v.Url
ht := v.HookType
// 获取单个用户的域名
dbDomains, err := dao.GetWDomainByUid(uid)
if err != nil {
logger.Error(uid, &amp;#34;计划任务域名证书检测获取域名列表错误，&amp;#34;, err)
continue
}
if len(dbDomains) == 0 {
logger.Error(uid, &amp;#34;计划任务域名证书检测用户域名数量为0&amp;#34;)
continue
}
// 解密URL地址为WEBHOOK
password := fmt.Sprintf(&amp;#34;domain:%d:%d&amp;#34;, uid, ht)
webhook, err := cryptoutil.AesDecryptWithPassword(url, password)
if err != nil {
logger.Error(uid, &amp;#34;计划任务域名证书检测webhook解密失败，&amp;#34;, err)
continue
}
// 获取域名列表，需要去重，用户是可以上传重复域名的。
domains := make([]string, 0)
domainMap := make(map[string]bool, 0)
for _, n := range dbDomains {
name := n.Name
isExist := domainMap[name]
if isExist {
continue
}
domains = append(domains, name)
domainMap[name] = true
}
// 开始批次检测，并将检测结果返回
chkResult := CheckDomainsCert(domains, nil)
// 如果结果不为空，则发送webhook通知
if len(chkResult) &amp;gt; 0 {
notifyCfg := NotifyConfig{
Type: ht,
URL: webhook,
RetryTimes: 3,
RetryDelay: 5,
}
result := NotifyContent{
Title: &amp;#34;以下域名的SSL证书将在7天内过期:&amp;#34;,
Domains: chkResult,
}
go SendWebhookNotificationWithRetry(notifyCfg, result)
}
// 休息1秒
time.Sleep(1 * time.Second)
}
logger.Info(&amp;#34;域名证书检测处理：&amp;#34;, len(webhooks))
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;在小程序上线后，用户使用极少，我不知道具体原因，可能是不信任功能，Webhook不想暴露，域名不想暴露？我又开发了客户端工具，纯本地运行，使用Wails3开发，是图形化桌面客户端，域名导入和通知都是在本地运行。并且增加了很多功能。例如支持HTTP/3，支持系统托盘，支持开启启动，支持导入更多域名，支持更多Webhook通知，监控面板更精细等。&lt;/p&gt;</description></item><item><title>实战笔记：为了不再发错下载链接，我给工具箱加了“扫码鉴定”</title><link>https://blog.91demo.top/tool-scan/</link><pubDate>Tue, 09 Jan 2018 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/tool-scan/</guid><description>&lt;p&gt;小工具可以解决大问题。在软件发布流程中，最尴尬的事情莫过于：宣传图已经发出去了，用户扫码后却发现链接打不开，或者下载了一个旧版本的安装包。&lt;/p&gt;
&lt;p&gt;我就亲身经历过这么一次“翻车”事故。&lt;/p&gt;
&lt;h3 id="事故现场一个-url-引起的麻烦"&gt;事故现场：一个 URL 引起的麻烦&lt;/h3&gt;
&lt;p&gt;有一次，我在更新软件下载页面后，由于换了一个新的 URL 地址，在生成二维码阶段没有进行严格校验，就直接把二维码发布了出去。&lt;/p&gt;
&lt;p&gt;结果当用户兴冲冲地扫码下载时，发现软件运行异常。我对比了半天才发现，生成二维码时填写的 URL 链接版本号写错了一个字符，导致用户下载到了一个有问题的版本，这是偶然中的极端。&lt;/p&gt;
&lt;p&gt;虽然这只是一个低级错误，但它让我意识到：&lt;strong&gt;在二维码挂载后、正式发布前，必须有一个极简的“内容鉴定”环节。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="痛点如何快速无痛地校验"&gt;痛点：如何快速、无痛地校验？&lt;/h3&gt;
&lt;p&gt;通常我们校验二维码，习惯性地直接拿手机扫一下。但如果二维码指向的是一个大容量的 APP 下载包或复杂的跳转链接，扫码后手机会自动触发下载或进入复杂的网页路径。&lt;/p&gt;
&lt;p&gt;我其实并不想真的下载并安装测试（那是后续的 QA 环节），我只是想&lt;strong&gt;一眼看到二维码里到底写了什么字符&lt;/strong&gt;，确定那个 URL 是不是我想要的那一个。&lt;/p&gt;
&lt;h3 id="实现借力小程序的原生能力"&gt;实现：借力小程序的原生能力&lt;/h3&gt;
&lt;p&gt;既然发现了痛点，解决起来就非常顺手了。得益于微信生态对扫码的深度支持，我在“豆子工具”里实现了一个“二维码识别”功能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;核心逻辑&lt;/strong&gt;：直接调用小程序的 &lt;code&gt;wx.scanCode&lt;/code&gt; 接口。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;功能表现&lt;/strong&gt;：不仅支持扫描实物二维码，还支持从手机相册里直接读取生成的二维码图片，还支持小程序码扫描，支持查看页面路径和scene值。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结果反馈&lt;/strong&gt;：系统不会触发自动跳转，而是将二维码包含的原始文本、URL、一维码内容直接以&lt;strong&gt;纯文本&lt;/strong&gt;的形式展示在屏幕上。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="价值多看一眼少出一次错"&gt;价值：多看一眼，少出一次错&lt;/h3&gt;
&lt;p&gt;现在，每当我生成一个新的二维码，无论是用于软件下载、文档分享还是活动跳转，我都会习惯性地用自己的工具“扫一下”：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;确认 URL 参数是否完整。&lt;/li&gt;
&lt;li&gt;确认是否有肉眼难以察觉的拼写错误。&lt;/li&gt;
&lt;li&gt;确认二维码的类型（一维码还是二维码）是否符合预期。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img alt="识别二维码" loading="lazy" src="https://blog.91demo.top/images/wander/tool-scan.webp"&gt;&lt;/p&gt;
&lt;h3 id="技术实现"&gt;技术实现&lt;/h3&gt;
&lt;p&gt;使用小程序纯原生实现，仅仅做一个页面展示即可。&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;scanQRCode: function () {
const that = this;
wx.showLoading({
title: &amp;#39;识别中...&amp;#39;,
mask: true,
})
wx.scanCode({
onlyFromCamera: false,
scanType: [&amp;#39;qrCode&amp;#39;, &amp;#39;barCode&amp;#39;, &amp;#39;datamatrix&amp;#39;, &amp;#39;pdf417&amp;#39;, &amp;#39;wxCode&amp;#39;],
success: (res) =&amp;gt; {
wx.hideLoading()
if(res.scanType==&amp;#39;WX_CODE&amp;#39;){
that.setData({
scanResult: res.path
});
}else{
that.setData({
scanResult: res.result
});
}
},
fail: (err) =&amp;gt; {
wx.hideLoading()
console.error(err);
}
});
},
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;需要注意的是，小程序码和其它码的结果字段有点不同，所以需要区分，以便取到正确的值。&lt;/p&gt;</description></item><item><title>实战笔记：从一个按钮到全能生成器，随机数功能的“进化论”</title><link>https://blog.91demo.top/tool-random/</link><pubDate>Sun, 07 Jan 2018 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/tool-random/</guid><description>&lt;p&gt;这个工具是我的图腾，还记得我之前提到的吗？2018 年，“豆子工具”的初版只有一个功能：点一下按钮，生成一个随机数。&lt;/p&gt;
&lt;p&gt;那时候的代码逻辑极其简单，甚至算不上一个“工具”。但随着这几年自己在开发、运维过程中不断遇到“需要一个复杂密码”、“需要一个 16 位纯金钥”或者“需要一个全大写 ID”等实际场景，我发现&lt;strong&gt;简单的随机数其实并不简单&lt;/strong&gt;。&lt;/p&gt;
&lt;h3 id="痛点单一随机数的局限"&gt;痛点：单一随机数的局限&lt;/h3&gt;
&lt;p&gt;早期的随机数功能非常死板，生成的格式往往不是我想要的。每次生成后，我可能还需要手动去改长度、改大小写，甚至手动补上特殊字符。&lt;/p&gt;
&lt;p&gt;作为一个开发者，如果一个工具不能让我“一键到位”，那就是不合格的。&lt;/p&gt;
&lt;h3 id="进化高度自定义的融合模式"&gt;进化：高度自定义的“融合模式”&lt;/h3&gt;
&lt;p&gt;于是，我把这些年所有关于“随机”的需求全部揉碎、重组，将它升级成了一个全能的生成器。&lt;/p&gt;
&lt;p&gt;现在的随机数功能，不再是盲目地随机，而是&lt;strong&gt;基于规则的精准生成&lt;/strong&gt;。我为其设计了一套组合逻辑：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;长度自定义&lt;/strong&gt;：不再局限于几位数字，用户可以根据需求自由输入长度。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;字符集自由组合&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;纯数字&lt;/strong&gt;：适用于验证码类场景。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;纯字母&lt;/strong&gt;：适用于临时 ID 或变量命名。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;字母+数字&lt;/strong&gt;：兼顾强度与易读性。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;含特殊字符&lt;/strong&gt;：专门为高强度随机密码设计。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;结果格式化&lt;/strong&gt;：
&lt;ul&gt;
&lt;li&gt;支持结果一键&lt;strong&gt;转大写&lt;/strong&gt;或&lt;strong&gt;转小写&lt;/strong&gt;。这在配置某些特定系统的 API Key 时非常有用，省去了手动切换输入法的麻烦。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;我经常使用它生成一些随机数作为密码，密钥。我不用去数长度，或者生成后，我简单调整一下，让它有一点意义，我再去使用。&lt;/p&gt;
&lt;p&gt;它使用小程序本地实现，下面是一段核心代码，描述了如何去实现随机数：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;// 生成随机字符串
genRndStr(rtype) {
const that = this;
if (utils.isEmpty(that.data.input1)) {
wx.showToast({
title: &amp;#39;内容为空&amp;#39;,
})
return
}
let result = &amp;#39;&amp;#39;;
switch (rtype) {
case 1:
// 纯数字
result = utils.randNumber(that.data.input1);
break;
case 2:
// 纯字母
result = utils.randLetter(that.data.input1);
break;
case 3:
// 字母和数字
result = utils.randStr(that.data.input1);
break;
case 4:
// 含特殊字符
result = utils.randSymbol(that.data.input1);
break;
}
that.setData({
result1: result
})
},
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="思考小功能里的产品观"&gt;思考：小功能里的“产品观”&lt;/h3&gt;
&lt;p&gt;虽然随机数在技术实现上只是简单的字符数组随机采样，但从产品角度看，它体现了一种**“减少用户操作”**的原则。&lt;/p&gt;</description></item><item><title>实战笔记：知识真的就是金钱，聊聊我的局域网调试工具</title><link>https://blog.91demo.top/debug-tcp/</link><pubDate>Sat, 06 Jan 2018 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/debug-tcp/</guid><description>&lt;p&gt;在“豆子工具”的所有功能中，&lt;strong&gt;局域网调试工具（TCP/UDP/WebSocket）&lt;/strong&gt; 并不是受众最广的，但它却是我最引以为傲的一个。&lt;/p&gt;
&lt;p&gt;因为它让我真切地体会到了一句话：&lt;strong&gt;知识就是力量，知识就是金钱。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="缘起把调试器揣进口袋"&gt;缘起：把调试器揣进口袋&lt;/h3&gt;
&lt;p&gt;做嵌入式开发或者网络协议调试的人都知道，以往测试局域网通信，必须背着电脑，接上串口线或网线，蹲在机柜旁守着。我当时就在想：既然微信小程序已经开放了网络通信的能力，为什么我不能做一个随身携带的调试器呢？&lt;/p&gt;
&lt;p&gt;于是，我深度调用了小程序的 &lt;code&gt;wx.createTCPSocket&lt;/code&gt;、&lt;code&gt;wx.createUDPSocket&lt;/code&gt; 和 &lt;code&gt;WebSocket&lt;/code&gt; API，在“豆子工具”里构建了一个完整的网络测试模块。它支持：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;十六进制（Hex）与 ASCII 码切换&lt;/li&gt;
&lt;li&gt;实时数据日志展示&lt;/li&gt;
&lt;li&gt;局域网内稳定的数据收发&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img alt="TCP调试工具" loading="lazy" src="https://blog.91demo.top/images/wander/tool-tcp.webp"&gt;&lt;/p&gt;
&lt;h3 id="变现从技术分享到第一桶金"&gt;变现：从技术分享到“第一桶金”&lt;/h3&gt;
&lt;p&gt;工具做成后，我并没有藏着掖着，而是把实现原理和核心逻辑整理成文，发布到了&lt;strong&gt;微信开发者社区&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;没过多久，一位用户通过那篇文章联系到了我。他在工业自动化领域遇到了一个难题：需要一套能够定制化采集设备数据的微信小程序工具，而协议正是基于 &lt;strong&gt;Modbus/TCP&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;由于我的“豆子工具”已经打好了坚实的底层基础，我对原有的 TCP 调试模块进行了针对性的修改，迅速就适配出了满足他实际业务场景的采集方案。&lt;/p&gt;
&lt;p&gt;虽然这笔订单带来的“第一桶金”数额并不算惊人，但它对我的意义非凡。它验证了一个逻辑：&lt;strong&gt;当你把一个细分领域的工具做到极致，并愿意分享出去时，价值自然会找上门来。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="技术实现"&gt;技术实现&lt;/h3&gt;
&lt;p&gt;这个工具是纯小程序实现，它调用微信小程序的网络API。&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;doConnServer() {
const that = this;
wx.getNetworkType({
success(res) {
const networkType = res.networkType
if (networkType !== &amp;#39;wifi&amp;#39;) {
wx.showToast({
title: &amp;#39;请打开WIFI&amp;#39;,
})
return
}
that.connTcp();
}
});
},
connTcp() {
const that = this;
if (!utils.isEmpty(t)) {
t.offMessage()
t.offConnect()
t.offError()
t.offClose()
t.close()
t = null;
that.setData({
isConn: false,
tips: &amp;#34;关闭成功&amp;#34;
})
wx.showToast({
title: &amp;#39;关闭成功&amp;#39;,
})
return
} else {
let ip = that.data.ip;
let port = that.data.port;
if (!utils.isValidIP(ip)) {
wx.showToast({
title: &amp;#39;无效IP地址&amp;#39;,
})
return
}
if (!utils.isPrivateIP(ip)) {
wx.showToast({
title: &amp;#39;请使用私有IP&amp;#39;,
})
return
}
if (utils.containInvalidPort(port)) {
wx.showToast({
title: &amp;#39;非法端口&amp;#39;,
})
return
}
t = wx.createTCPSocket()
t.onError(that.tErr)
t.onConnect(that.tConn)
t.onMessage(that.tMsg)
t.onClose(that.tClose)
t.connect({
address: ip,
port: port,
timeout: 3,
})
}
},
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;这是部分核心TCP代码，它是TCP创建连接的核心代码。完整代码请从下面地址中的豆子碎片小程序内下载：&lt;a href="https://91demo.top/tools/"&gt;https://91demo.top/tools/&lt;/a&gt;&lt;/p&gt;</description></item><item><title>实战笔记：为了省下服务器流量费，我给小程序加上了 Webp 转换</title><link>https://blog.91demo.top/image-convert/</link><pubDate>Fri, 05 Jan 2018 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/image-convert/</guid><description>&lt;p&gt;在“流量贵如油”的今天，作为一名自己买服务器、撸代码的站长，如何给服务器“减负”是每天都要思考的必修课。&lt;/p&gt;
&lt;h3 id="痛点博客网站的流量杀手"&gt;痛点：博客网站的流量杀手&lt;/h3&gt;
&lt;p&gt;我有一个运行多年的个人博客。在分析服务器日志时发现，最大的流量开销并不是文字，而是文章里那些高清的图片。&lt;/p&gt;
&lt;p&gt;虽然 JPG 和 PNG 已经很普及，但随着屏幕分辨率越来越高，原图动辄几 MB，对于按带宽付费或者流量计费的服务器来说，这都是白花花的银子。&lt;/p&gt;
&lt;h3 id="方案webp-格式的降维打击"&gt;方案：Webp 格式的“降维打击”&lt;/h3&gt;
&lt;p&gt;现在主流浏览器（Chrome, Safari, Edge 等）已经全面支持 &lt;strong&gt;Webp&lt;/strong&gt; 格式。相比于传统的 JPG 和 PNG，Webp 可以在保证肉眼看不出画质损失的前提下，将文件体积&lt;strong&gt;压缩 30% 到 70%&lt;/strong&gt;。这是一个非常惊人的数据。这意味着原本 100GB 的图床流量，换成 Webp 后可能只需要 30GB。&lt;/p&gt;
&lt;p&gt;为了方便处理博客素材，我决定把这个需求也集成到“豆子工具”里。&lt;/p&gt;
&lt;p&gt;&lt;img alt="图片格式转换" loading="lazy" src="https://blog.91demo.top/images/wander/image-convert.webp"&gt;&lt;/p&gt;
&lt;h3 id="实现基于-google-官方工具链"&gt;实现：基于 Google 官方工具链&lt;/h3&gt;
&lt;p&gt;图片转换的实现原理与我之前的“音频转换”方案异曲同工，主打一个&lt;strong&gt;稳定与高效&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;核心引擎&lt;/strong&gt;：在服务端安装了 Google 官方出品的 &lt;code&gt;webp&lt;/code&gt; 命令行工具链（&lt;code&gt;cwebp&lt;/code&gt;）。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后端调度&lt;/strong&gt;：依然由 &lt;strong&gt;Go&lt;/strong&gt; 语言担任“指挥官”，接收小程序上传的 PNG/JPG 原图，调用外部 &lt;code&gt;cwebp&lt;/code&gt; 进程进行压缩。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;参数优化&lt;/strong&gt;：在后端我预设了平衡性最好的质量参数，确保图片在压缩后的清晰度依然能够满足博客展示的需求。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;闭环应用&lt;/strong&gt;：现在我写每一篇博客前，都会先用小程序把配图过一遍，转成 Webp 后再上传。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;下面是核心代码片段的注解，这是小程序端：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;&amp;lt;page-meta root-font-size=&amp;#34;system&amp;#34; /&amp;gt;
&amp;lt;view class=&amp;#34;page&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;page__hd&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;page__title&amp;#34;&amp;gt;图片格式转换&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;page__desc&amp;#34;&amp;gt;可将图片转换为WebP格式，用于Web项目。&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;page__bd&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-cells weui-cells_radio&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-cell weui-cell_uploader&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-cell__bd&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-uploader&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-uploader__hd&amp;#34;&amp;gt;
&amp;lt;view aria-role=&amp;#34;option&amp;#34; class=&amp;#34;weui-uploader__overview&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-uploader__title&amp;#34;&amp;gt;图片信息&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-uploader__tips&amp;#34;&amp;gt;
请选取5M内的图片，目前仅支持一张
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-uploader__bd&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-uploader__files&amp;#34; id=&amp;#34;uploaderFiles&amp;#34;&amp;gt;
&amp;lt;block wx:for=&amp;#34;{{files}}&amp;#34; wx:key=&amp;#34;*this&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-uploader__file&amp;#34; bindtap=&amp;#34;previewImage&amp;#34; id=&amp;#34;{{item}}&amp;#34;&amp;gt;
&amp;lt;image class=&amp;#34;weui-uploader__img&amp;#34; src=&amp;#34;{{item}}&amp;#34; mode=&amp;#34;aspectFill&amp;#34; /&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/block&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-uploader__input-box&amp;#34;&amp;gt;
&amp;lt;view aria-role=&amp;#34;button&amp;#34; aria-label=&amp;#34;选择图片&amp;#34; class=&amp;#34;weui-uploader__input&amp;#34; bindtap=&amp;#34;chooseImage&amp;#34;&amp;gt;&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-cell&amp;#34;&amp;gt;
&amp;lt;view wx:if=&amp;#34;{{headImageExist}}&amp;#34;&amp;gt;
&amp;lt;image style=&amp;#34;width: 200px; height: 200px; background-color: #eeeeee;&amp;#34; mode=&amp;#34;aspectFit&amp;#34; src=&amp;#34;{{headImage}}&amp;#34; bindtap=&amp;#34;previewMakeImage&amp;#34;&amp;gt;&amp;lt;/image&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view wx:else&amp;gt;&amp;lt;text&amp;gt;转换完成的图片会展示在这里&amp;lt;/text&amp;gt;&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-cell&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;button-sp-area&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-btn weui-btn_primary weui-wa-hotarea&amp;#34; aria-role=&amp;#34;button&amp;#34; bindtap=&amp;#34;imageConvert&amp;#34;&amp;gt;开始转换&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-btn weui-btn_warn weui-wa-hotarea&amp;#34; aria-role=&amp;#34;button&amp;#34; bindtap=&amp;#34;saveToPhotosAlbum&amp;#34;&amp;gt;保存到相册&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;上面是界面骨架，现在说下它的灵魂。首先通过小程序的上传文件API，wx.uploadfile将图片文件上传到服务器，然后使用Go调用cwebp进行格式转换，转换成功后，下载文件到小程序本地，然后删除服务器端的文件。&lt;/p&gt;</description></item><item><title>实战笔记：我把 FFmpeg 搬进小程序，搞定了音频格式转换</title><link>https://blog.91demo.top/audio-convert/</link><pubDate>Thu, 04 Jan 2018 12:40:16 +0800</pubDate><guid>https://blog.91demo.top/audio-convert/</guid><description>&lt;p&gt;在“豆子工具”众多的功能里，&lt;strong&gt;音频转换（m4a 转 mp3）&lt;/strong&gt; 是我使用频率最高、也最具有“个人救赎”色彩的一个。&lt;/p&gt;
&lt;h3 id="起因被软件更新背刺后的郁闷"&gt;起因：被软件更新“背刺”后的郁闷&lt;/h3&gt;
&lt;p&gt;这个功能的由来非常接地气：有一段时间，我需要频繁地将苹果手机录音产生的 &lt;code&gt;m4a&lt;/code&gt; 格式文件转换成 &lt;code&gt;mp3&lt;/code&gt;，因为当时某个必须使用的业务软件只认 &lt;code&gt;mp3&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;那时候我找遍了各种转换工具。最后发现某款主流音乐软件自带的转换功能挺好用。然而，好景不长，在一次软件自动更新后，这个功能竟然被砍掉了。我去搜老版本安装包，却发现根本找不到安全的下载路径。&lt;/p&gt;
&lt;p&gt;那种“被绑架”的无奈感，相信每个工具控都深有体会。&lt;/p&gt;
&lt;h3 id="进阶大名鼎鼎的-ffmpeg"&gt;进阶：大名鼎鼎的 ffmpeg&lt;/h3&gt;
&lt;p&gt;郁闷之后，我转向了技术人的终极方案——&lt;strong&gt;ffmpeg&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;命令行虽然硬核，但确实强大到无以复加。一条简单的指令就能解决所有问题：&lt;br&gt;
&lt;code&gt;ffmpeg -i input.m4a output.mp3&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;用了很长一段时间的命令行后，新的问题又来了，一次在外面，突然用户需要转换音频，我总不能随时随地都带着电脑吧？于是，我动了把 ffmpeg 搬进“豆子工具”的心思。&lt;/p&gt;
&lt;h3 id="实现极简架构下的随时随地"&gt;实现：极简架构下的“随时随地”&lt;/h3&gt;
&lt;p&gt;在小程序里实现这个功能，原理其实并不复杂，核心在于&lt;strong&gt;后端调度&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;前端上传&lt;/strong&gt;：小程序端选择 &lt;code&gt;m4a&lt;/code&gt; 文件，上传至服务器。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;后端处理&lt;/strong&gt;：后端使用 &lt;strong&gt;Go&lt;/strong&gt; 语言接收文件，通过 &lt;code&gt;exec&lt;/code&gt; 模块调用服务器系统环境中的 &lt;code&gt;ffmpeg&lt;/code&gt; 进程进行转换。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;实时试听&lt;/strong&gt;：为了保证体验，我在小程序里集成了一个音频播放器。转换前可以听一下是否选对了文件，转换后也可以即时试听确认效果。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;即用即删&lt;/strong&gt;：转换生成的文件在用户下载后会立即从服务器删除，既保护了隐私，也完全不占用宝贵的服务器存储空间。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;下面是核心代码片段的注解，这是小程序端：&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;&amp;lt;page-meta root-font-size=&amp;#34;system&amp;#34; /&amp;gt;
&amp;lt;view class=&amp;#34;page&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-form&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-form__text-area&amp;#34;&amp;gt;
&amp;lt;h2 class=&amp;#34;weui-form__title&amp;#34;&amp;gt;音频格式转MP3&amp;lt;/h2&amp;gt;
&amp;lt;view class=&amp;#34;weui-form__desc&amp;#34;&amp;gt;因微信限制，保存文件即为分享文件，可通过微信文件传输助手下载文件。目前仅支持以下格式（M4A、WAV、AMR）文件转换为MP3文件。&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-form__control-area&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-cells__group weui-cells__group_form&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-cells weui-cells_radio&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-cell weui-cell_active weui-cell_vcode weui-cell_wrap&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-cell__hd&amp;#34;&amp;gt;&amp;lt;label class=&amp;#34;weui-label&amp;#34;&amp;gt;文件&amp;lt;/label&amp;gt;&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-cell__bd&amp;#34;&amp;gt;
&amp;lt;input class=&amp;#34;weui-cell__control weui-cell__control_flex weui-input&amp;#34; type=&amp;#34;text&amp;#34; placeholder=&amp;#34;请选取5M内的音频&amp;#34; placeholder-class=&amp;#34;weui-input__placeholder&amp;#34; value=&amp;#34;{{filename}}&amp;#34; /&amp;gt;
&amp;lt;view aria-role=&amp;#34;button&amp;#34; class=&amp;#34;weui-cell__control weui-btn weui-btn_default weui-vcode-btn&amp;#34; bindtap=&amp;#34;chooseMedia&amp;#34;&amp;gt;选择&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-cell&amp;#34;&amp;gt;
&amp;lt;view wx:if=&amp;#34;{{audioExist}}&amp;#34;&amp;gt;
&amp;lt;audio name=&amp;#34;{{filename}}&amp;#34; src=&amp;#34;{{filepath}}&amp;#34; id=&amp;#34;myAudio&amp;#34; controls&amp;gt;&amp;lt;/audio&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view wx:else&amp;gt;&amp;lt;text&amp;gt;选择音频文件后可以在这里试听&amp;lt;/text&amp;gt;&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-cell&amp;#34;&amp;gt;
&amp;lt;view wx:if=&amp;#34;{{makeAudioExist}}&amp;#34;&amp;gt;
&amp;lt;audio name=&amp;#34;{{makeAudioName}}&amp;#34; src=&amp;#34;{{makeAudioPath}}&amp;#34; id=&amp;#34;myAudio&amp;#34; controls&amp;gt;&amp;lt;/audio&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view wx:else&amp;gt;&amp;lt;text&amp;gt;转换完成的文件会展示在这里&amp;lt;/text&amp;gt;&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-form__opr-area&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;button-sp-area&amp;#34;&amp;gt;
&amp;lt;view class=&amp;#34;weui-btn weui-btn_primary weui-wa-hotarea&amp;#34; aria-role=&amp;#34;button&amp;#34; bindtap=&amp;#34;audioConvert&amp;#34;&amp;gt;开始转换&amp;lt;/view&amp;gt;
&amp;lt;view class=&amp;#34;weui-btn weui-btn_warn weui-wa-hotarea&amp;#34; aria-role=&amp;#34;button&amp;#34; bindtap=&amp;#34;saveFile&amp;#34;&amp;gt;保存文件&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&amp;lt;/view&amp;gt;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;上面是界面骨架，现在说下它的灵魂。首先通过小程序的上传文件API，wx.uploadfile将音频文件上传到服务器，然后使用Go调用ffmpeg进行格式转换，转换成功后，下载文件到小程序本地，然后删除服务器端的文件。&lt;/p&gt;</description></item></channel></rss>