[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"list-\u002Farticles\u002Ftech":3},{"items":4,"total":141},[5,23,34,43,55,67,79,88,99,108,120,131],{"id":6,"type":7,"slug":8,"title":9,"date":10,"category":11,"tags":12,"body_markdown":17,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":22},142,"article","ai-robot-boom-human-society-2026","与AI对谈系列：AI和机器人大爆发会给人类社会带来什么深刻变化","2026-08-04 00:00:00","tech",[13,14,15,16],"AI","机器人","生产力","未来","\n## 前言\n\n昨天和朋友聊天，大家都看到了AI已经给很多行业带来了深刻变化，忍不住聊到，当这些变化持续发生，未来会有什么深刻的变化。\n\n我的判断是：生产力大幅提升，人类工作的必要性逐步减少，最终人的主要功能——生产——消失，机器人干活，人类享受成果。\n\n回家路上又想起来这个话题还可以延伸，好像刚好契合马斯克最近的言论，即货币重要性会下降。于是回来和AI讨论了一会，整个逻辑和我的推演非常一致，因此整理一下以作记录和分享。\n\n这个话题非常具有前瞻性，因此也非常推荐同样具有前瞻性的一本书《未来简史》。这本书已经准确预言了很多事，例如逆全球化和战争的必然发生以及AI的成熟。下文讨论的问题有相当大一部分都包含在这本书中，但作者并没有下文这么乐观，推荐一看。\n\n此外，与AI讨论问题是一件很有趣的事情，后续我也许也会再发更多的话题，和大家分享。\n\n\u003C!-- more -->\n\n## 正文\n\n### 1. AI和机器人高速发展后，生产力会不会大爆发？\n\n会，而且爆发的潜力比以往任何技术革命都更大。\n\n简单说，以前的机器主要替代人的体力（比如蒸汽机、流水线），计算机主要辅助脑力。现在的AI能处理分析、写作、设计、决策等认知工作，机器人又能灵活完成各种体力任务。两者结合，相当于同时把“脑力和体力”都交给机器。结果是：同样的时间、同样的资源，能生产出远超今天的商品和服务，单位成本大幅下降。很多专家认为，这可能带来远高于过去几十年的生产力增长速度。\n\n### 2. 这会导致人类必要劳动力明显减少吗？\n\n会。\n\n当机器能以更低成本、更高效率完成大部分工作时，企业对人类劳动力的需求自然下降。工厂、物流、客服、基础办公、数据分析，甚至部分创意和专业服务，都可能被大幅自动化。很多人可能不再需要每周工作40小时才能维持生活。但这不等于“所有人都永久失业”，而是“必要劳动”减少，工作从“为了生存必须做”逐渐变成“可以选择做”。\n\n### 3. 物质生活条件反而会极大丰富吗？\n\n有很大可能。\n\n生产力大爆发意味着供给大幅增加、成本持续下降。食品、能源、交通、基础住房、医疗检测、教育内容等，都可能变得极其便宜甚至接近免费。就像今天手机里的地图、搜索、音乐几乎不花钱一样，未来很多物质需求也可能进入“不用精打细算”的状态。普通人即使工作减少，物质生活水平仍可能明显高于现在。\n\n### 4. 历史上每一次生产力大爆发都会导致大量失业，但之后会有新职业出现填补空缺。这一次有何不同？\n\n这是最关键的区别之一。\n\n过去确实如此：农业机械化让大量农民失业，但工厂出现了；工厂自动化让工人减少，但服务业和办公室工作增加；计算机普及淘汰了一些文书岗位，但又创造了程序员、网络工程师、数据分析师等新职业。整体就业最终回升，生活水平提高。\n\n这一次可能不同，原因主要有四点：\n\n覆盖面更广：以前技术主要冲击某一类工作（体力或简单脑力），这次同时冲击体力和复杂认知工作，连很多高学历、高技能岗位都可能被替代。\n\n速度可能更快：AI可以快速复制和学习，机器人一旦成熟也能规模化生产，转型时间可能比过去几十年压缩得更短。\n\n机器能自我改进：AI可以参与设计更好的AI和机器人，形成加速循环，而不是像过去那样主要靠人类发明。\n\n新工作可能更难大规模出现：如果机器几乎能做所有“有经济价值”的任务，那么“专门留给人类”的新职业空间会变窄。即使出现新工作，也可能很快再次被AI学会。\n\n因此，这一次更有可能出现“长期必要劳动需求下降”，而不是简单重复“失业→新职业填补”的旧循环。当然，人类仍可能在情感陪伴、高端创意、伦理决策、人际信任等领域保持优势，但这些岗位数量未必能完全消化被替代的劳动力。\n\n### 5. 货币重要性下降的核心逻辑是什么？\n\n货币的本质是“稀缺资源的分配工具”。在资源有限时，人们用货币来交换、计价和储蓄，确保东西不会被随意浪费。\n\n当AI和机器人让绝大多数商品和服务变得极其丰富、成本极低时，很多东西不再需要通过货币来“争夺”。基础生活需求可以近乎免费或极低成本满足，货币就从“活下去的必需品”变成“在丰裕基础上表达偏好的工具”。简单类比：今天的空气和自来水几乎不需要花钱考虑，未来很多商品也可能进入类似状态。\n\n### 6. 从“靠劳动赚钱才能活”到丰裕，具体怎么转变？\n\n现在大多数人必须先工作挣钱，再用钱买生活所需。机器接管生产后，社会创造的财富（盈余）可以更多通过以下方式直接分配给每个人：\n\n1. 全民基本收入或全民高收入；\n2. 让普通人持有AI和机器人相关资产的股份；\n3. 大幅扩张免费或低成本的公共服务（能源、交通、住房、医疗、教育）。\n\n这样，个人生活水平不再主要取决于“你个人挣多少钱”，而是更多取决于整个社会如何分享机器带来的巨大产出。\n\n### 7. 价格和货币会完全消失吗？\n\n不会。\n\n货币和价格体系仍然是高效的信息与激励工具。它们会继续存在，但使用场景会变化：基础物质需求变得很便宜，货币更多用于真正稀缺或个性化的东西，比如独特位置的房子、稀有艺术品、顶级旅行体验、纯人类提供的情感或创意服务等。人们不会再为柴米油盐发愁，但会为“更特别的东西”付钱。\n\n### 8. 哪些东西会继续保持稀缺？\n\n即使物质极大丰富，以下东西仍可能稀缺：\n\n1. 人的注意力和时间；\n2. 独特的地理位置和土地；\n3. 真正原创的人类创意与情感连接；\n4. 某些关键自然资源；\n5. 地位性、炫耀性的商品和服务。\n\n这些领域货币仍会发挥重要作用。\n\n### 9. 实现这种丰裕的主要风险和约束是什么？\n\n技术本身不能自动带来人人共享的好结果。主要风险包括：\n\n分配不均：如果机器创造的财富主要集中在少数资本所有者手中，普通人可能失去工作收入又分不到红利，导致严重不平等。\n\n过渡期阵痛：大规模岗位消失可能带来失业、技能错配和社会动荡，需要强有力的社会保障和再培训。\n\n物理限制：能源、关键矿物、环境承载力等仍会制约“无限生产”。\n\n制度滞后：教育、法律、福利体系如果跟不上技术速度，问题会被放大。\n\n### 10. 最终结果主要由什么决定？\n\n不取决于AI和机器人“会不会出现”，而取决于我们如何使用它们。关键在于：所有权如何分配、是否建立有效的再分配机制、社会如何帮助人们适应、以及我们是否能在物质丰裕后重新找到生活的意义和价值。技术提供了可能性，制度和选择决定最终是“共享的丰裕时代”，还是“少数人更富、多数人更焦虑”的分化社会。\n",null,0,"2026-08-28 04:37:17","published","",{"id":24,"type":7,"slug":25,"title":26,"date":27,"category":11,"tags":28,"body_markdown":33,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":22},141,"ai-media-content-automation-2026","记录2026年上半年的AI视频生成实践","2026-07-20 00:00:00",[13,29,30,31,32],"WorkBuddy","FFmpeg","video","automation","\n今年三四月份，我参加了一个实习项目，主题是用AI做自动化媒体内容生成。目标是搭建一条全自动的内容生产线——从选题策划、脚本撰写，到图像生成、视频合成，再到最后的发布上线。\n\n两个月下来，产出了两部成语故事动画短片《田忌赛马》和《守株待兔》，总共在YouTube上发布了三个版本。（见\u003Chttps:\u002F\u002Fwww.youtube.com\u002F@vivimemo>）\n\n\u003C!-- more -->\n\n## 流程概览\n\n最终沉淀的视频生产流程包含八个环节：\n\n1. 故事脚本撰写\n2. 角色图像生成\n3. 场景图像生成（可选）\n4. 分镜图像生成\n5. 旁白音频合成\n6. 视频片段生成\n7. 图像片段或视频片段合成\n8. 字幕叠加\n9. 成品输出\n\n每个环节都有多种工具可选，例如：\n\n- 脚本撰写：ChatGPT、Claude、豆包、WorkBuddy等\n- 图像生成：WorkBuddy（混元）、火山引擎文生图API、Seedream模型等\n- 视频生成：Seedance 2.0、可灵等\n- 语音合成：EdgeTTS、macOS系统语音等\n\n最终选择的组合是：\n\n- 基于静态图像的视频生成：火山引擎文生图API（图像）、EdgeTTS（语音）、FFmpeg（视频合成），由WorkBuddy负责统一调度。\n- 基于动态视频片段的视频生成：小云雀短剧一站式生成。\n\n## 图像生成\n\n使用火山引擎文生图API，模型为 `high_aes_general_v30l_zt2i`，分辨率固定为 1344×768（16:9），响应格式为 base64。\n\n提示词采用结构化模板：`风格前缀 + 场景 + 角色 + 动作 + 镜头 + 氛围`。\n\n## 语音合成\n\n使用 EdgeTTS，语音模型为 `zh-CN-XiaoxiaoNeural`（中文女声）。每段旁白独立生成 MP3，然后用 FFmpeg concat 分离器无损拼接：\n\n```bash\nffmpeg -f concat -safe 0 -i audio_list.txt -c copy output.mp3\n```\n\n每段音频的精确时长用 `ffprobe` 获取，用于后续视频片段的时长对齐。\n\n> 也是通过这个项目让我了解了 EdgeTTS，它原本是微软 Edge 浏览器的语音合成引擎，后来被逆向出来，成为一个很多人都在用的语音生成工具。整体来说，它的语音质量还是比较好的。\n\n## Ken Burns 动画\n\n对静态图像施加缓慢缩放效果，模拟摄像机运动。奇数片段放大，偶数片段缩小，交替进行：\n\n```\n奇数帧：zoompan=z='min(zoom+0.0008,1.08)':d=FRAMES:s=1280x720\n偶数帧：zoompan=z='max(zoom-0.0008,1.0)':d=FRAMES:s=1280x720\n```\n\n参数说明：每帧缩放 0.0008 倍，持续 FRAMES 帧后缩放 0.08 倍，输出分辨率 1280×720。\n\n## 字幕叠加\n\n字幕用 FFmpeg drawtext 滤镜实现。顺序问题：必须在所有视频处理完成后再叠加字幕。如果在 Ken Burns 动画之前添加字幕，字幕会随画面缩放变形，出现重影。\n\n中文字体需显式指定系统字体路径：\n\n```\nfontfile=\u002FSystem\u002FLibrary\u002FFonts\u002FSTHeiti Medium.ttc\n```\n\n默认字体不支持中文，会显示为方框。\n\n## 视频合成与发布\n\n视频片段用 FFmpeg concat 合并：\n\n```bash\nffmpeg -f concat -safe 0 -i concat.txt -c copy output.mp4\n```\n\n`-c copy` 启用无损流复制，不需要重新编码。\n\nYouTube 发布通过 WorkBuddy 的 `youtube-publisher` Skill 实现。需要在 Google Cloud Platform 创建应用获取 YouTube Data API v3 凭证，OAuth 2.0 桌面应用认证，Token 自动续期。\n\n## 视频生成方案迭代\n\n第一部《田忌赛马》采用上述方案——静态图像 + Ken Burns 动画。画面动态性有限，类似定格动画效果。\n\n第二部《守株待兔》改用字节跳动小云雀短剧 Agent 的 Seedance 2.0 模型，直接从文本生成动态视频片段。角色一致性更好，动作自然，但无 API 接口，需在平台界面手动操作。\n\n两种方案的适用场景不同：简单叙事适合前者，成本低、可控性强；需要视觉表现力则用后者。\n\n## 三层架构\n\n将整个流程封装为三层：\n\n| 层 | 组件 | 职责 |\n|---|---|---|\n| **Agent 层** | WorkBuddy | 统一任务调度与编排 |\n| **工具层** | 火山引擎、EdgeTTS、FFmpeg | 各环节专用工具 |\n| **人工层** | 人工审核节点 | 分镜、图像、成片三处质量把控 |\n\n## 遗留问题\n\n- 跨场景角色形象仍有细微差异\n- 背景音乐筛选依赖人工，难以完全自动化\n- 小云雀无 API，批量生产效率受限\n",{"id":35,"type":7,"slug":36,"title":37,"date":38,"category":11,"tags":39,"body_markdown":42,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":22},146,"there-was-no-path-2026","世上本没有路，走的人多了也便有了路","2026-07-18 15:00",[13,40,41],"编程","思考","\n大概十来年前，在知乎上看过一个讨论得非常热烈的问题：自然语言编程能否实现？\n\n我记得当时自己的观点是：不能。\n\n核心观点如下：\n\n1. 编程语言的语法掌握是很容易的事，这不是什么门槛\n2. 但编程是一个不断分解细化问题的过程，自然语言对问题的描述是很不清晰的，很难转换为确定性的代码\n\n\u003C!-- more -->\n\n## 未来已来\n\n十来年前讨论这个问题多少还有些科幻色彩，但在今天，AI 已经几乎可以完全自主完成各种编程任务了。有些人只用一句话就生成了整个软件，其中的交互细节、视觉风格、Edge Case 处理，AI 全都做了决定，而且结果看起来还不错。\n\n回顾人类生产软件的流程，细化问题并逐一回答是很重要的一步。一句话的需求背后有漫长的研发过程——需求定义、评审、技术评审、开发、测试——每一步都在把未回答的问题收敛为确定的细节。\n\n这让我重新思考当年的问题：自然语言明明是很不清晰的语言，AI 又跳过了这个需求分解细化的过程，直接按它自己的理解执行，理论上给最终结果带来了不确定性。但为什么实际上又能转换为确定性的代码？\n\n## 现在这条路是怎么走出来的\n\n如果我们仔细拆解 AI 做得很好的那些场景，会发现一个共同点，即它们都是**常见的模式**。\n\n我以前会举一个例子：当我们说“做一个搜索框”，背后实际上有很多细节需要处理：输入框的样式、placeholder 的文案、输入时的交互、防抖、搜索结果的展示、loading 状态、空状态、超时提示等等。\n\n但是，“做一个搜索框”这个模式在 GitHub 上出现了成千上万次，Stack Overflow 上有无数讨论，各种各样的实现、各种 Edge Case（debounce 设多少毫秒、空状态怎么展示、loading 状态如何处理、超时怎么提示），早就被社区反复讨论并形成了共识方案。\n\n当一片空地，被无数人以同样的路径反复走过无数次之后，空地上就形成了一条路。当无数人做过搜索框之后，社区就形成了一个共识：搜索框应该是这样的。\n\n现代大语言模型的训练数据覆盖了海量代码仓库、技术文档、设计规范，因此它学习到了一个典型的搜索框应该长什么样。当你说“做一个搜索框”时，模型并不是在重新思考“防抖应该设多少毫秒”，而是直接拿出了最典型的那一个方案——300ms。\n\n所谓的“细节决策”，对模型来说其实是“预测一个最多人用的可能性”。也就是说，AI 的成功不是因为它真的在推理，而是因为它记住了人类的决策结果是什么样的。\n\n## 世上本没有路\n\n“编程是一个不断分解细化问题的过程”——这句话在今天依然正确。但也必须承认，这样的说法忽略了另一面：绝大多数编程工作并没有什么开创性，而是在重复前人已经走过的路。当一个模式被走了足够多次之后，AI 也可以获取这些共识，直接给出经过验证的结果。\n\n鲁迅说：世上本没有路，走的人多了，也便成了路。\n\n十多年前我关注的是前半句——世上本没有路，所以需要人去不断做决策，去开路。十多年后，我发现后半句同样重要——走的人多了，也便成了路。AI 是那个已有路的人，它最擅长的就是沿着已有的路走下去。\n\n而在未知领域不断去开新路这件事，仍然是我们人类的事，这大概就是人类的价值所在吧。\n",{"id":44,"type":7,"slug":45,"title":46,"date":47,"category":11,"tags":48,"body_markdown":53,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":54},143,"new-nas-black-synology","新入一台黑群晖NAS","2026-06-20 18:00",[49,50,51,52],"NAS","群晖","数据存储","家庭服务器","\n5月底，正值暑假之前，我们全家出去旅游了一阵。回来之后一打开家门，就听到了群晖报警的声音。赶紧进入web界面看了一下，显示硬盘出现了问题，存储池已降级。\n\n![群晖报警截图](\u002Fassets\u002Ftech\u002F2026\u002Fnew-nas-black-synology\u002F01.png)\n\n我的第一台群晖NAS是2019年买的DS218+，2024年坏掉之后买了二手的DS218j顶着用。这次出问题的正是这台二手的DS218j。\n\n## 到底是硬盘还是NAS的问题？\n\n一开始，我是相信群晖系统给出的信息的，认为是硬盘坏了。但这块硬盘是我在2025年7月才换上去的新硬盘，才用了不到一年，坏得这么快实在有点不可思议。\n\n于是我尝试将两块硬盘调换位置，看看问题是否会解决。结果调掉之后一开机，发生了更离谱的事：机器检测不到任何硬盘！然后我将“坏掉”的硬盘拿掉，将好的硬盘单独放在机器里，结果无论放哪个盘位，都依然无法检测到硬盘。\n\n\u003C!-- more -->\n\n![检测不到硬盘](\u002Fassets\u002Ftech\u002F2026\u002Fnew-nas-black-synology\u002F02.png)\n\n到这里，问题就更复杂了，是NAS坏了还是硬盘坏了？如果是硬盘坏了的话，那单独放置好的硬盘也应该能被检测到才对。\n\n## 群晖也不是非用不可\n\n如果是NAS机器坏掉的话，那到此为止，我已经坏了两台群晖设备了。再加上群晖一贯超低的性价比，我也不想再继续投入了。于是我开始考虑其它方案。\n\n### 方案一：mac mini + 双盘位硬盘盒\n\n首先考虑的是使用现成的mac mini + 双盘位硬盘盒的方案。毕竟我已经有一台mac mini了，它的功率也很小，计算能力更是完全不用担心。\n\n但最终没有选择这个方案的原因是：mac系统的文件系统不一样，它完全不识别Linux系统使用的Ext4文件系统。因此如果要换的话还要重新格式化硬盘，在此之前我需要备份所有的数据，而我手头上没有足够大的存储设备。\n\n除此之外，硬盘盒也需要单独购买，虽然价格不贵，但也是一笔支出。\n\n### 方案二：小主机 + 双盘位硬盘盒\n\n既然mac不行，那如果买一台能使用Linux系统的小主机呢？于是又查了一下各种小主机。\n\n有一些小主机还是让人心动的，但是价格也比较贵，需要上千元的预算才能买到一台性能不错的主机了。如果要图便宜的话，也得三五百才能买到一个能用的主机了。\n\n硬盘盒依然需要单独购买。\n\n### 方案三：成品NAS设备\n\n既然预算都到三五百了，要不再看看有没有成品NAS设备呢？使用成品NAS还能省掉单独购买硬盘盒的钱。此外内置硬盘位的连接稳定性也往往大于外置硬盘盒。\n\n于是就发现了早些年非常流行的一台黑群晖NAS，叫“星际蜗牛”。价格也不贵，300元以内搞定，带内存和系统盘。其实很早就关注到这么一批机器，但早些年没有实际需求，因此也没有入手。\n\n这次正好可以下手，入了一台“星际蜗牛”黑群晖NAS（D款）。\n\n![星际蜗牛](\u002Fassets\u002Ftech\u002F2026\u002Fnew-nas-black-synology\u002F03.jpeg)\n\n(图片来自网络)\n\n## 黑群晖的初始化\n\n机器到手之后，把原来DS218j中使用的两块硬盘都装进去，然后开机。此时群晖会自动检测到硬盘，且知道硬盘中原来有系统。但因为硬件型号不一样，需要重新安装系统。\n\n![黑群晖初始化截图](\u002Fassets\u002Ftech\u002F2026\u002Fnew-nas-black-synology\u002F04.jpg)\n\n有一个点要特别小心：因为黑群晖有比较复杂的引导过程，所以安装的系统要和机器引导系统的大版本保持一致。比如我买的这台机器引导系统是DSM 7.2的，那么安装的系统也必须是DSM 7.2的，否则就可能出现安装完系统之后无法正常引导启动的问题。\n\n![黑群晖初始化截图](\u002Fassets\u002Ftech\u002F2026\u002Fnew-nas-black-synology\u002F05.jpg)\n\n![黑群晖初始化截图](\u002Fassets\u002Ftech\u002F2026\u002Fnew-nas-black-synology\u002F06.jpg)\n\n为保险起见，我选择了去官网下载小版本完全一样的系统来安装。然后选择手动安装系统，将下载好的系统镜像上传到安装界面，安装完成之后就可以正常使用了。\n\n![黑群晖初始化截图](\u002Fassets\u002Ftech\u002F2026\u002Fnew-nas-black-synology\u002F07.jpg)\n\n进入系统之后，第一件事就是检查硬盘的状态，系统仍然报告有一个硬盘出现了问题，但这个状态可能是之前的坏盘状态遗留下来的。因此我先将坏盘从存储池中移除，然后花了大半天做了一个SMART自检，显示硬盘没有问题。重启系统后，就看到了一块未使用的硬盘，说明之前的坏盘状态已经被清除了。然后通过“修复”功能将这块硬盘重新加入存储池，修复完成之后就恢复正常了。\n\n至此，新的黑群晖NAS就成功运行起来了，完整替代掉了之前DS218j。\n\n## 公网访问\n\n使用群晖自己的NAS设备（白群晖）时，可以直接使用群晖提供的QuickConnect服务来实现公网访问。但黑群晖没有这个功能，所以需要自己设置。我选择使用Cloudflare Tunnel来实现公网访问。\n\n设置过程并不复杂，主要是需要在Cloudflare上创建一个Tunnel并绑定域名，最后在群晖上（或局域网内其它设备上）安装Cloudflare Tunnel的客户端，并将Tunnel运行起来即可。此时就可以通过绑定的域名来从公网访问群晖了。\n\n不过公网访问并没有解决所有的问题，我们还需要解决一个问题：如何让客户端软件（例如Synology Photos）正确地找到群晖的地址。\n\n如果直接使用Cloudflare Tunnel提供的地址，则无论在什么网络下使用都会经过Cloudflare的服务器转发，导致在内网访问时速度也比较慢。而我希望客户端软件在内网访问时直接访问群晖的局域网地址，在外网访问时才通过Cloudflare Tunnel访问。\n\n要实现这个功能，最主流的思路就是DNS解析分流：同一个域名，在内网访问时解析到局域网地址，在外网访问时解析到Cloudflare Tunnel绑定的地址。\n\n实现这个功能需要依赖局域网网络相关的配置，例如在路由器上设置DNS解析规则。恰好我的路由器支持在局域网内设置DNS解析规则，所以我就设置了一个规则，让访问特定域名的请求在局域网内解析到群晖的局域网地址，这样就实现了内外网访问的无缝切换。\n\n## HTTPS证书\n\n除了设置DNS解析规则之外，还有一个需要注意的点是HTTPS证书的问题。因为Cloudflare Tunnel提供的地址是HTTPS的，所以在外网访问时是没有问题的。但在内网访问时，如果群晖不能提供一个有效的HTTPS证书，客户端软件就会提示安全警告。因此我们还需要在群晖上设置一个证书，来保证在内网访问时也能正常使用HTTPS。\n\n这里我使用了[Certimate](https:\u002F\u002Fdocs.certimate.me\u002F)这个工具。在群晖的套件中心可以直接下载Certimate套件，安装完成之后建立一个工作流，申请域名的HTTPS证书，然后在部署这一步选择群晖DSM。\n\n![Certimate工作流截图](\u002Fassets\u002Ftech\u002F2026\u002Fnew-nas-black-synology\u002F08.png)\n\n很神奇的是，Certimate集成了群晖DSM的部署功能，只要提供群晖的管理员账号和密码，就可以直接将申请到的证书部署到群晖上了。部署完证书之后，在内网访问时就不会再有安全警告了。\n\n## 关于安全\n\n严格来说，上述方案有安全风险：\n\n1. 将NAS直接暴露在公网，可能会被攻击者扫描到并尝试攻击，如果NAS的软件或者底层的操作系统存在漏洞，安全风险就会比较大\n2. 将账号密码暴露给第三方工具（例如Certimate）也存在安全风险，如果这个工具的安全性不好，或者被攻击者入侵了，那么账号密码就可能被泄露\n\n因此在使用上述方案之前，最好先评估一下自己的安全需求和风险承受能力。如果对安全要求比较高，或者不想冒太大的风险，那么可能需要考虑一些更安全的方案，例如使用VPN来访问NAS。\n\n## 结语\n\n买新的“星际蜗牛”算是赌对了，最终证明硬盘并没有问题，真正的问题大概是出现在之前的DS218j上了。\n\n新的设备从功能上来说，完全不输之前的DS218j，甚至因为硬件配置稍好一些，整体性能比DS218+都更好一些。\n\n要说有不足之处的话，可能就是这个设备的功耗肯定是高一些，发热量也比较大，因此我将它从客厅的封闭电视柜中移出来了，放到了书房的钢琴上。不过好在这不算大问题，不影响使用。\n","https:\u002F\u002Fassets.toobug.net\u002Fassets\u002Ftech\u002F2026\u002Fnew-nas-black-synology\u002F01.png",{"id":56,"type":7,"slug":57,"title":58,"date":59,"category":11,"tags":60,"body_markdown":65,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":66},144,"onedrop-in-three-hours","Onedrop：三小时内构建的临时文件传输工具","2026-06-15 00:00:00",[61,62,63,64],"onedrop","file-transfer","cloudflare","ai","\n\n![Onedrop 分享页面展示了一个包含文件列表的简洁界面](\u002Fassets\u002Ftech\u002F2026\u002Fonedrop-in-three-hours\u002Fonedrop-interface-share.png)\n\n体验地址：\u003Chttps:\u002F\u002F0x1.one>\n\n> 本文由AI辅助生成，经过人工编辑润色。\n\n我开发了 Onedrop，为了解决一个不断重复出现的文件传输难题。\n\n“文件现在就得传过去。但没有在同一个 Wi-Fi 下，也没有安装局域网传输工具，设备品牌不同无法使用 AirDrop，两端也没有共同的社交软件。”\n\n“手机传到电脑。安卓传到 iPhone。访客传到会议室公用电脑。”\n\n“文件本身很简单，但传输过程却很麻烦。”\n\n\u003C!-- more -->\n\n## 促使改变发生的阻力\n\n我一次又一次地遭遇同样的事情，虽然市面上有一些工具，但环境往往是使用这些工具的阻碍。\n\n大多数临时分享工具使用又长又复杂的 URL。如果你能直接点击链接，它们确实好用；但如果你必须在另一台设备上手动输入地址，过程就会变得异常痛苦。在那一刻，每一个多出来的字符都显得代价高昂。\n\n![典型文件分享工具的长 URL 截图，展示了在不同设备上手动输入的难度。](\u002Fassets\u002Ftech\u002F2026\u002Fonedrop-in-three-hours\u002Ftypical-sharing-tool.png)\n\nOnedrop 的出发点只有一个：让接收者无需任何配置就能快速加入。\n\n- **快速**：无需登录或注册账号。\n- **简短**：6 位取件码，易于记忆和输入。\n- **临时**：默认 3 小时有效，最长不超过 24 小时。\n\n没有繁文缛节，只有高效传输。\n\n## 设计：漂亮而实用\n\n界面设计非常干净，因为使用场景通常很紧迫。人们在分享文件时，往往处于移动中、交谈中，或者正在切换设备。界面应该减少用户的思考负担。\n\n我们选择了一种极简、实用驱动的美学。锐利的线条、高对比度的边框和结构化的留白取代了装饰性的模糊或圆角。它给人的感觉像是一个趁手的工具，而不是一个玩具。\n\n- 每个步骤只保留一个核心操作。\n- 取件码和状态在视觉层级中占据首位。\n- 措辞直接，无装饰性文案。\n- 取件码和时间戳这类严谨的数据使用 IBM Plex Mono 字体。\n\n每一个元素都带有明确的意图。极简并非更少，而是恰到好处的帮助。\n\n![Onedrop 简洁明快的界面截图，展示了专注于实用性的取件码输入页面。](\u002Fassets\u002Ftech\u002F2026\u002Fonedrop-in-three-hours\u002Fonedrop-interface-home.png)\n\n## 三小时完工\n\n在 AI 的辅助下，Onedrop 从一个想法变为可用的产品仅用了大约三小时。价值不仅在于速度，更在于快速迭代的能力。我们可以在一个短周期内完成测试、调整和再次测试，而不是把问题拖上好几个月。\n\n这改变了我们构建小工具的方式。如果一个问题是真实存在的，它就应该能够被立即解决。\n\n## 技术\n\n架构简单且可靠。我们使用 Cloudflare R2 进行文件存储和元数据管理。有效期由后端强制执行，并通过定时清理任务自动移除过期的空间和文件。\n\n当“立即发送”是核心任务时，基础设施的质量也是体验的一部分。全球边缘分发保证了极低的响应延迟，这对于设计上就是短时效的传输窗口来说至关重要。\n\nOnedrop 只解决一个问题：在不同类型的设备之间快速发送文件。它专注于临时传输、简单步骤和可靠的速度。\n","https:\u002F\u002Fassets.toobug.net\u002Fassets\u002Ftech\u002F2026\u002Fonedrop-in-three-hours\u002Fonedrop-interface-share.png",{"id":68,"type":7,"slug":69,"title":70,"date":71,"category":11,"tags":72,"body_markdown":78,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":22},145,"password-manager-i-am-using","我的密码管理方案","2026-01-23 00:00:00",[73,74,75,76,77],"password","manager","1password","bitwarden","lastpass","\n我已经不记得第一次使用密码管理软件是什么时候了，但肯定已经超过10年了。最近我又换了个密码管理软件，刚好也有朋友在咨询这件事情，就想起来整理一下。\n\n## 一些关于密码和管理软件的科普\n\n聊到密码管理，就必须得提一下关于密码安全的一些常识，以及密码管理软件的一些基本功能。有相关知识储备的朋友可以直接跳过这一大段。\n\n### 密码的本质\n\n如果用专业名词来说的话，其实“密码”这个词是错误的，我们常说的密码，其实是一种口令。\n\n三国演义中有一个关于口令的故事广为人知：\n\n> 曹操与刘备在汉中交战，双方相持不下。曹操的军队被困在斜谷，进退两难。\n>\n> 一天晚上，曹操正在吃晚饭，厨师端上来一碗鸡汤。曹操看着汤中的鸡肋骨，若有所思。这时，大将夏侯惇进来请示当晚的口令，曹操看着碗中的鸡肋，随口说：\"鸡肋。\"\n>\n> 主簿杨修听到这个口令后，立刻开始收拾行装准备撤退。夏侯惇不解，问杨修为什么。杨修解释说：\n>\n> \"鸡肋这种东西，食之无味，弃之可惜。现在我们进不能胜，退又怕人笑话，正如鸡肋。丞相已经决定撤军了，所以我在准备行装。\"\n>\n> 果然，第二天曹操就下令撤军了。\n\n\u003C!-- more -->\n\n因此，本质上密码就是一种约定：天知地知，你知我知。也就是只有自己（用户）和系统（网站\u002FAPP）知道密码，没有其他人知道。而如果因为某种原因，有第三个人知道了密码，那你的身份就会被盗用。\n\n### 密码泄露的手段\n\n密码泄露的手段有很多种，而且这里面的问题可能出现在自己（用户）身上，也可能出现在系统那一边，甚至有可能谁也没出问题，但密码被人猜到了。\n\n具体而言，大致分为如下几种：\n\n1. 用户自己泄露：例如将密码写在纸上、写在不安全的地方（如微信聊天记录）或者被人偷窥\n2. 系统泄露：即系统（网站\u002FAPP）中的密码数据被人窃取了\n3. 密码过于简单，被猜到或者被试出来了\n\n其中，系统泄露数据泄露并不一定会直接导致用户密码被盗窃，最终结果取决于系统是否为用户密码采取了合适的加密措施，这里不深入展开。\n\n### 如何提升安全性\n\n与上述密码泄露的手段一一对应，我们可以采取如下措施来提升安全性：\n\n1. 不要将密码记录在不安全的地方\n   1. 你可以不记录密码，只使用自己的大脑来存储，但这样容易忘记密码，也非常容易在不同的系统使用相同的密码\n   2. 将密码记录到密码管理软件中，这是一个比较好的选择\n2. 减少系统泄露数据带来的影响：作为用户无法干预数据被泄露的过程，但可以减少对自己的影响\n   1. 为不同的系统使用不同的密码，而不是使用相同的密码，但一般来说这需要密码管理软件的支持\n   2. 添加双因子2FA\u002F多因子认证MFA等手段，避免使用单一密码登录认证\n3. 使用复杂密码\n   1. 位数越多越好、包含字母、数字、特殊字符等\n   2. 避免使用与自己相关的信息作为密码，例如姓名、出生日期、手机号等\n\n这里提到了多因子认证，它的含义是在登录时除了密码之外，还需要提供其他的认证信息，例如短信验证码、邮箱验证码、TOTP认证器、通行密钥Passkey等。这样即使坏人获取到了密码，也无法登录系统，因为他们并不知道其他的认证信息。\n\n另外在这些提升安全性的措施中，我们也可以一窥密码管理软件的核心作用了：\n\n- 帮我们记录密码，避免忘记密码，也可以帮助我们为不同的系统使用不同的密码\n- 帮我们生成足够复杂的密码，避免使用简单密码\n- 帮我们管理多因子认证信息（例如TOTP认证器和通行密钥Passkey）\n\n除此之外，因为我们不再使用人脑来记忆密码，因此当需要填写密码时，密码管理软件会自动填充已经记录的密码，这也是实际操作中能够顺畅使用的核心功能。\n\n## 我的密码管理软件历程\n\n好了，回归正题。记录一下我之前用过的密码管理软件，以及几次换软件的主要原因。\n\n### LastPass\n\n> 因已多年未用，部分信息可能不准确。\n\n我用的第一个密码管理软件是LastPass，这个名字也非常有意思：The last password you need to remember（你需要记住的最后一个密码）。\n\n我刚开始用的时候将所有密码都记录到LastPass中。但因为之前已经累积了非常多的账号密码，因此基本没有使用过随机密码生成，大部分还是使用固定密码。\n\n这个阶段，可以说我还没有那么依赖密码管理软件，很多时候LastPass能帮我填充密码，但我自己也能记得住密码。\n\nLastPass当年只有浏览器插件，它的核心流程打磨得非常顺畅，尤其是在密码的记录和更新提示上。这一点我在切换到其它软件后，才深刻体会到：\n\n1. 注册账号的时候会提示生成一个密码，但并不会立刻要求将这个账号密码保存起来，而是等到表单提交成功后才会提示保存。\n2. 同理，当更新密码的时候，更新成功后才会提示保存。\n\n而1Password在这一点上就处理得不够好。例如在注册表单中，当你采纳它生成的建议密码时，会立刻要你保存一个登录信息，但此时我们的注册还没成功。\n\n同样在重设密码中，更是会出现“密码还没重置完，密码软件中的密码已经被更新了”的情况，有严重的不安全感。此时如果密码重置失败，就会导致密码软件中的密码与实际密码不一致。\n\nLastPass的缺点：\n\n1. 有一段时间短时间内出现过多起数据泄露问题（密码加密存储，可能未造成实际损失）\n2. 有了客户端以后，收费策略比较激进：免费用户不能同时使用桌面端（含浏览器）和移动端，只能二选一\n\n此外当时LastPass也没有记录2FA验证码的功能，还需要单独使用一个Google Authenticator之类的应用来记录2FA验证码。\n\n## 1Password\n\n我接触到1Password好像是因为Setapp，当时买了Setapp的会员，就顺便也用了一个1Password。但后来我停掉了Setapp的会员，1Password似乎也没有继续在Setapp中提供服务，因此只能独立付费使用了。\n\n首先必须要说，1Password的界面做得非常漂亮，相比之下LastPass和Bitwarden的界面都要差很多。\n\n切换到1Password的时机正好是社工库和撞库问题频发的时期。1Password提供了安全分析功能，可以看到哪些密码存在于已经泄露的密码库中（撞库），也可以分析自己的密码库中有哪些项目是重复使用的相同密码。也是借助这个功能，我在迁移到1Password之后，将所有的密码全部重置成了随机密码。\n\n至此以后，我就再也不可能记得自己的密码了，因此必须高度依赖1Password来管理所有的密码。随着使用的深入，我也开始将其它的密钥信息（如API密钥、数据库密码、SSH密钥等）也记录到1Password中，实现了几乎所有密钥的统一管理。\n\n整体上来说，1Password的体验还是比较好的，但它也有几个明显的问题：\n\n1. 核心流程不够好，如上所述，注册\u002F重置密码的体验不够好\n2. 移动端尤其是安卓端的体验非常差\n3. 收费贵，接近40刀一年\n\n关于安卓端，特别想记录一下：\n\n我很早就给家里人（小米手机）推荐安装了1Password，但一直被反馈不好用。因为我自己曾经主力机是iPhone，并没有发觉有很严重的问题，因此也没有认真去看这个问题。直到去年我的主力机也换成了小米手机，我开始注意这个问题，结果发现1Password在小米手机上的体验非常差。\n\n具体而言，有如下三个问题：\n\n1. 自动密码填充的出现时机非常不稳定，有时候会提示，有时候没有提示，有时候在反复聚焦输入框后才出现提示\n2. 软件界面经常白屏，无论是从桌面进去还是通过自动填充界面进去，经常碰到不显示密码条目，也无法搜索到密码的情况\n3. 完全不能使用Passkey功能\n\n其中第3条经搜索是小米系统的问题，截止2026年1月（澎湃OS3），目前所有的第三方密码管理软件都无法在小米系统中使用Passkey功能。\n\n而前两条，我一度也以为是小米的问题，直到我换了Bitwarden以后才确认，它更可能是1Password自己的问题。这让我非常不解，一个收费如此昂贵的软件，居然在一个使用量这么大的平台上，有这么低级的稳定性问题，可见质量保障这一块缺失非常之多。\n\n> 最近1Password还出现了引发很多人关注一个问题：1Password的浏览器扩展打包了一个自己的代码高亮插件，但并未妥善处理与页面内容的冲突，导致很多技术相关的网页上的代码无法正常高亮显示。这也可以算是一种微弱的草台班子的证明。\n\n## Bitwarden + VaultWarden\n\n因为1Password在小米小机上的问题非常严重，促使我决定试一试Bitwarden，顺道也决定使用VaultWarden来自托管后端。\n\nBitwarden是一个开源的密码管理软件，所有的客户端都是开源的，它自己在官网上也提供密码托管服务（即客户端对应的后端，存储密码的地方），而且可以免费使用，只有高级特性需要付费。\n\n而VaultWarden是一个自托管的Bitwarden后端，它不是Bitwarden官方提供的服务，而是一个社区维护的项目。使用VaultWarden，你可以将Bitwarden的密码托管服务部署在自己的服务器上，自己管理自己的密码数据。\n\n我在自己的服务器上部署了VaultWarden，然后使用Bitwarden的客户端连接到我的服务器上来使用。\n\n在试用了一段时间后，结果非常意外：Bitwarden在小米手机上运行非常稳定顺畅，所有的填充场景都能稳定触发（除了Passkey功能）。我完全没有想到一个开源软件能够提供比商业收费软件更好更完善的体验。\n\n当然，缺点也是有的，比如界面不如1Password那么漂亮，在密码类型的支持上不如1Password完善。但这些都不算是大问题，总体上来说瑕不掩瑜，Bitwarden的体验还是非常好的。\n\n## 总结\n\n随着个人在互联网上使用的服务越来越多，账户安全所面对的挑战也在日益增加。\n\n苹果、小米、微软（包含在edge中）等厂商也在推广自己的密码管理软件，整个业界也在不断完善密码管理和认证方案，例如MFA已经成为一种普遍使用的认证方式，Passkey也正处于快速发展推广的阶段。\n\n总而言之，账户安全和其中涉及到的密码管理始终是个很严肃的事情，我也建议大家有机会的话，不妨尝试一下使用这类管理软件，非常有用。\n",{"id":80,"type":7,"slug":81,"title":82,"date":83,"category":11,"tags":84,"body_markdown":87,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":22},139,"a-frustrating-experience-with-imap","聊一聊与邮件协议IMAP的一段经历","2025-11-04 11:45:00",[85,86],"IMAP","邮件协议","\n> 这不是一篇枯燥的技术文档，应该可以读得下去。\n\n## 引子\n\n很久没更新内容了，一直觉得自己没有资格写太多东西来“教育”大家，因此经常开天窗。\n\n最近见了一些朋友，也读了一些朋友的文字，也开始有了一些新的思考。也许我不需要像写技术文档一样来写文章，也许更随意的一些表达会更真诚，也许我只需要记录一些自己想法和感受就好。\n\n刚好最近花了两三个月的时候折腾完了IMAP协议，好像一块压在胸口几个月的石头终于被搬开了，确实也想写点东西，就当和朋友们聊聊天。\n\n\u003C!-- more -->\n\n## 一个非常古怪的工具：邮箱\n\n是的，之所以会涉及到IMAP协议，是因为我在做一个邮箱产品，如果从代码库中第一次提交算起的话，已经做了快5年了。对，我们要聊一个很重要的问题：为什么是邮箱？\n\n放在2025年来看，邮箱是一个非常古怪的工具。\n\n首先，它是为数不多还在使用分布式架构的应用之一。你可以选择任意一个邮箱服务商，来与全世界任意一个邮箱账号通信，而不必拘泥于某一个服务商。\n\n其次，邮件相关的通信协议非常古老，甚至可以看到远古互联网的影子。假设一下，你想要通过网络发送一封邮件给朋友，你会怎样来通信？\n\n- 你好，我是TooBug，我想给我的朋友发送一封邮件（ELHO TooBug）\n- 好的，TooBug，请告诉我你的邮箱地址（250 ok）\n- 我的邮箱地址是toobug@tinkmail.me（MAIL FROM：\\\u003Ctoobug@tinkmail.me\\>）\n- 好的，请继续（250 ok）\n- 我朋友的邮箱地址是support@tinkmail.me（RCPT TO：\\\u003Csupport@tinkmail.me\\>）\n- 好的，请告诉我邮件的内容（250 ok）\n- 邮件内容是“你好，朋友！”（DATA ......）\n- 好的，TooBug，我已经帮你把邮件发送出去了 （250 ok）\n- 谢谢，再见（QUIT）\n- 再见（221 bye）\n\n这就是发送邮件用的SMTP协议。它可以用人类语言来描述，几乎没有什么技术门槛，四十多年前，人们就是这样发送电子邮件的。\n\n一个如此古老且几乎没怎么升级过的应用层协议，即使有很多潜在的问题，但直到今天仍然在被广泛使用，这实在是令人惊讶。\n\n最后一点很古怪的是，过去二十年，几乎所有的互联网应用的形态都发生了巨大的变化，但唯独邮箱是个例外。\n\n我看到\n\n- 社交产品从BBS、Blog、微博、SNS网络一路演变到微信之类的超级应用\n- 内容消费产品从门户网站、RSS阅读器一路演变到今日的短视频、短剧平台\n- 协作办公产品也从单纯的即时通讯一路演变到今日的会议、文档一体化协作平台\n\n唯独邮箱应用的形态几乎没有什么变化。我们依然在使用Outlook、Thunderbird之类的邮箱客户端，而所有的Web邮箱也几乎都长得和这些客户端一模一样。\n\n你说说，是不是很古怪？\n## 邮箱产品的机会和挑战\n\n如果你读到了这里，应该会微微点头：确实，邮箱好多年没有变化了，那邮箱产品到底有什么机会呢？\n\n稍微思考一下就能发现很多普遍但不合理的事情：\n\n- 钓鱼和欺诈邮件如此普遍，但邮箱服务商却几乎没有为用户提供有价值的辅助判断工具，甚至有很多问题正是由于邮件展示的形态所导致的\n- 邮件几乎没有任何协作和任务管理功能，哪怕是标注稍后处理这样简单的功能也没有\n- 邮件的回复和转发链条如此混乱，阅读体验如此之差，但邮箱服务商却几乎没有提供任何工具来改善\n\n这里面能列出来的点还有非常多，每一项都意味着机会。\n\n但是，世上的事哪有这么容易的呢？几十年没有变化，有可能是有人忽略了一些事情，但也有可能是背后隐藏着很多死路。\n\n以我的能力，尚不足以靠推演来判断这些机会和陷阱到底在哪里。我只能说，看到了有人在尝试改进这些产品形态，也获得了一些成果。\n\n而最重要的是，我认为这里值得做出一些改变。先让改变发生，进而产生价值，最后收获成果，道阻且长。\n\n## 关于IMAP\n\n聊回IMAP。这是一个客户端用来收取邮件的协议，它也是一个基于文本的协议，但是复杂度却要高非常多。要完整实现所有的能力，需要先读58个RFC技术文档，然后一一用代码实现它们。\n\n我在2023年基于开源的wildduck模块实现了一版IMAP协议，只包含了最基本的能力。今年，我的邮箱产品有了一些来自海外的用户，并开始反馈IMAP的问题，于是我决定暂停一下，先把IMAP好好修一下，再继续其它特性的迭代。\n\n我从8月份开始啃IMAP协议的文档和实现，过程无比艰辛，几乎用上了我之前工作中所用到的全部的调试手段：\n\n- 打日志、打断点\n- 用WireShark抓包\n- 搭MITM代理抓包\n-  用Nginx搭建TCP代理来抓包\n- 自定义DNS服务器来调试移动端\n- 使用纯TCP协议来搭建mock服务器确认问题\n- 使用AI协助分析代码实现和数据包\n\n总之，过程非常艰辛，很多时候我认为完全没有问题的地方，客户端就会罢工。因此也时常会陷入自我怀疑的情绪中：是不是我不应该选择邮件这么难的一个领域？是不是我真的很不擅长协议调试？是不是我其实掌握不了这么复杂的协议？\n\n你知道，人一旦陷入自我怀疑的情绪中，就会带动方方面面的状态都变得很差：脾气不好，没有耐心，身体状态很差等等。\n\n这并不是我所期待的生活状态或者工作状态，因此一边被这种情绪所控制，一边又得不停安慰自己：没事的，多给自己一些时间，没有bug是修不好的。就像以前上班的时候，也经常碰到让人很绝望的bug，但最终每一次都会修好，这次也不例外。\n\n这个过程中也碰到过非常无语的事情，比如QQ邮箱的客户端，自己没有遵守规范，在请求时使用了小写的`uid`，却在碰到服务端返回的小写`uid`时直接罢工。大有一副“我这么做可以，但你这么做就是你的不对了”的面孔。以至于我一怒之下专门写了一个帖子罗列QQ邮箱在协议实现上的种种问题。\n\n好在，最终我找到了这些兼容问题，完善了这个协议的实现，最终让主流邮件客户端都能兼容。那天是10月29日，我发了一条推文：\n\n> 继续磕IMAP协议，又一个月过去了，终于可以告一段落了。 抓包和调试协议这个事情让我沮丧和自我怀疑了几个月，好在一点一点扒出来了。 现在请叫我IMAP协议专家，没有任何AI可以在IMAP协议实现上挑战我的地位！\n\n是的，这修的不是IMAP协议，而是生活呀。不急不躁，慢慢过，都会好的。\n",{"id":89,"type":7,"slug":90,"title":91,"date":92,"category":11,"tags":93,"body_markdown":97,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":98},137,"play-with-xiaomi-r1d-router","折腾小米初代路由器R1D","2024-12-01 22:00",[94,95,96],"路由器","小米","OpenWRT","\n随着装修逐步进入尾声，最近时不时就会开始想以后的Home Lab要怎么搭。眼光在家瞟来瞟去的，突然瞟到了已经十来年的小米路由器了。这是一台小米一代路由器（R1D），因为当时负责这款产品的老大还有一些同事都是以前腾讯的老同事，所以很早就注意到并且购买了这台造型独特、自带1T硬盘的路由器。\n\n![小米路由器R1D](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F01.jpg)\n\n后来好多年我家一直使用这台路由器，也是这台路由器让我感觉到原来面积中小的房子只需要一台路由器就能稳定覆盖WiFi信号。同时它自带的硬盘还能存点文件，有时候还能远程下载点影视剧。\n\n几年前IPv6开始普及了，当时这款路由器已经没有固件更新了，老固件没有对IPv6的支持。为了折腾IPv6，就换了一台路由器，这台小米一代路由器就被闲置了。\n\n\u003C!-- more -->\n\n## 刷机修复错误\n\n给路由器插上网线，通电之后，一开始是黄色灯常亮（开机中），后来变成了闪红灯。查了一下指示灯的显示：\n\n- 黄灯常亮：启动中\n- 蓝灯常亮：正常\n- 红灯闪烁：系统故障\n- 黄灯闪烁：升级\u002F刷机安装系统中\n\n那么看起来是系统坏了，进入了恢复模式。直接访问路由器的管理页面，也能看到很明显的提示，系统目前处于恢复模式。\n\n![恢复模式](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F02.png)\n\n网上的教程都说需要用U盘刷机，但此时管理页面是可以访问的，因此也可以直接从官网下载固件，然后在网页上选择刷入即可。因为本来就是想再折腾一下这台路由器，我直接选择了开发版固件。\n\n几分钟后，路由器刷机完成自动重启，终于变成了蓝灯，此时路由器的功能已完全恢复正常，内置硬盘中的文件也可以浏览操作了。\n\n## 打开SSH\n\n接下来试着打开路由器的SSH，官网有说明，按说明从官网下载SSH的工具包，改名为`miwifi_ssh.bin`，放入U盘（FAT文件系统）根目录插入路由器USB口，然后重启路由并用卡针顶住RESET口不放，直到黄灯闪烁，说明正在刷机。\n\n这一次只需要几秒钟即可，随后路由器再次重启，SSH端口成功打开。\n\n接下来兴高采烈地准备SSH连接上去`ssh root@192.168.31.1`，结果碰到一个华丽丽的报错：\n\n```sh\nUnable to negotiate with 192.168.31.1 port 22: no matching key exchange method found. Their offer: diffie-hellman-group1-sha1,diffie-hellman-group14-sha1\n```\n\n这个是什么意思呢？嗯……简单来说就是服务端和客户端的算法对不上，客户端是最新的macOS，而服务端是十年前的路由器系统（魔改OpenWRT），这十年间安全界也在不断淘汰旧算法，采纳新算法，于是一个跨越十年的“代沟”就这么产生了：服务端想用老算法，客户端说这玩意我不知道是啥。\n\n解决方法也不难，就是让客户端兼容一下服务端，勉为其难用一用旧算法：\n\n```sh\nssh -oKexAlgorithms=+diffie-hellman-group1-sha1,diffie-hellman-group14-sha1 root@192.168.31.1\n```\n\n至此终于可以登录到路由器中了。不过关于SSH的坑还没有踩完，这里也一并记录一下。\n\n一般来说登录到服务器后为了方便（也为了安全），会设置一下公钥登录。小米这款路由器的SSH服务端用的并不是OpenSSH，而是beardrop，因此需要将我们的公钥放在`\u002Fetc\u002Fbeardrop\u002Fauthorized_keys`中，而不是常见的`~\u002F.ssh\u002Fauthorized_keys`。\n\n当我将公钥放上去之后，公钥登录却并没有生效，通过给`ssh`命令添加`-v`参数查看调试信息，可以看到另一个错误：\n\n```\ndebug1: Offering public key: id_rsa_toobug RSA SHA256:cY3mrYxBSCKd9yDJYYSkvytZmKl6Btb+bMZG1SCrg\u002FM agent\ndebug1: send_pubkey_test: no mutual signature algorithm\n```\n\n通过报错信息，结合一些资料，可以知道，这也是一个“代沟”：客户端和服务端的签名算法对不上。同样的方法，给客户端指定一下旧的算法（`ssh-rsa`）即可。\n\n我将相关参数直接写在了ssh的配置文件中：\n\n```\nHost mi-r1d\n    HostName 192.168.31.1\n    User root\n    HostKeyAlgorithms +ssh-rsa\n    PubkeyAcceptedAlgorithms +ssh-rsa\n    KexAlgorithms +diffie-hellman-group1-sha1,diffie-hellman-group14-sha1\n```\n\n> 以上关于SSH命令的调试、解决均有ChatGPT的协助。\n\n## OPKG源\n\n解决了SSH的问题，本以为一切顺利了。正常来说就是用包管理软件了，先更新一下源，再安装想要的软件即可。\n\n```sh\nopkg update\n```\n\n结果这里碰到了无数个问题，为了简单起见，直接用一个列表按时间顺序列出步骤、问题和解决方案：\n\n1. 软件源地址失效：`\u002Fetc\u002Fopkg.conf`文件中写的源地址已不存在`http:\u002F\u002Fdownloads.openwrt.org\u002Fattitude_adjustment\u002F12.09\u002Fbrcm4709\u002FR1D\u002Fpackages`\n2. 在上述网站中手工翻找，找到`https:\u002F\u002Fdownloads.openwrt.org\u002Fattitude_adjustment\u002F12.09\u002Fbrcm47xx\u002Fgeneric\u002Fpackages`\n    - 执行`opkg update`时报错`wget: not an http or ftp url: https:\u002F\u002Fdownloads.openwrt.org\u002Fattitude_adjustment\u002F12.09\u002Fbrcm47xx\u002Fgeneric\u002Fpackages\u002FPackages.gz`，也就是说它调用了wget，但是wget不支持HTTPS\n      ![wget报错](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F03.png)\n    - 尝试换用`http`协议，wget直接报错`-1`，应该是网站不允许使用HTTP访问了\n    - 手工翻包列表，才发现原来wget还分wget-nossl和wget-ssl\n    - 手工下载`wget-ssl`的包，尝试通过SCP传到路由器上，报错（具体信息不记得了），同样也是SCP的传输方式已经变了，需要使用`-O`强制使用旧版本的方式传输\n    - `wget-ssl`的包上传到路由器之后并不能安装，而且无论架构是`brcm47xx`还是`armv7`都一样报错：\n      ```\n      opkg install \u002Ftmp\u002Fwget_1.13.4-1_ar7.ipk\n       Unknown package 'wget'.\n       Collected errors:\n        * pkg_hash_fetch_best_installation_candidate: Packages for wget found, but incompatible with the architectures configured\n        * opkg_install_cmd: Cannot install package wget.\n      ```\n    - 尝试使用下文的Entware的源，update可以成功，但所有软件都提示架构不兼容\n      ![使用Entware源报错](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F04.png)\n\n至此，折腾OPKG彻底失败，这台路由器到目前为止没有成功安装上任何软件。\n\n## 安装Entware\n\n在即将陷入僵局的时候，突然发现了有一个专为嵌入式设备服务的软件仓库：Entware（[Github](https:\u002F\u002Fgithub.com\u002FEntware\u002FEntware)）。它的文档还是比较多的，但是组织并不友好，安装时可参与其在某一个平台的文档，例如[在群晖上的安装文档](https:\u002F\u002Fgithub.com\u002FEntware\u002FEntware\u002Fwiki\u002FInstall-on-Synology-NAS)。\n\n其中最重要的是`wget -O - https:\u002F\u002Fbin.entware.net\u002Farmv7sf-k2.6\u002Finstaller\u002Fgeneric.sh | \u002Fbin\u002Fsh`这句脚本。（注意其中`armv7sf-k2.6`，其中2.6是内核，在路由器上通过`uname -a`可看到内核版本是2.6）。很明显，我们的wget访问不了这个HTTPS地址，但我们还有两种方法可以处理：\n\n1. 下载这个安装脚本后再通过SCP传到路由器上\n2. 将命令中的`https`改成`http`（是的，它支持使用HTTP协议访问！）\n\n![安装开始](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F05.png)\n\n![安装成功](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F06.png)\n\n因为Entware安装的软件都会在`\u002Fopt`目录中，我们需要按照提示，需要将`\u002Fopt\u002Fbin`和`\u002Fopt\u002Fsbin`放入`$PATH`变量中，这样才可以直接执行它安装的软件。这里我选择在`\u002Fetc\u002Fprofile`中修改。\n\n```sh\nexport PATH=\u002Fopt\u002Fbin:\u002Fopt\u002Fsbin:.....\n```\n\n此外还有一条命令，建议放到开机命令中去，大致方法是在`\u002Fetc\u002Finit.d`新建一个脚本，然后启用即可，这里我没有去做，可参考其他教程。\n\n在安装完Entware后，就有了另一个`opkg`命令，我们可以用它来安装软件了。你可以用`which opkg`来确认，确保当前运行的是`\u002Fopt`目录下的`opkg`。\n\n首先解决冤大头`wget-ssl`，这样就可以访问HTTPS网站了，但实际上安装完之后依然不能访问HTTPS网站，会显示证书不信任。这同样是因为时间太久了，路由器内置的CA证书与目前公认受信任的CA证书列表已大不一样。此时还需要安装`ca-certificates`，安装完后终于能正常访问HTTPS网站了。\n\n![安装wget-ssl](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F07.png)\n\n![安装ca-certificates](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F08.png)\n\n![成功访问https网站](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F09.png)\n\n后面我用Entware安装了nginx，使用完全正常使用。\n\n![nginx](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F10.png)\n\n## 软链\n\n路由器的`\u002Fopt`位于内置存储中，空间只有100多M，装几个软件就满了，因此我们需要给它们挪个位置，把软件都挪到1T的硬盘中去。\n\n```sh\nmkdir -p \u002Fuserdisk\u002Fdata\u002F.system\nmv \u002Fopt \u002Fuserdisk\u002Fdata\u002F.system\nln -s \u002Fuserdisk\u002Fdata\u002F.system\u002Fopt \u002Fopt\n```\n\n首先在硬盘中新建一个`.system`目录（名字随意），然后将`\u002Fopt`挪过去，最后将`opt`目录软链回原来的路径`\u002Fopt`。这样文件存储是在硬盘中，但访问路径没有任何变化，各个软件仍然可以正常工作。\n\n## 设置防火墙\n\n这个路由器现在是被当作Home Lab来使用，因此不承担路由的功能，它通过有线网连接到主路由下，对它来说有两个网络：\n\n- LAN：即原来的`192.168.31.1`网段（实际上为了避免与主路由冲突，修改了网段，此处为示意仍然使用这个地址）\n- WAN：即主路由所在网段\n\n如果电脑连接主路由，则只能访问到Home Lab的WAN接口，如果要访问LAN接口，则需要连接Home Lab的WiFi或者插入网线才可以。这两者主要区别是：管理界面和文件共享等功能都只允许从LAN访问，也就是说如果连接主路由，那Home Lab的管理界面和文件共享都访问不到。\n\n此时我们需要修改它的防火墙设置，让它允许接受来自WAN的访问。修改`\u002Fetc\u002Fconfig\u002Ffirewall`，找到`wan`，将其中的`input`从`REJECT`修改为`ACCEPT`，然后重启`\u002Fetc\u002Finit.d\u002Ffirewall restart`即可。\n\n![修改防火墙设置](\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F11.png)\n\n## 其他和小结\n\n虽然到目前为止，似乎解决了软件安装的问题和最常用的网络访问问题，但实际上在后续折腾的过程中还是碰到了更多问题。我尝试安装TailScale，安装成功了，但执行`tailscale up`时，报了`Illegal instruction (core dumped)`的错误。后续使用Node.js时也出现了同样现象，但神奇的是`node -v`可以正常执行。\n\n按照ChatGPT的说法，出现这种现象可能是程序使用了CPU不支持的指令，可以尝试使用`gdb`调试。我也试了一下生成Coredump文件，但实际上这已经远远超出了我的知识范围，只能暂时放弃了。\n\n至此，这个路由器的折腾告一段落，目前路由器功能正常，硬盘功能正常，有需要的话还可以开启nginx访问，只能说聊胜于无，先当个小垃圾用着吧。\n","https:\u002F\u002Fassets.toobug.net\u002Fassets\u002Ftech\u002F2024\u002Fplay-with-xiaomi-r1d-router\u002F01.jpg",{"id":100,"type":7,"slug":101,"title":102,"date":103,"category":11,"tags":104,"body_markdown":107,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":22},138,"synology-nas-data-recovery","记一次群晖NAS数据恢复","2024-07-31 12:08",[49,50,105,106],"数据恢复","Linux","\n## 背景：群晖坏了 买个二手\n\n我在2019年购入了群晖DS218+作为家里的主要存储设备，几年来一直正常使用，除了归档的文件之外，还存了家里几个人所有的照片视频，并时常从手机上自动备份新的照片视频。\n\n前几天，我突然发现群晖所有的灯都不亮了，一开始以为是某次关机后没有再次开机，于是手工开机试了一下，才发现没有任何反应。在排除了电源的问题后，我开始怀疑是NAS硬件出了问题。\n\n自己拆开看了一下，发现主板上有一颗芯片有明显的烧毁痕迹，应该是短路导致的。鉴于这已经超出我的能力范围，于是在网上找了一家维修店，后来对方反馈除了我看到的芯片短路之外，EC芯片也有问题，而EC芯片中有加密的程序，导致第三方无法维修。\n\n经过5分钟的快速思考，我决定先买个二手低端机顶一下，能正常读写数据和备份照片即可，至于其他的后续再打算。于是就在闲鱼上淘了一台DS218J，于是踩坑之旅正式开始。\n\n\u003C!-- more -->\n\n## 数据恢复方案\n\n一开始想得很简单：直接把硬盘换到新的机器上，然后就能一切照旧。但在我兴奋地插上两块硬盘之后，发现新的机器上无法看到原来的文件夹，看了下存储管理器，发现原来DS218J不能使用BTRFS文件系统，而我的DS218+上的硬盘正是BTRFS文件系统！\n\n对于这一点，我至今仍然在震惊当中，BTRFS是Linux底层的文件系统，为什么群晖会在不同型号的机器上使用不同的文件系统？\n\n于是开始手忙脚乱地设计数据恢复方案，我所拥有的条件：\n\n1. 一台DS218J，不支持BTRFS文件系统\n2. 两块RAID 1硬盘，BTRFS文件系统\n3. 一台macbook笔记本，可以安装Linux虚拟机\n4. 一个USB 3.0硬盘盒\n5. 一个macbook扩展坞，集充电、HDMI、USB、网络接口等功能于一体\n6. 一个国外的网络存储，有和硬盘中完全一样的数据备份\n\n最终设计的数据恢复方案（省略中间各种决策过程）：\n\n- 将原本作为RAID 1使用的两块硬盘拆开使用，一块放入USB 3.0硬盘盒中，另一块放入DS218J中\n- 将USB 3.0硬盘盒通过扩展坞连接到macbook上，使用Linux虚拟机挂载硬盘\n- 通过虚拟机文件共享机制，先将硬盘上的数据复制到macbook上\n- 逐步将macbook上的数据复制到DS218J中\n- 当所有的数据全部存入DS218J中的硬盘之后，再重新将两块硬盘组成RAID 1\n\n## 操作步骤\n\n参考[群晖官方文档](https:\u002F\u002Fkb.synology.cn\u002Fzh-cn\u002FDSM\u002Ftutorial\u002FHow_can_I_recover_data_from_my_DiskStation_using_a_PC#x_anchor_idenvironment1)\n\n```sh\n# 安装mdadm和lvm2\napt-get update\napt-get install -y mdadm lvm2\n\n# 重组磁盘阵列\nmdadm -AsfR && vgchange -ay\n\n# 获取磁盘阵列的信息\ncat \u002Fproc\u002Fmdstat\nlvs\n\n# 只读挂载目录\nmkdir \u002Fmnt\u002Fds\nmount \u002Fdev\u002Fvg1000\u002Flv \u002Fmnt\u002Fds -o ro\n```\n\n## 踩的一些坑和注意事项\n\n1. 虚拟机必须用Ubuntu 18.04.4以下的版本，否则在读取磁盘阵列的时候会出现错误，具体原因可参见[这篇文章](https:\u002F\u002Fyadom.in\u002Farchives\u002Fmount-synology-hard-drive-on-linux.html)。\n2. 虚拟机软件用的UTM，可能是因为USB 3.0支持有问题，当macOS中显示的扩展坞是USB 3.0 Hub时，虚拟机中无法识别硬盘，最终我找了一根“不那么厉害”的线，插上去之后macOS中显示为USB 2.0 Hub，此时才能在虚拟机中识别硬盘，但USB 2.0速度很慢，这导致从硬盘中读取文件需要很长时间\n3. 由于硬盘是BTRFS文件系统，所以在虚拟机中挂载硬盘的时候，需要先安装BTRFS工具，否则会报错。\n4. 文件传输到DS218J速度极慢，尤其是大量小文件时，速度可能只有20M\u002Fs左右，大约1G\u002F分钟，并且无论是SMB协议还是NFS、FTP协议，速度都差不多，原因不明。\n\n## 一些感想\n\n1. 硬件产品还是挺麻烦的，不出问题的时候啥都好，一旦出问题就得全靠自己\n2. 数据备份非常重要，尽管群晖坏了，但我完全不担心数据会丢失，因为硬盘是好的，且两块硬盘互为备份，而且还有一份在网络存储中\n3. 除了数据不丢之外，数据能被访问到也很重要，比如这次群晖坏掉之后所有的数据都无法访问\n4. 群晖的官方文档还是比较清晰的，只不过我在查了很多第三方资料，踩了很多坑之后才发现官方文档，浪费了不少时间\n",{"id":109,"type":7,"slug":110,"title":111,"date":112,"category":11,"tags":113,"body_markdown":118,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":119},132,"blog-upgrade","个人主页5.0升级全记录","2023-09-28 16:00",[114,115,116,117],"博客","个人主页","升级","VitePress","\n我大概是从2007年起开始写博客的，过去这么多年中，也有更新非常频繁的时间，但总体来看最近几年的确是写得越来越少了。随着公众号、短视频的兴起，大家也不再关注博客，而我也一度不怎么更新，甚至想不起来要写一写文字。\n\n但是最近两年，我又突然发现，虽然写内容的平台到处都是，但只有博客是真正属于自己的，那种永远不会背叛你的安全感是其他平台无法比拟的。\n\n再回头看看自己的博客，发现已经很久没有更新过了，而前一版整理的[AMP版本](\u002Farticle\u002Fweb\u002F2017\u002Fmigrate-blog-to-amp.html)，现在回头看也算是掉坑里了。刚好看到勾股写的[博客站迁移至 VitePress 的备忘](https:\u002F\u002Fjiongks.name\u002Fblog\u002Fmigrate-my-blog-to-vitepress)，于是就打算自己也再整理一下，顺便做一个升级，拍个脑袋就叫“个人主页5.0”。\n\n以下为过程中的一些记录。\n\n\u003C!-- more -->\n\n![首页截图](\u002Fassets\u002Ftech\u002F2023\u002Fblog-upgrade\u002F01.png)\n\n## 换程序\n\n之前使用的是Hexo，是一个典型的“静态博客”，即所有内容都在构建时生成，托管时只有静态的HTML和对应的资源文件。这次打算换成[VitePress](https:\u002F\u002Fvitepress.dev\u002F)，也同样是“静态”的，因此核心概念和大致用法上比较类似。按照VitePress的文档，首先安装好依赖，然后将文章（`.md`文件）放到`docs`目录下即可。\n\n由于之前使用Hexo时就在路由中加了一级`\u002Farticle`，本次迁移过程中也需要建立一个对应的子目录，因此将文章目录由`source\u002F_posts`修改为`docs\u002Farticle`即可。\n\n与之对应的，构建脚本也需要做修改，以前用`hexo server`和`hexo generate`，现在换成`vitepress dev docs`和`vitepress build docs`。需要注意的是，VitePress是一个“极为先进”的项目，所以需要在`package.json`中加入`\"type\": \"module\"`，否则会报错。\n\n图片的引用路径同样需要修改，按照VitePress的文档，图片最好放在`docs`目录下的子目录，例如`docs\u002Fassets`，然后在文章中使用相对路径引用即可。但最终我还是将图片放到了`docs\u002Fpublic\u002Fassets`目录下，具体原因见后文。\n\nVitePress的主题是通过`docs\u002F.vitepress\u002Ftheme`目录下的文件来定义的，其中`index.js`是主要的入口文件。我们可以自定义一个新的主题来使用，但如果这样做的话默认主题的一些功能就会丢失，例如暗黑模式、顶部导航、404页面等，于是我选择了直接修改默认主题，即从代码仓库中将整个默认主题拷贝过来，然后针对个别地方进行修改。\n\n基本上经过简单的替换以后，文章详情页就可以正常访问了，但是还有一些页面需要处理，例如首页、文章列表页以及一些固定的页面（如[关于](\u002Fabout.html)）。\n\n## 导航&固定页面\n\n固定页面的实现是最简单的，只需要在`docs`目录下新建一个`.md`文件即可，例如`docs\u002Fabout.md`，就能通过`\u002Fabout.html`访问。\n\nVitePress中顶部导航是通过`docs\u002F.vitepress\u002Fconfig.js`中的`themeConfig.nav`配置来实现的：\n\n```js\nthemeConfig: {\n    logo: '\u002Flogo.png',\n    nav: [\n        { text: '首页', link: '\u002F' },\n        { text: '作品', link: '\u002Fworks.html'},\n        { text: '关于', link: '\u002Fabout.html' },\n    ],\n    ...\n}\n```\n\n## 首页\n\n首页和固定页面的概念是一样的，只需要在`docs`目录下新建一个`index.md`文件即可，就能通过`\u002F`访问。但是如果直接在这个文件中写文字内容的话，它的表现和普通文章是一样的，因此这里需要更多的定制化内容。\n\n到这里终于可以体会到VitePress的高明之处了：所有的`.md`文件都会被转换为`.vue`组件，然后通过Vue的工具链进行编译，因此我们可以在`.md`文件中使用Vue的语法，例如`\u003Cscript>`、`\u003Cstyle>`等，基本上和写一个`.vue`组件没有太大差异。\n\n也正是因为有这样的机制在，使得我们可以在首页中使用一些Vue生态中可用的开源项目，例如为了编写样式，我引入了TailwindCSS，引入方法和在Vue 3项目中几乎没有区别，值得注意的有几个点：\n\n第一，通常Vue项目中我们会在`main.js`或者`app.js`中引入一个公共CSS文件，而VitePress中需要在`docs\u002F.vitepress\u002Ftheme\u002Findex.js`中引入（例如`tailwind.css`），其内容如下：\n\n```css\n@tailwind base;\n@tailwind components;\n@tailwind utilities;\n```\n\n第二，`tailwind.config.js`中配置的`content`选项需要修改，因为VitePress中的`.md`文件也需要处理，因此需要在`content`中加入`.md`后续。除此之外，因为`.vitepress`目录以`.`开头，默认会被忽略，需要单独写出来。\n\n为了适配VitePress的暗黑模式，这里可以顺便设置`darkMode: class`，这样就可以使用TailwindCSS中的`dark:`前缀为暗黑模式匹配样式。\n\n```js\nexport default {\n    content: [\n        '.\u002Fdocs\u002F**\u002F*.{vue,ts,js,md}',\n        '.\u002Fdocs\u002F.vitepress\u002F**\u002F*.{vue,ts,js,md}',\n    ],\n    darkMode: 'class',\n}\n```\n\n具体到页面内容，就比较无聊了，使用Grid布局放了几个大块块，然后适当做了一点响应式布局的调整就完成了。\n\n## 文章分类页\n\n![文章分类页](\u002Fassets\u002Ftech\u002F2023\u002Fblog-upgrade\u002F02.png)\n\n有了固定页的经验，按道理分类页也很简单，只需要在`docs`目录下新建对应分类的`.md`文件即可，例如`docs\u002Farticle\u002Fweb.md`，就能通过`\u002Farticle\u002Fweb.html`访问。但这样做有两个问题：\n\n1. 分类是写死的，一旦有新增或者变化就需要手工调整\n2. 无法处理分页的情况\n\n此时就可以使用VitePress的“动态路由”功能，具体而言，是这么做：\n\n1. 建立一个带有占位符的`.md`文件，例如`docs\u002Farticle\u002F[category]-[page].md`，其中`[category]`和`[page]`都是占位符\n2. 建立一个同名的`.paths.js`文件，例如`docs\u002Farticle\u002F[category]-[page].paths.js`，它的作用是输出`[category]`和`[page]`占位符的所有可能值，例如`web-1` `web-2` `tech-1` `tech-2`等等\n\n这样，就可以通过`\u002Farticle\u002Fweb-1.html` `article\u002Fweb-2.html`等访问到对应的分类页了。为了额外提供一个“所有”分类，在处理`[category]`的值时加入一个`all`作为分类，这样就可以通过`\u002Farticle\u002Fall-1.html`访问到所有文章的第一页了。\n\n具体的写法大致如下：\n\n```javascript\n\u002F\u002F [category]-[page].paths.js\n\n\u002F\u002F Recursively get all files in a directory\nfunction getFiles(dir: string): MdFile[] {\n  const files = readdirSync(dir);\n  let fileList: MdFile[] = [];\n\n  files.forEach((file) => {\n    const filePath = join(dir, file);\n    const stats = statSync(filePath);\n\n    if (stats.isDirectory()) {\n      fileList = fileList.concat(getFiles(filePath));\n    } else if (filePath.endsWith('.md') && !filePath.endsWith('\u002Findex.md') && !\u002F\\.\\\u002Fdocs\\\u002F[^\u002F]+\\.md\u002F.test(filePath)) {\n      const fileContent = readFileSync(filePath, 'utf8');\n      const { data } = matter(fileContent);\n      const category = data.category || (Array.isArray(data.categories) && data.categories[0]);\n      fileList.push({ filePath, category });\n    }\n  });\n\n  return fileList;\n}\n\n\u002F\u002F Get all markdown files\nconst data = getFiles('.\u002Fdocs');\n\n\u002F\u002F Group files by category\nconst categories: { [key: string]: MdFile[] } = {};\ndata.forEach((file) => {\n  const category = file.category || 'all';\n  if (!categories[category]) {\n    categories[category] = [];\n  }\n  categories[category].push(file);\n\n  if (category !== 'all') {\n    if (!categories.all) {\n      categories.all = [];\n    }\n    categories.all.push(file);\n  }\n});\n\n\u002F\u002F Calculate page count for each category\nconst pageParams: PageParams[] = [];\nObject.entries(categories).forEach(([category, files]) => {\n  const pageCount = Math.ceil(files.length \u002F PAGE_SIZE);\n  for (let i = 1; i \u003C= pageCount; i++) {\n    pageParams.push({ params: { category, page: i } });\n  }\n});\n\nexport default {\n  paths() {\n    return pageParams;\n  },\n};\n```\n\n> 写本文的时候在想为什么这里没有使用createContentLoader来写，暂时没有想清楚，可能尝试过碰到了问题，记不太清了，回头再试一下。\n\n按照官方文档，在`[category]-[page].md`中，可以使用`createContentLoader`来获取文章数据，这一段逻辑被封装到一个单独的文件`posts.data.js`中：\n\n```javascript\nexport default createContentLoader('article\u002F**\u002F*.md', {\n    excerpt: '\u003C!-- more -->',\n    includeSrc: true,\n    transform(raw): Post[] {\n        return raw\n            .filter(({ url }) => url && !\u002F\\[.+\\]\u002F.test(url))\n            .map(({ src, url, frontmatter, excerpt }) => ({\n                title: frontmatter.title,\n                url,\n                date: formatDate(frontmatter.date),\n                category: frontmatter.category || frontmatter.categories?.[0] || 'all',\n                excerpt,\n                cover: getCover(src),\n            }))\n            .sort((a, b) => b.date.time - a.date.time)\n    }\n});\n```\n\n其中`getCover()`是一个从原文中解析封面图片的方法。\n\n这里有一个困扰了很久的点，就是图片路径的问题。按照官方文档，推荐将图片放在`docs\u002Fassets`中，在`.md`文件中通过相对路径进行引用，如果这样做的话，这个`getCover()`获取到的将是构建前的图片原始地址，而构建之后，图片地址会发生变化，使得封面无法显示。因此最终还是将图片放在了`docs\u002Fpublic\u002Fassets`中。\n\n渲染的时候根据根据`params`中的`category`和`page`来筛选文章，最后将结果渲染出来即可：\n\n```javascript\nimport { data as posts } from '..\u002F.vitepress\u002Ftheme\u002Fposts.data'\n\nconst { params } = useData()\n\nconst PAGE_SIZE = 12\nconst PAGE = +params.value.page\nconst CATEGORY = params.value.category\n\nconst categoryPosts = computed(() => {\n    return posts.filter(post => CATEGORY === 'all' || post.category === CATEGORY);\n});\n\nconst currentPosts = computed(() => {\n    const start = (PAGE - 1) * PAGE_SIZE\n    const end = start + PAGE_SIZE\n    return categoryPosts.value.slice(start, end)\n});\n```\n\n在界面上还要放一些分类选择、页面选择的组件，比较常规，不过多记录。\n\n## 影集页面\n\n![影集详情页](\u002Fassets\u002Ftech\u002F2023\u002Fblog-upgrade\u002F03.png)\n\n影集模块中的详情页主要用来展示照片，这里不想使用文章中把图片顺序铺排下来的方式，而是希望有一个自定义的布局，于是扩展了一个单独的`GalaryLayout`来展示。\n\n首先编写好布局组件，就是非常常规的Vue组件，使用`useData()`方法获取页面对应的数据，然后进行展示即可。为了方便，在`docs\u002F.vitepress\u002Fconfig.js`中的`transformPageData()`hook中加了一个解析页面图片的功能：\n\n```javascript\ntransformPageData(pageData, ctx) {\n    if (pageData.frontmatter.category === 'galary') {\n        \u002F\u002F @ts-ignore\n        pageData.images = getImages(readFileSync(join(process.cwd(), 'docs', pageData.relativePath), 'utf8'));\n    }\n\n    return pageData;\n},\n```\n\n> 这个页面还有待进一步优化，图片的查看方式、整体视觉风格都还不够好。\n\n## 敏感信息擦除\n\n因为有一些文章中有部分文字不适宜对外发布，但作为个人的文字记录，又不想删掉，于是之前就做了一个敏感信息的标记（`[*敏感文字*]`），在渲染的时候会擦除标记中的文字。这次迁移的时候发现VitePress不容易做这样的处理，因为它没有给出在渲染前进行原文修改的hook，只能修改渲染后的HTML代码。\n\n于是修改后的代码如下：\n\n```javascript\nexport function eraseSensitiveText(text?: string): string {\n    if (!text) return '';\n    return text.replace(\u002F\\[\u003Cem>.*?\u003C\\\u002Fem>\\]\u002Fg, '[***]');\n}\n```\n\n在`docs\u002F.vitepress\u002Fconfig.js`中使用`trnasformHtml()`hook来调用：\n\n```javascript\ntransformHtml(code, id, ctx) {\n    return eraseSensitiveText(code);\n},\n```\n\n> 写成`transformHtml: eraseSensitiveText`的形式也可以，但个人习惯保留回调函数的完整参数信息。\n\n## 评论\n\n写作的时候Copilot提示说“评论是博客不可缺少的功能”，想了下也有几分道理，我的确是这么想的。\n\n之前使用的评论服务是Disqus，考虑到它已经被墙，且开始提供越来越多与评论无关的功能，这次打算顺手迁移一下。\n\n因为站点本身是静态的，不具备数据库和后端逻辑，因此评论数据需要托管到一个另外的地方。这里大致有几种路线：\n\n1. 基于Github issue的评论系统\n2. 基于LeanCloud之类的BaaS的评论系统\n3. 基于自托管的评论系统\n\n在方案选择上纠结了很久。首先个人觉得使用issue作为评论数据托管的地方，多多少少有一些滥用的嫌疑，而且Github的API现在也有频率限制，因此放弃这个路线。然后是LeanCloud之类的数据托管，有一些很私人的考虑，不太想再使用国内的服务，而国外的类似产品又不多（也可能是没有去穷尽）。\n\n最后看到了[Artalk](https:\u002F\u002Fartalk.js.org\u002F)这个产品，属于自托管的评论系统。看了一下前端界面还是挺符合需求的，但它的后端是用Go写的。我希望这个服务能部署到Cloudflare workers中去，因为刚好它还有D1（SQLite）服务，这样就可以完美解决后端程序和数据库的问题，既不用花钱，也不用自己运维。\n\n于是就翻了一下Artalk的文档和源码，自己写了一个接口完全一致的后端，部署到了Cloudflare workers中。当然，因为我只有自己使用，有一部分特性用不着，所以只实现了最核心的评论相关的特性，其它部分都未做实现。\n\n> Cloudfalre有一篇官方教程，讲的就是如何为静态站点写一个评论功能，作为入门参考非常合适。\u003Chttps:\u002F\u002Fdevelopers.cloudflare.com\u002Fd1\u002Ftutorials\u002Fbuild-a-comments-api\u002F>\n\n前端部分的使用和文档基本一致，需要注意的是VitePress生成的是一个SPA应用，即点击站内链接时页面不会刷新，这将导致评论内容也不更新。因此需要手工调用`update()`方法传入新的配置并调用`reload()`重新拉取评论。\n\n```javascript\nconst router = useRouter()\n\nrouter.onAfterRouteChanged = async () => {\n    initArtalk();\n}\n\nlet Artalk, artalk;\n\nconst initArtalk = () => {\n\n    const conf = {\n        el: '#artalk-container',\n        pageKey: location.href.replace(\u002F\\?.*\u002F, '').replace(\u002F#.*\u002F, '').replace(\u002Fhttp:\\\u002F\\\u002Flocalhost:\\d+\u002F, 'https:\u002F\u002Fwww.toobug.net'),\n        pageTitle: document.title,\n        server: location.host.includes('localhost') ? 'http:\u002F\u002Flocalhost:8787' : 'https:\u002F\u002Fcomments.toobug.net',\n        site: 'TooBug',\n        nestMax: 2,\n        emoticons: false,\n    }\n\n    if (!artalk) {\n        artalk = Artalk.init(conf);\n    } else {\n        artalk.update(conf);\n        artalk.reload();\n    }\n\n};\n```\n\n在这个过程中还发现了一个Bug，Artalk在调用`update()`方法时会重复为按钮绑定事件，从而导致点击时出现重复评论的情况，目前给官方报了issue（\u003Chttps:\u002F\u002Fgithub.com\u002FArtalkJS\u002FArtalk\u002Fissues\u002F592>），期待解决。而我自己先加了一个初始化的标识放在`dataset`上，使用`pnpm patch`打了一个补丁作为临时解决方案：\n\n```javascript\nfunction submitBtn(editor) {\n  editor.refreshSendBtnText();\n  const $submitBtn = editor.getUI().$submitBtn;\n  const isInit = $submitBtn.dataset.init === \"1\";\n  if (!isInit) {\n    $submitBtn.addEventListener(\"click\", () => editor.submit());\n    $submitBtn.dataset.init = \"1\";\n  }\n}\n```\n\n在评论的通知部分，Artalk原版后端应该是邮件通知的，这部分我没去做实现，只是简单接了一个Telegram机器人，这样当别人添加新的评论的时候我就能收到机器人的推送通知。\n\n## 其他\n\n### RSS\n\nRSS基本上照抄Vue博客的代码，源码位于\u003Chttps:\u002F\u002Fgithub.com\u002Fvuejs\u002Fblog\u002Fblob\u002Fmain\u002F.vitepress\u002FgenFeed.ts>。唯一的改动是我没有输入全部文章，因此加了一个`.slice()`方法来限制输出的文章数量。\n\n### GA & favicon\n\nGA和favicon之类的都很简单，只需要在`\u003Chead>`中添加几行代码即可，对应到VitePress是在`docs\u002F.vitepress\u002Fconfig.js`中添加配置：\n\n```javascript\nhead: [\n    ['link', { rel: 'icon', href: '\u002Flogo.png' }],\n    ['script', { async: '', src: 'https:\u002F\u002Fwww.googletagmanager.com\u002Fgtag\u002Fjs?id=G-XXXXXXXX' }],\n    ['script', {}, `window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', 'G-XXXXXXXX');`],\n],\n```\n\n### 首页彩蛋\n\n![首页渐变背景](\u002Fassets\u002Ftech\u002F2023\u002Fblog-upgrade\u002F04.png)\n\n在正式上线之前，临时为首页的模块加了一点点效果：鼠标hover到某个模块上时，会产生一个渐变的背景，并且每一次hover看到的颜色都是随机的。利益于Tailwind的封装，整体代码比较简洁：\n\n```html\n\u003Csection class=\"hover:bg-gradient-to-br\" :class=\"getGradient()\" @mouseenter=\"refreshGradient\">\n...\n\u003C\u002Fsection>\n\n\u003Cscript setup>\nconst gradientList = {\n    light: [\n        ['from-amber-200', 'to-yellow-400'],\n        ['from-amber-500', 'to-pink-500'],\n        ['from-violet-200', 'to-pink-200'],\n        ['from-blue-200', 'to-cyan-200'],\n        ['from-teal-200', 'to-teal-500'],\n    ],\n    dark: [\n        ['from-purple-500', 'to-purple-900'],\n        ['from-blue-800', 'to-indigo-900'],\n        ['from-emerald-500', 'to-emerald-900'],\n        ['from-slate-500', 'to-slate-800'],\n        ['from-violet-600', 'to-indigo-800'],\n    ]\n};\n\nconst isDark = ref(false);\nconst checkDark = () => {\n    isDark.value = document.documentElement.classList.contains('dark');\n}\n\nconst refreshGradient = () => {\n    isDark.value = true;\n    isDark.value = false;\n    checkDark();\n}\n\nconst themeList = computed(() => {\n    const listName = isDark.value ? 'dark' : 'light';\n    return gradientList[listName];\n});\n\nconst getGradient = () => {\n    const index = Math.floor(Math.random() * themeList.value.length);\n    return themeList.value[index].join(' ')\n};\n\nonMounted(checkDark)\n\u003C\u002Fscript>\n```\n\n整体思路是当鼠标hover的时候重置`isDark`的值，然后就会依次更新`themeList`和`getGradient()`的值，从而实现更换渐变背景的效果。\n\n## 小结\n\n翻了一下提交记录，第一次安装`vitepress`依赖的时候是8月28日，不知不觉居然折腾了一个月了，还是花费了不少时间的。从最终结果上来看，基本上达到了我的预期，希望后续可以在这个站点上做更多的记录。\n\n这里也遗留了很多后续需要去处理的问题：\n\n- 整体视觉风格的优化，包括TailwindCSS和VitePress默认主题的颜色不一致的问题处理以及暗黑模式下一些细节的优化\n- 影集模块的功能和视觉优化\n- 首页动画的微调和优化\n- 评论系统的完善，包括邮件通知、回复等功能\n- 移动端一些样式优化和交互优化\n- 构建性能的优化（目前需要3分钟）\n\n希望以后有时间可以慢慢去完善。\n\n### 题外话\n\n通过这次迁移，对VitePress的认知也更深刻了。总体上来看，它的设计非常优雅，能够使用的特性并不太多，但是有足够的灵活性来满足各种各样的需求。而且因为它的最终产物是SPA，使用体验极好，相对Astro之类的框架来说，最终的使用体验是更好的。因此如果VitePress在后续的扩展和迭代中保持目前设计上的优异表现，我相信完全可以产生一个更大的生态圈。\n","https:\u002F\u002Fassets.toobug.net\u002Fassets\u002Ftech\u002F2023\u002Fblog-upgrade\u002F01.png",{"id":121,"type":7,"slug":122,"title":123,"date":124,"category":11,"tags":125,"body_markdown":129,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":130},136,"run-chatglm2-on-m2-macbook","在M2 Macbook上运行ChatGLM2","2023-07-11 18:30",[13,126,127,128],"ChatGLM2","M2","Macbook","\n## 介绍\n\nChatGLM 是一个“开源双语对话语言模型”（官方介绍），也就是我们所熟知的“大语言模型”，或者简单理解为开源版ChatGPT平替（之一）。ChatGLM有时候也被叫作“清华大学的大语言模型”，但它的作者应该是和清华大学有合作，总之和清华大学有点关系就是了。\n\nChatGLM2 是 ChatGLM 的第二个版本，性能更强大，支持更长的上下文，推理更高效。目前 ChatGLM2 已经开源，能够下载到的模型是 6B 参数训练的，因此也叫 ChatGLM2-6B 。\n\n本文主要记录一下在 M2 Macbook 上运行 ChatGLM2 的过程。\n\n\u003C!-- more -->\n\n## 下载模型\n\nChatGLM-6B 的下载地址 \u003Chttps:\u002F\u002Fhuggingface.co\u002FTHUDM\u002Fchatglm2-6b> ，在 Files and versions 页面中找到 Clone repository，按照指引即可克隆下来：\n\n```sh\ngit lfs install\ngit clone https:\u002F\u002Fhuggingface.co\u002FTHUDM\u002Fchatglm2-6b\n```\n\n整个模型有 13G 左右，需要比较长的时间。\n\n> 注意，自行解决科学上网等问题。\n\n## 下载ChatGLM.cpp\n\n默认的模型在 Mac 系统下推理性能比较差，因此有人基于 ggml 重新实现了一个 ChatGLM.cpp，完全使用 C++ 实现，性能更好。\n\nChatGLM.cpp 的下载地址 \u003Chttps:\u002F\u002Fgithub.com\u002Fli-plus\u002Fchatglm.cpp>，按照指引克隆下来：\n\n```sh\ngit clone --recursive https:\u002F\u002Fgithub.com\u002Fli-plus\u002Fchatglm.cpp.git && cd chatglm.cpp\n```\n\n## 安装依赖\n\n接下来需要安装一些 Python 依赖。\n\n> 如果你没有安装 Python 3 的话，可以通过 Homebrew 安装：`brew install python3`\n\n```sh\npip install protobuf transformers==4.30.2 cpm_kernels torch==2.0 gradio mdtex2html sentencepiece accelerate\n```\n\n## 转换模型\n\n接下来需要将原始的模型转换成 ChatGLM.cpp 可以使用的格式。\n\n```sh\npython convert.py -i ..\u002Fchatglm2-6b\u002F -t q4_0 -o chatglm2-ggml.bin\n```\n\n其中`..\u002Fchatglm2-6b`是原始的模型目录，`chatglm2-ggml.bin`是转换后的模型文件。参数`-t`是量化类型，官方说明如下：\n\n- `q4_0` 4-bit integer quantization with fp16 scales.\n- `q5_0` 5-bit integer quantization with fp16 scales.\n- `q5_1` 5-bit integer quantization with fp16 scales and minimum values.\n- `q8_0` 8-bit integer quantization with fp16 scales.\n- `f16` half precision floating point weights without quantization.\n- `f32` single precision floating point weights without quantization.\n\n总之`q4_0`是对机器性能要求最低的，这里就选它了。\n\n## 编译ChatGLM.cpp\n\nChatGLM.cpp 的编译需要使用`cmake`，如果没有安装的话使用 Homebrew 安装：`brew install cmake`。\n\n```sh\ncmake -B build\ncmake --build build -j\n```\n\n## 运行ChatGLM.cpp\n\n```sh\n.\u002Fbuild\u002Fbin\u002Fmain -m chatglm2-ggml.bin -p 你好\n你好👋！我是人工智能助手 ChatGLM2-6B，很高兴见到你，欢迎问我任何问题。\n```\n\n可以使用`-i`参数进入交互式模式：\n\n```sh\n.\u002Fbuild\u002Fbin\u002Fmain -m chatglm2-ggml.bin -i\n    ________          __  ________    __  ___\n   \u002F ____\u002F \u002F_  ____ _\u002F \u002F_\u002F ____\u002F \u002F   \u002F  |\u002F  \u002F_________  ____\n  \u002F \u002F   \u002F __ \\\u002F __ `\u002F __\u002F \u002F __\u002F \u002F   \u002F \u002F|_\u002F \u002F\u002F ___\u002F __ \\\u002F __ \\\n \u002F \u002F___\u002F \u002F \u002F \u002F \u002F_\u002F \u002F \u002F_\u002F \u002F_\u002F \u002F \u002F___\u002F \u002F  \u002F \u002F\u002F \u002F__\u002F \u002F_\u002F \u002F \u002F_\u002F \u002F\n \\____\u002F_\u002F \u002F_\u002F\\__,_\u002F\\__\u002F\\____\u002F_____\u002F_\u002F  \u002F_(_)___\u002F .___\u002F .___\u002F\n                                              \u002F_\u002F   \u002F_\u002F\n\nWelcome to ChatGLM.cpp! Ask whatever you want. Type 'clear' to clear context. Type 'stop' to exit.\n\nPrompt   > 你好\nChatGLM2 > 你好👋！我是人工智能助手 ChatGLM2-6B，很高兴见到你，欢迎问我任何问题。\nPrompt   >\n```\n\n简单试了一下鸡兔同笼的问题：\n\n![](\u002Fassets\u002Ftech\u002F2023\u002Frun-chatglm2-on-m2-macbook\u002F01.png)\n\n可见 ChatGLM.cpp 的中文理解非常不错，数学成绩也还行，但是后续实测中代码的理解上还有待提高，和 ChatGPT 有差距。但不管怎样，一个本地能运行的大语言模型，也可以干很多事了，突然对这个行业的未来充满了期待。\n","https:\u002F\u002Fassets.toobug.net\u002Fassets\u002Ftech\u002F2023\u002Frun-chatglm2-on-m2-macbook\u002F01.png",{"id":132,"type":7,"slug":133,"title":134,"date":135,"category":11,"tags":136,"body_markdown":139,"permalink":18,"excerpt_src":18,"media_type":18,"media_title":18,"media_author":18,"media_url":18,"rating":18,"layout":18,"pv":19,"admin_only":19,"created_at":20,"updated_at":20,"deleted_at":18,"status":21,"cover":140},135,"perform-scheduled-tasks-using-gitLab-pipelines","使用Gitlab Pipelines执行定时任务","2023-06-16 12:00",[137,106,138],"定时任务","Gitlab","\n## 背景\n\n在Linux服务器下执行定时任务是一个非常常见的需求，我们可能需要定时备份数据库、定时执行程序脚本、定时清理日志等等。在旧版本Linux下，我们通常会使用`crontab`来执行定时任务，而新版本的Linux系统中则更推荐用systemd的`timer`来执行定时任务。\n\n但是systemd的配置麻烦又罗嗦，需要先定义一个`service`，再定义一个`timer`来调用`service`，而且`timer`的配置文件中还需要指定`OnCalendar`来指定定时任务的执行时间，这个时间格式又不是很好记。\n\n此外即使定义好了，管理起来也并不容易，更不要说排查问题了。\n\n而如果机器上使用Docker等容器环境，定时任务就更麻烦了：为了应用的可移植性，希望尽可能将定时任务放到容器中运行，但容器又不能直接以宿主机的身份执行各种脚本（例如重启其它容器，可以通过一些手段实现，但更麻烦）。\n\n此外，定制一个跑定时任务的容器也不是一件容易的事，首先需要有对应环境的基础镜像，然后基于这个镜像安装好`crond`之类的工具，再将定时任务的脚本刷到`crond`中，最后将这个容器部署到机器上。这个容器还必须有网络权限等。\n\n总之，定时任务的配置和管理是一个非常麻烦的事情，在容器环境下尤其如此。\n\n\u003C!-- more -->\n\n## Gitlab Pipelines\n\nGitlab提供了CI\u002FCD功能，可以自己定义Pipelines，然后在代码推送、MR合并等事件触发时执行Pipelines。利用这个功能可以很好地执行CI\u002FCD任务。\n\n最近发现Gitlab还有一个Pipeline Schedules的功能，可以在指定的时间点执行Pipelines。利用这个功能可以很好地解决定时任务的问题。\n\n### 使用方法\n\n首先在项目的CI\u002FCD设置中新建一个Pipelines：\n\n![screenshot-1](\u002Fassets\u002Ftech\u002F2023\u002Fperform-scheduled-tasks-using-gitLab-pipelines\u002F01.png)\n\n可以看到，这个界面能可视化地管理Pipelines的执行时间，非常方便。\n\n添加好之后，就可以在管理界面看到刚添加的定时任务了：\n\n![screenshot-2](\u002Fassets\u002Ftech\u002F2023\u002Fperform-scheduled-tasks-using-gitLab-pipelines\u002F02.png)\n\n但这里马上就会面临一个新问题：我们在Gitlab CI中定义的是CI\u002FCD的任务，但定时任务却希望是另外的脚本，怎么办呢？事实上如果此时点击手工运行的话，会发现Gitlab CI会执行我们定义的CI\u002FCD任务，而不是我们希望的定时任务。\n\n这个问题的解决方法是，我们可以在CI\u002FCD的任务中单独定义需要执行的定时任务，并通过条件来指定只有在定时任务触发时才执行。例如：\n\n```yaml\nci-task:\n    script:\n        - echo \"This is a CI task\"\n    rules:\n        - if: $CI_PIPELINE_SOURCE == \"push\" && $CI_COMMIT_BRANCH == \"master\"\n\ncron-job:\n    script:\n        - echo \"This is a cron job\"\n    rules:\n        - if: $CI_PIPELINE_SOURCE == \"schedule\"\n```\n\n上述配置中，我们定义了两个任务，一个是CI任务，一个是定时任务。其中CI任务只有在`push`到`master`分支时才会执行，而定时任务只有在定时任务触发时才会执行。这样就可以很好地解决定时任务的执行的问题了。\n\n> 详细说明可参考官方文档：\u003Chttps:\u002F\u002Fdocs.gitlab.com\u002Fee\u002Fci\u002Fjobs\u002Fjob_control.html#run-jobs-for-scheduled-pipelines>\n\n接下来的问题是：不是说好的要解决Linux系统下的定时任务问题吗？这里的任务是在Gitlab CI中执行的，怎么执行自己服务器上的任务呢？\n\n这就需要我们从Gitlab CI环境使用SSH连接到自己的服务器上，然后执行对应的任务脚本来实现了。\n\n### SSH连接\n\n首先需要在Gitlab CI\u002FCD的设置中将SSH Key作为变量添加进去，这样才能在Gitlab CI环境中获取到对应的Key，并使用SSH连接到自己的服务器上。\n\n![screenshot-3](\u002Fassets\u002Ftech\u002F2023\u002Fperform-scheduled-tasks-using-gitLab-pipelines\u002F03.png)\n\n截图中`SSH_CONFIG`的内容如下：\n\n```\nHost *\n    KexAlgorithms +diffie-hellman-group1-sha1\n    StrictHostKeyChecking no\n```\n\n这里是为了解决SSH客户端与服务端使用的加密方案不同造成无法连接的情况，可视情况修改或者添加对应的配置。\n\n对应的CI任务脚本如下：\n\n```yaml\nscript:\n    - apk update&&apk add openssh\n    - mkdir ~\u002F.ssh\n    - echo \"$SSH_CONFIG\">~\u002F.ssh\u002Fconfig\n    - echo \"$SSH_KEY_PRIVATE\">~\u002F.ssh\u002Fid_rsa\n    - echo \"$SSH_KEY_PUBLIC\">~\u002F.ssh\u002Fid_rsa.pub\n    - chmod 400 ~\u002F.ssh\u002Fid_rsa\n    - ssh root@100.1.2.3 \"sh \u002Fdata\u002Fscripts\u002Fdb-bak.sh&&docker restart example\"\n```\n\n首先安装SSH客户端，然后将SSH Key写入到对应的文件中，最后连接到服务器执行对应的脚本。\n\n## 小结和可改进点\n\n总的来说，这个方法可以让我们不用花太多心思去配置“定时”这件事情，只需要在Gitlab中添加一个定时任务，然后通过SSH连接到服务器上执行对应的脚本即可。\n\n但这个方法依然不是特别“干净”，因为最终还是需要留一个脚本在服务器上，并不完全符合容器可移植的要求。但这个问题也有一些可改进的方向：\n\n1. 将脚本放到容器中，通过SSH连接到服务器后执行`docker`相关命令进行调用\n2. 将需要定时任务的任务通过HTTP接口等形式暴露出来，然后在Gitlab CI中通过`curl`等工具调用\n3. 将定时任务的脚本放到Gitlab CI中，通过SSH连接到服务器后将脚本写入到服务器上，然后执行\n","https:\u002F\u002Fassets.toobug.net\u002Fassets\u002Ftech\u002F2023\u002Fperform-scheduled-tasks-using-gitLab-pipelines\u002F01.png",41]