Files
jiu/docs/manual/dev-manual.html
T

670 lines
65 KiB
HTML
Raw 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>开发手册 — 岩美酒库</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --success-bg:#E6F3EC; --warn:#B45309; --warn-bg:#FFF4E5; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);line-height:1.6;}
main{max-width:980px;margin:0 auto;padding:26px 30px 60px;}
h1{font-size:22px;margin:0 0 4px;}
.sub{color:var(--muted);font-size:13px;margin-bottom:6px;}
h2{font-size:17px;margin:38px 0 6px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
h3{font-size:14px;margin:20px 0 8px;color:var(--primary-dark);}
.gdesc{font-size:12.5px;color:var(--muted);margin:6px 0 12px;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin:14px 0;}
table{width:100%;border-collapse:collapse;font-size:12.5px;margin:6px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:11.5px;text-align:left;padding:7px 9px;border-bottom:1px solid var(--border);}
td{padding:6px 9px;border-bottom:1px solid #EEF1F6;vertical-align:top;}
tr:last-child td{border-bottom:0;}
.mono{font-family:ui-monospace,Menlo,monospace;}
code{font-family:ui-monospace,Menlo,monospace;font-size:11.5px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
pre{background:#232934;color:#E8ECF3;border-radius:8px;padding:12px 14px;font-size:12px;line-height:1.55;overflow:auto;}
pre code{background:none;color:inherit;padding:0;}
.tag{display:inline-block;font-size:10px;padding:0 6px;border-radius:9px;font-weight:600;line-height:16px;white-space:nowrap;}
.tag.pub{background:var(--success-bg);color:var(--success);}
.tag.jwt{background:#E5EEF9;color:var(--primary);}
.tag.admin{background:var(--warn-bg);color:var(--warn);}
.tag.super{background:var(--danger-bg);color:var(--danger);}
.tag.rl{background:#EEE;color:var(--muted);}
.callout{border-left:4px solid var(--primary);background:#F0F6FF;padding:9px 13px;border-radius:4px;margin:8px 0 12px;font-size:12.5px;}
.callout.danger{border-color:var(--danger);background:var(--danger-bg);}
.callout.warn{border-color:var(--warn);background:var(--warn-bg);}
.callout.ok{border-color:var(--success);background:var(--success-bg);}
.toc{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:6px 18px;background:#fff;border:1px solid var(--border);border-radius:10px;padding:14px 18px;margin:16px 0;}
.toc a{font-size:13px;color:var(--primary);text-decoration:none;}
.toc a:hover{text-decoration:underline;}
.toc b{grid-column:1/-1;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-top:4px;}
.svgwrap{background:#fff;border:1px solid var(--border);border-radius:10px;padding:14px;margin:12px 0;}
svg{display:block;width:100%;height:auto;max-width:900px;margin:0 auto;}
svg text{font-family:-apple-system,"PingFang SC",sans-serif;}
.m{font-family:ui-monospace,Menlo,monospace;font-weight:700;font-size:11px;}
.m.get{color:var(--success);} .m.post{color:var(--primary);} .m.put{color:var(--warn);} .m.del{color:var(--danger);} .m.patch{color:var(--accent);}
</style>
</head>
<body>
<main>
<h1>岩美酒库 · 开发手册</h1>
<div class="sub">面向后续开发者/维护者 · server v1.1.1 / client v1.1.1 · 更新于 2026-07-03 · 来源以仓库代码为准(<code>backend/</code> <code>client/</code> <code>deploy/</code> <code>scripts/</code></div>
<div class="toc">
<b>目录</b>
<a href="#arch">1. 系统架构总览</a>
<a href="#backend">2. 后端(Go / Gin</a>
<a href="#frontend">3. 前端(Flutter</a>
<a href="#db">4. 数据库</a>
<a href="#api">5. API 参考</a>
<a href="#ops">6. 数据操作与运维</a>
<a href="#workflow">7. 开发环境与工作流</a>
</div>
<!-- ============================================================ -->
<h2 id="arch">1. 系统架构总览</h2>
<p class="gdesc">五端 Flutter 客户端统一访问 <code>https://jiu.51yanmei.com</code>。阿里云主机上 nginx 1.24 终结 TLS443 ssl+http2certbot webroot 自动续期,80 → 443 跳转),按 location 分发:API/健康检查/版本反代到本机 8081 的 Go 后端(systemd <code>jiu.service</code>),静态资源(Flutter Web <code>/app</code>、商品图 <code>/images</code>、安装包 <code>/downloads</code>、营销站根路径)直接由 nginx 服务;<code>/product/:id</code><code>/app/product/:id</code>(扫码分享页)回代后端注入 OG 标签。后端连本机 Docker 容器 <code>jiu_mysql</code>MySQL 8127.0.0.1:3306)。未鉴权的 <code>/api/v1/(public|auth)/</code> 在 nginx 层再加一道 per-IP 限流(10r/s burst 20)。源:<code>deploy/nginx-jiu-ali.conf</code><code>deploy/jiu.service</code></p>
<div class="svgwrap">
<svg viewBox="0 0 900 470" role="img" aria-label="系统架构图">
<defs>
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="3.5" orient="auto">
<path d="M0,0 L8,3.5 L0,7 Z" fill="#6E7888"/>
</marker>
</defs>
<!-- clients -->
<g font-size="12" text-anchor="middle">
<rect x="30" y="20" width="140" height="36" rx="6" fill="#F0F4FF" stroke="#2563AC"/><text x="100" y="43" fill="#154072">Windows</text>
<rect x="200" y="20" width="140" height="36" rx="6" fill="#F0F4FF" stroke="#2563AC"/><text x="270" y="43" fill="#154072">macOS</text>
<rect x="370" y="20" width="140" height="36" rx="6" fill="#F0F4FF" stroke="#2563AC"/><text x="440" y="43" fill="#154072">Web (/app)</text>
<rect x="540" y="20" width="140" height="36" rx="6" fill="#F0F4FF" stroke="#2563AC"/><text x="610" y="43" fill="#154072">Android</text>
<rect x="710" y="20" width="140" height="36" rx="6" fill="#F0F4FF" stroke="#2563AC"/><text x="780" y="43" fill="#154072">iOS</text>
</g>
<line x1="100" y1="56" x2="430" y2="98" stroke="#6E7888" marker-end="url(#ar)"/>
<line x1="270" y1="56" x2="437" y2="98" stroke="#6E7888" marker-end="url(#ar)"/>
<line x1="440" y1="56" x2="443" y2="98" stroke="#6E7888" marker-end="url(#ar)"/>
<line x1="610" y1="56" x2="450" y2="98" stroke="#6E7888" marker-end="url(#ar)"/>
<line x1="780" y1="56" x2="457" y2="98" stroke="#6E7888" marker-end="url(#ar)"/>
<text x="450" y="88" text-anchor="middle" font-size="11" fill="#6E7888">https://jiu.51yanmei.com</text>
<!-- nginx -->
<rect x="30" y="100" width="840" height="170" rx="8" fill="#fff" stroke="#154072" stroke-width="1.5"/>
<text x="450" y="122" text-anchor="middle" font-size="13" font-weight="700" fill="#154072">ali · nginx 1.24 — 443 ssl+http2certbot webroot 自动续期)· 80 → 443</text>
<g font-size="11" text-anchor="middle">
<rect x="50" y="136" width="190" height="52" rx="5" fill="#E6F3EC" stroke="#2E8B57"/>
<text x="145" y="157" fill="#2E8B57">静态:/appFlutter Web</text>
<text x="145" y="173" fill="#2E8B57">/images · /downloads · / 营销站</text>
<rect x="260" y="136" width="190" height="52" rx="5" fill="#F0F4FF" stroke="#2563AC"/>
<text x="355" y="157" fill="#154072">反代:/api /health /version</text>
<text x="355" y="173" fill="#154072">→ 127.0.0.1:8081</text>
<rect x="470" y="136" width="190" height="52" rx="5" fill="#FFF4E5" stroke="#B45309"/>
<text x="565" y="157" fill="#B45309">限流反代:/api/v1/(public|auth)</text>
<text x="565" y="173" fill="#B45309">10r/s · burst 20per-IP</text>
<rect x="680" y="136" width="170" height="52" rx="5" fill="#FDECEC" stroke="#8B2331"/>
<text x="765" y="157" fill="#8B2331">/product · /app/product</text>
<text x="765" y="173" fill="#8B2331">OG 注入 → 回代后端</text>
<text x="450" y="252" fill="#6E7888" font-size="11">证书 /etc/letsencrypt/live/jiu.51yanmei.com/ · client_max_body_size 20m · /import 超时 300s · 安全头 HSTS/XFO/nosniff</text>
</g>
<line x1="450" y1="270" x2="450" y2="308" stroke="#6E7888" marker-end="url(#ar)"/>
<text x="470" y="294" font-size="11" fill="#6E7888">proxy_pass 127.0.0.1:8081</text>
<!-- backend -->
<rect x="180" y="310" width="540" height="56" rx="8" fill="#fff" stroke="#2563AC" stroke-width="1.5"/>
<text x="450" y="333" text-anchor="middle" font-size="13" font-weight="700" fill="#154072">jiu-serverGo / Gin)— systemd jiu.service</text>
<text x="450" y="352" text-anchor="middle" font-size="11" fill="#6E7888">/opt/jiu/backend/jiu-server · EnvironmentFile=/opt/jiu/config/production.envSERVER_PORT=8081</text>
<line x1="450" y1="366" x2="450" y2="402" stroke="#6E7888" marker-end="url(#ar)"/>
<!-- mysql -->
<rect x="280" y="404" width="340" height="50" rx="8" fill="#fff" stroke="#2E8B57" stroke-width="1.5"/>
<text x="450" y="425" text-anchor="middle" font-size="13" font-weight="700" fill="#2E8B57">MySQL 8 — docker 容器 jiu_mysql</text>
<text x="450" y="443" text-anchor="middle" font-size="11" fill="#6E7888">127.0.0.1:3306 · 库 jiu_db · 每日备份见 §6</text>
</svg>
</div>
<div class="callout warn">阿里机 8080 已被 pay 项目 <code>payd</code> 占用,后端端口固定 <b>8081</b>;本地开发默认仍是 8080。改 nginx 时只动 jiu 的 server block<code>/etc/nginx/conf.d/jiu.conf</code>),别碰同机其他站点。</div>
<!-- ============================================================ -->
<h2 id="backend">2. 后端(Go / Gin</h2>
<h3>2.1 目录分层</h3>
<div class="card"><table>
<thead><tr><th style="width:32%">目录 / 文件</th><th>职责</th></tr></thead>
<tbody>
<tr><td class="mono">main.go</td><td>入口:配置加载、生产前置检查(CORS/JWT 密钥)、连库、AutoMigrate、启动回填、SetTrustedProxies、挂路由</td></tr>
<tr><td class="mono">cmd/</td><td>一次性/运维工具(seed、gencode、import-history、fix-inventory-products、backfill-pinyin,详见 §6.4</td></tr>
<tr><td class="mono">config/</td><td>Viper 配置结构体 + <code>config.yaml</code>(本地,不入 git+ <code>version.yaml</code>(发版清单,/version 实时读)</td></tr>
<tr><td class="mono">internal/handler/</td><td>HTTP 处理器,每业务模块一个文件;参数校验 + 组织响应,<code>*_test.go</code> 为 SQLite in-memory 集成测试</td></tr>
<tr><td class="mono">internal/service/</td><td>业务逻辑层:auth(登录/失败锁定)、license(兑换券)、stock(审核/库存事务)、migrate(启动回填)、session_cleanup</td></tr>
<tr><td class="mono">internal/model/</td><td>GORM 模型;<code>base.go</code> 提供 Base/TenantBase/Date/JSON 公共类型</td></tr>
<tr><td class="mono">internal/middleware/</td><td>JWT 鉴权 + 会话校验、角色拦截(ReadOnly/AdminOnly/SuperAdminOnly)、LicenseGuard、限流</td></tr>
<tr><td class="mono">internal/util/</td><td>通用工具(<code>pinyin.go</code> 拼音搜索列生成等)</td></tr>
<tr><td class="mono">internal/router/router.go</td><td>全部路由注册与中间件编排(§5 的唯一真相源)</td></tr>
<tr><td class="mono">schema/schema.sql</td><td>完整建表 DDL(新装用;线上结构演进靠 AutoMigrate</td></tr>
<tr><td class="mono">seeds/</td><td>门店种子数据 SQL(S001 全量测试数据、S002 空门店)</td></tr>
<tr><td class="mono">testutil/setup.go</td><td>测试工具:SQLite in-memory DB、CreateTestXxx 工厂、GetAuthToken</td></tr>
</tbody>
</table></div>
<h3>2.2 JWT 与多租户隔离</h3>
<p class="gdesc">源:<code>backend/internal/middleware/auth.go</code>。JWTHS256Access 60 分钟 / Refresh 7 天)Claims 携带 <code>user_id / shop_id / role / sid / lic_exp</code>。带 <code>sid</code> 的 token 每请求校验 <code>user_sessions</code>(撤销/禁用即时下线,<code>last_seen_at</code> 30s 节流刷新)。</p>
<div class="callout danger"><b>铁律:</b><code>shop_id</code> 永远用 <code>middleware.GetShopID(c)</code> 从 JWT 取,<b>绝不</b>从请求参数/URL/请求体读;所有查询必须带 <code>WHERE shop_id = ?</code>。违反即多租户越权。</div>
<h3>2.3 角色中间件</h3>
<div class="card"><table>
<thead><tr><th style="width:26%">中间件</th><th style="width:30%">挂载位置</th><th>行为</th></tr></thead>
<tbody>
<tr><td class="mono">ReadOnly()</td><td>业务路由组全局 + /license</td><td><code>role=readonly</code> 的非 GET 请求 → 403<code>code: READONLY_USER</code></td></tr>
<tr><td class="mono">AdminOnly()</td><td>/users 组、/shop 写、/sessions/:id 删</td><td><code>admin / superadmin</code> 放行</td></tr>
<tr><td class="mono">SuperAdminOnly()</td><td>/admin 组</td><td><code>superadmin</code> 放行(清数据/对账/错误报告/反馈后台)</td></tr>
</tbody>
</table></div>
<p class="gdesc">角色顺序:<code>superadmin &gt; admin &gt; operator &gt; readonly</code>。新增写路由默认已受 ReadOnly 保护;仅管理员可用的要再包 AdminOnly 子组。</p>
<h3>2.4 LicenseGuard(授权过期降级)</h3>
<p class="gdesc">源:<code>middleware/license_guard.go</code>。按<b>当前 DB</b> 的有效授权实时算 phase(每店 30s 缓存,激活/续费调用 <code>InvalidateLicensePhase</code> 即时生效),不信任登录时 token 快照:</p>
<div class="card"><table>
<thead><tr><th style="width:18%">phase</th><th style="width:28%">条件</th><th>效果</th></tr></thead>
<tbody>
<tr><td><span class="tag pub">normal</span></td><td>未过期 / 永久授权</td><td>正常读写</td></tr>
<tr><td><span class="tag admin">grace</span></td><td>过期 07 天</td><td>可写,前端展示提醒横幅</td></tr>
<tr><td><span class="tag admin">readonly</span></td><td>过期 715 天</td><td>非 GET → 403<code>phase: readonly</code></td></tr>
<tr><td><span class="tag super">locked</span></td><td>过期 15+ 天,或授权全部被停用(吊销)</td><td>所有业务请求 403<code>phase: locked</code></td></tr>
</tbody>
</table></div>
<p class="gdesc"><code>/license/*</code> 与 登出/心跳/在线会话 挂在 LicenseGuard <b>之外</b>——锁定时仍能查看授权状态并激活新码。</p>
<h3>2.5 限流</h3>
<p class="gdesc">源:<code>middleware/ratelimit.go</code>(进程内令牌桶 + janitor 清理,<code>config.C.RateLimit.Enabled=false</code> 可整体关闭)。两个维度:</p>
<ul style="font-size:13px">
<li><b>per-IP</b>(未鉴权接口逐条挂):login / refresh / register / errors / 公开商品读 / 店铺商品列表各自独立配额,IP 取自 nginx 写入的 <code>X-Real-IP</code>main.go 只信任 127.0.0.1 代理,不可伪造)。</li>
<li><b>per-Shop</b>(挂在 JWT 之后,作用于全部已鉴权路由):按 <code>shop_id</code> 限 RPS+burst,防单店打爆共享后端。</li>
</ul>
<p class="gdesc">超限返回 429 + <code>Retry-After: 60</code>。nginx 层对 <code>/api/v1/(public|auth)/</code> 还有最外层 10r/s 兜底(§1)。</p>
<h3>2.6 单据审核流状态机</h3>
<p class="gdesc">入库单 / 出库单同一状态机(<code>service/stock.go</code> + <code>handler/stock_in.go</code><code>stock_out.go</code>)。审核通过时同一事务内:更新 <code>inventories</code>FOR UPDATE 锁行)+ 写 <code>inventory_logs</code> + 生成财务应收/应付;出库先校验库存充足,不足回滚。</p>
<div class="svgwrap">
<svg viewBox="0 0 900 300" role="img" aria-label="单据审核流状态机">
<defs>
<marker id="ar2" markerWidth="8" markerHeight="8" refX="7" refY="3.5" orient="auto">
<path d="M0,0 L8,3.5 L0,7 Z" fill="#6E7888"/>
</marker>
</defs>
<g font-size="13" text-anchor="middle" font-weight="700">
<rect x="50" y="90" width="140" height="46" rx="23" fill="#EEF2F8" stroke="#6E7888"/><text x="120" y="118" fill="#232934">draft 草稿</text>
<rect x="330" y="90" width="150" height="46" rx="23" fill="#FFF4E5" stroke="#B45309"/><text x="405" y="118" fill="#B45309">pending 审核中</text>
<rect x="640" y="30" width="160" height="46" rx="23" fill="#E6F3EC" stroke="#2E8B57"/><text x="720" y="58" fill="#2E8B57">approved 已审核</text>
<rect x="640" y="160" width="160" height="46" rx="23" fill="#FDECEC" stroke="#D14343"/><text x="720" y="188" fill="#D14343">rejected 已拒绝</text>
</g>
<g font-size="11.5" fill="#6E7888">
<!-- submit -->
<line x1="190" y1="107" x2="328" y2="107" stroke="#6E7888" marker-end="url(#ar2)"/>
<text x="259" y="99" text-anchor="middle">PUT /:id/submit</text>
<!-- withdraw back edge -->
<path d="M340,136 C280,180 220,180 195,140" fill="none" stroke="#B45309" stroke-dasharray="5 4" marker-end="url(#ar2)"/>
<text x="268" y="185" text-anchor="middle" fill="#B45309">withdraw 撤回(管理员任意单 / 操作员限本人单)</text>
<!-- approve -->
<line x1="480" y1="98" x2="637" y2="60" stroke="#2E8B57" marker-end="url(#ar2)"/>
<text x="545" y="65" text-anchor="middle" fill="#2E8B57">approve 审核通过</text>
<!-- reject -->
<line x1="480" y1="122" x2="637" y2="176" stroke="#D14343" marker-end="url(#ar2)"/>
<text x="545" y="165" text-anchor="middle" fill="#D14343">reject 拒绝</text>
<!-- approved actions -->
<rect x="560" y="230" width="320" height="58" rx="6" fill="#F0F6FF" stroke="#2563AC"/>
<line x1="720" y1="76" x2="720" y2="228" stroke="#2563AC" stroke-dasharray="4 4" marker-end="url(#ar2)"/>
<text x="720" y="250" text-anchor="middle" fill="#154072">approved 后续动作(POSTservice 内判权):</text>
<text x="720" y="268" text-anchor="middle" fill="#154072">return 退单(冲库存+冲应收/应付) · confirm-cost 确认进价(入库)</text>
<text x="720" y="283" text-anchor="middle" fill="#154072">confirm-sale 确认售价(出库,重算应收/利润)</text>
<!-- draft edit note -->
<text x="120" y="160" text-anchor="middle">draft 可改/可删</text>
</g>
</svg>
</div>
<h3>2.7 AutoMigrate 与启动回填</h3>
<ul style="font-size:13px">
<li><b>AutoMigrate</b>main.go):启动时对 31 个 model 只增不删地同步表结构;另幂等显式建 <code>uk_shop_code(products)</code> 唯一索引与 <code>idx_so_items_shop_code(stock_out_items)</code>(嵌入字段无法用 struct tag 表达)。</li>
<li><b>BackfillPricingColumns</b><code>service/migrate.go</code>):2026-07 定价字段消歧的一次性迁移,旧列(unit_price/total_price/total_amount)值拷入新列(cost_price/cost_amount/cost_total/sale_total/sale_amount/profit_total),幂等(新列为 0 才拷),旧列观察一版后手动 DROP;全新安装(无旧列)整体跳过。</li>
<li><b>backfillPartnerPinyin</b>main.go):为 <code>name_pinyin</code> 为空的存量往来单位补拼音搜索列;products 同机制由 handler 写入时生成 + 启动兜底。</li>
</ul>
<h3>2.8 拼音搜索</h3>
<p class="gdesc">源:<code>internal/util/pinyin.go</code>go-pinyin)。<code>ToPinyin(name)</code> 返回全拼 + 首字母,Create/Update 时自动写入 <code>name_pinyin / name_initials</code> 两列;搜索 SQL 同时 LIKE 匹配 name、code、name_pinyin、name_initials(汉字/全拼/首字母三种输入都命中)。新增需要拼音搜索的实体直接复用 <code>ToPinyin()</code></p>
<h3>2.9 客户端异常上报</h3>
<p class="gdesc">客户端 <code>POST /api/v1/public/errors</code>per-IP 限流)→ <code>error_reports</code> 表;超管在 <code>GET /api/v1/admin/errors</code> 查看。前端两处统一捕获(main.dart 的 runZonedGuarded + Dio 拦截器上报 5xx),业务代码只在技术性异常处手动 <code>reportError(e, st)</code>,已知业务错误(AppException)不上报。</p>
<!-- ============================================================ -->
<h2 id="frontend">3. 前端(Flutter</h2>
<h3>3.1 目录分层</h3>
<div class="card"><table>
<thead><tr><th style="width:26%">目录</th><th>职责</th></tr></thead>
<tbody>
<tr><td class="mono">lib/core/</td><td>横切基建:api(Dio 封装,401 自动刷新)、authAuthNotifier + 持久化)、configAppConfigBASE_URL 注入)、routergo_router 登录重定向)、themetoken 三主题)、responsive、update(应用内更新)、errorsreportError</td></tr>
<tr><td class="mono">lib/models/</td><td>与后端 JSON 对应的数据模型</td></tr>
<tr><td class="mono">lib/providers/</td><td>Riverpod 状态管理,每业务模块一个 provider(列表缓存/刷新/心跳等)</td></tr>
<tr><td class="mono">lib/repositories/</td><td>数据访问层:封装 API 调用,供 provider 消费</td></tr>
<tr><td class="mono">lib/screens/</td><td>页面(auth/shell/stock_in/stock_out/inventory/partners/finance/products/devices/settings/about/public/shared</td></tr>
<tr><td class="mono">lib/widgets/</td><td>共享组件;<code>widgets/ds/</code> 为设计系统组件库(唯一样式来源,见 3.3)</td></tr>
</tbody>
</table></div>
<h3>3.2 数据流</h3>
<p class="gdesc">单向:Screen 只组合 ds 组件并 watch providerprovider 调 repositoryrepository 走 DioApiClient 统一注入 token、401 自动刷新、5xx 自动上报)。</p>
<div class="svgwrap">
<svg viewBox="0 0 900 90" role="img" aria-label="前端数据流">
<defs>
<marker id="ar3" markerWidth="8" markerHeight="8" refX="7" refY="3.5" orient="auto">
<path d="M0,0 L8,3.5 L0,7 Z" fill="#6E7888"/>
</marker>
</defs>
<g font-size="12.5" text-anchor="middle" font-weight="700">
<rect x="20" y="22" width="140" height="46" rx="8" fill="#F0F4FF" stroke="#2563AC"/><text x="90" y="43" fill="#154072">Screen</text><text x="90" y="59" font-size="10.5" font-weight="400" fill="#6E7888">screens/ + ds 组件</text>
<rect x="205" y="22" width="150" height="46" rx="8" fill="#F0F4FF" stroke="#2563AC"/><text x="280" y="43" fill="#154072">Provider</text><text x="280" y="59" font-size="10.5" font-weight="400" fill="#6E7888">Riverpod 状态/缓存</text>
<rect x="400" y="22" width="150" height="46" rx="8" fill="#F0F4FF" stroke="#2563AC"/><text x="475" y="43" fill="#154072">Repository</text><text x="475" y="59" font-size="10.5" font-weight="400" fill="#6E7888">API 调用封装</text>
<rect x="595" y="22" width="140" height="46" rx="8" fill="#FFF4E5" stroke="#B45309"/><text x="665" y="43" fill="#B45309">Dio</text><text x="665" y="59" font-size="10.5" font-weight="400" fill="#6E7888">401 刷新 · 5xx 上报</text>
<rect x="780" y="22" width="100" height="46" rx="8" fill="#E6F3EC" stroke="#2E8B57"/><text x="830" y="49" fill="#2E8B57">API</text>
</g>
<line x1="160" y1="45" x2="203" y2="45" stroke="#6E7888" marker-end="url(#ar3)"/>
<line x1="355" y1="45" x2="398" y2="45" stroke="#6E7888" marker-end="url(#ar3)"/>
<line x1="550" y1="45" x2="593" y2="45" stroke="#6E7888" marker-end="url(#ar3)"/>
<line x1="735" y1="45" x2="778" y2="45" stroke="#6E7888" marker-end="url(#ar3)"/>
</svg>
</div>
<h3>3.3 ds 设计系统(单一真相源)</h3>
<p class="gdesc">UI 的真相源是原型 <code>.superpowers/prototype/</code>tokens + <code>atoms.css</code>);Flutter 侧一对一镜像,屏只组合 ds 组件、不自己堆样式。令牌层由 <code>lib/core/theme/token_source/tokens.css</code> codegen 生成 <code>app_tokens.g.dart / app_dims.g.dart / app_chrome.g.dart</code>——改样式改源后重跑脚本,<b>禁止手改 .g 文件、禁止硬编码色值</b></p>
<div class="card"><table>
<thead><tr><th style="width:30%">widgets/ds/ 文件</th><th>镜像对象</th></tr></thead>
<tbody>
<tr><td class="mono">ds_atoms.dart</td><td>atoms.css 原子组件全集(按钮/输入/徽章/卡片…),尺寸引 AppDims、颜色引 context.tokens</td></tr>
<tr><td class="mono">ds_table.dart</td><td>原型 .table + .toolbar + .pagertoolbar 与表连成一卡,pager 卡外透明)</td></tr>
<tr><td class="mono">ds_kpi.dart</td><td>原型 .kpi 汇总卡(图标块 .ic 五种 tone)</td></tr>
<tr><td class="mono">ds_menu.dart</td><td>原型 .menu 下拉(定位规则照抄 shell.js openMenu</td></tr>
<tr><td class="mono">ds_toast.dart</td><td>原型 .toast 1:1(单例顶替、2.2s 自动消失)</td></tr>
<tr><td class="mono">ds_bar_chart.dart</td><td>原型 finance.html 收支趋势分组柱状图(度量逐像素照抄)</td></tr>
<tr><td class="mono">grid_combo_cell.dart</td><td>表格内联可搜索下拉单元格(入库/出库编辑网格用)</td></tr>
</tbody>
</table></div>
<p class="gdesc"><b>闸机 1 — 代码端</b><code>client/tool/check_ds_code.mjs</code> 扫描 lib/ 禁 <code>Color(0x..)</code><code>Colors.x</code> 硬编码(合理特例需 <code>// ds-ignore: 理由</code>),<code>--changed --strict</code> 供 pre-commit。</p>
<h3>3.4 golden × 3 主题 + fidelity 像素闸</h3>
<ul style="font-size:13px">
<li><b>golden</b><code>client/test/golden/*_golden_test.dart</code> 对每屏 × 三主题(a/b/c)出基准图 <code>test/golden/goldens/&lt;prefix&gt;_&lt;theme&gt;.png</code>;更新用 <code>flutter test --update-goldens</code></li>
<li><b>闸机 2 — fidelity</b><code>tools/fidelity.mjs</code><b>原型截图当基准</b>Playwright 截 <code>.superpowers/prototype/screens/*.html</code>,注入 Noto Sans SC 统一字体)与 Flutter golden 做 pixelmatch diff,超逐屏校准阈值即 exit 1;支持 zones 分区阈值抓局部错位。屏注册表在 <code>tools/screens.mjs</code>fidelity 与人工目检 <code>tools/ds-compare.mjs</code> 共用)。</li>
</ul>
<pre><code>cd client && flutter test --update-goldens test/golden/inventory_list_golden_test.dart
node tools/fidelity.mjs inventory # 仓库根运行;--themes a,b,c--update 刷新原型基准</code></pre>
<h3>3.5 响应式</h3>
<p class="gdesc">断点统一 <code>context.isMobile</code>(宽 &lt; 600<code>core/responsive/responsive.dart</code>),弹窗宽度 <code>context.dialogWidth(X)</code>(≤ 屏宽 92%)。列表屏 <code>DataTableCard</code> 必传 <code>mobileCards</code>(窄屏卡片流),窄屏导航为 Drawer 抽屉。平台判断先 <code>kIsWeb</code><code>dart:io Platform</code>。后端 URL 一律 <code>AppConfig</code><code>--dart-define=BASE_URL=...</code> 注入),禁止硬编码。</p>
<!-- ============================================================ -->
<h2 id="db">4. 数据库</h2>
<p class="gdesc">MySQL 8 · 所有业务表含 <code>shop_id</code> 租户隔离列。<code>schema.sql</code> 定义 26 张表;另有 5 张仅由 AutoMigrate 创建(4 张商品属性字典 + <code>error_reports</code>),共 31 张。<b>完整列级文档见 <a href="../db-schema.html">db-schema.html</a></b></p>
<h3>4.1 分组速览</h3>
<div class="card"><table>
<thead><tr><th style="width:14%">分组</th><th style="width:26%"></th><th>要点</th></tr></thead>
<tbody>
<tr><td>租户与账号</td><td class="mono">shops<br>users<br>user_sessions<br>login_attempts</td><td>shops=租户根(code 唯一);users 角色 ENUM 四档、<code>uk_shop_username</code>user_sessions 支撑 sid 会话校验/踢人(revoked_at、last_seen_at);login_attempts 记失败登录供锁定</td></tr>
<tr><td>授权</td><td class="mono">licenses<br>license_devices<br>license_codes</td><td>时长兑换券模型:license_codes 为平台码池(短码),激活兑入 licensesexpires_at 驱动 LicenseGuard),license_devices 记设备绑定</td></tr>
<tr><td>商品与字典</td><td class="mono">products<br>product_categories<br>product_images<br>product_*_options ×6<br>product_description_docs</td><td><b>product = 特有产品/序列号(非 SKU)</b>code 同店唯一(uk_shop_code),name/series/spec=品牌/型号/版本,含 name_pinyin/name_initials 搜索列、public_id 公开页;字典表(名称/系列/规格/产地/保质期/储存/介绍)供入库选取后建 product</td></tr>
<tr><td>仓库与往来</td><td class="mono">warehouses<br>partners</td><td>partners.type=supplier/customer,含拼音搜索列</td></tr>
<tr><td>入库</td><td class="mono">stock_in_orders<br>stock_in_items</td><td>单据状态机(§2.6)、<code>uk_order_no(shop_id,order_no)</code>;明细含快照列 + cost_price/cost_amount + 批次/生产日期/有效期</td></tr>
<tr><td>出库</td><td class="mono">stock_out_orders<br>stock_out_items</td><td>同状态机;单头 sale_total/profit_total;明细 cost_price(成本快照)+ sale_price/sale_amount<code>idx_so_items_shop_code</code> 支撑按编码反查</td></tr>
<tr><td>库存</td><td class="mono">inventories<br>inventory_logs<br>inventory_checks<br>inventory_check_items</td><td>inventories=批次行(每行一个入库批,stock_in_item_id 溯源,idx_fifo 支撑先进先出);logs 记每次变动 qty_before/after + ref;盘点单 draft→completed</td></tr>
<tr><td>财务</td><td class="mono">finance_records</td><td>type=receivable/payable/receipt/paymentbalance=操作后余额,status=open/closedref_type/ref_id 关联单据</td></tr>
<tr><td>其他</td><td class="mono">number_rules<br>feedbacks<br>error_reports</td><td>number_rules 每店每类型一行(uk_shop_type),单号=前缀+日期+6 位序号;feedbacks 意见反馈;error_reports 客户端异常</td></tr>
</tbody>
</table></div>
<h3>4.2 定价字段口径(2026-07 消歧)</h3>
<div class="card"><table>
<thead><tr><th style="width:30%">新列</th><th style="width:18%">旧列(弃用)</th><th>口径</th></tr></thead>
<tbody>
<tr><td class="mono">stock_in_items.cost_price</td><td class="mono">unit_price</td><td>进价 · 单瓶</td></tr>
<tr><td class="mono">stock_in_items.cost_amount</td><td class="mono">total_price</td><td>总进价 = quantity × cost_price</td></tr>
<tr><td class="mono">stock_in_orders.cost_total</td><td class="mono">total_amount</td><td>应付合计 = Σ 明细 cost_amount</td></tr>
<tr><td class="mono">stock_out_items.cost_price</td><td class="mono">unit_price</td><td>成本单价(入库成本快照)</td></tr>
<tr><td class="mono">stock_out_items.cost_amount</td><td class="mono">total_price</td><td>成本小计 = quantity × cost_price</td></tr>
<tr><td class="mono">stock_out_items.sale_price</td><td class="mono">—(新增)</td><td>实际销售单价,0 = 待定价</td></tr>
<tr><td class="mono">stock_out_items.sale_amount</td><td class="mono">—(新增)</td><td>售价小计 = quantity × sale_price(待定价 = 0</td></tr>
<tr><td class="mono">stock_out_orders.sale_total</td><td class="mono">total_amount</td><td>应收合计 = Σ 明细 sale_amount</td></tr>
<tr><td class="mono">stock_out_orders.profit_total</td><td class="mono">—(新增)</td><td>总利润 = Σ(sale_price&gt;0 ? (sale_pricecost_price)×qty : 0),建单落库,确认售价/进价联动重算</td></tr>
</tbody>
</table></div>
<div class="callout warn">行利润只统计<b>已定价</b>行(sale_price&gt;0);待定价行不计利润也不计应收。旧列已停止读写,由启动回填拷值(§2.7),观察一版后手动 DROP——期间<b></b>新代码引用旧列。</div>
<h3>4.3 关键机制</h3>
<ul style="font-size:13px">
<li><b>批次行 + 快照列</b><code>inventories</code> 每行 = 某 product 在某仓的一个入库批;出入库明细与库存行都带 <code>product_code/product_name/series/spec/...</code> 快照列(导入/审核时拷贝),显示优先 product、回退快照(<code>COALESCE</code> / 前端 <code>lineOrProduct</code>)。<b>快照列保留,勿删。</b></li>
<li><b>生成列</b><code>inventory_check_items.diff_qty</code><code>GENERATED ALWAYS AS (actual_qty - system_qty) STORED</code>,不可写。</li>
<li><b>FOR UPDATE 并发锁</b>:单号生成(number_rules)、库存扣减(inventories)等 check-then-act 一律事务内锁行;<code>products.code</code><code>uk_shop_code</code> 时 max+1 重试(最多 5 次)。</li>
<li><b>custom_fields JSON</b>:新业务字段优先放 JSON 扩展列,不轻易加物理列。</li>
</ul>
<!-- ============================================================ -->
<h2 id="api">5. API 参考</h2>
<p class="gdesc">逐条核对自 <code>backend/internal/router/router.go</code>。权限图例:<span class="tag pub">公开</span> 无需登录(多带 per-IP 限流);<span class="tag jwt">JWT</span> 登录即可(readonly 角色写操作被 ReadOnly 拦);<span class="tag admin">Admin</span> = AdminOnly<span class="tag super">Super</span> = SuperAdminOnly<span class="tag rl">限流</span> = 独立 IP 限流。所有 <span class="tag jwt">JWT</span> 路由另受 per-Shop 限流;业务组(商品及以下)还受 LicenseGuard(§2.4)。</p>
<h3>5.1 根路由 / 认证 / 公开接口</h3>
<div class="card"><table>
<thead><tr><th style="width:8%">Method</th><th style="width:38%">Path</th><th>说明</th><th style="width:16%">权限</th></tr></thead>
<tbody>
<tr><td class="m get">GET</td><td class="mono">/health</td><td>健康检查(连通性探测)</td><td><span class="tag pub">公开</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/version</td><td>版本清单(实时读 version.yaml</td><td><span class="tag pub">公开</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/product/:public_id</td><td>扫码分享页(OG 标签注入的 index.html</td><td><span class="tag pub">公开</span> <span class="tag rl">限流</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/auth/login</td><td>登录(失败锁定 + IP 限流双保险)</td><td><span class="tag pub">公开</span> <span class="tag rl">限流</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/auth/refresh</td><td>刷新 token</td><td><span class="tag pub">公开</span> <span class="tag rl">限流</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/public/products/:public_id</td><td>公开商品详情(扫码页数据)</td><td><span class="tag pub">公开</span> <span class="tag rl">限流</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/public/shops/:shop_code/products</td><td>店铺公开商品列表(限流最紧)</td><td><span class="tag pub">公开</span> <span class="tag rl">限流</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/public/release</td><td>版本 + 下载链接 + changelog(官网/更新用)</td><td><span class="tag pub">公开</span> <span class="tag rl">限流</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/public/errors</td><td>客户端异常上报 → error_reports</td><td><span class="tag pub">公开</span> <span class="tag rl">限流</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/public/register</td><td>门店注册</td><td><span class="tag pub">公开</span> <span class="tag rl">限流</span></td></tr>
</tbody>
</table></div>
<h3>5.2 会话 / 授权(豁免 LicenseGuard</h3>
<div class="card"><table>
<thead><tr><th style="width:8%">Method</th><th style="width:38%">Path</th><th>说明</th><th style="width:16%">权限</th></tr></thead>
<tbody>
<tr><td class="m post">POST</td><td class="mono">/api/v1/auth/logout</td><td>登出(撤销会话)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/auth/ping</td><td>在线心跳</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/sessions</td><td>在线会话列表</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m del">DELETE</td><td class="mono">/api/v1/sessions/:id</td><td>强制下线</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/license/info</td><td>授权状态(与 LicenseGuard 同一取数口径)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/license/activate</td><td>兑换/激活授权码(锁定期仍可用)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/license/verify</td><td>校验当前授权</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/license/deactivate</td><td>解绑设备</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/license/devices</td><td>已绑定设备列表</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/license/purchase</td><td>在线购买/续费下单,返回 pay 收银台 pay_url(契约 pay-contract v1.0.0</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/license/purchase/:out_trade_no</td><td>购买单状态(支付结果页轮询,本店可见)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/pay/callback</td><td>pay 支付成功 webhook(HMAC 验签+幂等+金额核对后续期)</td><td><span class="tag pub">公开</span></td></tr>
</tbody>
</table></div>
<h3>5.3 商品 / 仓库 / 往来单位</h3>
<div class="card"><table>
<thead><tr><th style="width:8%">Method</th><th style="width:38%">Path</th><th>说明</th><th style="width:16%">权限</th></tr></thead>
<tbody>
<tr><td class="m get">GET</td><td class="mono">/api/v1/products</td><td>商品列表(汉字/全拼/首字母/编码搜索)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/products</td><td>新建商品(独立序列号,code 自增 max+1)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/products/find-or-create</td><td>按编号找/建 product(导入用;禁止按名称合并)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/products/:id/detail</td><td>商品详情(含图片/字典属性)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/products/:id/price-history</td><td>进价/售价历史</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/products/:id/qrcode</td><td>公开页二维码</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/products/:id</td><td>更新商品</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m del">DELETE</td><td class="mono">/api/v1/products/:id</td><td>删除商品(软删)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/products/:id/images</td><td>上传商品图片</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m del">DELETE</td><td class="mono">/api/v1/products/:id/images/:image_id</td><td>删除商品图片</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/warehouses</td><td>仓库列表</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/warehouses</td><td>新建仓库</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/warehouses/:id</td><td>更新仓库</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m del">DELETE</td><td class="mono">/api/v1/warehouses/:id</td><td>删除仓库</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/partners</td><td>往来单位列表(?type&amp;keyword,拼音搜索)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/partners</td><td>新建往来单位</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/partners/:id</td><td>更新往来单位</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m del">DELETE</td><td class="mono">/api/v1/partners/:id</td><td>删除往来单位</td><td><span class="tag jwt">JWT</span></td></tr>
</tbody>
</table></div>
<h3>5.4 入库 / 出库</h3>
<div class="card"><table>
<thead><tr><th style="width:8%">Method</th><th style="width:38%">Path</th><th>说明</th><th style="width:16%">权限</th></tr></thead>
<tbody>
<tr><td class="m get">GET</td><td class="mono">/api/v1/stock-in/orders</td><td>入库单列表(?status&amp;keyword,编码双路匹配)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/stock-in/summary</td><td>入库汇总(KPI 卡)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/stock-in/orders/:id</td><td>入库单详情</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/stock-in/orders</td><td>创建入库单(每明细行新建独立 product)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-in/orders/:id</td><td>更新(仅 draft</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m del">DELETE</td><td class="mono">/api/v1/stock-in/orders/:id</td><td>删除(仅 draft</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-in/orders/:id/submit</td><td>提交审核 draft→pending</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-in/orders/:id/approve</td><td>审核通过(事务:+库存 +流水 +应付)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-in/orders/:id/reject</td><td>拒绝</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-in/orders/:id/withdraw</td><td>撤回 pending→draft(管理员任意/操作员本人)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/stock-in/orders/:id/return</td><td>退单(库存删除 + 冲应付;service 内判管理员)</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/stock-in/orders/:id/confirm-cost</td><td>确认进价(暂估 0 价→真实价,前向补偿)</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/stock-out/orders</td><td>出库单列表(?status&amp;keyword</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/stock-out/summary</td><td>出库汇总</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/stock-out/orders/:id</td><td>出库单详情</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/stock-out/orders</td><td>创建出库单</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-out/orders/:id</td><td>更新(仅 draft</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m del">DELETE</td><td class="mono">/api/v1/stock-out/orders/:id</td><td>删除(仅 draft</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-out/orders/:id/submit</td><td>提交审核</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-out/orders/:id/approve</td><td>审核通过(校验库存充足,事务扣减 FIFO)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-out/orders/:id/reject</td><td>拒绝</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/stock-out/orders/:id/withdraw</td><td>撤回 pending→draft</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/stock-out/orders/:id/return</td><td>退单(库存加回 + 冲应收;管理员或本人)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/stock-out/orders/:id/confirm-sale</td><td>确认售价(先出后定价,重算应收/利润)</td><td><span class="tag admin">Admin</span></td></tr>
</tbody>
</table></div>
<h3>5.5 库存 / 财务 / 用户 / 店铺 / 反馈 / 编号规则</h3>
<div class="card"><table>
<thead><tr><th style="width:8%">Method</th><th style="width:38%">Path</th><th>说明</th><th style="width:16%">权限</th></tr></thead>
<tbody>
<tr><td class="m get">GET</td><td class="mono">/api/v1/inventory</td><td>库存列表(?warehouse_id&amp;keyword&amp;series&amp;spec&amp;in_stock</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/inventory/summary</td><td>库存汇总</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/inventory/logs</td><td>库存流水</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/inventory/:id/remark</td><td>更新库存行备注</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/inventory/checks</td><td>创建盘点单</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/inventory/checks/:id</td><td>盘点单详情</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/inventory/checks/:id/complete</td><td>完成盘点(盘盈盘亏落库)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/finance/records</td><td>财务流水列表(含日期区间)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/finance/records</td><td>手工记账(收款/付款)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/finance/records/:id/close</td><td>结清单条应收/应付</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/finance/records/close-by-ref</td><td>按关联单据结清</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/finance/summary</td><td>财务汇总</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/finance/trend</td><td>收支趋势(柱状图数据)</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/users</td><td>用户列表</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/users</td><td>新建用户</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/users/:id</td><td>更新用户(角色/启用)</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/users/:id/reset-password</td><td>重置密码</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/shop/info</td><td>店铺信息</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/shop/info</td><td>更新店铺信息</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/shop/logo</td><td>上传店铺 logo</td><td><span class="tag admin">Admin</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/feedback</td><td>提交意见反馈</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/feedback/images</td><td>上传反馈附图</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/number-rules</td><td>单号规则列表</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m put">PUT</td><td class="mono">/api/v1/number-rules/:id</td><td>更新单号规则</td><td><span class="tag jwt">JWT</span></td></tr>
</tbody>
</table></div>
<h3>5.6 数据导入(Excel</h3>
<div class="card"><table>
<thead><tr><th style="width:8%">Method</th><th style="width:38%">Path</th><th>说明</th><th style="width:16%">权限</th></tr></thead>
<tbody>
<tr><td class="m post">POST</td><td class="mono">/api/v1/import/products</td><td>导入商品</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/import/partners</td><td>导入往来单位</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/import/product-names</td><td>导入名称字典</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/import/product-series</td><td>导入系列字典</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/import/product-specs</td><td>导入规格字典</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/import/product-codes</td><td>导入商品编码</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/import/stock-in</td><td>导入历史入库单</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/import/stock-out</td><td>导入历史出库单</td><td><span class="tag jwt">JWT</span></td></tr>
<tr><td class="m post">POST</td><td class="mono">/api/v1/import/inventory</td><td>导入库存(按编号建 product,禁按名称合并)</td><td><span class="tag jwt">JWT</span></td></tr>
</tbody>
</table></div>
<h3>5.7 基础数据字典(/api/v1/product-options</h3>
<p class="gdesc">八组字典同构:每组 <span class="m get">GET</span>(支持 <code>?keyword</code> 服务端搜索)/ <span class="m post">POST</span> / <span class="m put">PUT /:id</span> / <span class="m del">DELETE /:id</span>,共 32 条路由,权限均为 <span class="tag jwt">JWT</span></p>
<div class="card"><table>
<thead><tr><th style="width:32%">子路径</th><th>字典</th></tr></thead>
<tbody>
<tr><td class="mono">/names</td><td>商品名称(品牌)</td></tr>
<tr><td class="mono">/series</td><td>系列(型号)</td></tr>
<tr><td class="mono">/specs</td><td>规格(版本)</td></tr>
<tr><td class="mono">/categories</td><td>香型 / 分类</td></tr>
<tr><td class="mono">/origins</td><td>产地</td></tr>
<tr><td class="mono">/shelf-lives</td><td>保质期</td></tr>
<tr><td class="mono">/storages</td><td>储存方式</td></tr>
<tr><td class="mono">/description-docs</td><td>商品介绍文档</td></tr>
</tbody>
</table></div>
<h3>5.8 超级管理员(/api/v1/admin</h3>
<div class="card"><table>
<thead><tr><th style="width:8%">Method</th><th style="width:38%">Path</th><th>说明</th><th style="width:16%">权限</th></tr></thead>
<tbody>
<tr><td class="m post">POST</td><td class="mono">/api/v1/admin/clear-data</td><td>清空本店业务数据</td><td><span class="tag super">Super</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/admin/reconcile</td><td>库存对账(inventories vs logs</td><td><span class="tag super">Super</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/admin/errors</td><td>客户端异常报告列表</td><td><span class="tag super">Super</span></td></tr>
<tr><td class="m get">GET</td><td class="mono">/api/v1/admin/feedback</td><td>意见反馈列表</td><td><span class="tag super">Super</span></td></tr>
<tr><td class="m patch">PATCH</td><td class="mono">/api/v1/admin/feedback/:id</td><td>更新反馈处理状态</td><td><span class="tag super">Super</span></td></tr>
</tbody>
</table></div>
<!-- ============================================================ -->
<h2 id="ops">6. 数据操作与运维</h2>
<h3>6.1 Schema 管理</h3>
<p class="gdesc"><code>backend/schema/schema.sql</code> = 新装环境的完整 DDL(docker 初始化时执行);<b>线上结构演进只靠启动时 AutoMigrate(只增不删)</b>,没有独立迁移文件。表结构变更 = 同步改 schema.sql + 对应 model。改生产库数据前<b>先备份</b></p>
<h3>6.2 种子数据</h3>
<pre><code>sh scripts/dev.sh seed S001 # 清空并写入 S001 全量测试数据(docker exec 注入 MySQL 容器)
sh scripts/dev.sh seed S002 # 空门店(基础数据相同,无单据/库存)</code></pre>
<p class="gdesc">每店一个 <code>backend/seeds/&lt;shop_code&gt;.sql</code>,文件顶部含 TRUNCATE,每次完整重建。</p>
<h3>6.3 每日备份</h3>
<p class="gdesc"><code>.gitea/workflows/backup.yml</code>:每日北京时间 02:00cron UTC 18:00)在 mac runner(开发者本机)跑 <code>scripts/ci/backup-db.sh</code>——SSH 到阿里主库机 dump MySQL,落到本机 <code>~/jiu-db-backups</code>Telegram 通知结果;也可 workflow_dispatch 手动触发(外网 nginx 挡 API 触发,需 Forgejo UI 操作)。</p>
<h3>6.4 一次性工具(backend/cmd/</h3>
<div class="card"><table>
<thead><tr><th style="width:26%">工具</th><th>用途</th></tr></thead>
<tbody>
<tr><td class="mono">cmd/seed</td><td>初始化/重置测试数据(<code>--reset</code> 删表重建、<code>--clear</code> 清数据)</td></tr>
<tr><td class="mono">cmd/gencode</td><td>平台方批量生成授权兑换券写入码池:<code>go run ./cmd/gencode -type annual -days 365 -count 100 -batch 2026-summer</code></td></tr>
<tr><td class="mono">cmd/import-history</td><td>旧系统历史进销存一次性迁入(历史只读、4 列快照直存、不回放库存)</td></tr>
<tr><td class="mono">cmd/fix-inventory-products</td><td>修复历史库存导入按「名称|系列|规格」错误合并 product 的数据(按快照编号重指)</td></tr>
<tr><td class="mono">cmd/backfill-pinyin</td><td>为存量 products 补 name_pinyin/name_initials(现启动已自动兜底)</td></tr>
</tbody>
</table></div>
<h3>6.5 生产环境配置</h3>
<p class="gdesc">生产 env 在 <code>/opt/jiu/config/production.env</code>systemd EnvironmentFile 加载):<code>SERVER_PORT=8081</code><code>DATABASE_DSN</code><code>JWT_SECRET</code>(生产必须非默认值否则拒绝启动)、CORS origin(生产禁 <code>*</code>)。密钥不入 git,需要时从 Bitwarden 取。</p>
<!-- ============================================================ -->
<h2 id="workflow">7. 开发环境与工作流</h2>
<h3>7.1 本地开发</h3>
<pre><code># 首次:起 MySQL 容器(127.0.0.1:3306 + Adminer :8888
docker compose -f deploy/docker-compose.yml up -d
# 一键起前后端(后端代码未变则跳过重启;--force 强制)
sh scripts/dev.sh run
sh scripts/dev.sh stop
# 测试数据 + 账号(门店编号 S001,密码均 password123
sh scripts/dev.sh seed S001 # admin=管理员 operator=操作员 test=只读</code></pre>
<p class="gdesc">Go 命令前须 <code>export PATH="/opt/homebrew/bin:$PATH"</code>(dev.sh 已内置)。发版前自测「指向线上后端」的 macOS 包:<code>sh scripts/local_test.sh</code>(BASE_URL 固定生产域名,版本取最新 client tag patch+1)。</p>
<h3>7.2 完成标准(DoD 门禁)</h3>
<pre><code># 后端
cd backend && go build ./... && go vet ./... && go test ./...
# 前端
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings && flutter test
# 设计系统闸(改了 UI 时)
cd client && node tool/check_ds_code.mjs --changed # 禁硬编码色
node tools/fidelity.mjs # golden vs 原型像素闸(仓库根)</code></pre>
<div class="callout danger">任一项失败禁止提交、禁止打 tag 发版。通用检查:多租户隔离未破坏(查询都带 shop_id)、schema.sql 与 model 同步、新接口有测试覆盖。</div>
<h3>7.3 Git 提交规范</h3>
<pre><code>{类型}({模块}): {简短说明} # 类型: feat|fix|test|docs|chore|security|refactor
# 模块: backend|client|db|deploy|docs
feat(backend): 新增财务报表接口
docs(api): 财务报表 API 接口文档</code></pre>
<h3>7.4 三条发版流水线</h3>
<p class="gdesc">client / site / server 三条互不影响的流水线,各有 tag 前缀、独立版本序列、独立 CHANGELOG,由 Forgejo CI 按 tag 前缀路由(<code>.gitea/workflows/deploy-*.yml</code> + <code>scripts/ci/</code>)。用 <code>/release &lt;part&gt; [version]</code>:本地 build → test → 更新 CHANGELOG → commit → tag → push;省略版本则自增最新 tag 的 patch。回滚用 <code>manual.yml</code> 输入带前缀 tag,从 Forgejo Release 下载产物重部署。</p>
<div class="svgwrap">
<svg viewBox="0 0 900 400" role="img" aria-label="发版流水线拓扑">
<defs>
<marker id="ar4" markerWidth="8" markerHeight="8" refX="7" refY="3.5" orient="auto">
<path d="M0,0 L8,3.5 L0,7 Z" fill="#6E7888"/>
</marker>
</defs>
<g font-size="11.5">
<!-- lane labels -->
<rect x="20" y="20" width="110" height="40" rx="6" fill="#F0F4FF" stroke="#2563AC"/>
<text x="75" y="37" text-anchor="middle" font-weight="700" fill="#154072">client-v*</text>
<text x="75" y="52" text-anchor="middle" fill="#6E7888" font-size="10">CHANGELOG-client</text>
<rect x="20" y="230" width="110" height="40" rx="6" fill="#F0F4FF" stroke="#2563AC"/>
<text x="75" y="247" text-anchor="middle" font-weight="700" fill="#154072">site-v*</text>
<text x="75" y="262" text-anchor="middle" fill="#6E7888" font-size="10">CHANGELOG-site</text>
<rect x="20" y="310" width="110" height="40" rx="6" fill="#F0F4FF" stroke="#2563AC"/>
<text x="75" y="327" text-anchor="middle" font-weight="700" fill="#154072">server-v*</text>
<text x="75" y="342" text-anchor="middle" fill="#6E7888" font-size="10">CHANGELOG-server</text>
<!-- client lane: workflow box -->
<rect x="160" y="20" width="560" height="180" rx="8" fill="#fff" stroke="#154072" stroke-width="1.2"/>
<text x="440" y="40" text-anchor="middle" font-weight="700" fill="#154072">deploy-client.yml — 五端构建矩阵</text>
<!-- mac serial chain -->
<text x="180" y="63" fill="#6E7888" font-size="10.5">mac runner(本机,容量 1,串行链):</text>
<rect x="180" y="72" width="118" height="34" rx="5" fill="#E6F3EC" stroke="#2E8B57"/><text x="239" y="93" text-anchor="middle" fill="#2E8B57">build-client-web</text>
<rect x="322" y="72" width="100" height="34" rx="5" fill="#E6F3EC" stroke="#2E8B57"/><text x="372" y="93" text-anchor="middle" fill="#2E8B57">build-macos</text>
<rect x="446" y="72" width="108" height="34" rx="5" fill="#E6F3EC" stroke="#2E8B57"/><text x="500" y="93" text-anchor="middle" fill="#2E8B57">build-android</text>
<rect x="578" y="72" width="90" height="34" rx="5" fill="#E6F3EC" stroke="#2E8B57"/><text x="623" y="93" text-anchor="middle" fill="#2E8B57">build-ios</text>
<line x1="298" y1="89" x2="320" y2="89" stroke="#6E7888" marker-end="url(#ar4)"/>
<line x1="422" y1="89" x2="444" y2="89" stroke="#6E7888" marker-end="url(#ar4)"/>
<line x1="554" y1="89" x2="576" y2="89" stroke="#6E7888" marker-end="url(#ar4)"/>
<!-- windows parallel -->
<text x="180" y="130" fill="#6E7888" font-size="10.5">windows runner(并行):</text>
<rect x="180" y="138" width="150" height="34" rx="5" fill="#FFF4E5" stroke="#B45309"/><text x="255" y="159" text-anchor="middle" fill="#B45309">build-windows (Inno)</text>
<!-- release job -->
<rect x="520" y="138" width="180" height="40" rx="5" fill="#F0F4FF" stroke="#2563AC"/>
<text x="610" y="155" text-anchor="middle" fill="#154072" font-weight="700">release-deploy-client</text>
<text x="610" y="170" text-anchor="middle" fill="#6E7888" font-size="10">收齐产物 → Release → 部署</text>
<line x1="623" y1="106" x2="617" y2="136" stroke="#6E7888" marker-end="url(#ar4)"/>
<line x1="330" y1="155" x2="518" y2="157" stroke="#6E7888" marker-end="url(#ar4)"/>
<text x="440" y="192" text-anchor="middle" fill="#6E7888" font-size="10">Android 需 ANDROID_* / iOS TestFlight 需 IOS_*·APPSTORE_* secrets,未配置优雅跳过</text>
<line x1="130" y1="40" x2="158" y2="40" stroke="#6E7888" marker-end="url(#ar4)"/>
<!-- site lane -->
<rect x="160" y="230" width="560" height="44" rx="8" fill="#fff" stroke="#154072" stroke-width="1.2"/>
<text x="440" y="257" text-anchor="middle" fill="#154072">deploy-site.yml — Eleventy 构建 → rsync /opt/jiu/marketingnginx 根路径)</text>
<line x1="130" y1="250" x2="158" y2="250" stroke="#6E7888" marker-end="url(#ar4)"/>
<!-- server lane -->
<rect x="160" y="310" width="560" height="44" rx="8" fill="#fff" stroke="#154072" stroke-width="1.2"/>
<text x="440" y="337" text-anchor="middle" fill="#154072">deploy-server.yml — go build+test → Release → 部署 jiu-server + nginx/systemd</text>
<line x1="130" y1="330" x2="158" y2="330" stroke="#6E7888" marker-end="url(#ar4)"/>
<!-- deploy target -->
<rect x="750" y="130" width="130" height="130" rx="8" fill="#E6F3EC" stroke="#2E8B57" stroke-width="1.5"/>
<text x="815" y="170" text-anchor="middle" font-weight="700" fill="#2E8B57">阿里云主机</text>
<text x="815" y="190" text-anchor="middle" fill="#2E8B57" font-size="10.5">/opt/jiu/*</text>
<text x="815" y="207" text-anchor="middle" fill="#2E8B57" font-size="10.5">Forgejo Release</text>
<line x1="700" y1="158" x2="748" y2="175" stroke="#6E7888" marker-end="url(#ar4)"/>
<line x1="720" y1="252" x2="748" y2="225" stroke="#6E7888" marker-end="url(#ar4)"/>
<line x1="720" y1="332" x2="748" y2="250" stroke="#6E7888" marker-end="url(#ar4)"/>
<!-- telegram -->
<rect x="750" y="290" width="130" height="40" rx="8" fill="#F0F4FF" stroke="#2563AC"/>
<text x="815" y="315" text-anchor="middle" fill="#154072">Telegram 通知</text>
<line x1="815" y1="260" x2="815" y2="288" stroke="#6E7888" marker-end="url(#ar4)"/>
</g>
</svg>
</div>
<div class="card"><table>
<thead><tr><th style="width:12%">part</th><th style="width:34%">范围</th><th style="width:14%">tag 前缀</th><th>要点</th></tr></thead>
<tbody>
<tr><td><b>client</b></td><td class="mono">client/ 全平台 + version.yaml</td><td class="mono">client-v*</td><td>version.yaml 归 client;后端 /version 与 /public/release <b>每请求实时读</b>——client 发版立即生效,不重启后端、不重建官网</td></tr>
<tr><td><b>site</b></td><td class="mono">web/ 营销站(不含 Web 版 app</td><td class="mono">site-v*</td><td>官网下载页运行时 fetch /public/release 动态刷新版本徽章/链接/更新日志</td></tr>
<tr><td><b>server</b></td><td class="mono">backend/ + nginx/systemd 基建</td><td class="mono">server-v*</td><td>nginx conf、jiu.service 归 server 流水线</td></tr>
</tbody>
</table></div>
<pre><code>/release client 1.1.1 # 指定版本
/release server # 省略则自增最新 server-v* tag 的 patch</code></pre>
<p class="gdesc">CI 脚本在 <code>scripts/ci/</code><code>lib-forgejo.sh</code>(公共函数)+ <code>compile-{client-web,site,backend,macos,android,ios,windows}.sh</code> + <code>release-*.sh</code> + <code>deploy-*.sh</code>。runnermac = 开发者本机(launchd 看门狗)、windows = 另一台机(nssm)、Forgejo 在 NASgit.51yanmei.com)。</p>
<hr style="border:none;border-top:1px solid var(--border);margin:36px 0 12px">
<div class="sub">相关文档:<a href="../db-schema.html">数据库 Schema(列级)</a> · <a href="../context/project.md">项目上下文 project.md</a> · <a href="../index.html">文档索引</a></div>
</main>
</body>
</html>