<?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://blog.secureview.tech/feed.xml" rel="self" type="application/atom+xml" /><link href="https://blog.secureview.tech/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-26T00:00:32+08:00</updated><id>https://blog.secureview.tech/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">只许大模型摘原句的问答机器人</title><link href="https://blog.secureview.tech/2026/10/25/quote-only-rag-zh/" rel="alternate" type="text/html" title="只许大模型摘原句的问答机器人" /><published>2026-10-25T19:00:00+08:00</published><updated>2026-10-25T19:00:00+08:00</updated><id>https://blog.secureview.tech/2026/10/25/quote-only-rag-zh</id><content type="html" xml:base="https://blog.secureview.tech/2026/10/25/quote-only-rag-zh/"><![CDATA[<p><a href="https://github.com/Waldo0926/mumguide-wechat-bot">马莫百科机器人</a>在微信上回答 Monash 马来西亚校区同学和家长的问题。大部分回答靠规则：我教过的问答、<a href="https://monashhub.secureview.tech">Monash Hub</a> 的接口，还有公众号自己的文章。答不上来的转给我。</p>

<p>规则会漏掉一些问题：答案其实就在这些资料里，只是说法不一样。所以我加了一个可选的 RAG 步骤，只在规则没命中或只有弱命中时才用。我给它定的规矩是：宁可说不知道，也不能说错流程。同学收到“我还答不上来，已经转给作者”，会再问一次；收到一个错的签证截止日期，是会照着去做的。</p>

<h2 id="让模型自己写不行">让模型自己写，不行</h2>

<p>第一版让免费模型根据检索到的片段自己组织回答。读起来很顺，但错在要命的地方。它会加上资料里没有的步骤，比如“老师会替你申请特殊考虑”；还把“ISP 在贴纸剩 14 天或更少时收护照”写成了“过期后有 14 天宽限期”。</p>

<p>我又试了用第二个模型检查第一个模型写的句子。它漏掉了一些，还删掉了一些对的。拿大模型去检查大模型，检查的那个也有同样的问题。</p>

<h2 id="模型只挑句子程序来核对">模型只挑句子，程序来核对</h2>

<p>所以模型不再写回答。提示词要求它从编号的资料里原样摘句子，一个字都不能改，最多 4 句，每行写成 <code class="language-plaintext highlighter-rouge">[资料编号] 原句</code>；资料里没有能回答的，就只回“这个我不确定”。</p>

<p>每一行都由程序核对：去掉空白后，摘的句子必须是它标的那段资料的子串，而且至少 6 个字。</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">sentence</span><span class="p">(</span><span class="n">quote</span><span class="p">,</span> <span class="n">h</span><span class="p">):</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">h</span><span class="p">.</span><span class="n">text</span>
    <span class="n">idx</span> <span class="o">=</span> <span class="p">[</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">c</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">text</span><span class="p">)</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">c</span><span class="p">.</span><span class="n">isspace</span><span class="p">()]</span>
    <span class="n">q</span> <span class="o">=</span> <span class="n">_squash</span><span class="p">(</span><span class="n">quote</span><span class="p">)</span>
    <span class="n">at</span> <span class="o">=</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">text</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">idx</span><span class="p">).</span><span class="n">find</span><span class="p">(</span><span class="n">q</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">q</span> <span class="ow">or</span> <span class="n">at</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">None</span>
    <span class="p">...</span>  <span class="c1"># 扩展到它所在的整句
</span></code></pre></div></div>

<p>改写过的句子、加出来的步骤、把真句子标错了资料编号，这三种都过不了。有一个测试专门喂这三种，要求一句都不发。通过的句子会扩展成它所在的整句，不会只发半句。回复下面列出句子来自哪些页面和链接，并注明是原文摘录。</p>

<p>模型说不确定，或者摘的句子一句都没通过，问题就转给我。这时规则的弱匹配（“你问的是不是…”）也不发：对另一个问题的瞎猜，比“已转给作者”更糟。</p>

<h2 id="真句子也可能误导人">真句子也可能误导人</h2>

<p>一字不差也不够。早期有一次回复摘了续签页面上的这句：</p>

<blockquote>
  <p>你必须在到期前四个月提交续签申请；并且</p>
</blockquote>

<p>页面上确实有这句话。但它写在“如果你没有满足上述任何一项要求：”下面，只适用于一部分同学。没有上面那一行，这句话就等于告诉所有人一个错的截止时间。</p>

<p>所以 <code class="language-plaintext highlighter-rouge">_why_not</code> 还会去掉离开上下文就会看错的句子：</p>

<ul>
  <li>标题以冒号结尾的列表项；</li>
  <li>以“；并且”或“：”结尾的行；</li>
  <li>以“此流程”开头的行，或者以“不，”开头、回答的是一个读者看不到的 FAQ 问题的行；</li>
  <li>没有表头的表格行；</li>
  <li>里面的年份全都已经过去的句子，比如去年的考试日期。</li>
</ul>

<p>教过的问答和 Hub 的 FAQ 是专门给这些同学写、核对过的，它们能回答时，不再在旁边混进一般网页的句子。以前就出现过一个旧页面写着“你必须选择参加考核”，旁边教过的问答说的却是可以不考。</p>

<h2 id="马来西亚不是澳洲">马来西亚不是澳洲</h2>

<p>来问的大多是马来西亚校区的同学，但 Monash 很多页面是写给澳洲校区的。Hub 的 FAQ 以前写过“休学会改变你的 CoE”。CoE 是澳洲学生签证的东西，对马来西亚的 Student Pass 没有意义。</p>

<p>现在每一句都按校区分类：来自澳洲校区页面的句子，或者提到 CoE、OSHC、ESOS、Centrelink 的句子，算澳洲；教过的问答、公众号文章和马来西亚校区页面算马来西亚。排序是马来西亚在前，各校区通用其次，澳洲最后。回复里只要有澳洲的句子，就加上小标题，澳洲的规定放在“澳洲校区（澳大利亚的规定，马来西亚校区不一定一样）”下面。签证、保险、修课量这些教过的问答也改成两个校区分开写；有个测试会检查，提到 CoE、OSHC 或 subclass 500 的回答，必须两个校区都标出来。</p>

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

<p>评测用固定的 50 道题，分别只用规则、用规则加 RAG 回答，由一个裁判模型对照参考要点打分。第八轮是 2026-10-25 跑的（<a href="https://github.com/Waldo0926/mumguide-wechat-bot/blob/main/docs/EVAL.md">docs/EVAL.md</a>）：</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>只用规则</th>
      <th>规则 + RAG</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>回答正确（裁判判）</td>
      <td>81%（34/42）</td>
      <td>88%（38/43）</td>
    </tr>
    <tr>
      <td>没有答案的题交给了我</td>
      <td>14%（1/7）</td>
      <td>86%（6/7）</td>
    </tr>
  </tbody>
</table>

<p>我最看重第二行。有 7 道题在所有资料里都没有答案。只用规则时，其中 6 道它都瞎猜了一个回答；加上 RAG 后，7 道里有 6 道转给了我。</p>

<p>第一行要打个折扣。裁判只看参考要点有没有出现，回答里多一个错的步骤，照样可能判对。这也是为什么核对放在程序里，而不是再交给一个模型。</p>

<p>它用的是 Groq 的免费额度（<code class="language-plaintext highlighter-rouge">qwen/qwen3.8-27b</code>），2026-10-24 起在线上打开。第八轮里 95% 的回复不到 1 秒，远低于微信 5 秒的限制。回答缓存 7 天，同样的问题不会再调模型。模型慢了或者挂了（免费额度同时来几个问题就会返回 429），教过的问答、公众号文章和 Hub FAQ 照常发；规则只有弱匹配的话就不猜，转给我。</p>

<h2 id="还做不到的">还做不到的</h2>

<p>核对能保证发出去的每一句确实在它链接的页面上，但保证不了那个页面就是这个问题该看的。检索拿到一段讲别的情况的资料，模型摘得一字不差，也还是答非所问。所以每条回复都附上页面链接、注明是摘录，答不上来的问题也还是会转给我。</p>

<p>代码：<a href="https://github.com/Waldo0926/mumguide-wechat-bot/blob/main/wxbot/rag.py"><code class="language-plaintext highlighter-rouge">rag.py</code></a>、<a href="https://github.com/Waldo0926/mumguide-wechat-bot/blob/main/tests/test_rag.py"><code class="language-plaintext highlighter-rouge">test_rag.py</code></a>、<a href="https://github.com/Waldo0926/mumguide-wechat-bot/blob/main/wxbot/evaluate.py"><code class="language-plaintext highlighter-rouge">evaluate.py</code></a>。</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[一个在微信上回答同学和家长签证、考试、选课问题的机器人。为什么里面的大模型只能摘原句，程序怎么逐句核对，以及哪些句子明明是真的也不发。]]></summary></entry><entry xml:lang="en"><title type="html">A Q&amp;amp;A bot where the model is only allowed to quote</title><link href="https://blog.secureview.tech/2026/10/25/quote-only-rag/" rel="alternate" type="text/html" title="A Q&amp;amp;A bot where the model is only allowed to quote" /><published>2026-10-25T19:00:00+08:00</published><updated>2026-10-25T19:00:00+08:00</updated><id>https://blog.secureview.tech/2026/10/25/quote-only-rag</id><content type="html" xml:base="https://blog.secureview.tech/2026/10/25/quote-only-rag/"><![CDATA[<p>The <a href="https://github.com/Waldo0926/mumguide-wechat-bot">马莫百科 bot</a> answers questions from Monash Malaysia students and their parents on WeChat. Most answers come from rules: Q&amp;As I taught it, the <a href="https://monashhub.secureview.tech">Monash Hub</a> API, and the account’s own articles. Anything it cannot answer is forwarded to me.</p>

<p>The rules miss questions whose answer is sitting in those same sources, worded differently. So I added an optional RAG step that runs only when the rules found nothing or only a weak match. The rule I set for it: better “不知道” than a wrong procedure. A student who is told “I don’t know, I’ve passed it on” asks again. A student who is told the wrong visa deadline acts on it.</p>

<h2 id="letting-the-model-write-did-not-work">Letting the model write did not work</h2>

<p>The first version let a free model answer in its own words from the retrieved passages. It read well and it was wrong in ways that matter. It added steps no passage has (“老师会替你申请特殊考虑”, the teacher will apply for special consideration for you). It turned “ISP 在贴纸剩 14 天或更少时收护照” (ISP collects passports when 14 days or fewer are left on the sticker) into “过期后有 14 天宽限期” (a 14-day grace period after expiry).</p>

<p>I tried a second model to check the first one’s sentences. It missed some of these and cut correct ones. A checker that is also a language model has the same problem as the writer.</p>

<h2 id="the-model-picks-sentences-the-code-checks-them">The model picks sentences, the code checks them</h2>

<p>So the model does not write anymore. The prompt asks it to copy sentences from the numbered passages without changing a character, at most four, one per line as <code class="language-plaintext highlighter-rouge">[passage number] sentence</code>, or to say “这个我不确定” when nothing answers the question.</p>

<p>Every line is then checked in code. The quote must be in the passage it names, with whitespace ignored, and at least six characters long:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">sentence</span><span class="p">(</span><span class="n">quote</span><span class="p">,</span> <span class="n">h</span><span class="p">):</span>
    <span class="n">text</span> <span class="o">=</span> <span class="n">h</span><span class="p">.</span><span class="n">text</span>
    <span class="n">idx</span> <span class="o">=</span> <span class="p">[</span><span class="n">i</span> <span class="k">for</span> <span class="n">i</span><span class="p">,</span> <span class="n">c</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">text</span><span class="p">)</span> <span class="k">if</span> <span class="ow">not</span> <span class="n">c</span><span class="p">.</span><span class="n">isspace</span><span class="p">()]</span>
    <span class="n">q</span> <span class="o">=</span> <span class="n">_squash</span><span class="p">(</span><span class="n">quote</span><span class="p">)</span>
    <span class="n">at</span> <span class="o">=</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">text</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">idx</span><span class="p">).</span><span class="n">find</span><span class="p">(</span><span class="n">q</span><span class="p">)</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">q</span> <span class="ow">or</span> <span class="n">at</span> <span class="o">&lt;</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="bp">None</span>
    <span class="p">...</span>  <span class="c1"># widen to the whole sentence it is in
</span></code></pre></div></div>

<p>A paraphrase, an added step, or a real sentence put on the wrong passage number all fail this check. A test feeds in exactly those three cases and expects nothing to be sent. What passes is widened to the whole sentence it sits in, so a half sentence never goes out on its own. The reply lists the pages the sentences come from, with links, and a line saying they are excerpts.</p>

<p>When the model says it is unsure, or none of its quotes survive, the question goes to me. A weak rule match (“你问的是不是…”) is held back too, because a confident guess about a different question is worse than “已转给作者”.</p>

<h2 id="a-true-sentence-can-still-mislead">A true sentence can still mislead</h2>

<p>Quoting exactly is not enough. One early reply quoted the renewal page:</p>

<blockquote>
  <p>你必须在到期前四个月提交续签申请；并且</p>
</blockquote>

<p>That sentence is on the page. But it sits under “如果你没有满足上述任何一项要求：” (if you meet none of the requirements above), so it only applies to some students. Without the line above it, the sentence tells everyone the wrong deadline.</p>

<p>So <code class="language-plaintext highlighter-rouge">_why_not</code> also drops sentences that need their surroundings to be read correctly:</p>

<ul>
  <li>a list item under a heading that ends in a colon,</li>
  <li>a line ending in “；并且” or “：”,</li>
  <li>a line starting with “此流程” (this process) or with the “不，” that answers an FAQ question the reader cannot see,</li>
  <li>a table row without its header row,</li>
  <li>a sentence whose years are all in the past, such as last year’s exam dates.</li>
</ul>

<p>Taught Q&amp;As and the Hub’s FAQ are written for these students, so when they answer, a general web page is not mixed in next to them. An old page once said “你必须选择参加考核” (you must sit the assessment) right next to a taught answer saying you can skip it.</p>

<h2 id="malaysia-is-not-australia">Malaysia is not Australia</h2>

<p>Most people who ask are at the Malaysia campus, but many Monash pages are written for Australia. The Hub’s FAQ once said intermission “会改变你的 CoE”. A CoE is part of the Australian student visa and means nothing on a Malaysian Student Pass.</p>

<p>Each quote is now labelled by campus. A sentence from an Australian page, or one that mentions CoE, OSHC, ESOS or Centrelink, counts as Australian. Taught Q&amp;As, the account’s articles and Malaysian pages count as Malaysian. Quotes are sorted Malaysia first, then all campuses, then Australia. When anything Australian is in the reply, the quotes get headings, so the Australian rule appears under “澳洲校区（澳大利亚的规定，马来西亚校区不一定一样）”. The taught Q&amp;As for visas, insurance and study load now give both campuses separately, and a test fails any answer that mentions CoE, OSHC or subclass 500 without both labels.</p>

<h2 id="how-well-it-does">How well it does</h2>

<p>The evaluation runs 50 fixed questions through the rules alone and through rules plus RAG. A judge model compares each reply with the expected points. Round 8, on 2026-10-25 (<a href="https://github.com/Waldo0926/mumguide-wechat-bot/blob/main/docs/EVAL.md">docs/EVAL.md</a>):</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>Rules only</th>
      <th>Rules + RAG</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Correct, marked by the judge</td>
      <td>81% (34/42)</td>
      <td>88% (38/43)</td>
    </tr>
    <tr>
      <td>No-answer questions left to me</td>
      <td>14% (1/7)</td>
      <td>86% (6/7)</td>
    </tr>
  </tbody>
</table>

<p>The second row is the one I care about. Seven questions have no answer in any source. The rules alone guessed at six of them. With RAG, six of the seven went to me.</p>

<p>The first row needs a warning. The judge only checks whether the expected points are there, so a reply with an extra wrong step can still be marked correct. That is the reason the quote check is in code and not in another model.</p>

<p>It runs on Groq’s free tier (<code class="language-plaintext highlighter-rouge">qwen/qwen3.8-27b</code>) and has been on in production since 2026-10-24. In round 8, 95% of replies took under a second, well inside WeChat’s 5-second limit. Answers are cached for seven days, so a repeated question does not call the model again. If the model is slow or down (the free tier answers 429 when several questions come at once), taught Q&amp;As, the account’s articles and the Hub’s FAQ still answer. When the rules only have a weak match, the bot does not guess and the question comes to me.</p>

<h2 id="what-it-still-cannot-do">What it still cannot do</h2>

<p>The check makes sure every sentence sent is really on the page it links to. It cannot make sure that page is the right one for the question. When retrieval picks a passage about another situation, the model can quote it correctly and still not answer what was asked. That is why every reply links its pages and says it is an excerpt, and why questions it cannot answer still come to me.</p>

<p>Code: <a href="https://github.com/Waldo0926/mumguide-wechat-bot/blob/main/wxbot/rag.py"><code class="language-plaintext highlighter-rouge">rag.py</code></a>, <a href="https://github.com/Waldo0926/mumguide-wechat-bot/blob/main/tests/test_rag.py"><code class="language-plaintext highlighter-rouge">test_rag.py</code></a>, <a href="https://github.com/Waldo0926/mumguide-wechat-bot/blob/main/wxbot/evaluate.py"><code class="language-plaintext highlighter-rouge">evaluate.py</code></a>.</p>]]></content><author><name>Shuoxun Wen</name></author><summary type="html"><![CDATA[A WeChat bot answers students and parents about visas, exams and enrolment. Why the language model in it may only quote sentences, how the code checks every quote, and which true sentences it still refuses to send.]]></summary></entry><entry xml:lang="zh-Hans"><title type="html">100 个用户，3% 的 CPU，大部分上传失败</title><link href="https://blog.secureview.tech/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://blog.secureview.tech/2026/10/24/secureview-pool-zh</id><content type="html" xml:base="https://blog.secureview.tech/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://blog.secureview.tech/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://blog.secureview.tech/2026/10/24/secureview-pool</id><content type="html" xml:base="https://blog.secureview.tech/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://blog.secureview.tech/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://blog.secureview.tech/2026/10/24/secureview-keys-zh</id><content type="html" xml:base="https://blog.secureview.tech/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://blog.secureview.tech/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://blog.secureview.tech/2026/10/24/secureview-keys</id><content type="html" xml:base="https://blog.secureview.tech/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. 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://blog.secureview.tech/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://blog.secureview.tech/2026/10/24/rating-abuse-zh</id><content type="html" xml:base="https://blog.secureview.tech/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://blog.secureview.tech/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://blog.secureview.tech/2026/10/24/rating-abuse</id><content type="html" xml:base="https://blog.secureview.tech/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://blog.secureview.tech/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://blog.secureview.tech/2026/10/24/translation-pipeline-zh</id><content type="html" xml:base="https://blog.secureview.tech/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://blog.secureview.tech/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://blog.secureview.tech/2026/10/24/translation-pipeline</id><content type="html" xml:base="https://blog.secureview.tech/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></feed>