<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://waldo0926.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://waldo0926.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-24T19:02:41+08:00</updated><id>https://waldo0926.github.io/feed.xml</id><title type="html">Shuoxun Wen</title><subtitle>How I build and run my own projects.</subtitle><author><name>Shuoxun Wen</name></author><entry xml:lang="zh-Hans"><title type="html">100 个用户，3% 的 CPU，大部分上传失败</title><link href="https://waldo0926.github.io/2026/10/24/secureview-pool-zh/" rel="alternate" type="text/html" title="100 个用户，3% 的 CPU，大部分上传失败" /><published>2026-10-24T18:20:00+08:00</published><updated>2026-10-24T18:20:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/secureview-pool-zh</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/secureview-pool-zh/"><![CDATA[<p>我们的毕业设计 <a href="https://github.com/Waldo0926/SecureView">SecureView</a> 有一个压测工具，在 <code class="language-plaintext highlighter-rouge">loadtest/</code> 里。它同时登录很多用户，上传、下载、分享文件，并用 SHA-256 把每个下载回来的文件和原文件对比。50 个并发用户以内，服务器没有报错。到 100 个，65% 到 78% 的上传返回 HTTP 500。</p>

<p>服务器 CPU 平均 3%，内存也没变化。所以服务器并不忙，它是卡在等什么东西。</p>

<h2 id="报错">报错</h2>

<p>每次失败，日志里都是同一句：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00
</code></pre></div></div>

<p>API 只跑了一个 uvicorn 进程，用的是 SQLAlchemy 默认连接池：5 个连接加 10 个溢出，同时最多 15 个数据库事务。请求等连接超过 30 秒就返回 500。但如果每个请求只占连接几毫秒，15 个连接给 100 个用户用绰绰有余。所以一定有东西占着连接很久。</p>

<h2 id="第一个猜测是错的">第一个猜测是错的</h2>

<p>测试报告猜的是：文件传输期间一直占着数据库连接。听起来很合理，但我先验证再改。我一边盯着 MySQL，一边故意慢速下载了三次 30 MB 的文件。传输过程中<strong>没有任何未提交的事务</strong>。下载在开始传输前就提交了，连接已经还回去了。</p>

<p>所以下载没问题。我在笔记本上复现了 100 人的失败（80% 上传失败，吞吐量掉到每秒 0.33 次操作），这样就不用占着共享的服务器排查了。日志显示，每次超时都发生在身份验证里。</p>

<h2 id="原因两个池子互相等">原因：两个池子互相等</h2>

<p>上传接口是 <code class="language-plaintext highlighter-rouge">async</code> 的。一次上传是这样的：</p>

<ol>
  <li>身份验证取出一个数据库连接，用完没有还。</li>
  <li>上传在事件循环上执行数据库调用。</li>
  <li>把上传的文件写到磁盘时，每写一块都要等一次线程池，而连接一直拿在手里。</li>
</ol>

<p>线程池有 40 个线程。100 个用户时，15 个上传占着全部 15 个连接，在等空闲线程；40 个线程全被别的请求占着，在等连接。两边互相等，谁也动不了，直到连接池 30 秒超时，请求失败。</p>

<p>这就解释了 3% 的 CPU。没有任何东西在计算，所有东西都在等。</p>

<h2 id="第一次修复反而更糟">第一次修复反而更糟</h2>

<p>最直接的改法是身份验证之后、写文件之前先把连接还掉。我只改了这一处，结果 <strong>50 个用户</strong>也变慢了：下载 p95 到了 30 秒。</p>

<p>上传剩下的数据库调用还在事件循环上跑。现在它们是<em>在事件循环上</em>等连接，这会卡住整个进程里的所有请求，不只是上传。这个改法没有合并。</p>

<h2 id="真正有效的修复">真正有效的修复</h2>

<p>把上传拆成三段，保证事件循环上没有数据库调用，等线程的时候也不拿着连接：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">upload</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">actor</span><span class="p">,</span> <span class="n">metadata</span><span class="p">,</span> <span class="n">ciphertext</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
    <span class="c1"># 1. 在线程池里检查：密钥、额度。检查完连接就释放。
</span>    <span class="n">owner_key</span><span class="p">,</span> <span class="n">recovery_key</span> <span class="o">=</span> <span class="k">await</span> <span class="n">run_in_threadpool</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">_check_upload</span><span class="p">,</span> <span class="n">actor</span><span class="p">,</span> <span class="n">metadata</span><span class="p">)</span>

    <span class="c1"># 2. 把加密文件写到暂存区。不占连接。
</span>    <span class="n">staged</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="p">.</span><span class="n">storage</span><span class="p">.</span><span class="n">stage_upload</span><span class="p">(</span><span class="n">ciphertext</span><span class="p">,</span> <span class="n">max_size_bytes</span><span class="o">=</span><span class="p">...)</span>

    <span class="c1"># 3. 回到线程池：写记录、审计事件、发布、提交。
</span>    <span class="k">return</span> <span class="k">await</span> <span class="n">run_in_threadpool</span><span class="p">(</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">_record_upload</span><span class="p">,</span> <span class="n">actor</span><span class="p">,</span> <span class="n">metadata</span><span class="p">,</span> <span class="n">context</span><span class="p">,</span> <span class="n">staged</span><span class="p">,</span> <span class="n">owner_key</span><span class="p">,</span> <span class="n">recovery_key</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>身份验证现在也会结束自己的只读事务，没有请求会把连接从身份验证一路带进接口。（上面省略了错误处理。第 2 步失败时仍然会回滚并删掉暂存文件。）</p>

<h2 id="结果">结果</h2>

<p>同一台笔记本、同一个工具、同一批账号，每档 30 秒，文件 1 MiB：</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">并发用户</th>
      <th style="text-align: right">修复前失败率</th>
      <th style="text-align: right">修复前 ops/s</th>
      <th style="text-align: right">修复后失败率</th>
      <th style="text-align: right">修复后 ops/s</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">20</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">53.6</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">58.4</td>
    </tr>
    <tr>
      <td style="text-align: right">50</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">54.5</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">56.1</td>
    </tr>
    <tr>
      <td style="text-align: right"><strong>100</strong></td>
      <td style="text-align: right"><strong>80%</strong></td>
      <td style="text-align: right"><strong>0.33</strong></td>
      <td style="text-align: right"><strong>0%</strong></td>
      <td style="text-align: right"><strong>56.1</strong></td>
    </tr>
  </tbody>
</table>

<p>修复后每一档吞吐量都稳定在每秒 56 次左右，延迟随排队增加，这才是机器本身的上限。笔记本上的数字是本地复现。真实服务器上，前一晚 100 个用户时 103 次上传只成功 23 次（78% 失败）；当天晚上重跑，102 次上传全部成功，每个下载文件的哈希都和原文件一致。</p>

<h2 id="守住修复的测试">守住修复的测试</h2>

<ul>
  <li>一个单元测试检查暂存开始前连接已经释放。</li>
  <li>故障注入测试在写入文件记录的那次提交时杀掉数据库连接，并检查清理任务会删掉孤立的文件。</li>
  <li>改完后整套后端测试在 MySQL 上通过：633 个测试，覆盖率 85%。</li>
</ul>

<h2 id="小结">小结</h2>

<ul>
  <li>CPU 很低但请求失败，说明有东西在等。去找连接池、锁和超时，不要去找慢代码。</li>
  <li>先验证猜测再动手。这里第一个猜测听起来对，其实是错的，一次测量就看出来了。</li>
  <li>在 async Python 里，事件循环上的阻塞数据库调用会卡住所有请求。要么改成异步调用，要么放进线程池，而且不要拿着连接跨过 <code class="language-plaintext highlighter-rouge">await</code>。</li>
  <li>修复前后的数字用同一个工具测。这样修复是测出来的，而不是说出来的。</li>
</ul>

<p>完整记录和原始数据在 <a href="https://github.com/Waldo0926/SecureView/tree/main/docs/performance/pool-fix-2026-10-04"><code class="language-plaintext highlighter-rouge">docs/performance/pool-fix-2026-10-04/</code></a>，代码在 <a href="https://github.com/Waldo0926/SecureView/blob/main/backend/app/services/file_service.py"><code class="language-plaintext highlighter-rouge">file_service.py</code></a>。</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[100 人同时上传时最多 78% 失败，CPU 却只有 3%。怎么找到原因，第一次修复为什么更糟，最后怎么把失败率降到 0。]]></summary></entry><entry xml:lang="en"><title type="html">100 users, 3% CPU, and most uploads failing</title><link href="https://waldo0926.github.io/2026/10/24/secureview-pool/" rel="alternate" type="text/html" title="100 users, 3% CPU, and most uploads failing" /><published>2026-10-24T18:20:00+08:00</published><updated>2026-10-24T18:20:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/secureview-pool</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/secureview-pool/"><![CDATA[<p><a href="https://github.com/Waldo0926/SecureView">SecureView</a>, our final year project, has a load test tool in <code class="language-plaintext highlighter-rouge">loadtest/</code>. It signs in as many users at once, uploads, downloads and shares files, and checks the SHA-256 of every downloaded file against the original. Up to 50 concurrent users the server returned no errors. At 100, 65% to 78% of uploads failed with HTTP 500.</p>

<p>The server’s CPU averaged 3% and memory was flat. So the server was not busy. It was stuck waiting on something.</p>

<h2 id="the-error">The error</h2>

<p>Every failure had the same message in the log:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>QueuePool limit of size 5 overflow 10 reached, connection timed out, timeout 30.00
</code></pre></div></div>

<p>The API ran as one uvicorn process with SQLAlchemy’s default pool: 5 connections plus 10 overflow, so at most 15 database transactions at once. A request that waits 30 seconds for a connection gets a 500. But 15 connections should be plenty for 100 users if each request holds one for a few milliseconds. Something was holding them much longer.</p>

<h2 id="the-first-guess-was-wrong">The first guess was wrong</h2>

<p>The test report guessed that a file transfer kept its database connection open for the whole transfer. That sounded likely, so I checked it rather than fixing it straight away. I ran three deliberately slow downloads of a 30 MB file while watching MySQL. There were <strong>no open transactions</strong> during the transfers. The download commits before it starts streaming, which gives the connection back.</p>

<p>So downloads were fine. I reproduced the failure on my laptop at 100 users (80% of uploads failed, throughput fell to 0.33 operations a second), which meant I could study it without the shared server. The log showed every timeout happening inside authentication.</p>

<h2 id="the-cause-a-deadlock-between-two-pools">The cause: a deadlock between two pools</h2>

<p>The upload endpoint is <code class="language-plaintext highlighter-rouge">async</code>. Here is what one upload did:</p>

<ol>
  <li>Authentication checked out a database connection and did not give it back.</li>
  <li>The upload ran its database calls on the event loop.</li>
  <li>To write the uploaded file to disk, it awaited the thread pool once per chunk, still holding the connection.</li>
</ol>

<p>The thread pool has 40 threads. With 100 users, 15 uploads held all 15 connections while waiting for a free thread, and all 40 threads were other requests waiting for a connection. Each side was waiting for the other. Nothing moved until the 30 second pool timeout, and then the requests failed.</p>

<p>This explains the 3% CPU. Nothing was computing. Everything was waiting.</p>

<h2 id="the-fix-that-made-it-worse">The fix that made it worse</h2>

<p>The obvious fix was to release the connection after authentication, before writing the file. I tried only that, and <strong>50 users</strong> became slow too: download p95 went to 30 seconds.</p>

<p>The upload’s remaining database calls still ran on the event loop. Now they waited for a connection <em>on the event loop</em>, which blocks every request in the process, not just the upload. I did not ship it.</p>

<h2 id="the-fix-that-worked">The fix that worked</h2>

<p>The upload is split into three parts so that no database call runs on the event loop and no connection is held while waiting for a thread:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">upload</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">actor</span><span class="p">,</span> <span class="n">metadata</span><span class="p">,</span> <span class="n">ciphertext</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
    <span class="c1"># 1. checks in the thread pool: keys, limits. The connection is released after.
</span>    <span class="n">owner_key</span><span class="p">,</span> <span class="n">recovery_key</span> <span class="o">=</span> <span class="k">await</span> <span class="n">run_in_threadpool</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">_check_upload</span><span class="p">,</span> <span class="n">actor</span><span class="p">,</span> <span class="n">metadata</span><span class="p">)</span>

    <span class="c1"># 2. write the encrypted file to a staging object. No connection held.
</span>    <span class="n">staged</span> <span class="o">=</span> <span class="k">await</span> <span class="bp">self</span><span class="p">.</span><span class="n">storage</span><span class="p">.</span><span class="n">stage_upload</span><span class="p">(</span><span class="n">ciphertext</span><span class="p">,</span> <span class="n">max_size_bytes</span><span class="o">=</span><span class="p">...)</span>

    <span class="c1"># 3. back in the thread pool: rows, audit event, publish, commit.
</span>    <span class="k">return</span> <span class="k">await</span> <span class="n">run_in_threadpool</span><span class="p">(</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">_record_upload</span><span class="p">,</span> <span class="n">actor</span><span class="p">,</span> <span class="n">metadata</span><span class="p">,</span> <span class="n">context</span><span class="p">,</span> <span class="n">staged</span><span class="p">,</span> <span class="n">owner_key</span><span class="p">,</span> <span class="n">recovery_key</span>
    <span class="p">)</span>
</code></pre></div></div>

<p>Authentication also ends its read-only transaction now, so no request carries a connection from authentication into the endpoint. (Error handling is left out above. A failure in step 2 still rolls back and removes the staged object.)</p>

<h2 id="results">Results</h2>

<p>Same laptop, same tool, same accounts, 30 seconds per step with 1 MiB files:</p>

<table>
  <thead>
    <tr>
      <th style="text-align: right">Concurrent users</th>
      <th style="text-align: right">Failed before</th>
      <th style="text-align: right">ops/s before</th>
      <th style="text-align: right">Failed after</th>
      <th style="text-align: right">ops/s after</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td style="text-align: right">20</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">53.6</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">58.4</td>
    </tr>
    <tr>
      <td style="text-align: right">50</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">54.5</td>
      <td style="text-align: right">0%</td>
      <td style="text-align: right">56.1</td>
    </tr>
    <tr>
      <td style="text-align: right"><strong>100</strong></td>
      <td style="text-align: right"><strong>80%</strong></td>
      <td style="text-align: right"><strong>0.33</strong></td>
      <td style="text-align: right"><strong>0%</strong></td>
      <td style="text-align: right"><strong>56.1</strong></td>
    </tr>
  </tbody>
</table>

<p>After the fix, throughput stays at about 56 operations a second at every step and latency grows with the queue, which is the machine’s real limit. The laptop numbers are a local reproduction. On the real server, the run the night before had 23 of 103 uploads succeed at 100 users (78% failed). The re-run that night had 102 of 102 succeed, and every download matched its original hash.</p>

<h2 id="tests-that-keep-it-fixed">Tests that keep it fixed</h2>

<ul>
  <li>A unit test checks that the connection is released before staging starts.</li>
  <li>The fault injection test kills the database connection at the exact commit that writes the file row, and checks that the cleanup job removes the orphaned object.</li>
  <li>The full backend suite passed on MySQL after the change: 633 tests, 85% coverage.</li>
</ul>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>Low CPU with failing requests means something is waiting. Look for pools, locks and timeouts, not for slow code.</li>
  <li>Check a guess before fixing it. The first theory here sounded right and was wrong, and one measurement showed it.</li>
  <li>In async Python, a blocking database call on the event loop stalls everything. Either make the call async or run it in the thread pool, and do not hold a connection across an <code class="language-plaintext highlighter-rouge">await</code>.</li>
  <li>Keep the before and after numbers from the same tool. Then the fix is a measurement, not a claim.</li>
</ul>

<p>The full write-up with raw data is in <a href="https://github.com/Waldo0926/SecureView/tree/main/docs/performance/pool-fix-2026-10-04"><code class="language-plaintext highlighter-rouge">docs/performance/pool-fix-2026-10-04/</code></a>, and the code is <a href="https://github.com/Waldo0926/SecureView/blob/main/backend/app/services/file_service.py"><code class="language-plaintext highlighter-rouge">file_service.py</code></a>.</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[At 100 concurrent users, up to 78% of uploads failed while the CPU sat at 3%. How the cause was found, a first fix that made things worse, and the fix that brought failures to zero.]]></summary></entry><entry xml:lang="zh-Hans"><title type="html">服务器读不了的文件分享</title><link href="https://waldo0926.github.io/2026/10/24/secureview-keys-zh/" rel="alternate" type="text/html" title="服务器读不了的文件分享" /><published>2026-10-24T18:00:00+08:00</published><updated>2026-10-24T18:00:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/secureview-keys-zh</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/secureview-keys-zh/"><![CDATA[<p><a href="https://github.com/Waldo0926/SecureView">SecureView</a> 是我们团队在 Monash 做的毕业设计，一个文件分享平台，已经上线，合并的 203 个 PR 里有 182 个是我写的。它背后的规则说起来很简单：文件在浏览器里加密，服务器收不到任何能解密它的东西。文件本身、文件密钥、用户私钥、passphrase，都收不到。</p>

<p>规则好说，加上分享、忘记 passphrase、会话被盗这几种情况之后就难守了。先说这条规则带来的好处。</p>

<h2 id="这样做有什么用">这样做有什么用</h2>

<p><strong>1. 服务器上只有加密后的文件。</strong> 服务器上没有原文件，也没有能打开文件的密钥。运营 SecureView 的人看不到用户的文件，就算服务器被攻破、数据库被整个拷走，拿到的也只是密文和封装好的密钥。没有每个用户自己的 passphrase，这些都没用。</p>

<p><strong>2. 账号被盗不等于文件被盗。</strong> 假设别人拿到了你的登录密码并登录进来，对方能看到你的文件列表，但读不到任何内容。预览不了，因为服务器上根本没有原文件可以给对方看。下载原文件和分享也做不到，因为这两件事都要用你的私钥，而私钥只有你的 passphrase 能解开。每次下载、每次分享，浏览器都会重新要 passphrase。对方最多只能下载到加密后的文件。</p>

<p>下面讲这个设计怎么守住这两点，以及它管不到的地方。</p>

<h2 id="两个密码不是一个">两个密码，不是一个</h2>

<p>每个账号有两个互相独立的秘密：</p>

<ul>
  <li><strong>登录密码</strong>用来向服务器证明你是谁。服务器存它的 Argon2id 哈希，和普通登录一样。</li>
  <li><strong>加密 passphrase</strong> 从不离开浏览器。它解锁账号的私钥，私钥再解锁你的文件。</li>
</ul>

<p>两者分开，上面第 2 点才成立。这也是忘了登录密码可以放心用邮件重置的原因：别人就算拿下了你的邮箱，也只能拿到会话，拿不到文件。</p>

<h2 id="密钥链">密钥链</h2>

<p>注册时所有密钥都在浏览器里生成，服务器只收到封装好的副本：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>passphrase  --Argon2id--&gt;       KEK（密钥加密密钥）
KEK         --AES-256-GCM--&gt;    打开 AMK（账户主密钥，随机 256 位）
AMK         --HKDF-SHA256--&gt;    打开 RSA 私钥（默认 3072 位）
私钥        --RSA-OAEP--&gt;       打开每个文件的 DEK（数据加密密钥）
DEK         --AES-256-GCM--&gt;    解密文件
</code></pre></div></div>

<p>服务器存的是包装后的 AMK、加密后的私钥、公钥，以及 Argon2id 的盐和参数。这些全拿到，没有 passphrase 也打不开任何东西。注册前浏览器还会自检：把刚封装好的东西重新解开，确认私钥和公钥是一对。这一步服务器做不了，因为它解不开。</p>

<p>每次上传都生成一个新的随机 DEK。浏览器用它加密文件（默认 AES-256-GCM，用户也可以选另外五种 AEAD 算法），然后用 RSA-OAEP-SHA256 把 DEK 包装两次：一次给文件所有者的公钥，一次给一把私钥离线保管的恢复密钥。服务器收到的是密文和两份包装后的 DEK。下载时服务器把密文原样传回，由浏览器解密。</p>

<h2 id="分享就是重新包装">分享就是重新包装</h2>

<p>要把文件分享给 Bob，服务器在密码学上帮不上忙，因为它没有 DEK。所以由发送方的浏览器来做：</p>

<ol>
  <li>取 Bob 的公钥，用公钥本身算出指纹。</li>
  <li>显示 Bob 的名字和指纹，如果和上次分享给 Bob 时不一样就警告。</li>
  <li>解锁发送方的私钥，解开这个文件的 DEK。</li>
  <li>用 Bob 的公钥重新包装同一个 DEK，只把这个发给服务器。</li>
</ol>

<p>服务器负责检查权限：发送方要对这个文件有 SHARE 权限，Bob 拿到的权限不能超过发送方自己有的。然后在一个事务里写入 Bob 的授权、包装后的 DEK 和一条审计记录。文件本身不用重新加密。</p>

<p>第 2 步存在是因为公钥是服务器发的。如果服务器把 Bob 的公钥换成自己的，分享给“Bob”的东西它就都能读。指纹警告只能在第一次分享之后起作用，而且要大家真的去和 Bob 在设置页看到的指纹对一下。这是真实存在的局限，文档里也写明了。</p>

<h2 id="忘了-passphrase">忘了 passphrase</h2>

<p>“服务器不拿密钥”到这里开始有代价。passphrase 忘了，AMK 就没了，服务器上没有任何东西能把它找回来。</p>

<p>出路是注册时浏览器只显示一次的私钥文件。这个文件本来就能打开账号能访问的所有文件，所以让持有它的人设置新的 passphrase，并没有给他任何新能力。重置的过程是：</p>

<ul>
  <li>在浏览器里读取密钥文件，确认它和账号的公钥匹配</li>
  <li>生成一个<strong>新的</strong> AMK，用新 passphrase 包装它，再用它重新加密同一把私钥</li>
  <li>发送之前，先在本地把两个新信封重新打开一遍</li>
</ul>

<p>密钥对不变，所以所有文件、分享和包装过的 DEK 都继续有效，什么都不用重新加密。passphrase 和密钥文件都丢了，文件就永远打不开了。注册页上用大白话写明了这一点。</p>

<h2 id="被盗的会话不能换掉密钥">被盗的会话不能换掉密钥</h2>

<p>服务器没法检查新信封能不能打开，因为它从来见不到 passphrase 和 AMK。如果没有别的检查，拿到被盗会话的人可以上传一堆垃圾信封，让主人再也打不开任何文件。</p>

<p>所以替换信封必须证明持有私钥。服务器返回一个摘要，绑定账号、密钥、两个预期 revision，以及两个新信封的每一个字段。浏览器用私钥签名（RSA-PSS-SHA256），服务器先验签再写入：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /keys/me/reset-protection/signing-payload  -&gt;  32 字节摘要
浏览器用账户私钥签名
POST /keys/me/reset-protection（信封 + 签名）
  别的密钥签的名   -&gt;  422，什么都不改
  revision 过期    -&gt;  409
</code></pre></div></div>

<p>每种需要签名的操作（重置、重新包装、添加密钥）在摘要里都有自己的域标签，一种操作的签名不能拿去重放成另一种。重置上线之后，记得旧 passphrase 时修改 passphrase 也走同一条路径。</p>

<h2 id="服务器还能看到什么">服务器还能看到什么</h2>

<p>密钥托管不等于什么都藏住了。用盗来的密码登录的人虽然读不了任何文件，但删除文件、取消分享这些普通的账号操作还是能做。服务器仍然能看到：</p>

<ul>
  <li>文件名、MIME 类型和准确的文件大小</li>
  <li>谁在什么时候把什么分享给了谁</li>
  <li>Argon2id 信封。数据库被偷的话，弱 passphrase 可以被离线猜出来</li>
</ul>

<p>针对最后一点，浏览器会拒绝常见的、有规律的、或者基本就是用户名的 passphrase，并要求至少 10 个字符。改过的客户端可以跳过这些检查，它们保护的是用户不选弱密码，不是保护服务器不受攻击。</p>

<p>一个主动作恶的服务器还可以返回旧的密文或者换掉的密钥包。这个设计做到的是 API 层面的零知识密钥托管，不能防住一个恶意的运营方。</p>

<h2 id="小结">小结</h2>

<ul>
  <li>把服务器绝不能收到的东西写下来，每个接口都拿这张清单对一遍。</li>
  <li>服务器验证不了的东西，就让客户端来证明，比如签名后才能替换信封。</li>
  <li>恢复方案要和加密一起设计，不能事后补。这里是用户自己保管的一个文件，文档写清楚了丢了会怎样。</li>
  <li>把局限写出来。一个安全声明说清楚了自己不管什么，反而更可信。</li>
</ul>

<p>更多细节见 <a href="https://github.com/Waldo0926/SecureView/blob/main/docs/SECURITY_FLOWS.zh-CN.md"><code class="language-plaintext highlighter-rouge">docs/SECURITY_FLOWS.md</code></a>，浏览器端代码在 <a href="https://github.com/Waldo0926/SecureView/tree/main/frontend/src/crypto"><code class="language-plaintext highlighter-rouge">frontend/src/crypto/</code></a>（<code class="language-plaintext highlighter-rouge">share.ts</code>、<code class="language-plaintext highlighter-rouge">passphrase-reset.ts</code>）。</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[SecureView 里每个文件都在浏览器里加密，服务器手里没有任何能打开文件的密钥。在这个前提下怎么分享文件，忘了 passphrase 又怎么办。]]></summary></entry><entry xml:lang="en"><title type="html">Sharing files the server cannot read</title><link href="https://waldo0926.github.io/2026/10/24/secureview-keys/" rel="alternate" type="text/html" title="Sharing files the server cannot read" /><published>2026-10-24T18:00:00+08:00</published><updated>2026-10-24T18:00:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/secureview-keys</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/secureview-keys/"><![CDATA[<p><a href="https://github.com/Waldo0926/SecureView">SecureView</a> is the file sharing platform our team built for our final year project at Monash. It is live, and I wrote 182 of the 203 merged pull requests. The rule behind it is simple to say: files are encrypted in the browser, and the server never receives anything that can decrypt them. Not the file, not the file key, not the user’s private key, not the passphrase.</p>

<p>The rule is easy to say and hard to keep once you add sharing, forgotten passphrases and stolen sessions. First, what the rule buys you.</p>

<h2 id="what-this-buys-you">What this buys you</h2>

<p><strong>1. The server only stores encrypted files.</strong> There is no original file on the server, and no key that opens one. The people running SecureView cannot read a user’s files, and neither can someone who breaks into the server or copies the database. What they get is ciphertext and sealed keys, and without each user’s passphrase that is useless.</p>

<p><strong>2. A stolen account is not a stolen file.</strong> Say someone gets your login password and signs in. They see your file list, but they cannot read any of it. They cannot preview a file, because the server has no original to show. They cannot download the real file or share it with anyone, because both need your private key, and only your passphrase unlocks it. The browser asks for the passphrase again on every download and every share. The most they can download is the encrypted file.</p>

<p>The rest of this post is how the design keeps those two promises, and where they stop.</p>

<h2 id="two-secrets-not-one">Two secrets, not one</h2>

<p>Every account has two separate secrets:</p>

<ul>
  <li><strong>The login password</strong> proves who you are to the server. The server stores an Argon2id hash of it, like any login.</li>
  <li><strong>The encryption passphrase</strong> never leaves the browser. It unlocks the account’s private key, and the private key unlocks your files.</li>
</ul>

<p>Keeping them apart is what makes point 2 work. It is also why a forgotten login password can be reset by email: taking over someone’s mailbox gets you their session, but not their files.</p>

<h2 id="the-key-chain">The key chain</h2>

<p>At registration the browser makes everything and sends the server only sealed copies:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>passphrase  --Argon2id--&gt;       KEK  (key-encryption key)
KEK         --AES-256-GCM--&gt;    opens the AMK  (account master key, random 256-bit)
AMK         --HKDF-SHA256--&gt;    opens the RSA private key (3072-bit by default)
private key --RSA-OAEP--&gt;       opens each file's DEK  (data-encryption key)
DEK         --AES-256-GCM--&gt;    decrypts the file
</code></pre></div></div>

<p>The server stores the wrapped AMK, the encrypted private key, the public key and the Argon2id salt and parameters. With all of that and no passphrase, it still cannot open anything. The browser also runs a self-test before registering: it unlocks what it just sealed and checks the private key matches the public key. The server cannot check that, because it cannot decrypt the bundle.</p>

<p>Each upload gets a fresh random DEK. The browser encrypts the file with it (AES-256-GCM by default, or one of five other AEAD suites the user picks), then wraps the DEK with RSA-OAEP-SHA256 twice: once to the owner’s public key, and once to a recovery key whose private half is kept offline. The server receives the ciphertext and the two wrapped DEKs. On download it streams the ciphertext back and the browser decrypts it.</p>

<h2 id="sharing-is-a-rewrap">Sharing is a rewrap</h2>

<p>To share a file with Bob, the server cannot help with the cryptography, because it does not have the DEK. So the sender’s browser does it:</p>

<ol>
  <li>Fetch Bob’s public key, and compute its fingerprint from the key itself.</li>
  <li>Show Bob’s name and fingerprint, and warn if it changed since the last share to Bob.</li>
  <li>Unlock the sender’s private key and unwrap the file’s DEK.</li>
  <li>Wrap the same DEK to Bob’s public key and send only that.</li>
</ol>

<p>The server checks permissions: the sender needs SHARE on the file, and Bob cannot get a capability the sender does not have. Then it stores Bob’s grant and wrapped DEK, plus an audit event, in one transaction. The file itself is never re-encrypted.</p>

<p>Step 2 is there because the server hands out public keys. A server that swapped Bob’s key for its own could read everything shared to “Bob”. The fingerprint warning only catches that after the first share, and only if people compare it with what Bob sees in Settings. It is a real limit, and the docs say so.</p>

<h2 id="someone-forgets-their-passphrase">Someone forgets their passphrase</h2>

<p>This is where “the server holds no keys” stops being free. Forget the passphrase and the AMK is gone, and nothing on the server can bring it back.</p>

<p>The way out is the private key file the browser shows once at registration. That file opens every file the account can reach, so letting its holder set a new passphrase gives them nothing they did not already have. The reset:</p>

<ul>
  <li>reads the key file in the browser and checks it matches the account’s public key</li>
  <li>makes a <strong>new</strong> AMK, wraps it under the new passphrase, and re-encrypts the same private key under it</li>
  <li>reopens both new envelopes locally before sending anything</li>
</ul>

<p>The key pair does not change, so every file, share and wrapped DEK keeps working and nothing is re-encrypted. Lose both the passphrase and the key file and the files are gone for good. Registration says this in plain words.</p>

<h2 id="a-stolen-session-must-not-replace-the-keys">A stolen session must not replace the keys</h2>

<p>The server cannot check that the new envelopes open, because it never sees the passphrase or the AMK. Without another check, someone with a stolen session could upload garbage envelopes and lock the owner out of every file.</p>

<p>So replacing the envelopes needs proof of the private key. The server returns a digest that binds the account, the key, both expected revisions and every field of both new envelopes. The browser signs it with the private key (RSA-PSS-SHA256), and the server verifies the signature before writing anything:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>POST /keys/me/reset-protection/signing-payload  -&gt;  32-byte digest
browser signs the digest with the account private key
POST /keys/me/reset-protection  (envelopes + signature)
  signature from another key  -&gt;  422, nothing changed
  stale revision              -&gt;  409
</code></pre></div></div>

<p>Each signed operation (reset, rewrap, adding a key) has its own domain label in the digest, so a signature for one cannot be replayed as another. Since the reset shipped, the same path also handles changing a passphrase you still know.</p>

<h2 id="what-the-server-can-still-see">What the server can still see</h2>

<p>Key custody is not the same as hiding everything. Someone signed in with a stolen password can still delete or unshare your files, since those are normal account actions, even though they cannot read any of them. The server still sees:</p>

<ul>
  <li>filenames, MIME types and exact file sizes</li>
  <li>who shared what with whom, and when</li>
  <li>the Argon2id envelope, which allows offline guessing of a weak passphrase if the database is stolen</li>
</ul>

<p>For that last point, the browser refuses passphrases that are common, patterned or mostly the username, and requires at least 10 characters. A modified client could skip those checks. They protect users from their own weak choices, not the server from an attacker.</p>

<p>A server that is actively malicious can also serve old ciphertext or a swapped key bundle. The design gives zero-knowledge key custody at the API, not protection from a hostile operator.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>Write down what the server must never receive, and check every endpoint against it.</li>
  <li>If the server cannot validate something, ask the client to prove it, like the signed envelope replacement.</li>
  <li>Recovery has to be designed with the encryption, not added later. Here it is a file the user keeps, and the docs say what losing it costs.</li>
  <li>Write the limits down. A security claim reads better when it says what it does not cover.</li>
</ul>

<p>More detail: <a href="https://github.com/Waldo0926/SecureView/blob/main/docs/SECURITY_FLOWS.md"><code class="language-plaintext highlighter-rouge">docs/SECURITY_FLOWS.md</code></a>, and the browser code in <a href="https://github.com/Waldo0926/SecureView/tree/main/frontend/src/crypto"><code class="language-plaintext highlighter-rouge">frontend/src/crypto/</code></a> (<code class="language-plaintext highlighter-rouge">share.ts</code>, <code class="language-plaintext highlighter-rouge">passphrase-reset.ts</code>).</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[In SecureView the browser encrypts every file and the server never holds a key that opens one. How sharing works under that rule, and what happens when someone forgets their passphrase.]]></summary></entry><entry xml:lang="zh-Hans"><title type="html">不用登录的课程评分，以及怎么防刷</title><link href="https://waldo0926.github.io/2026/10/24/rating-abuse-zh/" rel="alternate" type="text/html" title="不用登录的课程评分，以及怎么防刷" /><published>2026-10-24T17:30:00+08:00</published><updated>2026-10-24T17:30:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/rating-abuse-zh</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/rating-abuse-zh/"><![CDATA[<p><a href="https://monashhub.secureview.tech">Monash Hub</a> 可以给课程打 1 到 5 星，教学、内容、考核、难度、工作量这几项可以选填（<a href="https://github.com/Waldo0926/monash-hub/pull/163">#163</a>）。我希望不登录也能评分。打个分还要先注册，新网站最后往往一条评分都没有。</p>

<p>问题也很明显：谁都能评，那一个人就能评五十次。这篇讲几层让刷分变难的做法，以及它们管不到的地方。</p>

<h2 id="每人每门课一条">每人每门课一条</h2>

<p>登录用户的评分属于账号。没登录的评分属于一个随机密钥：浏览器第一次评分时生成（<code class="language-plaintext highlighter-rouge">crypto.randomUUID()</code>），存在 <code class="language-plaintext highlighter-rouge">localStorage</code> 里，之后每次请求都放在 <code class="language-plaintext highlighter-rouge">X-Guest-Key</code> 请求头里发过来。</p>

<p>数据库保证每个主人每门课只有一条：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">UniqueConstraint</span><span class="p">(</span><span class="s">"author_id"</span><span class="p">,</span> <span class="s">"unit_code"</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s">"uq_unit_review_once"</span><span class="p">),</span>
<span class="n">UniqueConstraint</span><span class="p">(</span><span class="s">"guest_key"</span><span class="p">,</span> <span class="s">"unit_code"</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s">"uq_unit_review_guest_once"</span><span class="p">),</span>
<span class="n">CheckConstraint</span><span class="p">(</span><span class="s">"author_id IS NOT NULL OR guest_key IS NOT NULL"</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s">"ck_unit_review_owner"</span><span class="p">),</span>
</code></pre></div></div>

<p>再评一次就是改原来那条，学生可以改主意。如果同一个人的两次保存同时到达，第二次插入会撞上唯一约束，代码接住 <code class="language-plaintext highlighter-rouge">IntegrityError</code>，把它变成一次修改。</p>

<h2 id="密钥不能原样存">密钥不能原样存</h2>

<p>访客密钥相当于修改这条评分的密码。如果表泄露了，而密钥是原样存的，任何人都能拿着它去改别人的评分。所以存的是用服务器密钥算的 HMAC：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">guest_hash</span><span class="p">(</span><span class="n">raw</span><span class="p">):</span>
    <span class="n">raw</span> <span class="o">=</span> <span class="p">(</span><span class="n">raw</span> <span class="ow">or</span> <span class="s">""</span><span class="p">).</span><span class="n">strip</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">_keyed</span><span class="p">(</span><span class="sa">f</span><span class="s">"guest:</span><span class="si">{</span><span class="n">raw</span><span class="si">}</span><span class="s">"</span><span class="p">)</span> <span class="k">if</span> <span class="n">GUEST_KEY</span><span class="p">.</span><span class="n">match</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span> <span class="k">else</span> <span class="bp">None</span>
</code></pre></div></div>

<p>有一个测试专门检查存下来的值里不包含发过来的密钥。</p>

<h2 id="密钥限不住人所以按网络限">密钥限不住人，所以按网络限</h2>

<p>浏览器想换密钥随时都能换：清掉存储、开无痕窗口，或者写个脚本。所以密钥只用来认“这条是谁的”，限流按网络地址算：</p>

<table>
  <thead>
    <tr>
      <th>对象</th>
      <th>限制</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>一个账号或一个访客密钥</td>
      <td>每天最多保存 30 次评分</td>
    </tr>
    <tr>
      <td>同一网络地址的未登录评分</td>
      <td>每天 100 次</td>
    </tr>
    <tr>
      <td>同一地址的所有 API 请求（nginx）</td>
      <td>每秒 20 次</td>
    </tr>
  </tbody>
</table>

<p>每天 100 次看起来很宽松，这是故意的。校园 Wi-Fi 下，一整个大课教室可能只共用几个地址。严到能挡住一个人的限制，会把一整个班挡在外面。地址存的也是 HMAC，不是 IP 本身。</p>

<p>拿到真实地址要小心。<code class="language-plaintext highlighter-rouge">X-Forwarded-For</code> 客户端想写什么都行，所以 API 只认 nginx 根据连接本身设置的 <code class="language-plaintext highlighter-rouge">X-Real-IP</code>，而且只在请求来自可信代理时才认。绕过 nginx 直接打到 API 的请求按它自己的地址限流，没法每次换一个地址。</p>

<h2 id="加锁再计数">加锁再计数</h2>

<p>计数放在 PostgreSQL 里而不是内存里，因为 API 有多个 worker，每个 worker 各数各的，限制就变成了好几份。这个限流器最早是给登录写的，当时有个竞态：同时到达的几次猜密码都数到同样的历史次数，结果全部放行。从 <a href="https://github.com/Waldo0926/monash-hub/pull/148">#148</a> 开始，检查前先对这个键加一把事务级 advisory lock，评分用的就是这个版本：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">check</span><span class="p">(</span><span class="n">db</span><span class="p">,</span> <span class="n">limit</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span>
    <span class="n">db</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">select</span><span class="p">(</span><span class="n">func</span><span class="p">.</span><span class="n">pg_advisory_xact_lock</span><span class="p">(</span><span class="n">func</span><span class="p">.</span><span class="n">hashtext</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">limit</span><span class="p">.</span><span class="n">kind</span><span class="si">}</span><span class="s">:</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s">"</span><span class="p">))))</span>
    <span class="n">since</span> <span class="o">=</span> <span class="n">_now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">seconds</span><span class="o">=</span><span class="n">limit</span><span class="p">.</span><span class="n">window_seconds</span><span class="p">)</span>
    <span class="n">count</span><span class="p">,</span> <span class="n">oldest</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="n">execute</span><span class="p">(...).</span><span class="n">one</span><span class="p">()</span>  <span class="c1"># 这个键在窗口期内的次数
</span>    <span class="k">if</span> <span class="p">(</span><span class="n">count</span> <span class="ow">or</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">limit</span><span class="p">.</span><span class="n">max_events</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">Throttled</span><span class="p">(</span><span class="n">retry_after</span><span class="p">)</span>
</code></pre></div></div>

<p>同一个键的请求会排队，等前一个提交了再数。被限流的请求返回 429，带 <code class="language-plaintext highlighter-rouge">Retry-After</code>。</p>

<h2 id="评分保存之后">评分保存之后</h2>

<ul>
  <li><strong>举报和隐藏。</strong> 评分可以像论坛帖子一样被举报，管理员可以隐藏。被隐藏的评分不计入平均分，但作者自己还能看到和修改。</li>
  <li><strong>作者信息尽量少。</strong> 登录用户的评分别人只看到名字首字母，未登录的评分连首字母都没有。学生评价教学团队时应该能说实话。</li>
  <li><strong>平均分读取时现算。</strong> 每页一条分组查询，不存平均分，也就不会和实际数据对不上。选填的每一项都有自己的人数，两个人填的平均分就标两个人。</li>
</ul>

<h2 id="这些挡不住什么">这些挡不住什么</h2>

<p>这些做法提高了刷分的成本，但没法让刷分变成不可能。一个人在手机流量、家里 Wi-Fi 和 VPN 之间切换，每个地址又有 100 次。一个人用一个浏览器每门课只评一次，看起来就和真实学生一模一样，因为对方可能本来就是。</p>

<p>对付这种情况主要不靠代码。页面会显示平均分是多少人打的，不到三个人时会提示平均分仅供参考。举报会有人看。如果刷分从偶尔变成常态，下一步是要求评分前验证邮箱，账号系统本来就支持（<a href="https://github.com/Waldo0926/monash-hub/blob/main/docs/adr/0005-accounts-without-identity.md">ADR 0005</a>）。</p>

<h2 id="测试">测试</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">test_signed_out_ratings_are_limited_per_network</code>：同一地址用三个新密钥评分，第三次返回 429。</li>
  <li><code class="language-plaintext highlighter-rouge">test_the_browser_key_is_not_stored_as_sent</code>。</li>
  <li>一个 Playwright 测试：访客给课程评分，另一个浏览器看到的是别人的评分，第一个浏览器再把评分撤回（<a href="https://github.com/Waldo0926/monash-hub/pull/205">#205</a>）。</li>
</ul>

<p>代码：<a href="https://github.com/Waldo0926/monash-hub/blob/main/backend/app/api/v1/reviews.py"><code class="language-plaintext highlighter-rouge">reviews.py</code></a>、<a href="https://github.com/Waldo0926/monash-hub/blob/main/backend/app/core/throttle.py"><code class="language-plaintext highlighter-rouge">throttle.py</code></a>、<a href="https://github.com/Waldo0926/monash-hub/blob/main/frontend/composables/useGuestKey.ts"><code class="language-plaintext highlighter-rouge">useGuestKey.ts</code></a>。</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[不登录也能给课程打分。一个浏览器怎么保证每门课只有一条评分，为什么限流按网络而不按密钥，以及这些还挡不住什么。]]></summary></entry><entry xml:lang="en"><title type="html">Unit ratings without sign-in, and keeping them honest</title><link href="https://waldo0926.github.io/2026/10/24/rating-abuse/" rel="alternate" type="text/html" title="Unit ratings without sign-in, and keeping them honest" /><published>2026-10-24T17:30:00+08:00</published><updated>2026-10-24T17:30:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/rating-abuse</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/rating-abuse/"><![CDATA[<p><a href="https://monashhub.secureview.tech">Monash Hub</a> lets students rate a unit out of five, with optional scores for teaching, content, assessment, difficulty and workload (<a href="https://github.com/Waldo0926/monash-hub/pull/163">#163</a>). I wanted it to work without an account. Asking for sign-up before a one-click rating is how a new site ends up with no ratings at all.</p>

<p>The problem is obvious: if anyone can rate, one person can rate fifty times. This post is about the layers that make that harder, and where they stop.</p>

<h2 id="one-rating-per-person-per-unit">One rating per person per unit</h2>

<p>A signed-in rating belongs to the account. A signed-out rating belongs to a random key that the browser makes the first time it rates (<code class="language-plaintext highlighter-rouge">crypto.randomUUID()</code>), keeps in <code class="language-plaintext highlighter-rouge">localStorage</code>, and sends as an <code class="language-plaintext highlighter-rouge">X-Guest-Key</code> header.</p>

<p>The database enforces one rating per owner per unit:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">UniqueConstraint</span><span class="p">(</span><span class="s">"author_id"</span><span class="p">,</span> <span class="s">"unit_code"</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s">"uq_unit_review_once"</span><span class="p">),</span>
<span class="n">UniqueConstraint</span><span class="p">(</span><span class="s">"guest_key"</span><span class="p">,</span> <span class="s">"unit_code"</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s">"uq_unit_review_guest_once"</span><span class="p">),</span>
<span class="n">CheckConstraint</span><span class="p">(</span><span class="s">"author_id IS NOT NULL OR guest_key IS NOT NULL"</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s">"ck_unit_review_owner"</span><span class="p">),</span>
</code></pre></div></div>

<p>Rating again edits the same row, so a student can change their mind. If two saves from the same owner arrive together, the second insert hits the unique constraint, and the handler catches the <code class="language-plaintext highlighter-rouge">IntegrityError</code> and turns it into an edit.</p>

<h2 id="dont-store-the-key-as-sent">Don’t store the key as sent</h2>

<p>The guest key works like a password for editing that rating. If the table leaked and the keys were stored as sent, anyone could send them back and edit other people’s ratings. So the key is stored as an HMAC with the server’s secret:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">guest_hash</span><span class="p">(</span><span class="n">raw</span><span class="p">):</span>
    <span class="n">raw</span> <span class="o">=</span> <span class="p">(</span><span class="n">raw</span> <span class="ow">or</span> <span class="s">""</span><span class="p">).</span><span class="n">strip</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">_keyed</span><span class="p">(</span><span class="sa">f</span><span class="s">"guest:</span><span class="si">{</span><span class="n">raw</span><span class="si">}</span><span class="s">"</span><span class="p">)</span> <span class="k">if</span> <span class="n">GUEST_KEY</span><span class="p">.</span><span class="n">match</span><span class="p">(</span><span class="n">raw</span><span class="p">)</span> <span class="k">else</span> <span class="bp">None</span>
</code></pre></div></div>

<p>A test checks that the stored value never contains the key that was sent.</p>

<h2 id="the-key-limits-nothing-so-limit-the-network">The key limits nothing, so limit the network</h2>

<p>A browser can make a new key whenever it likes: clear storage, open a private window, or write a script. So the key is only for ownership. The limit is on the network address:</p>

<table>
  <thead>
    <tr>
      <th>Who</th>
      <th>Limit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>One account, or one guest key</td>
      <td>30 ratings saved per day</td>
    </tr>
    <tr>
      <td>Signed-out ratings from one network address</td>
      <td>100 per day</td>
    </tr>
    <tr>
      <td>Any API request from one address (nginx)</td>
      <td>20 per second</td>
    </tr>
  </tbody>
</table>

<p>100 a day looks generous, and it is on purpose. On campus Wi-Fi, a whole lecture theatre can share a handful of addresses. A limit strict enough to stop one person would block a whole class. The address is also stored as an HMAC, not as the IP.</p>

<p>Getting the address right takes care. <code class="language-plaintext highlighter-rouge">X-Forwarded-For</code> is whatever the client writes in it, so the API only believes <code class="language-plaintext highlighter-rouge">X-Real-IP</code>, which nginx sets from the connection, and only when the request comes from a trusted proxy. Anything that reaches the API some other way is limited by its own address and cannot pick a new one per request.</p>

<h2 id="count-under-a-lock">Count under a lock</h2>

<p>The limits are counted in PostgreSQL rather than in memory, because the API runs more than one worker, and a limit each worker counts on its own becomes several limits. The same limiter was written for sign-in first, and it had a race: guesses arriving together all counted the same past events and all got through. Since <a href="https://github.com/Waldo0926/monash-hub/pull/148">#148</a> the check takes a transaction-level advisory lock on the key first, and ratings got that version:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">check</span><span class="p">(</span><span class="n">db</span><span class="p">,</span> <span class="n">limit</span><span class="p">,</span> <span class="n">key</span><span class="p">):</span>
    <span class="n">db</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">select</span><span class="p">(</span><span class="n">func</span><span class="p">.</span><span class="n">pg_advisory_xact_lock</span><span class="p">(</span><span class="n">func</span><span class="p">.</span><span class="n">hashtext</span><span class="p">(</span><span class="sa">f</span><span class="s">"</span><span class="si">{</span><span class="n">limit</span><span class="p">.</span><span class="n">kind</span><span class="si">}</span><span class="s">:</span><span class="si">{</span><span class="n">key</span><span class="si">}</span><span class="s">"</span><span class="p">))))</span>
    <span class="n">since</span> <span class="o">=</span> <span class="n">_now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">seconds</span><span class="o">=</span><span class="n">limit</span><span class="p">.</span><span class="n">window_seconds</span><span class="p">)</span>
    <span class="n">count</span><span class="p">,</span> <span class="n">oldest</span> <span class="o">=</span> <span class="n">db</span><span class="p">.</span><span class="n">execute</span><span class="p">(...).</span><span class="n">one</span><span class="p">()</span>  <span class="c1"># events for this key since the window start
</span>    <span class="k">if</span> <span class="p">(</span><span class="n">count</span> <span class="ow">or</span> <span class="mi">0</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">limit</span><span class="p">.</span><span class="n">max_events</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">Throttled</span><span class="p">(</span><span class="n">retry_after</span><span class="p">)</span>
</code></pre></div></div>

<p>Requests for the same key now queue behind each other until the first one commits. A throttled request gets a 429 with <code class="language-plaintext highlighter-rouge">Retry-After</code>.</p>

<h2 id="after-a-rating-is-saved">After a rating is saved</h2>

<ul>
  <li><strong>Reports and hiding.</strong> A rating can be reported the same way as a forum post, and a moderator can hide it. A hidden rating drops out of the average, but its author still sees it and can edit it.</li>
  <li><strong>Little about the author.</strong> Other readers see only an initial for a signed-in rating, and no name at all for a signed-out one. A student should be able to be honest about a teaching team.</li>
  <li><strong>Averages computed on read.</strong> One grouped query per page, so there is no stored average to drift from the rows. Each optional score has its own count, so an average from two answers says two.</li>
</ul>

<h2 id="what-this-does-not-stop">What this does not stop</h2>

<p>This raises the cost of faking ratings. It does not make it impossible. Someone switching between phone data, home Wi-Fi and a VPN gets a fresh 100 per address. Someone who rates once per unit from one browser looks exactly like a real student, because they might be one.</p>

<p>What helps against that is mostly not code. The page shows how many ratings an average is based on, and under three it says the average is only a rough sign. Reports go to a person. If abuse becomes a steady problem rather than an occasional one, the next step is to make ratings need a verified email. The account system already supports that (<a href="https://github.com/Waldo0926/monash-hub/blob/main/docs/adr/0005-accounts-without-identity.md">ADR 0005</a>).</p>

<h2 id="tests">Tests</h2>

<ul>
  <li><code class="language-plaintext highlighter-rouge">test_signed_out_ratings_are_limited_per_network</code>: three fresh keys from one address, and the third gets a 429.</li>
  <li><code class="language-plaintext highlighter-rouge">test_the_browser_key_is_not_stored_as_sent</code>.</li>
  <li>A Playwright test where a guest rates a unit, a second browser sees it as someone else’s, and the first takes it back (<a href="https://github.com/Waldo0926/monash-hub/pull/205">#205</a>).</li>
</ul>

<p>Code: <a href="https://github.com/Waldo0926/monash-hub/blob/main/backend/app/api/v1/reviews.py"><code class="language-plaintext highlighter-rouge">reviews.py</code></a>, <a href="https://github.com/Waldo0926/monash-hub/blob/main/backend/app/core/throttle.py"><code class="language-plaintext highlighter-rouge">throttle.py</code></a>, <a href="https://github.com/Waldo0926/monash-hub/blob/main/frontend/composables/useGuestKey.ts"><code class="language-plaintext highlighter-rouge">useGuestKey.ts</code></a>.</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[Students can rate a unit without an account. How one browser keeps one rating per unit, why the limit is on the network and not the key, and what this still does not stop.]]></summary></entry><entry xml:lang="zh-Hans"><title type="html">不花钱翻译 Monash Handbook</title><link href="https://waldo0926.github.io/2026/10/24/translation-pipeline-zh/" rel="alternate" type="text/html" title="不花钱翻译 Monash Handbook" /><published>2026-10-24T17:00:00+08:00</published><updated>2026-10-24T17:00:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/translation-pipeline-zh</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/translation-pipeline-zh/"><![CDATA[<p><a href="https://monashhub.secureview.tech">Monash Hub</a> 的读者大部分是中国学生，但要看的内容都是英文：Handbook 里五千多门课、学位结构，还有几百个 Monash 官方页面。这些内容一直在变，Handbook 每周重新抓一次。付费翻译 API 的价格学生项目扛不住，所以网站用的是免费引擎。这篇讲用免费引擎会出什么问题，以及翻译流程是怎么处理的。</p>

<h2 id="普通翻译器偏偏错在关键词上">普通翻译器偏偏错在关键词上</h2>

<p>最早用的是 Argos Translate，一个跑在同一台 VPS 上的离线模型，不要钱，也没有调用次数限制。在真实的 Handbook 页面上，它把：</p>

<ul>
  <li><em>census date</em> 译成“人口普查日期”</li>
  <li><em>unit</em> 译成“单位”</li>
  <li><em>Exercise</em> 译成“锻炼”</li>
  <li><em>Breadth studies</em> 译成“面包研究”</li>
</ul>

<p>这些恰好是学生要据此行动的词。错过 census date，退掉的课也要交钱。所以这些词不能交给模型决定。</p>

<h2 id="翻译流程">翻译流程</h2>

<p>每个句子都走同样的几步：</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>有固定译法吗？ -&gt;  把术语保护起来  -&gt;  翻译  -&gt;  把术语放回去
</code></pre></div></div>

<p><strong>固定值不进模型。</strong> 校区、教学时段、考核类型、学院名这些都来自固定列表。<a href="https://github.com/Waldo0926/monash-hub/blob/main/backend/app/knowledge/glossary.py"><code class="language-plaintext highlighter-rouge">glossary.py</code></a> 给每一个都写好了中日韩三种核对过的译法，包括全部 120 个学院和系的名字。学位名是拼出来的，不是翻出来的，因为英文学位名从前往后读，中文要倒过来。</p>

<p><strong>句子里的术语先遮住。</strong> 句子交给模型之前，里面的术语表词汇都换成占位符，翻完再换回固定译法。占位符用字母（<code class="language-plaintext highlighter-rouge">Zqa</code>、<code class="language-plaintext highlighter-rouge">Zqb</code>……），因为数字会被模型改掉。</p>

<p>字母也不是绝对安全。“What is Zqa?” 回来变成了“什么是兹卡?”，模型把占位符音译了。我拿线上指南里 59 个失败的句子试了几种占位符，每种丢掉的句子都不一样，所以现在一种不行就换下一种，一共五种，直到占位符完整回来为止。这样救回了 59 句里的 50 句。</p>

<h2 id="聊天模型也要先看术语表">聊天模型也要先看术语表</h2>

<p>Argos 的语法还是不行，“WAM of 65” 被译成 65 岁。所以我加了一个引擎，可以接任何兼容 OpenAI 接口的聊天模型（<a href="https://github.com/Waldo0926/monash-hub/pull/115">#115</a>），默认用智谱的 <code class="language-plaintext highlighter-rouge">glm-4-flash</code>，因为它免费。网站上现在大部分中文都是它翻的。</p>

<p>聊天模型反而不擅长处理占位符。让它翻 “Zqa units”，它会把两个都译成“课程”。所以给它的方式改成在提示词里直接列出固定译法，回复只有在什么都没丢的时候才算数（<a href="https://github.com/Waldo0926/monash-hub/pull/116">#116</a>）：</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># 从 crawler/translate/llm.py 简化
</span><span class="k">def</span> <span class="nf">faithful</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">reply</span><span class="p">,</span> <span class="n">terms</span><span class="p">):</span>
    <span class="k">if</span> <span class="nb">any</span><span class="p">(</span><span class="n">agreed</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">reply</span> <span class="k">for</span> <span class="n">_english</span><span class="p">,</span> <span class="n">agreed</span> <span class="ow">in</span> <span class="n">terms</span><span class="p">):</span>
        <span class="k">return</span> <span class="bp">False</span>
    <span class="k">if</span> <span class="nb">any</span><span class="p">(</span><span class="n">code</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">reply</span> <span class="k">for</span> <span class="n">code</span> <span class="ow">in</span> <span class="n">_CODE</span><span class="p">.</span><span class="n">findall</span><span class="p">(</span><span class="n">source</span><span class="p">)):</span>
        <span class="k">return</span> <span class="bp">False</span>
    <span class="k">if</span> <span class="n">bullets</span><span class="p">(</span><span class="n">source</span><span class="p">)</span> <span class="o">!=</span> <span class="n">bullets</span><span class="p">(</span><span class="n">reply</span><span class="p">):</span>
        <span class="k">return</span> <span class="bp">False</span>
    <span class="k">return</span> <span class="nb">all</span><span class="p">(</span><span class="n">n</span> <span class="ow">in</span> <span class="n">reply</span> <span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="n">_NUMBER</span><span class="p">.</span><span class="n">findall</span><span class="p">(</span><span class="n">source</span><span class="p">))</span>
</code></pre></div></div>

<p>没通过检查的句子，再走一遍遮术语的流程，那条路不会丢术语。</p>

<h2 id="拿不准就保留英文">拿不准就保留英文</h2>

<p>聊天模型还可能直接回答问题而不是翻译，或者开头先说一句“以下是翻译”。遇到下面这些情况，引擎会直接丢掉回复：</p>

<ul>
  <li>回复是空的，或者比原文长太多</li>
  <li>开头有客套话，或者把提示词原样吐回来</li>
  <li>文字不对，比如日文回复里夹着简体中文</li>
  <li>和英文原文一模一样</li>
</ul>

<p>翻不好的句子就保留英文。读者至少还能看英文，一句没人写过却说得很肯定的中文更糟。</p>

<h2 id="译文存在哪里">译文存在哪里</h2>

<p>译文都存在 <code class="language-plaintext highlighter-rouge">content_translations</code> 这张表里，和英文原文并排放，不覆盖原文。每一行有两个关键字段：</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">source_hash</code></strong>：生成这条译文时英文原文的哈希。Monash 改了页面，哈希就变了，页面会提示译文可能过时。重跑时哈希没变的直接跳过，所以每周刷新只把新句子发给模型。</li>
  <li><strong><code class="language-plaintext highlighter-rouge">provenance</code></strong>：<code class="language-plaintext highlighter-rouge">machine</code>（机器）、<code class="language-plaintext highlighter-rouge">curated</code>（对照英文核对过）或 <code class="language-plaintext highlighter-rouge">human</code>（人工）。读取时人工的优先。机器重跑永远不会覆盖人写的译文，页面上也会告诉读者现在看的是哪一种。</li>
</ul>

<p>Handbook 里同样的段落在几千门课里重复出现，所以引擎按句子缓存。有没有这层缓存，是跑一小时和跑一周的差别。</p>

<h2 id="日文和韩文">日文和韩文</h2>

<p>日文和韩文读者很少，所以只用同一套术语表加机器翻译，不做日常人工检查。即便这样，第一批样本还是要修（<a href="https://github.com/Waldo0926/monash-hub/pull/182">#182</a>、<a href="https://github.com/Waldo0926/monash-hub/pull/183">#183</a>）：</p>

<ul>
  <li>韩文的空格全没了，因为一条在术语后面拼接中文的规则也作用到了韩文上。</li>
  <li>韩文助词是按模型自己的词选的，不是按换回去的术语选的（写成了 과목를，应该是 과목을）。</li>
  <li>日文在术语前后重复（マレーシアキャンパスキャンパス）。</li>
  <li>日期写成了 12月2026年。</li>
</ul>

<p>免费模型大约十秒一句，日文和韩文全部翻完要一天以上。所以它在抓取结束后用单独的锁跑，中文先跑。</p>

<h2 id="小结">小结</h2>

<ul>
  <li>质量主要靠术语表。大部分翻译错误加一个术语就能修好，下次运行所有页面都会跟着改。</li>
  <li>存之前先检查模型返回的内容，固定译法、课程代码和数字一个都不能少。</li>
  <li>译文和原文并排存，带上哈希，原文变了才知道译文过时了。</li>
  <li>翻不好的句子就保留原文。</li>
</ul>

<p>代码：<a href="https://github.com/Waldo0926/monash-hub/blob/main/crawler/translate/engine.py"><code class="language-plaintext highlighter-rouge">engine.py</code></a>、<a href="https://github.com/Waldo0926/monash-hub/blob/main/crawler/translate/llm.py"><code class="language-plaintext highlighter-rouge">llm.py</code></a>、<a href="https://github.com/Waldo0926/monash-hub/blob/main/backend/app/knowledge/glossary.py"><code class="language-plaintext highlighter-rouge">glossary.py</code></a>，决策记录见 <a href="https://github.com/Waldo0926/monash-hub/blob/main/docs/adr/0004-glossary-before-machine-translation.md">ADR 0004</a>。</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[五千多门课和几百个官方页面，不花钱翻成中文。为什么模型前面要挡一层术语表，翻不好的时候怎么退回英文。]]></summary></entry><entry xml:lang="en"><title type="html">Translating the Monash Handbook without paying for it</title><link href="https://waldo0926.github.io/2026/10/24/translation-pipeline/" rel="alternate" type="text/html" title="Translating the Monash Handbook without paying for it" /><published>2026-10-24T17:00:00+08:00</published><updated>2026-10-24T17:00:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/translation-pipeline</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/translation-pipeline/"><![CDATA[<p>Most people who read <a href="https://monashhub.secureview.tech">Monash Hub</a> are Chinese students. What they need to read is in English: about five thousand Handbook units, the degree structures, and several hundred official Monash pages. Monash keeps changing it, so the Handbook is crawled again every week. A paid translation API would cost more than a student project can spend, so the site uses free engines. This post covers what goes wrong when you do that, and what the pipeline does about it.</p>

<h2 id="a-plain-translator-gets-the-important-words-wrong">A plain translator gets the important words wrong</h2>

<p>The first engine was Argos Translate, an offline model that runs on the same VPS. It costs nothing and has no rate limit. On real Handbook pages it translated:</p>

<ul>
  <li><em>census date</em> as 人口普查日期 (a population census)</li>
  <li><em>unit</em> as 单位 (an organisation, not a subject)</li>
  <li><em>Exercise</em> as 锻炼 (physical exercise)</li>
  <li><em>Breadth studies</em> as 面包研究 (bread studies)</li>
</ul>

<p>These are exactly the words a student acts on. Miss the census date and you pay for a unit you dropped. So the model is not allowed to decide them.</p>

<h2 id="the-pipeline">The pipeline</h2>

<p>Every string goes through the same steps:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>agreed value?  -&gt;  protect the terms  -&gt;  translate  -&gt;  put the terms back
</code></pre></div></div>

<p><strong>Agreed values never reach the model.</strong> A campus, a teaching period, an assessment type or a faculty name comes from a closed list. <a href="https://github.com/Waldo0926/monash-hub/blob/main/backend/app/knowledge/glossary.py"><code class="language-plaintext highlighter-rouge">glossary.py</code></a> has a checked wording for each one in Chinese, Japanese and Korean, including all 120 faculty and school names. Degree names are built from parts rather than translated, because English names a degree front to back and Chinese back to front.</p>

<p><strong>Terms inside a sentence are masked.</strong> Before a sentence goes to the model, every glossary term in it is swapped for a placeholder, and the agreed wording is put back afterwards. The placeholders are letters (<code class="language-plaintext highlighter-rouge">Zqa</code>, <code class="language-plaintext highlighter-rouge">Zqb</code>, …) because the model rewrote digits.</p>

<p>Letters were not safe either. “What is Zqa?” came back as 什么是兹卡?, where the model had turned the placeholder into Chinese sounds. I tested other tokens on the 59 sentences that were failing on the live guides. Each token lost a different set of sentences, so the engine now tries five masks in turn until one comes back intact. That recovered 50 of the 59.</p>

<h2 id="the-chat-model-gets-the-glossary-too">The chat model gets the glossary too</h2>

<p>Argos was still weak on grammar. “WAM of 65” became 65 years old. So I added an engine for any OpenAI-compatible chat model (<a href="https://github.com/Waldo0926/monash-hub/pull/115">#115</a>), defaulting to Zhipu’s <code class="language-plaintext highlighter-rouge">glm-4-flash</code> because it is free. Most of the Chinese on the site now comes from it.</p>

<p>A chat model does worse with placeholders. Asked to translate “Zqa units”, it writes 课程 for both. So it gets the agreed wordings in the prompt instead, and the reply is only accepted if nothing was lost (<a href="https://github.com/Waldo0926/monash-hub/pull/116">#116</a>):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># simplified from crawler/translate/llm.py
</span><span class="k">def</span> <span class="nf">faithful</span><span class="p">(</span><span class="n">source</span><span class="p">,</span> <span class="n">reply</span><span class="p">,</span> <span class="n">terms</span><span class="p">):</span>
    <span class="k">if</span> <span class="nb">any</span><span class="p">(</span><span class="n">agreed</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">reply</span> <span class="k">for</span> <span class="n">_english</span><span class="p">,</span> <span class="n">agreed</span> <span class="ow">in</span> <span class="n">terms</span><span class="p">):</span>
        <span class="k">return</span> <span class="bp">False</span>
    <span class="k">if</span> <span class="nb">any</span><span class="p">(</span><span class="n">code</span> <span class="ow">not</span> <span class="ow">in</span> <span class="n">reply</span> <span class="k">for</span> <span class="n">code</span> <span class="ow">in</span> <span class="n">_CODE</span><span class="p">.</span><span class="n">findall</span><span class="p">(</span><span class="n">source</span><span class="p">)):</span>
        <span class="k">return</span> <span class="bp">False</span>
    <span class="k">if</span> <span class="n">bullets</span><span class="p">(</span><span class="n">source</span><span class="p">)</span> <span class="o">!=</span> <span class="n">bullets</span><span class="p">(</span><span class="n">reply</span><span class="p">):</span>
        <span class="k">return</span> <span class="bp">False</span>
    <span class="k">return</span> <span class="nb">all</span><span class="p">(</span><span class="n">n</span> <span class="ow">in</span> <span class="n">reply</span> <span class="k">for</span> <span class="n">n</span> <span class="ow">in</span> <span class="n">_NUMBER</span><span class="p">.</span><span class="n">findall</span><span class="p">(</span><span class="n">source</span><span class="p">))</span>
</code></pre></div></div>

<p>If the reply fails this check, the same sentence goes through the masked path, which cannot drop a term.</p>

<h2 id="when-in-doubt-keep-the-english">When in doubt, keep the English</h2>

<p>A chat model can also answer the question instead of translating it, or start with “Here is the translation”. The engine throws a reply away when:</p>

<ul>
  <li>it is empty, or much longer than the source</li>
  <li>it opens with a preface, or echoes the prompt back</li>
  <li>it is in the wrong script, like Simplified Chinese inside a Japanese answer</li>
  <li>it is identical to the English</li>
</ul>

<p>A string that fails stays in English. A reader can work with English, but a confident sentence that nobody wrote is worse.</p>

<h2 id="where-translations-live">Where translations live</h2>

<p>Translations are stored in one table, <code class="language-plaintext highlighter-rouge">content_translations</code>, next to the English rather than replacing it. Each row has two things that matter:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">source_hash</code></strong>: the hash of the English it was made from. When Monash edits the page, the hash changes and the page says the translation may be out of date. A rerun skips everything whose hash has not moved, so the weekly refresh only sends new sentences to the model.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">provenance</code></strong>: <code class="language-plaintext highlighter-rouge">machine</code>, <code class="language-plaintext highlighter-rouge">curated</code> (checked against the English) or <code class="language-plaintext highlighter-rouge">human</code>. The human row wins on read. A machine run can never overwrite somebody’s work, and the page tells the reader which kind they are looking at.</li>
</ul>

<p>The Handbook repeats the same paragraphs across thousands of units, so the engine caches by sentence. That cache is the difference between an hour and a week.</p>

<h2 id="japanese-and-korean">Japanese and Korean</h2>

<p>Japanese and Korean have very few readers, so they get machine output behind the same glossary with no routine review. The first sample still needed fixes (<a href="https://github.com/Waldo0926/monash-hub/pull/182">#182</a>, <a href="https://github.com/Waldo0926/monash-hub/pull/183">#183</a>):</p>

<ul>
  <li>Korean lost all its spaces, because a rule that joins Chinese words after a glossary term also ran over Korean.</li>
  <li>Korean particles were chosen for the model’s own word, not the term put back (과목를 instead of 과목을).</li>
  <li>Japanese doubled words around a term (マレーシアキャンパスキャンパス).</li>
  <li>Dates came out as 12月2026年.</li>
</ul>

<p>At about ten seconds a string on the free model, a full Japanese and Korean fill takes a day or more. So it runs under its own lock after the crawl, and Chinese always goes first.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>The glossary is where quality comes from. Most translation bugs were fixed by adding a term, and the fix reaches every page on the next run.</li>
  <li>Check what the model returns before storing it. Every agreed term, code and number has to be there.</li>
  <li>Store translations beside the source with a hash, so you know when they go stale.</li>
  <li>A string you cannot translate well should stay in the original language.</li>
</ul>

<p>Code: <a href="https://github.com/Waldo0926/monash-hub/blob/main/crawler/translate/engine.py"><code class="language-plaintext highlighter-rouge">engine.py</code></a>, <a href="https://github.com/Waldo0926/monash-hub/blob/main/crawler/translate/llm.py"><code class="language-plaintext highlighter-rouge">llm.py</code></a>, <a href="https://github.com/Waldo0926/monash-hub/blob/main/backend/app/knowledge/glossary.py"><code class="language-plaintext highlighter-rouge">glossary.py</code></a>, and the decision record <a href="https://github.com/Waldo0926/monash-hub/blob/main/docs/adr/0004-glossary-before-machine-translation.md">ADR 0004</a>.</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[Five thousand units and hundreds of official pages, translated into Chinese for free. Why a glossary sits in front of the model, and how a bad translation falls back to English.]]></summary></entry><entry xml:lang="zh-Hans"><title type="html">Monash Hub 的自动部署和失败回滚</title><link href="https://waldo0926.github.io/2026/10/24/deploy-rollback-zh/" rel="alternate" type="text/html" title="Monash Hub 的自动部署和失败回滚" /><published>2026-10-24T10:00:00+08:00</published><updated>2026-10-24T10:00:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/deploy-rollback-zh</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/deploy-rollback-zh/"><![CDATA[<p><a href="https://monashhub.secureview.tech">Monash Hub</a> 是我做的 Monash 课程信息网站，给中国学生用。它跑在一台 VPS 上，用 Docker Compose 起 PostgreSQL、FastAPI 后端和 Nuxt 前端，前面是 nginx。代码合并到 <code class="language-plaintext highlighter-rouge">main</code> 就自动部署。</p>

<p>网站只有我一个人维护，我不可能每次部署都守着。所以部署脚本要自己判断这次上线成没成功，没成功就退回上一个版本。这篇讲它现在怎么做，以及我是被哪些问题逼着改成这样的。</p>

<h2 id="出过的问题">出过的问题</h2>

<p><strong>1. 上线的代码不是 CI 测过的那份。</strong> 最早的 Deploy workflow 只是 SSH 到服务器跑 <code class="language-plaintext highlighter-rouge">deploy.sh</code>，脚本自己 <code class="language-plaintext highlighter-rouge">git reset</code> 到 <code class="language-plaintext highlighter-rouge">origin/main</code>。如果 CI 跑完到 SSH 连上之间，<code class="language-plaintext highlighter-rouge">main</code> 上又多了一个提交，这个没测过的提交就直接上线了。</p>

<p><strong>2. 部署脚本把自己换掉了。</strong> bash 是边读边执行脚本的。<code class="language-plaintext highlighter-rouge">deploy.sh</code> 在执行中途 <code class="language-plaintext highlighter-rouge">git checkout</code> 新提交，磁盘上的脚本文件就变了，后面几行可能读到新文件里错位的内容。</p>

<p><strong>3. 一个官方页面换了地址，部署连续失败。</strong> Monash 把出国健康与安全那一页从 <code class="language-plaintext highlighter-rouge">study-abroad/overseas/</code> 挪到了 <code class="language-plaintext highlighter-rouge">study-abroad/outbound/</code>。部署时登记页面列表会按 URL 插入新行，结果撞了 slug 的唯一约束。从 #128 开始的几次部署都停在这一步，线上一直是旧版本，直到 <a href="https://github.com/Waldo0926/monash-hub/pull/131">#131</a> 修好。</p>

<p><strong>4. 健康检查说没问题，其实不知道上线的是什么。</strong> 原来只看 <code class="language-plaintext highlighter-rouge">/api/health</code> 返回 200。API 能响应，不代表页面能渲染，也不代表跑的是刚部署的那个提交。</p>

<h2 id="现在的流程">现在的流程</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CI 通过 → Deploy workflow 拿到 CI 测过的提交号
       → SSH 到 VPS 跑 deploy.sh &lt;提交号&gt;
          1. 确认这个提交在 origin/main 上，checkout，再 exec 新版本的自己
          2. pg_dump 备份数据库，保留 14 天
          3. 把正在运行的镜像打上 :previous 标签
          4. 构建、跑迁移、写入种子数据、启动
          5. 等 /api/health，大约 60 秒还没好就回滚
          6. smoke.sh 检查提交号和几个关键页面，失败也回滚
       → 在 GitHub Actions 上通过公网域名再跑一次 smoke.sh
</code></pre></div></div>

<h3 id="只部署-ci-测过的提交">只部署 CI 测过的提交</h3>

<p>Deploy workflow 由 CI 的 <code class="language-plaintext highlighter-rouge">workflow_run</code> 事件触发，传给服务器的是 CI 那次运行的 <code class="language-plaintext highlighter-rouge">head_sha</code>，不是 <code class="language-plaintext highlighter-rouge">main</code> 当时指向的提交。脚本收到后先检查它在不在 <code class="language-plaintext highlighter-rouge">origin/main</code> 上，不在就拒绝，所以从别的分支手动触发也部署不了。</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">!</span> git merge-base <span class="nt">--is-ancestor</span> <span class="s2">"</span><span class="nv">$DEPLOY_SHA</span><span class="s2">"</span> origin/main<span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"error: </span><span class="nv">$DEPLOY_SHA</span><span class="s2"> is not on origin/main; only reviewed commits are deployed"</span> <span class="o">&gt;</span>&amp;2
  <span class="nb">exit </span>1
<span class="k">fi
</span>git checkout <span class="nt">-q</span> <span class="nt">--detach</span> <span class="s2">"</span><span class="nv">$DEPLOY_SHA</span><span class="s2">"</span>
<span class="nb">exec</span> <span class="s2">"</span><span class="nv">$PROJECT_DIR</span><span class="s2">/deployment/deploy.sh"</span> <span class="nt">--at</span> <span class="s2">"</span><span class="nv">$DEPLOY_SHA</span><span class="s2">"</span>
</code></pre></div></div>

<p>最后一行解决的是问题 2：checkout 之后不再往下读旧文件，直接用 <code class="language-plaintext highlighter-rouge">exec</code> 运行新提交里的脚本，<code class="language-plaintext highlighter-rouge">--at</code> 告诉它不用再 checkout 一次。这个改动有个副作用：合并这个改动的那次部署跑的还是旧脚本，因为旧脚本不会 exec，从下一次部署才开始生效。</p>

<h3 id="保留上一版镜像">保留上一版镜像</h3>

<p>构建之前，把正在跑的 <code class="language-plaintext highlighter-rouge">api</code>、<code class="language-plaintext highlighter-rouge">web</code> 等镜像从 <code class="language-plaintext highlighter-rouge">:latest</code> 复制一个 <code class="language-plaintext highlighter-rouge">:previous</code> 标签。回滚就是把标签换回来，再用 <code class="language-plaintext highlighter-rouge">--no-build</code> 启动，不用重新构建：</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rollback<span class="o">()</span> <span class="o">{</span>
  <span class="k">for </span>service <span class="k">in </span>api web<span class="p">;</span> <span class="k">do
    if </span>docker image inspect <span class="s2">"monash-hub-</span><span class="nv">$service</span><span class="s2">:previous"</span> <span class="o">&gt;</span>/dev/null 2&gt;&amp;1<span class="p">;</span> <span class="k">then
      </span>docker tag <span class="s2">"monash-hub-</span><span class="nv">$service</span><span class="s2">:previous"</span> <span class="s2">"monash-hub-</span><span class="nv">$service</span><span class="s2">:latest"</span>
    <span class="k">fi
  done
  </span>docker compose <span class="nt">-p</span> monash-hub up <span class="nt">-d</span> <span class="nt">--no-build</span> api web
<span class="o">}</span>
</code></pre></div></div>

<h3 id="成功要看三件事">成功要看三件事</h3>

<p><code class="language-plaintext highlighter-rouge">/api/health</code> 现在会返回构建时传进去的提交号。<code class="language-plaintext highlighter-rouge">smoke.sh</code> 按访客的角度检查：</p>

<ul>
  <li>API 报告的提交号是不是这次要部署的</li>
  <li>首页的中文和英文能不能渲染</li>
  <li>一个课程页、课程搜索和 WAM 搜索有没有数据</li>
  <li><code class="language-plaintext highlighter-rouge">robots.txt</code>、sitemap 在不在</li>
  <li>不存在的课程和路径返回 404，不是 500</li>
</ul>

<p>服务器上先对容器端口跑一遍，失败就回滚。GitHub Actions 再通过 <code class="language-plaintext highlighter-rouge">monashhub.secureview.tech</code> 跑一遍，确认经过 nginx 之后看到的也是这个提交。</p>

<h2 id="回滚不了的东西">回滚不了的东西</h2>

<p>回滚只换容器，不撤销数据库迁移。旧镜像会在新的表结构上运行，所以迁移最好写成新旧代码都能用的样子。真遇到撤不回的情况，就用第 2 步的备份。</p>

<p>另外，原来部署里有两段一次性的数据修复，会去 Handbook 抓两门课并检查结果。这让每次部署都依赖 Monash 的网站在线，而且到了 2027 年 Handbook 换版本时会让所有部署失败。我把它们挪到了单独的 <code class="language-plaintext highlighter-rouge">verify-handbook-parser.sh</code>，部署脚本只做部署。</p>

<h2 id="部署之前">部署之前</h2>

<p>部署之前，CI 先跑后端测试（连真实 PostgreSQL，并检查模型和迁移有没有对不上）、依赖漏洞扫描、前端类型检查和构建，还有 Playwright 浏览器测试，在桌面和手机尺寸下点一遍课程页、选课规划和评分。CI 不过，Deploy 根本不会触发。</p>

<h2 id="小结">小结</h2>

<ul>
  <li>部署的对象要明确到提交号，并且能从线上查回来。</li>
  <li>回滚要便宜。留一份旧镜像比重新构建快，也不依赖当时的网络和依赖源。</li>
  <li>部署脚本里不要放依赖外部网站的步骤。</li>
</ul>

<p>代码：<a href="https://github.com/Waldo0926/monash-hub/blob/main/deployment/deploy.sh">deploy.sh</a>、<a href="https://github.com/Waldo0926/monash-hub/blob/main/deployment/smoke.sh">smoke.sh</a>、<a href="https://github.com/Waldo0926/monash-hub/blob/main/.github/workflows/deploy.yml">deploy.yml</a>。相关 PR：<a href="https://github.com/Waldo0926/monash-hub/pull/151">#151</a>、<a href="https://github.com/Waldo0926/monash-hub/pull/188">#188</a>。</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[一台 VPS，部署没人盯着。部署脚本怎么确认上线的是对的提交，不对时怎么退回上一版镜像。]]></summary></entry><entry xml:lang="en"><title type="html">How Monash Hub deploys itself and rolls back</title><link href="https://waldo0926.github.io/2026/10/24/deploy-rollback/" rel="alternate" type="text/html" title="How Monash Hub deploys itself and rolls back" /><published>2026-10-24T10:00:00+08:00</published><updated>2026-10-24T10:00:00+08:00</updated><id>https://waldo0926.github.io/2026/10/24/deploy-rollback</id><content type="html" xml:base="https://waldo0926.github.io/2026/10/24/deploy-rollback/"><![CDATA[<p><a href="https://monashhub.secureview.tech">Monash Hub</a> is a course information site I built for Monash students. It runs on one VPS: Docker Compose starts PostgreSQL, a FastAPI backend and a Nuxt frontend, with nginx in front. Merging into <code class="language-plaintext highlighter-rouge">main</code> deploys it.</p>

<p>I maintain it alone and I cannot watch every deploy. So the deploy script has to decide for itself whether a release worked, and put the previous version back when it did not. This post covers how it does that now, and the problems that pushed it there.</p>

<h2 id="what-went-wrong-before">What went wrong before</h2>

<p><strong>1. The code that went live was not the code CI tested.</strong> The first Deploy workflow only opened an SSH session and ran <code class="language-plaintext highlighter-rouge">deploy.sh</code>, which reset itself to <code class="language-plaintext highlighter-rouge">origin/main</code>. A commit pushed between CI finishing and the SSH session opening went live untested.</p>

<p><strong>2. The deploy script replaced itself.</strong> Bash reads a script while it runs it. When <code class="language-plaintext highlighter-rouge">deploy.sh</code> ran <code class="language-plaintext highlighter-rouge">git checkout</code> halfway through, the file on disk changed under it, and the remaining lines could come from the new file at the wrong offset.</p>

<p><strong>3. One moved page broke several deploys in a row.</strong> Monash moved its travel health and safety page from <code class="language-plaintext highlighter-rouge">study-abroad/overseas/</code> to <code class="language-plaintext highlighter-rouge">study-abroad/outbound/</code>. Registering official pages matched rows by URL, so it inserted a second row with the same slug and hit a unique constraint. Every deploy from #128 stopped at that step and production stayed on an older build until <a href="https://github.com/Waldo0926/monash-hub/pull/131">#131</a> fixed it.</p>

<p><strong>4. A green health check said little.</strong> The only check was a 200 from <code class="language-plaintext highlighter-rouge">/api/health</code>. An API that answers does not mean the pages render, or that the commit just deployed is the one running.</p>

<h2 id="the-flow-now">The flow now</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>CI passes → the Deploy workflow gets the commit CI tested
          → SSH to the VPS and run deploy.sh &lt;sha&gt;
             1. check the commit is on origin/main, check it out, exec the new copy of the script
             2. back up the database with pg_dump, kept for 14 days
             3. tag the running images :previous
             4. build, migrate, seed curated data, start
             5. wait for /api/health; roll back after about 60 seconds without it
             6. run smoke.sh on the commit and key pages; roll back if it fails
          → run smoke.sh again from GitHub Actions through the public hostname
</code></pre></div></div>

<h3 id="deploy-only-the-commit-ci-tested">Deploy only the commit CI tested</h3>

<p>The Deploy workflow is triggered by CI’s <code class="language-plaintext highlighter-rouge">workflow_run</code> event and passes the <code class="language-plaintext highlighter-rouge">head_sha</code> of that CI run, not whatever <code class="language-plaintext highlighter-rouge">main</code> points at by then. The script refuses a commit that is not on <code class="language-plaintext highlighter-rouge">origin/main</code>, so a manual run from another branch deploys nothing.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">!</span> git merge-base <span class="nt">--is-ancestor</span> <span class="s2">"</span><span class="nv">$DEPLOY_SHA</span><span class="s2">"</span> origin/main<span class="p">;</span> <span class="k">then
  </span><span class="nb">echo</span> <span class="s2">"error: </span><span class="nv">$DEPLOY_SHA</span><span class="s2"> is not on origin/main; only reviewed commits are deployed"</span> <span class="o">&gt;</span>&amp;2
  <span class="nb">exit </span>1
<span class="k">fi
</span>git checkout <span class="nt">-q</span> <span class="nt">--detach</span> <span class="s2">"</span><span class="nv">$DEPLOY_SHA</span><span class="s2">"</span>
<span class="nb">exec</span> <span class="s2">"</span><span class="nv">$PROJECT_DIR</span><span class="s2">/deployment/deploy.sh"</span> <span class="nt">--at</span> <span class="s2">"</span><span class="nv">$DEPLOY_SHA</span><span class="s2">"</span>
</code></pre></div></div>

<p>The last line fixes problem 2. After the checkout the old file is never read again: <code class="language-plaintext highlighter-rouge">exec</code> starts the script from the new commit, and <code class="language-plaintext highlighter-rouge">--at</code> tells it the checkout is done. One side effect: the deploy that shipped this change still ran the old script, which does not exec, so it took effect from the deploy after.</p>

<h3 id="keep-the-previous-images">Keep the previous images</h3>

<p>Before building, the running images are tagged <code class="language-plaintext highlighter-rouge">:previous</code>. Rolling back means moving the tags back and starting with <code class="language-plaintext highlighter-rouge">--no-build</code>, so nothing is rebuilt:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rollback<span class="o">()</span> <span class="o">{</span>
  <span class="k">for </span>service <span class="k">in </span>api web<span class="p">;</span> <span class="k">do
    if </span>docker image inspect <span class="s2">"monash-hub-</span><span class="nv">$service</span><span class="s2">:previous"</span> <span class="o">&gt;</span>/dev/null 2&gt;&amp;1<span class="p">;</span> <span class="k">then
      </span>docker tag <span class="s2">"monash-hub-</span><span class="nv">$service</span><span class="s2">:previous"</span> <span class="s2">"monash-hub-</span><span class="nv">$service</span><span class="s2">:latest"</span>
    <span class="k">fi
  done
  </span>docker compose <span class="nt">-p</span> monash-hub up <span class="nt">-d</span> <span class="nt">--no-build</span> api web
<span class="o">}</span>
</code></pre></div></div>

<h3 id="what-counts-as-success">What counts as success</h3>

<p><code class="language-plaintext highlighter-rouge">/api/health</code> now reports the commit the image was built from. <code class="language-plaintext highlighter-rouge">smoke.sh</code> checks what a visitor would see:</p>

<ul>
  <li>the commit the API reports is the one being deployed</li>
  <li>the home page renders in English and Chinese</li>
  <li>a unit page, a unit search and a search for WAM return data</li>
  <li><code class="language-plaintext highlighter-rouge">robots.txt</code> and the sitemap are there</li>
  <li>a missing unit and a missing path return 404, not 500</li>
</ul>

<p>It runs first on the VPS against the container ports and rolls back on failure. GitHub Actions then runs it through <code class="language-plaintext highlighter-rouge">monashhub.secureview.tech</code>, so the commit seen through nginx is checked too.</p>

<h2 id="what-a-rollback-does-not-undo">What a rollback does not undo</h2>

<p>A rollback swaps containers. It does not undo database migrations, so the old images run on the new schema, and migrations should work with both the old and the new code. When that is not enough, there is the backup from step 2.</p>

<p>The deploy also used to run two one-off data repairs that fetched two units from the Handbook and checked them. That made every deploy depend on Monash’s site being up, and the 2027 Handbook would have failed every deploy. They now live in a separate <code class="language-plaintext highlighter-rouge">verify-handbook-parser.sh</code>, and the deploy script only deploys.</p>

<h2 id="before-the-deploy">Before the deploy</h2>

<p>CI runs first: backend tests against a real PostgreSQL with a check that models and migrations agree, dependency audits, the frontend typecheck and build, and Playwright browser tests that go through the unit page, the course map and ratings at desktop and phone sizes. If CI fails, Deploy never starts.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>Name the deployed commit, and make it visible from the live site.</li>
  <li>Make rollback cheap. Keeping the old images is faster than rebuilding and does not depend on the network or package registries at that moment.</li>
  <li>Keep steps that depend on outside websites out of the deploy script.</li>
</ul>

<p>Code: <a href="https://github.com/Waldo0926/monash-hub/blob/main/deployment/deploy.sh">deploy.sh</a>, <a href="https://github.com/Waldo0926/monash-hub/blob/main/deployment/smoke.sh">smoke.sh</a>, <a href="https://github.com/Waldo0926/monash-hub/blob/main/.github/workflows/deploy.yml">deploy.yml</a>. Pull requests: <a href="https://github.com/Waldo0926/monash-hub/pull/151">#151</a>, <a href="https://github.com/Waldo0926/monash-hub/pull/188">#188</a>.</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[One VPS, no one watching the deploys. How the deploy script checks the right commit is live and puts the previous images back when it is not.]]></summary></entry></feed>