<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Liyuanjie 的个人博客</title><description>写代码，也记点写代码时遇到的事</description><link>https://liyuanjie.com/</link><language>zh_CN</language><item><title>给博客加浏览量：为什么我没用百度统计，也没做后台</title><link>https://liyuanjie.com/posts/eighteenth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/eighteenth-post/</guid><description>加一个「这篇文章被看了几次」。没用现成的统计服务（它们在收集访客信息），也没建数据库（就一个 .php 文件当数据库），最后还做了一个公开的统计页，而不是带密码的后台。</description><pubDate>Sun, 04 Oct 2026 22:30:00 GMT</pubDate><content:encoded>&lt;p&gt;今天给博客加了「阅读次数」。&lt;/p&gt;
&lt;p&gt;听起来是个很小的功能，但做下来有几个决定挺值得记的。&lt;/p&gt;
&lt;h2&gt;一、为什么不用现成的统计服务&lt;/h2&gt;
&lt;p&gt;最省事的做法是挂一段别人的统计脚本 —— 百度统计、Google Analytics 那种，挂上去就自动告诉你每篇文章被看了几次。&lt;/p&gt;
&lt;p&gt;我没用，两个原因。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一，那些脚本是从别人服务器加载的。&lt;/strong&gt; Google 的脚本在国内经常转圈甚至根本加载不出来，页面上就会卡着一个一直不结束的请求。而且我前几天刚把「不用第三方」当原则，转头就挂一个进来，说不过去。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第二，它们要收集访客信息。&lt;/strong&gt; 加了那段脚本，每个来看我博客的人，设备、IP、看了什么、停了多久，都会被记到别人公司的数据库里。&lt;/p&gt;
&lt;p&gt;我一个初中生的小博客，不值当让来看的人付出这个。&lt;/p&gt;
&lt;h2&gt;二、为什么用 PHP，不用 Node&lt;/h2&gt;
&lt;p&gt;我服务器上本来就跑着一个 Node 项目（HomeChat），顺手写个 Node 接口也行。&lt;/p&gt;
&lt;p&gt;但 PHP 有个 Node 没有的好处：&lt;strong&gt;不需要一直开着进程。&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Node&lt;/th&gt;
&lt;th&gt;PHP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;平时&lt;/td&gt;
&lt;td&gt;一个进程一直在后台跑着&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;不在&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;占内存&lt;/td&gt;
&lt;td&gt;一直占着&lt;/td&gt;
&lt;td&gt;有请求才起来，干完就退&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;崩了&lt;/td&gt;
&lt;td&gt;网站少个功能，得想办法自动重启&lt;/td&gt;
&lt;td&gt;下次请求自动重来&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;开机自启&lt;/td&gt;
&lt;td&gt;得单独配&lt;/td&gt;
&lt;td&gt;不用管&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;我这台机器只有 2G 内存，能省一点是一点。&lt;/p&gt;
&lt;h2&gt;三、没建数据库，用一个 &lt;code&gt;.php&lt;/code&gt; 文件当数据库&lt;/h2&gt;
&lt;p&gt;说起来有点土，但很好用。&lt;/p&gt;
&lt;p&gt;我要存的东西就长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;hits&quot;: { &quot;/posts/first-post/&quot;: 13, &quot;/posts/second-post/&quot;: 8 },
  &quot;seen&quot;: { &quot;访客哈希|/posts/first-post/&quot;: 1759600000 },
  &quot;rate&quot;: { &quot;访客哈希&quot;: [1759600000, 3] }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一共就十几篇文章，最坏也就几百条。&lt;strong&gt;为这个装一个 MySQL（常驻内存 400 兆）纯属浪费。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;所以直接写进一个文件。&lt;/p&gt;
&lt;h3&gt;关键在文件名&lt;/h3&gt;
&lt;p&gt;文件叫 &lt;code&gt;_data.php&lt;/code&gt;，内容的第一行是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;?php exit; ?&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;这一行是干什么的？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;那个文件放在网站目录里，别人是能猜到网址的。如果它叫 &lt;code&gt;count.json&lt;/code&gt;，别人打开网址就能把整个数据库下载走。&lt;/p&gt;
&lt;p&gt;但叫 &lt;code&gt;.php&lt;/code&gt; 就不一样了 —— 别人访问的时候，服务器会去&lt;strong&gt;执行&lt;/strong&gt;它，而不是把内容发出去。第一行 &lt;code&gt;exit&lt;/code&gt; 的意思就是「到此为止」，后面那串 JSON &lt;strong&gt;一个字都不会发出去&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;同样的存储，换个后缀，从「谁都能下载」变成「谁看都是空白」。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;比起去改 nginx 配置加一条「禁止访问这个文件」，这个办法不用改任何配置，也不会配错。&lt;/p&gt;
&lt;p&gt;（当然，前提是服务器上装了 PHP。要是没装，nginx 会把它当普通文本发出来。所以我是先把 PHP 装好，再放这个文件的。）&lt;/p&gt;
&lt;h2&gt;四、防刷：同一个 IP 看同一篇，一小时只算一次&lt;/h2&gt;
&lt;p&gt;不设这个的话，我自己按着 F5 就能把阅读量刷到一万。&lt;/p&gt;
&lt;p&gt;做法是把访客的 IP 加一点盐、算个哈希，然后记下「这个哈希在什么时候看过这个页面」。一小时之内再来，就不算了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;存的是哈希，不是原 IP。&lt;/strong&gt; 这样万一哪天这个文件被人拿到了，也看不出谁是谁。&lt;/p&gt;
&lt;p&gt;不过要老实说一句：&lt;strong&gt;这样会少算。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一个 WiFi 下十个人看同一篇文章，只算一次；同一个运营商的大内网里，可能几千人都算成一个。所以这个数字的意思是「&lt;strong&gt;大概有多少人看过&lt;/strong&gt;」，不是精确的点击次数。&lt;/p&gt;
&lt;p&gt;想看精确的也能做（记浏览器指纹），但那就是在追踪访客了。一个浏览量，不值当。&lt;/p&gt;
&lt;h2&gt;五、前端：一次请求把数字全拿回来&lt;/h2&gt;
&lt;p&gt;首页上摆着十几张文章卡片，每张都要显示阅读数。&lt;/p&gt;
&lt;p&gt;最直接的写法是&lt;strong&gt;一张卡发一次请求&lt;/strong&gt;。但那就是打开一次首页要发十几次 —— 我服务器带宽只有 5M，这属于自己给自己添堵。&lt;/p&gt;
&lt;p&gt;所以改成：脚本先把页面上所有需要的路径收齐，合成&lt;strong&gt;一次&lt;/strong&gt;发出去。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  hit:   &quot;当前这一篇&quot;,          // 记一笔
  read:  [&quot;卡片1&quot;, &quot;卡片2&quot;, …], // 顺便把这些的数字也给我
  total: true                   // 顺便把全站总数也给我
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;服务器一次把三样都办了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个来回，不管页面上有多少个数字。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;六、小设计：拿不到数字，就装作没有这个功能&lt;/h2&gt;
&lt;p&gt;如果服务器上的接口没装好，或者访客的网络把它拦了，会怎么样？&lt;/p&gt;
&lt;p&gt;我的做法是：&lt;strong&gt;那些位置一开始全是藏起来的，拿到数字才显示。&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;接口好的时候 → 正常显示「128 次阅读」&lt;/li&gt;
&lt;li&gt;接口不通的时候 → &lt;strong&gt;这一项根本不出现&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而不是留一个「0 次阅读」或者「NaN」在那儿碍眼。&lt;/p&gt;
&lt;p&gt;原理简单到只有一条 CSS：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[data-views-path]:not([data-views-ready]) { display: none !important; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;脚本把数字填进去之后，给这个元素加上 &lt;code&gt;data-views-ready&lt;/code&gt;，它就冒出来了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;「优雅降级」这个词听着挺高级，其实意思就是：坏了也别让人看见。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;七、统计页 vs 后台：我选了公开页面&lt;/h2&gt;
&lt;p&gt;本来想做个「后台」—— 加个密码、开个端口，只有我自己能看数据。&lt;/p&gt;
&lt;p&gt;后来想了想，改成做一个&lt;strong&gt;公开的统计页&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;理由是：&lt;/p&gt;
&lt;p&gt;&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;/p&gt;
&lt;pre&gt;&lt;code&gt;全站总阅读        文章          平均每篇
   1,234          17 篇           73

阅读排行
1  ████████████████████  博客搬进自己的服务器…   128 次
2  ██████████            今天把博客翻修了一遍…    64 次
3  ████                  我做的第一个像样的东西…   26 次
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个页面的骨架是&lt;strong&gt;构建的时候就写好的&lt;/strong&gt;（谁也不知道数字是多少），数字是打开的时候现拿的。所以它既是个静态页面（快、不怕挂），又能显示实时数据。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;没有后台，但该有的都有。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;今天做的其实是很多个「选哪条路」：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;要干的事&lt;/th&gt;
&lt;th&gt;选的路&lt;/th&gt;
&lt;th&gt;为什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;存数据&lt;/td&gt;
&lt;td&gt;一个 &lt;code&gt;.php&lt;/code&gt; 文件&lt;/td&gt;
&lt;td&gt;十几篇文章，不值得装 MySQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;接口用什么语言&lt;/td&gt;
&lt;td&gt;PHP&lt;/td&gt;
&lt;td&gt;不用常驻进程，省内存&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据怎么不被人看&lt;/td&gt;
&lt;td&gt;换个后缀&lt;/td&gt;
&lt;td&gt;零配置，比改 nginx 简单&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;前端怎么省请求&lt;/td&gt;
&lt;td&gt;合并成一次&lt;/td&gt;
&lt;td&gt;5M 带宽经不起十几次请求&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;数据看板&lt;/td&gt;
&lt;td&gt;公开页面&lt;/td&gt;
&lt;td&gt;少三个出错的地方&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;这里面没有一个是「哪个技术更高级」，全是「我这个具体情况该怎么办」。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我觉得这才是真正难的地方 —— 会用一个技术不难，知道&lt;strong&gt;在这个场景下该不该用它&lt;/strong&gt;才难。&lt;/p&gt;
</content:encoded></item><item><title>博客搬进自己的服务器：网盘、评论，还有一个藏了很久的 bug</title><link>https://liyuanjie.com/posts/seventeenth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/seventeenth-post/</guid><description>自己买了服务器，把博客从 Cloudflare 搬了过来。顺手把下载改成网盘、给文章加了评论，还挖出一个藏了很久的 bug：翻几篇文章之后点「返回」，一次会退好几页。</description><pubDate>Sun, 04 Oct 2026 20:30:00 GMT</pubDate><content:encoded>&lt;p&gt;今天干了三件事：买服务器、搬博客、加评论。&lt;/p&gt;
&lt;p&gt;搬完顺手点了一下文章左上角的「返回」——&lt;strong&gt;它一次退了好几页&lt;/strong&gt;，直接给我退到浏览器外面去了。&lt;/p&gt;
&lt;p&gt;追下去才发现是我自己埋的坑，而且是那种「不查源码根本想不到」的坑。&lt;/p&gt;
&lt;h2&gt;一、为什么要搬&lt;/h2&gt;
&lt;p&gt;以前博客放在 Cloudflare Pages 上，免费、快、不用管。听起来完美，但它有条规矩：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;单个文件不能超过 25 MB。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我那个「更多的音乐唱片」是 26.41 MB，超了 194 KB。而超了的后果不是「这一个文件传不上去」，是&lt;strong&gt;整次部署都失败&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;（试过重新压，只省得下 700 字节 —— 里面全是 ogg 音频，本来就已经压过了，压不动。）&lt;/p&gt;
&lt;p&gt;还有一个更实在的问题：&lt;strong&gt;下载&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;我在页面上放了自己的安装包，别人点一下就直接从我站上拿。在 Cloudflare 那边没事，但换成自己的服务器之后，这台机器带宽只有 &lt;strong&gt;5M&lt;/strong&gt;，大概 625 KB 一秒。一个 5.3 MB 的 APK，一个人下就要占满 &lt;strong&gt;8.5 秒&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这 8.5 秒里，别人打开我的博客就是转圈。&lt;/p&gt;
&lt;p&gt;所以就买了台香港的小机器：2 核 2G，38 块钱一个月。&lt;strong&gt;香港不用备案&lt;/strong&gt;，这是最省事的一点。&lt;/p&gt;
&lt;h2&gt;二、下载：我不该自己发文件&lt;/h2&gt;
&lt;p&gt;想通一件事：&lt;strong&gt;问题不在我代码写得怎么样，在「自己发文件」这件事本身。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;文件大了传不上去，有人一直下还会把带宽占满。这两条跟我写代码的水平没关系。&lt;/p&gt;
&lt;p&gt;所以改成：&lt;strong&gt;点「下载」不下载，弹个窗，把人送到网盘。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;点「下载」之后，先是这么两下：

  [[ 服务器繁忙，请使用备用地址或稍后再试 ]]        ← 先出来
┌──────────────────────────────────────────────┐
│  选择下载方式                                │
│  Home Chat 安卓端 APP                        │
│                                              │
│  ┌────────────────────────────────────────┐  │
│  │  GitHub                         [推荐] │  │
│  └────────────────────────────────────────┘  │
│                                              │
│  ┌────────────────────────────────────────┐  │
│  │ ☁ 蓝奏云                              │  │
│  └────────────────────────────────────────┘  │
│                                              │
│  ┌────────────────────────────────────────┐  │
│  │  🔑 提取密码  0808                     │  │
│  └────────────────────────────────────────┘  │
└──────────────────────────────────────────────┘
                                                  ← 250 毫秒后跟上
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;「服务器繁忙」那条&lt;strong&gt;先&lt;/strong&gt;冒出来，「选择下载方式」&lt;strong&gt;晚 250 毫秒&lt;/strong&gt;才跟上来。
&lt;strong&gt;先说自己忙、再给备用地址，读起来才顺&lt;/strong&gt; —— 反过来会像是自己没事找事。&lt;/p&gt;
&lt;p&gt;「服务器繁忙」那条&lt;strong&gt;先&lt;/strong&gt;冒出来，「选择下载方式」&lt;strong&gt;晚 250 毫秒&lt;/strong&gt;才跟上来。
&lt;strong&gt;先说自己忙、再给备用地址，读起来才顺&lt;/strong&gt; —— 反过来会像是自己没事找事。&lt;/p&gt;
&lt;p&gt;每样东西最多给两个选择：&lt;strong&gt;GitHub（拿源码，推荐）&lt;/strong&gt; 和蓝奏云（拿安装包）。源码放 GitHub 上最权威，也不会哪天链接失效。&lt;/p&gt;
&lt;h3&gt;这里有个坑：弹窗里的按钮不能&quot;拼字符串&quot;&lt;/h3&gt;
&lt;p&gt;弹窗里的按钮是&lt;strong&gt;按数据现场拼出来的&lt;/strong&gt;，不是写死在 HTML 里的 —— 因为下载页、文章页、HomeChat 页三个地方要弹同一个窗，我不可能每处都画一遍。&lt;/p&gt;
&lt;p&gt;那问题来了：&lt;strong&gt;按钮上有个图标（一朵云，或者 GitHub 那只猫），脚本画不出来。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我第一个想到的写法是拼字符串：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;box.innerHTML = `&amp;lt;a href=&quot;${href}&quot;&amp;gt;&amp;lt;svg&amp;gt;…&amp;lt;/svg&amp;gt;${label}&amp;lt;/a&amp;gt;`;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;千万别这么写。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;只要 &lt;code&gt;href&lt;/code&gt; 或者 &lt;code&gt;label&lt;/code&gt; 里混进一段别人能控制的文字，这就等于&lt;strong&gt;把一段字符串当代码执行&lt;/strong&gt; —— 这就是 XSS，课本级的漏洞。&lt;/p&gt;
&lt;p&gt;正确的写法是：&lt;strong&gt;提前把图标画好，藏在一个看不见的池子里，用的时候复制一份。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;const svg = pool.querySelector(&apos;[data-dl-icon=&quot;pan&quot;] svg&apos;);
a.appendChild(svg.cloneNode(true));

const label = document.createElement(&quot;span&quot;);
label.textContent = choice.label;   // ← 用 textContent，不是 innerHTML
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;textContent&lt;/code&gt; 是把内容当&lt;strong&gt;纯文字&lt;/strong&gt;塞进去的，里面写什么都不会被当成代码。&lt;/p&gt;
&lt;p&gt;再加一道保险：链接只认 &lt;code&gt;http://&lt;/code&gt; 和 &lt;code&gt;https://&lt;/code&gt; 开头的，&lt;code&gt;javascript:&lt;/code&gt; 那种一律丢掉。&lt;/p&gt;
&lt;h2&gt;三、评论：为什么选了 giscus&lt;/h2&gt;
&lt;p&gt;评论区我没自己写，用的是 &lt;strong&gt;giscus&lt;/strong&gt; —— 它把评论存在 GitHub Discussions 里。&lt;/p&gt;
&lt;p&gt;好处很明显：不用我维护、不怕人刷广告、不要钱，而且数据不在我服务器上（万一我那台机器炸了，评论还在）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;代价只有一个，但很关键：想评论的人必须有 GitHub 账号。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我为什么还是选了它？因为&lt;strong&gt;我这个博客的读者，基本都是小学同学&lt;/strong&gt; —— 打编程比赛、创客比赛那批人，他们有账号。&lt;/p&gt;
&lt;p&gt;换成别的读者群体，我可能就得自己写一套了。&lt;/p&gt;
&lt;h3&gt;又一个小坑：评论框不跟着主题变色&lt;/h3&gt;
&lt;p&gt;giscus 自带一个「跟随系统深浅色」的选项。但它&lt;strong&gt;认的是操作系统的设置，不认我博客上那个开关。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;也就是说：我把博客切成白色，评论框还是黑的，特别突兀。&lt;/p&gt;
&lt;p&gt;修法是给它发消息：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;frame.contentWindow.postMessage(
  { giscus: { setConfig: { theme: &quot;dark_dimmed&quot; } } },
  &quot;https://giscus.app&quot;
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再挂两个监听：一个盯着 &lt;code&gt;&amp;lt;html&amp;gt;&lt;/code&gt; 的 class 变没变（我切主题就是改这个），一个盯着系统设置变没变。&lt;/p&gt;
&lt;p&gt;颜色我挑的是 &lt;code&gt;dark_dimmed&lt;/code&gt;，不是 &lt;code&gt;dark&lt;/code&gt;。因为 GitHub 的纯黑是 &lt;code&gt;#0d1117&lt;/code&gt;，而我的卡片底色偏灰蓝，两个放一起会显得凹进去一块。&lt;/p&gt;
&lt;h2&gt;四、藏得最深的那个 bug：返回键&lt;/h2&gt;
&lt;p&gt;这是今天最有意思的一个。&lt;/p&gt;
&lt;p&gt;文章左上角有个「返回」按钮。逻辑是：从站内点进来的，就退回上一页；从站外点进来的，就回归档页。&lt;/p&gt;
&lt;p&gt;写完我测了一下 —— 好使。就放那儿了。&lt;/p&gt;
&lt;p&gt;结果今天翻了几篇文章之后点它，&lt;strong&gt;一次退了好几页&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;追下去&lt;/h3&gt;
&lt;p&gt;那段逻辑挂在 &lt;code&gt;document&lt;/code&gt; 上的点击监听里。&lt;strong&gt;一个监听，一次 &lt;code&gt;history.back()&lt;/code&gt;，不可能退好几页。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;除非……不止一个监听。&lt;/p&gt;
&lt;p&gt;我数了一下：翻三篇文章，就有三个监听。点一次返回，&lt;code&gt;history.back()&lt;/code&gt; 就被调用好几次。&lt;/p&gt;
&lt;h3&gt;多出来的监听哪来的&lt;/h3&gt;
&lt;p&gt;fuwari 这个主题用 &lt;strong&gt;swup&lt;/strong&gt; 做无刷新换页 —— 点链接的时候不整页刷新，只把 &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; 里的内容换掉，所以翻页很顺。&lt;/p&gt;
&lt;p&gt;swup 有个插件叫 &lt;strong&gt;ScriptsPlugin&lt;/strong&gt;，作用是：换页之后，把新内容里的 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; &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;/p&gt;
&lt;pre&gt;&lt;code&gt;document.addEventListener(&quot;click&quot;, handler);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;每执行一次，就多挂一个监听，旧的永远不会解绑。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;翻 N 页 → N 个监听 → 点一次返回 → &lt;code&gt;history.back()&lt;/code&gt; 跑 N 次。&lt;/p&gt;
&lt;h3&gt;修法：把逻辑挪出 main&lt;/h3&gt;
&lt;p&gt;既然重复执行是因为「脚本待在一个会被换掉的容器里」，那就&lt;strong&gt;别放在那儿&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;挪到布局文件里。那个脚本每页都会加载，但&lt;strong&gt;一个完整页面里只执行一次&lt;/strong&gt;，所以监听也只挂一个。&lt;/p&gt;
&lt;h3&gt;顺手搞懂的一件事：为什么挪出去就安全&lt;/h3&gt;
&lt;p&gt;这里有个我一开始没想明白的点：ScriptsPlugin 不是「把文档里的脚本全重塞一遍」吗？那布局里那个脚本不也一样被重塞、被重新执行？&lt;/p&gt;
&lt;p&gt;答案是&lt;strong&gt;浏览器有模块缓存&lt;/strong&gt;：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;脚本长什么样&lt;/th&gt;
&lt;th&gt;换页时会不会再执行&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;script type=&quot;module&quot;&amp;gt;代码直接写在这儿&amp;lt;/script&amp;gt;&lt;/code&gt;（内联）&lt;/td&gt;
&lt;td&gt;✅ &lt;strong&gt;会&lt;/strong&gt;，每换一页执行一次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;script type=&quot;module&quot; src=&quot;/xxx.js&quot;&amp;gt;&amp;lt;/script&amp;gt;&lt;/code&gt;（外链）&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;不会&lt;/strong&gt;，浏览器按网址缓存，一个页面只执行一次&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;而 Astro 打包出来的脚本全是第二种。&lt;/p&gt;
&lt;p&gt;所以：&lt;strong&gt;逻辑写在 &lt;code&gt;Layout.astro&lt;/code&gt; 的 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 里只跑一次；写在会被替换的区域里的内联 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt;，就会反复跑。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;一句话：&lt;strong&gt;要挂全局监听，就放布局。&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;同一个毛病还有一处&lt;/h3&gt;
&lt;p&gt;代码块右上角那个「复制」按钮，也是内联脚本、也是挂 &lt;code&gt;document&lt;/code&gt; 监听、也会叠。&lt;/p&gt;
&lt;p&gt;它的后果轻一点（连着复制同一段代码，结果是一样的），但监听还是一直在涨。&lt;/p&gt;
&lt;p&gt;swup 留了开关：给 &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; 加一个 &lt;code&gt;data-swup-ignore-script&lt;/code&gt;，它就会跳过这个脚本。加上就好了。&lt;/p&gt;
&lt;h2&gt;五、搬家的几个实操细节&lt;/h2&gt;
&lt;p&gt;记几个容易卡住的地方：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;① 端口填 80。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;添加站点的时候要填端口。填 &lt;code&gt;80&lt;/code&gt; —— 那是浏览器不写端口时默认去敲的那扇门。填别的（比如 8080），网址就得写成 &lt;code&gt;http://xxx:8080&lt;/code&gt;，又长又容易记错。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;② gzip 是默认开着的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我一开始看到首页 131 KB 吓了一跳，其实服务器真正传出去的只有 &lt;strong&gt;21 KB 左右&lt;/strong&gt; —— 剩下的都是压缩省下来的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;③ 404 页要自己接一根线。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Astro 会生成一个自己的 &lt;code&gt;404.html&lt;/code&gt;，但 nginx 不知道要用它，默认吐自己那个白底黑字的。要在站点配置里加一行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;error_page 404 /404.html;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;④ 香港服务器不用备案。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;域名解析过去就能用，省掉了最麻烦的一步。&lt;/p&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;今天加起来就三件事：搬家、下载改网盘、加评论。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但真正学到东西的，是最后那个返回键的 bug。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;它不是语法写错了，是我&lt;strong&gt;根本不知道「换页的时候脚本会重跑」这件事&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这也解释了为什么之前播放器也出过类似的毛病 —— 我总想把逻辑写在「离功能最近的地方」，觉得那样看着清楚、好改。&lt;/p&gt;
&lt;p&gt;可这个主题是&lt;strong&gt;无刷新换页&lt;/strong&gt;的。在这个前提下，「离功能最近」恰恰就是「最容易被换掉、最容易被重复执行」的地方。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;有些东西的作用域，比它看起来的位置大得多。&lt;/strong&gt;&lt;/p&gt;
</content:encoded></item><item><title>今天把博客翻修了一遍：五个我没想到的坑</title><link>https://liyuanjie.com/posts/sixteenth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/sixteenth-post/</guid><description>下载区重做、音乐播放器上线、404 页面修好。顺手挖出五个坑：被拉成长条的播放器、自己跑到左上角的小窗、一翻页就断的音乐，还有我自己挂到公网上的内部文件。</description><pubDate>Sat, 03 Oct 2026 22:40:00 GMT</pubDate><content:encoded>&lt;p&gt;今天没写新功能，全在修东西。&lt;/p&gt;
&lt;p&gt;修完发现，&lt;strong&gt;五个坑里没有一个是我&quot;代码写错了&quot;&lt;/strong&gt; —— 全是我想事情的时候漏了一块。
记下来，因为漏的那几块比语法错误难发现多了。&lt;/p&gt;
&lt;h2&gt;坑一：播放器被拉成了一根长条&lt;/h2&gt;
&lt;p&gt;音乐播放器右下角有个小窗，点上面一个圆点按钮，它会收成一个小圆盘。&lt;/p&gt;
&lt;p&gt;结果点下去，&lt;strong&gt;没变圆，变成了一根长条&lt;/strong&gt; —— 跟卡片一样宽、只有一点点高。&lt;/p&gt;
&lt;p&gt;原因藏在一行 CSS 里。播放器自己的样式这样写：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.nmpv3-player.nmpv3-is-minimized {
    width: 80px; height: 80px; border-radius: 50%;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;收起的时候，宽高都是 80 像素，圆角 50% —— 所以是个圆。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;而我为了让小窗里塞得下多出来的按钮，加过这么一条：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.nmpv3-player[data-layout=&quot;mini&quot;] {
    width: min(352px, 100%) !important;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;我这条带 &lt;code&gt;!important&lt;/code&gt;，它那条没带。&lt;/strong&gt; 于是收起的时候：宽度被我的 352px 覆盖了，
高度还是它自己的 80px。&lt;strong&gt;352 × 80，正好是那根长条。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;修法就一个词：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;.nmpv3-player[data-layout=&quot;mini&quot;]:not(.nmpv3-is-minimized) {
    width: min(352px, 100%) !important;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;加上 &lt;code&gt;:not(.nmpv3-is-minimized)&lt;/code&gt;，收起状态就不受我这条影响。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;学到的是：&lt;code&gt;!important&lt;/code&gt; 不是&quot;让我的样式生效&quot;，是&quot;让我的样式在任何情况下都生效&quot;。
一个组件有很多状态（收起 / 展开 / 播放中 / 面板打开），&lt;strong&gt;我在改一个属性的时候，
得先问一句：这个属性在别的状态下是什么值？&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;坑二：它还会自己跑到左上角&lt;/h2&gt;
&lt;p&gt;这个更玄学 —— 偶尔刷新，小窗会出现在&lt;strong&gt;左上角&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;不是随机漂移，是&lt;strong&gt;有条件的&lt;/strong&gt;：只有浏览器窗口比较窄的时候才出现。&lt;/p&gt;
&lt;p&gt;播放器会把&quot;上次拖到哪儿了&quot;存下来，下次打开照着那个坐标摆。恢复之前它会做一次夹取，
确保不跑到屏幕外面：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;left = Math.max(8, Math.min(窗口宽 - 播放器宽 - 8, 存下来的 left))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;看着没问题，对吧？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;窗口一窄，&lt;code&gt;窗口宽 - 播放器宽 - 8&lt;/code&gt; 就成了负数。&lt;code&gt;Math.min(负数, 存的坐标)&lt;/code&gt; 还是负数，
然后 &lt;code&gt;Math.max(8, 负数)&lt;/code&gt; —— &lt;strong&gt;得到 8。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;横坐标 8，纵坐标同理也是 8。&lt;strong&gt;左上角。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这个夹取本来是为了&quot;别跑出屏幕&quot;，结果在窗口太窄的时候，把坐标夹到了另一个极端。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我的修法比较粗暴：不让它拖了，再用 &lt;code&gt;!important&lt;/code&gt; 把位置按死在右下角，
顺便把存下来的旧坐标删掉。&lt;/p&gt;
&lt;h2&gt;坑三：一翻页，音乐就断了&lt;/h2&gt;
&lt;p&gt;这个是我自己设计的错。&lt;/p&gt;
&lt;p&gt;博客的翻页不是真的跳转，是一个叫 swup 的东西&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;strong&gt;把播放器挪到&quot;被替换的那块&quot;外面去。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;现在它写在最外层的布局里，全站就这一个，swup 碰不到它。
翻到哪一页音乐都在放，而且&lt;strong&gt;换页面的时候它还会换形态&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;音乐页：完整版（歌单、播放模式、歌词）&lt;/li&gt;
&lt;li&gt;别的页：一条简洁的小条，不挡着看文章&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;坑四：我把自己的内部文件挂到了公网上&lt;/h2&gt;
&lt;p&gt;这个是最该打自己的一下。&lt;/p&gt;
&lt;p&gt;博客的下载区里有个文件夹叫 &lt;code&gt;public/&lt;/code&gt;。&lt;strong&gt;这个文件夹里的东西，构建之后会被原样传到网上。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我在里面放了三个文件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;清单.txt&lt;/code&gt; —— 写下载项名字和说明的&lt;/li&gt;
&lt;li&gt;&lt;code&gt;_放文件说明.md&lt;/code&gt; —— 讲怎么加文件的&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vendor/说明.txt&lt;/code&gt; —— 讲播放器怎么升级的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;这三个全是写给我自己看的操作说明。&lt;/strong&gt; 里面写着&quot;把你的地址填进去&quot;、&quot;别忘了改这个&quot;。
任何人猜到网址，就能看到。&lt;/p&gt;
&lt;p&gt;我实测过，三个都是 HTTP 200 —— &lt;strong&gt;确实挂在公网上。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;修法：把它们全挪到 &lt;code&gt;public/&lt;/code&gt; 外面。&lt;strong&gt;该给访客看的和该给自己看的分开放。&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;顺便说一下怎么发现的：不是我看代码看出来的，是我把全站页面的文字抓下来，
搜&quot;内部说法&quot;搜出来的。&lt;strong&gt;换个角度扫一遍，比盯着代码看有用。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;坑五：加个下载项要改代码 —— 而我打不开那个文件&lt;/h2&gt;
&lt;p&gt;下载区原来是这样设计的：想加个东西，得打开 &lt;code&gt;download.astro&lt;/code&gt; 改一个数组。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;问题是我打不开 &lt;code&gt;.astro&lt;/code&gt; 文件。&lt;/strong&gt; 打开就是一堆代码，改错一个标点整个网站都构建不了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个要求用户改代码才能用的功能，对不会代码的用户来说就是不存在的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;现在改成两个动作，都不用碰代码：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;想干什么&lt;/th&gt;
&lt;th&gt;做什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;把东西给人下载&lt;/td&gt;
&lt;td&gt;传到网盘，拿一条分享链接&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;写清楚它是什么&lt;/td&gt;
&lt;td&gt;双击 &lt;code&gt;下载清单.txt&lt;/code&gt; 加一行&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;还做了兜底：清单里写了、但忘了配网盘链接的，构建的时候终端会红字提醒你是哪一行，
不会悄悄变成一个点不动的按钮。&lt;/p&gt;
&lt;h2&gt;最后发现：问题都不在&quot;代码写错了&quot;&lt;/h2&gt;
&lt;p&gt;五个坑，回头看看：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;坑&lt;/th&gt;
&lt;th&gt;真正的原因&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;长条播放器&lt;/td&gt;
&lt;td&gt;我改样式时没考虑&quot;另一个状态&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;跑到左上角&lt;/td&gt;
&lt;td&gt;一段&quot;防出屏&quot;的代码在窄窗口下失效了&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;翻页断音乐&lt;/td&gt;
&lt;td&gt;我把东西放在了会被替换的位置&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;内部文件上公网&lt;/td&gt;
&lt;td&gt;我混了&quot;给我看的&quot;和&quot;给访客看的&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;下载要改代码&lt;/td&gt;
&lt;td&gt;我设计的时候没想过&quot;用户会不会写代码&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;没有一个是语法错误、变量名写错、少个分号这种。&lt;/strong&gt; 全是&quot;我想事情的时候漏了一块&quot;：&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;li&gt;漏了&lt;strong&gt;谁会用&lt;/strong&gt;（坑五）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;而且这五个我自己都没发现 —— 是我把页面打开一个个点、点出来的。
&lt;strong&gt;代码跑起来不报错，不代表它是对的。&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;现在的样子&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;下载区：不用碰代码，拖文件 + 写清单就行&lt;/li&gt;
&lt;li&gt;每个下载项可以同时挂本地和网盘，点&quot;下载&quot;会问一句在哪儿下&lt;/li&gt;
&lt;li&gt;音乐播放器：全站都在，翻页不断；右下角，收得起来，不会自己乱跑&lt;/li&gt;
&lt;li&gt;404 页面：以前压根没有，现在跟站内其它页面一个样子&lt;/li&gt;
&lt;li&gt;网站上不会再有我自己的内部说明文件&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;至于那些坑 —— 记在这，下次改东西之前先把这五条过一遍。&lt;/p&gt;
</content:encoded></item><item><title>我用的是哪家服务器，新手该买什么样的</title><link>https://liyuanjie.com/posts/fifteenth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/fifteenth-post/</guid><description>我用的是雨云，2 核 2G、8Mbps。这篇说说为什么这么选，还有新手买服务器最容易踩的几个坑。</description><pubDate>Sat, 03 Oct 2026 20:40:00 GMT</pubDate><content:encoded>&lt;p&gt;先回答最直接的问题：&lt;strong&gt;我用的是雨云的服务器。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;配置是 &lt;strong&gt;2 核 2G，带宽 8Mbps&lt;/strong&gt;，上面跑着一个 Node 后端和 SQLite 数据库，
还装了宝塔面板。够用，但也就刚好够用。&lt;/p&gt;
&lt;p&gt;这篇说我为什么这么选，还有新手买服务器容易踩的坑。&lt;/p&gt;
&lt;h2&gt;我为什么选小厂&lt;/h2&gt;
&lt;p&gt;大厂也有，国内头部那几家都很好。但对我来说有两个原因选了别家：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一是便宜。&lt;/strong&gt; 同样 2 核 2G 的配置，价格能差好几倍。我是学生，没有收入，
能省就省。省下来的钱够买好几本参考书。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二是够用。&lt;/strong&gt; 我跑的是一个小网站，几个用户，一天也就几百个请求。
大厂那种带负载均衡、带各种企业功能的套餐，对我来说全是浪费。&lt;/p&gt;
&lt;p&gt;代价是：小厂偶尔会出点小问题，工单回复没大厂快。不过到目前为止我没遇到过
真正影响使用的事故。&lt;/p&gt;
&lt;h2&gt;新手最容易踩的三个坑&lt;/h2&gt;
&lt;h3&gt;坑一：只看核心数，不看内存&lt;/h3&gt;
&lt;p&gt;「2 核 2G」里面，&lt;strong&gt;G 比核重要&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;核是 CPU 核心数，2 核对小网站绰绰有余。内存（就是那个 G）才是关键：
Linux 系统本身要占几百兆，宝塔面板要占几百兆，你要是再装个 MySQL，
光数据库就得再吃五百兆起。&lt;/p&gt;
&lt;p&gt;我第一台机器是 &lt;strong&gt;1G 内存&lt;/strong&gt;，装完面板 + Node + MySQL，系统直接开始用交换分区，
整个网站卡得没法用。后来换成 2G，并且把 MySQL 换成了 SQLite（它不用单独起进程），
这才顺了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;结论：内存至少 2G。&lt;/strong&gt; 装数据库的话建议 4G。&lt;/p&gt;
&lt;h3&gt;坑二：带宽买太小&lt;/h3&gt;
&lt;p&gt;带宽决定「同时能有多少人访问」。1Mbps 差不多是 128KB/s——
一个人打开你的网页没问题，十个人同时打开就排队。&lt;/p&gt;
&lt;p&gt;我一开始买的是 1Mbps，自己测试觉得挺快，给同学发链接，
几个人一起点，页面就开始转圈。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;结论：带宽至少 3Mbps。&lt;/strong&gt; 我的 8Mbps 是够用的。
如果只是自己用、没有别人访问，那 1Mbps 也行。&lt;/p&gt;
&lt;h3&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;/ul&gt;
&lt;p&gt;我第一次买的时候完全没考虑这个，买完国内服务器、传完代码，
才发现域名访问不了——因为没备案。只能停下来等。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;结论：先决定要不要备案，再买服务器。&lt;/strong&gt; 要是想立刻上线又不想等，
就买香港的；要是长期做，就忍一下备案。&lt;/p&gt;
&lt;h2&gt;具体怎么选：按你的用途&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;你想干什么&lt;/th&gt;
&lt;th&gt;建议&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;只放一个静态博客&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;别买服务器&lt;/strong&gt;。用 Cloudflare Pages 或者 GitHub Pages，免费，还不用备案&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;跑一个 Node/Python 后端自己用&lt;/td&gt;
&lt;td&gt;2 核 2G，带宽 3Mbps 起，够&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;几个人一起用的小网站&lt;/td&gt;
&lt;td&gt;2 核 4G，带数据库&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;学习 Linux，随便折腾&lt;/td&gt;
&lt;td&gt;买个最便宜的，装坏了重装，练手用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;第一行是我最想说的：&lt;strong&gt;静态博客真的不需要服务器&lt;/strong&gt;。
我的这个博客就放在 Cloudflare Pages 上，一分钱不花，推一下 GitHub 就自动更新，
还自带 HTTPS 和全球加速。&lt;/p&gt;
&lt;p&gt;需要买服务器的场景只有一种：&lt;strong&gt;你的程序需要一直在后台跑&lt;/strong&gt;（比如我那个外呼软件的后端）。
纯展示的网站，用静态托管就够了。&lt;/p&gt;
&lt;h2&gt;一个省钱的办法&lt;/h2&gt;
&lt;p&gt;如果你的程序确实要跑在服务器上，但访问量很小，可以考虑：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;前端放 Cloudflare Pages（免费），只有后端放服务器。&lt;/strong&gt;
这样服务器上不用跑网站服务，压力小很多，配置也能买低一点。&lt;/p&gt;
&lt;p&gt;我现在就是这么分的：博客在 Cloudflare Pages，外呼软件的后端在自己服务器上。&lt;/p&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;总结一下我的选择：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;服务器&lt;/strong&gt;：雨云，2 核 2G，8Mbps&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;：SQLite，不用单独起进程，省内存&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;博客&lt;/strong&gt;：Cloudflare Pages，免费&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不是说这么配最好，是这套组合对「学生 + 小项目 + 想省钱」这个情况比较合适。
你要是预算充足，或者要给别人提供服务，那大厂的高配肯定更稳。&lt;/p&gt;
</content:encoded></item><item><title>0.1 + 0.2 为什么算不对，我是怎么绕过去的</title><link>https://liyuanjie.com/posts/seventh-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/seventh-post/</guid><description>电脑存小数用的是二进制浮点数，0.1 存不进去，只能存个近似的。我自己写了个计算器，一层一层往上把精度捞回来。</description><pubDate>Fri, 02 Oct 2026 19:19:00 GMT</pubDate><content:encoded>&lt;p&gt;先看一个事：在大多数计算器上敲 &lt;code&gt;0.1 + 0.2&lt;/code&gt;，你会得到
&lt;code&gt;0.30000000000000004&lt;/code&gt;。小学生都知道这是错的，但计算器就是这么答。&lt;/p&gt;
&lt;p&gt;原因不复杂：电脑存小数用的是二进制浮点数，而 0.1 在二进制里是个无限循环小数，
存不进去，只能存一个很近似的值。两个近似值一加，误差就露出来了。
这不是计算器的 bug，是它背后那套东西的天花板。&lt;/p&gt;
&lt;p&gt;我做「全能计算器」就是为了掀掉这个天花板。&lt;/p&gt;
&lt;h2&gt;不用第三方库，一层一层往上搭&lt;/h2&gt;
&lt;p&gt;整个计算器是纯 Python 标准库写的，一个外部依赖都没有。核心是一个&quot;精确数塔&quot;，
数值按精确度分成五层，任何运算都优先在最高那一层完成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;int&lt;/strong&gt;：任意精度整数，位数没有上限，Python 原生就支持。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fraction&lt;/strong&gt;：分数。加减乘除永远是精确的，自动约分。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rad&lt;/strong&gt;：形如 Σqᵢ√rᵢ 的代数数。√2、√2+√3、√6/3 都是精确的符号，不是小数。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PiMul&lt;/strong&gt;：形如 a + bπ，π/6、2π/3 这种也保持精确。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Decimal&lt;/strong&gt;：真正超越的情形才用它，比如 sin、ln、e^x。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以它会这么算：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0.1 + 0.2      = 3/10              （不是 0.30000000000000004）
0.75           = 3/4
1/3 + 1/6      = 1/2
√8             = 2√2
√2 + √3        = √2 + √3           （就保持这个形式）
√(3+2√2)       = 1 + √2             （嵌套根式自动拆开）
1/√3           = √3/3
sin(π/6)       = 1/2               （直接给精确值，不是 0.49999999999999994）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后那条我挺得意。三角函数的精确值表我是单独做的，而且能认出&quot;这个输入在数值上等于 π 的
有理数倍&quot;，然后按精确值返回。&lt;/p&gt;
&lt;h2&gt;π 是怎么算到两万位的&lt;/h2&gt;
&lt;p&gt;整数和分数不难，难的是 π 这种无理数。我用的是 Chudnovsky 公式加二叉分裂，
全程整数运算，两万位大概 0.5 秒。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;e&lt;/code&gt;、&lt;code&gt;ln&lt;/code&gt;、&lt;code&gt;exp&lt;/code&gt;、&lt;code&gt;sqrt&lt;/code&gt; 走的是 libmpdec 的任意精度实现；
&lt;code&gt;sin&lt;/code&gt;、&lt;code&gt;cos&lt;/code&gt;、&lt;code&gt;tan&lt;/code&gt;、&lt;code&gt;Γ&lt;/code&gt; 这一批是我自己写的级数和渐近展开。&lt;/p&gt;
&lt;h2&gt;界面也是自己画的&lt;/h2&gt;
&lt;p&gt;这部分花的时间比算法还多。我的界面没有用现成的控件去拼，是自己用 tkinter 一块一块画的，
光界面那块代码就两千多行。&lt;/p&gt;
&lt;p&gt;最麻烦的是&lt;strong&gt;二维公式排版&lt;/strong&gt;。分数不能写成 &lt;code&gt;1/3&lt;/code&gt; 糊弄过去，
得是分子在横线上、分母在横线下；根号要有真的钩和上横线；乘方要把指数抬高缩小。
根号套分数、分数套根号也得能排。内容太宽太高的时候，整块自动缩放到放得下。&lt;/p&gt;
&lt;p&gt;输入 &lt;code&gt;1/3+1/6&lt;/code&gt;，你看到的是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; 1    1     1
 ─  + ─  =  ─
 3    6     2
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;&quot;无限&quot;也是有边界的，我写在明面上&lt;/h2&gt;
&lt;p&gt;我不想说&quot;支持无限位&quot;这种话。真实情况是这样：计算器本身不设位数上限，
但有些规模物理上就是做不到，这时候它会直接告诉你为什么，而不是闷头硬算。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;99999^99999&lt;/code&gt; → 499,995 位，一秒左右。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;99999^999999&lt;/code&gt; → 499 万位，大概 11 秒，会另开一个进程算，带进度，随时能停。&lt;/li&gt;
&lt;li&gt;&lt;code&gt;(99999^99999)^99999&lt;/code&gt; → 大约 4.99×10¹⁰ 位，光把这些数字装进内存就要 20 GB，直接拒绝，并说明原因。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;sqrt(-1)&lt;/code&gt; 它也不会给你编一个 &lt;code&gt;i&lt;/code&gt; 出来，而是提示不支持复数。
极少数大到分解不动的根式，会退化成高精度小数，但界面上会老老实实标一个 ≈，
不假装那是精确值。&lt;/p&gt;
&lt;h2&gt;报错也要说人话&lt;/h2&gt;
&lt;p&gt;写这个的时候我一直在想一件事：我数学也不算好，如果我输入错了，我希望它骂我一句
还是告诉我错在哪？&lt;/p&gt;
&lt;p&gt;所以它是后面那种：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;敲 &lt;code&gt;(1+2&lt;/code&gt; → 会说&quot;第 1 个字符的这个「(」没有配对的「)」，数一数是不是少写了一个&quot;，同时在输入框里直接选中那个字符。&lt;/li&gt;
&lt;li&gt;敲 &lt;code&gt;2 3&lt;/code&gt; → 会说&quot;第 3 个字符这里有问题，「3」前面少了运算符，是漏写了 + - × ÷ 吗&quot;。&lt;/li&gt;
&lt;li&gt;结果大得算不出来 → 会告诉你大约多少位、要多少内存，再给三条可行的建议，比如改用 &lt;code&gt;log10(底数)*指数&lt;/code&gt; 只要位数。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;计算器现在放出来了，在&lt;a href=&quot;/download/&quot;&gt;下载区&lt;/a&gt;，免费。&lt;/p&gt;
</content:encoded></item><item><title>域名和 DNS：新手最容易漏的几件事</title><link>https://liyuanjie.com/posts/fourteenth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/fourteenth-post/</guid><description>域名是在阿里云买的。这篇讲怎么解析、怎么让域名别被人顺走，还有备案这件事到底卡在哪。</description><pubDate>Tue, 29 Sep 2026 21:00:00 GMT</pubDate><content:encoded>&lt;p&gt;我的域名是在&lt;strong&gt;阿里云&lt;/strong&gt;买的。买完之后我发现，光有域名没用——你得让它指向你的服务器，
还要防着它被人顺走。这篇把这些记一下。&lt;/p&gt;
&lt;h2&gt;买完之后先做两件事&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;第一件：实名认证。&lt;/strong&gt; 国内的域名注册商都要求实名，不认证域名会被暂停解析。
一般买的时候就要填身份证信息，等一两天审核过。&lt;/p&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;备案&lt;/strong&gt;，没得商量。备案在服务商那边提交，
要填网站名称、负责人信息，等管局审核，快的话一两周，慢的话一个月&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&gt;DNS 解析是怎么回事&lt;/h2&gt;
&lt;p&gt;DNS 就是把「域名」翻译成「IP 地址」。你在服务器上跑了一个网站，
别人输域名的时候，电脑得先问一圈「这个域名对应哪个 IP」，才知道去哪找。&lt;/p&gt;
&lt;p&gt;在阿里云的域名控制台里加一条记录就完事了：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;记录类型&lt;/th&gt;
&lt;th&gt;主机记录&lt;/th&gt;
&lt;th&gt;记录值&lt;/th&gt;
&lt;th&gt;什么意思&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;你的服务器 IP&lt;/td&gt;
&lt;td&gt;主域名指向哪个 IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;你的服务器 IP&lt;/td&gt;
&lt;td&gt;www 开头也指过去&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CNAME&lt;/td&gt;
&lt;td&gt;&lt;code&gt;blog&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xxx.pages.dev&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;指到另一个域名（比如 Cloudflare Pages）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;几个说明：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;@&lt;/code&gt; 表示主域名本身，比如 &lt;code&gt;example.com&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;www&lt;/code&gt; 就是 &lt;code&gt;www.example.com&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;A&lt;/code&gt; 记录填 IP，&lt;code&gt;CNAME&lt;/code&gt; 填域名&lt;/li&gt;
&lt;li&gt;改了之后&lt;strong&gt;不会立刻生效&lt;/strong&gt;，一般几分钟到几小时，这个叫 TTL&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我一开始改完就刷新，没反应，以为填错了。其实只是要等。&lt;/p&gt;
&lt;h2&gt;怎么别让域名被人顺走&lt;/h2&gt;
&lt;p&gt;域名被偷这事听着远，但真发生的时候很麻烦——别人把域名转走，
你的网站直接没了，而且拿回来很难。有几件事建议都做上：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一、开域名锁。&lt;/strong&gt; 域名控制台里有个「禁止转移」的开关（有的叫「域名锁」），
打开之后域名不能被转出到别的注册商。要转的时候自己先关掉。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二、开 DNSSEC。&lt;/strong&gt; DNSSEC 是给 DNS 加签名的，防止别人伪造解析结果把你
引到假网站。阿里云的云解析里支持，如果你用的 DNS 服务商支持就开上。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;三、别让源站 IP 暴露。&lt;/strong&gt; 如果你的服务器 IP 被人知道了，
别人可以直接打你的 IP，绕过域名。办法是用 Cloudflare 之类的做代理，
域名解析到 Cloudflare，它再转给你的服务器，别人只能看到 Cloudflare 的 IP。&lt;/p&gt;
&lt;p&gt;我的博客就放在 Cloudflare Pages 上，源站根本没有 IP 可以暴露。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;四、给域名单独设密码。&lt;/strong&gt; 注册商账号的密码别跟别的地方一样，
而且&lt;strong&gt;邮箱要单独一个&lt;/strong&gt;。域名相关的邮件（续费提醒、转移确认）都会发到那个邮箱，
邮箱丢了域名也就危险了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;五、开自动续费。&lt;/strong&gt; 域名忘了续费会被释放，释放之后别人就能注册。
我的域名开了自动续费加提前提醒。&lt;/p&gt;
&lt;h2&gt;备案这件事&lt;/h2&gt;
&lt;p&gt;如果你用国内服务器，备案是绕不过的。流程大概是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在服务器提供商那边提交备案申请&lt;/li&gt;
&lt;li&gt;填主体信息（个人就填身份证）、网站信息&lt;/li&gt;
&lt;li&gt;提供商初审&lt;/li&gt;
&lt;li&gt;提交给管局，等审核&lt;/li&gt;
&lt;li&gt;通过之后拿到备案号，一般要挂在网站底部&lt;/li&gt;
&lt;/ol&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;/li&gt;
&lt;li&gt;备案号下来之后如果换服务器，要办「接入备案」&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;小结&lt;/h2&gt;
&lt;p&gt;我现在这套是：&lt;strong&gt;域名在阿里云&lt;/strong&gt;，站点托管在 Cloudflare Pages 上（不用备案），
所以省了这一整块。如果你要用国内服务器跑后端，那就得老老实实备案。&lt;/p&gt;
&lt;p&gt;这几件事里，我觉得最值得做的是&lt;strong&gt;域名锁 + 独立邮箱&lt;/strong&gt;。
花五分钟，能避免后面特别大的麻烦。&lt;/p&gt;
</content:encoded></item><item><title>起床随机刷新方块大陆：我做过最折腾的一个模组</title><link>https://liyuanjie.com/posts/tenth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/tenth-post/</guid><description>在超平坦世界里睡一觉，醒来脚下全变了。网易版模组码 7573464，免费。防活埋、超平坦检测、流式刷新，全是坑。</description><pubDate>Sat, 19 Sep 2026 20:30:00 GMT</pubDate><content:encoded>&lt;p&gt;这是我在网易版《我的世界》里做得最久的一个模组，模组码 &lt;strong&gt;7573464&lt;/strong&gt;，
在模组中心搜「起床随机刷新」或者直接搜数字就能找到，&lt;strong&gt;免费&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一句话说玩法：你在一张超平坦世界里睡觉，睡醒的那一刻，脚底下的方块全被随机换掉。
不是换几块，是整片大陆。&lt;/p&gt;
&lt;p&gt;听着挺简单，实际上我从开始写到能玩，中间推倒过两次。&lt;/p&gt;
&lt;h2&gt;先说说为什么会想做这个&lt;/h2&gt;
&lt;p&gt;我玩超平坦世界玩腻了。超平坦就那么几层草方块，一眼望到底，建完东西就没意思了。&lt;/p&gt;
&lt;p&gt;有一天我想：如果每天早上醒来，世界都变一次呢？你昨天挖的矿洞没了，昨天搭的桥没了，
地形整个换了一遍。那这个存档就永远玩不腻。&lt;/p&gt;
&lt;p&gt;这个想法我跟朋友说了，他说这不就是给自己找罪受。但我觉得挺有意思。&lt;/p&gt;
&lt;h2&gt;坑一：防活埋&lt;/h2&gt;
&lt;p&gt;第一版写完，我自己进去试，一觉醒来被埋在自己家里了。&lt;/p&gt;
&lt;p&gt;因为我把「所有方块都换掉」当成了规则，包括空气。空气被换成石头之后，
屋子、洞穴、我挖的洞，全被填实了。玩家站在哪都一样，直接埋进去。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;改法&lt;/strong&gt;：刷新只替换「非空气」方块，空气保持空气。这样洞穴、房间都是原样保留的，
玩家站在哪都不会被包围。&lt;/p&gt;
&lt;p&gt;这一条现在是整个模组的底线，动别的都不能动它。&lt;/p&gt;
&lt;h2&gt;坑二：怎么知道这是不是超平坦世界&lt;/h2&gt;
&lt;p&gt;模组只该在超平坦世界里生效。如果玩家在一个正常世界里睡觉，把地形全刷了，
那叫破坏存档。&lt;/p&gt;
&lt;p&gt;我一开始想的是检查世界类型，但网易版的接口里没这么直接的东西。后来用的是笨办法：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;从玩家脚底下取 &lt;strong&gt;5×5 一共 25 个点&lt;/strong&gt;的高度&lt;/li&gt;
&lt;li&gt;看这些高度里哪个值出现得最多（众数）&lt;/li&gt;
&lt;li&gt;如果绝大多数都一样高，就认为是超平坦&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;为什么不用「全部一样高」？因为超平坦世界里也可能有玩家自己搭的房子、种的地，
不可能是完全平的。用众数判定才扛得住。&lt;/p&gt;
&lt;p&gt;还有个细节：这个检测&lt;strong&gt;不能一口气跑完&lt;/strong&gt;。一口气算 25 个点再刷新，
脚本一次性跑太久会被脚本监测盯上，所以我把它拆成好几帧，一帧算一点。&lt;/p&gt;
&lt;p&gt;如果检测出来不是超平坦，模组不会硬刷，而是每 1.5 秒在屏幕上弹一次红色标题加响铃，
提醒玩家这个世界不对。不刷新，但也不装作没事。&lt;/p&gt;
&lt;h2&gt;坑三：整片大陆怎么刷才不卡&lt;/h2&gt;
&lt;p&gt;一开始我想的是一次性全刷完。但 512×512 的地图有多少格你算算——
一次算完，游戏直接卡死。&lt;/p&gt;
&lt;p&gt;后来改成&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;strong&gt;玩家身体占的那两格要跳过&lt;/strong&gt;。不然刷新的时候
正好把方块填在玩家身上，人就卡在方块里了。&lt;/p&gt;
&lt;h2&gt;坑四：有些方块必须保住&lt;/h2&gt;
&lt;p&gt;如果什么都不留，会出两个事：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一是末地进不去了。&lt;/strong&gt; 末地传送门框架要是被换成石头，你就永远去不了末地。
所以框架必须保留。而且光留框架不行 —— 框架&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;/p&gt;
&lt;p&gt;大海、河流、湖泊这些&lt;strong&gt;不保留&lt;/strong&gt;。一开始我留了，结果刷新完地图上还是大片原来的水，
看着像一块「泉水大陆」，很出戏。现在它们也算「非空气」，一起翻新，新的湖由方块池重新生成。&lt;/p&gt;
&lt;h2&gt;还有两个小设计&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;液体要用静止的。&lt;/strong&gt; 方块池里要生成岩浆湖、水湖，如果放的是会流动的，
一刷新就是成百上千格流体同时算流向，帧率直接跪。所以用的是不流动的静止液体和固体岩浆。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;每人发一套东西。&lt;/strong&gt; 每次起床刷新之后，玩家会收到 &lt;strong&gt;12 个末地传送门框架 + 1 张床&lt;/strong&gt;。
因为框架可能被别的东西占了位置，床也可能被刷掉，不发的话玩家迟早玩不下去。
另外每 20 个游戏日会补发一次，防止中间丢了。&lt;/p&gt;
&lt;h2&gt;网易版和国际版是两套代码&lt;/h2&gt;
&lt;p&gt;这个模组做了两个版本：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;网易版&lt;/th&gt;
&lt;th&gt;国际版&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;脚本语言&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Python 2.7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;JavaScript&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;接口&lt;/td&gt;
&lt;td&gt;网易 MODSDK&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@minecraft/server&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;代码量&lt;/td&gt;
&lt;td&gt;4 个模块，675 行&lt;/td&gt;
&lt;td&gt;1 个文件，510 行&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;引擎要求&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;1.21.0 以上&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;网易版里内嵌的是 Python 2.7，&lt;code&gt;print&lt;/code&gt; 后面不加括号的那种。
国际版是 JavaScript，写法完全不一样，所以这两套是&lt;strong&gt;分开写、分开测&lt;/strong&gt;的，
不是一份代码编译两次。&lt;/p&gt;
&lt;p&gt;我先把网易版做完上线了（就是 7573464），国际版是后来重写的。&lt;/p&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;这个模组从想做到上线，中间隔了挺久。最花时间的不是写代码，是&lt;strong&gt;想清楚边界&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;哪些方块能换，哪些不能&lt;/li&gt;
&lt;li&gt;什么情况下该刷，什么情况下该拦住&lt;/li&gt;
&lt;li&gt;刷新的时候玩家在哪、会不会出事&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;现在这几条都写在配置文件里，改一个数字就能调。&lt;/p&gt;
&lt;p&gt;在网易版模组中心搜 &lt;strong&gt;7573464&lt;/strong&gt; 就能直接下，免费的，玩完告诉我哪里还能改。&lt;/p&gt;
</content:encoded></item><item><title>在网易版《我的世界》里写脚本模组</title><link>https://liyuanjie.com/posts/eighth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/eighth-post/</guid><description>网易版 MODSDK 内嵌的是 Python 2.7。我做了三个模组，其中一个是一起床世界就被随机刷新的那种。</description><pubDate>Tue, 15 Sep 2026 20:54:00 GMT</pubDate><content:encoded>&lt;p&gt;网易版《我的世界》可以自己做模组，官方给了个叫 MODSDK 的东西。我在那边的开发者名字
就是 tim-lyj，前后做了三个模组。&lt;/p&gt;
&lt;p&gt;先说个可能让人意外的：&lt;strong&gt;网易版 MODSDK 里内嵌的脚本语言是 Python 2.7&lt;/strong&gt;。
不是 3，是 2.7。第一次看到的时候我愣了一下，现在写的时候还得提醒自己
&lt;code&gt;print&lt;/code&gt; 后面不加括号。&lt;/p&gt;
&lt;h2&gt;三个模组&lt;/h2&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;/ul&gt;
&lt;h2&gt;那个&quot;一起床世界就变了&quot;的模组&lt;/h2&gt;
&lt;p&gt;一句话说清楚玩法：你在一张超平坦世界里睡觉，睡醒的那一刻，脚底下的方块全被随机换掉。&lt;/p&gt;
&lt;p&gt;听着简单，做起来全是坑。下面这几条是我跟它磨了很久才定下来的规矩。&lt;/p&gt;
&lt;h3&gt;一、空气不能被替换&lt;/h3&gt;
&lt;p&gt;这是最重要的一条。刷新的时候&lt;strong&gt;只替换&quot;非空气&quot;方块，空气保持空气&lt;/strong&gt;。
不然你一觉醒来就被埋在自己家里了 —— 洞穴、房间、你挖的洞，全给你填实。
所以刷新的对象是&quot;实心方块&quot;，不是&quot;所有方块&quot;。&lt;/p&gt;
&lt;h3&gt;二、水得特殊处理&lt;/h3&gt;
&lt;p&gt;大海、河流、湖泊算&quot;非空气&quot;，所以也会被一起翻新。这个一开始我觉得挺可惜，
但如果不换，刷新完之后地图上会留着大片原来的水，看着像一块&quot;泉水大陆&quot;，很出戏。
新的湖由方块池重新随机生成，反而更自然。&lt;/p&gt;
&lt;h3&gt;三、得有白名单，不然末地进不去&lt;/h3&gt;
&lt;p&gt;如果所有方块全换，那末地传送门框架也会被换掉 —— 换掉之后你就永远去不了末地了。
所以有一份白名单，橡木类的原木、石材类、家具，还有末地传送门框架，这些保留不动。
白名单我只留了硬性需要的，没敢放太宽。&lt;/p&gt;
&lt;h3&gt;四、用&quot;静止的&quot;水，别用会流的&lt;/h3&gt;
&lt;p&gt;这条是我吃过亏的。方块池里要放湖，如果放的是会流动的水和岩浆，一刷新就是成百上千格
流体开始算流向，帧率直接跪。&lt;/p&gt;
&lt;p&gt;解决办法是用不流动的&quot;静止液体&quot;和固体岩浆去铺湖。湖还是在，但不会乱流。&lt;/p&gt;
&lt;h2&gt;刷新不是一次性的&lt;/h2&gt;
&lt;p&gt;还有个设计选择：不做&quot;挖掉的坐标永久追踪&quot;。本来我打算记录哪些方块被挖了、下次要补回来，
后来发现这会让存档越滚越大，逻辑也乱。&lt;/p&gt;
&lt;p&gt;简化版是这样的：每次睡醒 = 整片&lt;strong&gt;覆盖&lt;/strong&gt;刷新一遍。你挖的洞会在下一轮被新方块盖掉，
不需要额外的循环去回填。&lt;/p&gt;
&lt;p&gt;另外为了让方块多的地方也不卡，刷新是&lt;strong&gt;流式&lt;/strong&gt;的：玩家走到哪，
新加载的区域才被刷新，随走随刷，最后覆盖整张地图，而不是开局把所有区块一次算完。&lt;/p&gt;
&lt;h2&gt;做完网易版，还要重写一遍&lt;/h2&gt;
&lt;p&gt;网易版用的是 Python 2.7，国际版基岩版用的是 &lt;code&gt;@minecraft/server&lt;/code&gt;（JavaScript）。
两套东西不通用，所以计划是两套工程分开写、分开测 —— 先把网易版跑通，
再针对国际版重写一遍。&lt;/p&gt;
&lt;p&gt;我知道听起来像要干两遍活，但脚本接口的差别实在太大，硬凑在一起会更麻烦。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;三个模组目前都还在我电脑里。等整理好之后我打算把它们的贴图和说明也放上来。&lt;/p&gt;
</content:encoded></item><item><title>《权衡之道》：我怎么把&quot;贪腐&quot;做成一个按键</title><link>https://liyuanjie.com/posts/sixth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/sixth-post/</guid><description>游戏里有 20 种设施，每种都有真实采购价。建的时候让你自己填申报价，差价进你自己口袋 —— 但风险值也在涨。</description><pubDate>Sat, 12 Sep 2026 16:49:00 GMT</pubDate><content:encoded>&lt;p&gt;《权衡之道》是我做的一个城市经营游戏，题材是政治讽刺。做它的时候最花心思的不是画地图，
是&quot;钱&quot;。普通经营游戏里钱只有一种来路：收税、卖东西。但我想做的这个游戏，主角是个官，
他的钱应该有两种来路 —— 一种正当，一种不正当。&lt;/p&gt;
&lt;h2&gt;正常赚钱太慢了&lt;/h2&gt;
&lt;p&gt;游戏里正当的收入就三条：税收、上级拨款、存款利息。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;税收 = 住宅税 × 人口 + 商业税 × 商业面积 + 工业税 × 工业面积
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;税率你能自己调，0% 到 30%。调高了收入多，但满意度往下掉；调低了反过来。
上级拨款是每个游戏日按你的职位拨一笔基础经费，主要是保证开局不至于一分钱没有、
直接卡死在那儿。存款利息就更不用说了，聊胜于无。&lt;/p&gt;
&lt;p&gt;这三条路都很慢。想发展快点，就得想别的办法。&lt;/p&gt;
&lt;h2&gt;我把&quot;虚报&quot;做成了核心玩法&lt;/h2&gt;
&lt;p&gt;游戏里有 20 种市政设施，每一种都有一个真实的采购价：风力发电机 10 万、自来水厂 20 万、
学校 30 万、医院 60 万、火车站 100 万。&lt;/p&gt;
&lt;p&gt;关键在于：建的时候不是按采购价付钱，而是让你自己填一个&lt;strong&gt;申报价&lt;/strong&gt;。
上级按你填的数字拨款，但你实际只花掉采购价。差价就进了你自己的口袋。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;采购价      10 万      （真实成本，上面不知道）
申报价      15 万      （你填的，向上要这么多）
上级拨款    15 万
实际支出    10 万
──────────────────────────
你拿到       5 万
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;填得越高来得越快，但风险值涨得也越快、廉洁度掉得越多。我没做成&quot;被发现就直接结束&quot;，
风险值是累积的，到阈值才触发调查那条链：被调查，然后留置审查，然后双开，最后判刑。
中间每一步都还有机会补救。&lt;/p&gt;
&lt;h2&gt;只靠差价不够，我又加了第二道&lt;/h2&gt;
&lt;p&gt;如果只有&quot;申报价&quot;这一条，玩家算出一个最优差价就会一直填那个数，玩几次就腻了。
所以我又加了一个：反常数据校验。&lt;/p&gt;
&lt;p&gt;系统会拿你的城市规模（人口、行政级别）去比经济指标。一个刚起步的小城，
GDP 高得离谱，本身就不正常。指标超出这个规模该有的水平，风险值自动往上加。&lt;/p&gt;
&lt;p&gt;说白了就是：贪得太急也会露。&lt;/p&gt;
&lt;h2&gt;28 个结局，我按&quot;你怎么走&quot;分类&lt;/h2&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;：下台免职、烂尾之城、借贷危机、政绩工程败露、群体事件、空城、重大事故。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;还有几个是藏起来的。比如&quot;急流勇退&quot;：你已经达成最高之城或者小康社会了，
当天廉洁满值、风险是零，这时候选&quot;主动退休&quot;，才会出来。
另一个是&quot;最小社会&quot;：你达到晋升条件了，但连续 10 个游戏日都不选晋升，也算一个结局。&lt;/p&gt;
&lt;h2&gt;最难的一条：每个结局至少要玩两小时&lt;/h2&gt;
&lt;p&gt;这是我自己给自己定的一条硬规矩。因为数值是一天一天涨的，很容易出现&quot;开局三分钟就被判刑&quot;
这种事 —— 那这游戏就没法玩了。&lt;/p&gt;
&lt;p&gt;所以所有结局的触发条件，我都挂在&lt;strong&gt;只能逐日累积的数值&lt;/strong&gt;上：风险值、满意度、
污染、债务利息、人口。这些数字没法一瞬间涨满。正常 1 倍速下，从开局到触发任何一个结局，
至少要两小时，差不多 10 个游戏日。&lt;/p&gt;
&lt;p&gt;还有一个卡时间的地方：升职是&quot;达标之后每个游戏日问你一次要不要升&quot;，
不是自动升。职位越高，能解锁的政策越多、能借的钱越多，但被查的后果也越重。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;这套设计写完，我自己最满意的是申报价那一条。它把&quot;贪&quot;变成了一个每天都要做的小选择 ——
不是一个大按钮叫&quot;贪腐&quot;，而是每次建东西的时候，那个空格里你填几。&lt;/p&gt;
</content:encoded></item><item><title>服务器怎么部署：宝塔面板从装到把 Node 跑起来</title><link>https://liyuanjie.com/posts/thirteenth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/thirteenth-post/</guid><description>不想背一堆 Linux 命令，所以我用了宝塔面板。从装面板、装 Node 环境，到把代码跑起来、放行端口，一步步记下来。</description><pubDate>Sun, 06 Sep 2026 20:15:00 GMT</pubDate><content:encoded>&lt;p&gt;我的外呼软件跑在一台服务器上。第一次部署的时候我对着终端输命令，输了半天没跑起来，
后来用了&lt;strong&gt;宝塔面板&lt;/strong&gt;，全是点鼠标，十几分钟就搞定了。&lt;/p&gt;
&lt;p&gt;这篇记一下我是怎么弄的。写的是我自己走的流程，不是标准答案，
你按自己的情况调。&lt;/p&gt;
&lt;h2&gt;为什么用宝塔&lt;/h2&gt;
&lt;p&gt;因为它把服务器上那些命令变成了按钮。&lt;/p&gt;
&lt;p&gt;要在终端里干的事大概是这些：装 Node、配环境变量、传文件、开防火墙、装进程守护、
看日志。每一样都是一串命令，而且&lt;strong&gt;版本不一样命令还不一样&lt;/strong&gt;，
出错的时候报错信息也看不太懂。&lt;/p&gt;
&lt;p&gt;宝塔把这些都做成了图形界面，点「安装」它自己装，点「启动」它自己起。
对不太熟 Linux 的人来说省事很多。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;有个前提：宝塔要装在&lt;strong&gt;你自己的服务器&lt;/strong&gt;上。它要 root 权限，装在别人的机器上不合适。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;第一步：把宝塔装上&lt;/h2&gt;
&lt;p&gt;登进服务器（我用的是网页版终端，你也可以用 SSH 工具），
用官方给的安装脚本跑一遍就行。装的过程会问你确认，按提示输 &lt;code&gt;y&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;装完它会打印一段东西，&lt;strong&gt;里面有三样要抄下来&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;面板地址（一般是 &lt;code&gt;你的IP:8888&lt;/code&gt; 这种）&lt;/li&gt;
&lt;li&gt;用户名&lt;/li&gt;
&lt;li&gt;密码&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这三样丢了就得重装，所以赶紧存到备忘录里。&lt;/p&gt;
&lt;h2&gt;第二步：放行面板端口&lt;/h2&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;/li&gt;
&lt;/ol&gt;
&lt;p&gt;两层都要放行同一个端口，少一层就打不开面板。&lt;/p&gt;
&lt;p&gt;我第一次就是只改了系统防火墙，浏览器一直转圈，查了半天才发现商家的控制台里
还有个安全组没开。&lt;/p&gt;
&lt;h2&gt;第三步：装 Node 环境&lt;/h2&gt;
&lt;p&gt;进面板之后找「软件商店」，搜 Node，装一个你项目需要的版本。&lt;/p&gt;
&lt;p&gt;我的项目要求 &lt;strong&gt;Node 18 以上&lt;/strong&gt;，所以装的是 18 的 LTS 版。
装完可以在面板的终端里敲 &lt;code&gt;node -v&lt;/code&gt; 确认一下，能出数字就成了。&lt;/p&gt;
&lt;h2&gt;第四步：把代码传上去&lt;/h2&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;Git&lt;/strong&gt;：服务器上拉一次，以后改完本地推上去，服务器上拉一下就行。适合要经常改的&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我用的是第二种，因为代码在 GitHub 上，服务器拉一下就有了。&lt;/p&gt;
&lt;p&gt;代码放哪：&lt;strong&gt;不要放在网站根目录&lt;/strong&gt;，单独建一个文件夹，比如
&lt;code&gt;/www/wwwroot/你的项目名/&lt;/code&gt;。Node 项目不是静态网站，放在网站目录里会被当网页请求。&lt;/p&gt;
&lt;h2&gt;第五步：建 Node 项目&lt;/h2&gt;
&lt;p&gt;面板里找「网站」→「Node 项目」→「添加 Node 项目」：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;填什么&lt;/th&gt;
&lt;th&gt;填成什么&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;项目目录&lt;/td&gt;
&lt;td&gt;你的代码文件夹，比如 &lt;code&gt;/www/wwwroot/xxx/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;启动文件&lt;/td&gt;
&lt;td&gt;一般是 &lt;code&gt;app.js&lt;/code&gt; 或 &lt;code&gt;src/index.js&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;端口&lt;/td&gt;
&lt;td&gt;你的程序里监听的端口，比如 3000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Node 版本&lt;/td&gt;
&lt;td&gt;刚装的那个&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;运行用户&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;点确定，它就开始跑。跑起来之后状态会显示「运行中」。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;关键一点&lt;/strong&gt;：这里的「端口」必须和代码里 &lt;code&gt;listen(3000)&lt;/code&gt; 里的数字一样。
不一样的话项目显示运行中，但外面连不上。&lt;/p&gt;
&lt;h2&gt;第六步：再放行一次端口&lt;/h2&gt;
&lt;p&gt;对，又是防火墙。你程序监听的端口（比如 3000）也要在&lt;strong&gt;安全组和系统防火墙两层&lt;/strong&gt;
都放行，外面才能访问。&lt;/p&gt;
&lt;p&gt;嫌麻烦的话可以在面板里配个反向代理，把域名指到 3000 端口，
这样只开 80 和 443 两个口就够了，用户也不用记端口号。&lt;/p&gt;
&lt;h2&gt;我踩过的三个坑&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;一、改了代码要重启。&lt;/strong&gt; Node 项目不像静态网页，改完文件刷新一下就行。
Node 是把代码读进内存里跑的，改了必须重启项目。宝塔里点一下「重启」按钮就行，
不用去命令行敲 &lt;code&gt;pm2 restart&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二、数据库文件的权限。&lt;/strong&gt; 我用的是 SQLite，数据库就是一个文件。
第一版跑起来之后一直报「无法写入」，查了半天是文件权限不对——
运行用户是 &lt;code&gt;www&lt;/code&gt;，但文件是 root 建的。在面板里改一下所有者就好了。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;三、内存不够。&lt;/strong&gt; 我的服务器是 2 核 2G。Node 本身吃不了多少，
但如果你还装了 MySQL、面板本身也占内存，2G 会紧张。
用 SQLite 就不用另起一个数据库进程，省内存。这也是我选它的原因。&lt;/p&gt;
&lt;h2&gt;最后&lt;/h2&gt;
&lt;p&gt;整套下来大概是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;服务器 → 装宝塔 → 装 Node → 传代码 → 建 Node 项目 → 放行端口 → 能访问了
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;真正麻烦的不是某一步，是&lt;strong&gt;防火墙有两层&lt;/strong&gt;这件事。记住这个，能省不少时间。&lt;/p&gt;
</content:encoded></item><item><title>给外呼软件的后端加一层锁</title><link>https://liyuanjie.com/posts/ninth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/ninth-post/</guid><description>登录接口暴露在公网上，谁都能试密码。加图形验证码、四层限流、防连点之后，顺手把“限流自己把服务器拖卡”这个坑也填了。</description><pubDate>Sun, 23 Aug 2026 17:26:00 GMT</pubDate><content:encoded>&lt;p&gt;易杰云呼是个给做电话外呼的人用的软件，前端是一个单页应用，后端是 Node.js + Express + SQLite。
前段时间我把后端整个加固了一遍，这篇记一下都改了什么、为什么这么改。&lt;/p&gt;
&lt;p&gt;先说背景：它跑在一台 2 核 2G 的服务器上，带宽 8Mbps，跑不起来太复杂的东西。
登录接口暴露在公网上，谁都能试密码。&lt;/p&gt;
&lt;h2&gt;第一件事：加图形验证码&lt;/h2&gt;
&lt;p&gt;光靠限流不够，得让&quot;机器批量试密码&quot;这件事本身变麻烦。所以加了个图形验证码。&lt;/p&gt;
&lt;p&gt;做的时候有几个细节是特意处理的：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;6 位，&lt;strong&gt;去掉 0、O、1、I、L&lt;/strong&gt; 这几个容易看错的字符 —— 不然用户自己都输不进去。&lt;/li&gt;
&lt;li&gt;每个字符随机旋转、随机颜色、随机大小，再叠 5 条干扰线和 30 个噪点。&lt;/li&gt;
&lt;li&gt;5 分钟过期，一次性，用完即废；大小写不敏感。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;直接输出 SVG 而不是图片，省掉了图片编码那一步，服务器压力小一点。&lt;/p&gt;
&lt;h2&gt;第二件事：四层限流&lt;/h2&gt;
&lt;p&gt;原来的限流只有一层，明显不够。现在从外到里是四层：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;第 1 层  全局     所有 IP 加起来 1 分钟超过 1000 次 → 全局关掉登录 5 分钟
第 2 层  单 IP    同一个 IP 1 分钟 5 次
第 3 层  验证码   同一个 IP 1 分钟最多取 20 次验证码
第 4 层  试错     同一个 IP 1 分钟内验证码错 5 次 → 禁 5 分钟
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;登录的顺序也改了，现在是：
&lt;code&gt;全局限流 → 单 IP 限流 → 验证码校验 → 密码校验&lt;/code&gt;。
验证码放在密码前面，等于在最外圈就先把机器挡掉，不让它消耗到密码比对那一步。&lt;/p&gt;
&lt;h2&gt;踩的坑：限流本身把服务器拖卡了&lt;/h2&gt;
&lt;p&gt;这个坑挺典型的。我一开始的限流是写文件的：每次登录请求都去
&lt;code&gt;readFileSync&lt;/code&gt; 读一遍、再 &lt;code&gt;writeFileSync&lt;/code&gt; 写回去。&lt;/p&gt;
&lt;p&gt;结果就是登录明显变卡 —— 因为同步读写会把整个 Node 的事件循环堵住。
更糟的是，它本身还是个 DoS 漏洞：只要有人一直请求，磁盘就一直被读写。&lt;/p&gt;
&lt;p&gt;后来改成了纯内存的 Map 计数，问题就没了。内存表也设了硬上限，超过一万条就删最旧的，防止无限增长。&lt;/p&gt;
&lt;h2&gt;开关防连点&lt;/h2&gt;
&lt;p&gt;这个是给用户用的，不是给攻击者用的。设置页里有几个开关，有人会一直点，
点一次发一次请求，服务器就被刷。&lt;/p&gt;
&lt;p&gt;现在的规则是：同一个用户每点满 10 次，锁 10 秒，返回一句&quot;手速太快，稍后再试 ⚡&quot;。
纯内存实现，按 userId 记，不落盘。&lt;/p&gt;
&lt;h2&gt;一个历史遗留问题：设备列表里 50 个&quot;在线&quot;&lt;/h2&gt;
&lt;p&gt;这个困扰我挺久。用户反复登录之后，设备列表里会堆出一堆重复的在线设备，
因为每次登录都会发一个新 token，旧的没作废。&lt;/p&gt;
&lt;p&gt;改法很直接：&lt;strong&gt;登录成功的时候，先把这台设备的旧 token 全部标成无效，只留最新一条。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;另外管理员那边的设备列表也改了查询方式，用 JOIN 去取每个设备最新的一条记录，
这样历史脏数据也不会把界面撑爆。&lt;/p&gt;
&lt;h2&gt;还剩一个没改完的&lt;/h2&gt;
&lt;p&gt;联系人批量导入那里还有个卡顿没解决：现在是一行一行循环插入，而且插入还没结束就提前
把语句收尾了，号码多的时候既卡又可能丢数据。&lt;/p&gt;
&lt;p&gt;要改成分批插入、等这一批真的写完了再往下走。这个我先记着，下次处理。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;这一轮改完，后端从原来的一层限流变成了四层，验证码、防连点、token 治理都补上了。
整个过程最值得记的一条是：&lt;strong&gt;限流这种东西，自己也可能成为被攻击的点&lt;/strong&gt; ——
我第一版就是因为写文件，反而把服务器拖垮了。&lt;/p&gt;
</content:encoded></item><item><title>更多的音乐唱片：把 13 张唱片换成真歌</title><link>https://liyuanjie.com/posts/twelfth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/twelfth-post/</guid><description>原版的唱片听腻了。我做了个包，把 13 张唱片全换成真歌——周杰伦、蔡健雅、C418，还有几首国外的。</description><pubDate>Sun, 09 Aug 2026 19:40:00 GMT</pubDate><content:encoded>&lt;p&gt;Minecraft 原版一共就十几张唱片。听多了会背下来，挖矿的时候一响就知道是哪首。&lt;/p&gt;
&lt;p&gt;所以我做了个包：&lt;strong&gt;把 13 张唱片全换成别的歌&lt;/strong&gt;。跟「永恒之钢」一样，这个是&lt;strong&gt;国际版&lt;/strong&gt;的
&lt;code&gt;.mcpack&lt;/code&gt;，双击导入，没上线。&lt;/p&gt;
&lt;h2&gt;歌单&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;唱片&lt;/th&gt;
&lt;th&gt;歌&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cat&lt;/td&gt;
&lt;td&gt;Alone - Marshmello&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;Episode 33 - She Her Her Hers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blocks&lt;/td&gt;
&lt;td&gt;Life Goes On - Oliver Tree&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;chirp&lt;/td&gt;
&lt;td&gt;Lucky（降调版）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;far&lt;/td&gt;
&lt;td&gt;Minecraft - C418&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mall&lt;/td&gt;
&lt;td&gt;Sweet Dreams - Breathe Carolina&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mellohi&lt;/td&gt;
&lt;td&gt;Take My Hand - DAISHI DANCE &amp;amp; Cécile Corbel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stal&lt;/td&gt;
&lt;td&gt;Wet Hands - C418&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;strad&lt;/td&gt;
&lt;td&gt;All I Want Is You - Rebzyyx &amp;amp; hoshie star&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ward&lt;/td&gt;
&lt;td&gt;i watch the moon - 东方下小雨&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;unhappy - s0rrow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;wait&lt;/td&gt;
&lt;td&gt;反方向的钟 - 周杰伦&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pigstep&lt;/td&gt;
&lt;td&gt;越来越不懂 - 蔡健雅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;C418 那两首我特意留着了。原版的 &lt;code&gt;far&lt;/code&gt; 是《Minecraft》主题曲、&lt;code&gt;stal&lt;/code&gt; 是《Wet Hands》，
这两首是 Minecraft 的魂，换掉就没味道了。&lt;/p&gt;
&lt;p&gt;剩下的按场景配的：挖矿的时候听节奏感强的，盖房子的时候听安静点的。&lt;/p&gt;
&lt;h2&gt;卡点一：音频格式&lt;/h2&gt;
&lt;p&gt;Minecraft 只认 &lt;strong&gt;&lt;code&gt;.ogg&lt;/code&gt;&lt;/strong&gt;，别的格式读不出来。&lt;/p&gt;
&lt;p&gt;13 首歌转完，一共 &lt;strong&gt;25 MB&lt;/strong&gt;。这个体积在附加包里算很大了——
原版整个游戏也就几百兆，你一个唱片包就吃掉 25 MB。
所以打包出来的 &lt;code&gt;.mcpack&lt;/code&gt; 有 26 MB，导入的时候得耐心等一会儿。&lt;/p&gt;
&lt;h2&gt;卡点二：让游戏知道去哪找声音&lt;/h2&gt;
&lt;p&gt;这是最关键的一步。皮肤、贴图那些放进去就能用，但&lt;strong&gt;声音必须在 &lt;code&gt;sound_definitions.json&lt;/code&gt;
里登记&lt;/strong&gt;，游戏才会去加载。&lt;/p&gt;
&lt;p&gt;登记方式是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;format_version&quot;: &quot;1.14.0&quot;,
  &quot;sound_definitions&quot;: {
    &quot;record.cat&quot;: {
      &quot;category&quot;: &quot;record&quot;,
      &quot;sounds&quot;: [
        { &quot;name&quot;: &quot;sounds/musicplus/disc_01&quot;, &quot;stream&quot;: true, &quot;volume&quot;: 1.0 }
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个点：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;record.cat&lt;/code&gt; 是&lt;strong&gt;原版的声音 ID&lt;/strong&gt;，我用它做键名，等于把原版那首「顶掉」了&lt;/li&gt;
&lt;li&gt;&lt;code&gt;stream: true&lt;/code&gt; 是&lt;strong&gt;流式播放&lt;/strong&gt;——不把整个文件读进内存，边放边读。
不加这个，25 MB 音频全塞内存里，低配设备直接崩&lt;/li&gt;
&lt;li&gt;&lt;code&gt;name&lt;/code&gt; 是路径，&lt;strong&gt;不要写后缀名&lt;/strong&gt;，游戏自己会找 &lt;code&gt;.ogg&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一共 13 条，每条对应一张唱片。写错一个 ID，那张唱片就没声音，
而且不报错，只是安静地什么都不放。&lt;/p&gt;
&lt;h2&gt;卡点三：唱片本身的贴图&lt;/h2&gt;
&lt;p&gt;唱片不只是声音，还有一张圆盘贴图。原版每张唱片的花纹都不一样，
你放自定义歌曲的时候，图案也得跟着换。&lt;/p&gt;
&lt;p&gt;我做了 5 张底色：&lt;strong&gt;绿、橙、蓝、红、深红&lt;/strong&gt;，13 张唱片按歌的风格分到这几张上。&lt;/p&gt;
&lt;p&gt;贴图也是 16×16，中间一个圆心、外面一圈纹路。看着简单，
但要在那么小的尺寸里做出「这是个唱片」的感觉，改了好几版。&lt;/p&gt;
&lt;h2&gt;一个必须说的事&lt;/h2&gt;
&lt;p&gt;这个包里的歌&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;strong&gt;这个包不上线&lt;/strong&gt;。跟「永恒之钢」一样，自己用。&lt;/p&gt;
&lt;p&gt;真要做能发布的版本，得换成无版权音乐，或者自己做的曲子。
这个我以后可能会弄，现在先这样。&lt;/p&gt;
&lt;h2&gt;想试的话&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;.mcpack&lt;/code&gt; 双击导入，进游戏之后找唱片机放进去就行。&lt;/p&gt;
&lt;p&gt;不过有个前提：&lt;strong&gt;你得先有正版的歌&lt;/strong&gt;——这个包只包含音频文件的组织形式，
里面的歌我是从自己买的数字专辑里导出来的。&lt;/p&gt;
&lt;p&gt;要试的话跟我说。&lt;/p&gt;
</content:encoded></item><item><title>永恒之钢：六件套卡在哪儿了</title><link>https://liyuanjie.com/posts/eleventh-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/eleventh-post/</guid><description>锭、剑、镐、斧、铲、锄，加上逐字换色的物品名。这套附加包没上线，做成了可以直接导入的国际版 .mcaddon。</description><pubDate>Sun, 26 Jul 2026 21:10:00 GMT</pubDate><content:encoded>&lt;p&gt;「永恒之钢」是我做的一套装备附加包：&lt;strong&gt;一个锭，加五件工具&lt;/strong&gt;——剑、镐、斧、铲、锄。&lt;/p&gt;
&lt;p&gt;它&lt;strong&gt;没有上线&lt;/strong&gt;，现在的形态是一个 &lt;code&gt;.mcaddon&lt;/code&gt; 文件，&lt;strong&gt;国际版双击就能导入&lt;/strong&gt;，
不用经过任何审核。做的时候是拿网易版的接口开发的，但打包成国际版格式也一样能跑，
所以就先这么放着了。&lt;/p&gt;
&lt;p&gt;这六个东西听着不多，但每一件都要走完整条链，卡的地方比我想的多。&lt;/p&gt;
&lt;h2&gt;卡点一：贴图&lt;/h2&gt;
&lt;p&gt;原版的物品贴图是 16×16 像素，一格一格画出来的。你要新加一件工具，
贴图必须自己画，而且得跟原版那套风格对得上——不然放在背包里一眼就看出是外来的。&lt;/p&gt;
&lt;p&gt;六件就是六张 16×16：锭、剑、镐、斧、铲、锄。&lt;/p&gt;
&lt;p&gt;工具的形状其实有规律：镐是上面一条横的加一根斜杆，斧是一块厚的刃加杆，
锹是铲头加杆。但难的是&lt;strong&gt;颜色&lt;/strong&gt;：钢这种材质既不能太亮（像铁），也不能太暗（像石头），
最后调的是偏冷一点的青灰色，跟原版的铁镐能区分开，但又不突兀。&lt;/p&gt;
&lt;p&gt;这一步花的时间比写代码多。&lt;/p&gt;
&lt;h2&gt;卡点二：让物品名一个字一个颜色&lt;/h2&gt;
&lt;p&gt;这个是最好玩的地方。你看到的物品名是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;永 恒 之 锭
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但每一个字的颜色都不一样：第一二个是紫色，第三个是青色，第四个是白色，
最后那个字按物品类型换——锭是金色，剑是红色，镐是蓝色，斧是绿色，铲是黄色，锄是紫色。&lt;/p&gt;
&lt;p&gt;做法是在语言文件里插颜色代码。Minecraft 的文本支持 &lt;code&gt;§&lt;/code&gt; 加一个字符来改颜色，
&lt;code&gt;§d&lt;/code&gt; 是紫、&lt;code&gt;§b&lt;/code&gt; 是青、&lt;code&gt;§f&lt;/code&gt; 是白、&lt;code&gt;§6&lt;/code&gt; 是金。&lt;/p&gt;
&lt;p&gt;所以那一行字其实长这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;item.eternal_steel:eternal_steel_ingot.name=§l§d永§b恒§f之§6锭
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;§l&lt;/code&gt; 是加粗，后面每个字前面单独挂一个颜色码。六个物品就是六行，每一行都要手写。&lt;/p&gt;
&lt;p&gt;写完还要写英文版。游戏会读 &lt;code&gt;zh_CN.lang&lt;/code&gt; 和 &lt;code&gt;en_US.lang&lt;/code&gt; 两份，哪个语言环境读哪份。&lt;/p&gt;
&lt;h2&gt;卡点三：一个物品要塞进两个包&lt;/h2&gt;
&lt;p&gt;这是最容易搞错的地方。附加包分&lt;strong&gt;资源包&lt;/strong&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;管「能干什么」：合成配方、实际属性&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;两个包里都要有 &lt;code&gt;manifest.json&lt;/code&gt;，而且&lt;strong&gt;每个包、每个模块都要有自己的 UUID&lt;/strong&gt;，
不能重复。我一开始两个包填了同一个 UUID，导入直接失败，报错也不说人话，
排查了好久才发现。&lt;/p&gt;
&lt;p&gt;目录大概是这样：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;EternalSteel/
├── resource_pack/
│   ├── manifest.json
│   ├── texts/zh_CN.lang        物品名（带颜色）
│   ├── texts/en_US.lang        英文名
│   ├── textures/items/*.png    六张贴图
│   └── items/*.json            每件物品长什么样
└── behavior_pack/
    ├── manifest.json
    ├── recipes/*.json          合成配方
    └── items/*.json            每件物品能干什么
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;最后整个文件夹打包成一个 &lt;code&gt;.mcaddon&lt;/code&gt;，国际版双击就导入。&lt;/p&gt;
&lt;h2&gt;卡点四：配方&lt;/h2&gt;
&lt;p&gt;配方是行为包这边的事，写成 JSON。比如剑是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;format_version&quot;: &quot;1.20.10&quot;,
  &quot;minecraft:recipe_shaped&quot;: {
    &quot;description&quot;: { &quot;identifier&quot;: &quot;eternal_steel:eternal_steel_sword&quot; },
    &quot;tags&quot;: [&quot;crafting_table&quot;],
    &quot;pattern&quot;: [&quot;X&quot;, &quot;X&quot;, &quot;#&quot;],
    &quot;key&quot;: {
      &quot;#&quot;: { &quot;item&quot;: &quot;minecraft:stick&quot; },
      &quot;X&quot;: { &quot;item&quot;: &quot;eternal_steel:eternal_steel_ingot&quot; }
    },
    &quot;result&quot;: { &quot;item&quot;: &quot;eternal_steel:eternal_steel_sword&quot;, &quot;count&quot;: 1 }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;pattern&lt;/code&gt; 就是工作台上的形状，一个字母一行。剑是竖着两个锭加一根棍，
所以是 &lt;code&gt;X&lt;/code&gt; &lt;code&gt;X&lt;/code&gt; &lt;code&gt;#&lt;/code&gt; 三行。镐、斧、铲、锄的形状各不相同，五个配方五份 JSON，
一个字母写错就合不出来，而且报错不会告诉你哪里错。&lt;/p&gt;
&lt;h2&gt;现在的状态&lt;/h2&gt;
&lt;p&gt;六件套的贴图、名字、配方都做好了，&lt;code&gt;.mcaddon&lt;/code&gt; 也打包了，国际版可以直接导入。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;但它没上线。&lt;/strong&gt; 原因很简单：我没想好怎么平衡数值——
钢的工具到底该比钻石强还是弱？耐久多少？挖矿等级算几级？&lt;/p&gt;
&lt;p&gt;数值定不下来，放出去就是半成品，所以先自己留着用。&lt;/p&gt;
&lt;p&gt;想试的话跟我说，我把 &lt;code&gt;.mcaddon&lt;/code&gt; 发给你。&lt;/p&gt;
</content:encoded></item><item><title>把项目传到 GitHub，我踩的坑</title><link>https://liyuanjie.com/posts/fifth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/fifth-post/</guid><description>第一次 push 就把 node_modules 传上去了，还差点把 data 文件夹一起传出去 —— 那里面有聊天记录和加密用的配置。记几个真实的坑。</description><pubDate>Sun, 28 Jun 2026 18:50:00 GMT</pubDate><content:encoded>&lt;p&gt;东西在自己电脑上跑通了，下一步就是传上去。
我以为这事十分钟能弄完，结果搞了一个晚上。&lt;/p&gt;
&lt;h2&gt;第一个坑：我在错的目录里 git init&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;git init&lt;/code&gt; 是在哪个文件夹里敲的，仓库的根就在哪。
我在桌面上敲的，结果整个桌面都变成仓库了 ——
连我放在桌面上的一堆作业和照片都算进去了。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;git status&lt;/code&gt; 一跑，屏幕上刷出来几千行，
我当时还不知道可以删掉重新来，就这么硬着头皮往下走，
越走越乱，最后是把 &lt;code&gt;.git&lt;/code&gt; 文件夹删掉重来的。&lt;/p&gt;
&lt;p&gt;正确的做法是先 &lt;code&gt;cd&lt;/code&gt; 进项目文件夹，确认一下
&lt;code&gt;pwd&lt;/code&gt; 打印出来的路径是你要的那个，再 init。&lt;/p&gt;
&lt;h2&gt;第二个坑：node_modules 被我一起传上去了&lt;/h2&gt;
&lt;p&gt;这个最典型。我这个项目服务端只依赖一个库 &lt;code&gt;ws&lt;/code&gt;，
但装完之后 &lt;code&gt;server/node_modules/&lt;/code&gt; 里多出来二十几个文件，
我 push 的时候全部一起上去了。&lt;/p&gt;
&lt;p&gt;为什么这是个问题：&lt;code&gt;node_modules&lt;/code&gt; 是&quot;装出来的&quot;，不是&quot;写出来的&quot;。
别人拿到代码之后自己敲一句 &lt;code&gt;npm install&lt;/code&gt; 就有了，
没必要存在仓库里。而且它文件多、版本一变就是一大堆没意义的改动，
看提交记录的时候完全看不出你真正改了什么。&lt;/p&gt;
&lt;p&gt;补救办法是补一个 &lt;code&gt;.gitignore&lt;/code&gt;，再把它从版本控制里挪出去：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 装出来的东西，不用进仓库
node_modules/

# 我自己的数据：聊天记录、成员账号、加密钥匙
data/
data.备份-*/

# 系统生成的文件
.DS_Store
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;git rm -r --cached server/node_modules
git commit -m &quot;把 node_modules 从仓库里挪出去&quot;
git push
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--cached&lt;/code&gt; 这个参数是关键：它只把文件从&quot;Git 管的东西&quot;里去掉，
硬盘上的文件还在。不用它的话，本地的文件夹会被一起删掉，
我当时没搞明白，删了一次，重新 npm install 才装回来。&lt;/p&gt;
&lt;h2&gt;第三个坑：data 文件夹差点传出去&lt;/h2&gt;
&lt;p&gt;这个比上面那个严重得多。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;data/&lt;/code&gt; 里面是聊天记录、家里人账号、群聊、备注名，
还有加密钥匙本身。虽然记录是加密存的，
但配置里那些东西凑在一起，是不该往公开仓库上放的。&lt;/p&gt;
&lt;p&gt;我是在 push 之前顺手看了一眼要传的文件列表，才发现的。
现在 &lt;code&gt;data/&lt;/code&gt; 和 &lt;code&gt;data.备份-*&lt;/code&gt; 都在
&lt;code&gt;.gitignore&lt;/code&gt; 里，README 里也写明了
&quot;你的数据，自动生成，不会传到 GitHub 上&quot;。&lt;/p&gt;
&lt;p&gt;顺便说一下，密码文件 &lt;code&gt;data/config.local.json&lt;/code&gt; 的权限是 600，
只有我自己读得到，而且密码不在代码里 —— 所以即使代码全公开，
也推不出我家的聊天密码。&lt;/p&gt;
&lt;h2&gt;第四个坑：中文文件名&lt;/h2&gt;
&lt;p&gt;我这个项目根目录下有六个双击就能跑的脚本，名字是中文的：
&lt;code&gt;启动HomeChat.command&lt;/code&gt;、&lt;code&gt;改密码.command&lt;/code&gt; 这种。&lt;/p&gt;
&lt;p&gt;我是故意用中文的，因为家里人要用，写英文他们不认识。
传上去之后 GitHub 网页上显示成乱码百分比编码，
看着挺难受，但 clone 下来在 Mac 上还是正常的中文名。&lt;/p&gt;
&lt;p&gt;解决倒是能解决 —— 让 Git 别对中文做转义：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;git config --global core.quotepath false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样 &lt;code&gt;git status&lt;/code&gt; 里显示的就是中文了，
不用再看 &lt;code&gt;&quot;\345\220\257\345\212\250...&quot;&lt;/code&gt; 这种一串数字。&lt;/p&gt;
&lt;h2&gt;最后记两件事&lt;/h2&gt;
&lt;p&gt;第一，push 之前一定要看一遍要传哪些文件。
&lt;code&gt;git status&lt;/code&gt; 加上 &lt;code&gt;git diff --stat&lt;/code&gt;，
两秒钟的事，但可能帮你少传一个不该传的文件夹。
我第三次侥幸是运气，不能次次靠运气。&lt;/p&gt;
&lt;p&gt;第二，&lt;code&gt;.gitignore&lt;/code&gt; 要写在最前面，别等传上去了再补。
补的时候虽然不算麻烦，但已经推上去的东西，
就算后一个提交删掉了，历史记录里其实还在 ——
别人翻你以前的提交还是能看到。这一点我是后来才知道的。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;仓库地址：
&lt;a href=&quot;https://github.com/Liyuanjie0808/home-chat_LocalAreaNetworkChat&quot;&gt;Liyuanjie0808/home-chat_LocalAreaNetworkChat&lt;/a&gt;，
里面也能看到我那几个中文名的脚本。&lt;/p&gt;
</content:encoded></item><item><title>消息不丢，靠的是一个编号</title><link>https://liyuanjie.com/posts/fourth-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/fourth-post/</guid><description>手机锁屏、切出去、WiFi 断一下，消息就没了。我一开始拿时间戳排序，结果全乱。后来改成服务端发连续编号，问题就解决了。</description><pubDate>Sun, 07 Jun 2026 21:30:00 GMT</pubDate><content:encoded>&lt;p&gt;这个 bug 是家里人帮我发现的。我妈说&quot;你爸发的图我这边没有&quot;，
我拿过手机一看，真的没有，刷新一下又出来了。
那次之后我才认真去处理&quot;消息会丢&quot;这件事。&lt;/p&gt;
&lt;h2&gt;一开始我是拿时间戳排序的&lt;/h2&gt;
&lt;p&gt;第一版每条消息带着发送方手机上的时间，收到的时候按时间排一排，
就显示出来了。写起来特别简单，我也觉得很合理 —— 时间不就是顺序吗。&lt;/p&gt;
&lt;p&gt;结果用起来是这样：我爸那条消息排到了下面，
因为他的手机时间比实际快了十几分钟；我妈锁屏之后再打开，
一下涌进来八条，顺序是乱的。&lt;/p&gt;
&lt;p&gt;问题的根子在：&lt;strong&gt;时间戳是发送方手机给的，手机时钟不准就全乱套&lt;/strong&gt;。
而且两台手机的时间差多少，服务器根本没办法判断谁是对的。&lt;/p&gt;
&lt;h2&gt;改成服务器发号&lt;/h2&gt;
&lt;p&gt;后来改成了最简单的办法：每来一条消息，
服务器给它盖一个自己的、连续的编号。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1  ← 妈：回来了吗
2  ← 爸：买了菜
3  ← 我：知道了
4  ← 妈：图片.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;号码是服务器一个人发的，就一个来源，不存在&quot;谁的表准&quot;这个问题。
时间戳还留着，但只用来显示&quot;几点几分&quot;，不用来排序。
排序永远按编号来。&lt;/p&gt;
&lt;h2&gt;客户端只需要记一件事&lt;/h2&gt;
&lt;p&gt;这是我做完之后觉得最舒服的一点：
手机那边只要记住一个数字 —— &lt;strong&gt;我已经收到第几号了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;下次再连上来的时候，不用服务器推，
客户端直接说&quot;我这儿到 47 号了&quot;，服务器把 48 号往后的一起给它。
断开多久都没关系，锁屏、切出去、WiFi 断一下，回来都是这一套。&lt;/p&gt;
&lt;p&gt;服务器那边还要能处理&quot;客户端比我还新&quot;的情况，
比如手机上留着一份旧的本地存档、编号比服务器上的还大。
这种情况我第一次没处理，表现是页面直接白掉，什么都不显示 ——
因为它在等一个永远不会来的号。&lt;/p&gt;
&lt;h2&gt;图片和文件不能这么补&lt;/h2&gt;
&lt;p&gt;文字消息能补，因为文字一直存在服务器硬盘上（加密的）。
图片和文件不行 —— 它们的定位就是&lt;strong&gt;只在内存里待五分钟，不落硬盘&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;对方当时不在线就收不到了，这一点我认了，因为这是我自己定的规则：
发的图片、语音、文件只在服务端内存里放着，谁取走或者超时就丢，
一个字节都不写盘。单个文件最大 32MB，内存总共 64MB，
满了就按最老的先丢。&lt;/p&gt;
&lt;p&gt;但直接&quot;什么都没有&quot;体验很差。所以现在消息还在，
点开的时候会说一句&quot;文件已不在&quot;。至少知道有过这么个东西，
而不是莫名其妙一片空白。&lt;/p&gt;
&lt;h2&gt;一个我解决不了的问题，也说一下&lt;/h2&gt;
&lt;p&gt;iPhone 把它切到后台之后，收不到消息。这是苹果的规矩：
只有它自己的推送服务能把 App 叫醒，而我家这台 Mac 不在那套体系里。
要接推送就得注册账号、消息得先过人家的服务器 ——
那跟&quot;不上云&quot;是冲突的，所以我没做。&lt;/p&gt;
&lt;p&gt;实际用起来是这样：&lt;strong&gt;打开就实时收，锁屏期间的消息在打开那一瞬间全部补上，一条不丢&lt;/strong&gt;。
安卓那边被杀掉之后也一样，本地通知得进程活着才能弹出来。&lt;/p&gt;
&lt;p&gt;这两条都写在 README 的&quot;已知限制&quot;里，标题下面第一个就是它。
我觉得写清楚比假装没有好。&lt;/p&gt;
</content:encoded></item><item><title>密码就是钥匙：我把加密改了三次</title><link>https://liyuanjie.com/posts/third-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/third-post/</guid><description>从“密码存起来比对”到“密码本身就是钥匙”，HomeChat 的加密改了三次。PBKDF2 三十万轮、HMAC-SHA256 流密码，还有为什么改密码要把所有聊天记录重新加密一遍。</description><pubDate>Sun, 03 May 2026 19:45:00 GMT</pubDate><content:encoded>&lt;p&gt;这部分是我整个项目里改得最多的地方，前后推倒重来三次。
记下来是因为第三次那个想法，是我自己想通的，感觉挺爽。&lt;/p&gt;
&lt;h2&gt;第一次：把密码存起来比对&lt;/h2&gt;
&lt;p&gt;最开始我写的是最土的那种：聊天密码存在服务器的配置文件里，
家里人输一个，跟文件里的比一下，一样就放进去。&lt;/p&gt;
&lt;p&gt;这样能跑，但问题很明显：&lt;strong&gt;打开那个配置文件就能看到密码&lt;/strong&gt;。
而且聊天记录是明文存在硬盘上的 —— 谁把电脑拿走、或者随便翻一下
&lt;code&gt;data/&lt;/code&gt; 那个文件夹，里面说了什么全都看得见。&lt;/p&gt;
&lt;p&gt;我当时的想法是&quot;反正就家里人用，谁会去看&quot;。但后来想想不对，
这个项目的卖点就是&quot;不上云&quot;，结果东西在自己电脑上全是明文，那不上云有什么意义。&lt;/p&gt;
&lt;h2&gt;第二次：想上 HTTPS&lt;/h2&gt;
&lt;p&gt;我想的第二个办法是套 HTTPS，就是浏览器地址栏那个小锁。
这样路上跑的东西就是密的了。&lt;/p&gt;
&lt;p&gt;试了才发现不行。家里没有域名、没有证书，只能自己签一个。
自己签的证书浏览器不认，会跳一个大红页，写着&quot;此连接不安全&quot;，
要点&quot;高级&quot;→&quot;继续前往&quot;才能进去。&lt;/p&gt;
&lt;p&gt;我自己知道怎么回事，点两下就过去了。但我妈不行，
她看到红色的东西第一反应就是关掉。这个方案当天就被我放弃了。&lt;/p&gt;
&lt;p&gt;这段经历现在写在 README 的&quot;技术上的几件事&quot;里，
原话是&quot;自签证书浏览器一片红，还得教家里人点&apos;继续访问&apos;，不划算&quot;。&lt;/p&gt;
&lt;h2&gt;第三次：让密码本身当钥匙&lt;/h2&gt;
&lt;p&gt;最后一次是我想通的：与其&quot;存一个密码、拿输入的去比对&quot;，
不如&lt;strong&gt;根本不存在密码这个东西，密码直接就是解密用的钥匙&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;流程是这样的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;密码
  │  PBKDF2-HMAC-SHA256，跑 30 万轮 + 一段随机盐
  ▼
钥匙（只存在内存里，服务器一停就没了）
  │
  ├── 用钥匙加密每条消息（HMAC-SHA256 做的流密码）
  └── 先加密，再对密文签名，防止路上被改
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;好处是服务器上根本没有&quot;密码&quot;这个东西可以偷。
配置里只存了一段盐和一份用来校验的东西，
拿走去别的机器上跑，也推不出原来的密码 ——
因为要跑 30 万轮，这个轮数就是故意设这么大，让人硬猜的代价变高。&lt;/p&gt;
&lt;h2&gt;改密码为什么要把所有记录重新加密一遍&lt;/h2&gt;
&lt;p&gt;这是这套方案最麻烦的地方，我一开始完全没意识到。&lt;/p&gt;
&lt;p&gt;别的软件改密码，就是改一下数据库里那一行。
但我这个不行 —— 密码就是钥匙。钥匙换了，
以前用旧钥匙锁上的东西，新钥匙打不开。&lt;/p&gt;
&lt;p&gt;所以 &lt;code&gt;改密码.command&lt;/code&gt; 双击之后干的事是：
把所有聊天记录用旧钥匙解开，再用新钥匙重新封一遍，一条都不落。
改之前会自动备份一份到 &lt;code&gt;data.备份-时间戳&lt;/code&gt;，
反悔了把那个文件夹改回名字就行。&lt;/p&gt;
&lt;p&gt;顺便说一个我现在觉得挺得意的小设计：
密码存的地方是 &lt;code&gt;data/config.local.json&lt;/code&gt;，
&lt;strong&gt;不在代码里&lt;/strong&gt;。所以整个项目传到 GitHub 上也不会带出我家的密码。
那个文件权限是 600，只有我自己读得到。&lt;/p&gt;
&lt;h2&gt;我怎么知道加密没写错&lt;/h2&gt;
&lt;p&gt;这是踩了坑之后才补上的。&lt;/p&gt;
&lt;p&gt;写加密最容易犯的错不是&quot;想不出算法&quot;，是&lt;strong&gt;以为自己写对了&lt;/strong&gt;。
我第一次写完自己测了一下，加密再解密，出来一样，就觉得没问题。
后来改别的地方动到了这段代码，改完再测，还是&quot;一样&quot;，
但我其实已经破坏了签名的顺序 —— 它还是能解出来，
只是&quot;防篡改&quot;这件事悄悄失效了。&lt;/p&gt;
&lt;p&gt;所以现在有 &lt;code&gt;selftest.js&lt;/code&gt;：用标准测试向量跑一遍，
输入是固定的，输出必须一位都不差。还有 &lt;code&gt;crosstest.js&lt;/code&gt;，
专门验证手机端和服务端两边的加密能不能对上 ——
两边我是分别写的，很容易一边改了另一边忘了改。&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cd server
node src/selftest.js     加密层的标准测试向量
node src/crosstest.js    手机端和服务端的加密对不对得上
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;老实说清楚它防不住什么&lt;/h2&gt;
&lt;p&gt;这个我觉得比吹自己加密写得好更重要。&lt;/p&gt;
&lt;p&gt;局域网里跑的还是明文 HTTP，内容本身是密的，
但&lt;strong&gt;抓包的人能看出来&quot;谁在跟谁说话、什么时候、大概多大的量&quot;&lt;/strong&gt;。
这个我防不了，除非上 HTTPS，而上 HTTPS 那条路前面已经说了走不通。&lt;/p&gt;
&lt;p&gt;还有浏览器里拦不了截屏 —— 网页没有这个接口，只有原生 App 能拦。
别人拿另一台手机拍你的屏幕，这个任何软件都防不住。
这两条我都写在 README 的&quot;已知限制&quot;里了，没藏着。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;加密那部分代码在 &lt;code&gt;server/src/crypto.js&lt;/code&gt; 和 &lt;code&gt;app/js/crypto&lt;/code&gt;，
两边是一套东西分别实现的。&lt;/p&gt;
</content:encoded></item><item><title>一个 8787 端口，干了四件事</title><link>https://liyuanjie.com/posts/second-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/second-post/</guid><description>HTTP 页面、WebSocket 聊天、文件上传、UDP 设备发现，全挤在 8787 一个端口上。为什么这么干，以及中间搞错的地方。</description><pubDate>Sun, 05 Apr 2026 20:10:00 GMT</pubDate><content:encoded>&lt;p&gt;HomeChat 跑起来之后，家里人要记的东西只有一样：
&lt;code&gt;192.168.1.23:8787&lt;/code&gt;。后面那个 8787 是唯一一个端口，
而这一个端口上面挤了四件事。&lt;/p&gt;
&lt;h2&gt;这四件事&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;HTTP 页面&lt;/strong&gt;。手机浏览器打开的登录页、聊天界面，都是这个端口发的。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;WebSocket 聊天&lt;/strong&gt;。消息是双向的，HTTP 那种一问一答不合适。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;文件上传&lt;/strong&gt;。发图片、发语音、发文件都从这里进来。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UDP 设备发现&lt;/strong&gt;。服务器往局域网里喊&quot;我在这儿&quot;，手机网页收到就自动填地址。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;前三个其实是同一套东西：WebSocket 连接本身就是一个 HTTP 请求升级上去的，
文件上传就是一个普通的 POST。它们本来就是&quot;一个 HTTP 服务&quot;能干的活。
真正另外一套的只有最后那个 UDP。&lt;/p&gt;
&lt;h2&gt;为什么非要挤在一个口上&lt;/h2&gt;
&lt;p&gt;不是为了炫技，是因为我家里的情况：&lt;/p&gt;
&lt;p&gt;我爸那台电脑上开着防火墙。每多开一个端口，就多一次&quot;这个要不要允许&quot;的弹窗，
他每次都点&quot;取消&quot;。而手机浏览器访问一个地址的时候，
如果网页和 WebSocket 不在同一个端口上，就变成&quot;网页能开、但发不出消息&quot;，
这种问题我自己都要查半天，别说他们了。&lt;/p&gt;
&lt;p&gt;所以最后定下来：一个端口，一个地址，防火墙只放一个口。
家里人需要记的东西越少越好。&lt;/p&gt;
&lt;h2&gt;搞错的地方：TCP 和 UDP 可以同号&lt;/h2&gt;
&lt;p&gt;一开始我以为 UDP 得用别的端口号，比如 8788，
因为&quot;8787 已经被占了啊&quot;。结果写的时候端口冲突了，查了一下才明白：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;TCP 的 8787 和 UDP 的 8787 是两个完全不同的东西&lt;/strong&gt;，
操作系统里是两张独立的表，同一个数字不会打架。&lt;/p&gt;
&lt;p&gt;所以我之前那个&quot;只能选一个&quot;的想法纯属自己想多了。现在两边都是 8787，
启动的时候会打印两行，看着挺整齐：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;就绪  192.168.1.23:8787
[udp] 设备发现已监听 8787/udp
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;设备发现是怎么&quot;发现&quot;的&lt;/h2&gt;
&lt;p&gt;局域网里广播一个 UDP 包，是发给整个网段的 ——
&lt;code&gt;255.255.255.255&lt;/code&gt; 这个地址谁都收得到，
不需要知道对方 IP。服务器每隔几秒喊一次，网页那边开着就听得到。&lt;/p&gt;
&lt;p&gt;听着挺美好，实际上我第一次写完根本没反应。原因是广播包发出去之后，
收的那一端得先&quot;加入&quot;这个广播组、还得允许地址重用，不然第二次启动就报
&lt;code&gt;EADDRINUSE&lt;/code&gt;。这两句是后来加上去的。&lt;/p&gt;
&lt;p&gt;另外，很多人家里不止一层路由（比如光猫后面又接了一个路由器），
广播过不去，那就还是得手输地址。所以自动发现我一直留着&quot;手动填&quot;的备选，
没敢把它删掉。&lt;/p&gt;
&lt;h2&gt;一个端口带来的麻烦&lt;/h2&gt;
&lt;p&gt;好处说完了，说坏处，这个是真的：&lt;/p&gt;
&lt;p&gt;一个端口同时管四件事，意味着服务端收到连接之后，
得先判断&quot;这条连接到底想干嘛&quot;。是来要网页的？来升级 WebSocket 的？
来传文件的？这部分的判断代码如果写乱了，
出问题的表现会非常奇怪 —— 比如&quot;网页能打开，但一发图片就断&quot;。&lt;/p&gt;
&lt;p&gt;我的做法是：HTTP 那层只管发文件和维护页面，
所有需要&quot;知道对方是谁&quot;的东西全部走 WebSocket。
文件上传虽然走 HTTP，但必须先带着 WebSocket 那边发过来的票据，
没票据的一律拒掉。&lt;/p&gt;
&lt;p&gt;还有一件事是后来才补的：网络看门狗。
家里 WiFi 偶尔会断一下，断了之后服务其实还在跑，
但已经跟外面失联了。现在有个 &lt;code&gt;net.js&lt;/code&gt; 专门盯着这件事，
发现长时间不通就自己停掉再重来，不然手机那边会一直转圈。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;这一段代码在仓库里的
&lt;a href=&quot;https://github.com/Liyuanjie0808/home-chat_LocalAreaNetworkChat&quot;&gt;server/src/&lt;/a&gt;
下面：&lt;code&gt;http.js&lt;/code&gt;、&lt;code&gt;hub.js&lt;/code&gt;、&lt;code&gt;discovery.js&lt;/code&gt;、&lt;code&gt;net.js&lt;/code&gt;。&lt;/p&gt;
</content:encoded></item><item><title>我做的第一个像样的东西：HomeChat</title><link>https://liyuanjie.com/posts/first-post/</link><guid isPermaLink="true">https://liyuanjie.com/posts/first-post/</guid><description>家里那台 Mac 一直开着当服务器，家里人手机连同一个 WiFi 就能聊。记我为什么要做 HomeChat，和它是怎么从“能发一句话”长成现在这样的。</description><pubDate>Sun, 08 Mar 2026 15:20:00 GMT</pubDate><content:encoded>&lt;p&gt;这是我第一个写完还敢给别人看的东西，所以放在最前面写。&lt;/p&gt;
&lt;h2&gt;一开始只是不想让家里人再装一个 App&lt;/h2&gt;
&lt;p&gt;事情特别小。我妈要在手机上给我传个文件，得先加好友、再发、再下载，
中间还要过一遍别人的服务器。我爸就更烦了，他嫌麻烦，干脆用微信发给自己。&lt;/p&gt;
&lt;p&gt;家里其实有一台 Mac 一直开着（我写代码就用它）。我就想，
那台电脑和手机在同一个 WiFi 里，直接连它不就行了，为什么非要绕一圈。&lt;/p&gt;
&lt;p&gt;所以这个东西的定位从一开始就很窄：&lt;strong&gt;只在同一个 WiFi 下用&lt;/strong&gt;。
不用买服务器，不用域名，不用备案，也不用公网 IP。
代价是出了家门就用不了 —— 我认了这个代价。&lt;/p&gt;
&lt;h2&gt;从&quot;能发一句话&quot;开始&lt;/h2&gt;
&lt;p&gt;第一版特别小，就三个文件：一个 Node 起 HTTP 服务的，
一个管 WebSocket 转发的，一个网页界面。能做的事只有一件：
两台设备同时打开页面，一边打字，另一边能看到。&lt;/p&gt;
&lt;p&gt;跑起来的时候窗口里打印这么两行，我盯着看了好一会儿：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;就绪  192.168.1.23:8787
[udp] 设备发现已监听 8787/udp
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第二行是后来才加的。因为第一版有个特别蠢的问题：
家里人得手动输 &lt;code&gt;192.168.1.23:8787&lt;/code&gt; 这一串东西，
我爸输错了三次，然后就放弃去用微信了。&lt;/p&gt;
&lt;p&gt;后来我加了个 UDP 广播：服务器每隔几秒往局域网里喊一声&quot;我在这儿&quot;，
网页那边收到就自动填进地址栏。现在他们只要点一下&quot;添加到主屏幕&quot;，
以后从桌面上点开就直接进去了。&lt;/p&gt;
&lt;h2&gt;后来越加越多&lt;/h2&gt;
&lt;p&gt;能用之后就不受控制了。现在里面有这些东西：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;群聊。家里人建一个&quot;家里&quot;的群，不用一个个发。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;发图片。先在手机上压到长边 1280 像素再发，原图最大 40MB，压完大概 3MB。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;发文件，单个最大 32MB。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;按住说话的语音。松手不会直接发出去，会先出来一条让你试听、重录，
满意了才发。这个是我妈的反馈，她说她老按错。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;撤回，自己发的消息两分钟之内能撤，撤了之后服务端硬盘上那条也一起抹掉。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;阅后即焚。一直开着、关不掉，对方读完 30 秒，服务端把那条从硬盘上抹掉。
界面上没有任何提示 —— 没有小火苗，没有倒计时，这是故意的。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;还有一个后台监控面板，能看谁在线、今天发了多少条、内存占了多少、阅后即焚排掉多少条。
聊天内容一条都看不到，只看数字。&lt;/p&gt;
&lt;h2&gt;现在长什么样&lt;/h2&gt;
&lt;p&gt;目录大概是这样分的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;app/          手机端网页
  index.html
  css/        界面样式
  js/         crypto 加密 / store 本地数据 / net 通信
              media 图片语音文件 / ui 渲染 / app 主流程
server/       服务端
  src/        index 启动 / hub 消息转发 / store 落盘
              http 接口 / crypto 加密 / filecache 内存中转
              discovery UDP 设备发现 / net 断网看门狗
admin/        监控面板
data/         我自己的数据，不传到 GitHub 上
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;服务端只依赖一个外部库 &lt;code&gt;ws&lt;/code&gt;，两百多 KB，纯 JS，不用原生编译。
剩下全是自己写的。不是我厉害，是因为我想让它在我爸那台旧电脑上也能跑起来。&lt;/p&gt;
&lt;h2&gt;做完之后才明白的几件事&lt;/h2&gt;
&lt;p&gt;第一，难的不是&quot;让两台电脑说上话&quot;，难的是&quot;说不上话的时候怎么办&quot;。
手机锁屏、切出去、WiFi 断一下，这些都是天天发生的，
我一开始完全没管，结果用两天就发现丢消息。&lt;/p&gt;
&lt;p&gt;第二，我一开始觉得&quot;自己写的东西不用测试&quot;。
后来改加密那部分，改完自己都忘了原来是什么行为，只好写了个标准测试向量跑一遍。
现在服务端有 7 个自检脚本加 1 个安全自查，改完代码先跑一遍再说。&lt;/p&gt;
&lt;p&gt;第三，家里人用起来是不看说明书的。我写了一大段&quot;点这里再点那里&quot;，
我妈一句没看，直接就在群里发了个表情。所以后来我把能自动的全自动了，
地址自动填、第一次连上在电脑上点一下&quot;同意&quot;就行，之后就再也不问了。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;代码在
&lt;a href=&quot;https://github.com/Liyuanjie0808/home-chat_LocalAreaNetworkChat&quot;&gt;GitHub&lt;/a&gt;
上，MIT，随便看随便改。现在只有 macOS 版，Windows 的还在做。&lt;/p&gt;
</content:encoded></item></channel></rss>