Files
jiu/docs/testing/license-test-flow.html
wangjia 47b3c89460 feat(web): 新增更新日志、服务条款、隐私政策页面;关于页更新日志链接修正
- web/dist/changelog/ 版本历史页,收录 v1.0.0–v1.0.29 全部发版记录
- web/dist/terms/ 服务条款页(付费/授权/数据/免责等)
- web/dist/privacy/ 隐私政策页(数据收集/使用/共享/用户权利等)
- 全站页脚补充 更新日志、服务条款、隐私政策三个链接
- about_screen 「更新日志」按钮链接由 /downloads/ 改为 /changelog/
- 新增 backend/cmd/issue 工具,用于签发指定门店的授权码 token

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:56:22 +08:00

642 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>#21 授权功能测试流程</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", sans-serif;
background: #f5f5f7;
color: #1d1d1f;
line-height: 1.6;
padding: 32px 16px;
}
.container {
max-width: 860px;
margin: 0 auto;
}
header {
margin-bottom: 36px;
}
header .badge {
display: inline-block;
background: #0071e3;
color: #fff;
font-size: 12px;
font-weight: 600;
letter-spacing: .04em;
padding: 3px 10px;
border-radius: 20px;
margin-bottom: 10px;
}
header h1 {
font-size: 28px;
font-weight: 700;
letter-spacing: -.02em;
}
header p {
margin-top: 6px;
color: #6e6e73;
font-size: 14px;
}
/* Progress bar */
.progress-bar {
display: flex;
gap: 6px;
margin-bottom: 36px;
flex-wrap: wrap;
}
.progress-step {
flex: 1;
min-width: 90px;
text-align: center;
font-size: 11px;
font-weight: 600;
padding: 6px 4px;
border-radius: 8px;
background: #e5e5ea;
color: #6e6e73;
position: relative;
}
.progress-step.phase-normal { background: #d1fae5; color: #065f46; }
.progress-step.phase-grace { background: #fef3c7; color: #92400e; }
.progress-step.phase-readonly{ background: #fee2e2; color: #991b1b; }
.progress-step.phase-locked { background: #fecaca; color: #7f1d1d; }
.progress-step.phase-renew { background: #dbeafe; color: #1e40af; }
.progress-step.phase-prep { background: #ede9fe; color: #5b21b6; }
/* Card */
.card {
background: #fff;
border-radius: 16px;
padding: 28px 32px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,.08), 0 0 0 1px rgba(0,0,0,.04);
}
.card-header {
display: flex;
align-items: flex-start;
gap: 14px;
margin-bottom: 20px;
}
.card-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.icon-prep { background: #ede9fe; }
.icon-normal { background: #d1fae5; }
.icon-grace { background: #fef3c7; }
.icon-readonly { background: #fee2e2; }
.icon-locked { background: #fecaca; }
.icon-renew { background: #dbeafe; }
.icon-verify { background: #f3f4f6; }
.card-title-block h2 {
font-size: 17px;
font-weight: 700;
}
.card-title-block .subtitle {
font-size: 13px;
color: #6e6e73;
margin-top: 2px;
}
/* Phase pill */
.phase-pill {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 2px 9px;
border-radius: 20px;
margin-left: 8px;
vertical-align: middle;
letter-spacing: .03em;
}
.pill-normal { background: #d1fae5; color: #065f46; }
.pill-grace { background: #fef3c7; color: #92400e; }
.pill-readonly { background: #fee2e2; color: #991b1b; }
.pill-locked { background: #fecaca; color: #7f1d1d; }
/* Steps */
ol.steps {
list-style: none;
counter-reset: step;
display: flex;
flex-direction: column;
gap: 10px;
}
ol.steps li {
counter-increment: step;
display: flex;
gap: 12px;
align-items: flex-start;
font-size: 14px;
}
ol.steps li::before {
content: counter(step);
min-width: 24px;
height: 24px;
background: #f2f2f7;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: #3c3c43;
flex-shrink: 0;
margin-top: 1px;
}
/* Expect block */
.expect {
margin-top: 14px;
padding: 12px 16px;
border-radius: 10px;
font-size: 13px;
}
.expect-ok { background: #f0fdf4; border-left: 3px solid #22c55e; }
.expect-warn{ background: #fffbeb; border-left: 3px solid #f59e0b; }
.expect-err { background: #fff1f2; border-left: 3px solid #f43f5e; }
.expect .label {
font-weight: 700;
margin-bottom: 6px;
font-size: 12px;
letter-spacing: .05em;
text-transform: uppercase;
}
.expect-ok .label { color: #16a34a; }
.expect-warn .label { color: #d97706; }
.expect-err .label { color: #e11d48; }
.expect ul {
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.expect ul li::before {
content: "✓ ";
font-weight: 700;
color: #22c55e;
}
.expect-warn ul li::before { color: #f59e0b; content: "⚠ "; }
.expect-err ul li::before { color: #f43f5e; content: "✗ "; }
/* Code block */
pre {
background: #1c1c1e;
color: #e5e7eb;
border-radius: 10px;
padding: 14px 18px;
font-size: 13px;
font-family: "SF Mono", "Fira Code", Consolas, monospace;
overflow-x: auto;
margin-top: 12px;
line-height: 1.6;
}
pre .comment { color: #6b7280; }
pre .keyword { color: #93c5fd; }
pre .string { color: #a3e635; }
/* Note */
.note {
background: #f2f2f7;
border-radius: 10px;
padding: 12px 16px;
font-size: 13px;
color: #3c3c43;
margin-top: 12px;
display: flex;
gap: 8px;
align-items: flex-start;
}
.note .note-icon { flex-shrink: 0; font-size: 15px; }
/* Divider */
.divider {
border: none;
border-top: 1px solid #e5e5ea;
margin: 24px 0;
}
/* Reset card */
.reset-card {
background: #fff9f0;
border: 1.5px dashed #fb923c;
border-radius: 16px;
padding: 22px 28px;
margin-bottom: 20px;
}
.reset-card h3 {
font-size: 15px;
font-weight: 700;
color: #c2410c;
margin-bottom: 12px;
}
/* Table */
.phase-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin-top: 12px;
}
.phase-table th {
background: #f2f2f7;
padding: 9px 14px;
text-align: left;
font-weight: 600;
font-size: 12px;
color: #6e6e73;
letter-spacing: .04em;
}
.phase-table td {
padding: 9px 14px;
border-bottom: 1px solid #f2f2f7;
}
.phase-table tr:last-child td { border-bottom: none; }
footer {
text-align: center;
color: #aeaeb2;
font-size: 12px;
margin-top: 40px;
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="badge">#21 授权信息功能</div>
<h1>授权功能本地测试流程</h1>
<p>覆盖注册 → 试用 → 宽限期 → 只读 → 锁定 → 续费激活全链路</p>
</header>
<!-- Phase overview -->
<div class="progress-bar">
<div class="progress-step phase-prep">🔑 前置准备</div>
<div class="progress-step phase-normal">✅ 试用期</div>
<div class="progress-step phase-grace">⚠️ 宽限期</div>
<div class="progress-step phase-readonly">🔒 只读模式</div>
<div class="progress-step phase-locked">🚫 锁定</div>
<div class="progress-step phase-renew">🔄 续费激活</div>
<div class="progress-step">🛡️ 防伪验证</div>
</div>
<!-- Phase reference table -->
<div class="card">
<div class="card-header">
<div class="card-icon icon-prep">📊</div>
<div class="card-title-block">
<h2>阶段速查表</h2>
<div class="subtitle">各阶段对应的 expires_at 偏移量与行为</div>
</div>
</div>
<table class="phase-table">
<thead>
<tr>
<th>阶段</th>
<th>expires_at 设置</th>
<th>写操作</th>
<th>登录</th>
<th>前端表现</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="phase-pill pill-normal">normal</span></td>
<td>未来时间(未过期)</td>
<td>✅ 正常</td>
<td>✅ 正常</td>
<td>绿色状态,无横幅</td>
</tr>
<tr>
<td><span class="phase-pill pill-grace">grace</span></td>
<td>17 天前</td>
<td>✅ 正常</td>
<td>✅ 正常</td>
<td>橙色横幅 + 启动弹窗</td>
</tr>
<tr>
<td><span class="phase-pill pill-readonly">readonly</span></td>
<td>715 天前</td>
<td>🚫 403</td>
<td>✅ 正常</td>
<td>红色横幅,写操作提示错误</td>
</tr>
<tr>
<td><span class="phase-pill pill-locked">locked</span></td>
<td>15+ 天前</td>
<td>🚫 403</td>
<td>🚫 拒绝</td>
<td>登录失败提示</td>
</tr>
</tbody>
</table>
</div>
<!-- Step 0: Prerequisites -->
<div class="card">
<div class="card-header">
<div class="card-icon icon-prep">🔑</div>
<div class="card-title-block">
<h2>前置准备 <small style="font-weight:400;color:#6e6e73;">仅需一次</small></h2>
<div class="subtitle">生成密钥对,配置后端环境变量</div>
</div>
</div>
<ol class="steps">
<li>
<div>
生成 Ed25519 密钥对:
<pre><span class="comment"># 在项目根目录执行</span>
export PATH="/opt/homebrew/bin:$PATH"
cd backend
go run ./cmd/genkey</pre>
输出两段 base64:第一段是<strong>私钥</strong>(存 Bitwarden),第二段是<strong>公钥</strong>
</div>
</li>
<li>
<div>
配置后端环境变量(在启动脚本或 <code>.env</code> 中):
<pre>export LICENSE_ED25519_PRIVATE_KEY="&lt;私钥 base64&gt;"
export LICENSE_ED25519_PUBLIC_KEY="&lt;公钥 base64&gt;"</pre>
</div>
</li>
<li><strong>重启后端服务</strong>,使配置生效。</li>
</ol>
<div class="note">
<span class="note-icon">💡</span>
<span>未配置私钥时,注册不会自动创建试用授权,注册后设置页会显示「无授权信息」。</span>
</div>
</div>
<!-- Step 1: Register -->
<div class="card">
<div class="card-header">
<div class="card-icon icon-normal"></div>
<div class="card-title-block">
<h2>阶段一:注册 → 自动试用 <span class="phase-pill pill-normal">normal</span></h2>
<div class="subtitle">验证注册时自动签发 30 天试用授权</div>
</div>
</div>
<ol class="steps">
<li>用客户端注册一个<strong>新账号</strong>(新门店)</li>
<li>登录后进入 <strong>设置 → 授权</strong> 标签</li>
<li>
(可选)查数据库确认自动创建:
<pre><span class="keyword">SELECT</span> id, shop_id, type, expires_at, is_active, max_devices
<span class="keyword">FROM</span> licenses
<span class="keyword">ORDER BY</span> id <span class="keyword">DESC LIMIT</span> 1;</pre>
记下 <code>shop_id</code>,后续所有 SQL 替换 <code>1</code> 为该值。
</li>
</ol>
<div class="expect expect-ok">
<div class="label">预期结果</div>
<ul>
<li>设置页显示「试用版」绿色状态</li>
<li>到期时间约为注册后 30 天</li>
<li>无横幅,无弹窗</li>
<li>DB 中 type='trial'is_active=1</li>
</ul>
</div>
</div>
<!-- Step 2: Grace -->
<div class="card">
<div class="card-header">
<div class="card-icon icon-grace">⚠️</div>
<div class="card-title-block">
<h2>阶段二:宽限期 <span class="phase-pill pill-grace">grace</span></h2>
<div class="subtitle">过期 0–7 天 · 仍可写 · 出现橙色横幅</div>
</div>
</div>
<ol class="steps">
<li>
执行 SQL,模拟 3 天前过期:
<pre><span class="comment">-- shop_id 替换为你的实际值</span>
<span class="keyword">UPDATE</span> licenses
<span class="keyword">SET</span> expires_at = DATE_SUB(NOW(), INTERVAL 3 DAY)
<span class="keyword">WHERE</span> shop_id = 1;</pre>
</li>
<li><strong>退出登录 → 重新登录</strong>JWT 重新签发,携带新的过期时间)</li>
<li>观察顶部横幅,进入设置查看状态颜色</li>
<li>尝试新建入库单或编辑商品,确认写操作正常</li>
</ol>
<div class="expect expect-warn">
<div class="label">预期结果</div>
<ul>
<li>顶部出现橙色横幅「授权已过期,请续费」</li>
<li>首次登录弹出到期提示弹窗</li>
<li>设置页状态为橙色「宽限期」</li>
<li>写操作(入库、编辑)仍然可以正常使用</li>
</ul>
</div>
</div>
<!-- Step 3: ReadOnly -->
<div class="card">
<div class="card-header">
<div class="card-icon icon-readonly">🔒</div>
<div class="card-title-block">
<h2>阶段三:只读模式 <span class="phase-pill pill-readonly">readonly</span></h2>
<div class="subtitle">过期 715 天 · 写操作被拦截</div>
</div>
</div>
<ol class="steps">
<li>
执行 SQL,模拟 10 天前过期:
<pre><span class="keyword">UPDATE</span> licenses
<span class="keyword">SET</span> expires_at = DATE_SUB(NOW(), INTERVAL 10 DAY)
<span class="keyword">WHERE</span> shop_id = 1;</pre>
</li>
<li><strong>退出登录 → 重新登录</strong></li>
<li>尝试<strong>新建入库单</strong><strong>编辑商品</strong></li>
<li>尝试<strong>查看</strong>商品列表、库存记录(只读应正常)</li>
</ol>
<div class="expect expect-err">
<div class="label">预期结果</div>
<ul>
<li>顶部红色横幅</li>
<li>写操作被拦截,显示「系统处于只读模式,无法修改」</li>
<li>查看类操作(GET 请求)正常响应</li>
<li>设置页状态为红色「只读模式」</li>
</ul>
</div>
</div>
<!-- Step 4: Locked -->
<div class="card">
<div class="card-header">
<div class="card-icon icon-locked">🚫</div>
<div class="card-title-block">
<h2>阶段四:锁定 <span class="phase-pill pill-locked">locked</span></h2>
<div class="subtitle">过期 15+ 天 · 无法登录</div>
</div>
</div>
<ol class="steps">
<li>
执行 SQL,模拟 20 天前过期:
<pre><span class="keyword">UPDATE</span> licenses
<span class="keyword">SET</span> expires_at = DATE_SUB(NOW(), INTERVAL 20 DAY)
<span class="keyword">WHERE</span> shop_id = 1;</pre>
</li>
<li><strong>不要退出</strong>,先测试当前会话:尝试任何写操作 → 应被拦截</li>
<li>然后<strong>退出登录</strong></li>
<li>尝试重新登录</li>
</ol>
<div class="expect expect-err">
<div class="label">预期结果</div>
<ul>
<li>当前会话写操作返回 403</li>
<li>退出后重新登录:失败,提示「授权已锁定,请续费后重新激活」</li>
</ul>
</div>
<div class="note">
<span class="note-icon"></span>
<span><strong>登录被拒</strong>是从数据库实时读取的,无需重启服务或刷新 JWT 即可生效。</span>
</div>
</div>
<!-- Step 5: Renewal -->
<div class="card">
<div class="card-header">
<div class="card-icon icon-renew">🔄</div>
<div class="card-title-block">
<h2>阶段五:续费激活</h2>
<div class="subtitle">签发新授权码 → 用户激活 → 恢复正常</div>
</div>
</div>
<hr class="divider">
<p style="font-weight:600;font-size:14px;margin-bottom:12px;">5.1 运营方:签发授权码</p>
<ol class="steps">
<li>
执行 issue 命令(确保环境变量已配置):
<pre><span class="comment"># shop 替换为实际 shop_iddays 为有效天数;devices 为最大设备数</span>
export PATH="/opt/homebrew/bin:$PATH"
cd backend
go run ./cmd/issue \
-shop 1 \
-days 365 \
-type annual \
-devices 5</pre>
输出的 token 字符串即为<strong>授权码</strong>,复制备用。
</li>
</ol>
<hr class="divider">
<p style="font-weight:600;font-size:14px;margin-bottom:12px;">5.2 用户:在应用内激活</p>
<ol class="steps">
<li>
此时登录仍会被拒。需要先用 SQL 临时解锁以便进入设置:
<pre><span class="comment">-- 临时恢复到 grace 阶段(允许登录)</span>
<span class="keyword">UPDATE</span> licenses
<span class="keyword">SET</span> expires_at = DATE_SUB(NOW(), INTERVAL 3 DAY)
<span class="keyword">WHERE</span> shop_id = 1;</pre>
</li>
<li>重新登录,进入 <strong>设置 → 授权 → 激活授权码</strong></li>
<li>粘贴步骤 5.1 的 token → 点击「激活」</li>
<li>激活成功后<strong>退出登录 → 重新登录</strong></li>
</ol>
<div class="expect expect-ok">
<div class="label">预期结果</div>
<ul>
<li>激活成功,设置页显示「正式版(annual)」绿色状态</li>
<li>到期日显示约 1 年后</li>
<li>重新登录成功,无横幅,写操作恢复正常</li>
</ul>
</div>
</div>
<!-- Step 6: Forgery test -->
<div class="card">
<div class="card-header">
<div class="card-icon icon-verify">🛡️</div>
<div class="card-title-block">
<h2>阶段六:防伪验证</h2>
<div class="subtitle">确认伪造/篡改的授权码被拒绝</div>
</div>
</div>
<ol class="steps">
<li>取上一步签发的 token,随意修改几个字符(如把末尾 3 个字符换掉)</li>
<li>在激活框粘贴修改后的 token → 点击「激活」</li>
<li>再试一次:随便输入一段乱码文本</li>
</ol>
<div class="expect expect-err">
<div class="label">预期结果</div>
<ul>
<li>两次激活均失败</li>
<li>提示签名无效或格式错误</li>
<li>当前授权状态不受影响</li>
</ul>
</div>
</div>
<!-- Reset -->
<div class="reset-card">
<h3>🔄 测试完毕 — 快速重置</h3>
<pre><span class="comment">-- 恢复为 30 天后到期(正常试用状态)</span>
<span class="keyword">UPDATE</span> licenses
<span class="keyword">SET</span> expires_at = DATE_ADD(NOW(), INTERVAL 30 DAY)
<span class="keyword">WHERE</span> shop_id = 1;</pre>
</div>
<!-- Notes -->
<div class="card">
<div class="card-header">
<div class="card-icon icon-prep">📝</div>
<div class="card-title-block">
<h2>注意事项</h2>
</div>
</div>
<ol class="steps">
<li><strong>每次改 DB 后必须重新登录</strong><code>LicenseExpiresAt</code> 缓存在 JWT 里,不重登前端阶段判断不会更新(登录拦截除外,那个实时读 DB)</li>
<li><code>shop_id</code> 替换为你实际注册账号对应的值,通常第一个账号为 1</li>
<li>issue 工具依赖 <code>LICENSE_ED25519_PRIVATE_KEY</code> 环境变量,确保 shell 会话中已导出</li>
<li>设置页的「激活授权码」卡片仅在授权为非正常状态(grace/readonly/locked)或 readonly phase 时显示;normal 下隐藏</li>
</ol>
</div>
<footer>
docs/testing/license-test-flow.html · #21 授权信息功能 · 测试流程文档
</footer>
</div>
</body>
</html>