# 代码审查报告 — 提交前复审(auth 双屏重建 + DS 原子扩展 + 备案 https 回切) **审查时间**:2026-07-03(第二轮,接 `docs/review/2026-07-03-uncommitted-review.md`) **变更范围**:git diff HEAD 210 个文件(+11354 / −10986)+ 约 90 个未跟踪文件(auth_shared / ds_toast / ds_menu / users_screen / golden 基线等) **已验证事实**(调用方提供):`flutter analyze` 0 error、251 测试全过、`check_ds_code` 0 违规、`go build/vet/test` 全过 ## 总体评价 本轮新增改动质量高,无阻塞提交的问题。登录/注册双屏照原型重建后,dispose 路径(Timer/OverlayEntry/FocusNode/Controller)总体齐全;上一轮 6 条 should-fix 中 4 条已修复(https 回切一并解决 SHOULD-001/002,manual.yml 兼容变量解决 003,DsButton 置灰解决 004),SEC-001/002/003 修复无回归且补了跨租户回归测试。剩余问题集中在登录屏两处小竞态/生命周期缝隙和几条运维/文档收尾项。 --- ## 必须修复(blocking) 无。 --- ## 建议改进(should-fix,non-blocking) ### [SHOULD-101] 登录中途导航离开 → catch 分支 setState 打在已 dispose 的 State 上 **文件**:`client/lib/screens/auth/login_screen.dart:306-311` **问题**:`_login()` 里 `await AuthRepository.login(...)` 期间,「注册新门店 →」「忘记密码?」链接**未随 `_loading` 禁用**,用户可在等待网络时点走(`context.go('/register')`)。此后: - 失败路径:`on AuthException catch` / `catch` 里的 `setState(() => _errorMessage = ...)` 无 `mounted` 守卫(对照 finally 是有的),在已 dispose 的 State 上抛 FlutterError,经全局捕获还会打进错误上报; - 成功路径:`await ref.read(authStateProvider.notifier).login(user)` 在 dispose 后使用 `ref` 同样会抛,且被泛型 catch 捕到后再次走无守卫的 setState。 **建议**: ```dart } on AuthException catch (e) { if (!mounted) return; setState(() => _errorMessage = e.message); } ``` 两个 catch 都补 `mounted` 守卫;或更简单——`_loading` 时把两个 `_Link` 的 onTap 置空。 ### [SHOULD-102] 历史下拉:失焦关闭 Timer 在重新聚焦时未取消,会误关刚打开的下拉 **文件**:`client/lib/screens/auth/login_screen.dart:81-95, 97-111` **问题**:失焦分支安排 `_closeShopTimer = Timer(150ms, _closeShop)`;但 `hasFocus` 分支只调 `_closeShop()` 不取消该 Timer。序列「聚焦 → 点外部失焦(Timer 起跑)→ 150ms 内再点回字段(下拉重新打开)→ 旧 Timer 到点」会把刚打开的下拉关掉,且箭头图标状态与实际不符。username 侧同构。 **建议**:`hasFocus` 分支先 `_closeShopTimer?.cancel()` / `_closeUsernameTimer?.cancel()` 再开下拉。 ### [SHOULD-103] SEC-004 残留:盘点建单仍未校验 warehouse_id / product_id 归属 **文件**:`backend/internal/handler/inventory.go:240-271`(`CreateCheck`) **问题**:本轮 `finance.Create` 已补 `ensureShopRefOpt`(finance.go:96-100),stock_in/out 的 Create/Update 也都补了;但审计报告 SEC-004 点名的另一处 `CreateCheck` 依旧直存请求里的 `req.WarehouseID` 与 `Items[i].ProductID`,塞他店 id 会留下跨店脏引用。读取侧已按 shop_id 收口(Preload 带条件、system_qty 查询带 shop_id),**无信息泄露**,但脏数据会让该盘点行 system_qty 恒为 0、`Items.Product` 恒为 nil。 **建议**:对齐 stock_in.Create 的写法: ```go if err := ensureShopRef(h.db, "warehouses", req.WarehouseID, shopID); err != nil { ... } for i := range req.Items { if err := ensureShopRef(h.db, "products", req.Items[i].ProductID, shopID); err != nil { ... } } ``` ### [SHOULD-104] 证书续期路径依赖营销站 webroot,8 月底到期前需确认打通 **文件**:`deploy/nginx-jiu-ali.conf:107-112`(80 → 443 无条件 301)、注释第 8-9 行 **问题**:证书 2026-08-28 到期(不足两个月)。80 端口整站 `return 301`,certbot HTTP-01 会跟随跳转到 443,challenge 最终落在 `location /` 的 `root /opt/jiu/marketing`。两个脆弱点:① certbot `--webroot` 必须恰好指向 `/opt/jiu/marketing`;② `deploy-site.sh` 若用 `rsync --delete` 同步营销站,续期窗口内可能删掉 challenge 文件。 **建议**:在 80 的 server 块里、`return 301` 之前加一个独立 challenge 位置,让续期与营销站部署解耦: ```nginx location ^~ /.well-known/acme-challenge/ { root /opt/jiu/acme; } ``` 并在 baize 台账登记续期命令用同一 webroot。 ### [SHOULD-105] 项目 CLAUDE.md 仍强制使用本轮已删除的 DataTableCard **文件**:`/Users/wangjia/code/jiu/CLAUDE.md`(「表格列头筛选」「响应式 / 移动端适配」两节) **问题**:`client/lib/widgets/data_table_card.dart` 本轮已删(连同 form_dialog / page_scaffold / search_chip / select_product_dialog / tab_state_provider,经全量 grep 确认**零残留引用**),但项目规则仍写「用 `DataTableCard` 时必须同时传 `mobileCards`」「可筛选列使用 `FilterableColumnHeader`」。规则与代码脱节,后续 agent 会照死文档施工。(`ColDef` 仍存活于 `multi_select_dropdown.dart`,该行不受影响。) **建议**:随本次提交把这两节改写为 DS 重建后的真实套路(ds_atoms / MobileListCard 现状)。 ### [SHOULD-106] 测试失败截图混在未跟踪文件里,勿随本次提交入库 **文件**:`client/test/failures/tmp_date_menu_{isolatedDiff,maskedDiff,masterImage,testImage}.png` **问题**:golden 失败产物;`client/.gitignore` 只忽略 `test/golden/failures/`,没盖住 `test/failures/`,`git add -A` 会把 4 张调试图带进仓库(违背「截图放 tmp 不落 repo」约定)。 **建议**:`client/.gitignore` 补一行 `test/failures/`,并删除这 4 个文件(用 git clean)。 ### [SHOULD-107](上轮 SHOULD-005 收尾)结清后 financePartnerRowsProvider 仍陈旧 **文件**:`client/lib/screens/stock_in/stock_in_list_screen.dart:1117-1119`(stock_out 同构) **问题**:本轮已补 `reload()` 刷新列表(好),但 `financePartnerRowsProvider` 是**非 autoDispose** 的 FutureProvider(finance_provider.dart:113),结清后切到财务屏仍显示结清前的应收应付,直到财务屏自己的刷新路径触发。 **建议**:closeByRef 成功后追加 `ref.invalidate(financePartnerRowsProvider)`(一行)。 --- ## 细节(nit) ### [NIT-101] 登录历史下拉定宽 340,窄屏溢出字段甚至屏幕 **文件**:`client/lib/screens/auth/login_screen.dart:202` `context.dialogWidth(340)` 只按屏宽 92% 封顶。360dp 手机上字段实际宽约 248(440 卡 − 24 外距 − 32 内距 ×2),下拉 331 宽从字段左缘起排,右侧超出屏幕约 27px 被裁。建议宽度取锚点字段实宽(LayoutBuilder 或缓存 RenderBox size)。 ### [NIT-102] `replaceFirst('AuthException: ', '')` 是死代码 **文件**:`client/lib/screens/auth/register_screen.dart:136` `AuthException.toString()` 直接返回 message(auth_repository.dart:17),该前缀永不出现。无害,删掉即可。 ### [NIT-103] `_resetPassword` 的 TextEditingController 仍未 dispose(上轮 NIT-003 未修) **文件**:`client/lib/screens/settings/users_screen.dart:465` 另:该对话框用裸 Material `TextField` + `InputDecoration(hintText)`,与 DS 的 `DsInput` 不一致(check_ds_code 未拦,属风格缝隙)。 ### [NIT-104] 授权锁定判定靠英文文案子串匹配 **文件**:`client/lib/screens/auth/login_screen.dart:60-64` `m.contains('license') && (lock|expire)` 与后端 `service.ErrLicenseLocked` 固定文案强耦合,后端一旦本地化/改文案即静默失效退回普通错误条。注释已写明来源,可接受;更稳做法是后端返回错误 code。 ### [NIT-105] 注册页「确认密码」与「登录密码」共用一个 `_obscure` **文件**:`client/lib/screens/auth/register_screen.dart:280-297` 点密码框的眼睛会同时明文显示确认框。行为可辩护(对照确认更方便),确认是有意为之即可。 ### [NIT-106] 登录/注册页脚年份硬编码「© 2026」 **文件**:`client/lib/screens/auth/login_screen.dart:562` 跨年需手改。原型如此则属 1:1 还原,提请知悉。 --- ## 值得肯定的地方 - **上轮反馈闭环干净**:https 回切一举解决 SHOULD-001(mixed content)与 SHOULD-002——5 个 `compile-*.sh` 的 `BASE_URL` 全部回到 `https://jiu.51yanmei.com`,Android `usesCleartextTraffic`、iOS/macOS `NSAllowsArbitraryLoads` 明文豁免**全部撤除**(工作树与 HEAD 一致,经 PlistBuddy/grep 逐一确认);`manual.yml` 补 `EC2_* = ALI_*` 兼容变量解决旧 tag 回滚炸 unbound variable(SHOULD-003);`DsButton` disabled 态 `Opacity(0.55)`(SHOULD-004)✅ - **SEC 修复无回归且加了防回归测试**:`ownership.go` 写入侧 `ensureShopRef` + 读取侧全部 `Preload("X", "shop_id = ?", shopID)` 双防线,`TestStockInHandler_CrossTenantRefs` 覆盖「跨店外键 400 + 存量脏引用不带出 PII」两条路径;`main.go` 生产模式空/默认 JWT secret 直接拒启(SEC-002);三个运维 workflow 的 DB 密码改从容器内 `$MYSQL_ROOT_PASSWORD` 取、不再走命令行(SEC-003),`DB_PASSWORD` secret 引用清零 ✅ - **登录屏生命周期总体严谨**:`dispose()` 中 Timer cancel、OverlayEntry remove、3 controller + 2 FocusNode 全 dispose;`ds_toast` 单例 `_entry.mounted` 守卫 + `_entry == entry` 比对避免关错新 toast;「被顶下线漏提示」的 initState 首帧补读修法正确且注释讲清了根因 ✅ - **`fillStockOutItemSnapshots` 顺带完成了出库明细 product 归属校验**(`shop_id = ? AND id IN ?` + 缺行报错),比逐行 ensureShopRef 还省往返 ✅ - **死文件清理彻底**:6 个删除文件在 lib/ 与 test/ 均无残留引用(analyze 0 error 交叉印证)✅ - **注册契约核对无误**:`AuthRepository.register` 六字段与后端 `service.RegisterInput`(auth.go:424-432)逐一对应,密码 min=6 与前端校验一致,与原型差异(无编号/兑换券字段、地址必填)在文件头注释里如实登记 ✅ --- ## 结论 - [x] 0 处 blocking,可以提交 - [ ] should-fix 7 处:SHOULD-101/102(登录屏两行守卫,建议顺手修掉再提交)、SHOULD-106(.gitignore + 删 4 张失败截图,**建议提交前处理**以免脏图入库)、SHOULD-105(CLAUDE.md 同步)、SHOULD-103/104/107 可随后续迭代 - [ ] nit 6 处,不阻塞