Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
10 KiB
代码审查报告 — 提交前复审(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。
建议:
} 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 的写法:
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 位置,让续期与营销站部署解耦:
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,AndroidusesCleartextTraffic、iOS/macOSNSAllowsArbitraryLoads明文豁免全部撤除(工作树与 HEAD 一致,经 PlistBuddy/grep 逐一确认);manual.yml补EC2_* = ALI_*兼容变量解决旧 tag 回滚炸 unbound variable(SHOULD-003);DsButtondisabled 态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_PASSWORDsecret 引用清零 ✅ - 登录屏生命周期总体严谨:
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 与前端校验一致,与原型差异(无编号/兑换券字段、地址必填)在文件头注释里如实登记 ✅
结论
- 0 处 blocking,可以提交
- should-fix 7 处:SHOULD-101/102(登录屏两行守卫,建议顺手修掉再提交)、SHOULD-106(.gitignore + 删 4 张失败截图,建议提交前处理以免脏图入库)、SHOULD-105(CLAUDE.md 同步)、SHOULD-103/104/107 可随后续迭代
- nit 6 处,不阻塞