Skip to content

全面代码评审:24 项数据安全/崩溃/并发/功能修复(附完整评审报告) - #170

Merged
gooooloo merged 31 commits into
mainfrom
code-review-fixes
Jun 11, 2026
Merged

全面代码评审:24 项数据安全/崩溃/并发/功能修复(附完整评审报告)#170
gooooloo merged 31 commits into
mainfrom
code-review-fixes

Conversation

@gooooloo

@gooooloo gooooloo commented Jun 11, 2026

Copy link
Copy Markdown
Owner

概述

基于一次全面代码评审(5 个维度:存储与并发、Views 层、Services 与网络、测试套件、象棋领域逻辑),本 PR 修复其中 24 项可安全自动化修复的问题 + 1 个存量构建问题 + 1 个崩溃恢复增强,每项一个独立 commit、引用对应 issue。

文档(包含在本 PR 中)

修复清单

数据安全(P0)

崩溃(P0)

并发与线程

功能正确性

构建(评审后续验证中发现的存量问题)

工程

  • 新增 .githooks/pre-push:push 前本地跑 iOS Simulator 构建,防止 macOS-only API 再次漏进共享代码(启用:git config core.hooksPath .githooks
  • actions/checkout 升级 v4 → v5(GitHub 6/16 起强制 Node 24)

不在本 PR、已建 issue 跟踪

验证

🤖 Generated with Claude Code

gooooloo and others added 26 commits June 11, 2026 00:12
- docs/code-review-2026-06-10.md: 五维度评审(存储并发/Views/Services/测试/领域逻辑)完整发现
- docs/code-review-fix-plan.md: 24 项修复计划与 issue 映射(#131-#154),15 项 backlog(#155-#169)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
同一 runloop 内多次 mutation(如一次走子触发 ensureFenId+ensureMove)原先会让
dataVersion 多次自增,破坏 checkpoint=dataVersion-1 的版本仲裁假设,引发虚假
版本告警、忽略远端真实更新;mutation 后立即 save() 也会因 isDirty 未置位被跳过。
现 markDirty/markClean 在主线程同步生效,非主线程调用保持异步派发。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
启动时若存档文件存在但读取/解码失败(损坏/未下载/schema 不兼容),原先静默创建
空库且保存时无任何确认,空库可直接覆盖云端真实数据。现在:
- DatabaseStorage.databaseFileExists() 区分全新安装与加载失败(含 .icloud 占位文件)
- Database 记录 loadFailedAtStartup 状态
- saveToDefault 在版本号不可读且存档存在时弹确认,确认后先把原存档备份到本地
  Application Support/XiangqiNotebook/OverwriteBackups 再写入

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
原先 willTerminateNotification 只停引擎不保存,Cmd+Q 或 iOS 被系统杀掉时
所有未手动保存的修改(含引擎评估结果)静默丢失。现 macOS 退出前、iOS 进
后台时自动保存;并复用 saveToDefault 的安全护栏:远端版本更新或版本不可
读时跳过自动保存,留给用户手动走确认流程。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
删除组或路径时原先只在删的恰好是选中项时重置选择;选中靠后的项再删除
靠前的项会留下 stale index,pathGroups[selectedGroupIndex] 越界崩溃。
现删除后平移/重置选中索引,并以 selectionIsValid 统一做边界保护。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PGN 的 [FEN] 头原先未经任何校验经 ensureFenId 入库,空串或行列数错误的
FEN 会在 fenToPiecesBySquare(数组越界)或着法列表显示(String.index 越界)
时崩溃,用户可用一份畸形 PGN 文件触发。现在:
- 新增 XiangqiBoardUtils.isValidBoardFen 结构校验(10 行 × 9 列、字符集)
- generateFenSequence 对外部 FEN 头校验失败返回 nil(按导入错误计数)
- fenToPiecesBySquare/findDiffColumns/normalizeFen 对畸形输入防御性返回

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
playRandomGame 强制解包 allGamePaths 并在空范围上 Int.random:黑方开局库
为空的用户按随机一局(随机到黑方筛选时)当前局面不在视图范围内,生成不出
任何路径,直接崩溃。现无可用路径时返回 nil 并保持当前局面。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
棋局可同时挂在多本棋书下;deleteBook 删除棋局对象但不清理其他棋书的
gameIds 引用,之后 getGamesInBookUnfiltered 的强制解包即崩溃。改用
compactMap 容忍悬空引用。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 文件选择弹窗 .actionSheet 改 .alert:iPad 上未配置 popover 锚点的
  actionSheet 在 present 时抛 NSGenericException 直接崩溃
- presentingViewController 不可用时统一回调 completion(nil/false):
  原先静默不回调,recoverFromUserChoice 的 withCheckedContinuation 永久泄漏
- openFile/recoverData 主线程化(原先在调用方线程直接操作 UIKit)

注:本机 Xcode 未安装 iOS 平台支持,此 iOS-only 文件经 swiftc -parse 语法
校验并对照同文件既有模式人工核对;macOS 全量测试通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- iPadContentView 补 setViewModel 接线与全局 .alert modifier(此前
  showAlert/showWarningAlert 全部 no-op,练习模式提示无声消失)
- presentingViewController 改为呈现时惰性解析:init 时机 scene 尚未
  foregroundActive,注入值常为 nil;并下钻到最顶层 presented VC,
  避免已有弹窗时呈现失败

注:iOS-only 代码经 swiftc -parse 校验与既有模式核对;macOS 全量测试通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
queryFenScore/pikafishQuickMove 的 catch 分支会从后台任务线程调用
showWarningAlert,dismissCurrentAlert 在调用方线程直接触碰 AppKit
(orderOut/NSApp.stopModal)并无同步地读写共享状态,违反 AppKit 主线程
约定。现三个弹窗方法整体先收敛到主线程再执行。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
markAsRemoved 只把 targetFenId 置 nil(软删除),moveToId 映射残留;
重新走该招时 ensureMove 返回僵尸 move,addMoveIfNeeded 与 autoExtendGame
均拒绝,棋子无声弹回且本会话内无法恢复(重启后 rebuildIndexes 排除僵尸
才正常)。现在:
- ensureMove 跳过 targetFenId 为 nil 的僵尸映射,直接新建(兼容老数据遗留状态)
- removeCurrentStep 删招时同步清理 moveToId 条目
- 僵尸对象保留在 moveObjects 中,维持 count+1 发号方案的 ID 唯一性

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
start() 原先 guard process == nil,对引擎崩溃后残留的死进程引用直接
no-op;evaluatePosition 的重启路径形同虚设,之后每次评估都 10 秒超时,
只能重启 app 恢复。现检测到进程已死时清理重启;启动流程失败时终止进程
并清空状态,保证下次可干净重试。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cancelAll 原先立即置 nil processingTask,但 cancel 不会中断在飞的
evaluatePosition;紧接着的 enqueue 会启动第二个处理任务,与旧任务并发
驱动同一个无锁的 PikafishService(交错 UCI 命令、竞争 outputBuffer)。
被取消任务的收尾还会误删新队列的去重键、覆盖发布状态。现在:
- 引入取消代数 generation,过期任务不得触碰新一代队列的共享状态
- processingTask 槽位由任务退出时自行释放;取消期间入队的新请求
  在旧任务退出后自动补启动处理
- cancelAll 后在飞任务退出前队列不算 idle(同时收紧 pikafishQuickMove
  的 isIdle 防并发护栏)

测试:更新 testCancelAll 以反映新语义(取消后等待旧任务退出再断言 idle),
新增取消后立即重新入队的回归测试。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
两者经快捷键闭包的无结构 Task 调用,原先整段在全局执行器上运行:
惰性创建 pikafishService/evaluationQueue 的 ivar 写入、queue.isIdle
(MainActor 状态)与 session 读取均与主线程竞争。现标记 @mainactor,
仅引擎/网络的 await 在后台挂起,原 MainActor.run 包装随之内联。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
importPGNFile 修改 Database 并切换 @published dataChanged,原先在
PGNHttpServer 私有队列与 DispatchQueue.global 上直接调用,属于离主线程
发布 + 与主线程的数据竞争(与 #130 修复的同类问题)。HTTP 回调用
main.sync 等待结果以便写回响应;文件导入路径读文件留后台、导入回主线程。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
新增的取消语义测试依赖固定时长等待,负载波动下偶发失败;改为带超时
的条件轮询。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- {} 注释与 () 变着用跨行状态机剥离:注释内形如坐标的 token(如
  { h2e2 better })原先会被当真实着法插入主线,静默污染棋局
- 同时含 [Game] 与 [Event] 头的标准 PGN 原先每局被拆成幽灵局+真实局
  两份;现仅在当前局已读到着法时才开新局
- parseDate 设 en_US_POSIX locale,避免非公历系统日历下解析错误

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
标准 SM-2 规定 q<3 时只重置重复次数与间隔、不更新 EF;原实现对失败评分
也执行 EF 公式(.again 每次 -0.54、.hard -0.32),叠加间隔重置形成双重
惩罚,失败项 EF 速降钉死在下限 1.3,间隔再也长不起来。现仅成功评分
(q>=3)更新 EF。同步更新编码旧语义的测试。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
原先随机调用 toggleFilterRed/BlackOpeningOnly:已在红方开局筛选时随机到
红方会把筛选关掉、在全库上随机。意图是切换到目标筛选,改用显式
setFilters(与 practiceRedOpening 等同模式一致)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
循环内 guard else return 会让一个局面的数据缺失(targetFenObject 不在
范围内)中止整个批量操作并返回部分统计且无提示,改为 continue。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
toggleStepLimitation 与 toggleShowLastMove 都注册了 sequence(",l"),
查找表后注册者覆盖前者,步数限制的快捷键静默失效。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
旧实现注释称选择最新版本,实际无条件用 gained 的冲突版本覆盖当前文件
(冲突版本可能更旧),随后 removeOtherVersionsOfItem 把被覆盖的版本永久
删除;操作未经写协调、未标记 isResolved,还强制解包 presentedItemURL。
现比较 modificationDate 仅在冲突版本较新时经协调写替换,并对所有未解决
冲突版本置 isResolved。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
StatusBarView 经 viewModel.evaluationQueue.state 读取进度,但没有视图
直接观察 EvaluationQueue 这个 ObservableObject,ViewModel 也不转发其
objectWillChange——取消评估后进度条悬挂到下一次无关更新才消失。现在
ensureEvaluationQueue 时把队列的 objectWillChange 转发给 ViewModel。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
scrollPosition(id:) 缺少配套的 scrollTargetLayout(),且 MoveListView 的
scrollPosition 误挂在外层 VStack 而非 ScrollView 上;VariantListView 的
ForEach 身份(Move)与 scrollPosition 值类型(Int 索引)不匹配。三处
修正后长棋局导航时列表能正确跟随当前着法滚动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Database.reload() 与 restoreFromBackup() 替换 databaseData 后未失效
realGamesByFenId,isRealGamesIndexReady 仍为 true,实战列表会按旧库
内容显示错误数据。索引失效后查询自动走线性扫描兜底路径。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gooooloo and others added 3 commits June 11, 2026 13:43
exportPGNCurrentDatabaseView/exportPGNCurrentGame 的实现包在
#if os(macOS) 内(使用 NSSavePanel),但动作注册没有平台守卫,iOS
编译报 no member 错误。main 分支即存在(安装 iOS SDK 后首次构建发现),
自该功能引入后 iOS target 一直无法编译。

验证:iOS Simulator 构建 BUILD SUCCEEDED;macOS 全量单元测试通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
防止 macOS-only API 漏进共享代码(macOS 端日常构建与测试查不出此类
问题,见 #171 的存量案例)。模拟器构建用 ad-hoc 签名,无需证书。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GitHub 警告 Node.js 20 actions 将于 2026-06-16 起强制运行在 Node 24 上,
checkout@v4 可能失效;v5 原生支持 Node 24。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
gooooloo and others added 2 commits June 11, 2026 14:16
- 移除 .github/workflows/ci.yml
- 新增版本化的 .githooks/pre-push:push 前运行 iOS Simulator 构建,
  防止 macOS-only API 漏进共享代码(存量案例见 #171)
- CLAUDE.md 记录启用方式(git config core.hooksPath .githooks)
  与跳过方式(git push --no-verify)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
按用户反馈调整 #144:保留「不自动保存、Cmd+Q 即丢弃未保存改动」的工作流,
改为崩溃保护——

- 撤销退出/进后台自动写 database.json 的行为
- 周期性(30s,仅脏且版本变化时)把当前数据库写入本地固定恢复文件
  Application Support/XiangqiNotebook/recovery.json(不碰正式存档、不走 iCloud)
- macOS 干净退出(willTerminate)清除快照——干净退出视为主动丢弃,
  只有崩溃/被杀(不触发回调)才会留下快照
- iOS 进后台强制写一次快照(防挂起期间被系统杀)
- 手动保存成功 / 冲突解决后清除快照
- 启动时若快照版本 > 已保存版本,弹确认(恢复/丢弃);恢复后保持 dirty 待手动保存

新增 PlatformService.showConfirmAlert 自定义按钮文案重载(Mac/iOS 各自实现,
带默认实现以兼容测试 mock),供恢复对话框用「恢复/丢弃」按钮。

测试:StorageTests 新增恢复快照读写/版本/缺失文件用例;macOS 全量测试通过。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment