<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="/feed.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>程序员项目库 · 涛哥的技术博客</title>
        <link>https://msnan.github.io</link>
        <description>分享可运行的实战项目源码与踩坑记录</description>
        <lastBuildDate>Fri, 16 Oct 2026 15:17:26 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>zh-CN</language>
        <copyright>© 2018-present 涛哥</copyright>
        <item>
            <title><![CDATA[给 Cursor / Claude 装上 uni-app 老手经验：开源 vue-uniapp-skills]]></title>
            <link>https://msnan.github.io/posts/vue-uniapp-skills</link>
            <guid>https://msnan.github.io/posts/vue-uniapp-skills</guid>
            <pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[12 个中文 AI 技能：条件编译、分包、登录、支付、401 请求封装、Pinia、发版审核清单。一行 npx 装进 Cursor / Claude Code / Trae / Codex。]]></description>
            <content:encoded><![CDATA[<p>AI 写 uni-app 代码时经常踩这些坑：用了 <code>axios</code> / <code>localStorage</code> / <code>div</code>，到小程序直接报错；忘了条件编译，H5 能跑、小程序白屏；401 一来，并发请求一起跳登录页；主包超过 2MB 传不上去。</p>
<p>我把这些踩坑经验写成 <strong>12 个中文 AI 技能包</strong>，开源成 <a href="https://github.com/MsNan/vue-uniapp-skills" target="_blank" rel="noreferrer">vue-uniapp-skills</a>，支持 Cursor Rules、Claude Code Skills、Trae Rules、Codex（<code>AGENTS.md</code>），一行命令装进项目。</p>
<h2 id="一行安装" tabindex="-1">一行安装 <a class="header-anchor" href="#一行安装" aria-label="Permalink to &quot;一行安装&quot;"></a></h2>
<p>在 uni-app 项目根目录执行：</p>
<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npx</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> -y</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> github:MsNan/vue-uniapp-skills</span></span></code></pre>
</div><p>只装某个工具，或只装部分技能：</p>
<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npx</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> -y</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> github:MsNan/vue-uniapp-skills</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> --tool</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> cursor</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npx</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> -y</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> github:MsNan/vue-uniapp-skills</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> --skills</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> uniapp-request,uniapp-cross-platform</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npx</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> -y</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> github:MsNan/vue-uniapp-skills</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> --list</span></span></code></pre>
</div><h2 id="_12-个技能覆盖什么" tabindex="-1">12 个技能覆盖什么 <a class="header-anchor" href="#_12-个技能覆盖什么" aria-label="Permalink to &quot;12 个技能覆盖什么&quot;"></a></h2>
<table tabindex="0">
<thead>
<tr>
<th>技能</th>
<th>解决什么</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>vue3-composition</code></td>
<td><code>&lt;script setup&gt;</code>、Composition API 写法</td>
</tr>
<tr>
<td><code>uniapp-project-structure</code></td>
<td>目录、命名、环境变量约定</td>
</tr>
<tr>
<td><code>uniapp-cross-platform</code></td>
<td>条件编译、跨端必知坑</td>
</tr>
<tr>
<td><code>uniapp-request</code></td>
<td><code>uni.request</code> 封装 + 401 只刷新一次</td>
</tr>
<tr>
<td><code>uniapp-login</code></td>
<td>微信 / H5 / App 多端登录</td>
</tr>
<tr>
<td><code>uniapp-payment</code></td>
<td>小程序 / App / H5 支付与订单确认</td>
</tr>
<tr>
<td><code>uniapp-subpackages</code></td>
<td>主包 2MB、分包与预下载</td>
</tr>
<tr>
<td><code>pinia-store</code></td>
<td>setup store、持久化到 uni storage</td>
</tr>
<tr>
<td><code>uniapp-ui-libs</code></td>
<td>uni-ui / uView Plus easycom</td>
</tr>
<tr>
<td><code>uniapp-permissions</code></td>
<td>定位相机等权限与隐私合规</td>
</tr>
<tr>
<td><code>uniapp-performance</code></td>
<td>长列表、图片、首屏</td>
</tr>
<tr>
<td><code>release-checklist</code></td>
<td>发版审核自查与常见驳回</td>
</tr>
</tbody>
</table>
<h2 id="技能长什么样-以请求封装为例" tabindex="-1">技能长什么样：以请求封装为例 <a class="header-anchor" href="#技能长什么样-以请求封装为例" aria-label="Permalink to &quot;技能长什么样：以请求封装为例&quot;"></a></h2>
<p>AI 最容易写错的是 401。多个接口同时 401 时，如果每个都去刷新 token、每个都跳登录页，用户会看到一串闪屏。技能里的写法是：<strong>并发 401 共享一个 <code>refreshing</code> Promise，刷新失败只跳一次登录</strong>。</p>
<div class="language-ts vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">ts</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">let</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> refreshing</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">:</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> Promise</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">&#x3C;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">boolean</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">> </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">|</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> null</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> null</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">async</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> refreshToken</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">()</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">:</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> Promise</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">&#x3C;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">boolean</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> user</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> useUserStore</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">()</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">user.refreshToken) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> false</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">  refreshing </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||=</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> new</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> Promise</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">&#x3C;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">boolean</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">>((</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">resolve</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">request</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">      url: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">BASE_URL</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '/auth/refresh'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">      method: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'POST'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">      data: { refreshToken: user.refreshToken },</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">      success</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">r</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">:</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> any</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r.statusCode </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 200</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> &#x26;&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r.data.code </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">          user.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">setToken</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(r.data.data)</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">          resolve</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">else</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> resolve</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">false</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">      },</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">      fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: () </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> resolve</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">false</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">),</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    })</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">  }).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">finally</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { refreshing </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> null</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> })</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> refreshing</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
</div><p>另外几条硬约束也会写进规则里：</p>
<ul>
<li>用 <code>uni.request</code>，不要用 axios（小程序没有 XHR，除非单独装适配器）</li>
<li>标签用 <code>view</code> / <code>text</code> / <code>image</code>，不用 <code>div</code> / <code>span</code> / <code>img</code></li>
<li>尺寸用 <code>rpx</code>，1px 边框单独用 <code>px</code></li>
<li>平台差异大时用条件编译，并给出降级方案</li>
</ul>
<h2 id="跨端条件编译怎么写" tabindex="-1">跨端条件编译怎么写 <a class="header-anchor" href="#跨端条件编译怎么写" aria-label="Permalink to &quot;跨端条件编译怎么写&quot;"></a></h2>
<div class="language-vue vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">vue</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">&#x3C;!-- #ifdef MP-WEIXIN --></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">&#x3C;</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D">button</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> open-type</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"getPhoneNumber"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> @</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">getphonenumber</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">onPhone</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">>手机号登录&#x3C;/</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D">button</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">></span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">&#x3C;!-- #endif --></span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">&#x3C;!-- #ifdef H5 || APP-PLUS --></span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">&#x3C;</span><span style="--shiki-light:#22863A;--shiki-dark:#85E89D">SmsLogin</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> /></span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">&#x3C;!-- #endif --></span></span></code></pre>
</div><p>JS 用 <code>// #ifdef</code>，CSS 用 <code>/* #ifdef */</code>。平台标识：<code>H5</code>、<code>MP-WEIXIN</code>、<code>APP-PLUS</code>、<code>MP</code>（所有小程序）。业务层尽量调统一接口，平台细节收到 <code>platform/</code> 里。</p>
<h2 id="适合谁" tabindex="-1">适合谁 <a class="header-anchor" href="#适合谁" aria-label="Permalink to &quot;适合谁&quot;"></a></h2>
<ul>
<li>用 Cursor / Claude Code / Trae / Codex 写 Vue3 + uni-app 的同学</li>
<li>接单、做毕设、维护小程序的人（审核清单和分包规则特别实用）</li>
<li>想让 AI「一开始就写对」，而不是生成完再改 20 分钟的人</li>
</ul>
<h2 id="相关链接" tabindex="-1">相关链接 <a class="header-anchor" href="#相关链接" aria-label="Permalink to &quot;相关链接&quot;"></a></h2>
<ul>
<li>源码：<a href="https://github.com/MsNan/vue-uniapp-skills" target="_blank" rel="noreferrer">github.com/MsNan/vue-uniapp-skills</a></li>
<li>同系列：<a href="https://github.com/MsNan/cn-dev-mcp" target="_blank" rel="noreferrer">cn-dev-mcp</a>（国内开发者离线 MCP 工具集）</li>
<li>博客项目页：<a href="https://msnan.github.io/" target="_blank" rel="noreferrer">msnan.github.io</a></li>
</ul>
<p>MIT 开源。有问题或想加新技能，直接提 Issue / PR。</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[用 Python + UI Automation 读写微信 PC 版：群消息自动回复的实现与踩坑]]></title>
            <link>https://msnan.github.io/posts/wechat-group-bot-ui-automation</link>
            <guid>https://msnan.github.io/posts/wechat-group-bot-ui-automation</guid>
            <pubDate>Fri, 16 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[基于 Windows UI Automation 读取微信 PC 版群消息，三层决策回复、多模型降级熔断与限流，附踩坑记录。]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>⚠️ <strong>先说风险</strong>：个人微信的第三方自动化违反《微信个人账号使用规范》，存在封号风险。本文仅供学习 Windows UI 自动化与消息处理的技术思路，<strong>请勿用于商业或骚扰用途，实验请用小号</strong>。</p>
</blockquote>
<h2 id="一、背景-为什么选-ui-自动化" tabindex="-1">一、背景：为什么选 UI 自动化 <a class="header-anchor" href="#一、背景-为什么选-ui-自动化" aria-label="Permalink to &quot;一、背景：为什么选 UI 自动化&quot;"></a></h2>
<p>想让程序读微信群消息，常见路子有三条：Hook 注入、协议逆向、UI 自动化。前两条风险高、维护成本也高，微信一升级就要重新逆向。UI 自动化走的是 Windows 给读屏软件准备的<strong>无障碍接口（UI Automation）</strong>，只&quot;看&quot;界面、&quot;按&quot;键盘，不碰进程内存，实现上最朴素。</p>
<p>代价也很明确：</p>
<ul>
<li>只能在 Windows 上跑，而且必须是<strong>已登录的交互式桌面</strong>（锁屏就读不到）；</li>
<li>强依赖微信客户端版本——实测 <strong>4.1.8.107</strong> 可用，<strong>4.1.13 起</strong>关闭了无障碍接口，任何 UI 自动化方案都读不到内容。</li>
</ul>
<p>整个项目只依赖一个开源库 <code>uiautomation</code>（Apache-2.0），读写微信界面的部分自己封装在 <code>wxui/</code> 里。</p>
<h2 id="二、wxui-把微信窗口-读-出来" tabindex="-1">二、wxui：把微信窗口&quot;读&quot;出来 <a class="header-anchor" href="#二、wxui-把微信窗口-读-出来" aria-label="Permalink to &quot;二、wxui：把微信窗口&quot;读&quot;出来&quot;"></a></h2>
<h3 id="_2-1-找主窗口-别只认一个类名" tabindex="-1">2.1 找主窗口：别只认一个类名 <a class="header-anchor" href="#_2-1-找主窗口-别只认一个类名" aria-label="Permalink to &quot;2.1 找主窗口：别只认一个类名&quot;"></a></h3>
<p>第一个坑：微信主窗口的 ClassName 不固定。同一台机器上实测出现过 <code>mmui::MainWindow</code> 和 <code>Qt51514QWindowIcon</code> 两种，重启后还会变。所以按&quot;类名 + 标题 + 尺寸&quot;多特征匹配：</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">MAIN_WINDOW_CLASSES</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"mmui::MainWindow"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"Qt51514QWindowIcon"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">MIN_MAIN_WIDTH</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 500</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">MIN_MAIN_HEIGHT</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 400</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">for</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> child </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">in</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> root.GetChildren():</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    cls</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> str</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(child.ClassName </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">or</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> ""</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    name </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> str</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(child.Name </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">or</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> ""</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    rect </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> child.BoundingRectangle</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> cls</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> not</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> in</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> MAIN_WINDOW_CLASSES</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        continue</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> name </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> "微信"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        continue</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    w, h </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> rect.width(), rect.height()</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> w </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x3C;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> MIN_MAIN_WIDTH</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> or</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> h </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x3C;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> MIN_MAIN_HEIGHT</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        continue</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   # 登录窗、托盘小窗也可能同类名，用尺寸排除</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> w </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">*</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> h </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> best_area:</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        best, best_area </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> child, w </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">*</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> h</span></span></code></pre>
</div><h3 id="_2-2-先判断-读不读得到-再谈别的" tabindex="-1">2.2 先判断&quot;读不读得到&quot;，再谈别的 <a class="header-anchor" href="#_2-2-先判断-读不读得到-再谈别的" aria-label="Permalink to &quot;2.2 先判断&quot;读不读得到&quot;，再谈别的&quot;"></a></h3>
<p>版本不对时，症状是&quot;窗口找得到，内容全是空的&quot;，非常迷惑。我用 <code>WM_GETOBJECT</code> 直接探测窗口有没有实现无障碍接口：</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">def</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> _has_accessibility</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(hwnd):</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    # 返回 0 表示完全没有 —— 实测微信 4.1.13.12 就是 0</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> int</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(ctypes.windll.user32.SendMessageW(</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        hwnd, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">_WM_GETOBJECT</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, ctypes.c_long(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">_OBJID_CLIENT</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">).value))</span></span></code></pre>
</div><p>返回 0 就直接抛 <code>AccessibilityUnavailable</code>，和&quot;微信没启动&quot;的 <code>WeChatNotRunning</code> 区分开：前者等多久都没用，后者可以等。<strong>把不同性质的失败分成不同异常</strong>，上层才能做对的决策。</p>
<h3 id="_2-3-新消息怎么算-最长公共后缀对齐" tabindex="-1">2.3 新消息怎么算：最长公共后缀对齐 <a class="header-anchor" href="#_2-3-新消息怎么算-最长公共后缀对齐" aria-label="Permalink to &quot;2.3 新消息怎么算：最长公共后缀对齐&quot;"></a></h3>
<p>UI 自动化只能拿到&quot;当前屏幕上可见的消息列表&quot;，没有消息 id。每轮轮询拿到一个列表，要算出&quot;本轮新增&quot;。</p>
<p>直觉做法是用集合去重，但那样&quot;同一个人连发两条『在吗』&quot;会被吞掉一条。最后用的是<strong>最长公共后缀对齐</strong>：</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">def</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> diff_new</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(previous, current):</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">    """</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">        previous:  A B C D</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">        current:      C D E F   -> 新增 E F</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">    刻意不用集合去重，所以「D D」这种重复内容能正确识别成两条。</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">    """</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    prev_keys </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> [m.key() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">for</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> m </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">in</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> previous]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    cur_keys </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> [m.key() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">for</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> m </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">in</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> current]</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    max_k </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> min</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">len</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(prev_keys), </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">len</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(cur_keys))</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    for</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> k </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">in</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> range</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(max_k, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">-</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">):</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> prev_keys[</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">-</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">k:] </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">==</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> cur_keys[:k]:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">            return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> current[k:]</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    # 完全对不上：可能切了会话或列表被整体刷新，交给上层重建基线</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> None</span></span></code></pre>
</div><p>对不上时返回 <code>None</code> 而不是&quot;全部当新消息&quot;——后者会导致一次性回复一大堆，相当于刷屏。</p>
<h3 id="_2-4-判断-是不是自己发的-看气泡颜色" tabindex="-1">2.4 判断&quot;是不是自己发的&quot;：看气泡颜色 <a class="header-anchor" href="#_2-4-判断-是不是自己发的-看气泡颜色" aria-label="Permalink to &quot;2.4 判断&quot;是不是自己发的&quot;：看气泡颜色&quot;"></a></h3>
<p>不识别自己的消息，机器人就会自问自答进入死循环。UIA 树里没有可靠的&quot;发送方&quot;字段，位置判断又受窗口宽度和缩放影响，最终方案是<strong>沿消息行中线横扫像素，找最宽的非背景色块（即气泡），按颜色判定</strong>：</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> best </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">is</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> None</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> or</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> best[</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">] </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x3C;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 30</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> None</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">color </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> best[</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">]</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> _rgb_close(color, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">SELF_BUBBLE_RGB</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">):</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> True</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> _rgb_close(color, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">PEER_BUBBLE_RGB</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">):</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> False</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> None</span></span></code></pre>
</div><p>关键约定：<strong>判不出来返回 <code>None</code>，业务层把 <code>None</code> 当成&quot;不可回复&quot;</strong>，绝不默认成&quot;别人发的&quot;。另一个踩过的坑是视口边缘只露出半截的消息，行中线落在可见区外，扫出来是空的——所以采样 y 要先夹到列表可见范围内。</p>
<h3 id="_2-5-发送-宁可不发-绝不发错群" tabindex="-1">2.5 发送：宁可不发，绝不发错群 <a class="header-anchor" href="#_2-5-发送-宁可不发-绝不发错群" aria-label="Permalink to &quot;2.5 发送：宁可不发，绝不发错群&quot;"></a></h3>
<p>发错群是最严重的事故，所以 <code>send_text</code> 做了三道确认：</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">cur </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.current_chat()</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> cur </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> expect_chat:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    raise</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> TargetMismatch(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"发送前复核不通过 ..."</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">uia.SetClipboardText(text)          </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D"># 中文用剪贴板粘贴，SendKeys 打中文不可靠</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">box.SendKeys(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">{Ctrl}</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">a"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">waitTime</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0.05</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">box.SendKeys(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">{Ctrl}</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">v"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">waitTime</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0.15</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">cur2 </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.current_chat()          </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D"># 输入过程中焦点可能被别的窗口抢走</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> cur2 </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> expect_chat:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    raise</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> TargetMismatch(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"输入完成后复核不通过 ..."</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">box.SendKeys(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">{Enter}</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">waitTime</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0.1</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">verified </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._verify_sent(text, verify_timeout)   </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D"># 回读界面确认</span></span></code></pre>
</div><p>最后一步 <code>_verify_sent</code> 会在几秒内回读最近的&quot;自己发的消息&quot;，确认内容真的出现了。<strong>&quot;调用没报错&quot;不等于发出去了</strong>，这一步不能省。</p>
<h2 id="三、三层回复决策链" tabindex="-1">三、三层回复决策链 <a class="header-anchor" href="#三、三层回复决策链" aria-label="Permalink to &quot;三、三层回复决策链&quot;"></a></h2>
<p>收到一条消息后，按顺序走三层，命中即停：</p>
<table tabindex="0">
<thead>
<tr>
<th>层</th>
<th>作用</th>
<th>成本</th>
</tr>
</thead>
<tbody>
<tr>
<td>1. 业务指令 <code>handlers.py</code></td>
<td>查自己的系统（HTTP / MySQL）拿真实数据</td>
<td>零</td>
</tr>
<tr>
<td>2. 关键词规则 <code>RULES</code></td>
<td>固定话术，秒回</td>
<td>零</td>
</tr>
<tr>
<td>3. AI 兜底 <code>ai_provider.py</code></td>
<td>开放式对话，默认只在被 @ 时触发</td>
<td>按 token</td>
</tr>
</tbody>
</table>
<p>为什么业务指令必须排在 AI 前面？**订单号、库存、金额这类有唯一正确答案的问题，交给大模型它会编，而且编得很像真的。**所以这类问题先由业务指令拦下：</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">result </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> handlers.route(sender, content, group, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">api</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">API</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">db</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">DB</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">store</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">STORE</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> isinstance</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(result, handlers.AIAnswer):</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    # 查到真实数据了，让 AI 只负责"用人话组织一下"</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> AI</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> and</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> AI</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.enabled:</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        text </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> _ai_reply(trace, sender, content, group,</span></span>
<span class="line"><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">                         extra_context</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">result.as_context())</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> text:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">            return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> text, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"业务指令+AI播报"</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">    # AI 不可用就把原始数据直接给出去，总比不回强</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> result.as_context(), </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"业务指令(AI不可用,直出数据)"</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> isinstance</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(result, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">str</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">and</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> result:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> result, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"业务指令"</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">text </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> match_rules(sender, content, group)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> text:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> text, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"关键词规则"</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> AI</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> and</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> AI</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.enabled:</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    ...</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">  # 第 3 层：AI 兜底</span></span></code></pre>
</div><p>&quot;业务指令 + AI 播报&quot;模式下，system prompt 里会硬性追加：</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">sys_text </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">+=</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> f</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">\n\n</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">【以下是系统查询到的真实数据，回答必须基于它，不要编造】</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">\n{</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">extra_context</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">}</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span></span></code></pre>
</div><p>AI 只负责措辞，不负责事实。</p>
<h2 id="四、多模型降级与熔断" tabindex="-1">四、多模型降级与熔断 <a class="header-anchor" href="#四、多模型降级与熔断" aria-label="Permalink to &quot;四、多模型降级与熔断&quot;"></a></h2>
<p>AI 层支持配置多个 OpenAI 兼容接口（智谱、DeepSeek、通义等），按顺序尝试，一个失败就换下一个：</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">for</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> p </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">in</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.providers:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> not</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> p.available:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        continue</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    text </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> p.chat(messages)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> text:</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">        self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.memory.add(group, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"user"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">f</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">{</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">sender</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">}</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">{</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">content</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">}</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">        self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.memory.add(group, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"assistant"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, text)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> text</span></span>
<span class="line"></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">log.warning(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"[AI] 所有模型都失败了，降级不回复"</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> None</span></span></code></pre>
</div><p>单个 provider 连续失败 3 次就熔断 120 秒，避免每条消息都去撞一个已经挂掉的接口：</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">def</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> _mark_fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(self, reason):</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">    self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._fails </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">+=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 1</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._fails </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">>=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 3</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">:</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">        self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._blocked_until </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> time.time() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">+</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 120</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">        self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._fails </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span></span></code></pre>
</div><p>几个兼容性细节：</p>
<ul>
<li>有的厂商 <code>base_url</code> 自带 <code>/v1</code>，有的不带，<code>_endpoint()</code> 统一拼成 <code>.../chat/completions</code>；</li>
<li>智谱 GLM-4.5 起的&quot;思考模式&quot;会把输出吃光、<code>content</code> 返回空，需要传关闭开关；但别的厂商传了这个字段会直接 400，所以按&quot;厂商 + 模型名&quot;双重判定，<strong>宁可不传也不要报错</strong>；</li>
<li>会话记忆按群隔离，只保留最近几轮，控制 token 成本。</li>
</ul>
<h2 id="五、降低封号风险的几道闸" tabindex="-1">五、降低封号风险的几道闸 <a class="header-anchor" href="#五、降低封号风险的几道闸" aria-label="Permalink to &quot;五、降低封号风险的几道闸&quot;"></a></h2>
<p>再强调一次：这些措施只能<strong>降低</strong>风险，不能消除。</p>
<div class="language-python vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">python</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">def</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> check</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(self, sender, group):</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    while</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._hour_window </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">and</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> now </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">-</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._hour_window[</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">] </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">></span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 3600</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">:</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">        self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._hour_window.popleft()</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> len</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._hour_window) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">>=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> config.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">MAX_REPLIES_PER_HOUR</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> False</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">f</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"已达每小时上限 </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">{</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">config.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">MAX_REPLIES_PER_HOUR}</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> 条"</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> now </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">-</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._sender_last.get(sender, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x3C;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> config.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">PER_SENDER_COOLDOWN</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> False</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">f</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"对 </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">{</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">sender</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">}</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> 冷却中"</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> now </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">-</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> self</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">._group_last.get(group, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x3C;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> config.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">PER_GROUP_COOLDOWN</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">:</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> False</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">f</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">"群 </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">{</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">group</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">}</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> 冷却中"</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> True</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">""</span></span></code></pre>
</div><ul>
<li><strong>每小时上限</strong>：滑动窗口计数；</li>
<li><strong>同人冷却 / 同群冷却</strong>：避免短时间对同一人、同一群连续回复；</li>
<li><strong>随机延迟</strong>：<code>random.uniform(*config.REPLY_DELAY_RANGE)</code>，不在收到消息的同一秒回复；</li>
<li><strong>连续发送失败保护</strong>：连续失败到阈值进入冷静期，期间只收不发，不在微信异常时反复操作。</li>
</ul>
<p>注意 <code>check</code> 和 <code>commit</code> 是分开的：只有<strong>真正发送成功</strong>才记入冷却和计数，失败的尝试不占额度。</p>
<h2 id="六、踩过的坑汇总" tabindex="-1">六、踩过的坑汇总 <a class="header-anchor" href="#六、踩过的坑汇总" aria-label="Permalink to &quot;六、踩过的坑汇总&quot;"></a></h2>
<ol>
<li><strong>内容指纹去重误杀</strong>：最初在 id 去重之外又叠了一层&quot;内容指纹去重&quot;，结果用户故意连发两条&quot;在吗&quot;，第二条被吞了。后来改为：适配层已经按序列对齐分配稳定 id 时，关掉指纹去重。</li>
<li><strong>去重有效期不能永久</strong>：否则同一个人今天说&quot;在吗&quot;、明天再说&quot;在吗&quot;，第二次永远收不到回复。</li>
<li><strong>@ 功能放弃了</strong>：微信的 @ 需要从候选列表里挑人，无法在发送前确认挑对了，干脆改成在正文里带称呼。</li>
<li><strong>昵称不稳定</strong>：多条消息同一瞬间到达时，只有最后一条能拿到发送者昵称，其余只能显示&quot;群成员&quot;。依赖严格白名单的场景要提前评估。</li>
<li><strong>微信会静默自动升级</strong>：这是故障复发的头号原因，升级到 4.1.13+ 就彻底读不到。部署时必须堵掉自动更新，启动时也会打印当前微信版本用于排查。</li>
<li><strong>剪贴板被覆盖</strong>：中文用剪贴板粘贴发送，会覆盖系统剪贴板，挂机的机器上要知道这一点。</li>
</ol>
<h2 id="七、小结" tabindex="-1">七、小结 <a class="header-anchor" href="#七、小结" aria-label="Permalink to &quot;七、小结&quot;"></a></h2>
<p>这个项目最大的体会是：<strong>UI 自动化的难点不在&quot;能不能读到&quot;，而在&quot;读错了怎么办&quot;。</strong> 判不出是谁发的就不回、会话对不上就不发、发了要回读确认、AI 不知道的事不让它答——每一处都选择保守，代价是偶尔漏回，换来的是不发错、不刷屏、不胡说。</p>
<p>再次提醒：个人微信自动化有封号风险，仅供学习交流，请用小号测试。</p>
<hr>
<p>完整源码开源在 GitHub <a href="https://github.com/MsNan/wechat-group-bot" target="_blank" rel="noreferrer">https://github.com/MsNan/wechat-group-bot</a></p>
<p>顺带一提，作者之前还开源了一个 uni-app 商城项目，有兴趣可以看看：<a href="https://github.com/MsNan/taohaowu-shop" target="_blank" rel="noreferrer">https://github.com/MsNan/taohaowu-shop</a></p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Express + JWT 实现前台用户与管理后台双角色鉴权（uni-app 商城实战）]]></title>
            <link>https://msnan.github.io/posts/express-jwt-dual-role-auth</link>
            <guid>https://msnan.github.io/posts/express-jwt-dual-role-auth</guid>
            <pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[同一 JWT 密钥、不同载荷，实现前台用户与管理后台互不越权，附前端请求封装与 401 处理的坑。]]></description>
            <content:encoded><![CDATA[<p>做商城类项目时，前台用户和管理后台通常是两套登录体系：用户用手机号验证码登录，要求长时间保持登录；管理员用账号密码登录，权限大，登录态要短。本文结合一个 uni-app + Express 的商城项目，拆解它是如何用<strong>同一个 JWT 密钥</strong>实现两种角色互不越权的，以及前端请求封装、401 处理中容易踩的坑。</p>
<p>技术栈：后端 Express + jsonwebtoken + lowdb；前台 uni-app（Vue 3 + Pinia）；管理后台为原生 JS 单页。</p>
<h2 id="一、整体思路" tabindex="-1">一、整体思路 <a class="header-anchor" href="#一、整体思路" aria-label="Permalink to &quot;一、整体思路&quot;"></a></h2>
<table tabindex="0">
<thead>
<tr>
<th>角色</th>
<th>登录方式</th>
<th>Token 载荷</th>
<th>有效期</th>
<th>校验中间件</th>
</tr>
</thead>
<tbody>
<tr>
<td>前台用户</td>
<td>手机号 + 验证码</td>
<td><code>{ uid }</code></td>
<td>30 天</td>
<td><code>auth</code></td>
</tr>
<tr>
<td>管理员</td>
<td>账号 + 密码</td>
<td><code>{ admin }</code></td>
<td>12 小时</td>
<td><code>guard</code></td>
</tr>
</tbody>
</table>
<p>关键点在于：<strong>两类 Token 用同一个 SECRET 签发，但载荷字段不同，各自的中间件只认自己的字段</strong>。这样即使用户拿着自己的 Token 去请求 <code>/api/admin/*</code>，也会因为没有 <code>admin</code> 字段被拒绝；反过来管理员 Token 没有 <code>uid</code>，也调不了用户接口。</p>
<h2 id="二、密钥配置-开发与生产区分" tabindex="-1">二、密钥配置：开发与生产区分 <a class="header-anchor" href="#二、密钥配置-开发与生产区分" aria-label="Permalink to &quot;二、密钥配置：开发与生产区分&quot;"></a></h2>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> SECRET</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> process.env.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">JWT_SECRET</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> ||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (process.env.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">NODE_ENV</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> ===</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> 'production'</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> ?</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">await</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'node:crypto'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">randomBytes</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">32</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">toString</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'hex'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">:</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> 'taohaowu-dev-secret'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span></code></pre>
</div><p>这一行做了三层兜底：</p>
<ol>
<li>优先读环境变量 <code>JWT_SECRET</code>；</li>
<li>生产环境没配置时，启动时随机生成 32 字节密钥，避免使用人人都知道的默认值；</li>
<li>开发环境用固定字符串，方便重启后 Token 仍然有效。</li>
</ol>
<p>注意第 2 点的副作用：<strong>服务每次重启，所有已签发的 Token 都会失效</strong>。所以生产环境一定要显式配置 <code>JWT_SECRET</code>，随机密钥只是安全兜底，不是推荐用法。</p>
<h2 id="三、前台用户-签发与校验" tabindex="-1">三、前台用户：签发与校验 <a class="header-anchor" href="#三、前台用户-签发与校验" aria-label="Permalink to &quot;三、前台用户：签发与校验&quot;"></a></h2>
<h3 id="_1-登录签发" tabindex="-1">1. 登录签发 <a class="header-anchor" href="#_1-登录签发" aria-label="Permalink to &quot;1. 登录签发&quot;"></a></h3>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">post</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'/api/auth/login'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">async</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">phone</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">code</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> req.body</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> c</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.data.codes[phone]</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">c </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> c.code </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!==</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> String</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(code) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> Date.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">now</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> c.exp) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'验证码错误或已过期'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  delete</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.data.codes[phone]</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  let</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> u </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.data.users.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">find</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">u</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> u.phone </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> phone)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">u) { u </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { id: </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">nid</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(), phone, nickname: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'淘友'</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> phone.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">slice</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">-</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">4</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">), createdAt: Date.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">now</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() }; db.data.users.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">push</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(u) }</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (u.disabled) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'账号已被禁用'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">403</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">write</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">()</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">  ok</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, { token: jwt.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">sign</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">({ uid: u.id }, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">SECRET</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, { expiresIn: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'30d'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> }), user: u })</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">})</span></span></code></pre>
</div><p>几个细节值得注意：</p>
<ul>
<li>验证码校验通过后立即 <code>delete</code>，保证<strong>一码一用</strong>，防止重放；</li>
<li><code>String(code)</code> 统一类型，避免前端传数字导致 <code>!==</code> 判断失败；</li>
<li>首次登录自动注册，登录和注册合并成一个接口；</li>
<li>载荷里只放 <code>uid</code>，<strong>不放手机号、昵称等信息</strong>。JWT 只是 Base64 编码而不是加密，任何人都能解出载荷内容。</li>
</ul>
<h3 id="_2-校验中间件" tabindex="-1">2. 校验中间件 <a class="header-anchor" href="#_2-校验中间件" aria-label="Permalink to &quot;2. 校验中间件&quot;"></a></h3>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> auth</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> t</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (req.headers.authorization </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> ''</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">replace</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">/</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">^</span><span style="--shiki-light:#032F62;--shiki-dark:#DBEDFF">Bearer </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">/</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">''</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  try</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { req.uid </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> jwt.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">verify</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(t, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">SECRET</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">).uid; </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">req.uid </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.data.users.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">find</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">u</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> u.id </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> req.uid)?.disabled) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">throw</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">; </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">catch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'请先登录'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">401</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
</div><p>这个中间件把所有失败情况都收敛到一个 <code>catch</code> 里，统一返回 401：</p>
<ul>
<li>没带 Token / 格式错 / 签名不对 / 已过期 —— <code>jwt.verify</code> 直接抛错；</li>
<li>Token 合法但没有 <code>uid</code>（比如拿管理员 Token 来调）—— 手动 <code>throw 0</code>；</li>
<li>用户已被后台禁用 —— 同样 <code>throw 0</code>。</li>
</ul>
<p>最后一点很重要：<strong>JWT 本身是无状态的，签出去就无法撤回</strong>。30 天有效期内如果用户被封禁，单靠 <code>jwt.verify</code> 是拦不住的。这里每次请求都去查一下用户的 <code>disabled</code> 标记，用一次轻量查询换来了&quot;即时封禁&quot;的能力。</p>
<p>使用时直接挂在路由上：</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">get</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'/api/me'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, auth, (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> ok</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, db.data.users.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">find</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">u</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> u.id </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> req.uid)))</span></span></code></pre>
</div><h2 id="四、管理后台-签发与统一守卫" tabindex="-1">四、管理后台：签发与统一守卫 <a class="header-anchor" href="#四、管理后台-签发与统一守卫" aria-label="Permalink to &quot;四、管理后台：签发与统一守卫&quot;"></a></h2>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> safeEq</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">a</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">b</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> x</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> Buffer.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">from</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">String</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(a)), </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">y</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> Buffer.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">from</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">String</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(b)); </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> x.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">length</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> ===</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> y.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">length</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> &#x26;&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> crypto.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">timingSafeEqual</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(x, y) }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">post</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">A</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '/login'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">username</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">password</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> req.body </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {}</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">safeEq</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(username, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">ADMIN_USER</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> !</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">safeEq</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(password, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">ADMIN_PASSWORD</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'账号或密码错误'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">401</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">  ok</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, { token: jwt.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">sign</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">({ admin: username }, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">SECRET</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, { expiresIn: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'12h'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> }), edition: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">EDITION</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> })</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">})</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> guard</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  try</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> p</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> jwt.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">verify</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">((req.headers.authorization </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> ''</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">replace</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">/</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">^</span><span style="--shiki-light:#032F62;--shiki-dark:#DBEDFF">Bearer </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">/</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">''</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">), </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">SECRET</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">); </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">p.admin) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">throw</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">; </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">catch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'请先登录后台'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">401</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">use</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">A</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (req.path </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '/login'</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> ?</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">:</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> guard</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(req, res, next)))</span></span></code></pre>
</div><p>和用户侧相比有三处不同：</p>
<p><strong>1. 用 <code>timingSafeEqual</code> 比较密码。</strong> 普通的 <code>===</code> 在遇到第一个不同字符时就返回，攻击者理论上可以通过响应时间差逐位猜测。<code>crypto.timingSafeEqual</code> 是常量时间比较，但它要求两个 Buffer 长度一致，否则直接抛异常，所以前面先判断了 <code>x.length === y.length</code>。</p>
<p><strong>2. 有效期只有 12 小时。</strong> 后台权限大，Token 泄露的代价远高于普通用户，缩短有效期是最简单的降低风险的方式。</p>
<p><strong>3. 用前缀中间件统一守卫。</strong> 不必在每个后台路由上手动写 <code>guard</code>，而是 <code>app.use('/api/admin', ...)</code> 一次性拦截，只放行 <code>/login</code>。新增后台接口时不会因为忘记加中间件而裸奔，这比逐个路由挂中间件更不容易出错。</p>
<p>此外，管理员密码也做了和 SECRET 类似的兜底：</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">ADMIN_PASSWORD</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (process.env.</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">NODE_ENV</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> ===</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> 'production'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">ADMIN_PASSWORD</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> crypto.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">randomBytes</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">9</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">toString</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'base64url'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">); console.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">warn</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'[admin] 未设置 ADMIN_PASSWORD，本次随机密码：'</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> ADMIN_PASSWORD</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) }</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  else</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">ADMIN_PASSWORD</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> 'admin123'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">; console.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">warn</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'[admin] 开发模式默认账号 admin / admin123，生产环境请设置 ADMIN_PASSWORD'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
</div><p>生产环境绝不使用弱口令默认值，这是很多开源项目上线后被入侵的常见原因。</p>
<h2 id="五、uni-app-请求封装-自动携带-jwt" tabindex="-1">五、uni-app 请求封装：自动携带 JWT <a class="header-anchor" href="#五、uni-app-请求封装-自动携带-jwt" aria-label="Permalink to &quot;五、uni-app 请求封装：自动携带 JWT&quot;"></a></h2>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> request</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">url</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, { </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">method</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> 'GET'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">data</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {}) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> token</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">getStorageSync</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'token'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  return</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> new</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> Promise</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">((</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">resolve</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">reject</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">request</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">({</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">      url: </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">API_BASE</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '/api'</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> url, method, data,</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">      header: token </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">?</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { Authorization: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'Bearer '</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> token } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {},</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">      success</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">r</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r.statusCode </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 200</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> &#x26;&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r.data.code </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> resolve</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(r.data.data)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> msg</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r.data?.message </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '请求失败'</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">        if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r.statusCode </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 401</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) { uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">removeStorageSync</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'token'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">        uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">showToast</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">({ title: msg, icon: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'none'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> }); </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">reject</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">new</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> Error</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(msg))</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">      },</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">      fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">e</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">showToast</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">({ title: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'网络开小差了'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, icon: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'none'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> }); </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">reject</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(e) }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    })</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">  })</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
</div><p>要点：</p>
<ul>
<li><strong>每次请求时才读取 Token</strong>，而不是在模块顶部读一次缓存起来。否则登录后旧模块里的变量还是空值，要刷新页面才生效。</li>
<li>没有 Token 时不发送 <code>Authorization</code> 头，避免后端收到 <code>Bearer undefined</code>。</li>
<li><code>uni.request</code> 的 <code>success</code> 回调<strong>只要服务器有响应就会触发，包括 4xx/5xx</strong>，只有网络层失败才进 <code>fail</code>。所以必须同时判断 <code>statusCode</code> 和业务码 <code>code</code>。</li>
<li>统一解包 <code>r.data.data</code>，业务代码拿到的直接就是数据。</li>
</ul>
<p>登录成功后在 Pinia 中写入 Token：</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">async </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">login</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(phone, code) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> r</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> api.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">login</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(phone, code)</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">  this</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.token </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r.token; </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">this</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.user </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r.user</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">  uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">setStorageSync</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'token'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, r.token); uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">setStorageSync</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'user'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, r.user)</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">  // ...合并游客购物车</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">},</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">requireLogin</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">this</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.token) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> true</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">  uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">navigateTo</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">({ url: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'/pages/login/login'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> }); </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> false</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
</div><h2 id="六、管理后台的-401-处理" tabindex="-1">六、管理后台的 401 处理 <a class="header-anchor" href="#六、管理后台的-401-处理" aria-label="Permalink to &quot;六、管理后台的 401 处理&quot;"></a></h2>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">async</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> api</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">url</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">opt</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {}) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> r</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> await</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> fetch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'/api/admin'</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> url, { method: opt.method </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> 'GET'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, headers: { </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'Content-Type'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'application/json'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(token </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">?</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { Authorization: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'Bearer '</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> token } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">:</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {}) }, body: opt.body </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">&#x26;&#x26;</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> JSON</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">stringify</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(opt.body) })</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> j</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> r.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">json</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">().</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">catch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> ({}))</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r.status </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 401</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> &#x26;&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> url </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!==</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '/login'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) { </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">logout</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(); </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">throw</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> new</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> Error</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'登录已过期'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) }</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (j.code </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!==</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) { </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">toast</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(j.message </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '请求失败'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">); </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">throw</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> new</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> Error</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(j.message) }</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  return</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> j.data</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
</div><p>注意 <code>url !== '/login'</code> 这个条件：登录接口密码错误时也返回 401，如果不排除，用户输错密码会被当成&quot;登录过期&quot;，提示信息就错了。这是 401 统一处理时非常常见的一个坑。</p>
<h2 id="七、常见坑总结" tabindex="-1">七、常见坑总结 <a class="header-anchor" href="#七、常见坑总结" aria-label="Permalink to &quot;七、常见坑总结&quot;"></a></h2>
<ol>
<li>
<p><strong>用户 Token 越权调用后台接口。</strong> 两套 Token 共用 SECRET 时，中间件必须校验角色字段（本文的 <code>uid</code> / <code>admin</code>），只校验签名等于没有角色隔离。更严格的做法是两个角色使用不同的密钥。</p>
</li>
<li>
<p><strong>封禁后 Token 依然可用。</strong> JWT 无法主动撤销，需要在中间件里查一次用户状态，或者维护黑名单。</p>
</li>
<li>
<p><strong>生产环境沿用默认密钥 / 默认密码。</strong> 一旦源码公开，等于把签名密钥公开，任何人都能伪造管理员 Token。</p>
</li>
<li>
<p><strong>401 只清了 Storage，没清内存状态。</strong> 本文的 uni-app 封装在 401 时执行了 <code>uni.removeStorageSync('token')</code>，但 Pinia 里的 <code>this.token</code> 还在，<code>requireLogin()</code> 仍会返回 true，页面会显示已登录。修正方法是在 401 时同时调用 store 的 <code>logout()</code>。由于 store 本身也 import 了 request.js，两者存在循环引用，所以 <code>useShopStore()</code> 要放在回调内部调用，而不是在模块顶层执行：</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">import</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { useShopStore } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">from</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '../store'</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">// ...</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (r.statusCode </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 401</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) { uni.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">removeStorageSync</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'token'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">); </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">try</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">useShopStore</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">().</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">logout</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">catch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (e) {} }</span></span></code></pre>
</div></li>
<li>
<p><strong>登录接口自身的 401 被误判为过期</strong>，如第六节所示，需要排除登录地址。</p>
</li>
<li>
<p><strong>把敏感信息放进载荷。</strong> JWT 载荷可被任意解码，只放 ID 和角色标识。</p>
</li>
<li>
<p><strong><code>uni.request</code> 的 <code>success</code> 不等于成功。</strong> 一定要判断 <code>statusCode</code>。</p>
</li>
</ol>
<h2 id="八、小结" tabindex="-1">八、小结 <a class="header-anchor" href="#八、小结" aria-label="Permalink to &quot;八、小结&quot;"></a></h2>
<p>这套方案的核心可以归纳为三句话：同一密钥、不同载荷、中间件只认自己的字段；有效期按权限大小区分；前端在请求层统一携带 Token、统一处理 401。对于中小型项目，这样已经能满足大部分双角色场景。如果后续角色增多，可以把载荷改为 <code>{ sub, role }</code>，再写一个 <code>requireRole('admin')</code> 的中间件工厂来扩展。</p>
<p>完整代码见 GitHub：MsNan/taohaowu-shop</p>
]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[从 0 到 1 做一个带管理后台的 uni-app 商城（附免费源码）]]></title>
            <link>https://msnan.github.io/posts/uni-app-mall-from-0-to-1</link>
            <guid>https://msnan.github.io/posts/uni-app-mall-from-0-to-1</guid>
            <pubDate>Wed, 14 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[一套 uni-app 代码跑 H5、小程序和 App，后端 Express，自带 /admin 管理后台，按真实代码讲架构与关键实现。]]></description>
            <content:encoded><![CDATA[<blockquote>
<p>一套 uni-app 代码跑 H5、小程序和 App，后端 Node.js + Express，自带 <code>/admin</code> 管理后台，开源免费。本文按真实代码讲技术栈、架构和几段关键实现。</p>
</blockquote>
<h2 id="一、技术栈" tabindex="-1">一、技术栈 <a class="header-anchor" href="#一、技术栈" aria-label="Permalink to &quot;一、技术栈&quot;"></a></h2>
<table tabindex="0">
<thead>
<tr>
<th>层</th>
<th>选型</th>
</tr>
</thead>
<tbody>
<tr>
<td>前端</td>
<td>uni-app（Vue 3 + Vite）+ Pinia + uni-icons，一套代码出 H5 / 微信小程序 / Android / iOS</td>
</tr>
<tr>
<td>后端</td>
<td>Node.js + Express，依赖只有 <code>express</code>、<code>cors</code>、<code>jsonwebtoken</code>、<code>lowdb</code> 四个</td>
</tr>
<tr>
<td>数据</td>
<td>lowdb（单个 JSON 文件 <code>server/data/db.json</code>，删掉即重置）</td>
</tr>
<tr>
<td>鉴权</td>
<td>JWT（前台用户 30 天，后台管理员 12 小时）</td>
</tr>
<tr>
<td>管理后台</td>
<td>原生 JS + HTML，挂在 <code>/admin</code>，不需要构建</td>
</tr>
</tbody>
</table>
<h2 id="二、整体架构" tabindex="-1">二、整体架构 <a class="header-anchor" href="#二、整体架构" aria-label="Permalink to &quot;二、整体架构&quot;"></a></h2>
<div class="language- vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang"></span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span>src/                 uni-app 前端（pages / components / store / api）</span></span>
<span class="line"><span>  api/request.js     统一请求封装，自动带 JWT</span></span>
<span class="line"><span>server/</span></span>
<span class="line"><span>  index.js           前台全部 REST 接口（约 200 行）</span></span>
<span class="line"><span>  admin.js           管理后台接口</span></span>
<span class="line"><span>  edition.js         免费版 / 付费版功能开关</span></span>
<span class="line"><span>  admin/             后台页面（原生 JS）</span></span>
<span class="line"><span>  seed.json          商品、分类、轮播种子数据</span></span></code></pre>
</div><p>前台和后台共用同一份 lowdb 数据，后端构建 H5 后还会顺手托管 <code>dist/build/h5</code>，所以一个 <code>npm start</code> 就能同时访问商城和后台。</p>
<p>前台已实现：首页（搜索、轮播、秒杀倒计时、商品流）、分类、搜索、商品详情与规格弹层、购物车（游客购物车登录后自动合并）、手机号验证码登录（模拟短信）、收货地址、下单 → 模拟收银台 → 订单列表/详情、售后申请（仅退款 / 退货退款 / 换货）。</p>
<h2 id="三、关键代码" tabindex="-1">三、关键代码 <a class="header-anchor" href="#三、关键代码" aria-label="Permalink to &quot;三、关键代码&quot;"></a></h2>
<h3 id="_1-jwt-登录" tabindex="-1">1. JWT 登录 <a class="header-anchor" href="#_1-jwt-登录" aria-label="Permalink to &quot;1. JWT 登录&quot;"></a></h3>
<p>验证码登录成功后签发 token，未注册手机号自动建号：</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">post</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'/api/auth/login'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">async</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">phone</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">code</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> req.body</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> c</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.data.codes[phone]</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">c </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> c.code </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!==</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> String</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(code) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> Date.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">now</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> c.exp) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'验证码错误或已过期'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  delete</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.data.codes[phone]            </span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">// 验证码一次性</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  let</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> u </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.data.users.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">find</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">u</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> u.phone </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> phone)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">u) { u </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { id: </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">nid</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(), phone, nickname: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'淘友'</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> +</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> phone.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">slice</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">-</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">4</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">), createdAt: Date.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">now</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() }; db.data.users.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">push</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(u) }</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (u.disabled) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'账号已被禁用'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">403</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">write</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">()</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">  ok</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, { token: jwt.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">sign</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">({ uid: u.id }, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">SECRET</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, { expiresIn: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'30d'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> }), user: u })</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">})</span></span></code></pre>
</div><p>鉴权中间件同时检查“用户是否被后台禁用”：</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> auth</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> t</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (req.headers.authorization </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> ''</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">).</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">replace</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">/</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">^</span><span style="--shiki-light:#032F62;--shiki-dark:#DBEDFF">Bearer </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">/</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">''</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  try</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    req.uid </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> jwt.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">verify</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(t, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">SECRET</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">).uid</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">req.uid </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">||</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.data.users.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">find</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">u</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> u.id </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> req.uid)?.disabled) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">throw</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 0</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">()</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">  } </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">catch</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> { </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'请先登录'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">401</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
</div><p>前端在 <code>request.js</code> 里统一加 <code>Authorization: Bearer &lt;token&gt;</code>，收到 401 就清掉本地 token。后台登录用 <code>crypto.timingSafeEqual</code> 比较账号密码，签发的 token 里带 <code>admin</code> 字段，和前台 token 互不通用。</p>
<h3 id="_2-订单状态流转" tabindex="-1">2. 订单状态流转 <a class="header-anchor" href="#_2-订单状态流转" aria-label="Permalink to &quot;2. 订单状态流转&quot;"></a></h3>
<p>状态：<code>待付款 → 待发货 → 待收货 → 已完成</code>，待付款可转 <code>已取消</code>。用一个小工厂函数统一做“当前状态是否允许这个动作”的校验，并记录时间线：</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> transit</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">from</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">to</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">msg</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">extra</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  return</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> async</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">    sweep</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(); </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> o</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> myOrder</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(req.uid, req.params.id)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">o) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'订单不存在'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">404</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">!</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">from.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">includes</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(o.status)) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">return</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">`当前状态「${</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">o</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">.</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">status</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">}」不可操作`</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">    o.status </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> to; o.timeline.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">push</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">({ t: Date.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">now</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(), s: msg }); </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">extra</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">?.(o, req); </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">await</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">write</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(); </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">ok</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, o)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">  }</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">post</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'/api/orders/:id/pay'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,     auth, </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">transit</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">([</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'待付款'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">], </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'待发货'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'支付成功（模拟）'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">))</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">post</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'/api/orders/:id/cancel'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,  auth, </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">transit</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">([</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'待付款'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">], </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'已取消'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'用户取消订单'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">))</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">post</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'/api/orders/:id/confirm'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, auth, </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">transit</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">([</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'待收货'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">], </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'已完成'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'已确认收货，交易完成'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">))</span></span></code></pre>
</div><p>未支付超时自动取消（默认 15 分钟，<code>PAY_TIMEOUT_MS</code> 可改）：定时器每 10 秒扫一次，查询订单前也会先扫一次。</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">function</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> sweep</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() {</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> now</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> Date.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">now</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(); </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">let</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> changed </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> false</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  for</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> o</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> of</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> db.data.orders)</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">    if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (o.status </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">===</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '待付款'</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> &#x26;&#x26;</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> now </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> o.expireAt) { o.status </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '已取消'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">; o.cancelReason </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> '超时未支付'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">; changed </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> true</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> }</span></span>
<span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">  if</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (changed) db.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">write</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">()</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">setInterval</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(sweep, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">10</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> *</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> 1000</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span></code></pre>
</div><h3 id="_3-免费版-付费版授权校验思路" tabindex="-1">3. 免费版 / 付费版授权校验思路 <a class="header-anchor" href="#_3-免费版-付费版授权校验思路" aria-label="Permalink to &quot;3. 免费版 / 付费版授权校验思路&quot;"></a></h3>
<p>所有功能登记在一张表里，<code>free</code> 人人可用，<code>pro</code> 需要授权：</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">export</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> const</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF"> FEATURE_TABLE</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> {</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">  'admin.products'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'free'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'admin.orders'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'free'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'admin.dashboard.basic'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'free'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">...</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">  'admin.dashboard.charts'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'pro'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'admin.export.csv'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'pro'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'admin.aftersale'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'pro'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,</span></span>
<span class="line"><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">  'admin.products.batch'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'pro'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'admin.rewards.manage'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'pro'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'admin.users.disable'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">: </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'pro'</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">}</span></span></code></pre>
</div><p>启动时用 <code>EDITION</code>、<code>LICENSE_OWNER</code>、<code>LICENSE_SALT</code>、<code>LICENSE_KEY</code> 校验授权码（<code>TH-PRO-</code> + sha256(owner:salt) 前 16 位），不匹配就自动回退免费版。关键在于<strong>接口层拦截</strong>，不是只把前端按钮藏起来：</p>
<div class="language-js vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">js</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">const</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> need</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70"> f</span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583"> =></span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> (</span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">req</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">res</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#E36209;--shiki-dark:#FFAB70">next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">=></span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">  has</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(f) </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">?</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> next</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">() </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">:</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0"> fail</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(res, </span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'该功能属于付费版，请配置授权后使用'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">402</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">get</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">`${</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">A</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">}/orders-export`</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">, </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">need</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'admin.export.csv'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">), </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span>
<span class="line"><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">app.</span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">get</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">`${</span><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">A</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">}/aftersales`</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">,    </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">need</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">(</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF">'admin.aftersale'</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">), </span><span style="--shiki-light:#D73A49;--shiki-dark:#F97583">...</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8">)</span></span></code></pre>
</div><blockquote>
<p>实话说一句：开源代码里的离线校验只是“功能开关”，不是防破解机制。想做真正的授权，需要服务端在线校验。</p>
</blockquote>
<h2 id="四、截图" tabindex="-1">四、截图 <a class="header-anchor" href="#四、截图" aria-label="Permalink to &quot;四、截图&quot;"></a></h2>
<p><img src="/images/shop-admin-login.png" alt="后台登录">
<img src="/images/shop-admin-dashboard-free.png" alt="数据概览（免费版）">
<img src="/images/shop-admin-dashboard-pro.png" alt="数据概览（付费版：趋势图、分类销售额、热销 TOP5）">
<img src="/images/shop-admin-products.png" alt="商品管理">
<img src="/images/shop-admin-orders.png" alt="订单管理与发货">
<img src="/images/shop-admin-aftersales-free-locked.png" alt="免费版访问售后审核：接口返回 402，页面提示升级">
<img src="/images/shop-h5-my.png" alt="H5「我的」页"></p>
<h2 id="五、运行步骤" tabindex="-1">五、运行步骤 <a class="header-anchor" href="#五、运行步骤" aria-label="Permalink to &quot;五、运行步骤&quot;"></a></h2>
<p>需要 Node.js 18+。</p>
<div class="language-bash vp-adaptive-theme"><button title="Copy Code" class="copy"></button><span class="lang">bash</span><pre class="shiki shiki-themes github-light github-dark vp-code" tabindex="0" v-pre=""><code><span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">git</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> clone</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> https://github.com/MsNan/taohaowu-shop.git</span></span>
<span class="line"><span style="--shiki-light:#005CC5;--shiki-dark:#79B8FF">cd</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> taohaowu-shop/server</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> &#x26;&#x26; </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> install</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> &#x26;&#x26; </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> start</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">   # http://localhost:3000</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D"># 另开终端，在项目根目录</span></span>
<span class="line"><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> install</span><span style="--shiki-light:#24292E;--shiki-dark:#E1E4E8"> &#x26;&#x26; </span><span style="--shiki-light:#6F42C1;--shiki-dark:#B392F0">npm</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> run</span><span style="--shiki-light:#032F62;--shiki-dark:#9ECBFF"> dev:h5</span><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D">                          # H5 开发调试</span></span>
<span class="line"><span style="--shiki-light:#6A737D;--shiki-dark:#6A737D"># 或 npm run build:h5，构建后直接访问 http://localhost:3000</span></span></code></pre>
</div><ul>
<li>管理后台：<a href="http://localhost:3000/admin/" target="_blank" rel="noreferrer">http://localhost:3000/admin/</a> ，开发模式默认 <code>admin / admin123</code>；生产环境务必设置 <code>JWT_SECRET</code> 和 <code>ADMIN_PASSWORD</code>（不设置时会生成随机密码打印到控制台）。</li>
<li>验证码：开发模式会打印在后端控制台，并显示在登录页。</li>
<li>微信小程序：<code>npm run build:mp-weixin</code>，用微信开发者工具导入 <code>dist/build/mp-weixin</code>。</li>
<li>App：用 HBuilderX 打开项目云打包（iOS 需要 Apple 开发者账号）。</li>
<li>端到端测试：<code>node tests/e2e.cjs</code>（puppeteer-core + 无头 Chrome）。</li>
</ul>
<h2 id="六、局限-写在前面免得踩坑" tabindex="-1">六、局限（写在前面免得踩坑） <a class="header-anchor" href="#六、局限-写在前面免得踩坑" aria-label="Permalink to &quot;六、局限（写在前面免得踩坑）&quot;"></a></h2>
<ul>
<li>lowdb 是单文件数据库，只适合演示和小流量，正式环境建议换 MySQL/PostgreSQL。</li>
<li>短信和支付都是模拟的，上线需要接入真实短信服务和微信/支付宝支付。</li>
</ul>
<h2 id="获取源码" tabindex="-1">获取源码 <a class="header-anchor" href="#获取源码" aria-label="Permalink to &quot;获取源码&quot;"></a></h2>
<ul>
<li><strong>免费版（开源）</strong>：<a href="https://github.com/MsNan/taohaowu-shop" target="_blank" rel="noreferrer">https://github.com/MsNan/taohaowu-shop</a></li>
<li><strong>付费进阶版</strong>（趋势图表、CSV 导出、售后审核、批量上下架、用户禁用、打赏核对）：正在整理，上架后会在公众号「程序员项目库」发通知</li>
<li>交流：关注公众号「程序员项目库」</li>
</ul>
<blockquote>
<p>本项目仅供学习参考。</p>
</blockquote>
]]></content:encoded>
        </item>
    </channel>
</rss>