功能深度指南:数据模型、Pledge 匹配机制与源码实践)
金融科技后端前端移动开发桌面应用AI 应用【免费下载链接】sureThe personal finance app for everyone (by everyone)项目地址https://gitcode.com/gh_mirrors/sure5/sure点击查看免费下载导读本文以 SureThe personal finance app for everyone开源仓库中的 docs/llm-guides/goals.md 为骨架结合app/models/goal.rb、app/models/goal_pledge.rb、GoalPledge::Reconciler及两个控制器等源码系统讲解储蓄目标savings-goals功能的设计与实现。你将掌握Goal 与 GoalPledge 的数据模型与状态机、pledge 从创建到匹配的完整生命周期、pace/monthly_target_amount等关键指标的计算口径、以及增删字段、新增状态与 pledge kind、触碰 reconciler 时需要注意的承重墙式不变量。无论你是要修改该功能、修复 bug 还是为其编写测试本文都是一份可直接对照源码的实操参考。架构总览一次请求如何贯穿目标页面docs/llm-guides/goals.md用一张调用链图概括了 Goals 功能的主要数据流结合源码可以还原出完整路径GoalsController#index → active_goals Family.goals.includes(:open_pledges, linked_accounts: :account_providers) → KPI strip per-goal cards (Goals::CardComponent) → pending-pledges callout if any goal has an open pledge GoalsController#show → goal.open_pledges.reverse_chronological → pending-pledge banners → progress ring (Goals::ProgressRingComponent) → projection chart (data-controllergoal-projection-chart) → Goals::FundingAccountsBreakdownComponent (linked-account rows) → Notes section if goal.notes.present? GoalPledgesController#create (turbo-frame: modal) → goal.goal_pledges.new(amount:, account:, kind: kind_for_account(account)) → save! → matches?-loop runs once the next sync arrives Account::ProviderImportAdapter#import_transaction → GoalPledge::Reconciler.new(entry).run (transfer-kind path) Account::ReconciliationManager#reconcile → GoalPledge::Reconciler.new(prepared_valuation).run (manual_save path) SweepExpiredGoalPledgesJob (cron, every 15 minutes) → GoalPledge.open_and_expired_now.find_each(:expire!)实际源码中GoalsController#index并不直接手写预加载而是调用Goal.prepared_for(Current.family)app/models/goal.rb该方法统一完成includes(:open_pledges, :goal_accounts, linked_accounts: :account_providers)并通过inject_backing_math!一次性注入全家族的pooled_allocations、market_flows与pooled_pace避免 N1 查询。而GoalPledgesController#create中的 kind 也不是用kind_for_account计算而是读取账户的default_pledge_kind见后文Connected vs manual accounts设计意图一致让创建时的 kind 与匹配时的预期完全一致。页面与组件分工index 页KPI 条GoalsController#kpi_payload统计 30 天流入速度、behind 数量、on-track 比例等 每张目标卡片Goals::CardComponent 存在 open pledge 时的顶部 callout。show 页倒序排列的 pending-pledge 横幅、进度环Goals::ProgressRingComponent、投影图D3data-controllergoal-projection-chart、资金账户分解组件Goals::FundingAccountsBreakdownComponent以及goal.notes.present?时才渲染的 Notes 区块。pledge 创建Turbo Frame 模态框创建成功后不立即匹配而是等下一次同步到达时由 reconciler 触发。关键文件清单文档给出了完整的关键文件索引这里按层次整理路径均已转换为仓库根目录相对路径模型层app/models/goal.rb — balance、pace、status、projection、color map 等核心计算。app/models/goal_pledge.rb — pledge 实体、匹配策略与生命周期。app/models/goal_pledge/reconciler.rb — entry → pledge 的解析器由导入适配器调用。app/models/account.rb —#manual?实例方法镜像Account.manualscope驱动 pledge kind 判定。app/models/family.rb —#savings_inflow_velocity为 KPI 条供数。控制器 / 路由app/controllers/goals_controller.rb — index / show / new / create / edit / update / destroy任意状态/ pause / resume / complete / archive / unarchive / reopen / consume / record_consumption。app/controllers/goal_pledges_controller.rb — new / create / renew / destroy。config/routes.rb —resources :goals do resources :pledges ... member { patch :renew } end。视图app/views/goals/ 下的index.html.erb、show.html.erb、new.html.erb、edit.html.erb以及_form_stepper.html.erb、_form_edit.html.erb、_pending_pledge_banner.html.erb、_empty_state.html.erb、_color_picker.html.erb。app/views/goal_pledges/new.html.erb。视图组件app/components/goals/card_component.{rb,html.erb}— index 页的目标卡片。app/components/goals/funding_accounts_breakdown_component.{rb,html.erb}— show 页按账户分解组件。app/components/goals/avatar_component.{rb,html.erb}— 彩色字母/图标头像。app/components/goals/account_stack_component.{rb,html.erb}— 卡片上的重叠账户头像。app/components/goals/progress_ring_component.{rb,html.erb}— show 页进度环。app/components/goals/status_pill_component.{rb,html.erb}— 状态徽章。Stimulus 控制器app/javascript/controllers/goal_stepper_controller.js— 两步创建模态框。app/javascript/controllers/goal_pledge_preview_controller.js— 实时金额影响预览 帮助文案切换。app/javascript/controllers/goal_projection_chart_controller.js— show 页 D3 投影图。app/javascript/controllers/goals_filter_controller.js— index 页筛选 chips 搜索带 URL 状态。Schema / 迁移实际迁移文件为 db/migrate/20260511100000_create_goals.rb文档中的编号与当前仓库略有出入以仓库实际文件为准包含表、枚举、部分索引与金额 check 约束chk_goal_pledges_amount_positive、chk_savings_goals_target_amount_positive等。旧的goal_contributions台账被移除pledge 取代了它的职责。测试 / Fixturestest/models/goal_test.rb、goal_pledge_test.rb、goal_pledge/reconciler_test.rb。test/controllers/goals_controller_test.rb、goal_pledges_controller_test.rb。test/jobs/sweep_expired_goal_pledges_job_test.rb。test/fixtures/goals.yml、goal_accounts.yml、goal_pledges.yml。多语言文案config/locales/views/goals/en.yml、goal_pledges/en.yml。config/locales/models/goal/en.yml、goal_pledge/en.yml。数据模型目标是一个意图不是一本账Goal 记录name、target_amount、可选的target_date、color、可选icon、可选notes、currency以及一个由 AASM 管理的stateactive/paused/completed/archived并通过联结表goal_accounts关联到 depository 账户。最重要的设计决策目标没有贡献台账。目标的进度就是所有关联账户的实时余额Goal#current_balance在请求时读取linked_accounts.sum(:balance)app/models/goal.rb。我存了多少永远等于这些账户现在有多少而不是逐笔累加的历史。这也意味着同一个 depository 账户可以同时资助两个目标两个目标都会读到全额余额造成进度重复计算——这是文档明确列出的已知限制见Gotchas。目标与账户之间通过GoalAccount建立链接链接上可携带allocated_amount表示专项 earmark指定金额为空则代表占用整个账户余额中未被其他目标 earmark 的部分。从迁移文件可以看到底层约束db/migrate/20260511100000_create_goals.rbgoal_accounts上[goal_id, account_id]唯一索引防止同一目标重复链接同一账户goal_pledges上[goal_id, status]索引、[status, expires_at]部分索引仅status open用于快速扫描到期 pledge、matched_transaction_id的唯一部分索引仅IS NOT NULL保证一笔交易只能被一个 pledge 认领amount 0、target_amount 0的 check 约束在数据库层兜底。GoalPledge是一个意图intent它记录amount、account、kind、status、expires_at。status 枚举为open/matched/cancelled/expiredkind 枚举为transfer/manual_save在创建时根据所选账户的连接状态决定详见下文。状态语义status是算出来的state是存下来的Goal#status在渲染时实时计算app/models/goal.rb:reached—progress_percent 100或已 completed、无剩余金额:no_target_date—target_date.nil?开放式目标:on_track— 有截止日且monthly_target_amount pace:behind— 其他情况。AASM 的state与之相互独立。要拿到正确的徽章文案必须读Goal#display_status而非#status当 state 不是:active时返回 AASM state否则回落到#statusapp/models/goal.rb。文档还特别强调了几点容易被误用的语义archived只是留档不是删除的前置条件。GoalsController#destroy接受任何状态的目标app/controllers/goals_controller.rb。唯一的删除入口是 show 页的 kebab 菜单——index 卡片刻意不携带任何操作让卡片保持单击直达的单一目标。删除只级联goal_accounts和goal_pledgesGoalPledge#clear_matched_transaction_extraapp/models/goal_pledge.rb会把匹配 pledge 曾写入交易的extra[goal][pledge_id]抹掉。账户、余额、entry、交易一律不动。新的调用点应复用Goal#deletion_confirmapp/models/goal.rb不要另起一个CustomConfirm。Goal#pace是滚动 90 天净流入 ÷ 3。查询 joinentries与transactionsjoin 形状天然排除 valuation丢弃被排除的 entry并通过Transaction.excluding_pending丢弃 pending 的 provider 交易app/models/goal.rb。这个过滤很关键一笔 pending 的 Plaid 存款如果后来被冲正会悄悄扭曲 pace。注意 Sure 的 entry 金额符号约定是流入为负所以 pace 计算里取-net/3。Goal#monthly_target_amount是(remaining_amount / months_remaining).ceil(2)。months_remaining采用天精度(target_date - Date.current) / 30.0下限 0。文档明确警告按日历月计算是错误的——它会在最后 30 天产生悬崖效应让所需月存金额突然飙升。Goal#catch_up_delta_money返回max(0, monthly_target - pace - sum_of_open_pledges)。show 页的补差提醒在该值为 0 时隐藏提醒内的 pledge CTA 会预填这个差额接受一次即可补足缺口而不是在完整所需速率之上继续叠加。Pledge 匹配窗口三次检查两道唯一索引GoalPledge#matches?app/models/goal_pledge.rb检查三件事pledge 状态必须是openentry 必须落在 pledge 的account_id上entry 的date落在[created_at - 5d, max(created_at 5d, expires_at)]区间内且|entry.amount|与 pledge 金额的误差不超过$0.50 或 1%取较大者。几个关键常量app/models/goal_pledge.rbDEFAULT_WINDOW_DAYS 7 # 创建时默认有效期 EXTEND_DAYS 7 # renew 延长天数 MATCH_DATE_TOLERANCE_DAYS 5 # 日期容忍创建前后各 5 天 MATCH_AMOUNT_TOLERANCE_ABSOLUTE BigDecimal(0.50) MATCH_AMOUNT_TOLERANCE_RATIO BigDecimal(0.01)上界随expires_at延展是文档强调的关键细节extend!renew每次 7 天把expires_at推后时匹配窗口的上界取created_at 5d与expires_at的较大者。如果没有这个加宽Extend 7 days只会把过期时间推后而实际匹配窗口仍锚定在创建时刻延长操作就名存实亡了。金额匹配还有两层方向性保护transferkind 只匹配流入交易Sure 约定流入为负因此要求entry.amount 0避免一笔 $200 的支出被误认为 $200 的存入manual_savekind 匹配 valuation 时比较的是balance delta新余额 − 旧余额而不是 valuation 的原始金额那是账户的完整新总额不是贡献额同时拒绝投资账户上的 valuation delta——那更可能是市场波动而非存款。Reconcilerapp/models/goal_pledge/reconciler.rb按(account_id, status: open, kind: expected_kind, expires_at NOW())选取候选 pledgeexpected_kind对 valuation entry 取manual_save、对 transaction 取transfer。候选按created_at, id升序旧的先解析保证确定性。对每个候选调用matches?命中后若 entry 是 Transactionpledge.resolve_with!在双重with_lockpledge 行锁 transaction 行锁下把transaction.extra[goal][pledge_id] pledge.id写入并置matched_transaction_idapp/models/goal_pledge.rb若 entry 是 Valuationresolve_with_valuation!仅翻转状态无需盖戳。两道部分唯一索引共同保证单次认领语义见迁移文件goal_pledges (matched_transaction_id) WHERE matched_transaction_id IS NOT NULLtransactions ((extra - goal - pledge_id)) WHERE (extra - goal - pledge_id) IS NOT NULLGoal#last_matched_pledge_atapp/models/goal.rb通过matched_transaction_id反查 entry 的date因此 show 页头部显示的Last pledge matched N days ago读的是真实 entry 日期而不是goal_pledges.updated_at。这个区别很重要一次 sync 重跑会触碰每个已匹配 pledge 的updated_at若以它为准所有目标的最近匹配时间文案都会被重置。Connected vs manual 账户kind 是逐账户判定的Account#manual?app/models/account.rb在以下条件全部成立时返回 trueaccount_providers关联为空plaid_account_id为空simplefin_account_id为空。它与Account.manualscopeapp/models/account.rb完全镜像避免实例判断与查询作用域漂移。kind 的判定收敛在Account#default_pledge_kindapp/models/account.rb而不是控制器里散落的逻辑def default_pledge_kind manual? !investment? ? manual_save : transfer end即手动账户且非投资账户→manual_save连接了 provider 的账户、以及投资账户 →transfer。投资账户永远不用manual_save因为其正向 valuation delta 通常是市场波动而非存款会误匹配。Goal#any_connected_account?app/models/goal.rb在任意一个关联账户非 manual 时返回 true它驱动模态框标题文案有连接账户时显示I just transferred…纯手动账户的目标显示I just saved…。由于 kind 是逐账户的一个同时关联手动账户与连接账户的目标也能正确工作——kind 反映的是用户具体选中的那个账户而非整个目标。颜色映射同一账户在目标内处处同色Goal#account_color_mapapp/models/goal.rb返回{ account_id palette_hex }按 id 排序后依次分配调色板颜色Goals::AvatarComponent::PALETTE容量 10超出取模循环。三个消费方必须一致goal 卡片上的AccountStackComponent资金分解组件里的分布条distribution bar资金分解组件每行的头像。按 id 排序保证了即使账户重新加载顺序变化颜色分配也不会抖动。已知可接受的不一致目标上下文之外新建目标的账户勾选清单仍调用Goals::AvatarComponent.color_for(account.name)与映射无关。文档明确表示这是可以接受的——表单是一次性选择器不是反复出现的视图。常见开发任务1. 给Goal加字段文档给出了标准七步这里补充每步对应的仓库位置迁移add_column :goals, :your_field, :type若该字段会被查询加部分索引校验在 app/models/goal.rb 中添加 presence/范围规则参考现有validates :name, presence: true, length: { maximum: 255 }等写法强参数更新 app/controllers/goals_controller.rb 的goal_params创建用与goal_update_params更新用表单在app/views/goals/_form_stepper.html.erb创建与_form_edit.html.erb编辑中呈现文案在goals.form_stepper.step1.fields.*与activerecord.attributes.goal.*下添加 label展示选择正确的展示位show 页头部、卡片副行等测试扩展test/models/goal_test.rb校验逻辑控制器测试覆盖表单参数流。2. 给Goal#status新增状态status 的枚举隐式存在于方法体的符号返回值中app/models/goal.rb因此新增状态需要同步改动Goal#status在正确的分支返回新符号若新状态与 AASM state 交互改Goal#display_statusGoals::StatusPillComponent::VARIANTS增加徽章样式class 图标Goals::CardComponent#footer_line若卡片脚注文案受影响GoalsController#kpi_payload若 KPI 条需要统计它注意tracked_total的分母排除逻辑app/controllers/goals_controller.rbconfig/locales/views/goals/en.yml的goals.status.*增加徽章文案若新状态参与 index 筛选还需 chip 与 subtitle key若新状态参与筛选改Goals::StatusPillComponent#status_key与 goal-filter Stimulus 控制器chips 上的data-status...。3. 新增 pledge kindkind 是 Postgres 枚举goal_pledge_kind见迁移中的create_enum :goal_pledge_kind, %w[transfer manual_save]支撑GoalPledge#kind属性。新增值需要迁移ALTER TYPE goal_pledge_kind ADD VALUE your_kind——该操作在 Postgres 中不可逆先想清楚是真的需要新 kind还是只是要在现有 kind 上换一种匹配策略GoalPledge::KINDS常量app/models/goal_pledge.rb若新 kind 有逐账户触发条件改 kind 判定目前收敛在Account#default_pledge_kindGoalPledge::Reconciler#expected_kindapp/models/goal_pledge/reconciler.rb若新 kind 匹配不同形状的 entrygoal_pledges.new.helper_*的文案与模态框帮助文本。4. 触碰 reconcilerReconciler 是热点代码——每个 provider 的每笔导入交易都会调用它。文档列出三条红线外层rescue StandardError是保护性的意外的 raise 会打断所有账户的导入器。保留 rescue但要转发给 Sentry源码正是这样做的app/models/goal_pledge/reconciler.rb让底层 bug 保持可见内层 rescue 只捕获已知竞态NotOpenError、AlreadyClaimedError、RecordInvalid、RecordNotUniqueapp/models/goal_pledge/reconciler.rb。它们覆盖了两种典型竞态另一个 worker 先认领了 pledge或另一个 pledge 先盖了交易的戳。在这里新增异常类应该是一个深思熟虑的决定find_each循环在第一次成功解析后 return被 rescue 的失败则落到下一个候选 pledge 继续尝试。Gotchas已知限制与踩坑清单文档明确列出的限制与坑值得任何修改者在动代码前先读一遍同一账户资助两个目标会重复计算进度。两个目标都读到全额余额并向各自目标贡献。解决方案需要一个分配原语按比例或显式用户权重拆分余额。Goal#pace包含工资、房租、借记卡消费——即关联账户上的一切流入。对主账户打款账户而言该指标等于余额净变化而非有意储蓄。月光族即使主动转账pace 也可能接近零。要隔离有意储蓄需要转账对transfer-pair检测。单个低月导致状态跳变。当前行为是诚实但刺眼的连续五个月正常六月度假突然 Behind。文档建议用两个月移动条件或恢复横幅来软化。浅色 palette 条目在bg-container上的对比度偏弱。修复在设计系统层不在 goal 功能内。可见表面是分布条分段与目标卡片的进度环。Goal#balance_series_values在Balance::ChartSeriesBuilder抛错时 rescueStandardError并记录到 Sentry。图表降级为只画目标线而不是 500。排查投影图 saved 线为何为空时先查 Sentry。演示数据九个目标覆盖全部状态Demo::Generator#generate_goals!播种九个目标刻意让至少一张卡片覆盖每种状态Active 计算状态:reached、:on_track、:behind、:no_target_date外加一个已过期的 active 目标用来触发was due头部文案AASM 状态paused、archived、completed两个 open pledge横幅 index callout一个 matched pledge 绑定到真实的近期流入交易覆盖Last pledge matched N days ago头部。路由到不同账户池主账户持有大部分余额、副账户只持十分之一是迫使某些目标落在目标值以下而不是超出的手段——如果改动演示账户余额目标值也要跟着改。从零重新生成bundle exec rails db:drop db:create db:schema:load SKIP_CLEAR1 bundle exec rake demo_data:defaultSKIP_CLEAR0会先清空既有数据但在全新 schema 上 clear 步骤与trades约束有已知问题所以SKIP_CLEAR1是可靠路径。后台进程谁在背后干活SweepExpiredGoalPledgesJob通过 sidekiq-cronconfig/schedule.yml每 15 分钟运行一次。扫描GoalPledge.open_and_expired_nowstatus: open且expires_at Time.current把匹配行翻转为expiredapp/jobs/sweep_expired_goal_pledges_job.rb。Job 对单条记录与查询阶段分别 rescue 并上报 Sentry单条失败不会中断整个清扫。GoalPledge::Reconciler不是独立 Job而是同步运行在既有导入管线内部。任何 provider 同步Plaid、SimpleFIN、Lunchflow、Enable Banking、Brex、IBKR、Kraken、SnapTrade和任何手动余额对账都会流经Account::ProviderImportAdapter或Account::ReconciliationManager从而触发 reconciler 钩子。结语Sure 的 Goals 功能把目标建模为意图 实时余额的组合没有贡献台账进度即账户余额pledge 是带过期窗口的匹配承诺由同步管线中的 reconciler 在交易/估值到达时完成认领。这套设计的承重墙在于kind 判定收敛在账户级default_pledge_kind、匹配窗口上界随extend!加宽、单次认领由两道部分唯一索引兜底、pace/状态计算全部基于实时数据而非历史账本。理解这些不变量是安全修改该功能的起点。文中所有结论均可对照 docs/llm-guides/goals.md、app/models/goal.rb、app/models/goal_pledge.rb、app/models/goal_pledge/reconciler.rb 与 db/migrate/20260511100000_create_goals.rb 验证。赞分享金融科技后端前端移动开发桌面应用AI 应用【免费下载链接】sureThe personal finance app for everyone (by everyone)项目地址https://gitcode.com/gh_mirrors/sure5/sure点击查看免费下载相关推荐Argo/BootstrapBlazor模糊匹配功能深度解析与实践指南Argo/BootstrapBlazor模糊匹配功能深度解析与实践指南 概述 在现代Web应用开发中高效的数据搜索和过滤功能是提升用户体验的关键因素。Boo前端UI组件LifeOS TELOS Goals 文件详解GOALS.md 的目标数据结构、解析链路与新鲜度机制LifeOS TELOS Goals 文件详解GOALS.md 的目标数据结构、解析链路与新鲜度机制 本文以 LifeOS 仓库中的 GOALS.md httAI 技能人工智能AI 应用Obsidian笔记美化终极指南如何用CSS布局技巧打造个性化知识库Obsidian笔记美化终极指南如何用CSS布局技巧打造个性化知识库 你是否厌倦了Obsidian默认的单列布局想让你的知识库拥有杂志般的排版效果吗前端上一篇Yarn 1.x 配置系统详解.yarnrc文件的完整配置指南下一篇Hexo持续集成部署终极指南GitHub Actions自动化工作流完整教程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考