人生就是一段路，当你觉得费力时，你已经走上坡路了！加油，少年！
心存侥幸“CDN 被盗刷”的故事，网上隔三差五就能看到——一晚上甚至几分钟，几千几万块就没了。 听多了，难免会想：“我一个博客，日访问量破百都难，谁盯得上我？” 结果今年 3 月，短短 5 分钟，我的 CDN 被恶意刷掉 1.55 TB 流量 + 661 万次 HTTPS 请求。说来也巧，去年开发 多吉云上传插件 时，我其实已经做过了时间戳防盗链功能。 但当时头像也放在 CDN 上，如果开了防盗链，别人博客上的头像就会裂开（就像下图这样）。于是线上测试没问题后，我果断……关掉了这个功能。事发3 月 12 日下午，手机突然收到多吉云的短信提醒：赶紧登录控制台一看：1.55 TB 流量。 好在之前设了风控规则，被刷 5 分钟后 CDN 服务自动暂停——要不是这个“封顶”，损失还会大得多。处理1. 封禁 IP 段 在后台查到攻击 IP 全是境外地址：美国、日本、韩国、新加坡等。 用 AI 整理后，在多吉云的访问控制策略里，把这些 IP 段一次性封禁。2. 重新开启 URL 鉴权（时间戳防盗链） 之前关掉的防盗链功能，这次老老实实开了回来。 如果当初没关，这次事件大概率可以避免——当然，也就少了这趟“宝贵的教训”。3. 设定风控策略 结合博客正常的访问量、资源大小和数量，重新设置了：每秒 QPS / IP 限制每小时封顶流量盗刷时携带的特定 UA 直接限制后续算了一下，这次在多吉云的实际损失大约 193 元。多吉云（按量计费）流量：1.55 TB = 1587.2 GB × 0.11 元/GB = 174.59 元请求：(660.54 万 - 200 万) = 460.54 万次 × 0.04 元/万次 = 18.42 元合计：193.01 元我又好奇算了一下：如果去年没换到多吉云，还在用又拍云（而且同样没开防盗链），会是什么代价？又拍云（按量计费）流量：1.55 TB = 1587.2 GB × 0.29 元/GB = 460.29 元请求：660.54 万次 × 0.05 元/万次 = 33.03 元合计：493.32 元多吉云可以买流量包和 HTTPS 包抵扣，最后实际只付了 179 元左右，比又拍云便宜了 300 多。一点感悟老话说得好：人教人，教不会；事教人，一次就够了。无论是运维还是开发，安全这件事，永远不能心存侥幸。 所谓的“事前防御”，不是摆设，而是你真正被刷了一次之后，才明白它是多么值钱的东西。尽量做到：事故之前，防患于未然；事故之后，不留下一次机会。
出于安全考虑，去年开发了一个 OTP 多因素认证插件，后面就一直想着开发通行密钥插件，这样就不需要输入密码了，点两下就能登录进来，于是就花了两个小时使用 GPT-5.3-Codx 写了这个插件，不得不说 AI 还是太好用了。功能支持多用户的 PassKey 插件（通行密钥、WebAuthn 说的都是它）,支持 Typecho 1.2.0 + 的版本。支持自定义通行密钥名称一个用户支持创建多个通行密钥使用安装插件后，去个人设置页面绑定一个通行密钥通行密钥绑定后 就可以在登录时使用下载去 CNB 下载这个插件https://cnb.cool/dbkuaizi/typecho/passkey/-/releases或者直接下载代码仓库：https://cnb.cool/dbkuaizi/typecho/passkey注意：releases 发布版本 中的 zip 包已经包含 PassKey 插件目录，如果是直接下载的代码仓库，需要自行创建 usr/plugins/PassKey 目录
自 2020 年 11 月上线个人博客至今，一眨眼已经五年过去了，一晃时间也不短了，总觉得需要发一篇博文记录一下这个不平凡的时长。但人总归是喜好偷懒的，于是就拖到了 12 月 30 日，也不知道这篇博文今天能写完不，还是要拖到 2026 年 1 月份。更新频率当初上线博客的时候，计划着一个月最少一篇博文来着，刚开始更新倒也是积极，几乎每个月都有一到三篇的博文更新，而且都是笔记向每篇都是过万字的，信息密度还蛮高的。24年、25年 肉眼可见的懒了很多，学习向的博客 今年只更新了一篇 《极简 Docker 入门笔记》，尤其是 4 ~ 7 月 干脆直接断更四个月（虽然也没什么人看）。25 年没有输出较为深度的内容，从侧面印证今年的我也没有太多长进（技术方向）。到了年底想起这件事，总感觉自己今年荒废了很多，有点像温水煮懒蛤蟆，躺平固然是舒服的，但退步也是明显的（在程序员这个行业里，如果一段时间没有太大的进步，其实也是一种退步）。AI 变革与迷茫抛开懒不说，还有一个导致更新懈怠的主观因素，其实是 AI 的飞速发展。今年每次打开 Vnote （笔记软件）想学点东西的时候，心里总是在问自己：「现在 AI 这么厉害，写这些东西还有意义吗」。每次想到这里就没有写下的动力了（其实我自己有个选题库，真要说写文章的话，也不是没有什么东西可写）。写作的本意不过最近我也释然了，回看的当初开始写博文的本意，也并非是奢求有什么阅读量或者名气。只是想有个地方分享我输出的内容，那么我为什么要输出内容呢？我写作的起源来自 https://learnku.com/cs/t/29327 这篇帖子，我印象最深的一句话：「一件事情你不能讲清楚，十有八九你还没有完全理解」。后面发现确实是这样，然后在学习新东西的时候就尝试先学一遍，然后使用自己的方式表达出来，在尝试表达的过程中发现会有很多不明白的地方。所以好奇心就会驱使自己去弄明白这个点，而这个好奇心驱使的对未知知识点的探索欲，最终也会让自己的知识逐渐的丰富起来，甚至超过了最开始要输出的这个技术点本身的知识范畴。求实博客创建的前几年，不得不承认也是我个人技术成长最快的几年。当我写一篇文章的时候会有一种责任感：写下的每一个字都保证尽可能的准确无误，甚至有的时候为了验证一两句话的内容是否确实如此，往往会花费半个小时 甚至数天的休息时间去弄个水落石出。「实践是检验真理的唯一标准」，只有当自己亲手尝试过、确认这个技术方案能行得通，才可以心安理得的写到文章中。后续捋清了博客写作的出发点，希望来年更新的质量和频率也能恢复到之前的水平。其实这个标题我想叫做《写作五年》，但自认为这些年输出的内容撑不起这么大的标题，干脆就叫博客五年吧。
最近使用 1Panel 面板部署 FastAdmin 项目用于测试，站点的伪静态选择 thinkphp，在部署完成后发现用户端可以正常访问，而管理端则会无限制重定向到 login/index，最终提示 “重定向次数过多...” 如下图所示：经过排查 发现问题出现在 1Panel 自带的 fastcgi-php.conf 上这个文件上，如图 21 行:解决方案是注释掉这一行，然后使用 fastadmin 官方推荐的 fastcgi 配置，代码如下：location ~ [^/]\.php(/|$) { fastcgi_pass 127.0.0.1:9000; # include fastcgi-php.conf; # 注释掉这一行或者直接复制我的示例代码 include fastcgi_params; fastcgi_index index.php; fastcgi_split_path_info ^((?U).+\.php)(/?.+)$; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; }保存修改的配置后，fastadmin 管理端入口即可正常访问：使用版本为 1Panel 2.0.16, OpenResty 版本为 1.27.1.2-2-3-focal
前言作为一名 Web 开发者，我的日常工作就是在本地进行开发，实现各种功能。过去几年，我一直使用 127.0.0.1 作为本地服务的访问地址。当需要同时开发多个项目时，我会用不同端口来区分，例如：项目A：127.0.0.1:8080项目B：127.0.0.1:8081…以此类推当然，也可以用 localhost 代替 127.0.0.1。前段时间我在想，有没有办法让本地访问的地址看起来更“真实”一点？我知道可以通过配置 HOSTS 文件将任意域名解析到本地，但有没有本身就专门设计用于解析到本地的域名呢？最初，我尝试将 local.dbkuaizi.com 及其子域名解析到 127.0.0.1，这样访问 local.dbkuaizi.com 或 test.local.dbkuaizi.com 都能指向本地站点。但用了几天后发现一个问题：域名太长，输入不方便。于是我又想，不如自己注册一个短域名？但在域名注册网站找了半天，发现两位的短域名要么已被注册，要么价格不菲。后来我在网上搜索了一番，发现确实存在一些专门解析到本地的域名。现将整理结果汇总如下：本地解析域名localhostIETF（互联网工程任务组）在 RFC 6761 中明确规定：localhost 和 *.localhost 应始终解析为本地回环地址（即 127.0.0.1）。因此，以下地址都会被解析到 127.0.0.1，满足各种使用场景：域名说明http://localhost顶级域名http://dbkuaizi.localhost二级域名http://dbkuaizi.test.localhost三级域名需要注意的是，.localhost 解析到本地是应用层规范，Safari、Chrome、CURL 等主流工具都支持，但并非所有应用都遵循。由于这是官方硬性规定，该域名非常稳定且通用性强，在绝大多数情况下推荐使用。*.lvh.me该域名的持有者是 @levicook。自 2010 年起，该域名及其泛域名均解析到 127.0.0.1。2021 年 6 月，作者因认为使用人数不多、不愿继续承担域名费用而停止续费。随后，越来越多网友在其推特下留言表示愿意捐助，希望保留该域名。最终 @levicook 恢复了续费，并开启了捐款通道（但目前该捐款地址已返回 403）。以下地址都会被解析到 127.0.0.1：域名说明http://lvh.me顶级域名http://dbkuaizi.lvh.me二级域名http://dbkuaizi.test.lvh.me三级域名这是目前最短的可用域名，也是我日常开发的选择，体验良好。*.fbi.com这个域名…很有意思，虽然不清楚所有者是谁，但至少从 2022 年至今，它一直解析到本地回环地址。域名说明http://fbi.com顶级域名http://demo.fbi.com二级域名http://demo.test.fbi.com三级域名由于所有者不明，不推荐用于正式开发，当作一个趣闻即可。*.nip.ionip.io 和 sslip.io 是一种 DNS 服务，当查询包含嵌入 IP 地址的主机名时，会将域名解析为对应 IP。这两个服务已稳定运行十多年，每秒处理约 5000 次请求，甚至被 Google 引用在 Kubernetes 文档 中。举例说明：域名解析结果http://127.0.0.1.nip.io127.0.0.1http://app.192.168.0.10.nip.io192.168.0.10http://7f000101.nip.io127.0.0.1（十六进制表示）http://127-0-0-1.nip.io127.0.0.1（带破折号格式）需要注意的是，nip.io 和 sslip.io 本身不解析到 127.0.0.1。虽然该服务稳定，但我个人不太喜欢，因为域名中必须包含 IP，与我最初的目标不符。local.gd这是 nip.io 服务的一部分，该域名及其子域名均解析到 127.0.0.1，用法与其他本地域名相同：域名说明http://local.gd顶级域名http://dbkuaizi.local.gd二级域名http://dbkuaizi.test.local.gd三级域名local.dbkuaizi.com这是我个人持有的域名。对我而言，其稳定性仅次于 localhost。如果你有需要，也可以直接使用（至少在可预见的未来，该域名会持续解析到 127.0.0.1）。域名说明http://local.dbkuaizi.com二级域名http://demo.local.dbkuaizi.com三级域名http://demo.test.local.dbkuaizi.com四级域名其他我还收集了其他一些解析到 127.0.0.1 的域名，但不建议使用，因为它们未承诺长期解析到本地：http://curd.lifehttp://crud.lifehttp://localhost.ptlogin2.qq.comhttp://localtest.me使用场景在本地开发测试中，使用本地域名可以实现以下功能：Cookie 子域名共享浏览器将 127.0.0.1 视为 IP 地址而非域名，而 Cookie 在域名和 IP 的处理逻辑上完全不同。例如，设置 Cookie 时可以使用：document.cookie = "session=abc123; domain=.local.dbkuaizi.com; path=/"这样，无论是 demo.local.dbkuaizi.com 还是 test.local.dbkuaizi.com 都能共享该 Cookie，而使用 IP 地址无法模拟此场景。OAuth 开发大多数 OAuth 服务商出于安全考虑，不允许使用 IP 作为重定向 URL，要求使用真实域名，例如：✅ https://myapp.lvh.me:3000/auth/callback✅ https://myapp.localhost:3000/auth/callback❌ https://127.0.0.1:3000/auth/callback❌ http://localhost:3000/auth/callback（部分服务商不允许 HTTP）本地同时开发多个项目例如，项目A 运行在 3000 端口，项目B 运行在 4000 端口：使用 IP:端口，难以区分：❌ http://127.0.0.1:3000❌ http://127.0.0.1:4000使用本地域名更清晰：✅ http://project-a.lvh.me → 指向项目A✅ http://project-b.lvh.me → 指向项目B多商户系统假设你正在开发一个名为 “Shop” 的电商平台，支持多个商户拥有独立店铺。使用本地域名可以完美模拟多租户架构：张氏百货：http://zhang3.shop.lvh.me李氏商城：http://li4.shop.lvh.me王氏旗舰店：http://wang5.shop.lvh.me每个租户拥有独立的子域名，数据完全隔离。多模块划分仍以多商户系统为例，通过子域名将功能模块清晰分离，每个服务都有独立的访问入口：http://admin.shop.lvh.mehttp://api.shop.lvh.me这样，开发环境与生产环境的域名结构完全一致，代码中的域名逻辑无需调整，实现从开发到上线的无缝衔接。本文由两双筷子撰写，并利用 DeepSeek 对排版与语句进行了优化。
去年了解到多吉云这个CDN 厂商，感觉性价比很高，并且网友使用后的评价也很不错 @二丫讲梵。然后看了一眼现在使用的又拍云 CDN，还有一些余额没有用完。想着还是等用完了这些余额，再迁移到多吉云上去。直到今年十月前后，终于快用完了。但是迁移时发现 Typecho 没有支持多吉云的插件，有一个支持 S3 存储协议的插件，但是阅读完多吉云文档后发现，多吉云的 S3 文档与标准版 S3 规范有一些差异，不能直接使用，所以就有了这个插件。DogeUpload 插件Typecho 多吉云附件上传插件, 版本要求 Typecho >= 1.2.0 版本。直接上传附件到多吉云，且支持更新和删除附件。自定义加速域名。多吉云图片处理后缀支持。时间戳防盗链，支持自定义 Token 有效期。历史资源处理 （将 博客域名自动替换成 CDN 域名）。超轻量级设计，贯彻 Typecho 极简设计理念，插件体积仅 32 KB。代码结构简单、注释完善、使用命名空间，适合作为插件开发参考。2026-01-26 更新 插件 1.0.1 适配 Typecho 1.3.0 版本，解决因 Upload 基类传参调整导致上传报 “Server Error” 的问题，感谢 @曾墨 的反馈下载地址：https://cnb.cool/dbkuaizi/typecho/DogeUpload/-/releases/tag/1.0.1下载 "DogeUpload.zip"安装方式：将压缩包中的文件夹 DogeUpload 上传至 Typecho 插件目录 typecho/usr/plugins 下 并在插件面板启用。配置页预览更高性价比的选择博客建立后一直使用又拍云的CDN 服务，去年发现了多吉云这个CDN厂商，了解后得知 多吉云其实是一个 "二道贩子"，他们底层使用 阿里云、腾讯云等国内大厂的 CDN 节点作为基础支撑，得益于量大的原因，他们可以给出比直接购买阿里云、腾讯云CDN 更加实惠的价格。因为我前期一直在使用 又拍云，所以对又拍云的计费规则比较了解，这里就对比一下 又拍云 和 多吉云的成本差异：注：这里以 2025年10月15日 各厂商官网价格为基础作比较，若需核算成本，请以官网价格为准。又拍云云存储：0.0043 元 / GB / 天 (注：可以以当天流量使用情况进行抵扣)CDN 国内流量：0.29 元 / GBHTTPS 请求：0.05 元 / 万次HTTPS 证书免费更新多吉云云存储：0.003 元 / GB / 日CDN 国内流量：0.11 元 / GB （每月前 20 GB 免费）HTTPS 请求：0.04 元 / 万次（每月前 200W 次免费，日流量或云存储超过2TB 可联系客服免除 HTTPS 费用）HTTPS 证书续订目前免费，但官方说后期会收费，默认证书厂商 1元/次，指定厂商2元/次（后面有免费方案）我这里以 月流量 100 GB、云存储 10 GB、HTTPS 月请求数量 60W 来计算两家使用成本的差异（注：如果考虑购买资源包，成本会更低）。费用项目又拍云多吉云备注1. 云存储费用 - 计算公式10GB × 0.0043元/天 × 30天10GB × 0.003元/天 × 30天 - 小计1.29 元0.9 元 2. CDN流量费用 - 计算公式100GB × 0.29元/GB(100GB - 20GB) × 0.11元/GB多吉云每月前20GB免费- 小计29 元8.8 元 3. HTTPS请求费用 - 计算公式60万次 × 0.05元/万次0 元多吉云每月前200万次免费，60万次在免费额度内- 小计3 元0 元 4. 费用抵扣 - 规则存储费可抵扣流量费无额外抵扣规则又拍云特色- 抵扣金额1.29 元 月度最终成本 - 计算公式(CDN费 - 抵扣额) + HTTPS费存储费 + CDN费 + HTTPS费 - 总计(29 - 1.29) + 3 = 30.71 元0.9 + 8.8 + 0 = 9.7 元 大家可以参考这个费用情况核算一下，更换为多吉云后可节省多少钱，基本上都能节省 2/3 左右。HTTPS 证书更新多吉云的 CDN HTTPS 证书与又拍云有些差异，又拍云提供免费与收费的证书，且免费的证书可以免费自动续签。多吉云本身提供免费的 HTTPS 证书，但自动申请证书的服务是收费的（更新成功 默认厂商 1元/次，指定厂商 2元/次），但目前处于公测阶段，限时免费 ，具体可见官方文档： https://docs.dogecloud.com/cdn/manual-config-auto-cert。 现阶段证书是免费更新的，但官方文档中说后续肯定是会收费的，那么每年的成本是多少？以多吉云证书自动更新规则来计算，证书有效期 90 天，到期前 10 天自动更新，则每个证书有效期 记为 80 天，则每年续签 5 次，需要 5 ~10 元的证书更新费用。如果有多个CDN 加速域名，那也是一笔不不少的开销，那么是否有免费方案呢？有的，兄弟！可以自行部署 Certimate（https://docs.certimate.me）服务，这是一个开源的 SSL 证书管理工具，可以帮助你自动申请、部署 SSL 证书，并在证书即将过期时自动续期。先关闭掉 多吉云 HTTPS 证书无忧服务，接着在 Certimate 中设置工作流，每天凌晨检查证书有效期是否小于 5 天，如果是就自动续订，并通过 API 接口，自动部署到多吉云 CDN 服务上。 即可实现 CDN 证书的免费更新服务。
前言：本篇笔记内容来自于 B 站 《40分钟的Docker实战攻略，一期视频精通Docker》视频，博主 @技术爬爬虾，视频地址：https://www.bilibili.com/video/BV1THKyzBER6。Docker 核心概念Docker 容器Docker 简单来说就是通过容器化技术给应用程序封装一个独立的运行环境，每个运行环境就是一个容器，运行容器的计算机被称为 宿主机。与虚拟机最大的区别是：Docker 容器共享同一个系统内核，而每个虚拟机都包含一个完整的系统内核。所以 Docker 容器比虚拟机更轻量，启动速度更快。Docker 镜像镜像可以理解为是一个特殊的压缩包，容器就像是通过压缩包解压（创建）出来的文件，我们可以使用同一个压缩包，解压出很多份相同的文件，也可以将这个压缩包分享给他人，得到和我们一样的文件。这里所说的“文件”，就是上面提到的容器。我们也可以通过修改容器，并生成自己特定的 Docker 镜像，并将这些镜像分享给他人，其他人可以通过镜像，创建出和我们一样的容器环境。Docker 仓库Docker 仓库就是我们用来存储、分享镜像的地方。所有人都可以把自己的镜像上传到仓库里面，其他人就可以下载镜像并使用。Docker 的官方仓库是 Docker Hub (https://hub.docker.com)Docker 技术原理Docker 利用了 Linux 的两大原生功能，实现容器化：Cgroups 用来限制和隔离进程的资源使用。可以为每个容器设定 CPU、内存、网络带宽等资源的使用上限，确保容器的内存消耗不会影响到宿主机。Namespaces 用于隔离进程的资源视图，使得容器只能看到自己内部的进程ID、网络资源、文件目录，看不到宿主机的。容器本质上还是一个特殊的进程，但是当我们进入容器内部时，看起来就像是一个独立的操作系统Docker 安装Docker 是基于 Linux 的容器化技术，Windows 和 Mac 需要虚拟化一个 Linux 的子系统。所以 Docker最好的使用方式是使用 Linux系统的宿主机。Linux 系统Linux 系统可以通过包管理器安装 Docker，例如 Redhat 系可以使用 yum install docker 安装。 Alpine Linux 可以通过 apk add docker 安装Windows 安装打开 “Windows 功能”, 勾选上 Virtual Machine Paftform（虚拟机平台）、适用于 Linux 的 Windows 子系统，并重启电脑。管理员打开命令提示符，安装 wsl2wsl --set-default-version 2 wsl --update --web-download去 Docker 官网 或者 https://github.com/tech-shrimp/docker_installer/releases 下载符合自己系统的 Docker Desktop 安装包，并进行安装，安装成功后 可以在终端尝试一下：PS C:\Users\Administrator> docker --version Docker version 28.3.3, build 980b856在使用的过程中 Docker Desktop 需要一直开着，否则执行命令会出现这个报错。PS C:\Users\Administrator> docker ps error during connect: Get "http://%2F%2F.%2Fpipe%2FdockerDesktopLinuxEngine/v1.51/containers/json": open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.Mac 安装直接在 Docker 官网下载安装包 安装就行。Docker 镜像下载镜像Docker Pull 命令用来从仓库下载镜像。docker pull docker.io/library/nginx:latest一个Docker 镜像下载地址包含 4 部分内容：docker.io: registry：仓库地址/注册表，如果是 Docker 官方仓库，则可以省略这个地址。library: 命名空间（镜像作者），为了防止不同用户上传同一个名字的镜像 发生冲突。“library" 是docker 官方仓库的命名空间，这个空间下的所有镜像都是由 Docker 官方维护。如果是官方的命名空间 则可以省略不写nginx: 镜像名latest: 标签名、版本号。写 "latest" 或者不写 表示获取最新版本的镜像。镜像库repository: 镜像库，存放一个镜像的不同版本，"docker.io/library/nginx" 就是一个镜像库。简化后的命令如下：# 从 Docker 官方仓库的官方命名空间里面下载最新的 Nginx Docker 镜像 docker pull nginx其他例子：# 从 docker.n8n.io 的私有仓库下载 n8nio 上传的 n8n 镜像 docker pull docker.n8n.io/n8nio/n8nDocker Hubdocker 官方的镜像仓库地址为 https://hub.docker.com 可以在这个上面搜索自己需要的镜像，标有 "Docker Offcial Images" 表示 Docker 官方维护的镜像。如果打不开 可以通过 https://docker.fxxk.dedyn.io/ 镜像站来搜索镜像。镜像源配置在国内的网络环境中，如果执行 docker pull 可能会出现 下载失败的问题，可以参考这篇文档配置镜像站解决：docker_installer?tab=readme-ov-file#2-pull镜像（我尝试了 UP的这几个镜像 下载还是失败了，最后用这个镜像可以正常下载，有需要的各位可以试试看）"registry-mirrors": [ "https://docker.1ms.run" ]拉取特定架构的镜像docker pull --platform=xxx nginx默认情况下，docker 会选择当前宿主机 CPU架构的镜像，大部分情况下我们不需要关注这个参数。Mac 目前的 M系列CPU 都是 arm 架构的，但在运行 AMD64 架构的容器时，会自动调用 QEMU 来模拟 x86_64 指令集，从而实现兼容 AMD64 的镜像，不过可能会存在一些兼容性问题 以及额外的性能开销。查看镜像docker images使用此命令可以查看 所有 Pull 到本地的镜像。删除镜像docker rmi [镜像标识]使用此命令可以删除 pull 到本地的镜像，镜像标识可以选择 镜像的ID（image id）或者镜像的名称（repository）Docker 容器创建并运行容器docker run [镜像标识]使用 run 命令 可以通过 镜像创建一个容器，并启动它。镜像标识可以是 镜像ID 或者镜像名称。例如：docker run nginx 就是创建一个 Nginx 容器。docker pull 命令可以省略，直接使用 docker run 运行，如果docker发现本地没有这个镜像 则会自动拉取。分离模式默认情况下 docker run 创建容器后会导致当前终端挂起，不能进行其他操作，可以增加 -d 参数 表示容器在后台运行，不阻塞当前窗口。PS C:\Users\Administrator> docker run -d nginx c18e9f86c0c89f2d9984dbe566349542318f8e38bb86fd17579bd9f39a8c5977自定义容器名称docker run -d --name kz_nginx nginx容器的名称和 容器ID的效果是等价的，但是名字更方便记忆。端口映射容器的网络和宿主机的网络是隔离的，例如运行了一个 Nginx 容器，容器内的Nginx 监听了 80 端口。这时通过宿主机的 80 端口是无法访问到 Nginx 服务的。所以需要 -p {宿主机端口}:{容器端口} 命令进行映射，例如 -p 8080:80 标识将 Nginx 容器的 80 端口映射到宿主机的 8080 端口。目录映射（绑定挂载）与端口映射类似的就是目录映射，将容器外和容器内的目录进行绑定，容器内对文件的修改会影响宿主机的文件夹，宿主机的修改也会影响容器内文件夹。这种目录也被称为 挂载卷，他的最大作用是数据的持久化。 当容器删除时，容器内的所有数据都会被删除，但通过挂载卷映射到宿主机的文件夹将得以保留。docker run -d -p 8080:80 -v ./:/usr/share/nginx/html nginx使用绑定挂载的时候，宿主机的文件会暂时覆盖掉容器内的目录。除了这种用法 还有一种叫 Docker 卷，可以在容器之间共享和重用。环境变量可以在命令行通过 -e 参数传递环境变量到容器内部，例如创建一个数据库应用，需要在 创建容器时，就指定数据库的账户、密码等信息。docker run -d -p 27017:27017 \ -e MONGO_INITDB_ROOT_USERNAME=tech \ -e MONGO_INITDB_ROOT_PASSWORD=dbkuaizi \ mongo如果不知道容器的环境变量有哪些，可以在 Docker Hub 上搜索一下，或者去Github上看一下 readme 文档，都有详细的描述。进入容器docker run -it alpine通过 -it 参数可以在创建一共容器时，同时进入容器内部的终端。退出删除docker run -it --rm alpine--rm 表示当退出容器时，自动删除这个容器。一般和 -it 命令配合使用容器重启策略docker run -d --restart always nginx--restart 参数用来表示 容器在停止时的重启策略，它有两个选项：always 只要容器停止了，就会立即重启。包含容器因为内部错误崩溃，或者宿主机断电等场景unless-stopped ，与 always 类似，唯一区别是：手动停止的容器不会尝试重启了创建容器docker create nginx与 run 命令功能类似，区别在于只创建容器，不自动启动。容器列表PS C:\Users\Administrator> docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 48fe482ecff2 nginx "/docker-entrypoint.…" About a minute ago Up About a minute 80/tcp cool_hodgkinps 是 Process Status (进程状态) 的缩写，也是 Linux 上的一个经典命令，用于查看进程的状态信息。这一命令也被继承到 Docker 里面了这些列含义如下：CONTAINER ID 容器ID，每个容器在创建时会生成一个唯一的IDIMAGE 基于那个镜像创建出来的CREATED 镜像创建时间STATUS 镜像当前状态PORTS 镜像使用端口NAMES 容器的名字，如果创建容器时没有指定名字，系统就会随机分配一个。增加 -a 参数可以看到所有的容器，包括正在运行的和已经停止的。启停容器每次使用 docker run 运行都会创建一个新的容器，如果我们需要对同一个容器进行持续的操作，可以通过 容器的启停命令来控制。# 启动容器 docker start {容器标识} # 停止容器 docker stop {容器标识}使用 start 启停容器的时候，不需要再传递创建容器时的 端口映射、挂载卷、环境变量等参数都不需要重新写了，docker 已经自动保存了，重新启动可以按照原因运行。删除容器docker rm {容器标识}使用 rm 命令可以删除容器，如果要删除运行中的容器 需要加 -f 参数强制删除。容器内部# 进入容器内部 docker exec -it {容器标识} bash使用 exec -it 命令 可以进入容器，在容器内部执行 shell 命令docker exec {容器标识} {shell 命令}也可以通过这种方式 在容器外部执行容器内部的命令# 查看容器 alpine 中的进程信息 docker exec alpine ps -ef注意 docker 镜像为了尽可能缩小镜像体积，内部一般是一个极简的操作系统，很多系统工具、基础命令都是缺失的查看创建信息docker inspect {容器标识}使用这个命令可以看到容器的所有信息，输出的是一个 JSON 格式，可以直接丢给ai 帮忙解析容器日志docker logs {容器标识}这个命令可以看容器的日志，加上 -f 命令，可以持续输出，滚动查看。Docker 卷docker volume 命令用于管理 Docker 卷（volume）。卷是用于持久化数据的文件系统，可以将数据和应用程序分离，便于管理，可以在容器之间共享和重用。同时卷可以用于数据的备份和恢复。创建卷docker volume create {卷名称}查看卷信息docker volume inspect nginx_html [ { "CreatedAt": "2025-09-07T14:58:39Z", "Driver": "local", "Labels": null, "Mountpoint": "/var/lib/docker/volumes/nginx_html/_data", "Name": "nginx_html", "Options": null, "Scope": "local" } ]查看所有卷docker volume list DRIVER VOLUME NAME local nginx_html删除卷docker volume rm nginx_html删除未使用的卷docker volume pruneDockerfileDockerfile 是一个用来 构建镜像文件的文本文件，Dockerfile文件内包含了构建镜像所需的各种信息。在项目目录下创建一个名为 Dockerfile 的文件，并在文件中编写镜像构成的信息。# 选择一个基础镜像作为运行环境 FROM python:3.13-slim # 在镜像内切换一个工作目录，后续所有的操作都是基于这个目录来的 WORKDIR /app # 将项目文件拷贝到镜像的工作目录 # 第一个 “.” 代表当前目录，第二个 “.” 代表镜像的工作目录 COPY . . # 安装容器内环境需要的依赖 RUN pip install -r requirements.txt # 声明对外提供服务的端口是哪个 EXPOSE 8000 # 容器内服务启动命令，每次启动时容器内会自动执行这个命令 CMD ["python3","main.py"]项目内容main.pyfrom fastapi import FastAPI import uvicorn app = FastAPI() @app.get("/") def read_root(): return {"hello":"dbkuaizi"} if __name__ == "__main__": uvicorn.run(app,host='0.0.0.0',port=8000)requirements.txtfastapi uvicorn构建镜像Dockerfile 文件写好了，可以使用 docker build 构建镜像。PS E:\docker\demo> docker build -t docker_test . [+] Building 65.7s (9/9) FINISHED docker:desktop-linux => [internal] load build definition from Dockerfile 0.0s => => transferring dockerfile: 616B 0.0s => [internal] load metadata for docker.io/library/python:3.13-slim 0.0s => [internal] load .dockerignore 0.0s => => transferring context: 2B 0.0s => [1/4] FROM docker.io/library/python:3.13-slim@sha256:1bca0202e953784ac2a1daf36ebbc9cbebed48afcfba12d1225aaab3793eca33 0.0s => => resolve docker.io/library/python:3.13-slim@sha256:1bca0202e953784ac2a1daf36ebbc9cbebed48afcfba12d1225aaab3793eca33 0.0s => [internal] load build context 0.0s => => transferring context: 310B 0.0s => CACHED [2/4] WORKDIR /app 0.0s => [3/4] COPY . . 0.1s => [4/4] RUN pip install -r requirements.txt 63.6s => exporting to image 1.6s => => exporting layers 0.9s => => exporting manifest sha256:fb2f23e7feeb23365ee8cc3cd9a5a8c5d497c73538c16463e23195a6b873d7bf 0.0s => => exporting config sha256:41ffab42f6b9d9c336023838318f252e00e15a9d5a73cc17a765dae05fdc5435 0.0s => => exporting attestation manifest sha256:83d850f342fee281553b7b387484f4790d4553015fb12d4cec7ac00462229a25 0.0s => => exporting manifest list sha256:1bfb1d56ae1e7a710f2f6277d9c2a6b2b746e6710a83b1792bea01ef7bbec520 0.0s => => naming to docker.io/library/docker_test:latest 0.0s => => unpacking to docker.io/library/docker_test:latest 创建容器并运行PS E:\docker\demo> docker run -d -p 8000:8000 docker_test 22fd617066ad0f95b1a664257f4c00a6dc1b60d9de7c894f8940f24b34859629 PS E:\docker\demo> docker ps -a CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 22fd617066ad docker_test "python3 main.py" 4 seconds ago Up 4 seconds 0.0.0.0:8000->8000/tcp, [::]:8000->8000/tcp sharp_banach PS E:\docker\demo> curl http://127.0.0.1:8000 StatusCode : 200 StatusDescription : OK Content : {"hello":"dbkuaizi"} RawContent : HTTP/1.1 200 OK Content-Length: 20 Content-Type: application/json Date: Tue, 09 Sep 2025 14:31:30 GMT Server: uvicorn {"hello":"dbkuaizi"} Forms : {} Headers : {[Content-Length, 20], [Content-Type, application/json], [Date, Tue, 09 Sep 2025 14:31:30 GMT], [Server, uvicorn]} Images : {} InputFields : {} Links : {} ParsedHtml : System.__ComObject RawContentLength : 20Docker 网络桥接模式Docker 网络 默认 Bridge（桥接模式），所有的容器都连接到这个网络中，每一个容器都分配了一个内部的IP地址，一般都是 172.17 开头。在这个内部子网里面，容器可以通过内部 IP 地址互相访问。但容器网络和宿主机的网络是隔离的，可以使用 docker network create 命令创建子网，默认情况下，子网也是桥接模式的一种，然后可以指定容器加入不同的子网，同一个子网内的容器可以互相通信，而跨子网则不可以通信。使用子网还有一个好处，同一个子网内的容器，可以直接使用容器名称互相访问，而不必使用内部的IP地址。# 创建一个 名叫 network1 的子网 docker network create network1 # 创建一个 nginx 容器 使用 network1 的子网 docker run -d --network network1 nginxHOST 模式host 模式下，docker 容器直接共享宿主机的网络，容器直接使用宿主机的IP地址，无需 -p参数进行端口映射，容器内的服务直接运行在宿主机的端口上，通过宿主机的IP和端口就能访问到容器中服务。docker run -d --network host nginxNONE 模式这个模式表示不联网控制命令创建网络docker network create network1查看网络PS C:\Users\Administrator> docker network list NETWORK ID NAME DRIVER SCOPE d30095b3bc13 bridge bridge local 24b646446630 host host local a26161c3eff2 network1 bridge local 7800c9b21eb6 none null local除了我们创建的模式以外，还有 Docker 自带的三种模式，需要注意的是 这三个自带的网络模式是不可删除的删除网络docker network rm network1Docker Compose有的时候 一个完整的应用可能会是很多部分组成的，例如前端、后端、数据库 以及各种附加的技术栈，这些东西应该如何容器化呢？我们可以自然的想到，将这些模块都打包在一起，做成一个巨大的容器。但这样做有一个弊端，只要其中一个模块发生故障，例如 服务端内存泄露，可能会导致整个容器都崩溃挂掉。并且这样做的可伸缩性差，如果想给系统做扩容，只能把整个大容器在复制一份，做不到对某个模块的精确扩容。多应用的最佳实践，是把每一个模块都打包成一个独立的容器。但这样多容器 增加了很多使用成本，因为想创建多个 容器 就要多次使用 docker run ，还需要配置容器之间的网络环境，尝试管理这些容器时，一个遗漏就会导致很多问题，并且若让其他人部署项目，如果操作者对部署流程不熟悉 也会导致各种问题的发生。这个时候，容器编排技术就很有用了，也就是 Docker Compose，它使用 yml 文件 管理多个容器，在这个文件中记录了容器之间时如何创建以及如何协同工作的，我们可以简单的把 Docker Compose 文件理解成一个或多个 Docker run 命令，按照特定的格式书写到一个文件中，Docker Compose 格式如下：右侧最顶级的就是 Services 元素，每个元素就对应一个 Services左侧的 --name 在右侧就变成了 services 名左侧的镜像名，在右侧写在了 image: 属性后面左侧的 -e 参数，对应右边的 environments左侧的 -v 对应右侧的 volume 也就是挂载卷。左侧的 -p 对应右边的 ports右侧的 depends_on 用来表示启动顺序关系，这里表示 my_mongodb_express 容器，依赖 my_mongodb 。这时 程序会先启动 mongodb 再启动荣亲左右两边唯一的区别是：左边自定义了一个子网 network1 ，而右边没有。同一个 compose 文件中，定义的所有容器都会自动加入同一个子网，不用我们额外维护。我们可以借助 AI 来生成 需要的 Compose 文件，而无需手动编写。使用 Compose在启动目录下创建 docker-compose.yaml 文件，内容如下：services: my_mongodb: image: mongo environment: MONGO_INITDB_ROOT_USERNAME: name MONGO_INITDB_ROOT_PASSWORD: pass volumes: - /my/datadir:/data/db my_mongodb_express: image: mongo-express ports: - 8081:8081 environment: ME_CONFIG_MONGODB_SERVER: my_mongodb ME_CONFIG_MONGODB_ADMINUSERNAME: name ME_CONFIG_MONGODB_ADMINPASSWORD: pass depends_on: - my_mongodb创建并运行启动然后执行 docker compose up -d 运行，如果容器已经在运行了 重复执行这个命令不会有任何效果。执行该命令时，会检测当前目录下 名为 docker-compose.yaml 或 compose.yaml 文件。可以通过 docker compose -f tesst.yaml up -d 指定 compose 文件。PS E:\dbkuaizi\mongodb> docker compose up -d [+] Running 3/3 ✔ Network mongodb_default Created 0.0s ✔ Container mongodb-my_mongodb-1 Started 0.6s ✔ Container mongodb-my_mongodb_express-1 Started 0.7s PS E:\dbkuaizi\mongodb>停止并删除docker compose down 命令会停止并删除容器PS E:\docker\mongodb> docker compose down time="2025-09-18T21:47:55+08:00" level=warning msg="E:\\docker\\mongodb\\docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion" [+] Running 3/3 ✔ Container mongodb-my_mongodb_express-1 Removed 0.3s ✔ Container mongodb-my_mongodb-1 Removed 0.3s ✔ Network mongodb_default Removed 0.4s停止不删除docker compose stop 命令会停止并且不会删除容器启动docker compose start 命令会启动停止的容器
背景我们公司有大量的 PHP 和 Shell 脚本需要定时执行。起初使用 Linux 的 crontab 管理任务，但随着业务的增长，定时任务数量一度超过 50 个，管理成本迅速上升。为了方便线上操作，当时在 GitHub 上找到了 lisijie/webcron 这个项目。它功能简单，能满足基本的定时任务管理需求，我们便直接投入使用。遇到的问题随着时间推移，我们开始遇到以下两个关键问题：日志查询影响主业务性能 Webcron 默认使用 MySQL 存储任务和日志，与业务共用数据库。当日志量激增后，任务列表页面需要联表查询日志，页面加载时间常超过 30 秒，严重影响了主业务性能。日志数据膨胀，存储压力大部分任务执行频率高（如每 10 秒一次）；部分任务每次输出大量日志（3MB+）；日志需保留 6 个月。例如：项目月记录数MySQL .ibd 文件体积A 项目120 万条1.2 GBB 项目480 万条8.9 GB如果按照此增长速度保留半年，B 项目的日志将占用约 50GB 的磁盘空间。解决方案我们决定重构整个定时任务管理系统，重点优化三方面：1. 用 SQLite 替代 MySQL 存储日志MySQL 查询开销大、影响业务，我们将日志存储迁移到 SQLite。它无需额外服务，具备完整 SQL 支持，适合做独立日志存储方案，查询性能也更可控。2. 使用 zstd 压缩日志内容观察日志内容后发现，大量输出存在重复格式，非常适合压缩。我们评估了几种压缩算法：zstd（默认级别 / L7级别）zstd + 自训练字典gzip（Go 标准库实现）压缩对比如下：压缩方案A 项目（120 万）B 项目（480 万）MariaDB .ibd 文件1.2 GB8.9 GBSQLite 明文存储856 MB665 MBgzip（默认）212 MB405 MBzstd（无字典）198 MB335 MBzstd + B 项目字典149 MB192 MBzstd L7 + B 项目字典139 MB186 MBzstd + A 项目字典95.1 MB224 MB注：字典通过 zstd --train 命令生成，使用项目历史日志训练；字典在不同项目中交叉使用效果不一；字典需维护更新，综合考虑后未在最终方案中使用。最终策略是：当单条日志长度 > 150 字节时启用 zstd 压缩，否则直接存储明文。读取时自动判断并解压。3. 分月存储日志文件为了避免单文件过大并简化清理流程，我们将日志按月分别存储，每月 1 号新建一个 SQLite 日志库文件（如 2025-08.db）。这样带来两个好处：查询范围控制在单月文件，性能更稳定；清理历史数据只需删除 .db 文件即可，维护成本极低。实际效果经过重构后，系统在实际运行中表现稳定，存储与性能均有显著改善：压缩后磁盘占用：每 10 万条日志占用约 9~10 MB；查询性能：500 万条日志文件中，查询指定任务最近 20 条记录耗时 < 20ms；支持字段：支持根据任务 ID、执行状态、执行时间进行高效查询。技术实现细节开发语言：Go 1.24数据库引擎：GORM + SQLite（无 cgo 版本）压缩库：github.com/klauspost/compress为什么不用列式压缩？日志是按行追加写入的，若采用列式压缩，需要额外拆分压缩、整合再写入逻辑，开发成本较高，不适合高频日志场景。因此我们采用了“逐行压缩”策略，开发上更轻量。灵感来源与参考本项目思路部分参考了 V2EX 上的帖子：使用 SQLite 存日志 + zstd 压缩，但实际测试中压缩比与原贴略有差异。总体来说，该方案在中小型系统中表现优异，值得借鉴。后续优化方向✅ 探索 DuckDB 替代 SQLite：支持列式存储，可能带来更高压缩比与查询性能；✅ 动态字典优化：尝试用上月日志训练下月字典，实现自动演进，提高长期压缩效率；✅ 分布式合并查询支持：支持跨月日志合并检索，便于追溯问题全链路。小结这次定时任务系统重构，核心目标是“日志系统轻量化、可维护、低资源占用”。通过 SQLite + zstd 压缩 + 分月归档的组合，我们在不依赖额外服务的前提下，成功将海量日志压缩到可控范围，并提升了查询体验。如果你的系统也面临日志存储冗余、查询卡顿等问题，或许可以尝试这种“小数据库 + 压缩”的路线。文章内容由两双筷子原创，使用 ChatGPT 进行排版优化。
对于自己的软件产品，希望别人付费、或者在我们授权的情况下才允许使用。那么我们应该如何去这个授权码 / 许可证的机制呢？前言离线授权的方案无非就是两种，一种是软件层面的授权，一种是硬件层面的授权。软件层面：我们向用户提供一串特定的字符串，用户在软件中输入我们提供的字符串，在使用端校验用户输入的授权码，即可完成软件的授权操作。硬件层面：我们向用户提供一个 USB Key（加密狗），用户在运行软件时我们可以通过硬件加密狗来判断授权信息，这种加密狗在网上 几块钱到十几块钱就能买一个，有的加密狗还内置独立时钟，可以杜绝用户进行时钟回拨等。这种加密狗可以用在对授权安全级别更高的场景中，但需要承受额外的成本：用户的使用成本，授权分发成本（加密狗+快递费+快递时间）。软件层面的加密方案使用起来更加灵活，硬件层面加密狗安全级别更高，本篇文章主要是探讨软件层面的授权方式。授权码要点那么设计一个相对安全的离线授权码，需要满足那些哪些特征呢？绑定性：每个授权码对应一台设备的授权，客户无法通过一个授权码激活多台设备。安全性：授权码应难以被伪造或篡改可验证：授权码应能被系统快速验证其合法性可复现：当客户授权码遗失时，我们可以根据客户的机器码重新生成授权码，避免用户无法使用软件的情况。时效性（可选）：授权码可包含授权有效期，限定授权使用时长。可扩展（可选）：可根据自己需求来灵活扩展授权码内容，以方便携带更多信息。你可以以任何形式生成一个满足上方条件的授权码，无需纠结生成的思路是否与我的思路一致唯一标识在离线授权场景中，我们需要确定授权设备的唯一标识，这个标识需要具有不可更改性质，在正常情况下该唯一标识都不会发生变化（例如重启软件、重启系统不会发生变化，但重装系统、更换硬件等行为会发生变化是可接受的）。在这种场景中，我们唯一标识一般采用 CPUID、主板 BIOS UUID、网卡 MAC 地址、或自定义算法来生成唯一ID，提醒一下 如果产品是服务器应用，不应使用 硬盘序列号作为唯一标识的组成部分，因为服务器硬盘都有明确的生命周期会定期更换。当更换后就会造成授权失效的情况。这个唯一标识没有明确的授权规定，大家可以根据自己的需求来选择，也可以通过现成的开源库来获取。例如我开发的主语言是 Golang，我一般会使用 github.com/denisbrodbeck/machineid 这个库来获取硬件ID，并且可以在各个系统下运转良好。授权码方案对称方案对称方案是指授权码的生成逻辑、校验逻辑完全一样。所谓校验就是客户端内部生成正确的授权码，然后和用户输入的授权码做对比，对比成功，则说明授权有效。以我个人习惯为例，授权码的字符串一般包含“机器码”、“项目名称”、“到期时间”、“盐值”，其中盐值用于增加授权码的复杂性，即使客户知道了授权码的生成方式，在不知道盐值的情况下，无法自行伪造授权码。生成过程我们先生成一个明文的字符串，然后通过特定分隔符拼接出明文的授权码，格式可以是: 机器码:项目名称:到期时间:盐值，例如：123456:tpamis:281231:abcdef123456：客户提供的机器码，这里以 123456 代替tpamis：授权项目名称，同一个机器上可能存在多个软件产品，可以加一个项目名称用于区分。281231：到期时间，这里取 2028-12-31 的后6位作为到期时间。这里也可以写成授权天数，先选定一个时间作为起始日期，然后在这个时间上叠加偏移时间，得到的最终时间作为授权到期时间。例如：我出生的时间是：1998-11-11，我以此作为起始时间。偏移时间：9628 天，得到授权到期时间为 2028-12-31。abcdef:盐值，可以防止彩虹表攻击，即使攻击者知道生成算法，也无法通过预计算破解授权码。接着，我们通过一个 MD5 算法（也可使用其他 HASH 摘要算法）得到一个签名 "cfb9fc32230fa1a19423ef8b6af63a61"，此签名就是一个授权码。但这时的授权码有一个问题，签名是不可逆的，所以我们无法在验证签名时通过签名得到授权到期时间，所以我们需要人为的拼接一下：直接拼接： cfb9fc32230fa1a19423ef8b6af63a61281231使用分隔字符： cfb9fc32230fa1a19423ef8b6af63a61-281231舍去后六位，以 UUID 的方式呈现： cfb9fc32-230f-a1a1-9423-ef8b6a281231掩藏到期时间这个时你会发现 后六位明晃晃的摆在那里，太容易被人猜到是到期时间了，虽然不会被篡改，但仍希望隐藏在授权码中，我们可以通过 十进制转十六进制来实现：281231 转十六进制为：44a8f，这时授权码变成：cfb9fc32-230f-a1a1-9423-ef8b6a44a8f。同时你可以将到期时间藏在授权码中间，替换掉对应位置的签名值，得到这样的授权码：cf44a8f2-230f-a1a1-9423ef8b6af63a61。验证授权码到这一步，已经完成了授权码的生成过程，客户端程序需要提前内置盐值和同样的生成算法，在用户输入授权码激活时，先从授权码中截取出到期时间。再用客户端生成的授权码进行对比验证是否正确，以及是否过期。用户体验优化签名生成的授权码以及可以满足正常的离线授权的功能了，但有一些不允许插入U盘或联网的场景中，客户可能需要手动输入授权码，我们应该尽可能缩短授权码长度，方便用户输入。我们可以在前面授权码的基础上，参考 Windows 的授权码长度 （AAAAA-BBBBB-CCCCC-DDDD-EEEEE）,每组5个字符，5 组共25个字符，通过横杠分割方便用户输入。前面 MD5 生成的签名是 128 位的二进制，转换为16进制后，长度为 32 个字符。其实我们可以 MD5 转为 36 进制得到: CAQ3DFUC0YPEHDZLA7ZKHHYLD。我们再拼接上前面得到的十六进制到期时间，并转为全大写，就得到了方便输入的授权码格式：CAQ3D-FUC0Y-PEHDZ-LA7ZK-HHYLD-44A8F。如果需要长度和 Windows 一模一样，则需要在 36 进制转换后，再截取掉 5 位，拼接时间戳。这里提供一个 Go 语言 十六进制转36进制的方法示例：package main import ( "crypto/md5" "encoding/hex" "fmt" "math/big" ) // 将 MD5 哈希值转换为 36 进制字符串 func md5ToBase36(md5Hash string) string { // 将 MD5 哈希值（十六进制）转换为大整数 bigInt := new(big.Int) bigInt.SetString(md5Hash, 16) // 定义 36 进制的字符集 const charset = "0123456789abcdefghijklmnopqrstuvwxyz" // 将大整数转换为 36 进制 base36 := "" base := big.NewInt(36) zero := big.NewInt(0) remainder := new(big.Int) for bigInt.Cmp(zero) > 0 { bigInt.DivMod(bigInt, base, remainder) base36 = string(charset[remainder.Int64()]) + base36 } return base36 } func main() { // 计算字符串的 MD5 哈希值 data := "123456:tpamis:281231:abcdef" hash := md5.Sum([]byte(data)) md5Hash := hex.EncodeToString(hash[:]) // 转换为十六进制字符串 fmt.Println("MD5 哈希值:", md5Hash) // 输出: cfb9fc32230fa1a19423ef8b6af63a61 // 将 MD5 哈希值转换为 36 进制 base36Code := md5ToBase36(md5Hash) fmt.Println("36 进制编码:", base36Code) // 输出: caq3dfuc0ypehdzla7zkhhyld }几个问题补充为什么要转 36进制而不是直接截取 MD5？因为截取后过短的 MD5 增加了碰撞的概率，转为 36进制，会包含 0-9、A-Z 36 个字符。36进制相比 16进制更为紧凑，在缩短长度同时保留较高的信息密度。且 36 进制在客户输入时，不需要考虑大小写问题，用户体验更好。摘要算法选择MD5 算法已被证实存在碰撞漏洞，但在安全性要求相对不是很高的场景中，还是可以接受的。如果需要安全性更高的摘要算法，可以换成例如 SHA-256、国密 SM3 等更为安全算法，在得到签名后截取 32 位 当作 MD5 使用，实现安全性、用户体验的兼顾。36进制字符集因为 36 进制是自行实现的，所以可以自定义字符集，大家可以在这一步对字符集排布顺序进行打乱，实现混淆效果。非对称方案前面的授权码方案可以提供一个方便输入，长度较短的授权码字符串，但携带授权信息较少、密钥（盐值）需要内置在客户端中，遇到逆行破解场景，有盐值泄露的可能性。授权码设计非对称加密我们可以使用 RSA 算法来实现。当我们使用 RSA 算法时，我们可以通过授权码给客户端携带更多信息，所以我们就以 JSON 为许可证内容的载体。我们可以这样设计：{ "iss":"tpamis", "sub":"pord", "aud": "123456", "exp": "1861804800", "nbf": "1743436800", "iat":"1742728673", "rge":["功能a","功能b","功能b"] }接参考了 JWT 官方规定的 Payload 字段，设计的许可证 JSON 内容，且这些字段可以根据需求随意添加调整。iss (issuer)：授权项目sub (subject)：授权方式，prod 正式授权、test 测试授权aud (audience)：授权对象，客户的机器码exp (expiration time)：过期时间nbf (Not Before)：生效时间iat (Issued At)：签发时间rge：授权功能列表生成授权码我们生成一对 RSA 公私钥，公钥保存在客户端，私钥在我们手里，我们根据用户的机器码，生成一个授权码，以上面为例，我额外扩展了授权模块功能，可以精确控制客户允许使用的功能范围。生成包含许可证信息的JSON 字符串{"iss":"tpamis","sub":"pord","aud":"123456","exp":"1861804800","nbf":"1743436800","iat":"1742728673","rge":["功能a","功能b","功能b"]}然后我们使用 RSA 私钥加密生成密文，这个密文就是我们需要提供给客户的授权码。[binary]当许可证内容过多，导致密文过长时，可以考虑输出一个密钥文件给客户，客户在产品中上传、选择 密钥文件，我们通过读取密钥文件来获取授权码。这样体验更好。授权码验证用户将我们的授权码输入到软件中，客户端使用公钥解密，解密成功则读取进入进行进一步的授权校验，解密失败直接提示授权码错误。说明：将公钥内置到客户端，在极端情况下，存在反编译的可能性。所以我们可以假设公钥已经泄露、不安全的状态。不过只要私钥不泄露，别人拿到公钥也只能解密我们的许可证密文，而不能伪造一个许可证，相对来说也是可以接受的。代码示例以 Go 语言为例 加密解密过程如下package main import ( "encoding/json" "fmt" "github.com/dromara/dongle" ) func main() { // 声明 map[string]any 许可证结构 licenseData := map[string]any{ "iss": "tpamis", "sub": "pord", "aud": "123456", "exp": "1861804800", "nbf": "1743436800", "iat": "1742728673", "rge": []string{"功能a", "功能b", "功能b"}, } // 将 map 转换为 JSON 字符串 jsonData, _ := json.Marshal(licenseData) // 使用RSA 私钥 授权Json cipherText := dongle.Encrypt.FromBytes(jsonData).ByRsa(pkcs1PrivateKey).ToBase64String() fmt.Println("RSA密文", cipherText) // 客户端 使用 公钥进行解密 licenseJson := dongle.Decrypt.FromBase64String(cipherText).ByRsa(pkcs1PublicKey).ToString() fmt.Println("许可证Json", licenseJson) } var pkcs1PublicKey = []byte(`-----BEGIN RSA PUBLIC KEY----- MIGJAoGBAK12MTd84qkCZzp4iLUj8YSUglaFMsFlv9KlIL4+Xts40PK3+wbsXPEw cujGeUmdgMeZiK7SLLSz8QeE0v7Vs+cGK4Bs4qLtMGCiO6wEuyt10KsafTyBktFn dk/+gBLr7B/b+9+HaMIIoJUdsFksdAg3cxTSpwVApe98loFNRfqDAgMBAAE= -----END RSA PUBLIC KEY-----`) var pkcs1PrivateKey = []byte(`-----BEGIN RSA PRIVATE KEY----- [binary] -----END RSA PRIVATE KEY-----`)JWT 方案JWT 方案则是直接使用标准的 JWT 库来实现授权码的签发功能，客户端和服务端共享 JWT 的密钥来验证授权签名的有效性。本方案的优点是可以充分利用现有的标准化 JWT 库，只需定义一个密钥，而无需进行过多的开发，但许可证内容会直接暴露给客户，也可以考虑 JWE，需自行抉择。JWT Header { "alg": "HS256", "typ": "JWT" } JWT Payload { "iss": "tpamis", "sub": "pord", "aud": "123456", "exp": "1861804800", "nbf": "1743436800", "iat": "1742728673", "rge": [ "功能a", "功能b", "功能b" ] } 密钥 dbkuaizi.com JWT Token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0cGFtaXMiLCJzdWIiOiJwb3JkIiwiYXVkIjoiMTIzNDU2IiwiZXhwIjoiMTg2MTgwNDgwMCIsIm5iZiI6IjE3NDM0MzY4MDAiLCJpYXQiOiIxNzQyNzI4NjczIiwicmdlIjpbIuWKn-iDvWEiLCLlip_og71iIiwi5Yqf6IO9YiJdfQ.izIAwMyiLxPiHrWf-FDGu3fHMvfaC7bqEh40ha8YYAA结尾没有绝对的安全任何安全领域的手段都是相对安全，没有绝对的安全。再复杂的授权码的本质只能是 防君子不防小人，例如用户可以通过逆向 得到你的盐值、甚至直接修改你的程序逻辑跳过验证的逻辑，更何况我们的场景中只有离线校验的逻辑。防止时钟回拨时钟回拨是指，就是用户直接通过修改系统时间到软件授权到期之前，从而继续使用软件的目的。那么我们在离线场景中如何去避免这样的情况发生呢？首先我需要声明的是，离线授权场景中，不能彻底避免这种情况（除了独立时钟的加密狗），我们能做的就是通过代码逻辑尽可能规避这种情况的发生，一般有这几种手段：记录上一次运行时间众所周知，时间是不会倒流的，也就是说软件的第二次运行时间，不可能小于上一次运行时的系统时间。所以，在软件每次运行时我们可以先记录当前时间，并与上一次系统时间做对比，若第二次运行时的系统时间小于之前记录的时间，则认为出现了系统时钟回拨的问题，直接提示用户并终止软件运行。安全的存储时间标记那么如何保证我们存储的时间没有被篡改呢？我们可以通过 （时间戳.盐）+md5 的方式实现：例如：时间戳为 1717487962 盐 是 abcdefg，通过： md5('1717487962.abcdefg') 摘要算法，获得时间戳签名:a06de98fc28e45bf38a9a5f27630cd03。然后将这样的字符串进行存储：1717487962.a06de98fc28e45bf38a9a5f27630cd03，启动时再通过上面的逻辑进行校验，即可保证 记录的时间不被修改。如何防止用户删除时间标记可以在打包的时候，就记录一个打包时间作为初始化时间戳，这样即使用户第一次运行也必须有时间戳。若没读取不到时间戳标记，则说明用户人为清理了时间标记，结束运行。与业务数据强关联如果你产品运行过程中产生的业务数据很重要，也可以使用业务中的时间戳来做时钟回拨校验，例如使用最后一条订单的创建时间。若业务数据很重要，用户总不可能为了继续使用软件而删掉业务数据吧。抛砖引玉授权码的设计方案有很多，这里只是整理了我用过的几种方案，大家可以根据自己的需要对逻辑进行调整，如果你有更好的方案，欢迎留言交流。
去年环完海南之后，本以为短时间内不会再去海南环岛，后来又总想着还有中线没有骑、东线没有去东郊椰林、没有去木兰湾，都是挺遗憾的，所以今年年底又去了海南。这次的路线规划是 中线+东线 的方式，从海口走中线到五指山、在保亭向东到陵水，接着一路向北，最后走东线过木兰湾回到海口。因为休假时间是两周，所以骑行并不是很赶，基本上每天都是休闲骑，30 ~ 80 公里的样子，一共骑了 10 天，总里程 676 公里。今年的运气还行，基本上没下雨。准备和去年一样骑行用的是自己的车子，12 月 10 日去家附近的车店打包车子，然后寄到海口，去年是和朋友一起去环岛的，今年朋友因为时间的原因无法成行，只能自己一个人去环岛了。14日下午坐飞机到海口直接去联系好的海口车店取车，已经叫老板帮忙装好了，所以赶着车店下班时间去店里直接取了车子，在附近找了个酒店住宿。过程海口 ~ 定安县 57.85KM12 月 15 日，从海口出发已经是中午 12 点了，按照网上的中线这一天是要到屯昌的。海口到屯昌有大概 80 公里的距离，想到有两周的假期没必要那么赶，主打一个休闲骑，所以第一天先到了定安县。因为路程不长，下午四点就到了，从海口到定安县都是平路，骑起来也没太多压力。定安县 ~ 琼中县 104.77KM从定安到屯昌只有不到 50 公里，所以第二天的目的地设在了琼中县，一百公里左右。从定安往西骑行20多公里，然后向南拐上了 224 国道，顺着 224 国道可以一路直达三亚。中午在 屯昌 吃的午饭，吃完饭后就开始爬坡，不过到琼中这段路爬升不多，海拔只有300多，爬升也才不到 200 米，压力也不是很大。等到琼中县已经是下午五点半了，就近找了个地方住下。琼中县 ~ 五指山 78.95KM从琼中到五指山，是中线难度最大的一段路，海拔最高点 790M，断断续续的有好几个坡，一会爬升到 450 一会又下到 300 多再爬上来，到下午三点才到 毛阳镇。当时分不清是我体力的原因还是上坡的原因，我当天下午的速度很慢，遇到坡的时候只有 6 ~ 8 公里/小时的骑行速度（这个时候轮应该扎了一个小钢丝，但是没有完全没气，所以没有及时发现）。在毛阳镇吃了午饭后已经三点半了，就继续赶路往五指山市出发。从毛阳镇到 五指山要翻越中线上爬升最多的一个坡（阿陀岭），毛阳镇海拔 220 左右，需要爬升到 790M。因为后轮气不够的原因，爬升很慢，等我到山顶的时候已经六点半了，掏出手机拍了张照片，一眨眼的功夫天就开始黑了下来，打开车灯骑着车慢慢往山下走，不过好在从山顶到五指山一路下坡，半个小时就到五指山市了。五指山 ~ 保亭 35.97KM从五指山出发的时候已经时中午了，因为前一天到五指山市已经七点多了，今天不想骑太远，本来计划中这一天到陵水县的。中午出发的时候才发现后轮偏软，猜测可能是扎胎了，但一想气没有跑完，应该不是扎胎，所以只是将气补到 60 等到了保亭再看看要不要补一下。接着吃完早饭，沿着三月三大道一路向南，出了市区就开始爬坡，最高爬升到 450M 左右，到了保亭界的路牌后，开始下坡一路到保亭和三亚的分岔口。从分岔口拐向保亭方向，可能是因为昨天爬坡的原因，腿开始有点疼，后面到保亭的这段路 有一些起伏路，骑的也慢。到保亭已经下午三点了，就近找了地方住宿。入住后看了一下后轮，又只有一半的气，基本上确定是扎胎了，好在今天时间较早，可以慢慢补不着急。保亭 ~ 石梅湾 89.41KM早上九点多出发，出了保亭一路向东，一直到陵水全是平路，胎补好了速度也能快一些。这一天的上午是阴天，也没有大风，所以速度也比昨天快很多，一路20+的速度，十一多点就到了陵水。说是路过陵水，但因为直接导航的是石梅湾那边，跟着导航提前向东北方向拐去，走乡村小路，也没路过陵水县城的城镇区域。路上因为一些琐事耽误了些时间，到 223 国道的时候已经快一点了。拐上 223 国道一路向北，接下来几天的线路就和去年走东线的路线重合了。拐到223 国道向北就开始爬坡，如果你走东线+西线的话，这个坡是全程爬坡最多的一个坡，但实际爬升也才一百多米，没有什么难度，到山顶的时候也才一点多。今年走这段路的时候，发现车特别多，去年这一段基本上没有车。后来在网上才了解到 这段时间 日月湾到三亚的高速修路封掉了，所有的汽车在这一段路得走国道。这一段路级别上都是从日月湾向三亚方向去的汽车，我爬坡对向来车是下坡，一路上超车逆行，加上下坡速度都比较快，所以这段路还是挺危险的，尤其是大车逆行，离我不到一米远，从我旁边过的时候车头都扶不稳，再后面看到有大车逆行过来时就会先停下，等大车过去后再继续骑。骑到山顶后拍了张照，因为路过的车太多，车速也快，所以没有停留直接继续向北下山。向北继续骑行 20公里左右 到南桥镇，吃了午饭，先用手机定好了住的地方直接导航过去。这一天说是到石梅湾，实际上是到了石梅湾更北一点的神州半岛， 吃完饭从南桥镇开始重新导航，想着尽量多走靠近海边的线路。从南桥镇出来没多久就向东拐上了一条去往海边的路，我以为和去年走 223 国道一样，到石梅湾都是平路，结果走了没多久就开始爬坡。好在坡不长，不到一个小时就开始下坡，爬升应该还不到 100M 来着。日月湾修路，这段路不好走。到神州半岛的时候天还没黑，住下后洗了洗衣服，晾在阳台再出去吃晚饭。石梅湾 ~ 博鳌 83.89KM早上九点多从神州半岛出来，继续向北骑往博鳌镇走。从石梅湾到博鳌镇是海南环岛旅游公里中风景最好的一段路，前半段没有坡顶多是起伏路，本应是没有什么难度的一天。但不幸的是赶上台风了，虽然台风离海南挺远的，但是受台风影响，我骑东线的时候一路逆风。提到这次逆风我就来气：去年东进西出，西线就是一路吹逆风，骑都骑不动。很多人告诉我西线就是风大，所以现在流行西进东出，不受逆风的影响。没想到我今年只走东线，依然受到了逆风影响 <(T-T)>。从石梅湾到博鳌，如果一路走环岛旅游公路的话，几乎没有什么吃饭的地方，买水的地方也不多，骑到下午3点多路过一个村口，还好有猪脚饭吃。吃完之后继续往博鳌走，我印象里不到30公里就到了。继续骑行不久就到了正门岭，这是今天唯一一个可以之为坡的地方，但爬升也不多十多分钟就可以到最高点。再继续骑行一个小时就到了博鳌镇，去吃我心心念念的椰子鸡，去年和朋友骑行的时候在这里吃了一家椰子鸡，味道超好吃，以至于我今年路过博鳌镇的时候特意住在附近，又来吃了一次。博鳌 ~ 会文 43.04KM如果正常骑行的话，应该是直接从博鳌到龙楼的。我是从24年初开始喜欢文玩，盘一些手串啥的。而文昌市会文镇则是国内最大的星月菩提集散地，国内能买到的大部分星月菩提都是从这里出去的，所以我想顺路在会文镇逛逛，看看有没有喜欢的手串。最后手串没买到，但是接触到了一种新的体验。从博鳌到会文镇并不远只有40多公里，我中午从博鳌镇出发，骑了20多公里就进入了文昌界，没多久就到了文昌市区和会文镇的分岔口，在这里休息了一下，订了会文镇住宿的地方，便又继续出发。会文镇因为小的缘故吧，住宿的条件稍微差一些，但也还算干净。住下后已经三点多了，收拾一下到附近的“会文佛珠交易市场”转了一会，感觉线下的种类还是没有线上的多，但是量很大（应该是很多商家在这里批发原材料，回去再加工）。逛完出来四点半左右，后面的时间就很悠闲了，不用继续骑车，就在会文镇的街上溜达，我个人是很喜欢去一个陌生的地方，街头漫无目的的闲逛。走了一会之后发现会文镇这边有很多“中西茶餐厅”，其实就是海南这边说的 "老爸茶"（说到这里，推荐看一个b站up主的海口vlog：野的海口），就随便找了一家坐下来，点了一些东西一边吃一边打发时间。也不是第一次吃老爸茶了，去年和朋友一起时，在海口去了网上好评较多的正方华体验过一次。上一次在海口的感受就是感觉老爸茶和我们这边的小吃街差不多，坐下吃小吃，吃完就走，没有什么特别的感受。但这次在会文镇，对老爸茶有了一些新的理解，会文镇这边老爸茶店铺的老板大多是做镇上老顾客的生意，所以感觉会文镇的老爸茶餐厅更有生活气息、烟火气一些。点一些小吃、一杯茶，坐了一个多小时，听着当地人在茶餐厅里聊天，坐到五点多人家要关门了，才继续在镇上溜达着往回走。（一个多月过去，我依然时不时想起那个下午的在会文镇的放松和惬意）。在镇上溜达了一会，走回去的路上天已经开始黑了，晚霞是粉红色的，搭配着小镇上宁静的街道，别有一番景色。会文 ~ 龙楼 57.12KM早上八点多起来，去了会文镇另外一家茶餐厅吃早餐，待到十点多就出发继续骑行往龙楼方向。出发没多久就拐上了海南环岛旅游公路，过了文昌市区和清澜大桥，就开始进入东郊椰林的区域，因为去年是直接从海口走 S201 省道到的文昌市，所以没有走东郊椰林一直觉得挺遗憾的，今年必然是要补上这个遗憾的。前面提到过，这几天有台风经过，博鳌到会文这段路走的里面，没有太靠海边感觉影响不大。但是到了东郊椰林，逆风就开始了，不过在椰树下骑行，风的影响不是很大。出了东郊椰林，没有太多遮挡，环岛旅游公路就贴着海边蜿蜒。风，真大 <(T-T)>！一直到龙楼镇，风都没停过。逆风慢慢骑，到龙楼镇已经是下午六点了，在附近找了住的地方，洗了衣服 就去龙楼的街上逛，主要是看看有什么吃的，吃了一碗抱罗粉，或许是因为离抱罗镇不远把，味道很不错，应该算很正宗。龙楼 ~ 潮滩鼻 60.82KM在很多环岛骑行攻略上，从海口到龙楼只有 125KM，但我在龙楼实际导航下来，有 140 公里左右。如果早点出发倒是也能骑完，问题是我运气不好碰上逆风了，所以就分成两天来骑，从龙楼到铺前镇没有找到合适的住宿点，最好在飞登的攻略中，找到潮滩鼻有一家可以住宿的地方（但这里确实除了住宿也没什么，吃饭也只能在驿站吃，所以不是很推荐住在这里）。从龙楼出发时已经是早上九点半，出发时就下着小雨，出了龙楼镇又开始刮起了大风。一路逆风骑速度在15左右起不来，这个大风一直伴随着我到铺前镇。本来出了龙楼后是一直沿着海南环岛旅游公里走的，结果风越来越大，雨也越来越大。而这一段路的两遍都是拦腰断掉的大片树林，想来也是前两个月“台风摩羯”造成的影响。因为风的影响，继续沿着海边骑行速度越来越慢，于是计划从走里面过翁田镇，到翁田镇的时候已经是一点多了，顺便吃了午饭，休息了一下就继续向潮滩鼻骑。出了翁田镇没多久又回到了海南环岛旅游公路上，还是不过这里的风小了一些，也没有下雨，比刚出龙楼要强得多。到了潮滩鼻，联系客栈的老板住了下来，这是海边很小的一个村子，大概只有十几户的样子，因为时间还早就在村子附近转了一下。潮滩鼻 ~ 海口 66.85KM第二天早上九点从潮滩鼻出发往海口走，刚出发不久就看到海边的一排风车，想来应该是木兰湾的风车海岸了。今天的风小了很多，因为只有60公里，所以也不赶，一路慢慢骑，十点多就到木兰灯塔了，在木兰灯塔拍了照，休息了一会就继续向海口骑，印象里 从木兰灯塔向西骑行 会下一个挺长的坡，如果是东进西出的话，应该是需要花一些时间爬坡的。到了铺前镇，过了海文大桥就是海口市区，这次 中线+东线 骑行的行程也就由此结束了。这次骑行补上了中线的经历，以及东线 东郊椰林+木兰湾的遗憾，也算是满足了我最初的规划。结尾到了海口后又在海口待了两天，重新去了一次正方华，和去年的感受差不多，第二天又去了正方华对面的东隆茶店，这里没有那么网红，本地人会多一些，至于味道其实大差不差。连着两年来海南骑车，走完了 东线、西线、中线，全是补全了海南环岛的所有遗憾了。去年是和朋友两个人，今年是我自己一个人。各有优点吧，一个人自由，两个人结伴出行路上不会感觉无聊。至于骑行的花费、自行车邮寄等问题可以参考我上一篇海南游记：海南环岛：东线+西线回家之后发现网上开始推海南环热带旅游公路，后面有计划再说吧，暂时是不打算再去海南骑车了。