Skip to content

Latest commit

 

History

History
226 lines (162 loc) · 21.9 KB

File metadata and controls

226 lines (162 loc) · 21.9 KB

Tech Design · v0.11 · 隐私模式

状态:待评审(TDD 阶段)· 对应 prd/v0.11.md · 预览 preview/v0.11/privacy-mode.html 不是代码清单。每个关键决策列备选 + 取舍 + 选定理由 + 为什么不。

概述

纯前端叠加的「隐私模式」:一键把绝对金额高斯模糊,保留比例/百分比/走势形状。 零接口、零 schema、零数据改动——对 prod 现有数据 0 影响(继承 [[feedback_prod_backward_compat]] 红线,本迭代天然满足)。

核心约束来自 PRD:遮金额留比例 · 一键全局瞬时 · 高斯模糊 · 每次进默认显示(会话内保持)。


决策 126 · 遮挡机制:客户端 CSS class 切换

备选 取舍
A. <body>privacy class + CSS filter:blur 遮带标记的金额节点(选定) 瞬时、零延迟、纯前端、离线、HTMX 片段天然继承、截图即模糊。代价:DOM 仍含真值(非取证级)
B. 服务端渲染遮挡(cookie 标志,服务端输出 ****) DOM 无真值(更强),但切换要往返刷新(违"瞬时")、每个 HTMX 片段+图表数据都要 mask、改动面巨大
C. JS 运行时遍历替换文本(toggle 时把金额 textContent 换 mask,原值存 data-attr) 可彻底移除明文,但仍要先识别金额(还得标记)、HTMX 刷新要重跑、闪烁、复杂度高于 A 而收益有限

选 A。 威胁模型是肩窥 / 随手截图(PRD 明确),不是防 devtools;A 完全满足且最简、最快、离线可用、截图天然安全。用户选了高斯模糊样式,CSS filter:blur 正是它的自然实现。 为何不 B: 违背"瞬时"+ 无谓的服务端复杂度 + 改动面太大。为何不 C: 多余 JS 复杂度与闪烁;A 已满足威胁模型,C 的"定宽防量级泄露"用 CSS min-width 即可达成。


决策 127 · 金额标记策略:显式标记 + 护栏(不复用现有 class)

备选 取舍
A. 新增专用标记(class amount,加在绝对金额输出处)+ qa 护栏扫覆盖(选定) 精准只遮金额、不误遮比例;渐进可控。代价:要逐处加标记 + 靠护栏防漏标
B. 复用现有 class(tnum/kpi-value)做选择器 几乎不改模板,但金额与比例混用同一 class(实测:紧急储备「7.2 月」也是 kpi-value tnum)→ 会误遮比例,直接违背核心原则。否决
C. 集中式金额片段 _money(全站金额改走统一片段,coverage by construction) 理论最稳,但金额输出点多且形态不一(KPI/表格/账本 Java 拼/图表/tooltip);一次性集中化风险高、易漏改反而更乱

选 A。 现状金额是服务端 money() 格式化成字符串、th:text 插入,且 tnum/kpi-value 金额比例混用——没有现成信号能区分"绝对金额"与"比例数字",必须显式标记。 为何不 B: 会把百分比/月数一起糊,违背"留比例"。为何不 C: 全站集中化是高风险大重构,不如显式标记 + 自动护栏稳。 护栏(关键) —— "漏标的金额在隐私态会漏出来"是本功能的核心 bug 类,按 [[feedback_currency_invariance]] 的"属性级护栏网整类"+ [[feedback_verify_user_path]] 的"走用户实际路径验":

  • qa 静态守护:逐个用户面页断言已知金额面(KPI 净资产/总资产/总负债/本月收益、账户 当前价值/累计损益/净投入/本期损益/MoM、账本金额行、走势端点)都带 amount 标记;
  • qa/手验:隐私态走 dashboard/reports/checkup/accounts/entry,确认无裸金额漏出(非旁路)。

决策 128 · 状态存储:sessionStorage

备选 取舍
A. sessionStorage(选定) 会话内(同 tab)跨全页导航 / HTMX 片段 / 币种账期切换保持;关 tab/重开 → 清空 → 默认显示。精确匹配 PRD「每次进默认显示 + 会话内保持」
B. 内存 JS 变量 本 app 是 Thymeleaf+HTMX,页间多为全页导航 → 内存变量导航即失 → 一翻页就漏明文。否决
C. localStorage 持久化到设备 → 下次打开仍隐藏,违背用户明确选定的「每次进默认显示」

选 A。 用户语义("电梯瞥一眼后下次开默认显示,但这次用着别一翻页就漏")正好是 session 粒度。 为何不 B: 全页导航丢状态。为何不 C: 用户否了设备记忆。 落地风格沿用现有 chips 的 localStorage try-catch 读写(本 app 已有套路),只换 sessionStorage


决策 129 · 防明文闪现(FOUC):layout <head> 内联同步脚本

隐私态 on 时,若页面先渲染明文、再由脚本加 class → 公共场合会闪一下明文(等于泄露)。

备选 取舍
A. <head> 顶部内联同步小脚本,DOM 构建前据 sessionStorage 给 <html>privacy class(选定) 阻塞渲染前执行 → 零闪现。仿暗色模式防闪通行做法
B. DOMContentLoaded 后加 class 已渲染才加 → 必闪明文,在隐私场景不可接受

选 A,CSS 选择器用 html.privacy ...(而非 body),保证最早生效。脚本极小、无依赖、放 layout.html(全站继承)。


决策 130 · 图表联动:Chart 配置感知(隐藏金额标签,非糊 canvas)

canvas 内无法对"某些文字"做 CSS 局部模糊。

备选 取舍
A. 图表配置感知隐私态:金额 y 轴 ticks + 金额 datalabels 隐藏/置空,百分比标签与曲线形状保留;toggle 时 chart.update(选定) 精准——只藏金额文字,留形状+比例。代价:各图 ticks/datalabels callback 接入隐私判断 + toggle 触发重绘
B. canvas 整体 filter:blur 一行 CSS,但把曲线/扇形也糊了 → 违背"留形状"
C. 不管图表 y 轴金额刻度暴露量级 → 漏

选 A。 唯一能"藏金额文字、留形状与百分比"的精准做法;与 DOM 一致地遵守核心原则。 为何不 B: 糊形状违背原则。为何不 C: y 轴金额泄露。 落地:financeCharts(现 dashboard 内联)集中加 privacyMaskAmount(v);toggle 时遍历 Chart.instancesupdate('none')。注:图表金额是"隐藏/置空"而非高斯模糊(canvas 局限),与 DOM 模糊样式略异但各自契合介质——可接受。


决策 131 · 双入口(nav + 常驻浮动)+ 全局生效 + 零 JS 同步

长页(dashboard/reports/checkup + 长文目录)若只放 nav,"隐藏 + 滚到底"够不着 → 必须有随处可达入口。

备选 取舍
A. nav 眼睛 + 常驻浮动眼睛,双入口同步(选定) nav 利于第一次发现(与镜头 pill 同组);浮动随处可达(隐藏/恢复)。代价:两个眼睛——用 CSS 跟随单一 class、隐私态浮动控件才展开成 chip,弱化"为何两个"的疑惑
B. 仅常驻浮动 随处可达且唯一,但脱离了"镜头控件都在 nav"的归类,首次发现略弱
C. 仅 nav 与镜头同组,但长页滚到底够不着——用户明确否决

选 A。 关键是零 JS 同步:两个入口的 onclick 都只 toggle <html>privacy class(+ 写 sessionStorage);两处眼睛的 eye/eye-off 显隐纯 CSS 跟随 html.privacy,无需 JS 分别更新、无需事件总线,天然一致。

  • 唯一状态源:<html>privacy class(配 sessionStorage 决策 128)。所有页共用 layout.html(浮动控件 + CSS + FOUC 脚本)+ nav.html(nav 眼睛)→ 一处接入全站生效。HTMX 片段替换不动 <html> → 片段刷新隐私态自然保持。
  • nav 眼睛:nav.html,与币种/账期/账户 pill 同组;Feather eye/eye-off inline-SVG(遵 [[feedback_no_emoji]]);文案「隐藏金额 / 显示金额」(遵 [[feedback_user_friendly_naming]])。
  • 浮动控件:layout.html 全局 fixed,固定左下(left:14px; bottom:18px)。移动端目录浮钮 toc-fab 一并挪到左下(bottom:66px,叠在隐私浮控上方)→ 目录 + 隐私成一组左下操作区(用户要求"两个能力放相近入口")。PC 目录是 sticky 侧栏 rail,隐私浮控独立左下。z-index 低于 toast(z-10000),不挡内容。
  • 状态指示 + 恢复(FR-120):隐私态下浮动控件展开为「金额已隐藏 · 点恢复」chip(eye-off 高亮),常驻屏上 = 指示 + 随处恢复二合一;nav 眼睛同步 eye-off。不用会被滚出视野的顶部条。

标记覆盖清单(决策 127 的落地面)

需加 amount 标记的绝对金额:

  • dashboard:5 KPI 中的金额块(净资产/总资产/总负债/本月资产收益;紧急储备月数不加)、账户表金额列(当前价值/累计损益/净投入/本期损益/本期较上期Δ)、走势/收支图金额标签与 y 轴、人赚vs钱赚拆解金额。
  • reports:家庭金额类 KPI、账户级金额、水位金额、收支双柱金额。
  • checkup:家庭/账户金额(余额/欠款/估值);风险刻度、占比、收益率%不加
  • accounts:列表与详情金额、账本流水金额(EntryController Java 拼 HTML 处一并加)。
  • entry:账本金额、收支汇总金额。 不加(保留):一切 %、收益率、环比/同比、占比、储蓄率、紧急储备月数、负债率、日期、账户名、文案。

防回归 / 护栏

  • qa-run.sh 新增 v11-PRIVACY-*:① 顶部含眼睛开关 + privacy 切换钩子 + FOUC head 脚本;② 关键金额面带 amount 标记(KPI/账户列/账本);③ 比例类(%/月)不带标记(防误遮);④ nav 提示条存在。
  • 黑盒计数随新增守护 +N(同步 README 两处 + landing data-stat + v07-CLEAN-2),走 [[feedback_doc_sync]]。
  • 手验:隐私态走 dashboard→reports→checkup→accounts→entry(PC + 移动),逐屏确认无裸金额(遵 [[feedback_verify_user_path]],守用户真实入口非旁路)。

backward-compat / 风险

  • 纯前端叠加:0 schema、0 接口、0 数据迁移 → prod 升级 0 风险。
  • 已知局限(PRD 已记):DOM 仍有真值(非取证级);模糊保留字符宽度,min-width 定宽压低量级泄露,接受残余。
  • 性能:仅 CSS class 切换 + 图表 update('none'),无网络、无重排压力。

决策 132 · 按住临时查看(peek):事件委托 + Pointer 事件 + CSS 覆盖

备选 取舍
A. 按住临时查看(选定) 按下去模糊、松开/滑走/滚动/失焦复原;刻意+短暂,不会忘关一直露。手机长按+桌面按住通用
B. 点一下翻开保持 方便多看几眼,但忘了点回去会一直露(公共场合风险),与隐私目的相悖
C. 桌面 hover 预览 手机无效;鼠标划过易误显,公共场合危险

选 A。 实现:

  • 事件委托(document 上挂 Pointer 监听)→ 覆盖 HTMX 动态片段,无需逐元素绑定。
  • 真·长按阈值(v0.11.1 修):多数金额在 <a> 内(KPI 跳 /checkup、账户行跳详情)。若 pointerdown 即 peek,松手那次 click 会触发跳转。改为:pointerdownsetTimeout(HOLD_MS=320ms),按住超阈值才加 priv-peek(didPeek=true);短按(早于阈值松手)不 peek、放行原生 click → 正常跳转(数字仍隐藏)。
  • 抑制长按后的跳转:pointerup 时若 didPeek → 置 suppressClick;capture 阶段的 click 监听 preventDefault()+stopPropagation() 吃掉这一次(<a> 不跳转),随即复位。
  • 不在 pointerdown preventDefault:否则会阻断短按跳转与页面滚动;防选中/呼出菜单交给 CSS -webkit-touch-callout:none + user-select:none
  • iOS PWA 链接预览(v0.11.1 修):长按被遮金额触发的是其外层 <a> 的 iOS 链接预览小窗,而非内层 [data-priv]。故 -webkit-touch-callout:none 必须设在链接上:html.privacy a, html.privacy a *{ -webkit-touch-callout:none }(仅隐私态,不影响轻点跳转)。另对 [data-priv]contextmenu 兜底桌面/安卓。
  • 移动容差:pointermoveMOVE_TOL=10px(滚动/滑走)→ 取消计时与 peek,放行滚动。pointercancel/scroll/window blur 一并复原。
  • CSS 覆盖:html.privacy [data-priv].priv-peek{ filter:none }html.privacy [data-priv] 多一个 class → 特异性更高,无需 !important
  • 可发现性:隐私态 [data-priv] 显示 cursor:pointer + 浮动 chip 文案「长按可看」。只显示被按住的那一个。
  • 取舍:短按=跳转、长按=peek(不跳转)。这是移动端「可点击元素上叠长按手势」的标准解法,优于"隐私态禁用所有跳转"(会牺牲导航)。

验收映射(对齐 PRD FR)

FR-116 开关→决策 131 · FR-117 遮留范围→决策 127+覆盖清单 · FR-118 模糊+不可复制+定宽→决策 126 · FR-119 会话级→决策 128 · FR-120 指示→决策 131 · FR-121 图表→决策 130 · FR-122 截图安全→决策 126 · FR-123 按住查看→决策 132。

不做(本迭代)

服务端遮挡 / 账户名遮挡 / 设备持久化 / 逐值点开保持 / 取证级隐藏 —— 均 PRD 非目标。


v0.11.2 · 账期滚动修复(切月两 bug · 决策 133/134)

决策 133 · 开新期即关旧期(bug1)

  • 背景:PeriodOpener.openIfDue(每天 00:30 cron)只 createPeriodAndTodos(开新期),全代码 close() 仅「管理员手动 / forceClose / 全员完成」三路径 → 06 未被全员填完就一直 OPEN,07-01 到点只 new 了 07 → 两期同 OPEN、06 月报/指标不落定。
  • 选定(用户定:开新期即关):开新期前 closePriorOpenPeriodsPeriodMapper.findOpenBefore(family, newStart) 取所有更早的 OPEN 期,逐个 periodService.forceClose(id, systemMemberId)(待填按上期末延续 + 触发月报/指标)。先关旧期再建新期 → 新期预填从已落定的上期结转。openIfDue(自动)+ openNextNow(管理员)同口径。
  • 备选(否) 留缓冲/延迟关:更贴合月初补填,但要加延迟关账任务,复杂;用户要单一 OPEN 期不变式。备选(否) 仅页面提示:半自动,旧期仍可能长期悬挂。
  • 幂等:forceClose 对已非 OPEN 抛 IllegalStateException,closePriorOpenPeriods 捕获跳过;无活跃成员则跳过(无法代签)。

决策 134 · LOAN 预填夹零 ≤0(bug2)

  • 背景:LOAN 开账预填趋势外推 prev+(prev−prevPrev)(为房贷匀速还款预测下期);但贷款一次性还平(-72000→0,delta=+72000)后外推成 +72000 —— 贷款=欠款不应为正。
  • 选定:抽纯函数 predictLoanBalance(prev, prevPrev),外推后夹到 ≤0(predicted>0 → 0);草稿还款额改 predicted−prev(只在实际还款>0 时起草,已还平不再起草)。房贷仍为负不受影响。纯函数便于单测(PeriodOpenerLoanPrefillTest)。
  • 备选(否) LOAN 不外推只沿用 prev:丢掉"预测房贷下期余额"的 UX;夹零已足够修 bug 且保留外推价值。

v0.11.4 · 报表账户表补全指标 + vs基准口径修(决策 135/136)

决策 135 · 报表第四表复用管理页指标配置(不另造一套)

  • 背景:ReportsController 早已算好全字段 AccountPerformance(11 指标),却在 accountBenchmarkRows 这一步压成只含 name/type/pcCode/xirr/benchmark/diff/value 的精简 record → 报表账户表只能显示 4 列;而仪表盘用同一份 AccountPerformance + /admin/metrics(账户级)开关渲染全部指标。
  • 选定(复用管理页配置):报表模板改为迭代全字段 accountRows,注入 acctMetrics=metricPrefsService.enabled(family.metricPrefs,"account"),单元格标记直接复用仪表盘同款(data-mcol + th:if="${acctMetrics.contains(...)}"),基准数据按 accountIdMap<Long,AccountBenchmarkRow> benchmarkByAccount 供模板 zip。chips 与仪表盘共享 localStorage['acctHiddenCols'](两页隐藏集一致),JS scoped 到 #reports-region
  • 备选(否) 在报表另加一组固定列:与管理页脱钩,用户在管理页增减指标报表不跟随,违背「一处配置」。备选(否) 抽共享 JS 静态文件:要动仪表盘现有可用 JS(prod 已上线),回归面大;scoped 复制一份更稳(约 30 行,低风险)。
  • 权衡诚实:两表都由同一份 AccountPerformance 驱动,复用零新增计算;仅渲染层配置化。

决策 136 · vs基准 / 预实:实际取「显示的那个 XIRR」、单位 pp(修 v0.10.5 的爆值 + 脱节)

  • 背景:v0.10.5 把 diff 的「实际」改成 cumPnl ÷ 净投入(累计回报),本意是「短账户累计 vs 年化预期」错判的修复;但净投入极小的账户/家庭 → 比值爆成 +19497pp,且与卡片头条显示的 XIRR 是两个不同的数(头条 8.30% 却显示跑输 -243%)。单位也一直错标 %(比例减比例应是 pp)。
  • 选定:新增纯函数 BenchmarkAggregator.displayedDiffPercentPoints(displayedXirr, annualBenchmark, months) + beatStatusDisplayed:
    • 实际 = 卡片/列显示的那个 xirr 本身(与 XirrCalculator.annualizedOrCumulative 完全同口径:months<12 为累计回报、≥12 为年化)→ 头条与 pill 永远同源、量级一致。
    • 基准同基:months>=12 直接减年化基准;<12expectedOverWindowPct 把年化基准缩放到持有窗口(保留 v0.10.5「短账户不被年化预期错判」的价值)。
    • 阈值同基:beatStatusDisplayed 满 12 期 ±2pp、不足缩放。
    • 三处调用统一切换:ReportsController(家庭 familyXirrDecimal + 账户 ap.xirr())、FactViewServiceImpl 预实(xirr.get(accountId))。模板 pill 一律 + 'pp'
  • 备选(否) 保留 cumPnl/净投入 只改单位为 pp:量级仍爆(+19497pp),且与头条脱节,治标不治本。备选(否) 实际用 TWR:TWR 剔除收入,与「含收入」卡语义不符。
  • 无 schema / 接口变更,纯显示口径;BenchmarkAggregatorTest +3 断言锁口径。

v0.11.5 · 比例相比口径审计 + 报表观察账期(决策 137/138)

决策 137 · 全系统「两比例相比 → 相减 pp」审计

  • 背景:v0.11.4 修了家庭 XIRR vs基准后,用户要求横扫全系统同类指标。逐页核对结论见 docs/qa-cases.md v0.11.5
  • 命中改:① 配置对照 超配/欠配(_allocation-diff.html)dif=当前−模板 本就是相减,仅单位 %pp;② 财富水位 真实/相对社会收益(WaterLevelCalculator.realReturnPct)原 Fisher 除法 (1+名义)/(1+基准)−1 出「真实收益率(%)」→ 改名义−基准相减取 pp,展示 + LLM prompt 同步。
  • 选定理由:用户口径统一压倒 Fisher 精确性——「跑赢通胀/社会多少」在家用语境按百分点直观读数即可;且与 vs基准/预实/体检基准全线一致(横向可比)。
  • 不改:单一比率(收益率/XIRR/TWR/占比/回撤/负债率/储蓄率/份额/敞口/进度)与增长率(环比同比)——它们不是「相比」,% 正确。
  • 守护:WaterLevelCalculatorTest 改断言(相减 pp);qa-run v11-AUDIT-PP(模板 pp + calc 相减 + 无 Fisher 除法)。

决策 138 · 报表观察账期(as-of)· 复用仪表盘模式,下拉上界取「默认锚」

  • 背景:报表是每月快照,却无仪表盘那样的 as-of 选择,不能回看历史月。
  • 选定:ReportsControllerasof(可选 query)→ 在「已关账账期」内命中则锚它(closedSnapshot=true)、否则默认最近已关账;注入 periods(CLOSED 列表)+ asof 给模板下拉。模板下拉 onchangedata-base(Thymeleaf 生成含 range/currency/accounts 的 URL)+ 追加 &asof= 全量导航,range/币种/账户链接也贯穿 asof。
  • 关键取舍(下拉上界):closedPeriods 过滤上界用 defaultAnchor.periodStart(resolveAnchor 走 DB 日期挑的锚),不用 LocalDate.now()。原因:JVM 与 DB 日期若有偏差(本次实测 DB CURDATE 与 JVM LocalDate 边界不一致),用 now 作界会把「当前默认锚」挤出下拉、导致下拉无选中/漏当月。用锚作界 → 默认锚必在列且选中。
  • 备选(否) 复用仪表盘 resolveAsOf(含 OPEN 期):报表语义是已关账快照,不应能选进行中的 OPEN 期。备选(否) HTMX 局部切换:全量导航更简单稳,回看账期非高频交互。
  • 无 schema 变更;asof 可选、缺省即旧行为,向后兼容。

v0.11.6 · dashboard 首屏层级修正(决策 139)

决策 139 · 目标进度 + AI洞察 下移进 region(KPI 总览之后)

  • 背景:两条(goals/_progress-stripdashboard/_insight-strip)原挂 dashboard/index.html 顶部、#dashboard-region 之外(当初为「HTMX 刷新不重渲」)。副作用:首屏先见目标空态卡 + 洞察条,净资产标题/KPI 主角被下压 → 观感「结构被破坏」。
  • 选定:把两条 include 移进 _region.html,置于 KPI grid 之后、#dash-cashflow 之前。insight/goalsProgress 本就在 populateModel(全量 + HTMX 共用),故下移后 HTMX 局部刷新仍有数据;代价是两条随 region 每 90s 重渲一次 —— 纯展示、内容不变、无副作用,可接受(换来正确的视觉层级)。
  • 备选(否) 保留 region 外、仅挪到 region 之后(账户表之下):离总览太远,等于沉底。备选(否) 把总览拆出 region 单独置顶:总览含 KPI/净资产,正是要随 HTMX 刷新的部分,拆出即失去自动刷新。
  • 附:收支趋势图加 cashflowSeriesHasData(近月有非零收支才出图),全零显空态细条,避免空白大卡。

v0.11.7 · 「待办」页退休(决策 140)

决策 140 · 退休 /my-todos,折叠进 /entry

  • 背景:/my-todos 早已只是 entryService.listRows(mineOnly=true)只读渲染 + 「填 →」跳 /entry;而 /entry?mine=true 用同一份数据且能内联填(余额/收支/转账)+「我未填」标记 + 进度 + 全员提交自动关账。仪表盘有「未填 N 个」提醒条,短信/站内提醒也指向填报页。→ 三处重叠、导航「填报/待办」双入口,违背 10 分钟/月 + 非技术家属 的极简定位。
  • 选定:退休 /my-todos —— 导航删「待办」项、pendingCount「·N」角标并入「填报」项;MyTodosController 瘦成 redirect:/entry?mine=true(保老书签/深链);删 my-todos.html
  • 备选(否) 保留但差异化(待办=清单落地、entry=管理):仍是两个入口,认知负担未减,且 entry 已具「只看我的」筛选。备选(否) 直接 404 /my-todos:老书签/历史链接会断,302 成本极低更稳。
  • 无 schema/接口破坏;state.pendingCount(NavState)不变,仅展示位从待办项移到填报项。