From 6224ef5980f771c3f9b4f2dbca549447584966f0 Mon Sep 17 00:00:00 2001 From: yuxuanhui Date: Tue, 29 Sep 2026 13:47:26 +0800 Subject: [PATCH] feat: add growth tracking page and related functionality - Implemented a new Growth page to track practice time and trends. - Added API integration for fetching review data. - Created components for displaying practice statistics and trends. - Updated navigation titles for the main index and growth pages. - Removed unused styles from the index page. - Introduced a Projects management page for adding and editing practice projects. - Developed a Record form for logging practice sessions with validation. - Added utility functions for date manipulation and duration formatting. - Implemented error handling and session management in the practice service. - Created unit tests for the practice service to ensure reliability. --- .../issues/01-ihour-evidence.md | 73 ++++ .../issues/02-recording-flow.md | 36 ++ .../issues/03-practice-organization.md | 36 ++ .../issues/04-growth-review.md | 34 ++ .../issues/05-record-continuity.md | 26 ++ .../issues/06-core-flow-prototype.md | 47 +++ .../issues/07-ballet-knowledge-sources.md | 13 + .../issues/08-voice-feasibility.md | 13 + .../issues/09-release-sequence.md | 12 + .../issues/10-v1-implementation.md | 49 +++ .scratch/ballet-practice-v1/map.md | 45 ++ .scratch/ballet-practice-v1/spec.md | 213 ++++++++++ AGENTS.md | 13 + README.md | 37 +- backend/.env.example | 4 + backend/cmd/server/main.go | 9 +- backend/compose.yaml | 2 + backend/internal/database/001_practice.sql | 47 +++ backend/internal/database/migrate.go | 41 ++ backend/internal/httpapi/practice.go | 210 +++++++++ backend/internal/httpapi/practice_test.go | 397 ++++++++++++++++++ backend/internal/identity/wechat.go | 61 +++ backend/internal/practice/records.go | 199 +++++++++ backend/internal/practice/review.go | 122 ++++++ backend/internal/practice/store.go | 201 +++++++++ backend/scripts/test-integration.sh | 27 ++ docs/agents/domain.md | 31 ++ docs/agents/issue-tracker.md | 40 ++ docs/agents/triage-labels.md | 15 + miniprogram/package.json | 3 +- miniprogram/project.config.json | 29 +- miniprogram/src/app.config.ts | 17 +- miniprogram/src/app.scss | 106 ++++- miniprogram/src/components/common.tsx | 33 ++ miniprogram/src/hooks/useRemote.ts | 29 ++ .../src/pages/calendar/index.config.ts | 1 + miniprogram/src/pages/calendar/index.tsx | 67 +++ miniprogram/src/pages/growth/index.config.ts | 1 + miniprogram/src/pages/growth/index.tsx | 45 ++ miniprogram/src/pages/index/index.config.ts | 3 +- miniprogram/src/pages/index/index.scss | 21 - miniprogram/src/pages/index/index.tsx | 55 ++- .../src/pages/projects/index.config.ts | 1 + miniprogram/src/pages/projects/index.tsx | 69 +++ miniprogram/src/pages/record/index.config.ts | 1 + miniprogram/src/pages/record/index.tsx | 132 ++++++ miniprogram/src/services/practice.ts | 124 ++++++ miniprogram/src/utils/dates.ts | 21 + miniprogram/tests/practice.test.cjs | 84 ++++ 49 files changed, 2824 insertions(+), 71 deletions(-) create mode 100644 .scratch/ballet-practice-v1/issues/01-ihour-evidence.md create mode 100644 .scratch/ballet-practice-v1/issues/02-recording-flow.md create mode 100644 .scratch/ballet-practice-v1/issues/03-practice-organization.md create mode 100644 .scratch/ballet-practice-v1/issues/04-growth-review.md create mode 100644 .scratch/ballet-practice-v1/issues/05-record-continuity.md create mode 100644 .scratch/ballet-practice-v1/issues/06-core-flow-prototype.md create mode 100644 .scratch/ballet-practice-v1/issues/07-ballet-knowledge-sources.md create mode 100644 .scratch/ballet-practice-v1/issues/08-voice-feasibility.md create mode 100644 .scratch/ballet-practice-v1/issues/09-release-sequence.md create mode 100644 .scratch/ballet-practice-v1/issues/10-v1-implementation.md create mode 100644 .scratch/ballet-practice-v1/map.md create mode 100644 .scratch/ballet-practice-v1/spec.md create mode 100644 backend/internal/database/001_practice.sql create mode 100644 backend/internal/database/migrate.go create mode 100644 backend/internal/httpapi/practice.go create mode 100644 backend/internal/httpapi/practice_test.go create mode 100644 backend/internal/identity/wechat.go create mode 100644 backend/internal/practice/records.go create mode 100644 backend/internal/practice/review.go create mode 100644 backend/internal/practice/store.go create mode 100755 backend/scripts/test-integration.sh create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md create mode 100644 miniprogram/src/components/common.tsx create mode 100644 miniprogram/src/hooks/useRemote.ts create mode 100644 miniprogram/src/pages/calendar/index.config.ts create mode 100644 miniprogram/src/pages/calendar/index.tsx create mode 100644 miniprogram/src/pages/growth/index.config.ts create mode 100644 miniprogram/src/pages/growth/index.tsx delete mode 100644 miniprogram/src/pages/index/index.scss create mode 100644 miniprogram/src/pages/projects/index.config.ts create mode 100644 miniprogram/src/pages/projects/index.tsx create mode 100644 miniprogram/src/pages/record/index.config.ts create mode 100644 miniprogram/src/pages/record/index.tsx create mode 100644 miniprogram/src/services/practice.ts create mode 100644 miniprogram/src/utils/dates.ts create mode 100644 miniprogram/tests/practice.test.cjs diff --git a/.scratch/ballet-practice-v1/issues/01-ihour-evidence.md b/.scratch/ballet-practice-v1/issues/01-ihour-evidence.md new file mode 100644 index 0000000..60d79bc --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/01-ihour-evidence.md @@ -0,0 +1,73 @@ +# iHour 的哪些能力支撑练习记录与回顾,截图证明了哪些实际用法? + +Type: research +Labels: wayfinder:research +Status: resolved +Assignee: yuxuanhui +Research agent: /root/ihour_research +Research agent status: complete +Resolved: 2026-09-28 +Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md) +Blocked by: none + +## Question + +通过 iHour 官方资料和用户截图,核实项目组织、日常记录、计时、提醒、统计、成就、分享和数据延续能力。区分官方证实、截图直接观察、推断和未知,提供后续第一期取舍所需依据;不代替用户决定功能范围。 + +## Answer + +研究子代理 `/root/ihour_research` 已完成只读检索;主代理抽查官方产品页、Google Play 与中国区 App Store,并查看用户提供的原始统计截图。未安装或实际操作 iHour,以下不是运行验证。 + +### 官方可核实的能力 + +| 能力 | 已核实事实 | 来源 | +| --- | --- | --- | +| 项目与记录 | 按不同项目记录每日投入,展示项目累计时间 | [官方产品页](https://app.ipad.ly/ihour?lang=zh_hans) | +| 计时、提醒与规划 | 日常提醒、正倒计时,以及长期时间规划 | [Google Play 开发者介绍](https://play.google.com/store/apps/details?hl=zh&id=com.clover.ihour) | +| 图表与进度 | 时间投入统计、各项目累计进度 | [官方产品页](https://app.ipad.ly/ihour?lang=zh_hans) | +| 激励与个性化 | 成就、隐藏成就、社交分享、背景与项目图标 | [官方产品页](https://app.ipad.ly/ihour?lang=zh_hans) | +| 专注与同步 | iOS 介绍列出专注主题、环境音、禁用手机模式、云端同步及怪兽收集;版本说明列出每日专注目标与快捷启动 | [中国区 App Store](https://apps.apple.com/cn/app/ihour-%E6%97%B6%E9%97%B4%E6%8A%95%E8%B5%84%E8%AE%A1%E5%88%92-%E4%B8%93%E6%B3%A8%E8%AE%A1%E6%97%B6%E5%A4%A7%E5%B8%88/id687625208) | + +可归纳出的产品循环为:组织项目 → 记录投入或计时 → 回顾时间分布与累计 → 获得激励。该循环是研究归纳;“线下上课”和“自主练习”不是官方已证实的独立记录类型。 + +### 截图直接观察 + +用户提供三个页面:时间投资计划、我的项目、我的统计。证据来自本次对话附件,文件名分别为 `codex-clipboard-4f251b27-ac28-41ea-af31-3a571f87a65d.jpg`、`codex-clipboard-57729cc0-5e2f-44ac-bfbe-a64c46ea2986.jpg`、`codex-clipboard-bcfe8704-b26f-4de9-9b74-942814031d3f.jpg`;没有把临时图片路径视为长期有效的资产链接。 + +- “芭蕾”之下列出八个项目;每个有图标、名称和添加入口。截图没有展示添加后的表单,不能据此还原具体输入步骤。 +- 统计页显示 163 条记录、167 小时、平均每周 4 小时、最近 7 天 4.5 小时;另有“在 iHour 记录时间的 103 天”。“103 天”的算法未核实,不能解读为连续打卡。 +- 同一统计页的专注时长为 0,怪兽数量为 0、成就数量为 31。只能说明该页面的记录总时长与专注时长不同,不能断言用户从未使用计时或不喜欢游戏化。 +- 页面包含日期热图、单日项目分布、最近 7 天/每周/每月/每年柱状统计,以及全部/年/月/周的累计分布。 + +统计页的精确展示如下: + +| 项目 | 小时 | +| --- | ---: | +| 足髋训练 | 3 | +| 软开素质 | 19 | +| 基础提升 | 45.5 | +| 小球核心 | 19 | +| 天鹅臂颈 | 31 | +| 呼吸训练 | 2 | +| 核心臀腿 | 11 | +| 零基础 | 36.5 | +| 合计 | 167 | + +八项相加恰为 167,支持此样本中“芭蕾”是汇总层的判断;不证明 iHour 所有父子项目都采用相同规则。项目页把基础提升和零基础显示为 46、37 小时,统计页显示为 45.5、36.5 小时,提示页面精度不同,不能用取整后的展示值重新计算总量。 + +### 对芭蕾场景的启发,尚待用户决策 + +- 课后快速记录应作为优先候选:这个样本积累了大量记录,却没有显示相应的专注时长。需要真实使用习惯进一步确认,不能据此直接取消计时。 +- 现有名称同时包含学习阶段、身体部位和器械等信息,也可能直接来自课程名称。应允许讨论保留熟悉的名称,不宜立即强制统一为专业动作分类。 +- 回顾可以先回答“这周练了什么、投入多少、过去积累了多少”;是否加入教师提示、主观收获、目标和里程碑属于后续决策。时长不能直接换算为技术等级。 + +### 资料边界 + +- 官方公开资料没有核实过去日期补记、编辑和删除的操作规则、父子项目汇总细节、各统计指标算法、文件导出与导入能力。未证实不表示应用没有这些功能。 +- 官方网站同时提供 iOS 和 Android 入口。两端商店均有付费项目,但免费/会员权限边界及跨端一致性未核实;不据此制定本项目付费策略。 +- 调研没有使用旧用户评论来定义当前功能,也没有把 iOS 专属能力推定到 Android 或微信小程序。 +- 三张截图只有汇总与局部日期信息,无法重建 163 条原始记录;不能承诺仅凭截图完整迁移历史明细。 + +## Comments + +- 2026-09-28:按用户“先调研”要求完成研究,由主代理保存结论;本票解决事实问题,不确认第一期功能清单。 diff --git a/.scratch/ballet-practice-v1/issues/02-recording-flow.md b/.scratch/ballet-practice-v1/issues/02-recording-flow.md new file mode 100644 index 0000000..b34654c --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/02-recording-flow.md @@ -0,0 +1,36 @@ +# 第一期如何让上课和自主练习被轻松记下来? + +Type: grilling +Labels: wayfinder:grilling +Status: resolved +Assignee: yuxuanhui +Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md) +Blocked by: 01 + +## Question + +在用户已有线下课或跟练内容的前提下,首期主要采用练后填写时长、练前启动计时,还是两者都提供?通过一节 90 分钟线下课和一次 15 分钟自主练习,明确录入的最少必填内容、常用项目复用方式、是否需要课后感受或教师提示,以及补记、修改、删除的边界。 + +先确认真实记录习惯,再决定功能优先级;不能仅凭截图中的“+”或专注计时为零断言用户一定怎样操作。 + +## Answer + +经逐项讨论及用户最终回复“可以”,第一期记录流程确定如下: + +1. 练完后填写时长;以整堂课为一条记录,不要求拆分课内内容。额外自主练习另记。 +2. 最小表单为项目、日期、时长,以及选填文字笔记。笔记可用于教师提示或个人收获,不填也能保存。 +3. 预置少量常用项目,允许用户改名、删除和新增。已有记录的项目采用归档,从常用列表移除,历史记录和累计统计保留。 +4. 允许补记过去日期、修改已保存记录和删除误记。统计随有效记录变化更新;项目归档与单条记录更正、删除是不同操作。 + +由上述规则推导的验收例子:一节 90 分钟课程及额外 15 分钟自主练习分别登记,合计 105 分钟;将课程更正为 60 分钟后合计 75 分钟;删除误记的 15 分钟后合计 60 分钟。归档课程项目不再减少这 60 分钟。以上是后续实现的验收依据,尚未执行产品测试。 + +具体预置清单、项目分组、改名后的历史呈现及汇总层级由[练习项目如何组织,才能保留熟悉的课名又不重复计算时间?](03-practice-organization.md)继续确认;图表口径、数据延续和页面反馈由各自后续事项处理。本票不代表全部第一期需求已定稿。 + +## Comments + +- 用户确认:“练完后填写时长”。第一期的主记录方式确定为练后登记;不将练前启动计时作为主流程。该回答没有决定具体表单字段、补记与修改规则,也不等于永久排除计时能力。 +- 用户确认:“整堂记录”。一堂课记一条练习记录并填写整堂总时长,不要求按把杆、中间、核心等内容拆分。承接上一轮选项,额外自主练习另记。 +- 用户确认:“预置几个常用项目,允许改名、删除和新增”。首次使用提供少量可直接选择的项目,用户可按自己的课程与练习习惯管理;具体预置清单尚未确定。该回答未决定删除项目是否影响既有记录,也未决定改名对历史名称的呈现方式。 +- 用户确认:“保留历史(推荐)”。已有练习记录的项目从常用列表移除时采用“归档”,历史记录及其累计统计继续保留。本决定针对项目管理,不代表禁止用户更正或删除单条误记;单条记录的维护边界尚待确认。 +- 用户确认:“增加选填笔记”。每条练习记录可附一段文字,用于教师提示或个人练习收获;不填写笔记也能保存。没有据此增加图片、视频或其他附件需求。 +- 用户对“项目、日期、时长与选填笔记”的表单,以及“允许补记、修改、删除误记,统计同步更新”的规则回复“可以”。本票据此解决,完整结论见 Answer。 diff --git a/.scratch/ballet-practice-v1/issues/03-practice-organization.md b/.scratch/ballet-practice-v1/issues/03-practice-organization.md new file mode 100644 index 0000000..d2fb1b4 --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/03-practice-organization.md @@ -0,0 +1,36 @@ +# 练习项目如何组织,才能保留熟悉的课名又不重复计算时间? + +Type: grilling +Labels: wayfinder:grilling +Status: resolved +Assignee: yuxuanhui +Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md) +Blocked by: 01, 02 + +## Question + +沿用[第一期如何让上课和自主练习被轻松记下来?](02-recording-flow.md)已经确认的记录粒度及项目管理规则,不重新讨论。剩余需要决定:项目采用平铺列表还是需要分组/父子层级,具体预置哪些名称,项目改名后历史记录如何呈现? + +截图中的“零基础、基础提升、足髋训练、小球核心”等名称可以作为候选,不视为统一训练标准。若使用分组,需明确分组是否仅用于组织和汇总、是否允许直接登记,避免重复累计。时长输入与图表显示精度转入成长回顾事项统一明确;这里不决定技术结构。 + +## Answer + +用户已确认第一期采用以下项目组织规则: + +1. 平铺展示具体练习项目,不设分组或父子项目层级。 +2. 首批预置八个项目:零基础、基础提升、软开素质、足髋训练、核心臀腿、小球核心、天鹅臂颈、呼吸训练。这些是可编辑的起始名称,不是统一训练分类标准。 +3. 项目改名后,历史记录和统计统一显示新名称,仍属于原来的同一项目;日期、时长和笔记保持不变。 + +沿用[已确认的记录流程](02-recording-flow.md),一条练习记录选择一个项目。由此推导,总量按有效记录各累计一次,项目汇总不是另一份额外投入;改名不生成新项目或新的记录,不增加或减少累计时长。项目新增及归档规则由记录流程事项保存,本票不重复定义。 + +例如,将“基础提升”改名为“芭蕾基训”后,旧记录与项目统计均显示“芭蕾基训”,原来登记的 90 分钟仍是同一条 90 分钟记录。这是后续实现验收场景,尚未执行产品测试。 + +时长输入、显示精度及各图表的具体口径统一交由[成长回顾首先应回答哪些问题,并提供怎样的鼓励?](04-growth-review.md)处理,尚未视为已决定。 + +## Comments + +- 记录流程已确认后领取本票;先讨论平铺与分组,再确定预置清单及其余组织规则。 +- 用户确认:“平铺列表”。第一期直接展示并选择具体练习项目,不设置分组或父子项目层级。 +- 用户对所提八项回复“可以”,首批预置项目确定为:零基础、基础提升、软开素质、足髋训练、核心臀腿、小球核心、天鹅臂颈、呼吸训练。这些仅作为可编辑的起始项目名称,不是统一训练标准。 +- 用户确认:“统一显示新名称(推荐)”。历史记录与统计统一显示改名后的名称,原有日期、时长和笔记不变。项目组织问题据此解决。 +- 时长输入与显示精度和统计呈现关联更紧,已明确转入成长回顾事项继续讨论,没有在本票中默认为已确认。 diff --git a/.scratch/ballet-practice-v1/issues/04-growth-review.md b/.scratch/ballet-practice-v1/issues/04-growth-review.md new file mode 100644 index 0000000..245a4b3 --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/04-growth-review.md @@ -0,0 +1,34 @@ +# 成长回顾首先应回答哪些问题,并提供怎样的鼓励? + +Type: grilling +Labels: wayfinder:grilling +Status: resolved +Assignee: yuxuanhui +Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md) +Blocked by: 02, 03 + +## Question + +用户最需要回顾的是练了多少、多久练一次、各项目时间分布,还是每次练习的收获?据此决定总时长、记录次数、练习天数、周月趋势、日历和项目分布的首期优先级,以及是否需要目标、里程碑或分享。 + +明确练习天数与连续天数、记录条数与课次的区别,以及补记对图表和里程碑的影响。投入时间不等于技术水平提升;需要讨论如何呈现休息日,而不是默认以每日连续打卡作为唯一激励。 + +承接项目组织事项中的时长精度问题,明确用户如何输入时长、统计如何展示小时和分钟,以及总量与项目展示值之间的关系,避免先取整再汇总造成误差。 + +## Answer + +用户确认首期包含累计时长与练习天数、练习日历、周月时长趋势,以及各项目时长和占比。日历可点开日期回看记录;同一天多条记录,练习天数计一天。 + +用户明确决定练习目标、成就徽章和分享卡片均放到后续版本。第一期的成长回顾由练习数据及已有笔记支撑,不额外建立技能等级、打卡奖励或分享流程。 + +采用以下常规默认方案,后续在原型和规格中集中验证:时长按分钟输入,使用完整时长汇总后再格式化;周按周一至周日、月按自然月;补记按实际练习日期归属;项目归档不影响其历史统计。以上为代理提供的默认细节,并非用户逐项确认。 + +验收推导:同日登记 90 分钟课程与 15 分钟自主练习,累计 105 分钟、练习天数为一天;将其中一条改到另一个日期后,累计时长不变,练习天数及相应日期的图表随有效记录变化。尚未执行产品测试。 + +## Comments + +- 记录流程与项目组织均已解决,现领取本票。 +- 用户对所提统计范围回复“可以”:第一期包含累计时长与练习天数、可点开日期回看记录的练习日历、周月时长趋势、各项目时长及占比。同一天多条练习记录,练习天数计一天。 +- 常规细节拟采用以下默认方案,后续统一在原型或规格中呈现,不逐项追加问答:时长以分钟为输入精度,汇总使用完整时长后再格式化展示;日期默认今天且可补记;周按周一至周日、月按自然月;补记按实际练习日期归属;归档项目的历史继续计入统计。这些是代理提出的默认方案,不标记为用户逐项确认。 +- 用户明确确认:“练习目标、成就徽章和分享卡片,都放到后续版本”。本票据此解决。 +- 用户询问剩余决策:除本票的激励与分享外,现有地图还需确认数据保存及旧记录衔接,并通过核心流程原型确认页面。后续问题聚焦影响范围或数据含义的取舍,常规可逆细节提供默认方案。 diff --git a/.scratch/ballet-practice-v1/issues/05-record-continuity.md b/.scratch/ballet-practice-v1/issues/05-record-continuity.md new file mode 100644 index 0000000..dafc410 --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/05-record-continuity.md @@ -0,0 +1,26 @@ +# 练习记录需要怎样保存,已有 iHour 积累如何衔接? + +Type: grilling +Labels: wayfinder:grilling +Status: resolved +Assignee: yuxuanhui +Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md) +Blocked by: 01 + +## Question + +用户首次使用、换设备或重装后应如何保留自己的记录,首次录入前是否接受登录?对于已有的 iHour 积累,第一期从新记录开始、允许逐条补录,还是需要期初汇总? + +如果带入历史总时长,需要决定其是否进入趋势、练习天数和次数统计,不能从总时长虚构历史明细。自动导入必须先取得真实可导出样例再评估,公开资料未证实其可用;不因参考 iHour 就默认承诺同步或迁移能力。 + +## Answer + +用户确认数据保存按建议执行:关联微信身份,云端保存练习记录,同一微信身份换设备后可以恢复自己的记录。该项是产品要求,尚未接入或验证实际登录、云端保存与恢复;实现继续沿用当前小程序和后端项目,不因“云端保存”一词改变技术供应商。 + +用户明确“先不考虑旧数据衔接”。第一期从本产品新建立的记录开始,不做 iHour 导入、迁移或期初累计录入,也不带入截图中的历史总量。这不取消记录流程已确认的日常漏记补录能力。 + +首次使用的登录提示与保存反馈作为原型中的体验细节验证;不增加独立的手机号注册流程。原型只用内存模拟数据,不实际调用身份服务或上传用户内容。 + +## Comments + +- 用户在同一条回复中明确确认数据保存、旧数据范围和激励范围;据此记录已经作出的产品取舍,不重复请求确认。 diff --git a/.scratch/ballet-practice-v1/issues/06-core-flow-prototype.md b/.scratch/ballet-practice-v1/issues/06-core-flow-prototype.md new file mode 100644 index 0000000..893e77c --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/06-core-flow-prototype.md @@ -0,0 +1,47 @@ +# 哪套最小页面与流程能完成记录和回顾? + +Type: prototype +Labels: wayfinder:prototype +Status: resolved +Assignee: yuxuanhui +Parent: [芭蕾岛第一期:练习记录与成长回顾](../map.md) +Blocked by: 02, 03, 04, 05 + +## Question + +在记录方式、项目组织、回顾指标和数据延续边界得到确认后,以低保真流程或可交互草图讨论“首次建立常用项目 → 记录一次练习 → 回看本周与累计”的最小路径。哪些页面、入口、空状态和反馈必须存在,什么结果能证明第一期已经满足需要? + +原型是讨论材料,需要用户实际反馈后才能解决本票;不交付生产界面,也不把当前候选的“记录、日历、成长”当作已确认导航。 + +## Answer + +用户体验原型后明确回复:“顺手,可以按此设计”。据此确认该原型作为第一期交互设计依据: + +- 主导航采用“记录、日历、成长”三个入口。 +- 记录页直接选择平铺项目,进入包含项目、日期、整堂时长与选填笔记的表单;保存后回到来源页面并显示结果。 +- 日历页选择日期后查看当天记录,可新增该日期的记录或编辑已有记录;修改后日历与统计同步更新。 +- 成长页展示已确认的累计投入、练习天数、周月趋势及项目分布。 +- 项目管理从记录页进入,支持新增、改名及移除;有历史记录的项目按已确认规则归档,历史继续保留。 +- 沿用草图中展示的空状态、表单提示、保存/取消和误记删除路径,作为正式实现时的设计基线。演示场景切换和“当前演示数据”面板仅供讨论,不属于正式产品功能。 + +本票确认的是用户认可的交互方案。正式组件适配、真实微信登录、云端保存与恢复、网络失败和重试仍需在后续实现中完成并验证;不将内存原型当成已上线或已接通后端的产品。现有原型保留为设计参考,不直接替换业务代码。 + +## Comments + +- 前置产品决策已明确,开始制作“记录、日历、成长”的可交互流程草图。采用 prototype 的状态/操作流程分支,检验新增、更正、项目改名/归档后各页面数据是否易于理解;不在此轮比较正式视觉风格。 +- 将原来的模糊项“中断、漏记、重复录入反馈”纳入本票的具体检查:保存与取消入口、补记日期、空状态、无效时长提示、误记删除、提交后返回位置。首次使用与保存反馈也在此讨论。 +- 使用内存中的演示数据;不调用微信登录或云服务,不修改业务源码,不提交或切换分支。用户反馈前保持 claimed。 +- 用户已反馈“顺手,可以按此设计”,本票据此收束,不再重复询问已确认的入口和操作路径。 + +## Assets + +- [练习记录与回顾的交互草图](/Users/yuxuanhui/.codex/visualizations/2026/09/28/01a0e6a5-d2a3-7f51-bff3-545ef8c42df0/ballet-practice-flow.html):会话内展示,纯本地交互;提供“首次使用”和“已有练习记录”两个演示场景。示例数据不来自用户历史记录,刷新后重置。 + +## Verification + +- 已在浏览器实际操作:新增 90 分钟记录并保存笔记;更正为 60 分钟后成长页显示 1 小时、1 天;项目改名并归档后统计仍保留 1 小时且使用新名称。 +- 已操作日历回看与日期更正;删除该条模拟误记后,累计回到 0 分钟、0 天。 +- 已切换示例场景和周/月统计;示例三条记录合计 195 分钟、三个练习日。 +- 已检查约 320px 内容宽度下的成长页和日历;检查后的浏览器预览尺寸已恢复。原型脚本通过语法检查。 +- 尚未验证真实微信身份、云端保存、换设备恢复及生产端能力。首次登录提示、网络失败与重试也未在草图中模拟。 +- 用户验收反馈已收到:认可三个入口以及原型中的操作路径。交互检查和用户反馈均已有记录;真实产品接入仍未验证。 diff --git a/.scratch/ballet-practice-v1/issues/07-ballet-knowledge-sources.md b/.scratch/ballet-practice-v1/issues/07-ballet-knowledge-sources.md new file mode 100644 index 0000000..b8e7f79 --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/07-ballet-knowledge-sources.md @@ -0,0 +1,13 @@ +# 哪些公开芭蕾知识来源适合建立动作目录? + +Type: research +Labels: wayfinder:research +Status: claimed +Assignee: yuxuanhui +Research agent: /root/ballet_knowledge +Parent: [芭蕾岛产品规划:练习记录、语音录入与动作成长](../map.md) +Blocked by: none + +## Question + +查找舞团、舞校、考试机构等公开的芭蕾动作术语来源,核实哪些适合作为动作名称、别名、分类和简短释义的参考。给出少量种子动作,区分术语目录与分级教学/掌握标准,说明法语及中文名称差异、访问与复用边界;不复制完整教材、不创建正式知识库,不替用户定义动作解锁规则。 diff --git a/.scratch/ballet-practice-v1/issues/08-voice-feasibility.md b/.scratch/ballet-practice-v1/issues/08-voice-feasibility.md new file mode 100644 index 0000000..6a76d13 --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/08-voice-feasibility.md @@ -0,0 +1,13 @@ +# 语音解析、LLM 与工具调用如何可靠地产生练习记录? + +Type: research +Labels: wayfinder:research +Status: claimed +Assignee: yuxuanhui +Research agent: /root/voice_feasibility +Parent: [芭蕾岛产品规划:练习记录、语音录入与动作成长](../map.md) +Blocked by: none + +## Question + +结合 Taro 微信小程序与现有 Go 后端,核实录音、语音识别、中文与芭蕾术语处理、结构化抽取及工具调用所需的可用能力。比较短录音后处理与实时交互对首期的影响,提出可验证的记录草稿流程;列明相对日期、多项练习、信息缺失、语音更正、重复提交和模型失败的处理原则。只做文档研究,不调用付费模型、不选择最终供应商、不修改业务代码。 diff --git a/.scratch/ballet-practice-v1/issues/09-release-sequence.md b/.scratch/ballet-practice-v1/issues/09-release-sequence.md new file mode 100644 index 0000000..5b4de7b --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/09-release-sequence.md @@ -0,0 +1,12 @@ +# 语音录入与动作成就分别在哪一期交付? + +Type: grilling +Labels: wayfinder:grilling +Status: claimed +Assignee: yuxuanhui +Parent: [芭蕾岛产品规划:练习记录、语音录入与动作成长](../map.md) +Blocked by: none + +## Question + +整体产品包含练习记录、语音录入和动作成就;首期是否包含语音录入及动作成就,哪些作为后续扩展? diff --git a/.scratch/ballet-practice-v1/issues/10-v1-implementation.md b/.scratch/ballet-practice-v1/issues/10-v1-implementation.md new file mode 100644 index 0000000..f665df9 --- /dev/null +++ b/.scratch/ballet-practice-v1/issues/10-v1-implementation.md @@ -0,0 +1,49 @@ +# 第一期本地实现与验证 + +Type: task +Status: resolved +Scope: [已确认规格](../spec.md) + +## Work + +完成微信身份与业务会话、PostgreSQL 迁移、项目管理、练习记录、日历和成长页;保持 07–09 规划票不变。不部署、发布、提交或推送。 + +## Validation + +按规格默认测试安排,通过 HTTP 请求/响应验证真实 PostgreSQL 持久化、身份隔离、幂等、归档与统计;仅替换外部微信核验。另执行后端 vet/race/build、小程序 typecheck/build。微信开发者工具、真实身份和跨设备验收分别记录,不由构建结果替代。 + +## Comments + +- 2026-09-28:已读取交接、规格及仓库约定。工作区原有 `AGENTS.md` 修改和 `.scratch/`、`docs/` 材料保留。 +- 2026-09-29:用户完成微信开发者工具登录并授权开启本机服务端口。CLI 通过 34789 确认 `login: true` 并打开项目,通过 34790 接入官方自动化 SDK;验收边界与结果补充如下。 +- 2026-09-29:用户配置后端微信凭据后,核对 AppID 与项目一致;已启动本机 Compose PostgreSQL 和 Go 开发服务。`healthz`、`readyz` 均为 200;从模拟器取得真实 `wx.login` code,由本机 HTTP 客户端提交后端,微信身份交换成功返回业务会话,携带会话读取到 8 个初始项目。没有输出或另存凭据。模拟器直接请求本机接口仍被域名校验拦截,已请求用户确认仅对被 Git 忽略的本机私有配置关闭该校验;确认前不修改安全设置。 +- 2026-09-29:用户手动勾选本机“不校验合法域名”。确认私有配置 `urlCheck: false` 后,完成真实后端的模拟器保存、更正、重读和统计联调;临时记录已清理,后端与数据库保持运行。 + +## Answer + +2026-09-28 完成第一期本地实现,2026-09-29 补充真实微信身份及本机模拟器联调;本票不代表生产发布或完整真机验收完成。 + +- 后端:版本化事务迁移;微信 code2Session 核验适配器;30 天随机业务会话;八个项目原子预置;项目新增/改名/删除或归档;练习增删改查、提交幂等及删除回执;按实际练习日期计算累计、去重练习日、周/月趋势和项目分布。写入按用户串行化并校验归属。 +- 小程序:记录、日历、成长三个入口,独立记录表单和项目管理页;保存后回来源页并刷新;失败与空结果分开;会话失效恢复;待确认提交保留输入和原标识。近期列表按登记/更正时间排序,补记可立即看到。未加入演示数据、计时器、激励、分享或 07–09 的其他规划。 +- 执行默认值:项目名 40 个 Unicode 字符、笔记 2000 字符;分钟为正整数并受 PostgreSQL integer 范围约束。暂存表单只跨当前进程内页面导航,不提供离线队列或重启后的草稿持久化。 + +### 实际验证 + +- `go vet ./...`、连接隔离 PostgreSQL 18 的 `REQUIRE_TEST_DATABASE=1 go test -race ./...`、`go build -o bin/api ./cmd/server` 全部通过。Go 共 12 个顶层测试(其中 9 个业务/数据库集成测试、3 个健康检查测试);业务测试替换微信外部网络响应,其他会话、授权和数据库操作真实执行。 +- HTTP 验证包括首次/再次登录、过期会话、另一账户猜测标识、改名/归档、归档记录编辑限制、补记与更正/删除后的统计、周日/周一及月底边界、上海业务日期、正整数/空值/长度约束、分页、重复提交/主动重复登记、并发重试/项目移除、删除回执、服务失败。重新创建 handler 后仍能恢复同一账户的数据库记录。 +- `./scripts/test-integration.sh` 已实际启动独立临时 PostgreSQL 并执行 race 测试成功,退出后自动清理其容器。开发过程中另建的隔离测试容器也在收尾清理;这些隔离集成测试没有使用业务数据库或 Compose 数据卷。 +- `pnpm typecheck`、`pnpm test:client`(3 项通过)、`pnpm build` 全部通过。客户端测试在 Taro 网络/存储边界验证续期、待确认提交、失败状态及成功写入通知,不等于小程序端到端运行验证。 +- `docker compose --env-file .env.example config --quiet` 和本地镜像 `ballet-island-v1-local-check` 构建通过。构建时有上游 Node `punycode` 弃用提示,不影响编译成功。 +- 两次只读核验:后端核验未发现确定缺陷;前端核验发现“未确认保存可取消而丢失提交标识”,已由主代理修正并补充客户端回归检查。主代理另修正近期记录排序,并以失败到通过的 HTTP 回归验证。 +- 2026-09-29 微信模拟器:开发者工具 2.02.2608070、基础库 3.17.4、iPhone 12/13 (Pro) 390px 视口。记录、日历、成长三个页面均能打开,在真实后端不可达时明确显示网络失败及重试入口,没有捕获到未处理异常。 +- 临时模拟微信/HTTP 响应后,9 项 UI 检查通过:八项目与近期记录、项目卡片进入表单并预选、空时长阻止提交、填写与保存后返回首页、日历标记及当天记录、补记首项选择与取消返回、周趋势和分布、月趋势切换与点按、项目管理添加。模拟结果不代表真实微信核验或数据库持久化;未写入真实后端或本地业务会话存储,检查后恢复原始 wx 方法并清除模拟数据。 +- 截图发现 Taroify 默认按钮尺寸与项目 375px 设计宽度不匹配;仅通过公开主题变量修正按钮尺寸、边距和字号。再次执行 `pnpm typecheck`、`pnpm build`、`git diff --check` 通过,并重新通过上述 9 项 UI 检查。模拟器测得小按钮 33px、主按钮 45px,四个快捷时长按钮同排;已目视检查五页截图。 +- 本机真实联调 7 项通过,全程未模拟微信身份或 HTTP 响应:登录后读取项目;模拟器保存 1 分钟临时记录并由已认证接口读回;日历显示记录及练习日标记;成长页与接口均增加 1 分钟、1 条记录;重新进入首页仍读到记录;表单更正为 2 分钟且记录数不增加;通过已认证 DELETE 接口清理本次临时记录后,累计时长和记录数恢复联调前值。没有捕获到未处理异常。此轮没有验证原生删除确认弹窗;清理仅按本次唯一临时标记定位,不修改用户其他记录。 + +### 未验证与下一步 + +- 本机 `backend/.env` 已配置 `WECHAT_APP_ID` / `WECHAT_APP_SECRET`,真实微信身份交换与已认证项目读取已验证。凭据只供服务端使用,没有输出或加入小程序包。 +- 微信开发者工具登录、项目打开和五页模拟器渲染已验证;原生键盘、其他窄屏尺寸、弱网/断网保存及系统返回提示仍未完成平台验收。本次模拟器验证使用自动化输入事件,并仅对部分页面执行滚动检查。 +- 本机 127.0.0.1:8080 的后端与 PostgreSQL 已启动且就绪。用户关闭本机开发者工具域名校验后,小程序到后端、数据库的真实保存、更正与读取链路已通过。 +- 正式 request 合法域名/HTTPS、另一设备读取、客户端进程重启恢复和真实多账户隔离仍待验收;重新进入首页不等于完整进程重启。HTTP 中的受控微信响应不能替代这些证据。 +- 本轮没有部署、发布、提交或推送;07–09 规划票保持原状。启动步骤和接口约定见根目录 `README.md`。 diff --git a/.scratch/ballet-practice-v1/map.md b/.scratch/ballet-practice-v1/map.md new file mode 100644 index 0000000..8a1ab48 --- /dev/null +++ b/.scratch/ballet-practice-v1/map.md @@ -0,0 +1,45 @@ +# 芭蕾岛第一期:练习记录与成长回顾 + +Labels: wayfinder:map +Status: open +Created: 2026-09-28 + +## Destination + +明确面向已有线下课或跟练内容的芭蕾爱好者,第一期应保留哪些 iHour 能力、如何适配练习场景,以及核心流程、统计口径和验收边界。地图完成后,后续工作可据此形成实现规格并开发。 + +## Notes + +- 用户已确认第一期主要解决“已有线下课或跟练内容,只需记录练习、回顾成长”。这一定位来自建图对话,不代表具体功能已获确认。 +- 本地图只做调研与决策。遵循 wayfinder;人与产品有关的决策结合 grilling、domain-modeling,流程形态通过 prototype 与用户讨论。 +- 每次只问一个关键问题;事实由代理查询,取舍由用户确认。候选建议不得写成已决定事项;建图阶段不关闭人工决策票,后续讨论按用户实际确认逐项解决。 +- 事实依据分为官方产品资料、用户提供的三张截图、推断。单个用户的分类和使用数据不能代表全部芭蕾爱好者。 +- 用户明确要求先调研 iHour,因此先行只读检索,再收录研究票。research 子技能未安装,采用网页检索与官方来源抽查;研究代理只读,主代理保存本地结论,不创建研究提交或切换分支。 +- 本仓库按 `docs/agents/issue-tracker.md` 在本地 Markdown 跟踪;依赖使用 `Blocked by`,领取使用 `Status: claimed`。事项详情只保存在各自票中,本文件仅作索引。 +- 本任务中的术语与规则以各决策票为准,未决建议继续标注为草案。已确认术语先留在本任务,不修改共享 CONTEXT.md 或 ADR。 +- 本轮已收束基础记录与回顾的产品及交互设计。同目录中未参与本轮讨论的其他规划事项保持原状,不据此扩充已确认的第一期范围;整张地图暂不标记为全部完成。 +- 已依据已解决事项整理[第一期实现规格](spec.md),状态为 `ready-for-agent`;实现范围、数据规则与验收依据以该规格为入口。 + +## Decisions so far + + + +- [iHour 的哪些能力支撑练习记录与回顾,截图证明了哪些实际用法?](issues/01-ihour-evidence.md):已核实项目记时、统计和激励能力;截图表明用户按芭蕾项目积累记录,记录时长与专注时长需要区分,具体操作与导入规则仍未证实。 +- [第一期如何让上课和自主练习被轻松记下来?](issues/02-recording-flow.md):练后整堂登记,笔记选填;项目可管理且归档保留历史,练习记录允许补记与更正,统计随之更新。 +- [练习项目如何组织,才能保留熟悉的课名又不重复计算时间?](issues/03-practice-organization.md):平铺八个可编辑预置项目,改名后历史与统计统一使用新名称,同一条练习记录只累计一次。 +- [成长回顾首先应回答哪些问题,并提供怎样的鼓励?](issues/04-growth-review.md):累计、日历、周月趋势和项目分布进入首期;目标、徽章和分享后置。 +- [练习记录需要怎样保存,已有 iHour 积累如何衔接?](issues/05-record-continuity.md):微信身份关联云端记录,首期不做旧数据衔接,保留日常补记能力。 +- [哪套最小页面与流程能完成记录和回顾?](issues/06-core-flow-prototype.md):用户认可“记录、日历、成长”三个入口与草图操作路径,作为第一期交互设计依据。 +- [第一期本地实现与验证](issues/10-v1-implementation.md):后续执行阶段已按规格完成本地代码、真实 PostgreSQL HTTP 集成验证和两端构建;真实微信与设备验收仍待环境配置,未部署、发布或提交。07–09 规划票保持原状。 + +## Not yet specified + +- 产品范围稳定后,识别需要核实的小程序能力限制及实现前置条件。 + +## Out of scope + +- 本轮不实现业务代码、不部署、不发布;这些属于地图完成后的执行阶段。 +- 第一期开设或推荐训练内容、自动编排训练计划,不属于用户已确认的“记录已有练习”定位。 +- 全量复刻 iHour、多平台原生客户端、商业化体系不属于本地图的目的。 +- 第一期的练习目标、成就徽章和分享卡片后置,依据见[成长回顾决策](issues/04-growth-review.md)。 +- 第一期不做旧数据衔接,依据见[记录保存与历史衔接决策](issues/05-record-continuity.md)。 diff --git a/.scratch/ballet-practice-v1/spec.md b/.scratch/ballet-practice-v1/spec.md new file mode 100644 index 0000000..166570c --- /dev/null +++ b/.scratch/ballet-practice-v1/spec.md @@ -0,0 +1,213 @@ +# 芭蕾岛第一期:练习记录与成长回顾 + +Status: ready-for-agent +Created: 2026-09-28 +Parent: [第一期决策地图](map.md) + +## Problem Statement + +芭蕾爱好者已经在线下上课或跟随现有内容练习,需要一个适合课后快速登记、长期回顾投入的工具。用户提供的 iHour 截图展示了按熟悉的课程或练习名称记录时长、回看累计与项目分布的实际使用方式,但通用时间管理中的项目层级、计时和激励体系并非第一期都需要。 + +用户希望练完后一笔记下整堂课,不必拆分把杆、中间或每个动作;偶尔另外练习,也能单独记录。课名和练习习惯会变化,因此项目需要可管理,改名或停用不能损失历史。回顾应能回答“练了多久、在哪些天练习、时间花在哪里、当时有什么收获”,而不是把时长当作技术水平。 + +## Solution + +提供微信小程序中的“记录、日历、成长”三个入口,沿用用户已认可的交互草图。 + +| 入口 | 第一期开通的能力 | +| --- | --- | +| 记录 | 从平铺项目进入课后登记;填写实际练习日期、整堂时长和选填笔记;查看近期记录;进入项目管理 | +| 日历 | 识别有练习的日期;点选日期查看当天记录;为所选日期补记,或更正已有记录 | +| 成长 | 累计时长、练习天数、周/月时长趋势、各项目时长与占比 | + +首次使用预置八个可编辑项目:零基础、基础提升、软开素质、足髋训练、核心臀腿、小球核心、天鹅臂颈、呼吸训练。这些是来自用户使用习惯的便捷名称,不是芭蕾训练的标准分类。 + +数据关联微信身份并存于现有后端使用的数据库,同一微信身份换设备后可恢复记录。新账户从空记录开始;不导入 iHour 历史,不带入截图中的累计数字。练习目标、成就徽章和分享卡片留待后续版本。 + +## User Stories + +1. 作为芭蕾爱好者,我希望打开小程序即可理解“记录、日历、成长”三个入口,以便快速找到登记和回顾的位置。 +2. 作为首次使用者,我希望获得八个熟悉的预置项目,以便无需先建立分类体系就能记录练习。 +3. 作为首次使用者,我希望历史与统计真实地从零开始,以便区分自己的投入和产品演示数据。 +4. 作为上课的学员,我希望练完后手动填写整堂课的时长,以便不在上课时操作计时器。 +5. 作为上课的学员,我希望一条记录只选择一个项目,以便无需拆分课堂环节,也不会重复计算同一段时间。 +6. 作为自主练习者,我希望能另外登记一次练习,以便保留课程之外的投入。 +7. 作为记录者,我希望常用项目平铺展示,以便直接选中课名或练习名称。 +8. 作为记录者,我希望日期默认今天,以便完成常见的课后登记。 +9. 作为漏记过练习的人,我希望能选择实际练习日期补记,以便日历和趋势反映练习发生的时间。 +10. 作为记录者,我希望按分钟填写时长,以便保留不足一小时的练习。 +11. 作为记录者,我希望选填老师反馈或个人收获,以便回顾那一次练习的具体内容。 +12. 作为记录者,我希望不写笔记也能保存,以便只想记时长时快速完成。 +13. 作为记录者,我希望无效日期、时长或缺失项目得到明确提示,以便在保存前改正输入。 +14. 作为记录者,我希望可以取消登记,以便未完成的输入不会变成正式记录。 +15. 作为记录者,我希望保存成功后回到发起登记的页面并看到结果,以便确认本次操作已经完成。 +16. 作为记录者,我希望保存失败时保留输入并允许重试,以便无需重新填写整堂课的信息。 +17. 作为记录者,我希望重复点击或重试同一次保存不会生成重复记录,以便统计不会被网络问题放大。 +18. 作为回顾练习的人,我希望在记录页看到近期记录,以便检查刚刚登记的信息。 +19. 作为误填信息的人,我希望更正记录的项目、日期、时长和笔记,以便保留准确的练习历史。 +20. 作为误记练习的人,我希望删除单条错误记录,以便将其从日历和统计中移除。 +21. 作为回顾练习的人,我希望日历标记有记录的日期,以便看清练习在时间上的分布。 +22. 作为回顾练习的人,我希望点选日期后看到当天的全部记录及笔记,以便回想那天练了什么。 +23. 作为补记练习的人,我希望从日历发起登记时沿用所选日期,以便不必重复选择日期。 +24. 作为更正记录的人,我希望修改后相关日期、累计与趋势一起更新,以便不同页面展示一致的事实。 +25. 作为有个人练习习惯的人,我希望新增项目,以便使用自己的课程或练习名称。 +26. 作为调整课名的人,我希望给项目改名,以便名称符合现在的用法。 +27. 作为回顾历史的人,我希望改名后的项目在旧记录和统计里也统一使用新名称,以便仍能识别为同一个项目。 +28. 作为整理项目的人,我希望删除没有使用过的项目,以便缩短日常选择列表。 +29. 作为停止某类练习的人,我希望将有记录的项目移出常用列表并保留历史,以便不用担心整理项目导致数据丢失。 +30. 作为回顾历史的人,我希望归档项目的记录、笔记和时长仍可查看和更正,以便过去的练习不会消失。 +31. 作为关注投入的人,我希望看到准确的累计时长,以便了解自己在芭蕾练习上投入了多少时间。 +32. 作为关注练习习惯的人,我希望同一天无论登记几次都只算一个练习日,以便练习天数不被记录条数放大。 +33. 作为回顾近期练习的人,我希望切换周和月查看时长趋势,以便了解一段时间内的练习安排。 +34. 作为回顾练习结构的人,我希望看到各项目时长及占比,以便了解投入分布。 +35. 作为需要休息的练习者,我希望没有练习的日期如实留空,以便回顾不依赖连续打卡或惩罚休息日。 +36. 作为使用多个设备的人,我希望同一微信身份能读取已保存的项目和记录,以便更换设备后继续使用。 +37. 作为记录个人笔记的人,我希望自己的数据只能由自己的身份读取和修改,以便笔记和练习历史保持私密。 +38. 作为遇到网络或登录问题的人,我希望界面说明当前未能加载或保存,而不把失败显示为零记录或保存成功,以便判断是否需要重试。 + +## Implementation Decisions + +### 决策来源与实现默认值 + +- 用户已确认:练后填写、整堂记录、选填笔记、八个可编辑平铺项目、删除项目保留历史、改名统一显示新名称、三页流程、云端身份关联保存、不衔接旧数据、激励与分享后置。 +- 已认可的原型决定页面入口、操作顺序、保存后返回位置、空状态与更正路径。正式界面适配小程序;演示场景切换、固定演示日期和演示数据面板不进入产品。 +- 为使规格可执行,采用已讨论过的常规默认值:正整数分钟、日期默认今天、允许过去日期而不允许未来日期、周一至周日为一周、自然月为一个月。这些是实现默认值,不表述为用户逐项确认的产品取舍。 +- 日历日期统一按一个业务时区解释,首期默认 Asia/Shanghai;练习日期存为日期值,与创建时间分开。不依据设备时区或创建时间重新划分已保存记录的日期,首期不增加时区设置页面。 +- 下文身份校验、失败反馈、幂等写入和数据约束属于完成云端保存及准确统计所需的实现要求,不扩展为新的独立产品功能。 + +### 架构与模块职责 + +- 延续 Taro React 小程序和 Go/PostgreSQL 后端。云端保存指现有后端持久化,不因此改用另一套云服务或建立第二套业务数据源。 +- 身份与会话模块负责核验微信身份、映射内部用户标识、建立与校验业务会话。外部身份核验集中在一个适配边界;不得将客户端自报的用户标识直接作为授权依据。 +- 练习业务模块统一负责项目管理、记录增删改查和回顾统计。对外提供业务操作,内部封装归属校验、分钟与日期规则、归档规则及数据库操作;不按每个页面重复实现统计逻辑。 +- 小程序负责页面状态、表单反馈与展示,通过统一请求边界使用业务接口。服务端对所有写入再次校验,不依赖客户端校验保证正确性。 +- 复用现有服务启动、数据库连接和 HTTP 请求处理结构。现有存活及就绪检查继续保持职责独立;新增业务接口必须校验身份。 +- 首期统计直接从有效记录计算,避免维护另一套容易失真的累计余额。需要优化时以相同可观察结果为约束,不提前建立复杂缓存或异步统计系统。 + +### 数据模型与不变量 + +| 对象 | 必要信息 | 约束 | +| --- | --- | --- | +| 用户 | 内部身份、经服务端验证的微信身份关联 | 同一身份恢复同一份数据;身份密钥与平台凭据仅存于服务端 | +| 练习项目 | 稳定标识、所属用户、当前名称、是否归档 | 用户之间隔离;改名保留标识;有历史记录时不可级联删除 | +| 练习记录 | 稳定标识、所属用户、项目标识、实际练习日期、整数分钟、可空文本笔记 | 每条只属于一个本人的项目;日期不晚于业务当天;分钟大于零;笔记可留空 | +| 写入识别信息 | 用户范围内的请求标识及处理结果 | 同一次新增的重试可识别;不能吞掉用户有意新建的第二条相同内容记录 | + +- 服务端首次建立用户时一次性创建八个预置项目。初始化应原子且可重入;再次登录不得重复创建,也不得恢复用户已删除或归档的预置项目。 +- 项目改名后,历史记录通过同一项目标识读取当前名称。记录日期、分钟与笔记不变,不另存一份用于展示的旧项目名称。 +- 没有记录的项目可以删除;存在记录的项目执行归档。判定与移除应具有一致性,避免与同时新增记录竞争时删掉历史。 +- 归档项目不出现在新增记录的项目选择中,仍参与历史查询和全部统计。编辑其原有记录时可保留该项目或改选一个活跃项目;不能把另一条记录新改入已归档项目。 +- 删除练习记录后,该记录不再参与日历、练习天数和时长统计。项目归档与记录删除是不同操作:前者保留历史,后者去掉误记。 +- 新增、改名时拒绝全空白项目名;笔记按普通文本保存和展示。客户端与服务端保持一致的输入长度限制和错误提示,不把原型中的临时输入上限当作产品决策。 +- 所有关联校验都包含用户归属;不能通过猜测另一个项目或记录标识跨账户读取或修改数据。 + +### 接口契约 + +以下约定业务输入输出,不预先固定路由命名或内部函数形状。所有业务操作从有效会话识别用户,错误返回应能区分需要重新登录、输入无效、对象不可用和暂时性服务失败。 + +| 操作 | 输入与条件 | 可观察结果 | +| --- | --- | --- | +| 建立/恢复会话 | 微信端取得的有效身份交换凭据 | 返回业务会话;恢复同一用户,必要时完成一次性预置初始化 | +| 查询项目 | 有效会话 | 返回当前活跃项目;查询历史时仍能解析归档项目的名称和状态 | +| 新增/改名项目 | 名称;改名另带项目标识 | 返回稳定标识与最新名称;改名在后续历史和统计查询中统一生效 | +| 移除项目 | 本人项目标识 | 未使用项目删除,已使用项目归档;结果明确告知采取的操作 | +| 查询练习记录 | 日期或日期范围;近期列表使用有界查询 | 返回本人记录、当前项目名称、日期、分钟及笔记;归档项目记录仍可返回 | +| 新增记录 | 活跃项目、练习日期、分钟、可空笔记、此次提交标识 | 完整持久化后返回记录;相同提交标识的重试不重复新增 | +| 更正记录 | 本人记录标识和完整有效表单 | 原记录被更新;不新增记录;遵守归档项目的编辑规则 | +| 删除记录 | 本人记录标识 | 记录不再可见且不参与汇总;重试不会作用于其他记录 | +| 查询回顾 | 累计范围、选定周或月等所需范围 | 返回总分钟、去重练习天数、按日期的分钟及项目分布;结果使用一致的范围口径 | + +- 对同一次新增,客户端在等待或重试期间复用提交标识,首次成功后结束该次提交;再次主动登记生成新的标识。重复提交不同内容时应给出可识别错误,不静默覆盖第一次结果。 +- 加载失败与有效空结果是不同状态。失败不得被映射为零分钟、零天或空历史;保存提示以服务端确认持久化为准。 +- 写入成功后更新或重新获取受影响的记录、日历和成长数据。迟到的旧请求响应不得覆盖新结果;返回来源页面时应能看到本次修改。 +- 会话失效时允许重新建立会话并继续操作;在结果未确认前保留用户填写内容。客户端仅作临时表单保留,不承担跨设备数据持久化或离线冲突合并。 + +### 页面与交互 + +- 记录页以活跃项目平铺列表作为主要录入入口,展示近期记录并提供项目管理入口;新用户看到真实空状态和预置项目。 +- 记录表单包含项目、实际练习日期、分钟、选填笔记。整堂课只产生一条记录,额外练习另建记录。保存期间阻止重复操作;失败保留表单;取消不写入。 +- 从记录页新增时日期默认今天;从日历新增时沿用选中日期;编辑时读取原记录。保存完成回到原页面,并展示成功反馈及更新后的数据。 +- 记录删除沿用原型中的误记删除路径并清楚提示影响,避免将“移除项目”和“删除记录”混淆。无效提交不能关闭表单或显示成功。 +- 项目管理支持新增、改名和移除。有历史时明确告知将从常用项目中移除、历史仍保留;首期不增加项目分组、层级或独立归档恢复中心。 +- 日历支持月份切换、练习日期标记和当天明细。没有记录的日期展示可补记的空状态;有多条时完整列出,不把当天汇总伪装成一堂课。 +- 成长页展示累计、练习天数、周/月趋势及项目时长分布。采用原型中的趋势切换路径;首期不再增加年趋势或任意范围分析面板。 +- 所有项目均移出活跃列表时,仍可查看历史和成长,并提供新增项目入口。零记录时占比展示为空状态,不出现无效百分比或虚构数据。 + +### 统计口径 + +- 总时长等于所选范围内有效记录的整数分钟之和;先汇总,再格式化为小时和分钟。展示取整不得反向参与统计。 +- 练习天数等于所选范围内至少有一条有效记录的不同练习日期数,不等于记录条数、注册天数或连续打卡天数。 +- 每条记录计入一个项目一次;所有项目分钟之和等于同范围总分钟。有历史的归档项目照常计入,名称使用最新名称。 +- 项目占比以该项目分钟除以同范围总分钟计算;零总量时不做除法。显示百分比可按统一精度取整,因此显示值总和可能有舍入差异,不能靠改动分钟数强凑。 +- 周趋势按所选周的日期汇总,月趋势按所选自然月的日期汇总;无记录日为零。日历与图表按实际练习日期归属,不按提交日期归属。 +- 补记、更改日期、更改项目、修改分钟或删除记录后,所有受影响日期、范围和项目的统计应重新反映有效记录。改名和归档本身不改变累计时长或练习天数。 + +## Testing Decisions + +### 主要自动化边界 + +优先使用现有 HTTP handler 的公开请求/响应边界,覆盖身份后的项目、记录、回顾完整业务路径。现有健康检查测试已经使用 Go 标准测试工具和 HTTP 测试请求验证状态码、响应及上下文行为,可沿用该风格。 + +业务集成测试连接独立 PostgreSQL 测试数据库或隔离命名空间,运行相同的数据结构初始化,验证真实持久化、关联和查询。只在外部微信身份核验边界替换不可控的平台网络响应,业务会话、归属校验和数据库行为继续实际执行。不为每个内部函数、SQL 语句或页面组件另设一套测试边界。 + +仓库当前只有健康检查测试,没有项目、练习记录或前端端到端测试。上述业务测试环境需要随功能建立;默认不另引入小程序端到端自动化框架。该安排是依据现有测试入口提出的执行默认方案,尚未经用户单独确认;不影响已经确认的产品范围。 + +### 验收场景 + +| 场景 | 对外结果 | +| --- | --- | +| 同一身份首次进入与再次登录 | 首次得到八个活跃项目和零记录;再次进入不重复预置、不恢复已移除项目;有效历史保持不变 | +| 不同用户访问同一对象标识 | 第二个用户不能读取、更改或删除第一个用户的项目及记录;接口不泄露其笔记 | +| 同日登记 90 分钟课程及 15 分钟额外练习 | 两条记录、105 分钟、1 个练习日;项目分布分钟合计为 105 | +| 将 90 分钟改为 60,再删除 15 分钟误记 | 先得到 75 分钟,再得到 60 分钟;日历、近期记录与成长一致 | +| 将同日两条中的一条移到另一日期 | 总分钟不变,练习日从 1 变为 2;原日期和新日期的日历与趋势同时变化 | +| 改名并归档有记录的项目 | 历史和统计统一显示新名称,时长及练习天数不变;新增选择器中不再出现该项目 | +| 编辑归档项目原有记录 | 可更正日期、时长、笔记并保留原项目,或改选活跃项目;不能新增记录到归档项目 | +| 删除未使用项目并重新登录 | 项目仍保持移除状态,不被再次初始化;仍可添加新的项目 | +| 删除某天最后一条记录 | 该日期不再计入练习天数;删除全部记录后累计为 0、练习天数为 0,分布展示空状态 | +| 分钟精度与边界日期 | 45 分钟和 30 分钟合计显示 1 小时 15 分钟;月底、周日/周一的记录按约定边界归属,补记不落在提交日 | +| 可空笔记和无效表单 | 空笔记成功保存;零、负数、非整数分钟、未来日期、空项目名以及不可用项目写入被拒绝,不产生部分数据 | +| 重复提交与主动重复登记 | 同一提交标识重试只得到一条记录;用户两次主动登记相同内容可得到两条记录 | +| 提交已成功但响应丢失 | 使用原提交标识重试后得到已保存记录,不重复累计;输入不会因未经确认的结果而丢失 | +| 服务异常或会话失效 | 明确区分失败与空历史;重试或重新登录后可继续;不显示虚假保存成功 | + +好的测试从请求开始,断言返回结果及后续读取能看到的事实;时长、练习天数和归档语义均通过业务接口验证。不以内部调用次数、SQL 字符串、组件结构或样式快照替代业务结果。日期测试使用受控的业务当天,避免执行日期不同造成不稳定。 + +### 小程序与真实平台验收 + +- 用微信开发者工具走完三页导航、首次空状态、整堂记录、补记、更正、误记删除、项目改名与归档,核对表单键盘、滚动和窄屏布局。 +- 用真实微信身份完成首次使用、会话恢复和另一设备读取同一份记录;重启客户端后仍能读取,另一身份看不到这些记录。 +- 检查弱网/断网保存失败、重复点击、会话失效、重新进入页面后的反馈。确认保存成功来源于真实后端响应。 +- 小程序源码变更后,在其项目目录执行 `pnpm typecheck`、`pnpm build`;后端源码变更后,在其项目目录执行 `go vet ./...`、`go test -race ./...`、`go build -o bin/api ./cmd/server`。若调整部署配置,另验证 Compose 和镜像构建。 +- 静态检查、构建、HTTP 集成测试和真实微信设备验收分别记录结果;任何一项通过均不替代其他项。本文是规格,本轮未执行这些生产实现检查。 + +## Out of Scope + +- 实时计时器、后台计时、番茄钟,以及把一堂课拆为多个动作或训练环节。 +- 自建课程、教学视频、动作知识库、训练计划、自动推荐、技能等级评估或医疗/康复建议。 +- 语音录入、LLM 解析练习、动作识别。其他尚未确认的规划事项不自动扩充本规格。 +- 练习目标、成就徽章、分享卡片、连续打卡奖励和社交排行。 +- iHour 自动同步、数据导入、历史迁移、期初累计;普通的按实际日期补记仍在范围内。 +- 项目父子层级、同时给多项目累计同一条记录、完整归档管理或回收站体系。 +- 笔记附件、图片或视频上传,以及笔记内容的自动分析。 +- 独立手机号注册、多平台原生客户端、订阅或付费体系。 +- 离线保存队列、多设备同时编辑冲突合并、实时推送同步。已确认的云端记录跨设备读取与恢复仍在范围内。 +- 本次规格交付不包含业务代码实现、生产部署或发布。 + +## Further Notes + +### 依据与交付边界 + +- 需求依据为当前对话及已解决的[记录流程](issues/02-recording-flow.md)、[项目组织](issues/03-practice-organization.md)、[成长回顾](issues/04-growth-review.md)、[数据保存](issues/05-record-continuity.md)、[核心交互](issues/06-core-flow-prototype.md)决策。 +- [iHour 调研](issues/01-ihour-evidence.md)用于理解参考产品;用户截图中的数字与布局是使用证据,不是导入要求,也不构成训练分类或能力评估标准。 +- [用户认可的交互草图](/Users/yuxuanhui/.codex/visualizations/2026/09/28/01a0e6a5-d2a3-7f51-bff3-545ef8c42df0/ballet-practice-flow.html)是本机设计参考,不直接复制为生产实现。原型使用内存和虚构样例,未接入微信或后端;正式实现不得依赖该本机路径运行。 +- 原型已实际验证新增、编辑、删除、改名、归档、日历回看和周/月切换,并检查过窄屏显示;这些证据只说明流程草图可操作,不证明生产持久化、登录或跨设备恢复可用。 +- 同目录的其他规划票保持原状,未纳入这轮已确认范围。本规格不宣称整张规划地图全部完成。 + +### 实现前置条件 + +- 当前仓库是小程序欢迎页与 Go 服务健康检查骨架,尚无业务数据结构、身份认证、项目/记录接口或成长页面。应把本规格作为完整业务闭环的新实现,不能假设相应接口已经存在。 +- 当前项目声明 Taro 4.2.1、React 18.3.1、Taroify 1.0.6,后端使用 Go、pgx 和 PostgreSQL;实现时依照各项目约定核对实际依赖和所需官方文档。此处版本描述来自配置读取,不表示本轮运行过这些工具。 +- 实现阶段需要为新业务结构建立可重复执行的数据迁移,并为测试提供隔离数据库。不得使用用户实际练习数据作破坏性验收样本。 +- 微信身份交换、会话生命周期、平台所需网络配置和可用的 HTTPS 后端地址需按真实应用配置接通并核实。平台凭据不能放入小程序包,部署及凭据配置不能由“规格已完成”推定为已授权发布。 +- `ready-for-agent` 表示范围和验收依据足以交给开发代理,不表示业务代码已完成、构建已通过或产品已经上线。 diff --git a/AGENTS.md b/AGENTS.md index 3a10dbd..19d88e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,3 +9,16 @@ 修改小程序后,在 `miniprogram/` 执行 `pnpm typecheck` 和 `pnpm build`。修改后端后,在 `backend/` 执行 `go vet ./...`、`go test -race ./...` 和 `go build -o bin/api ./cmd/server`;部署配置变更另需检查 Compose 和镜像构建。仅报告实际执行的验证。 +## Agent skills + +### Issue tracker + +任务使用本地 Markdown 跟踪;读写事项或规格前,先读 `docs/agents/issue-tracker.md`。 + +### Triage labels + +采用默认的五个分流标签;分流事项前,先读 `docs/agents/triage-labels.md`。 + +### Domain docs + +采用 single-context 布局;探索代码库前,先读 `docs/agents/domain.md`。 diff --git a/README.md b/README.md index ba12954..02f49de 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Ballet Island -同一 Git 仓库中的两个独立项目,分别安装依赖、运行与构建。当前提供项目骨架、欢迎页和服务健康检查,业务需求后续补充。 +同一 Git 仓库中的两个独立项目,分别安装依赖、运行与构建。第一期提供课后练习登记、日历回看、成长统计和练习项目管理,通过微信身份关联 PostgreSQL 中的个人数据。 ## 目录 @@ -12,10 +12,12 @@ miniprogram/ Taro 4.2.1 + React 18 + TypeScript + Taroify 1.0.6 pnpm-lock.yaml 小程序依赖锁文件 node_modules/ 小程序本地依赖(不提交) .nvmrc Node 版本 - .agents/skills/taroify/ Taroify Agent Skill backend/ Go 1.26 + PostgreSQL 18 cmd/server/ 服务入口、数据库连接与优雅退出 internal/httpapi/ HTTP 路由与测试 + internal/identity/ 微信身份核验适配器 + internal/practice/ 会话、项目、记录与回顾业务 + internal/database/ 内嵌版本迁移 go.mod / go.sum Go 模块与依赖 Dockerfile 后端镜像构建 compose.yaml API 与数据库编排 @@ -37,7 +39,7 @@ pnpm install --frozen-lockfile pnpm dev ``` -微信开发者工具导入 `miniprogram/`,AppID 为 `wx6dfbf1021db8aee0`,编译目录为 `dist/`。首页“检查连接”请求后端 `/readyz`,同时检查数据库是否可用。 +微信开发者工具导入 `miniprogram/`,AppID 为 `wx6dfbf1021db8aee0`,编译目录为 `dist/`。底部入口为“记录、日历、成长”。首次请求通过 `Taro.login` 取得 code,在后端核验后建立业务会话;登录失败或网络失败会显示重试入口,不会当作空记录。 默认 API 为 `http://127.0.0.1:8080`。使用本地 HTTP 地址时,在开发者工具的本地设置中开启“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”,仅用于本地调试。真机需使用能够访问的接口地址,并按微信要求配置域名。 @@ -50,6 +52,10 @@ TARO_APP_API_BASE_URL=https://api.example.com pnpm build `TARO_APP_API_BASE_URL` 是公开接口地址,不自动读取后端 `.env`;未设置时使用本机默认地址。 +首次登录原子创建八个可编辑项目,练习数据从零开始。表单支持补记、更正和误记删除;项目改名同步显示于历史,有记录的项目移除时归档。近期列表按最后登记或更正时间排序,使刚保存的补记也能立即看到;日历和统计使用实际练习日期,统一按 Asia/Shanghai 解释,周一开始一周。 + +项目名最多 40 个 Unicode 字符,笔记最多 2000 个字符;前后端使用相同校验。分钟为正整数,按 PostgreSQL integer 存储范围上限为 2147483647。笔记只作为普通文本显示。每次主动新增生成独立提交标识;结果未确认时保留该标识与完整输入,重试不重复累计。首期不提供离线保存队列。 + ## 后端开发 需要 Go 1.26 或更高版本,以及 Docker Compose。仅使用 Docker 运行后端时无需本机 Go。 @@ -67,6 +73,10 @@ docker compose up -d --wait postgres 后端全程无需 Node.js 或 pnpm。 +在可信的 `backend/.env` 中设置 `WECHAT_APP_ID` 和 `WECHAT_APP_SECRET`,AppID 必须与小程序一致。AppSecret 只存在于服务端配置中,不能放入小程序源码或构建变量。通过[微信官方 code2Session 接口](https://developers.weixin.qq.com/miniprogram/dev/server/API/user-login/api_code2session.html)核验身份,不接受客户端自报的用户标识;业务会话为随机 bearer token,有效期 30 天,数据库只存 token 摘要,过期后小程序自动重新登录一次。 + +服务启动会在一个带锁的事务中运行尚未应用的版本迁移,初始化用户、会话、项目、练习和幂等回执表。后续启动不会重置数据或补回已移除项目。迁移文件位于 `backend/internal/database/`,已经应用的迁移应保留不改,通过新增版本演进结构。没有微信凭据时健康检查仍可运行,登录返回 503;配置缺失不提供模拟身份或开发后门。 + ## Docker 运行与部署 以下命令在 `backend/` 中执行: @@ -95,6 +105,7 @@ PostgreSQL 18 数据卷按[官方镜像说明](https://hub.docker.com/_/postgres ```sh pnpm typecheck +pnpm test:client pnpm build ``` @@ -104,13 +115,33 @@ pnpm build go vet ./... go test -race ./... go build -o bin/api ./cmd/server + +# 真实 PostgreSQL 18 HTTP 集成验证,自动创建和清理临时测试容器。 +./scripts/test-integration.sh ``` +集成脚本只使用独立、内存存储的 PostgreSQL 容器,不读取业务 `.env`,不挂载 Compose 数据卷。每个用例创建自己的 schema 并在结束后清理。已有独立测试数据库时可通过 `TEST_DATABASE_URL` 指定(用户须有创建 schema 的权限);`REQUIRE_TEST_DATABASE=1` 可让缺少配置直接失败。单独执行 `go test` 而未提供测试数据库时,会明确跳过数据库集成用例,不能据此宣称业务集成通过。 + +集成测试经过真实 HTTP handler、业务会话和 PostgreSQL,仅在微信网络核验处使用受控响应,覆盖首次初始化、重复登录、身份隔离、归档与更正、分钟汇总、周/月边界、分页、提交重试和并发。`pnpm test:client` 使用 Node 内置测试工具验证请求层重新登录、待确认输入与提交标识保留、失败反馈和成功写入通知;只替换 Taro 网络与本地存储调用,不引入小程序端到端框架。 + +保存未确认时不能通过表单取消;系统返回会提示,待确认输入只在当前小程序进程内暂存,再次登记会恢复原提交。完全退出小程序会失去该临时表单,重新进入后应先查看云端历史;这不提供离线队列或进程重启后的草稿持久化。微信开发者工具中的键盘/滚动/窄屏表现、真实微信首次登录、跨设备恢复及断网流程仍需独立验收,构建和模拟微信响应均不能替代这些验证。 + 产物分别在 `miniprogram/dist/` 和 `backend/bin/api`,均不进入版本控制。 | 接口 | 用途 | 响应 | | --- | --- | --- | | `GET /healthz` | HTTP 服务存活检查 | `200 {"status":"ok"}` | | `GET /readyz` | 实际检查 PostgreSQL 连接 | 可用时 200,不可用时 `503 {"status":"unavailable"}` | +| `POST /v1/session` | 微信 code 换业务会话 | `{token, expiresAt}` | +| `GET /v1/projects` | 活跃项目;`includeArchived=true` 包含归档 | `{projects, today}` | +| `POST /v1/projects` / `PUT /v1/projects/{id}` | 新增 / 改名,JSON `{name}` | 最新项目 | +| `DELETE /v1/projects/{id}` | 删除未使用项目或归档 | `{action: "deleted" / "archived"}` | +| `GET /v1/records` | `from` / `to` 日期范围、`limit`(1–100,默认20)、`cursor` | `{records, nextCursor}` | +| `GET /v1/records/{id}` | 读取本人单条记录 | 项目当前名称、日期、分钟、笔记 | +| `POST /v1/records` / `PUT /v1/records/{id}` | 新增 / 更正 | `{projectId, date, minutes, note}`;新增另需 `requestId`(16–128位字母、数字、下划线或短横线) | +| `DELETE /v1/records/{id}` | 删除误记,可安全重试 | `{action: "deleted"}` | +| `GET /v1/review?period=week&date=YYYY-MM-DD` | `week` / `month`,日期默认业务今天 | 累计、选定周期、每日分钟与项目分布 | + +除会话交换和健康检查外,接口均需 `Authorization: Bearer `。失败以 `{error:{code,message}}` 返回:401 重新登录、400 输入无效、404 对象不可用、409 归档项目或提交内容冲突、410 已删除记录的旧提交、503 暂时性失败。错误响应不返回数据库细节、微信密钥或其他用户的数据。移除/记录写入在用户范围内串行化,防止并发删除项目损坏历史。 Go 服务启动时检查数据库连接,收到 `SIGINT` / `SIGTERM` 时停止接收请求并关闭连接池。 diff --git a/backend/.env.example b/backend/.env.example index 8976493..c357f93 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,3 +11,7 @@ HTTP_ADDR=127.0.0.1:8080 API_PORT=8080 API_BIND=127.0.0.1 +# Server-only WeChat credentials. Never put AppSecret in the mini program bundle. +# Without these, health checks work but identity exchange returns 503. +WECHAT_APP_ID=wx6dfbf1021db8aee0 +WECHAT_APP_SECRET= diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index ae55225..c844c1d 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -11,7 +11,9 @@ import ( "syscall" "time" + "ballet-island/backend/internal/database" "ballet-island/backend/internal/httpapi" + "ballet-island/backend/internal/identity" "github.com/jackc/pgx/v5/pgxpool" ) @@ -40,8 +42,11 @@ func run() error { } defer pool.Close() - startupCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + startupCtx, cancel := context.WithTimeout(ctx, 30*time.Second) err = pool.Ping(startupCtx) + if err == nil { + err = database.Migrate(startupCtx, pool) + } cancel() if err != nil { return fmt.Errorf("connect database: %w", err) @@ -53,7 +58,7 @@ func run() error { } server := &http.Server{ Addr: addr, - Handler: httpapi.NewHandler(pool.Ping), + Handler: httpapi.NewAppHandler(pool, &identity.WeChat{AppID: os.Getenv("WECHAT_APP_ID"), AppSecret: os.Getenv("WECHAT_APP_SECRET")}, nil), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, diff --git a/backend/compose.yaml b/backend/compose.yaml index ffd13f0..427ae7c 100644 --- a/backend/compose.yaml +++ b/backend/compose.yaml @@ -32,6 +32,8 @@ services: PGUSER: ${PGUSER:?Set PGUSER in .env} PGPASSWORD: ${PGPASSWORD:?Set PGPASSWORD in .env} PGSSLMODE: disable + WECHAT_APP_ID: ${WECHAT_APP_ID:-} + WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-} ports: - "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080" depends_on: diff --git a/backend/internal/database/001_practice.sql b/backend/internal/database/001_practice.sql new file mode 100644 index 0000000..d4983c5 --- /dev/null +++ b/backend/internal/database/001_practice.sql @@ -0,0 +1,47 @@ +CREATE TABLE users ( + id text PRIMARY KEY, + wechat_identity text NOT NULL UNIQUE, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE sessions ( + token_hash text PRIMARY KEY, + user_id text NOT NULL REFERENCES users(id), + expires_at timestamptz NOT NULL +); +CREATE INDEX sessions_expiry ON sessions(expires_at); + +CREATE TABLE projects ( + id text PRIMARY KEY, + user_id text NOT NULL REFERENCES users(id), + name text NOT NULL CHECK (char_length(btrim(name)) BETWEEN 1 AND 40), + archived boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + position bigint GENERATED ALWAYS AS IDENTITY, + UNIQUE (user_id, id) +); +CREATE INDEX projects_owner ON projects(user_id, position); + +CREATE TABLE records ( + id text PRIMARY KEY, + user_id text NOT NULL REFERENCES users(id), + project_id text NOT NULL, + practice_date date NOT NULL CHECK (practice_date >= DATE '0001-01-01'), + minutes integer NOT NULL CHECK (minutes > 0), + note text NOT NULL DEFAULT '' CHECK (char_length(note) <= 2000), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (user_id, project_id) REFERENCES projects(user_id, id) ON DELETE RESTRICT +); +CREATE INDEX records_owner_date ON records(user_id, practice_date DESC, id DESC); +CREATE INDEX records_owner_updated ON records(user_id, updated_at DESC, id DESC); +CREATE INDEX records_project ON records(user_id, project_id); + +-- Keep submission receipts after a record is deleted so a delayed retry cannot resurrect it. +CREATE TABLE submissions ( + user_id text NOT NULL REFERENCES users(id), + request_id text NOT NULL, + payload_hash text NOT NULL, + record_id text NOT NULL, + PRIMARY KEY (user_id, request_id) +); diff --git a/backend/internal/database/migrate.go b/backend/internal/database/migrate.go new file mode 100644 index 0000000..da651ee --- /dev/null +++ b/backend/internal/database/migrate.go @@ -0,0 +1,41 @@ +package database + +import ( + "context" + _ "embed" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +//go:embed 001_practice.sql +var practiceSQL string + +// Migrate applies versioned migrations atomically. A transaction-scoped lock keeps +// concurrent service starts from applying the same version twice. +func Migrate(ctx context.Context, pool *pgxpool.Pool) error { + tx, err := pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + if _, err = tx.Exec(ctx, "SELECT pg_advisory_xact_lock(684127930)"); err != nil { + return err + } + if _, err = tx.Exec(ctx, "CREATE TABLE IF NOT EXISTS schema_migrations (version integer PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())"); err != nil { + return err + } + var applied bool + if err = tx.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version=1)").Scan(&applied); err != nil { + return err + } + if !applied { + if _, err = tx.Exec(ctx, practiceSQL); err != nil { + return fmt.Errorf("migration 1: %w", err) + } + if _, err = tx.Exec(ctx, "INSERT INTO schema_migrations(version) VALUES (1)"); err != nil { + return err + } + } + return tx.Commit(ctx) +} diff --git a/backend/internal/httpapi/practice.go b/backend/internal/httpapi/practice.go new file mode 100644 index 0000000..267306c --- /dev/null +++ b/backend/internal/httpapi/practice.go @@ -0,0 +1,210 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "ballet-island/backend/internal/identity" + "ballet-island/backend/internal/practice" + "github.com/jackc/pgx/v5/pgxpool" +) + +// IdentityVerifier is the only external identity dependency. Implementations +// return an app-scoped verified identity and must never trust client user IDs. +type IdentityVerifier interface { + Exchange(context.Context, string) (string, error) +} + +type app struct { + store *practice.Store + verifier IdentityVerifier +} +type operation func(http.ResponseWriter, *http.Request, string) error + +// NewAppHandler adds authenticated business routes while retaining independent health checks. +func NewAppHandler(pool *pgxpool.Pool, verifier IdentityVerifier, now func() time.Time) http.Handler { + a := &app{store: practice.New(pool, now), verifier: verifier} + mux := http.NewServeMux() + mux.Handle("/", NewHandler(pool.Ping)) + mux.HandleFunc("POST /v1/session", a.login) + mux.HandleFunc("GET /v1/projects", a.auth(a.projects)) + mux.HandleFunc("POST /v1/projects", a.auth(a.saveProject)) + mux.HandleFunc("PUT /v1/projects/{id}", a.auth(a.saveProject)) + mux.HandleFunc("DELETE /v1/projects/{id}", a.auth(a.removeProject)) + mux.HandleFunc("GET /v1/records", a.auth(a.records)) + mux.HandleFunc("GET /v1/records/{id}", a.auth(a.record)) + mux.HandleFunc("POST /v1/records", a.auth(a.saveRecord)) + mux.HandleFunc("PUT /v1/records/{id}", a.auth(a.saveRecord)) + mux.HandleFunc("DELETE /v1/records/{id}", a.auth(a.deleteRecord)) + mux.HandleFunc("GET /v1/review", a.auth(a.review)) + return mux +} + +func respond(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} + +func decode(w http.ResponseWriter, r *http.Request, value any) error { + r.Body = http.MaxBytesReader(w, r.Body, 32*1024) + d := json.NewDecoder(r.Body) + d.DisallowUnknownFields() + if err := d.Decode(value); err != nil { + return practice.ErrInvalid + } + if err := d.Decode(new(any)); err != io.EOF { + return practice.ErrInvalid + } + return nil +} + +func failure(w http.ResponseWriter, err error) { + status, code, message := 503, "service_unavailable", "服务暂时不可用,请稍后重试" + switch { + case errors.Is(err, practice.ErrUnauthorized), errors.Is(err, identity.ErrInvalidCode): + status, code, message = 401, "unauthorized", practice.ErrUnauthorized.Error() + case errors.Is(err, practice.ErrInvalid): + status, code, message = 400, "invalid_input", practice.ErrInvalid.Error() + case errors.Is(err, practice.ErrUnavailable): + status, code, message = 404, "unavailable", practice.ErrUnavailable.Error() + case errors.Is(err, practice.ErrArchived): + status, code, message = 409, "archived_project", practice.ErrArchived.Error() + case errors.Is(err, practice.ErrConflict): + status, code, message = 409, "submission_conflict", practice.ErrConflict.Error() + case errors.Is(err, practice.ErrDeleted): + status, code, message = 410, "record_deleted", practice.ErrDeleted.Error() + default: + // Do not log raw database/network errors: they may contain notes, credentials or URLs. + slog.Error("business request failed", "category", code) + } + respond(w, status, map[string]any{"error": map[string]string{"code": code, "message": message}}) +} + +func (a *app) auth(fn operation) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + r = r.WithContext(ctx) + header := r.Header.Get("Authorization") + if !strings.HasPrefix(header, "Bearer ") { + failure(w, practice.ErrUnauthorized) + return + } + user, err := a.store.Authenticate(ctx, strings.TrimPrefix(header, "Bearer ")) + if err == nil { + err = fn(w, r, user) + } + if err != nil { + failure(w, err) + } + } +} + +func (a *app) login(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) + defer cancel() + var input struct { + Code string `json:"code"` + } + if err := decode(w, r, &input); err != nil || strings.TrimSpace(input.Code) == "" || len(input.Code) > 512 { + failure(w, practice.ErrInvalid) + return + } + identity, err := a.verifier.Exchange(ctx, input.Code) + if err != nil { + failure(w, err) + return + } + session, err := a.store.Login(ctx, identity) + if err != nil { + failure(w, err) + return + } + respond(w, 200, session) +} + +func (a *app) projects(w http.ResponseWriter, r *http.Request, user string) error { + projects, err := a.store.Projects(r.Context(), user, r.URL.Query().Get("includeArchived") == "true") + if err == nil { + respond(w, 200, map[string]any{"projects": projects, "today": a.store.Today()}) + } + return err +} +func (a *app) saveProject(w http.ResponseWriter, r *http.Request, user string) error { + var input struct { + Name string `json:"name"` + } + if err := decode(w, r, &input); err != nil { + return err + } + p, err := a.store.SaveProject(r.Context(), user, r.PathValue("id"), input.Name) + if err == nil { + respond(w, 200, p) + } + return err +} +func (a *app) removeProject(w http.ResponseWriter, r *http.Request, user string) error { + mode, err := a.store.RemoveProject(r.Context(), user, r.PathValue("id")) + if err == nil { + respond(w, 200, map[string]string{"action": mode}) + } + return err +} + +func (a *app) records(w http.ResponseWriter, r *http.Request, user string) error { + q := r.URL.Query() + limit := 20 + if q.Has("limit") { + var err error + limit, err = strconv.Atoi(q.Get("limit")) + if err != nil { + return practice.ErrInvalid + } + } + list, err := a.store.Records(r.Context(), user, q.Get("from"), q.Get("to"), q.Get("cursor"), limit) + if err == nil { + respond(w, 200, list) + } + return err +} +func (a *app) record(w http.ResponseWriter, r *http.Request, user string) error { + record, err := a.store.Record(r.Context(), user, r.PathValue("id")) + if err == nil { + respond(w, 200, record) + } + return err +} +func (a *app) saveRecord(w http.ResponseWriter, r *http.Request, user string) error { + var input practice.RecordInput + if err := decode(w, r, &input); err != nil { + return err + } + record, err := a.store.SaveRecord(r.Context(), user, r.PathValue("id"), input) + if err == nil { + respond(w, 200, record) + } + return err +} +func (a *app) deleteRecord(w http.ResponseWriter, r *http.Request, user string) error { + err := a.store.DeleteRecord(r.Context(), user, r.PathValue("id")) + if err == nil { + respond(w, 200, map[string]string{"action": "deleted"}) + } + return err +} +func (a *app) review(w http.ResponseWriter, r *http.Request, user string) error { + review, err := a.store.Review(r.Context(), user, r.URL.Query().Get("period"), r.URL.Query().Get("date")) + if err == nil { + respond(w, 200, review) + } + return err +} diff --git a/backend/internal/httpapi/practice_test.go b/backend/internal/httpapi/practice_test.go new file mode 100644 index 0000000..f14f326 --- /dev/null +++ b/backend/internal/httpapi/practice_test.go @@ -0,0 +1,397 @@ +package httpapi_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "ballet-island/backend/internal/database" + "ballet-island/backend/internal/httpapi" + "ballet-island/backend/internal/identity" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type testIdentity struct{} + +func (testIdentity) Exchange(_ context.Context, code string) (string, error) { + return "test-app:" + code, nil +} + +type harness struct { + t *testing.T + h http.Handler + now time.Time + pool *pgxpool.Pool +} + +func setup(t *testing.T) *harness { + t.Helper() + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + if os.Getenv("REQUIRE_TEST_DATABASE") == "1" { + t.Fatal("TEST_DATABASE_URL is required") + } + t.Skip("PostgreSQL integration: set TEST_DATABASE_URL or run scripts/test-integration.sh") + } + ctx := context.Background() + admin, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatal(err) + } + t.Cleanup(admin.Close) + schema := fmt.Sprintf("practice_test_%d", time.Now().UnixNano()) + quoted := pgx.Identifier{schema}.Sanitize() + if _, err = admin.Exec(ctx, "CREATE SCHEMA "+quoted); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = admin.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE") }) + config, err := pgxpool.ParseConfig(url) + if err != nil { + t.Fatal(err) + } + config.ConnConfig.RuntimeParams["search_path"] = schema + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + if err := database.Migrate(ctx, pool); err != nil { + t.Fatal(err) + } + // The same migration path is safe on every service restart. + if err := database.Migrate(ctx, pool); err != nil { + t.Fatal(err) + } + h := &harness{t: t, pool: pool, now: time.Date(2026, 9, 28, 2, 0, 0, 0, time.UTC)} + h.h = httpapi.NewAppHandler(pool, testIdentity{}, func() time.Time { return h.now }) + return h +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestWeChatExchangeAndSessionExpiry(t *testing.T) { + h := setup(t) + verifier := &identity.WeChat{AppID: "test-app", AppSecret: "server-only", Client: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Scheme != "https" || r.URL.Host != "api.weixin.qq.com" || r.URL.Query().Get("secret") != "server-only" || r.URL.Query().Get("grant_type") != "authorization_code" { + t.Fatal("invalid identity exchange") + } + body := `{"openid":"verified-alice","session_key":"never-return-this"}` + if r.URL.Query().Get("js_code") == "invalid" { + body = `{"errcode":40029,"errmsg":"invalid code"}` + } + if r.URL.Query().Get("js_code") == "upstream-down" { + body = `{"errcode":-1,"errmsg":"system error"}` + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body))}, nil + })}} + h.h = httpapi.NewAppHandler(h.pool, verifier, func() time.Time { return h.now }) + h.request("POST", "/v1/session", "", `{"code":"invalid"}`, 401) + h.request("POST", "/v1/session", "", `{"code":"upstream-down"}`, 503) + session := h.request("POST", "/v1/session", "", `{"code":"valid"}`, 200) + serialized, _ := json.Marshal(session) + if strings.Contains(string(serialized), "verified-alice") || strings.Contains(string(serialized), "never-return-this") { + t.Fatal("private identity details leaked") + } + token := session["token"].(string) + h.request("GET", "/v1/projects", token, "", 200) + h.now = h.now.Add(31 * 24 * time.Hour) + h.request("GET", "/v1/projects", token, "", 401) + h.request("GET", "/v1/projects", h.login("valid"), "", 200) +} + +func (h *harness) request(method, path, token, body string, want int) map[string]any { + h.t.Helper() + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + h.h.ServeHTTP(w, r) + if w.Code != want { + h.t.Fatalf("%s %s: got %d %s, want %d", method, path, w.Code, w.Body.String(), want) + } + result := map[string]any{} + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + h.t.Fatal(err) + } + return result +} + +func (h *harness) login(code string) string { + h.t.Helper() + return h.request("POST", "/v1/session", "", `{"code":"`+code+`"}`, 200)["token"].(string) +} + +func TestFirstLoginSeedsOnceAndSessionsRestoreIdentity(t *testing.T) { + h := setup(t) + token := h.login("alice") + projects := h.request("GET", "/v1/projects", token, "", 200)["projects"].([]any) + if len(projects) != 8 { + t.Fatalf("got %d projects", len(projects)) + } + first := projects[0].(map[string]any) + if first["name"] != "零基础" { + t.Fatal(first) + } + h.request("DELETE", "/v1/projects/"+first["id"].(string), token, "", 200) + projects = h.request("GET", "/v1/projects", h.login("alice"), "", 200)["projects"].([]any) + if len(projects) != 7 { + t.Fatal("login must not restore removed presets") + } + h.request("GET", "/v1/projects", "", "", 401) + h.request("GET", "/v1/projects", "invented-session", "", 401) +} + +func (h *harness) project(token, name string) string { + h.t.Helper() + body, _ := json.Marshal(map[string]string{"name": name}) + return h.request("POST", "/v1/projects", token, string(body), 200)["id"].(string) +} + +func recordBody(project, date string, minutes int, note, key string) string { + body, _ := json.Marshal(map[string]any{"projectId": project, "date": date, "minutes": minutes, "note": note, "requestId": key}) + return string(body) +} + +func (h *harness) summary(token string, minutes, days float64) map[string]any { + h.t.Helper() + review := h.request("GET", "/v1/review?period=week&date=2026-09-28", token, "", 200) + total := review["allTime"].(map[string]any) + if total["totalMinutes"] != minutes || total["practiceDays"] != days { + h.t.Fatalf("unexpected total: %v", total) + } + var projectMinutes float64 + for _, raw := range total["projects"].([]any) { + projectMinutes += raw.(map[string]any)["minutes"].(float64) + } + if projectMinutes != minutes { + h.t.Fatalf("project distribution: %v", total) + } + return review +} + +func TestRecordsRoundTripCorrectionsAndStatistics(t *testing.T) { + h := setup(t) + token := h.login("alice") + class := h.project(token, "晚课") + extra := h.project(token, "课后练习") + h.summary(token, 0, 0) + one := h.request("POST", "/v1/records", token, recordBody(class, "2026-09-28", 90, "老师反馈:放松肩颈", "lesson-000000001"), 200) + two := h.request("POST", "/v1/records", token, recordBody(extra, "2026-09-28", 15, "", "lesson-000000002"), 200) + h.summary(token, 105, 1) + list := h.request("GET", "/v1/records?from=2026-09-28&to=2026-09-28", token, "", 200)["records"].([]any) + if len(list) != 2 { + t.Fatal(list) + } + id1, id2 := one["id"].(string), two["id"].(string) + h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-27", 90, "改到周日", ""), 200) + review := h.summary(token, 105, 2) + if review["period"].(map[string]any)["totalMinutes"] != float64(15) { + t.Fatal("Sunday must belong to the previous week") + } + h.request("PUT", "/v1/records/"+id1, token, recordBody(class, "2026-09-28", 60, "", ""), 200) + h.summary(token, 75, 1) + h.request("DELETE", "/v1/records/"+id2, token, "", 200) + h.summary(token, 60, 1) + h.request("DELETE", "/v1/records/"+id1, token, "", 200) + h.request("DELETE", "/v1/records/"+id1, token, "", 200) + h.summary(token, 0, 0) +} + +func TestArchiveRenameAndOwnershipAcrossAccounts(t *testing.T) { + h := setup(t) + alice, bob := h.login("alice"), h.login("bob") + p := h.project(alice, "旧课名") + active := h.project(alice, "新练习") + r := h.request("POST", "/v1/records", alice, recordBody(p, "2026-09-20", 45, "私人笔记", "archive-000000001"), 200) + id := r["id"].(string) + h.request("GET", "/v1/records/"+id, bob, "", 404) + h.request("PUT", "/v1/records/"+id, bob, recordBody(p, "2026-09-20", 60, "偷改", ""), 404) + h.request("DELETE", "/v1/records/"+id, bob, "", 200) + h.request("PUT", "/v1/projects/"+p, bob, `{"name":"偷改"}`, 404) + h.request("DELETE", "/v1/projects/"+p, bob, "", 404) + h.request("POST", "/v1/records", bob, recordBody(p, "2026-09-20", 60, "", "cross-user-000001"), 404) + h.summary(bob, 0, 0) + h.request("PUT", "/v1/projects/"+p, alice, `{"name":"基础晚课"}`, 200) + removed := h.request("DELETE", "/v1/projects/"+p, alice, "", 200) + if removed["action"] != "archived" { + t.Fatal(removed) + } + record := h.request("GET", "/v1/records/"+id, alice, "", 200) + if record["projectName"] != "基础晚课" || record["note"] != "私人笔记" || record["archived"] != true { + t.Fatal(record) + } + for _, p0 := range h.request("GET", "/v1/projects", alice, "", 200)["projects"].([]any) { + if p0.(map[string]any)["id"] == p { + t.Fatal("archived project in active choices") + } + } + summary := h.summary(alice, 45, 1)["allTime"].(map[string]any) + if summary["projects"].([]any)[0].(map[string]any)["name"] != "基础晚课" { + t.Fatal(summary) + } + h.request("POST", "/v1/records", alice, recordBody(p, "2026-09-20", 30, "", "archive-000000002"), 409) + h.request("PUT", "/v1/records/"+id, alice, recordBody(p, "2026-09-21", 30, "仍保留归档项目", ""), 200) + h.request("PUT", "/v1/records/"+id, alice, recordBody(active, "2026-09-21", 30, "改入活跃项目", ""), 200) + h.request("PUT", "/v1/records/"+id, alice, recordBody(p, "2026-09-21", 30, "不能移回归档项目", ""), 409) + // A fresh handler models a second process/device reading the same persisted account. + h.h = httpapi.NewAppHandler(h.pool, testIdentity{}, func() time.Time { return h.now }) + h.summary(h.login("alice"), 30, 1) +} + +func TestSubmissionRetriesAreIdempotentAndCannotResurrectDeletedRecords(t *testing.T) { + h := setup(t) + token := h.login("alice") + p := h.project(token, "课程") + body := recordBody(p, "2026-09-28", 45, "", "retry-0000000001") + first := h.request("POST", "/v1/records", token, body, 200) + retry := h.request("POST", "/v1/records", h.login("alice"), body, 200) + if first["id"] != retry["id"] { + t.Fatal("response-loss retry duplicated the record") + } + h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 46, "", "retry-0000000001"), 409) + h.summary(token, 45, 1) + h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 45, "", "retry-0000000002"), 200) + h.summary(token, 90, 1) + h.request("DELETE", "/v1/records/"+first["id"].(string), token, "", 200) + h.request("POST", "/v1/records", token, body, 410) + h.summary(token, 45, 1) +} + +func TestInvalidFormsNeverPersistAndDateBoundariesUseShanghai(t *testing.T) { + h := setup(t) + h.now = time.Date(2026, 9, 27, 16, 1, 0, 0, time.UTC) // already Monday in Shanghai + token := h.login("alice") + p := h.project(token, "课程") + for i, entry := range []struct { + date string + minutes int + note string + }{ + {"2026-09-28", 0, ""}, {"2026-09-28", -1, ""}, {"2026-09-29", 30, ""}, + {"2026-02-30", 30, ""}, {"2026-9-1", 30, ""}, {"0000-01-01", 30, ""}, + {"2026-09-28", 30, strings.Repeat("字", 2001)}, {"2026-09-28", 30, "bad\x00note"}, + } { + h.request("POST", "/v1/records", token, recordBody(p, entry.date, entry.minutes, entry.note, fmt.Sprintf("invalid-%016d", i)), 400) + } + h.request("POST", "/v1/records", token, `{"projectId":"`+p+`","date":"2026-09-28","minutes":1.5,"requestId":"invalid-decimal1"}`, 400) + h.request("POST", "/v1/records", token, recordBody("", "2026-09-28", 30, "", "missing-00000001"), 400) + h.request("POST", "/v1/projects", token, `{"name":"  \n "}`, 400) + longName, _ := json.Marshal(map[string]string{"name": strings.Repeat("字", 41)}) + h.request("POST", "/v1/projects", token, string(longName), 400) + h.summary(token, 0, 0) + h.request("POST", "/v1/records", token, recordBody(p, "2026-08-31", 45, "", "boundary-0000001"), 200) + h.request("POST", "/v1/records", token, recordBody(p, "2026-09-01", 30, "", "boundary-0000002"), 200) + h.summary(token, 75, 2) + aug := h.request("GET", "/v1/review?period=month&date=2026-08-31", token, "", 200) + sep := h.request("GET", "/v1/review?period=month&date=2026-09-28", token, "", 200) + if aug["period"].(map[string]any)["totalMinutes"] != float64(45) || sep["period"].(map[string]any)["totalMinutes"] != float64(30) || len(sep["days"].([]any)) != 30 { + t.Fatal(aug, sep) + } + h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", 1, "", "boundary-0000003"), 200) +} + +func TestRecordPaginationReturnsEveryEntryOnce(t *testing.T) { + h := setup(t) + token := h.login("alice") + p := h.project(token, "课程") + for i := 0; i < 5; i++ { + h.request("POST", "/v1/records", token, recordBody(p, "2026-09-28", i+1, "", fmt.Sprintf("page-%016d", i)), 200) + } + latest := h.request("POST", "/v1/records", token, recordBody(p, "2026-08-01", 45, "补记也应出现在近期记录顶部", "page-backdated-1"), 200) + recent := h.request("GET", "/v1/records?limit=1", token, "", 200)["records"].([]any) + if recent[0].(map[string]any)["id"] != latest["id"] { + t.Fatal("a saved backdated entry must appear in recent records") + } + seen := map[string]bool{} + cursor := "" + for { + page := h.request("GET", "/v1/records?from=2026-09-28&to=2026-09-28&limit=2&cursor="+cursor, token, "", 200) + for _, raw := range page["records"].([]any) { + id := raw.(map[string]any)["id"].(string) + if seen[id] { + t.Fatal("duplicate entry") + } + seen[id] = true + } + cursor = page["nextCursor"].(string) + if cursor == "" { + break + } + } + if len(seen) != 5 { + t.Fatal(seen) + } +} + +func TestConcurrentRetriesAndProjectRemovalKeepHistory(t *testing.T) { + h := setup(t) + token := h.login("alice") + p := h.project(token, "课程") + body := recordBody(p, "2026-09-28", 90, "", "concurrent-00001") + responses := make(chan *httptest.ResponseRecorder, 8) + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Go(func() { + r := httptest.NewRequest("POST", "/v1/records", strings.NewReader(body)) + r.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + h.h.ServeHTTP(w, r) + responses <- w + }) + } + wg.Wait() + close(responses) + for w := range responses { + if w.Code != 200 { + t.Fatalf("concurrent save: %d %s", w.Code, w.Body.String()) + } + } + h.summary(token, 90, 1) + // Removing a project and creating a record race for the same user. Whichever + // arrives first, the original history survives and totals remain exact. + var create, remove *httptest.ResponseRecorder + wg.Go(func() { + r := httptest.NewRequest("POST", "/v1/records", strings.NewReader(recordBody(p, "2026-09-28", 15, "", "concurrent-00002"))) + r.Header.Set("Authorization", "Bearer "+token) + create = httptest.NewRecorder() + h.h.ServeHTTP(create, r) + }) + wg.Go(func() { + r := httptest.NewRequest("DELETE", "/v1/projects/"+p, nil) + r.Header.Set("Authorization", "Bearer "+token) + remove = httptest.NewRecorder() + h.h.ServeHTTP(remove, r) + }) + wg.Wait() + if remove.Code != 200 || (create.Code != 200 && create.Code != 409) { + t.Fatal(remove.Code, create.Code) + } + want := float64(90) + if create.Code == 200 { + want = 105 + } + h.summary(token, want, 1) +} + +func TestDatabaseFailureIsNotAnEmptyHistory(t *testing.T) { + h := setup(t) + token := h.login("alice") + h.pool.Close() + h.request("GET", "/v1/records", token, "", 503) + h.request("GET", "/v1/review?period=week", token, "", 503) + h.request("GET", "/healthz", "", "", 200) + h.request("GET", "/readyz", "", "", 503) +} diff --git a/backend/internal/identity/wechat.go b/backend/internal/identity/wechat.go new file mode 100644 index 0000000..d73f916 --- /dev/null +++ b/backend/internal/identity/wechat.go @@ -0,0 +1,61 @@ +package identity + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "time" +) + +var ErrInvalidCode = errors.New("WeChat login code is invalid or expired") +var errUnavailable = errors.New("WeChat identity verification is unavailable") + +// WeChat exchanges wx.login codes at the server only. Client is an optional +// network dependency for tests; production uses a bounded HTTPS client. +type WeChat struct { + AppID string + AppSecret string + Client *http.Client +} + +// Exchange returns an app-scoped openid after WeChat verifies the code. It never +// returns session_key or wraps URL errors, since exchange URLs contain secrets. +func (w *WeChat) Exchange(ctx context.Context, code string) (string, error) { + if w.AppID == "" || w.AppSecret == "" { + return "", errUnavailable + } + query := url.Values{"appid": {w.AppID}, "secret": {w.AppSecret}, "js_code": {code}, "grant_type": {"authorization_code"}} + request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com/sns/jscode2session?"+query.Encode(), nil) + if err != nil { + return "", errUnavailable + } + client := w.Client + if client == nil { + client = &http.Client{Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + } + response, err := client.Do(request) + if err != nil { + return "", errUnavailable + } + defer response.Body.Close() + if response.StatusCode != 200 { + return "", errUnavailable + } + var result struct { + OpenID string `json:"openid"` + ErrCode int `json:"errcode"` + } + if err = json.NewDecoder(io.LimitReader(response.Body, 16*1024)).Decode(&result); err != nil { + return "", errUnavailable + } + if result.ErrCode == 40029 || result.ErrCode == 40163 { + return "", ErrInvalidCode + } + if result.ErrCode != 0 || result.OpenID == "" { + return "", errUnavailable + } + return w.AppID + ":" + result.OpenID, nil +} diff --git a/backend/internal/practice/records.go b/backend/internal/practice/records.go new file mode 100644 index 0000000..6e760a3 --- /dev/null +++ b/backend/internal/practice/records.go @@ -0,0 +1,199 @@ +package practice + +import ( + "context" + "encoding/json" + "errors" + "regexp" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5" +) + +type RecordInput struct { + ProjectID string `json:"projectId"` + Date string `json:"date"` + Minutes int `json:"minutes"` + Note string `json:"note"` + RequestID string `json:"requestId"` +} + +type Record struct { + updatedAt time.Time + ID string `json:"id"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + Archived bool `json:"archived"` + Date string `json:"date"` + Minutes int `json:"minutes"` + Note string `json:"note"` +} + +type RecordList struct { + Records []Record `json:"records"` + NextCursor string `json:"nextCursor"` +} + +var requestIDPattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{16,128}$`) +var objectIDPattern = regexp.MustCompile(`^[A-Z2-7]{26}$`) + +func validDate(date string) bool { + t, err := time.Parse(time.DateOnly, date) + return err == nil && t.Year() >= 1 && t.Format(time.DateOnly) == date +} + +const recordSelect = `SELECT r.id,r.project_id,p.name,p.archived,r.practice_date::text,r.minutes,r.note,r.updated_at + FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id` + +func scanRecord(row pgx.Row) (Record, error) { + var r Record + err := row.Scan(&r.ID, &r.ProjectID, &r.ProjectName, &r.Archived, &r.Date, &r.Minutes, &r.Note, &r.updatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return r, ErrUnavailable + } + return r, err +} + +func (s *Store) Record(ctx context.Context, user, id string) (Record, error) { + return scanRecord(s.db.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id)) +} + +// Records bounds every response. The updated-at/id cursor permits complete day lists +// without silently truncating them or loading an account's entire history. +// Recent entries include just-saved corrections and backdated practices first. +func (s *Store) Records(ctx context.Context, user, from, to, cursor string, limit int) (RecordList, error) { + if limit < 1 || limit > 100 { + return RecordList{}, ErrInvalid + } + if from == "" { + from = "0001-01-01" + } + if to == "" { + to = s.Today() + } + if !validDate(from) || !validDate(to) || from > to { + return RecordList{}, ErrInvalid + } + cursorTime, cursorID := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC), "~" + if cursor != "" { + parts := strings.Split(cursor, "~") + if len(parts) != 2 || !objectIDPattern.MatchString(parts[1]) { + return RecordList{}, ErrInvalid + } + var err error + cursorTime, err = time.Parse(time.RFC3339Nano, parts[0]) + if err != nil { + return RecordList{}, ErrInvalid + } + cursorID = parts[1] + } + rows, err := s.db.Query(ctx, recordSelect+` WHERE r.user_id=$1 AND r.practice_date BETWEEN $2::date AND $3::date + AND (r.updated_at,r.id)<($4,$5) ORDER BY r.updated_at DESC,r.id DESC LIMIT $6`, user, from, to, cursorTime, cursorID, limit+1) + if err != nil { + return RecordList{}, err + } + defer rows.Close() + result := RecordList{Records: []Record{}} + for rows.Next() { + r, err := scanRecord(rows) + if err != nil { + return RecordList{}, err + } + result.Records = append(result.Records, r) + } + if err := rows.Err(); err != nil { + return RecordList{}, err + } + if len(result.Records) > limit { + result.Records = result.Records[:limit] + last := result.Records[limit-1] + result.NextCursor = last.updatedAt.UTC().Format(time.RFC3339Nano) + "~" + last.ID + } + return result, nil +} + +// SaveRecord commits the record and submission receipt together. Retrying an +// uncertain create keeps its ID; a different payload using the same key is rejected. +func (s *Store) SaveRecord(ctx context.Context, user, id string, input RecordInput) (Record, error) { + if !validDate(input.Date) || input.Date > s.Today() || input.Minutes < 1 || input.Minutes > MinuteLimit || + input.ProjectID == "" || utf8.RuneCountInString(input.Note) > NoteLimit || strings.ContainsRune(input.Note, 0) { + return Record{}, ErrInvalid + } + if id == "" && !requestIDPattern.MatchString(input.RequestID) { + return Record{}, ErrInvalid + } + var result Record + err := s.write(ctx, user, func(tx pgx.Tx) error { + payload := input + payload.RequestID = "" + encoded, _ := json.Marshal(payload) + hash := digest(string(encoded)) + if id == "" { + var savedHash, savedID string + err := tx.QueryRow(ctx, "SELECT payload_hash,record_id FROM submissions WHERE user_id=$1 AND request_id=$2", user, input.RequestID).Scan(&savedHash, &savedID) + if err == nil { + if savedHash != hash { + return ErrConflict + } + result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, savedID)) + if errors.Is(err, ErrUnavailable) { + return ErrDeleted + } + return err + } + if !errors.Is(err, pgx.ErrNoRows) { + return err + } + } + originalProject := "" + if id != "" { + err := tx.QueryRow(ctx, "SELECT project_id FROM records WHERE user_id=$1 AND id=$2", user, id).Scan(&originalProject) + if errors.Is(err, pgx.ErrNoRows) { + return ErrUnavailable + } + if err != nil { + return err + } + } + var archived bool + err := tx.QueryRow(ctx, "SELECT archived FROM projects WHERE user_id=$1 AND id=$2", user, input.ProjectID).Scan(&archived) + if errors.Is(err, pgx.ErrNoRows) { + return ErrUnavailable + } + if err != nil { + return err + } + if archived && originalProject != input.ProjectID { + return ErrArchived + } + if id == "" { + id = newID() + if _, err = tx.Exec(ctx, `INSERT INTO records(id,user_id,project_id,practice_date,minutes,note) + VALUES($1,$2,$3,$4::date,$5,$6)`, id, user, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil { + return err + } + if _, err = tx.Exec(ctx, "INSERT INTO submissions(user_id,request_id,payload_hash,record_id) VALUES($1,$2,$3,$4)", user, input.RequestID, hash, id); err != nil { + return err + } + } else { + if _, err = tx.Exec(ctx, `UPDATE records SET project_id=$3,practice_date=$4::date,minutes=$5,note=$6,updated_at=now() + WHERE user_id=$1 AND id=$2`, user, id, input.ProjectID, input.Date, input.Minutes, input.Note); err != nil { + return err + } + } + result, err = scanRecord(tx.QueryRow(ctx, recordSelect+" WHERE r.user_id=$1 AND r.id=$2", user, id)) + return err + }) + return result, err +} + +// DeleteRecord is scoped to the session owner and safe to retry, including after +// the last record of a date has gone. Submission receipts deliberately remain. +func (s *Store) DeleteRecord(ctx context.Context, user, id string) error { + return s.write(ctx, user, func(tx pgx.Tx) error { + _, err := tx.Exec(ctx, "DELETE FROM records WHERE user_id=$1 AND id=$2", user, id) + return err + }) +} diff --git a/backend/internal/practice/review.go b/backend/internal/practice/review.go new file mode 100644 index 0000000..5a381c0 --- /dev/null +++ b/backend/internal/practice/review.go @@ -0,0 +1,122 @@ +package practice + +import ( + "context" + "sort" + "time" +) + +type ProjectMinutes struct { + ProjectID string `json:"projectId"` + Name string `json:"name"` + Archived bool `json:"archived"` + Minutes int64 `json:"minutes"` +} +type Summary struct { + TotalMinutes int64 `json:"totalMinutes"` + PracticeDays int `json:"practiceDays"` + RecordCount int64 `json:"recordCount"` + Projects []ProjectMinutes `json:"projects"` +} +type DayMinutes struct { + Date string `json:"date"` + Minutes int64 `json:"minutes"` +} +type Review struct { + Today string `json:"today"` + From string `json:"from"` + To string `json:"to"` + AllTime Summary `json:"allTime"` + Period Summary `json:"period"` + Days []DayMinutes `json:"days"` +} +type summaryBuilder struct { + summary Summary + dates map[string]bool + projects map[string]ProjectMinutes +} + +func newSummary() *summaryBuilder { + return &summaryBuilder{dates: map[string]bool{}, projects: map[string]ProjectMinutes{}} +} +func (b *summaryBuilder) add(date string, project ProjectMinutes, count int64) { + b.summary.TotalMinutes += project.Minutes + b.summary.RecordCount += count + b.dates[date] = true + previous := b.projects[project.ProjectID] + project.Minutes += previous.Minutes + b.projects[project.ProjectID] = project +} +func (b *summaryBuilder) result() Summary { + b.summary.PracticeDays = len(b.dates) + b.summary.Projects = []ProjectMinutes{} + for _, p := range b.projects { + b.summary.Projects = append(b.summary.Projects, p) + } + sort.Slice(b.summary.Projects, func(i, j int) bool { + a, c := b.summary.Projects[i], b.summary.Projects[j] + if a.Minutes == c.Minutes { + return a.ProjectID < c.ProjectID + } + return a.Minutes > c.Minutes + }) + return b.summary +} + +// Review derives totals, daily minutes and project shares from one SQL snapshot. +// Archived projects participate and always use their current name. Monday starts a week. +func (s *Store) Review(ctx context.Context, user, period, date string) (Review, error) { + if date == "" { + date = s.Today() + } + if !validDate(date) || date > s.Today() { + return Review{}, ErrInvalid + } + anchor, _ := time.Parse(time.DateOnly, date) + var start, end time.Time + switch period { + case "week": + start = anchor.AddDate(0, 0, -(int(anchor.Weekday())+6)%7) + end = start.AddDate(0, 0, 6) + case "month": + start = time.Date(anchor.Year(), anchor.Month(), 1, 0, 0, 0, 0, time.UTC) + end = start.AddDate(0, 1, -1) + default: + return Review{}, ErrInvalid + } + result := Review{Today: s.Today(), From: start.Format(time.DateOnly), To: end.Format(time.DateOnly), Days: []DayMinutes{}} + // A single grouped query keeps all-time and selected-period summaries consistent + // during concurrent edits, without storing a second set of cumulative balances. + rows, err := s.db.Query(ctx, `SELECT r.practice_date::text,p.id,p.name,p.archived,sum(r.minutes)::bigint,count(*) + FROM records r JOIN projects p ON p.user_id=r.user_id AND p.id=r.project_id + WHERE r.user_id=$1 GROUP BY r.practice_date,p.id,p.name,p.archived`, user) + if err != nil { + return Review{}, err + } + defer rows.Close() + all, selected := newSummary(), newSummary() + daily := map[string]int64{} + for rows.Next() { + var date string + var p ProjectMinutes + var count int64 + if err := rows.Scan(&date, &p.ProjectID, &p.Name, &p.Archived, &p.Minutes, &count); err != nil { + return Review{}, err + } + all.add(date, p, count) + if date >= result.From && date <= result.To { + selected.add(date, p, count) + daily[date] += p.Minutes + } + } + if err := rows.Err(); err != nil { + return Review{}, err + } + result.AllTime = all.result() + result.Period = selected.result() + for d := start; !d.After(end); d = d.AddDate(0, 0, 1) { + date := d.Format(time.DateOnly) + result.Days = append(result.Days, DayMinutes{Date: date, Minutes: daily[date]}) + } + return result, nil +} diff --git a/backend/internal/practice/store.go b/backend/internal/practice/store.go new file mode 100644 index 0000000..5595b40 --- /dev/null +++ b/backend/internal/practice/store.go @@ -0,0 +1,201 @@ +package practice + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "strings" + "time" + "unicode/utf8" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const NameLimit = 40 +const NoteLimit = 2000 +const MinuteLimit = 2147483647 // PostgreSQL integer storage limit; minutes remain exact. + +var ( + ErrInvalid = errors.New("输入无效,请检查后重试") + ErrUnavailable = errors.New("项目或记录不可用,请刷新后重试") + ErrUnauthorized = errors.New("登录已失效,请重新登录") + ErrArchived = errors.New("该项目已移出常用列表,请选择其他项目") + ErrConflict = errors.New("这次提交已保存了不同内容,请刷新记录后更正") + ErrDeleted = errors.New("这次提交的记录已被删除,不会重复创建") +) + +var presets = []string{"零基础", "基础提升", "软开素质", "足髋训练", "核心臀腿", "小球核心", "天鹅臂颈", "呼吸训练"} + +type Store struct { + db *pgxpool.Pool + now func() time.Time +} + +// New keeps all ownership, dates, archival and persistence rules behind business operations. +// now is injectable so business-day boundaries can be verified without changing the host clock. +func New(db *pgxpool.Pool, now func() time.Time) *Store { + if now == nil { + now = time.Now + } + return &Store{db: db, now: now} +} + +func newID() string { return rand.Text() } +func digest(s string) string { sum := sha256.Sum256([]byte(s)); return hex.EncodeToString(sum[:]) } + +// Today is a calendar date in the fixed first-release business timezone, not the device timezone. +func (s *Store) Today() string { + return s.now().In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format(time.DateOnly) +} + +type Session struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expiresAt"` +} + +// Login accepts only a server-verified, app-scoped WeChat identity. User creation, +// presets and the session commit together; deleted presets never reappear on login. +func (s *Store) Login(ctx context.Context, identity string) (Session, error) { + if identity == "" { + return Session{}, ErrUnauthorized + } + tx, err := s.db.Begin(ctx) + if err != nil { + return Session{}, err + } + defer tx.Rollback(ctx) + id := newID() + result, err := tx.Exec(ctx, "INSERT INTO users(id,wechat_identity) VALUES($1,$2) ON CONFLICT(wechat_identity) DO NOTHING", id, identity) + if err != nil { + return Session{}, err + } + if result.RowsAffected() == 1 { + for _, name := range presets { + if _, err = tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", newID(), id, name); err != nil { + return Session{}, err + } + } + } else if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE wechat_identity=$1", identity).Scan(&id); err != nil { + return Session{}, err + } + // Serialize session maintenance for this user as well as business writes. + if _, err = tx.Exec(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", id); err != nil { + return Session{}, err + } + if _, err = tx.Exec(ctx, "DELETE FROM sessions WHERE user_id=$1 AND expires_at <= $2", id, s.now()); err != nil { + return Session{}, err + } + session := Session{Token: newID() + newID(), ExpiresAt: s.now().Add(30 * 24 * time.Hour)} + if _, err = tx.Exec(ctx, "INSERT INTO sessions(token_hash,user_id,expires_at) VALUES($1,$2,$3)", digest(session.Token), id, session.ExpiresAt); err != nil { + return Session{}, err + } + if err = tx.Commit(ctx); err != nil { + return Session{}, err + } + return session, nil +} + +// Authenticate never accepts a client-provided user ID. Only opaque, unexpired +// bearer tokens are usable; their hashes, not tokens, are kept in PostgreSQL. +func (s *Store) Authenticate(ctx context.Context, token string) (string, error) { + if len(token) != 52 { + return "", ErrUnauthorized + } + var user string + err := s.db.QueryRow(ctx, "SELECT user_id FROM sessions WHERE token_hash=$1 AND expires_at>$2", digest(token), s.now()).Scan(&user) + if errors.Is(err, pgx.ErrNoRows) { + return "", ErrUnauthorized + } + return user, err +} + +// write serializes a user's mutations. Project removal, record writes and +// idempotency receipts therefore see a consistent state even across devices. +func (s *Store) write(ctx context.Context, user string, fn func(pgx.Tx) error) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + var id string + if err = tx.QueryRow(ctx, "SELECT id FROM users WHERE id=$1 FOR UPDATE", user).Scan(&id); err != nil { + return err + } + if err = fn(tx); err != nil { + return err + } + return tx.Commit(ctx) +} + +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Archived bool `json:"archived"` + HasRecords bool `json:"hasRecords"` +} + +func (s *Store) Projects(ctx context.Context, user string, archived bool) ([]Project, error) { + rows, err := s.db.Query(ctx, `SELECT p.id,p.name,p.archived,EXISTS(SELECT 1 FROM records r WHERE r.user_id=p.user_id AND r.project_id=p.id) + FROM projects p WHERE p.user_id=$1 AND ($2 OR NOT p.archived) ORDER BY p.position`, user, archived) + if err != nil { + return nil, err + } + defer rows.Close() + projects := []Project{} + for rows.Next() { + var p Project + if err := rows.Scan(&p.ID, &p.Name, &p.Archived, &p.HasRecords); err != nil { + return nil, err + } + projects = append(projects, p) + } + return projects, rows.Err() +} + +func (s *Store) SaveProject(ctx context.Context, user, id, name string) (Project, error) { + name = strings.TrimSpace(name) + if name == "" || utf8.RuneCountInString(name) > NameLimit || strings.ContainsRune(name, 0) { + return Project{}, ErrInvalid + } + p := Project{ID: id, Name: name} + err := s.write(ctx, user, func(tx pgx.Tx) error { + if id == "" { + p.ID = newID() + _, err := tx.Exec(ctx, "INSERT INTO projects(id,user_id,name) VALUES($1,$2,$3)", p.ID, user, name) + return err + } + err := tx.QueryRow(ctx, `UPDATE projects SET name=$3 WHERE user_id=$1 AND id=$2 RETURNING archived, + EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2)`, user, id, name).Scan(&p.Archived, &p.HasRecords) + if errors.Is(err, pgx.ErrNoRows) { + return ErrUnavailable + } + return err + }) + return p, err +} + +func (s *Store) RemoveProject(ctx context.Context, user, id string) (string, error) { + mode := "deleted" + err := s.write(ctx, user, func(tx pgx.Tx) error { + var used bool + err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM records WHERE user_id=$1 AND project_id=$2) + FROM projects WHERE user_id=$1 AND id=$2`, user, id).Scan(&used) + if errors.Is(err, pgx.ErrNoRows) { + return ErrUnavailable + } + if err != nil { + return err + } + if used { + mode = "archived" + _, err = tx.Exec(ctx, "UPDATE projects SET archived=true WHERE user_id=$1 AND id=$2", user, id) + } else { + _, err = tx.Exec(ctx, "DELETE FROM projects WHERE user_id=$1 AND id=$2", user, id) + } + return err + }) + return mode, err +} diff --git a/backend/scripts/test-integration.sh b/backend/scripts/test-integration.sh new file mode 100755 index 0000000..b5df3f0 --- /dev/null +++ b/backend/scripts/test-integration.sh @@ -0,0 +1,27 @@ +#!/bin/sh +set -eu + +backend_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$backend_dir" + +# A supplied test database must permit isolated CREATE SCHEMA. Tests never use +# PG* application settings or touch existing tables; every test owns its schema. +if [ -n "${TEST_DATABASE_URL:-}" ]; then + REQUIRE_TEST_DATABASE=1 go test -race ./... + exit +fi + +# Disposable memory-backed database, loopback only, no existing Compose volumes. +test_container=$(docker run --detach --rm --publish 127.0.0.1::5432 \ + --env POSTGRES_USER=ballet_test --env POSTGRES_PASSWORD=local-isolated-test \ + --env POSTGRES_DB=ballet_test --tmpfs /var/lib/postgresql postgres:18-alpine) +trap 'docker stop "$test_container" >/dev/null' EXIT HUP INT TERM +attempt=0 +until docker exec "$test_container" pg_isready -U ballet_test -d ballet_test >/dev/null 2>&1; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge 30 ]; then printf '%s\n' 'Test PostgreSQL did not become ready.' >&2; exit 1; fi + sleep 1 +done +test_port=$(docker port "$test_container" 5432 | sed 's/.*://') +TEST_DATABASE_URL="postgres://ballet_test:local-isolated-test@127.0.0.1:$test_port/ballet_test?sslmode=disable" \ + REQUIRE_TEST_DATABASE=1 go test -race ./... diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..79961ca --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,31 @@ +# Domain Docs + +本仓库采用 single-context 布局,前端和后端共享根目录的领域文档。 + +## 探索前读取 + +- 根目录的 `CONTEXT.md`:领域术语、概念和关系。 +- 根目录的 `docs/adr/`:仅阅读与当前工作相关的架构决策。 + +文件不存在时直接继续,不把缺失视为阻塞,也不主动建议预先创建。 +后续由 `domain-modeling` 在术语或决策得到验证、且用户明确批准后 +按需创建。 + +## 文件布局 + +- `CONTEXT.md`:仓库共享的领域上下文。 +- `docs/adr/NNNN-.md`:按编号保存架构决策。 +- `miniprogram/` 和 `backend/` 继续按各自的项目约定开发。 + +## 术语一致性 + +事项标题、重构建议、假设和测试名称中的领域概念, +使用 `CONTEXT.md` 已定义的术语,遵循其明确规定的用词边界。 + +所需概念尚未定义时,先判断是否误用了项目之外的术语; +确有缺口时,将其列为后续领域建模的候选。 + +## 决策冲突 + +输出与现有 ADR 冲突时,明确指出对应 ADR、冲突内容及重新讨论的理由, +不静默覆盖已有决策。 diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..adbdecc --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,40 @@ +# Issue tracker: Local Markdown + +本仓库的事项和规格以 Markdown 文件保存在 `.scratch/`。 + +## 文件约定 + +- 每个功能一个目录:`.scratch//`。 +- 规格文件:`.scratch//spec.md`。 +- 实现事项:`.scratch//issues/-.md`, + 从 `01` 开始编号,每个事项一个文件。 +- 分流状态写在事项文件顶部附近的 `Status:` 行; + 使用 `triage-labels.md` 中的标签字符串。 +- 评论和讨论追加到文件底部的 `## Comments` 下。 + +## 发布和读取 + +技能要求“发布到 issue tracker”时,在对应功能目录中创建文件, +按需创建目录。 + +技能要求“获取相关 ticket”时,读取指定路径的事项文件。 +仅提供编号时,在对应功能目录中定位。 + +## Wayfinding + +供 `wayfinder` 使用,一个地图文件对应多个子事项文件。 + +- 地图:`.scratch//map.md`,包含 Notes、 + Decisions-so-far 和 Fog。 +- 子事项:`.scratch//issues/-.md`, + 从 `01` 编号,正文记录待解决的问题。 +- 类型:顶部的 `Type:` 行使用 research、prototype、grilling 或 task。 +- 此流程的工作状态:`Status: open`、`Status: claimed`、 + `Status: resolved`。 +- 依赖:顶部用 `Blocked by: NN, NN` 记录前置事项; + 所有前置事项均为 resolved 时解除阻塞。 +- 选取:扫描当前 effort 的 issues 目录,按编号选择第一个 + open 且未被阻塞的事项。 +- 领取:开始工作前写入 `Status: claimed` 并保存。 +- 解决:在 `## Answer` 下追加结论,写入 `Status: resolved`, + 再向地图的 Decisions-so-far 追加结论摘要及事项链接。 diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 0000000..8710291 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +技能中的五种标准分流角色映射如下。 +本地 Markdown 事项的 `Status:` 行使用“本仓库标签”列。 + +| 标准角色 | 本仓库标签 | 含义 | +| --- | --- | --- | +| needs-triage | needs-triage | 等待维护者评估 | +| needs-info | needs-info | 等待补充信息 | +| ready-for-agent | ready-for-agent | 需求明确,可交由代理实现 | +| ready-for-human | ready-for-human | 需要人工实现 | +| wontfix | wontfix | 不予处理 | + +技能提及某个分流角色时,使用对应的本仓库标签。 +后续调整标签名称时,修改本仓库标签列。 diff --git a/miniprogram/package.json b/miniprogram/package.json index 207c245..5886afe 100644 --- a/miniprogram/package.json +++ b/miniprogram/package.json @@ -10,7 +10,8 @@ "scripts": { "dev": "taro build --type weapp --watch", "build": "taro build --type weapp", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:client": "node --test tests/practice.test.cjs" }, "dependencies": { "@babel/runtime": "^7.28.4", diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json index 04a456f..a9a84f8 100644 --- a/miniprogram/project.config.json +++ b/miniprogram/project.config.json @@ -8,6 +8,29 @@ "es6": false, "minified": true, "postcss": false, - "urlCheck": true - } -} + "urlCheck": true, + "compileWorklet": false, + "uglifyFileName": false, + "uploadWithSourceMap": true, + "enhance": false, + "packNpmManually": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "minifyWXML": true, + "localPlugins": false, + "disableUseStrict": false, + "useCompilerPlugins": false, + "condition": false, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + } + }, + "simulatorPluginLibVersion": {}, + "packOptions": { + "ignore": [], + "include": [] + }, + "editorSetting": {} +} \ No newline at end of file diff --git a/miniprogram/src/app.config.ts b/miniprogram/src/app.config.ts index aafc414..37591ec 100644 --- a/miniprogram/src/app.config.ts +++ b/miniprogram/src/app.config.ts @@ -1,10 +1,17 @@ export default defineAppConfig({ - pages: ['pages/index/index'], + pages: ['pages/index/index', 'pages/calendar/index', 'pages/growth/index', 'pages/record/index', 'pages/projects/index'], + tabBar: { + color: '#817573', selectedColor: '#9c5268', backgroundColor: '#fffdfa', borderStyle: 'white', + list: [ + { pagePath: 'pages/index/index', text: '记录' }, + { pagePath: 'pages/calendar/index', text: '日历' }, + { pagePath: 'pages/growth/index', text: '成长' }, + ], + }, window: { - navigationBarTitleText: 'Ballet Island', - navigationBarBackgroundColor: '#ffffff', + navigationBarTitleText: '芭蕾岛', + navigationBarBackgroundColor: '#faf7f3', navigationBarTextStyle: 'black', - backgroundColor: '#f7f8fa', + backgroundColor: '#faf7f3', }, }) - diff --git a/miniprogram/src/app.scss b/miniprogram/src/app.scss index f963dba..b4d3e75 100644 --- a/miniprogram/src/app.scss +++ b/miniprogram/src/app.scss @@ -1,7 +1,105 @@ page { - background: #f7f8fa; - color: #323233; + background: #faf7f3; + color: #3e3435; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif; - font-size: 16px; + font-size: 15px; + line-height: 1.55; + --primary-color: #9c5268; + --button-primary-background-color: #9c5268; + --button-primary-border-color: #9c5268; + --button-border-radius: 12px; + --button-default-color: #655656; + --button-default-border-color: #e5dcd6; + --button-default-background-color: #fffdfa; + // Match this project's 375px design width; Taroify defaults use 750px dimensions. + --button-border-width: 1px; + --button-height-small: 32px; + --button-padding-small: 0 8px; + --button-font-size-small: 13px; + --button-height-medium: 44px; + --button-padding-medium: 0 16px; + --button-font-size-medium: 16px; + --button-loading-icon-size: 20px; + --button-content-gap: 4px; } - +view, text, input, textarea { box-sizing: border-box; } +.page { padding: 20px 18px 32px; padding-bottom: calc(32px + env(safe-area-inset-bottom)); } +.page-heading { margin: 4px 2px 24px; } +.eyebrow { display: block; color: #946d76; font-size: 10px; letter-spacing: 2px; margin-bottom: 8px; } +.page-title { display: block; margin-bottom: 8px; font-size: 26px; font-weight: 600; line-height: 1.35; letter-spacing: -0.5px; } +.muted { color: #827572; font-size: 13px; } +.small { font-size: 12px; } +.block { display: block; } +.panel { padding: 18px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 18px; } +.hero-card { padding: 23px 22px; border: 1px solid #eddfda; background: #f1e7e3; border-radius: 21px; } +.hero-number { display: block; color: #864b5e; font-size: 29px; font-weight: 600; line-height: 1.5; margin: 4px 0 8px; overflow-wrap: anywhere; } +.section-heading, .row { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.section-heading { margin: 24px 2px 12px; } +.section-title { font-size: 17px; font-weight: 600; } +.project-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.project-tile { border: 1px solid #e7ded8; border-radius: 17px; min-height: 126px; padding: 15px 16px; background: #f4ece8; } +.project-tile--1 { background: #eef0e7; border-color: #e0e5d9; } +.project-tile--2 { background: #eeeaf2; border-color: #e3dfea; } +.project-tile--3 { background: #f4efdf; border-color: #eae4d5; } +.project-mark { display: block; color: #9a8583; font-size: 10px; letter-spacing: 1px; margin-bottom: 10px; } +.project-title { display: block; font-size: 17px; font-weight: 600; overflow-wrap: anywhere; } +.project-hint { display: block; font-size: 11px; color: #8b7476; margin-top: 8px; } +.record-card { padding: 16px 18px; background: #fffdfa; border: 1px solid #ece3dd; border-radius: 16px; margin-bottom: 10px; } +.record-name { font-size: 15px; font-weight: 500; overflow-wrap: anywhere; flex: 1; min-width: 0; } +.record-duration { color: #9c5268; font-size: 13px; text-align: right; max-width: 45%; } +.record-note { display: block; white-space: pre-wrap; overflow-wrap: anywhere; padding-top: 12px; border-top: 1px solid #f0e9e3; font-size: 13px; color: #756364; } +.empty { text-align: center; padding: 20px 4px; } +.empty-title { display: block; font-size: 16px; font-weight: 500; margin-bottom: 10px; } +.empty .muted { display: block; line-height: 1.8; } +.status-panel { display: flex; flex-direction: column; gap: 16px; align-items: center; margin-bottom: 16px; } +.error-text { color: #a43b4b; } +.error-banner, .success-banner { display: block; border-radius: 12px; padding: 13px 15px; margin: 14px 0; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; } +.error-banner { color: #9d3748; background: #fae9e9; } +.success-banner { color: #4f6b4c; background: #eaf0e7; } +.form-panel .field-label:first-child { margin-top: 0; } +.field-label { display: block; font-size: 14px; font-weight: 500; margin: 22px 0 10px; } +.field { display: block; width: 100%; background: #f8f4ef; border: 1px solid #e8dfd8; border-radius: 11px; padding: 12px 14px; height: 48px; font-size: 16px; color: #44383b; } +.picker-field { display: flex; justify-content: space-between; height: auto; min-height: 48px; overflow-wrap: anywhere; gap: 8px; } +.quick-minutes { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } +.note-input { min-height: 140px; height: 140px; line-height: 1.6; } +.field-counter { display: block; text-align: right; color: #968983; font-size: 11px; margin-top: 6px; } +.field-help { display: block; color: #877872; font-size: 12px; margin: 10px 0 14px; line-height: 1.7; } +.form-actions { margin-top: 22px; } +.project-name-input { margin-top: 16px; } +.page-footnote { display: block; text-align: center; color: #998a82; font-size: 11px; line-height: 1.8; margin: 24px 12px 8px; } +.managed-project { padding: 12px 0; border-bottom: 1px solid #f0e8e1; } +.managed-project:first-child { padding-top: 0; } +.managed-project:last-child { padding-bottom: 0; border-bottom: 0; } +.managed-project > .record-name { display: block; } +.managed-project > .row { align-items: flex-end; } +.inline-actions { display: flex; flex-shrink: 0; } +.month-nav { display: flex; align-items: center; justify-content: space-between; gap: 4px; margin: 0 -8px 12px; } +.calendar-panel { padding: 16px 12px; } +.calendar-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); text-align: center; } +.weekday-row { color: #a39790; font-size: 12px; margin: 12px 0 8px; } +.calendar-day { height: 47px; border-radius: 13px; display: flex; flex-direction: column; align-items: center; justify-content: center; margin: 2px 1px; font-size: 14px; } +.calendar-day--selected { color: #fffdfa; background: #9c5268; } +.calendar-day--future { color: #c7bdb7; } +.practice-dot { width: 4px; height: 4px; border-radius: 50%; margin-top: 4px; background: transparent; } +.practice-dot--visible { background: #bd7d8e; } +.calendar-day--selected .practice-dot--visible { background: #fffdfa; } +.calendar-footer { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #eee5df; margin-top: 14px; padding: 8px 4px 0; } +.stats-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 12px 0 22px; } +.stat-number { display: block; color: #675759; font-size: 30px; font-weight: 600; } +.stat-unit { font-size: 12px; font-weight: 400; } +.trend-panel .section-heading { margin: 0 0 14px; } +.segmented { display: flex; padding: 3px; background: #f1ebe5; border-radius: 12px; } +.trend-total { display: block; font-size: 18px; color: #955367; margin: 14px 0; } +.chart { display: flex; gap: 9px; align-items: flex-end; height: 147px; margin-top: 22px; } +.chart-column { display: flex; flex: 1; min-width: 0; flex-direction: column; align-items: center; height: 100%; } +.chart-track { height: 120px; width: 100%; display: flex; align-items: flex-end; border-bottom: 1px solid #e9ded7; } +.chart-bar { width: 100%; border-radius: 5px 5px 0 0; background: #d8a8b2; } +.chart-bar--focused { background: #9c5268; } +.chart-label { font-size: 10px; color: #a18f87; margin-top: 6px; } +.chart--month { gap: 3px; } +.chart--month .chart-label { font-size: 8px; } +.chart--month .chart-column:nth-child(2n) .chart-label { visibility: hidden; } +.distribution { margin-bottom: 20px; } +.distribution:last-child { margin-bottom: 0; } +.distribution-track { height: 7px; border-radius: 7px; background: #f0e8e2; margin: 10px 0 6px; overflow: hidden; } +.distribution-bar { height: 100%; border-radius: 7px; background: #bc8292; } diff --git a/miniprogram/src/components/common.tsx b/miniprogram/src/components/common.tsx new file mode 100644 index 0000000..16b5d4d --- /dev/null +++ b/miniprogram/src/components/common.tsx @@ -0,0 +1,33 @@ +import Taro from '@tarojs/taro' +import { Text, View } from '@tarojs/components' +import Button from '@taroify/core/button' +import '@taroify/core/button/style' +import type { PracticeRecord } from '../services/practice' +import { duration } from '../utils/dates' + +export { Button } + +export function PageHeading({ title, subtitle }: { title: string; subtitle: string }) { + return BALLET ISLAND{title}{subtitle} +} +export function LoadState({ loading, error, retry }: { loading: boolean; error: string; retry: () => unknown }) { + if (!loading && !error) return null + return {loading ? '正在读取你的练习…' : error}{error && } +} +export function Empty({ title, text }: { title: string; text: string }) { + return {title}{text} +} +export function RecordCards({ records }: { records: PracticeRecord[] }) { + return {records.map(record => + {record.projectName}{duration(record.minutes)} + {record.date}{record.archived ? ' · 已归档项目' : ''} + {record.note && {record.note}} + )} +} + +/** Return to the actual originating page; deep-entry forms fall back to records. */ +export async function returnToSource(message?: string) { + if (Taro.getCurrentPages().length > 1) await Taro.navigateBack() + else await Taro.switchTab({ url: '/pages/index/index' }) + if (message) void Taro.showToast({ title: message, icon: 'none' }) +} diff --git a/miniprogram/src/hooks/useRemote.ts b/miniprogram/src/hooks/useRemote.ts new file mode 100644 index 0000000..5167989 --- /dev/null +++ b/miniprogram/src/hooks/useRemote.ts @@ -0,0 +1,29 @@ +import { useCallback, useEffect, useRef, useState, type DependencyList } from 'react' +import { useDidHide, useDidShow, useUnload } from '@tarojs/taro' +import { errorMessage, onPracticeChange } from '../services/practice' + +/** Each load owns a sequence number. Late responses from a previous date, + * hidden page, or pre-save request cannot overwrite newer visible results. */ +export function useRemote(loader: () => Promise, dependencies: DependencyList = []) { + const load = useRef(loader) + load.current = loader + const sequence = useRef(0) + const visible = useRef(true) + const [state, setState] = useState<{ data?: T; loading: boolean; error: string }>({ loading: true, error: '' }) + const reload = useCallback(async () => { + const version = ++sequence.current + setState({ loading: true, error: '' }) + try { + const data = await load.current() + if (version === sequence.current) setState({ data, loading: false, error: '' }) + } catch (error) { + if (version === sequence.current) setState({ loading: false, error: errorMessage(error) }) + } + }, []) + useEffect(() => { void reload(); return () => { sequence.current++ } }, [reload, ...dependencies]) + useEffect(() => onPracticeChange(() => { if (visible.current) void reload() }), [reload]) + useDidShow(() => { visible.current = true; void reload() }) + useDidHide(() => { visible.current = false; sequence.current++ }) + useUnload(() => { visible.current = false; sequence.current++ }) + return { ...state, reload } +} diff --git a/miniprogram/src/pages/calendar/index.config.ts b/miniprogram/src/pages/calendar/index.config.ts new file mode 100644 index 0000000..11c2568 --- /dev/null +++ b/miniprogram/src/pages/calendar/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '芭蕾岛 · 日历' }) diff --git a/miniprogram/src/pages/calendar/index.tsx b/miniprogram/src/pages/calendar/index.tsx new file mode 100644 index 0000000..f056229 --- /dev/null +++ b/miniprogram/src/pages/calendar/index.tsx @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react' +import Taro, { useDidHide } from '@tarojs/taro' +import { Picker, Text, View } from '@tarojs/components' +import { Button, Empty, LoadState, PageHeading, RecordCards } from '../../components/common' +import { useRemote } from '../../hooks/useRemote' +import { api, errorMessage, type RecordList } from '../../services/practice' +import { addMonths, displayDate, duration, monthLabel, monthStart, parseDate, shanghaiToday } from '../../utils/dates' + +export default function Calendar() { + const [selected, setSelected] = useState(shanghaiToday()) + const [today, setToday] = useState(shanghaiToday()) + const month = monthStart(selected) + const state = useRemote(async () => { + const [review, records] = await Promise.all([api.review('month', selected), api.records({ from: selected, to: selected, limit: 100 })]) + return { review, records } + }, [selected]) + const [list, setList] = useState() + const [moreError, setMoreError] = useState('') + const [loadingMore, setLoadingMore] = useState(false) + const pageSequence = useRef(0) + const moreBusy = useRef(false) + useEffect(() => { + pageSequence.current++; moreBusy.current = false; setLoadingMore(false); setMoreError('') + setList(state.data?.records) + if (state.data) setToday(state.data.review.today) + }, [state.data]) + useDidHide(() => { pageSequence.current++ }) + const review = state.data?.review + const offset = (parseDate(month).getUTCDay() + 6) % 7 + const total = review?.days.find(day => day.date === selected)?.minutes || 0 + + async function more() { + if (!list?.nextCursor || moreBusy.current) return + const version = pageSequence.current + moreBusy.current = true; setLoadingMore(true); setMoreError('') + try { + const next = await api.records({ from: selected, to: selected, limit: 100, cursor: list.nextCursor }) + if (version === pageSequence.current) setList({ records: [...list.records, ...next.records], nextCursor: next.nextCursor }) + } catch (error) { if (version === pageSequence.current) setMoreError(errorMessage(error)) } + finally { if (version === pageSequence.current) { moreBusy.current = false; setLoadingMore(false) } } + } + + return + + + + + setSelected(`${event.detail.value.slice(0, 7)}-01`)}>{monthLabel(month)} ⌄ + + + {['一', '二', '三', '四', '五', '六', '日'].map(day => {day})} + + {review && <> + {Array.from({ length: offset }, (_, i) => )}{review.days.map(day => today ? 'calendar-day--future' : ''}`} onClick={() => { if (day.date <= today) setSelected(day.date) }}> + {Number(day.date.slice(8))} + )} + 圆点表示这天有练习 + } + + {review && list && <> + {displayDate(selected)}共 {duration(total)} + {list.records.length ? : } + {moreError && {moreError}} + {list.nextCursor && } + } + +} diff --git a/miniprogram/src/pages/growth/index.config.ts b/miniprogram/src/pages/growth/index.config.ts new file mode 100644 index 0000000..ac1ce38 --- /dev/null +++ b/miniprogram/src/pages/growth/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '芭蕾岛 · 成长' }) diff --git a/miniprogram/src/pages/growth/index.tsx b/miniprogram/src/pages/growth/index.tsx new file mode 100644 index 0000000..15a01db --- /dev/null +++ b/miniprogram/src/pages/growth/index.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' +import { Text, View } from '@tarojs/components' +import { Button, Empty, LoadState, PageHeading } from '../../components/common' +import { useRemote } from '../../hooks/useRemote' +import { api } from '../../services/practice' +import { addDays, addMonths, duration, monthStart, shanghaiToday } from '../../utils/dates' + +export default function Growth() { + const [period, setPeriod] = useState<'week' | 'month'>('week') + const [anchor, setAnchor] = useState(shanghaiToday()) + const state = useRemote(() => api.review(period, anchor), [period, anchor]) + const review = state.data + const max = Math.max(1, ...(review?.days.map(day => day.minutes) || [])) + const [focused, setFocused] = useState('') + const focus = review?.days.find(day => day.date === focused) + const move = (direction: number) => { + const next = period === 'week' ? addDays(review!.from, direction * 7) : addMonths(review!.from, direction) + if (next >= '0001-01-01' && next <= review!.today) { setFocused(''); setAnchor(next) } + } + const currentPeriod = review && (period === 'week' ? review.to >= review.today : monthStart(review.from) === monthStart(review.today)) + return + + + {review && <> + 累计练习{duration(review.allTime.totalMinutes)}每一分钟,都来自你留下的真实记录。 + {review.allTime.practiceDays} 天练习天数{review.allTime.recordCount} 次练习记录 + + 时长趋势{(['week', 'month'] as const).map(value => )} + {review.from} — {review.to.slice(5)} + {duration(review.period.totalMinutes)} · {review.period.practiceDays} 个练习日 + {review.days.map(day => setFocused(day.date)}> + {Number(day.date.slice(8))} + )} + {focus ? `${focus.date} · ${duration(focus.minutes)}` : '点按柱形查看当天时长;空白表示没有记录。'} + {!currentPeriod && } + + 时间花在哪里全部练习 + {review.allTime.projects.length ? review.allTime.projects.map(project => + {project.name}{project.archived ? ' · 已归档' : ''}{(project.minutes / review.allTime.totalMinutes * 100).toFixed(1)}% + {duration(project.minutes)} + ) : } + 时长记录的是投入,不是技术水平。慢慢来,按自己的节奏练习。 + } + +} diff --git a/miniprogram/src/pages/index/index.config.ts b/miniprogram/src/pages/index/index.config.ts index d7f90fe..d1a2bda 100644 --- a/miniprogram/src/pages/index/index.config.ts +++ b/miniprogram/src/pages/index/index.config.ts @@ -1,4 +1,3 @@ export default definePageConfig({ - navigationBarTitleText: 'Ballet Island', + navigationBarTitleText: '芭蕾岛 · 记录', }) - diff --git a/miniprogram/src/pages/index/index.scss b/miniprogram/src/pages/index/index.scss deleted file mode 100644 index dc6ead9..0000000 --- a/miniprogram/src/pages/index/index.scss +++ /dev/null @@ -1,21 +0,0 @@ -.welcome { - margin: 32px 20px; - padding: 28px 24px; - border-radius: 16px; - background: #fff; - - &__title { - display: block; - margin-bottom: 12px; - font-size: 28px; - font-weight: 600; - } - - &__message { - display: block; - margin-bottom: 28px; - color: #646566; - line-height: 1.6; - } -} - diff --git a/miniprogram/src/pages/index/index.tsx b/miniprogram/src/pages/index/index.tsx index 7eeafa9..3cef6ba 100644 --- a/miniprogram/src/pages/index/index.tsx +++ b/miniprogram/src/pages/index/index.tsx @@ -1,34 +1,29 @@ -import { useState } from 'react' +import Taro from '@tarojs/taro' import { Text, View } from '@tarojs/components' -import Button from '@taroify/core/button' -import '@taroify/core/button/style' -import { checkServiceReady } from '../../services/health' -import './index.scss' +import { Button, Empty, LoadState, PageHeading, RecordCards } from '../../components/common' +import { useRemote } from '../../hooks/useRemote' +import { api } from '../../services/practice' +import { duration } from '../../utils/dates' export default function Index() { - const [loading, setLoading] = useState(false) - const [message, setMessage] = useState('欢迎来到 Ballet Island') - - async function handleCheck() { - setLoading(true) - try { - await checkServiceReady() - setMessage('连接成功,服务已就绪') - } catch { - setMessage('暂时无法连接,请稍后重试') - } finally { - setLoading(false) - } - } - - return ( - - Ballet Island - {message} - - - ) + const state = useRemote(async () => { + const [projects, recent, review] = await Promise.all([api.projects(), api.records(), api.review('week')]) + return { ...projects, recent, review } + }) + const data = state.data + const todayMinutes = data?.review.days.find(day => day.date === data.today)?.minutes || 0 + return + + + {data && <> + 今天的练习{duration(todayMinutes)}{data.today} · 按实际练习日期记录 + 这次练了什么 + {data.projects.length ? {data.projects.map((project, index) => { void Taro.navigateTo({ url: `/pages/record/index?projectId=${project.id}&date=${data.today}` }) }}> + {String(index + 1).padStart(2, '0')}{project.name}记录练习 + + )} : } + 近期记录最近 20 条 + {data.recent.records.length ? : } + + } + } - diff --git a/miniprogram/src/pages/projects/index.config.ts b/miniprogram/src/pages/projects/index.config.ts new file mode 100644 index 0000000..a336417 --- /dev/null +++ b/miniprogram/src/pages/projects/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '管理练习项目' }) diff --git a/miniprogram/src/pages/projects/index.tsx b/miniprogram/src/pages/projects/index.tsx new file mode 100644 index 0000000..6731cb2 --- /dev/null +++ b/miniprogram/src/pages/projects/index.tsx @@ -0,0 +1,69 @@ +import { useRef, useState } from 'react' +import Taro from '@tarojs/taro' +import { Input, Text, View } from '@tarojs/components' +import { Button, Empty, LoadState, PageHeading } from '../../components/common' +import { useRemote } from '../../hooks/useRemote' +import { api, errorMessage, NAME_LIMIT, type Project } from '../../services/practice' + +export default function Projects() { + const state = useRemote(() => api.projects()) + const [editing, setEditing] = useState() + const [name, setName] = useState('') + const [busy, setBusy] = useState(false) + const busyRef = useRef(false) + const [error, setError] = useState('') + const [notice, setNotice] = useState('') + + async function save() { + if (busyRef.current) return + const clean = name.trim() + if (!clean || Array.from(clean).length > NAME_LIMIT || clean.includes('\0')) { setError(`项目名称须为 1–${NAME_LIMIT} 字,不能只含空白或包含空字符`); return } + busyRef.current = true; setBusy(true); setError(''); setNotice('') + try { + await api.saveProject(clean, editing?.id) + setNotice(editing ? '已改名,历史记录也会使用这个名称。' : '项目已添加。') + setName(''); setEditing(undefined) + await state.reload() + } catch (cause) { setError(errorMessage(cause)) } + finally { busyRef.current = false; setBusy(false) } + } + + async function remove(project: Project) { + if (busyRef.current) return + busyRef.current = true; setBusy(true); setError(''); setNotice('') + try { + const confirmed = await Taro.showModal({ + title: project.hasRecords ? '移出常用项目?' : '移除这个项目?', + content: project.hasRecords ? `“${project.name}”将归档,历史记录、笔记和累计时长都会保留。` : `“${project.name}”没有记录时会被删除。如果已有新记录,将改为归档并保留历史。`, + confirmText: '确认移除', cancelText: '保留项目', + }) + if (!confirmed.confirm) return + const result = await api.removeProject(project.id) + if (editing?.id === project.id) { setEditing(undefined); setName('') } + setNotice(result.action === 'archived' ? '已归档,历史记录和时长均已保留。' : '未使用的项目已删除。') + await state.reload() + } catch (cause) { setError(errorMessage(cause)) } + finally { busyRef.current = false; setBusy(false) } + } + + return + + + {editing ? '修改项目名称' : '添加练习项目'} + setName(event.detail.value)} /> + 最多 {NAME_LIMIT} 字。改名后,历史记录会统一显示新名称。 + + {editing && } + + {error && {error}} + {notice && {notice}} + 常用项目{state.data ? `${state.data.projects.length} 个` : ''} + + {state.data && + {state.data.projects.length ? state.data.projects.map(project => + {project.name}{project.hasRecords ? '已有练习 · 移除会保留历史' : '尚无练习记录'} + ) : } + } + 有练习记录的项目会归档,不会删除历史。归档项目仍计入成长,也能从原记录进入更正。 + +} diff --git a/miniprogram/src/pages/record/index.config.ts b/miniprogram/src/pages/record/index.config.ts new file mode 100644 index 0000000..40426b7 --- /dev/null +++ b/miniprogram/src/pages/record/index.config.ts @@ -0,0 +1 @@ +export default definePageConfig({ navigationBarTitleText: '练习记录' }) diff --git a/miniprogram/src/pages/record/index.tsx b/miniprogram/src/pages/record/index.tsx new file mode 100644 index 0000000..f9203dc --- /dev/null +++ b/miniprogram/src/pages/record/index.tsx @@ -0,0 +1,132 @@ +import { useEffect, useRef, useState } from 'react' +import Taro, { useDidHide, useDidShow, useRouter, useUnload } from '@tarojs/taro' +import { Input, Picker, Text, Textarea, View } from '@tarojs/components' +import { Button, LoadState, PageHeading, returnToSource } from '../../components/common' +import { useRemote } from '../../hooks/useRemote' +import { api, ApiError, clearPendingRecord, errorMessage, getPendingRecord, MINUTE_LIMIT, NOTE_LIMIT, retainPendingRecord, submissionID, type PendingRecord } from '../../services/practice' +import { validDate } from '../../utils/dates' + +export default function RecordForm() { + const { params } = useRouter() + const resumed = useRef(getPendingRecord()) + const id = resumed.current ? resumed.current.id : params.id + const initialised = useRef(false) + const busyRef = useRef(false) + const pending = useRef(resumed.current) + const visible = useRef(true) + useDidShow(() => { visible.current = true }) + useDidHide(() => { visible.current = false }) + useUnload(() => { visible.current = false }) + const key = useRef(submissionID()) + const state = useRemote(async () => { + const [projects, record] = await Promise.all([api.projects(true), id && !resumed.current ? api.record(id) : Promise.resolve(undefined)]) + return { ...projects, record } + }, [id]) + const [context, setContext] = useState() + const [projectId, setProjectId] = useState('') + const [date, setDate] = useState('') + const [minutes, setMinutes] = useState('') + const [note, setNote] = useState('') + const [busy, setBusy] = useState(false) + const [uncertain, setUncertain] = useState(!!resumed.current) + const [error, setError] = useState('') + const [done, setDone] = useState('') + + useEffect(() => { + if (!state.data) return + const data = state.data + setContext(data) + if (initialised.current) return + initialised.current = true + const draft = resumed.current?.input + setProjectId(draft?.projectId || data.record?.projectId || params.projectId || '') + setDate(draft?.date || data.record?.date || (params.date && validDate(params.date) && params.date <= data.today ? params.date : data.today)) + setMinutes(draft ? String(draft.minutes) : data.record ? String(data.record.minutes) : '') + setNote(draft?.note ?? data.record?.note ?? '') + }, [state.data, params.projectId, params.date]) + + const choices = context?.projects.filter(project => !project.archived || project.id === context.record?.projectId || project.id === resumed.current?.input.projectId) || [] + const selected = choices.find(project => project.id === projectId) + const locked = busy || uncertain + + useEffect(() => { + if (!visible.current) return + if (uncertain || (busy && pending.current)) Taro.enableAlertBeforeUnload({ message: '保存尚未确认,请先重试。返回后再次登记会恢复原输入。', fail: () => {} }) + else Taro.disableAlertBeforeUnload({ fail: () => {} }) + }, [busy, uncertain]) + + async function finish(message: string) { + if (!visible.current) return + Taro.disableAlertBeforeUnload({ fail: () => {} }) + setDone(message) + try { await returnToSource(message) } catch { setError('已保存结果,可点击下方按钮返回') } + } + + async function save() { + if (busyRef.current || done || !context) return + if (!pending.current) { + if (!selected) { setError('请选择一个可用项目'); return } + if (!validDate(date) || date > context.today) { setError('请选择有效的实际练习日期,不能晚于今天'); return } + if (!/^\d+$/.test(minutes) || Number(minutes) < 1 || Number(minutes) > MINUTE_LIMIT) { setError(`时长须为 1–${MINUTE_LIMIT} 的整数分钟`); return } + if (Array.from(note).length > NOTE_LIMIT || note.includes('\0')) { setError(`笔记最多 ${NOTE_LIMIT} 字,不能包含空字符`); return } + pending.current = { id, input: { projectId, date, minutes: Number(minutes), note, ...(id ? {} : { requestId: key.current }) } } + } + const attempt = pending.current + retainPendingRecord(attempt) + busyRef.current = true; setBusy(true); setError('') + try { + await api.saveRecord(attempt.input, id) + clearPendingRecord(attempt) + setUncertain(false) + await finish(id ? '记录已更正' : '练习已保存') + } catch (cause) { + const unknownResult = !(cause instanceof ApiError) || cause.status === 0 || cause.status >= 500 + setUncertain(unknownResult) + // Freeze the submitted values after an uncertain result, keeping the exact + // payload and key until retry confirms persistence. Never silently create another entry. + if (!unknownResult) { clearPendingRecord(attempt); pending.current = undefined } + setError(unknownResult ? `${errorMessage(cause)}。本次结果尚未确认,请重试确认;填写内容已保留。` : errorMessage(cause)) + } finally { busyRef.current = false; setBusy(false) } + } + + async function remove() { + if (!id || busyRef.current || uncertain) return + busyRef.current = true; setBusy(true) + try { + const result = await Taro.showModal({ title: '删除这条误记?', content: '这条记录的时长和笔记将被删除,日历与成长统计会一起更新。', confirmText: '删除记录', confirmColor: '#a23d4c', cancelText: '保留记录' }) + if (!result.confirm) return + setError('') + await api.deleteRecord(id) + await finish('记录已删除') + } catch (cause) { setError(`${errorMessage(cause)}。删除结果未确认,可重试删除。`) } + finally { busyRef.current = false; setBusy(false) } + } + + return + + {!context && } + {context && !done && <> + {resumed.current && uncertain && 上次保存尚未确认,已恢复当时的项目、日期和填写内容。请先重试确认这次保存。} + + 练习项目 + `${project.name}${project.archived ? '(已归档)' : ''}`)]} value={choices.findIndex(project => project.id === projectId) + 1} disabled={locked || !choices.length} onChange={event => setProjectId(choices[Number(event.detail.value) - 1]?.id || '')}> + {selected ? `${selected.name}${selected.archived ? '(已归档)' : ''}` : '请选择项目'}⌄ + + {!choices.length && 先添加一个项目,就能开始记录。} + {selected?.archived && 这条历史记录可以保留原项目,也可改选常用项目。} + 实际练习日期 + setDate(event.detail.value)}>{date}⌄ + 整堂时长 · 分钟 + setMinutes(event.detail.value)} /> + {[30, 45, 60, 90].map(value => )} + 练习笔记 选填 +