From f485093a5283293b58515cfcbbb179ca62a34b84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E7=BF=94=E5=AE=87?= Date: Tue, 19 May 2026 17:34:33 +0800 Subject: [PATCH 1/2] docs: add 2026-05-19 product + code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - product-review.md: UX 完整度、竞品对比、上架前 P0/P1 清单 - code-review.md: 26 文件审视,标出 Toast.swift:64 truncate bug、 ETFClient LOF 误路由、dead K 线代码等 P0 项 Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/reviews/2026-05-19-code-review.md | 236 ++++++++++++++++++++++ docs/reviews/2026-05-19-product-review.md | 124 ++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 docs/reviews/2026-05-19-code-review.md create mode 100644 docs/reviews/2026-05-19-product-review.md diff --git a/docs/reviews/2026-05-19-code-review.md b/docs/reviews/2026-05-19-code-review.md new file mode 100644 index 0000000..030ac57 --- /dev/null +++ b/docs/reviews/2026-05-19-code-review.md @@ -0,0 +1,236 @@ +# 看盘侠 — 代码质量 Review + +**日期**: 2026-05-19 +**审视分支**: `feat/v0.3.0-toast-confirm-feedback` @ `63c7c0b` +**源码规模**: 26 Swift 文件,~3000 行;HoldingsView 395 行 / FundClient 244 行最大 +**测试**: 0(无 XCTest target,无测试文件) + +--- + +## 一、架构 — 7/10 + +清晰分层: + +``` +data/ HTTP clients (actor) + parser + Models +storage/ 持久化 stores (@MainActor) +engine/ RefreshScheduler + FundStore + debug log +ui/ SwiftUI views + Theme + Toast/ConfirmDialog +FundPlugin 主类胶水 +``` + +`@MainActor` 标在状态层 (FundStore / Watchlist / GoldPositionStore),`actor` 标在网络客户端 (FundClient / GoldClient / ETFClient / SpotGoldClient / GoldKlineClient / GoldMinuteClient)。**并发模型一致**。 + +### 问题 1:store 持有 watchlist 但没转发 objectWillChange + +`FundStore.swift:30` `let watchlist: Watchlist`。store 没 subscribe `watchlist.$funds` 转发到自己。watchlist 的 `@Published` 改动只对直接观察它的 view 生效。`HoldingsView` 直接 `store.watchlist.funds` 读能 work 是因为 SwiftUI 沿 @ObservedObject 链 propagation,但 store 上的 `totalMarketValue` / `count(for:)` 这类 computed 不会触发 store 自己的 objectWillChange。**结果**:watchlist add/remove 后,hero card 重算依赖下次 store.estimates 触发的 view update 顺带过来,时序边缘有 stale 风险。 + +**修法**: +```swift +init() { + self.watchlist = Watchlist() + watchlist.objectWillChange + .sink { [weak self] in self?.objectWillChange.send() } + .store(in: &cancellables) +} +``` + +### 问题 2:goldDailyBars / GoldRange 全链路 dead code + +`Models.swift:181 GoldRange` + `FundStore.goldBars(for:)` + `GoldKlineClient.swift` 全部健在,但 `GoldView` 渲染的是 `goldMinuteLine`(分钟线),不再调 `goldBars(for:)`。整套日 K 线机制在 store 里循环 refresh,view 没 render。**删能少 200 行**。 + +--- + +## 二、状态管理 — 7/10 + +### 强项 + +- `FundStore.shared` 单例,view 全用 `@ObservedObject` 共享,一致 +- `WatchlistFund.shares/costNav` Optional 设计 graceful 支持 watchlist-only 模式 + +### 问题 1:estimates 整本字典拷贝 + +`FundStore.swift:94-112` 每次 refresh 拷贝整本 dict → mutate → assign 回 `self.estimates`。同理 `goldQuotes`。N 只基金 O(N) 复制。对 10-50 只 OK,扩到 200 只 row diff 全量重算会 visible jank。 + +**修法**:单 entry update + `self.estimates[code] = ...` 直接 mutate,依赖 `@Published` 的 willSet/didSet 一次性发 signal。 + +### 问题 2:Watchlist init() 同步 load + +`Watchlist.swift:18-26` init() 内同步 `Data(contentsOf: storeURL)` + JSON decode。watchlist.json 大到几 MB 会卡主线程。当前不是问题,但 ceiling 在那里。 + +--- + +## 三、并发安全 — 8/10 + +### 强项 + +- 所有 HTTP client 都标 `actor`,避免 URLSession 跨线程 hazard +- `@MainActor` 在 store 和 UI 控制器 (ToastController / ConfirmController) +- `Task { @MainActor in ... }` 在 plugin lifecycle 里跨域 hop 正确 + +### 🔴 BUG 1:Toast.swift:64 sleep 时长 truncate + +```swift +try? await Task.sleep(nanoseconds: UInt64(self?.durationSeconds ?? 2.6) * 1_000_000_000) +``` + +`UInt64(2.6) == 2`(Double → UInt64 truncate)。乘 1_000_000_000 得 2_000_000_000 ns = **2.0s**,不是 `durationSeconds: Double = 2.6` 想要的 2.6s。Toast 比设计早 0.6s 消失。 + +**修法**: +```swift +let nanos = UInt64((self?.durationSeconds ?? 2.6) * 1_000_000_000) +try? await Task.sleep(nanoseconds: nanos) +``` + +这是 typo 级 bug,**v0.3.0 release 前必修**。 + +### 问题 2:ConfirmController 并发 ask() 没保护 + +`ConfirmDialog.swift:54-56`:若两个 caller 同时 await `ask()`,第一个 continuation 被 resume(false),第二个 pending = req 覆盖。@MainActor 隔离让这 race 不会内存损坏,但语义上前者拿到一个"用户没操作"的 false。当前注释说"shouldn't normally happen"但没保证。**建议**:要么文档化要么改严格队列。 + +--- + +## 四、错误处理 — 5/10 + +### 🔴 问题 1:AddView 搜索失败 silent swallow + +`AddView.swift:138`: +```swift +do { hits = try await FundClient.shared.search(q) } +catch { hits = [] } +``` +用户搜东西网超时看到"未找到匹配的基金"。实际是 15s timeout。**P0 加 error toast**。 + +### 问题 2:GoldClient / SpotGoldClient errors silent degrade + +`FundStore.swift:140` `if let s = await spot { self.spotGold = s }` — fetch 失败 spotGold 保持上一次值。用户看到 stale price 不知道。**建议**:连续 3 次失败 → footer 显示"网络异常"。 + +### 问题 3:FundDebugLog 写 `/tmp/fund-plugin.log` 无 rotation + +`FundDebugLog.swift:14`:production 装机后每次 refresh 都追加文件。macOS `/tmp` 不会自动清。size cap 缺。**建议**:写到 `~/Library/Logs/Mio Island/fund-plugin.log` + 10MB rotate;或只在 DEBUG flag 启用。 + +--- + +## 五、网络层 — 8/10 + +### 强项 + +- 真懂 Sina/Eastmoney quirks:GB18030 编码、Referer 校验、JSONP `jsonpgz(...)` 包装、cache-buster `?rt=ts` +- `withTaskGroup` 并行 fetch,单 endpoint 死不拖累全 panel +- 全 HTTPS(FundClient.estimate URL 也是 https) + +### 问题 1:timeout 不一致 + +| Client | request / resource | +|---|---| +| FundClient | 15s / 25s | +| GoldClient | 6s / 12s | +| SpotGoldClient | 6s / 12s | +| ETFClient | 6s / 12s | +| GoldKlineClient | 8s / 15s | +| GoldMinuteClient | 8s / 15s | + +混合超时本身 OK,但 cold-start 时第一个 TLS 握手往往压在 fundgz(FundClient 15s 是对的)。其它 client 沿用 6s 在 cold start 会被掐。**建议**:全 client 统一到 `URLSessionConfiguration.timeoutIntervalForRequest = 12` baseline。 + +### 问题 2:没 retry / 没 exponential backoff + +fetch 失败 → 等下次 cadence (60s/30min) → 再失败。冷启动 3 分钟没数据 = 用户走人。**建议**:first refresh 失败 → 5s 后再试一次(不算正常 cadence)。 + +### 🔴 问题 3:ETFClient.isETFCode 路由过宽 + +`EtfClient.swift:71-72`: +```swift +case 510...599, 150...199: + return true +``` + +`150...199` 覆盖深市 159 (ETF) **但也覆盖 161/162/163 (LOF)** 和 167 (LOF) — LOF 走 OTC 净值估值不是分笔交易,被路由到 ETFClient 会失败/取错数据。**修法**:白名单收紧到 159, 165, 188, 159001-159999 实际是 ETF 的码段。 + +--- + +## 六、UI 层 — 7/10 + +### 强项 + +- `Theme.swift` 集中色彩 token,all views 引用 +- 一致的 RoundedRectangle + overlay04 + 0.5 stroke 风格 +- `Color.upDown(value)` helper 抽象红涨绿跌 +- `SparkLine.swift` 自绘 SVG-equivalent,零依赖 + +### 问题 1:watchlist.funds.map(\.code) 当 animation value + +`HoldingsView.swift:131`: +```swift +.animation(.spring(...), value: store.watchlist.funds.map(\.code)) +``` +每次 render 都构造新 `[String]`。性能上无害但 inefficient。**建议**:Watchlist 加 `var codeFingerprint: Int { funds.reduce(0) { $0 &+ $1.code.hashValue } }`。 + +### 问题 2:没 reorder UI + +`Watchlist.swift:56` 提供 `move(from:to:)` API,但没 view 调用。drag-reorder 没实现。**P1**。 + +--- + +## 七、安全 — 7/10 + +### 强项 + +- 全 HTTPS +- 没存敏感 token / 用户登录 — 100% 本地 +- watchlist.json 写到 user-domain Application Support,不需要 entitlement +- `codesign --force --deep --sign -` ad-hoc 签名 OK + +### 问题 1:User-Agent "Mozilla/5.0" + +短期 OK,但上架后用户激增被识别为 bot 是 risk。**建议**:UA 改成 `MioIsland-FundPlugin/0.3.0 (...)` —— 被 ban 时是定向不是误伤。 + +### 问题 2:没 rate limit + +`ExpandedView.onAppear` trigger refresh,用户频繁 show/hide 刘海 panel 会蹬一波请求。**建议**:onAppear 检查 `lastFundRefresh < 30s` 就跳过。 + +--- + +## 八、测试 — 0/10 + +**No tests at all**。HANDOFF.md 提到 `/tmp/test-fund-clients.swift` 单文件 smoke test 但不在 repo 里。 + +**v1 必须有的**: + +| 文件 | 测试场景 | +|---|---| +| `SinaQuoteParser` | GB18030 sample fixtures × 6 source(COMEX/London/SHFE/AU9999/sh/sz ETF)| +| `ETFClient.isETFCode` | 边界码:510, 512, 518, 159, 161 (LOF), 167 (LOF), 588 | +| `FundClient` JSONP 解析 | happy / `jsonpgz();` empty / 非 UTF-8 malformed | +| `Watchlist` save/load roundtrip | + 异常磁盘 atomic 写 | +| `RefreshScheduler.isAShareTradeHour` | 09:29 / 09:30 / 11:30 / 11:31 / 13:00 / 15:00 / 周末 | + +--- + +## 九、Code smell 汇总 + +| 文件:行 | 问题 | 优先级 | 修复行数 | +|---|---|---|---| +| `Toast.swift:64` | `UInt64(2.6)` truncate,toast 早 0.6s 消失 | 🔴 P0 | 2 | +| `AddView.swift:138` | 搜索失败 swallow,没 toast | 🔴 P0 | 5 | +| `EtfClient.swift:71` | 150-199 路由 ETF 把 LOF 误收 | 🔴 P0 | 5 | +| `FundStore.swift:160-163` + `Models.swift:181` + `GoldKlineClient.swift` | 日 K 线 dead code | 🟡 P1 | 删 ~200 | +| `Watchlist.swift:78` | init 同步 load 大文件 | 🟡 P1 | 异步迁移 | +| `FundDebugLog.swift` | 写 `/tmp` 无 rotation | 🟡 P1 | 移路径 + size cap | +| `HoldingsView.swift:131` | map(\.code) 每次 render 跑 | 🟢 P2 | 加 fingerprint | +| 全 repo | 0 tests | 🟡 P1 | 加 XCTest target | +| `FundStore.swift:30` | watchlist objectWillChange 未转发 | 🟢 P2 | 5 | + +--- + +## 十、总评 + +代码质量 **7/10** — 架构清晰、并发模型一致、网络层懂中国数据源。 + +**主要短板**: +1. **测试覆盖 = 0** —— 上架后崩了不知道哪坏 +2. **Toast.swift:64 是 typo 级 bug**(UInt64 cast 数值常量)— 必修 +3. **dead code 还在** — GoldRange / goldDailyBars / GoldKlineClient 没人用了 +4. **ETF 码段路由把 LOF 误收** — 用户体验直接坏 +5. **silent network failures** — 没用户可见信号 + +修完 P0 三项 + 加最小测试集(5 个 fixture),code quality 能拉到 9/10。 diff --git a/docs/reviews/2026-05-19-product-review.md b/docs/reviews/2026-05-19-product-review.md new file mode 100644 index 0000000..489778b --- /dev/null +++ b/docs/reviews/2026-05-19-product-review.md @@ -0,0 +1,124 @@ +# 看盘侠 — 产品 Review + +**日期**: 2026-05-19 +**审视版本**: v0.3.0 (`feat/v0.3.0-toast-confirm-feedback` @ `63c7c0b`) +**装机验证**: `~/.config/codeisland/plugins/fund.bundle` v0.3.0 build 3 已在 Mio Island 3.0.0 运行 + +--- + +## 一、核心价值主张 + +| 维度 | 评分 | 说明 | +|---|---|---| +| 差异化 | 8/10 | 基金 + 黄金跨品类合一,刘海常驻,无服务端依赖 | +| 价值密度 | 7/10 | 持仓 P&L 实时算 + 黄金分钟线 + 多市场对比 pill 一屏内 | +| 上手摩擦 | 8/10 | 持仓 shares/cost 可不填,graceful 降级到 watchlist 模式 | + +### 强项 + +1. **跨品类合一** — 市面上养基宝只看基金,跟谁学只看金价。同时看一屏没几个。 +2. **零服务端架构** — 数据全走天天/东方财富/新浪公开端点,永远不会因为后端关停就死。代价:没法做服务端聚合或推送。 +3. **持仓非强制** — `WatchlistFund.shares` / `costNav` 都是 `Double?`,没填也能用 watchlist 模式(`HoldingsView.swift:142-175` hero card 自动 fallback "X 只基金")。降低 onboarding 摩擦。 + +### 弱项 + +1. **README pricing 是空头支票** — README.md:98-105 写了 ¥49 once / ¥99 yearly 的 Pro tier 表格 + AI 解盘,v0.3.0 全免费、license 系统不存在。要么删定价段,要么尽快做 license gating —— 否则上架审核员看到会困惑,用户看到会觉得"骗"。 +2. **"看盘侠"名字 vs 实际功能 mismatch** — 名字暗示"看 K 线深度"(同花顺/雪球级别),实际是基金估值 + 金价。第一次打开的用户期待会被拉低。 +3. **黄金为什么挂在基金 tracker 里?** — 概念上不重叠。如果用户只想看金价不看基金,"持仓 tab + 添加 tab" 都是 dead weight。 + +--- + +## 二、UX 流程完整度 + +### 走通 happy path(v0.3.0 新增反馈环) + +1. 持仓空状态 → 点 "去添加" → tab 切到添加 ✓ +2. 搜 "易方达蓝筹" → tap + → 绿 toast「已添加「xxx」到自选」✓(v0.3 新) +3. 回持仓 tab → 看见 row → tap 展开 PositionEditor → 填份额+成本 → 绿 toast ✓ +4. Hero card 自动算 总市值 / 今日盈亏 / 累计盈亏 / 收益率 ✓ +5. hover row → 出 ✕ → tap → 红色 ConfirmDialog → 确认/取消 ✓(v0.3 新,防误删) + +### 断点与边缘 case + +1. **搜索失败 silent swallow** — `AddView.swift:138` + ```swift + do { hits = try await FundClient.shared.search(q) } + catch { hits = [] } + ``` + 用户搜 "易方达" 网超时,看到"未找到匹配的基金"。实际是 15s 超时不是没结果。**P0**:error path 加错误 toast。 +2. **没"搜索历史"** — 用户搜过一次就丢,下次重启又要打字。`AddView` state 完全 ephemeral。**P1**:5 个 recent searches 就够。 +3. **黄金 K 线产品意图不明** — `GoldView` 实际渲染的是 `goldMinuteLine`(当日分钟线),但 README 还在讲"1月/3月/1年/全部" 日 K 切换,`Models.swift:181 GoldRange` enum 也还在。两套数据流并存只渲染一套。要么把日 K 切换的 UI 找回来,要么砍 dead code。 +4. **"添加" tab 已添加 row** — `SearchRow.swift:182 .disabled(alreadyAdded)` 与 v0.3 新加的"再点弹 info toast"逻辑冲突 — disabled 状态会吃掉 tap,info toast 永远不触发。需 cleanup。 +5. **黄金持仓 vs 基金持仓 UX 不一致** — 基金 PositionEditor 在 row 下方展开 (`HoldingsView.swift:99`);黄金 GoldPositionCard 在 card 内 toggle (`GoldPositionCard.swift:32-43`)。同一概念两种交互。 +6. **footer "更新于 X 分前"误读** — `ExpandedView.swift:210` 显示上次刷新时间,但盘后实际 cadence 是 30 min。用户看到"更新于 25 分前"会以为坏了。**建议**:盘后改成"下次刷新 N 分后"或"盘后空闲"。 + +--- + +## 三、信息架构 + +| 强项 | 弱项 | +|---|---| +| 持仓 column header(今日/累计)— 一眼看清两列含义 | "添加"tab 默认页写"数据源: 东方财富 fund.eastmoney.com" — 偏 dev-y | +| Hero card graceful 降级("X 只基金")| "国内金价 AU9999.SGE" — 普通用户不认这 ticker | +| 黄金 tab 一屏放下:实时 + 分钟线 + 持仓 + 伦敦/纽约 pill | 没"全局 status"footer 字段,refresh 失败/网异常没 surface | +| `.upDown(value)` helper 一致的中国式红涨绿跌 | hero card 上 hover 没 affordance(用户不知道 row 可点) | + +--- + +## 四、与竞品对比 + +| | 看盘侠 | 养基宝 | 蛋卷基金 | 同花顺 Mac | +|---|---|---|---|---| +| 桌面常驻 | ✅ 刘海 | ❌ App | ❌ App | ❌ App | +| 隐私(无登录无服务端)| ✅ | ❌ | ❌ | ❌ | +| 基金 + 黄金一屏 | ✅ | ❌ | ❌ | ⚠️ 不同模块 | +| 持仓 P&L | ✅ | ✅ | ✅ | ✅ | +| AI 解盘 | ❌(计划)| ✅ | ❌ | ⚠️ | +| K 线深度 | ⚠️ 分钟线 | ❌ | ❌ | ✅✅ | +| 多账户 | ❌ | ✅ | ✅ | ✅ | +| ETF 实时 | ✅ | ⚠️ | ⚠️ | ✅ | + +**差异化定位**:刘海 + 跨品类 + 隐私。瞄准"已经知道自己持仓、不想再装一个 app 占菜单栏"的轻量用户。**不要**对标同花顺做深度。 + +--- + +## 五、上架前必修(P0) + +| # | 项 | 文件 / 位置 | +|---|---|---| +| 1 | README pricing 删掉或改成"Pro tier 计划中" | `README.md:98-105` | +| 2 | 搜索失败要弹 error toast | `AddView.swift:138` catch 块 | +| 3 | 删 dead K 线代码 OR 把日 K 切换 UI 加回来 | `GoldKlineClient` / `goldDailyBars` / `GoldRange` | +| 4 | footer 文案盘后改成"下次刷新 N 分后" | `ExpandedView.swift:209-213` | +| 5 | SearchRow.disabled vs info toast 二选一 | `AddView.swift:182` | + +--- + +## 六、建议加(P1) + +1. **个股 quote tab** — 已有 ETFClient (Sina sh/sz endpoint),扩展 A 股股票几乎就是 prefix 路由 +2. **搜索历史** — 5 个 recent searches +3. **设置面板** — refresh interval 可调、tab 显示偏好(隐藏黄金?) +4. **每日 P&L 累计 chart** — 持仓 hero data 存 SQLite,画周线趋势(这是 Pro tier 的真正钩子,比 AI 解盘更落地) +5. **drag-reorder 持仓** — Watchlist 已经有 `move(from:to:)` API,view 没用 + +--- + +## 七、建议砍(P-1) + +1. **黄金 K 线 1月/3月/1年/全部 切换** — 代码留着没用上;产品上"长线看金"跟 quick-glance 体验不匹配 +2. **MioIsland 主题适配** — HANDOFF.md 自己说没做。固定 lime/gold 配色其实视觉更统一,不要补 + +--- + +## 八、总评 + +| 问 | 答 | +|---|---| +| v0.3.0 上架免费版? | **是** | +| v0.3.0 上架收钱版? | **否**(pricing 是空头支票,AI 解盘没影)| +| 上架前必修项数? | 5 个(见上)| +| 最大产品风险 | 名字"看盘侠" vs 实际功能 mismatch,第一印象拉低 | +| 最大产品亮点 | 跨品类 + 刘海常驻 + 隐私三合一 | + +**优先级 sequence**:修 P0 五项 → tag v0.3.0 → 上架免费 → 做"个股 tab"和"P&L 趋势"作为 Pro 钩子 → 再谈付费。 From b5f0d2d6607063de0fd44e12312177a56f1fc11d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E7=BF=94=E5=AE=87?= Date: Tue, 19 May 2026 17:51:49 +0800 Subject: [PATCH 2/2] fix(etf): exclude LOF prefixes from ETF code routing Prior `150...199` range caught 161/162/163/165/166/167/168/169 which are LOF (open-end fund traded on-exchange but priced by 1-day NAV, not intraday ticks). Routing LOF codes through ETFClient pulled stale/wrong-shape data from the Sina sz endpoint. Explicit-deny LOF prefixes; preserve legacy range for everything else. P0 from 2026-05-19 code review. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/data/EtfClient.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/data/EtfClient.swift b/Sources/data/EtfClient.swift index d18486d..c0d43d1 100644 --- a/Sources/data/EtfClient.swift +++ b/Sources/data/EtfClient.swift @@ -68,7 +68,19 @@ actor ETFClient { static func isETFCode(_ code: String) -> Bool { guard code.count == 6, let n = Int(code) else { return false } let prefix = n / 1000 + // P0 fix (2026-05-19 review): the prior `150...199` range was + // too wide — it caught LOF codes that aren't ETFs: + // 161xxx 兴全 LOF / 162xxx 黄金 LOF / 163xxx 招商 LOF + // 165xxx 银华 LOF / 166xxx 中欧 LOF / 167xxx 中海 LOF + // 168xxx, 169xxx 多家 LOF + // LOFs trade by 1-day NAV pricing, not by ETF intraday quote + // ticks. Routing them through ETFClient would surface stale + // or wrong-shaped data on the holding row. + // Explicit-deny the LOF prefixes; keep everything else in the + // legacy range routing for backwards compat. switch prefix { + case 161, 162, 163, 165, 166, 167, 168, 169: + return false case 510...599, 150...199: return true default: