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.
This commit is contained in:
@@ -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:按用户“先调研”要求完成研究,由主代理保存结论;本票解决事实问题,不确认第一期功能清单。
|
||||
@@ -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。
|
||||
@@ -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
|
||||
|
||||
- 记录流程已确认后领取本票;先讨论平铺与分组,再确定预置清单及其余组织规则。
|
||||
- 用户确认:“平铺列表”。第一期直接展示并选择具体练习项目,不设置分组或父子项目层级。
|
||||
- 用户对所提八项回复“可以”,首批预置项目确定为:零基础、基础提升、软开素质、足髋训练、核心臀腿、小球核心、天鹅臂颈、呼吸训练。这些仅作为可编辑的起始项目名称,不是统一训练标准。
|
||||
- 用户确认:“统一显示新名称(推荐)”。历史记录与统计统一显示改名后的名称,原有日期、时长和笔记不变。项目组织问题据此解决。
|
||||
- 时长输入与显示精度和统计呈现关联更紧,已明确转入成长回顾事项继续讨论,没有在本票中默认为已确认。
|
||||
@@ -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
|
||||
|
||||
- 记录流程与项目组织均已解决,现领取本票。
|
||||
- 用户对所提统计范围回复“可以”:第一期包含累计时长与练习天数、可点开日期回看记录的练习日历、周月时长趋势、各项目时长及占比。同一天多条练习记录,练习天数计一天。
|
||||
- 常规细节拟采用以下默认方案,后续统一在原型或规格中呈现,不逐项追加问答:时长以分钟为输入精度,汇总使用完整时长后再格式化展示;日期默认今天且可补记;周按周一至周日、月按自然月;补记按实际练习日期归属;归档项目的历史继续计入统计。这些是代理提出的默认方案,不标记为用户逐项确认。
|
||||
- 用户明确确认:“练习目标、成就徽章和分享卡片,都放到后续版本”。本票据此解决。
|
||||
- 用户询问剩余决策:除本票的激励与分享外,现有地图还需确认数据保存及旧记录衔接,并通过核心流程原型确认页面。后续问题聚焦影响范围或数据含义的取舍,常规可逆细节提供默认方案。
|
||||
@@ -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
|
||||
|
||||
- 用户在同一条回复中明确确认数据保存、旧数据范围和激励范围;据此记录已经作出的产品取舍,不重复请求确认。
|
||||
@@ -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 内容宽度下的成长页和日历;检查后的浏览器预览尺寸已恢复。原型脚本通过语法检查。
|
||||
- 尚未验证真实微信身份、云端保存、换设备恢复及生产端能力。首次登录提示、网络失败与重试也未在草图中模拟。
|
||||
- 用户验收反馈已收到:认可三个入口以及原型中的操作路径。交互检查和用户反馈均已有记录;真实产品接入仍未验证。
|
||||
@@ -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
|
||||
|
||||
查找舞团、舞校、考试机构等公开的芭蕾动作术语来源,核实哪些适合作为动作名称、别名、分类和简短释义的参考。给出少量种子动作,区分术语目录与分级教学/掌握标准,说明法语及中文名称差异、访问与复用边界;不复制完整教材、不创建正式知识库,不替用户定义动作解锁规则。
|
||||
@@ -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 后端,核实录音、语音识别、中文与芭蕾术语处理、结构化抽取及工具调用所需的可用能力。比较短录音后处理与实时交互对首期的影响,提出可验证的记录草稿流程;列明相对日期、多项练习、信息缺失、语音更正、重复提交和模型失败的处理原则。只做文档研究,不调用付费模型、不选择最终供应商、不修改业务代码。
|
||||
@@ -0,0 +1,12 @@
|
||||
# 语音录入与动作成就分别在哪一期交付?
|
||||
|
||||
Type: grilling
|
||||
Labels: wayfinder:grilling
|
||||
Status: claimed
|
||||
Assignee: yuxuanhui
|
||||
Parent: [芭蕾岛产品规划:练习记录、语音录入与动作成长](../map.md)
|
||||
Blocked by: none
|
||||
|
||||
## Question
|
||||
|
||||
整体产品包含练习记录、语音录入和动作成就;首期是否包含语音录入及动作成就,哪些作为后续扩展?
|
||||
@@ -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`。
|
||||
@@ -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
|
||||
|
||||
<!-- 仅索引已解决事项;未决事项通过 issues/ 中的状态与依赖查找。 -->
|
||||
|
||||
- [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)。
|
||||
@@ -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` 表示范围和验收依据足以交给开发代理,不表示业务代码已完成、构建已通过或产品已经上线。
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 <token>`。失败以 `{error:{code,message}}` 返回:401 重新登录、400 输入无效、404 对象不可用、409 归档项目或提交内容冲突、410 已删除记录的旧提交、503 暂时性失败。错误响应不返回数据库细节、微信密钥或其他用户的数据。移除/记录写入在用户范围内串行化,防止并发删除项目损坏历史。
|
||||
|
||||
Go 服务启动时检查数据库连接,收到 `SIGINT` / `SIGTERM` 时停止接收请求并关闭连接池。
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+27
@@ -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 ./...
|
||||
@@ -0,0 +1,31 @@
|
||||
# Domain Docs
|
||||
|
||||
本仓库采用 single-context 布局,前端和后端共享根目录的领域文档。
|
||||
|
||||
## 探索前读取
|
||||
|
||||
- 根目录的 `CONTEXT.md`:领域术语、概念和关系。
|
||||
- 根目录的 `docs/adr/`:仅阅读与当前工作相关的架构决策。
|
||||
|
||||
文件不存在时直接继续,不把缺失视为阻塞,也不主动建议预先创建。
|
||||
后续由 `domain-modeling` 在术语或决策得到验证、且用户明确批准后
|
||||
按需创建。
|
||||
|
||||
## 文件布局
|
||||
|
||||
- `CONTEXT.md`:仓库共享的领域上下文。
|
||||
- `docs/adr/NNNN-<slug>.md`:按编号保存架构决策。
|
||||
- `miniprogram/` 和 `backend/` 继续按各自的项目约定开发。
|
||||
|
||||
## 术语一致性
|
||||
|
||||
事项标题、重构建议、假设和测试名称中的领域概念,
|
||||
使用 `CONTEXT.md` 已定义的术语,遵循其明确规定的用词边界。
|
||||
|
||||
所需概念尚未定义时,先判断是否误用了项目之外的术语;
|
||||
确有缺口时,将其列为后续领域建模的候选。
|
||||
|
||||
## 决策冲突
|
||||
|
||||
输出与现有 ADR 冲突时,明确指出对应 ADR、冲突内容及重新讨论的理由,
|
||||
不静默覆盖已有决策。
|
||||
@@ -0,0 +1,40 @@
|
||||
# Issue tracker: Local Markdown
|
||||
|
||||
本仓库的事项和规格以 Markdown 文件保存在 `.scratch/`。
|
||||
|
||||
## 文件约定
|
||||
|
||||
- 每个功能一个目录:`.scratch/<feature-slug>/`。
|
||||
- 规格文件:`.scratch/<feature-slug>/spec.md`。
|
||||
- 实现事项:`.scratch/<feature-slug>/issues/<NN>-<slug>.md`,
|
||||
从 `01` 开始编号,每个事项一个文件。
|
||||
- 分流状态写在事项文件顶部附近的 `Status:` 行;
|
||||
使用 `triage-labels.md` 中的标签字符串。
|
||||
- 评论和讨论追加到文件底部的 `## Comments` 下。
|
||||
|
||||
## 发布和读取
|
||||
|
||||
技能要求“发布到 issue tracker”时,在对应功能目录中创建文件,
|
||||
按需创建目录。
|
||||
|
||||
技能要求“获取相关 ticket”时,读取指定路径的事项文件。
|
||||
仅提供编号时,在对应功能目录中定位。
|
||||
|
||||
## Wayfinding
|
||||
|
||||
供 `wayfinder` 使用,一个地图文件对应多个子事项文件。
|
||||
|
||||
- 地图:`.scratch/<effort>/map.md`,包含 Notes、
|
||||
Decisions-so-far 和 Fog。
|
||||
- 子事项:`.scratch/<effort>/issues/<NN>-<slug>.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 追加结论摘要及事项链接。
|
||||
@@ -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 | 不予处理 |
|
||||
|
||||
技能提及某个分流角色时,使用对应的本仓库标签。
|
||||
后续调整标签名称时,修改本仓库标签列。
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
+102
-4
@@ -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; }
|
||||
|
||||
@@ -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 <View className='page-heading'><Text className='eyebrow'>BALLET ISLAND</Text><Text className='page-title'>{title}</Text><Text className='muted'>{subtitle}</Text></View>
|
||||
}
|
||||
export function LoadState({ loading, error, retry }: { loading: boolean; error: string; retry: () => unknown }) {
|
||||
if (!loading && !error) return null
|
||||
return <View className='panel status-panel'><Text className={error ? 'error-text' : 'muted'}>{loading ? '正在读取你的练习…' : error}</Text>{error && <Button size='small' variant='outlined' onClick={() => { void retry() }}>重新加载</Button>}</View>
|
||||
}
|
||||
export function Empty({ title, text }: { title: string; text: string }) {
|
||||
return <View className='empty'><Text className='empty-title'>{title}</Text><Text className='muted'>{text}</Text></View>
|
||||
}
|
||||
export function RecordCards({ records }: { records: PracticeRecord[] }) {
|
||||
return <View>{records.map(record => <View className='record-card' key={record.id}>
|
||||
<View className='row'><Text className='record-name'>{record.projectName}</Text><Text className='record-duration'>{duration(record.minutes)}</Text></View>
|
||||
<View className='row'><Text className='muted small'>{record.date}{record.archived ? ' · 已归档项目' : ''}</Text><Button size='small' variant='text' onClick={() => { void Taro.navigateTo({ url: `/pages/record/index?id=${record.id}` }) }}>更正</Button></View>
|
||||
{record.note && <Text className='record-note'>{record.note}</Text>}
|
||||
</View>)}</View>
|
||||
}
|
||||
|
||||
/** 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' })
|
||||
}
|
||||
@@ -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<T>(loader: () => Promise<T>, 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 }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '芭蕾岛 · 日历' })
|
||||
@@ -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<RecordList>()
|
||||
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 <View className='page'>
|
||||
<PageHeading title='练习留在日子里' subtitle='有练习的日子做个记号,休息日就留白。' />
|
||||
<View className='panel calendar-panel'>
|
||||
<View className='month-nav'>
|
||||
<Button variant='text' size='small' disabled={month <= '0001-01-01'} onClick={() => setSelected(addMonths(month, -1))}>上月</Button>
|
||||
<Picker mode='date' fields='month' value={selected} start='0001-01-01' end={today} onChange={event => setSelected(`${event.detail.value.slice(0, 7)}-01`)}><Text className='section-title'>{monthLabel(month)} ⌄</Text></Picker>
|
||||
<Button variant='text' size='small' disabled={month >= monthStart(today)} onClick={() => setSelected(addMonths(month, 1))}>下月</Button>
|
||||
</View>
|
||||
<View className='calendar-grid weekday-row'>{['一', '二', '三', '四', '五', '六', '日'].map(day => <Text key={day}>{day}</Text>)}</View>
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{review && <>
|
||||
<View className='calendar-grid'>{Array.from({ length: offset }, (_, i) => <View key={`blank-${i}`} />)}{review.days.map(day => <View key={day.date} className={`calendar-day ${selected === day.date ? 'calendar-day--selected' : ''} ${day.date > today ? 'calendar-day--future' : ''}`} onClick={() => { if (day.date <= today) setSelected(day.date) }}>
|
||||
<Text>{Number(day.date.slice(8))}</Text><View className={`practice-dot ${day.minutes ? 'practice-dot--visible' : ''}`} />
|
||||
</View>)}</View>
|
||||
<View className='calendar-footer'><Text className='muted small'>圆点表示这天有练习</Text><Button variant='text' size='small' onClick={() => setSelected(today)}>回到今天</Button></View>
|
||||
</>}
|
||||
</View>
|
||||
{review && list && <>
|
||||
<View className='section-heading'><View><Text className='section-title'>{displayDate(selected)}</Text><Text className='muted small block'>共 {duration(total)}</Text></View><Button color='primary' size='small' onClick={() => { void Taro.navigateTo({ url: `/pages/record/index?date=${selected}` }) }}>补记练习</Button></View>
|
||||
{list.records.length ? <RecordCards records={list.records} /> : <View className='panel'><Empty title='这一天还没有记录' text='休息也是日常。漏记了一堂课,也可以补在这天。' /></View>}
|
||||
{moreError && <Text className='error-banner'>{moreError}</Text>}
|
||||
{list.nextCursor && <Button block variant='outlined' loading={loadingMore} disabled={loadingMore} onClick={more}>查看当天更多记录</Button>}
|
||||
</>}
|
||||
</View>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '芭蕾岛 · 成长' })
|
||||
@@ -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 <View className='page'>
|
||||
<PageHeading title='把投入慢慢积累' subtitle='回看练习的时间,也回看自己的节奏。' />
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{review && <>
|
||||
<View className='hero-card'><Text className='eyebrow'>累计练习</Text><Text className='hero-number'>{duration(review.allTime.totalMinutes)}</Text><Text className='muted'>每一分钟,都来自你留下的真实记录。</Text></View>
|
||||
<View className='stats-grid'><View className='panel'><Text className='stat-number'>{review.allTime.practiceDays}<Text className='stat-unit'> 天</Text></Text><Text className='muted'>练习天数</Text></View><View className='panel'><Text className='stat-number'>{review.allTime.recordCount}<Text className='stat-unit'> 次</Text></Text><Text className='muted'>练习记录</Text></View></View>
|
||||
<View className='panel trend-panel'>
|
||||
<View className='section-heading'><Text className='section-title'>时长趋势</Text><View className='segmented'>{(['week', 'month'] as const).map(value => <Button key={value} size='small' variant={period === value ? 'contained' : 'text'} color={period === value ? 'primary' : 'default'} onClick={() => { setPeriod(value); setFocused('') }}>{value === 'week' ? '按周' : '按月'}</Button>)}</View></View>
|
||||
<View className='month-nav'><Button size='small' variant='text' disabled={review.from <= '0001-01-01'} onClick={() => move(-1)}>上一{period === 'week' ? '周' : '月'}</Button><Text className='small'>{review.from} — {review.to.slice(5)}</Text><Button size='small' variant='text' disabled={!!currentPeriod} onClick={() => move(1)}>下一{period === 'week' ? '周' : '月'}</Button></View>
|
||||
<Text className='trend-total'>{duration(review.period.totalMinutes)}<Text className='muted small'> · {review.period.practiceDays} 个练习日</Text></Text>
|
||||
<View className={`chart chart--${period}`}>{review.days.map(day => <View key={day.date} className='chart-column' onClick={() => setFocused(day.date)}>
|
||||
<View className='chart-track'><View className={`chart-bar ${day.date === focused ? 'chart-bar--focused' : ''}`} style={{ height: `${day.minutes / max * 100}%` }} /></View><Text className='chart-label'>{Number(day.date.slice(8))}</Text>
|
||||
</View>)}</View>
|
||||
<Text className='field-help'>{focus ? `${focus.date} · ${duration(focus.minutes)}` : '点按柱形查看当天时长;空白表示没有记录。'}</Text>
|
||||
{!currentPeriod && <Button block variant='text' size='small' onClick={() => setAnchor(review.today)}>回到本{period === 'week' ? '周' : '月'}</Button>}
|
||||
</View>
|
||||
<View className='section-heading'><Text className='section-title'>时间花在哪里</Text><Text className='muted small'>全部练习</Text></View>
|
||||
<View className='panel'>{review.allTime.projects.length ? review.allTime.projects.map(project => <View className='distribution' key={project.projectId}>
|
||||
<View className='row'><Text className='record-name'>{project.name}{project.archived ? ' · 已归档' : ''}</Text><Text className='small'>{(project.minutes / review.allTime.totalMinutes * 100).toFixed(1)}%</Text></View>
|
||||
<View className='distribution-track'><View className='distribution-bar' style={{ width: `${project.minutes / review.allTime.totalMinutes * 100}%` }} /></View><Text className='muted small'>{duration(project.minutes)}</Text>
|
||||
</View>) : <Empty title='第一笔投入,还在前面' text='保存练习后,这里会如实显示时长和项目分布。' />}</View>
|
||||
<Text className='page-footnote'>时长记录的是投入,不是技术水平。慢慢来,按自己的节奏练习。</Text>
|
||||
</>}
|
||||
</View>
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: 'Ballet Island',
|
||||
navigationBarTitleText: '芭蕾岛 · 记录',
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<View className="welcome">
|
||||
<Text className="welcome__title">Ballet Island</Text>
|
||||
<Text className="welcome__message">{message}</Text>
|
||||
<Button color="primary" block loading={loading} disabled={loading} onClick={handleCheck}>
|
||||
检查连接
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
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 <View className='page'>
|
||||
<PageHeading title='每一次练习,都算数' subtitle='课后记下一笔,慢慢看见自己的投入。' />
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{data && <>
|
||||
<View className='hero-card'><Text className='eyebrow'>今天的练习</Text><Text className='hero-number'>{duration(todayMinutes)}</Text><Text className='muted'>{data.today} · 按实际练习日期记录</Text></View>
|
||||
<View className='section-heading'><Text className='section-title'>这次练了什么</Text><Button size='small' variant='text' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>管理项目</Button></View>
|
||||
{data.projects.length ? <View className='project-grid'>{data.projects.map((project, index) => <View key={project.id} className={`project-tile project-tile--${index % 4}`} onClick={() => { void Taro.navigateTo({ url: `/pages/record/index?projectId=${project.id}&date=${data.today}` }) }}>
|
||||
<Text className='project-mark'>{String(index + 1).padStart(2, '0')}</Text><Text className='project-title'>{project.name}</Text><Text className='project-hint'>记录练习 +</Text>
|
||||
</View>)}</View> : <View className='panel'><Empty title='常用项目暂时为空' text='历史和成长仍会保留。添加一个项目,继续记录。' /><Button block color='primary' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>添加项目</Button></View>}
|
||||
<View className='section-heading'><Text className='section-title'>近期记录</Text><Text className='muted small'>最近 20 条</Text></View>
|
||||
{data.recent.records.length ? <RecordCards records={data.recent.records} /> : <View className='panel'><Empty title='从第一堂课开始' text='选择上面的项目,记录时长;想写的时候,再留下收获。' /></View>}
|
||||
<Button block variant='text' onClick={() => { void Taro.switchTab({ url: '/pages/calendar/index' }) }}>去日历回看或补记</Button>
|
||||
</>}
|
||||
</View>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '管理练习项目' })
|
||||
@@ -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<Project>()
|
||||
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 <View className='page'>
|
||||
<PageHeading title='用你熟悉的名字' subtitle='课程会变化,留下的练习一直都在。' />
|
||||
<View className='panel form-panel'>
|
||||
<Text className='section-title'>{editing ? '修改项目名称' : '添加练习项目'}</Text>
|
||||
<Input className='field project-name-input' value={name} maxlength={-1} disabled={busy} placeholder='例如:周末基础课' onInput={event => setName(event.detail.value)} />
|
||||
<Text className='field-help'>最多 {NAME_LIMIT} 字。改名后,历史记录会统一显示新名称。</Text>
|
||||
<Button block color='primary' loading={busy} disabled={busy} onClick={save}>{editing ? '保存名称' : '添加项目'}</Button>
|
||||
{editing && <Button block variant='text' disabled={busy} onClick={() => { setEditing(undefined); setName(''); setError('') }}>取消改名</Button>}
|
||||
</View>
|
||||
{error && <Text className='error-banner'>{error}</Text>}
|
||||
{notice && <Text className='success-banner'>{notice}</Text>}
|
||||
<View className='section-heading'><Text className='section-title'>常用项目</Text><Text className='muted small'>{state.data ? `${state.data.projects.length} 个` : ''}</Text></View>
|
||||
<LoadState {...state} retry={state.reload} />
|
||||
{state.data && <View className='panel'>
|
||||
{state.data.projects.length ? state.data.projects.map(project => <View className='managed-project' key={project.id}>
|
||||
<Text className='record-name'>{project.name}</Text><View className='row'><Text className='muted small'>{project.hasRecords ? '已有练习 · 移除会保留历史' : '尚无练习记录'}</Text><View className='inline-actions'><Button size='small' variant='text' disabled={busy} onClick={() => { setEditing(project); setName(project.name); setError(''); void Taro.pageScrollTo({ scrollTop: 0, duration: 200 }) }}>改名</Button><Button size='small' color='danger' variant='text' disabled={busy} onClick={() => remove(project)}>移除</Button></View></View>
|
||||
</View>) : <Empty title='还没有常用项目' text='可以在上方添加项目,之前的历史仍可在日历里查看。' />}
|
||||
</View>}
|
||||
<Text className='page-footnote'>有练习记录的项目会归档,不会删除历史。归档项目仍计入成长,也能从原记录进入更正。</Text>
|
||||
</View>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default definePageConfig({ navigationBarTitleText: '练习记录' })
|
||||
@@ -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<PendingRecord | undefined>(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<typeof state.data>()
|
||||
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 <View className='page form-page'>
|
||||
<PageHeading title={id ? '更正这次练习' : '记下一次练习'} subtitle='一堂课记一笔,额外练习也可以单独记。' />
|
||||
{!context && <LoadState {...state} retry={state.reload} />}
|
||||
{context && !done && <>
|
||||
{resumed.current && uncertain && <Text className='error-banner'>上次保存尚未确认,已恢复当时的项目、日期和填写内容。请先重试确认这次保存。</Text>}
|
||||
<View className='panel form-panel'>
|
||||
<Text className='field-label'>练习项目</Text>
|
||||
<Picker mode='selector' range={['请选择项目', ...choices.map(project => `${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 || '')}>
|
||||
<View className='field picker-field'>{selected ? `${selected.name}${selected.archived ? '(已归档)' : ''}` : '请选择项目'}<Text>⌄</Text></View>
|
||||
</Picker>
|
||||
{!choices.length && <View className='field-help'><Text>先添加一个项目,就能开始记录。</Text><Button size='small' variant='text' onClick={() => { void Taro.navigateTo({ url: '/pages/projects/index' }) }}>添加项目</Button></View>}
|
||||
{selected?.archived && <Text className='field-help'>这条历史记录可以保留原项目,也可改选常用项目。</Text>}
|
||||
<Text className='field-label'>实际练习日期</Text>
|
||||
<Picker mode='date' value={date || context.today} start='0001-01-01' end={context.today} disabled={locked} onChange={event => setDate(event.detail.value)}><View className='field picker-field'>{date}<Text>⌄</Text></View></Picker>
|
||||
<Text className='field-label'>整堂时长 · 分钟</Text>
|
||||
<Input className='field' type='number' value={minutes} maxlength={10} disabled={locked} placeholder='例如 90' onInput={event => setMinutes(event.detail.value)} />
|
||||
<View className='quick-minutes'>{[30, 45, 60, 90].map(value => <Button key={value} size='small' variant='outlined' disabled={locked} onClick={() => setMinutes(String(value))}>{value} 分钟</Button>)}</View>
|
||||
<Text className='field-label'>练习笔记 <Text className='muted small'>选填</Text></Text>
|
||||
<Textarea className='field note-input' value={note} maxlength={-1} disabled={locked} cursorSpacing={24} placeholder='老师的提醒、今天的感受,想写什么都可以。' onInput={event => setNote(event.detail.value)} />
|
||||
<Text className='field-counter'>{Array.from(note).length} / {NOTE_LIMIT}</Text>
|
||||
</View>
|
||||
{error && <Text className='error-banner'>{error}</Text>}
|
||||
<View className='form-actions'><Button color='primary' block loading={busy} disabled={busy || (!pending.current && !choices.length)} onClick={save}>{uncertain ? '重试并确认保存结果' : '保存练习'}</Button><Button block variant='text' disabled={locked} onClick={() => { void returnToSource() }}>取消并返回</Button></View>
|
||||
{id && <Button color='danger' variant='text' block disabled={busy || uncertain} onClick={remove}>删除这条误记</Button>}
|
||||
</>}
|
||||
{done && <View className='panel status-panel'><Text>{done}</Text><Button color='primary' block onClick={() => { void returnToSource() }}>返回</Button></View>}
|
||||
</View>
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
export const NAME_LIMIT = 40
|
||||
export const NOTE_LIMIT = 2000
|
||||
export const MINUTE_LIMIT = 2147483647
|
||||
|
||||
export interface Project { id: string; name: string; archived: boolean; hasRecords: boolean }
|
||||
export interface PracticeRecord {
|
||||
id: string; projectId: string; projectName: string; archived: boolean
|
||||
date: string; minutes: number; note: string
|
||||
}
|
||||
export interface RecordInput { projectId: string; date: string; minutes: number; note: string; requestId?: string }
|
||||
export interface RecordList { records: PracticeRecord[]; nextCursor: string }
|
||||
export interface ProjectMinutes { projectId: string; name: string; archived: boolean; minutes: number }
|
||||
export interface Summary { totalMinutes: number; practiceDays: number; recordCount: number; projects: ProjectMinutes[] }
|
||||
export interface Review {
|
||||
today: string; from: string; to: string; allTime: Summary; period: Summary
|
||||
days: { date: string; minutes: number }[]
|
||||
}
|
||||
interface Session { token: string; expiresAt: string }
|
||||
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, public status = 0, public code = 'network_error') { super(message) }
|
||||
}
|
||||
|
||||
const storageKey = `ballet-session:${__API_BASE_URL__}`
|
||||
let current: Session | undefined
|
||||
let storageRead = false
|
||||
let loginPromise: Promise<Session> | undefined
|
||||
const changeListeners = new Set<() => void>()
|
||||
|
||||
export function onPracticeChange(listener: () => void) {
|
||||
changeListeners.add(listener)
|
||||
return () => { changeListeners.delete(listener) }
|
||||
}
|
||||
|
||||
// A temporary form receipt, not an offline queue. It survives page navigation
|
||||
// within this app session so native Back cannot silently discard an uncertain save.
|
||||
export interface PendingRecord { id?: string; input: RecordInput }
|
||||
let pendingRecord: PendingRecord | undefined
|
||||
export function getPendingRecord() { return pendingRecord }
|
||||
export function retainPendingRecord(value: PendingRecord) { pendingRecord = value }
|
||||
export function clearPendingRecord(value: PendingRecord) { if (pendingRecord === value) pendingRecord = undefined }
|
||||
|
||||
async function send<T>(path: string, method: Method, data?: object, token?: string): Promise<T> {
|
||||
let response: Taro.request.SuccessCallbackResult<T & { error?: { code: string; message: string } }>
|
||||
try {
|
||||
response = await Taro.request({
|
||||
url: `${__API_BASE_URL__}/v1${path}`, method, data, timeout: 12000,
|
||||
header: { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
})
|
||||
} catch {
|
||||
throw new ApiError('网络连接未完成,请检查网络后重试')
|
||||
}
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw new ApiError(response.data?.error?.message || '服务暂时不可用,请稍后重试', response.statusCode, response.data?.error?.code)
|
||||
}
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function session(): Promise<Session> {
|
||||
if (!storageRead) {
|
||||
storageRead = true
|
||||
try { current = Taro.getStorageSync<Session>(storageKey) || undefined } catch { /* Memory sessions still work if storage is unavailable. */ }
|
||||
}
|
||||
if (current?.token && Date.parse(current.expiresAt) > Date.now() + 60000) return current
|
||||
if (!loginPromise) {
|
||||
loginPromise = (async () => {
|
||||
let code: string
|
||||
try { code = (await Taro.login({ timeout: 10000 })).code } catch { throw new ApiError('微信登录未完成,请重试') }
|
||||
if (!code) throw new ApiError('未取得微信登录凭据,请重试')
|
||||
const result = await send<Session>('/session', 'POST', { code })
|
||||
current = result
|
||||
try { Taro.setStorageSync(storageKey, result) } catch { /* Do not turn a confirmed login into a failure. */ }
|
||||
return result
|
||||
})().finally(() => { loginPromise = undefined })
|
||||
}
|
||||
return loginPromise
|
||||
}
|
||||
|
||||
/** Refresh a rejected session once. A 401 is returned before business writes;
|
||||
* transport failures are left to the form, which retains its submission key. */
|
||||
async function request<T>(path: string, method: Method = 'GET', data?: object): Promise<T> {
|
||||
const original = await session()
|
||||
let result: T
|
||||
try { result = await send<T>(path, method, data, original.token) } catch (error) {
|
||||
if (!(error instanceof ApiError) || error.status !== 401) throw error
|
||||
if (current?.token === original.token) {
|
||||
current = undefined
|
||||
try { Taro.removeStorageSync(storageKey) } catch { /* The next exchange replaces storage. */ }
|
||||
}
|
||||
const renewed = await session()
|
||||
result = await send<T>(path, method, data, renewed.token)
|
||||
}
|
||||
if (method !== 'GET') changeListeners.forEach(listener => listener())
|
||||
return result
|
||||
}
|
||||
|
||||
function query(values: Record<string, string | number | undefined>) {
|
||||
return Object.entries(values).filter(([, value]) => value !== undefined && value !== '')
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`).join('&')
|
||||
}
|
||||
|
||||
export const api = {
|
||||
projects: (includeArchived = false) => request<{ projects: Project[]; today: string }>(`/projects?includeArchived=${includeArchived}`),
|
||||
saveProject: (name: string, id?: string) => request<Project>(id ? `/projects/${id}` : '/projects', id ? 'PUT' : 'POST', { name }),
|
||||
removeProject: (id: string) => request<{ action: 'deleted' | 'archived' }>(`/projects/${id}`, 'DELETE'),
|
||||
records: (options: { from?: string; to?: string; cursor?: string; limit?: number } = {}) => request<RecordList>(`/records?${query(options)}`),
|
||||
record: (id: string) => request<PracticeRecord>(`/records/${id}`),
|
||||
saveRecord: (input: RecordInput, id?: string) => request<PracticeRecord>(id ? `/records/${id}` : '/records', id ? 'PUT' : 'POST', input),
|
||||
deleteRecord: (id: string) => request<{ action: 'deleted' }>(`/records/${id}`, 'DELETE'),
|
||||
review: (period: 'week' | 'month', date?: string) => request<Review>(`/review?${query({ period, date })}`),
|
||||
}
|
||||
|
||||
/** An operation identifier, not an authentication secret. A new form gets a new
|
||||
* key even when its content is identical to an earlier intentional entry. */
|
||||
export function submissionID() {
|
||||
return `${Date.now().toString(36)}-${Array.from({ length: 32 }, () => Math.floor(Math.random() * 36).toString(36)).join('')}`
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : '暂时无法完成,请稍后重试'
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Date-only calculations use UTC fields, independent of the device timezone. */
|
||||
export function shanghaiToday() { return new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 10) }
|
||||
export function parseDate(date: string) { return new Date(`${date}T00:00:00Z`) }
|
||||
export function dateString(date: Date) { return date.toISOString().slice(0, 10) }
|
||||
export function addDays(date: string, days: number) {
|
||||
const value = parseDate(date); value.setUTCDate(value.getUTCDate() + days); return dateString(value)
|
||||
}
|
||||
export function monthStart(date: string) { return `${date.slice(0, 7)}-01` }
|
||||
export function addMonths(date: string, months: number) {
|
||||
const value = parseDate(monthStart(date)); value.setUTCMonth(value.getUTCMonth() + months); return dateString(value)
|
||||
}
|
||||
export function monthLabel(date: string) { return `${Number(date.slice(0, 4))} 年 ${Number(date.slice(5, 7))} 月` }
|
||||
export function displayDate(date: string) { return `${Number(date.slice(5, 7))} 月 ${Number(date.slice(8, 10))} 日` }
|
||||
export function duration(minutes: number) {
|
||||
const hours = Math.floor(minutes / 60), rest = minutes % 60
|
||||
return hours ? `${hours} 小时${rest ? ` ${rest} 分钟` : ''}` : `${rest} 分钟`
|
||||
}
|
||||
export function validDate(date: string) {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(date) && date >= '0001-01-01' &&
|
||||
!Number.isNaN(parseDate(date).getTime()) && dateString(parseDate(date)) === date
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
|
||||
// Exercise the exported client against the platform network/storage seam. No
|
||||
// frontend E2E framework or production authentication bypass is introduced.
|
||||
function client(network, options = {}) {
|
||||
const storage = new Map()
|
||||
let loginNumber = 0
|
||||
const taro = {
|
||||
login: async () => ({ code: `login-${++loginNumber}` }),
|
||||
getStorageSync: key => storage.get(key),
|
||||
setStorageSync: (key, value) => storage.set(key, value),
|
||||
removeStorageSync: key => { if (options.storageRemovalFails) throw new Error('storage unavailable'); storage.delete(key) },
|
||||
request: network,
|
||||
}
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/services/practice.ts'), 'utf8')
|
||||
const js = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true } }).outputText
|
||||
const exports = {}
|
||||
vm.runInNewContext(js, { exports, require: name => { assert.equal(name, '@tarojs/taro'); return taro }, __API_BASE_URL__: 'https://local-test.invalid' })
|
||||
return exports
|
||||
}
|
||||
const ok = data => ({ statusCode: 200, data })
|
||||
const session = token => ok({ token, expiresAt: '2099-01-01T00:00:00Z' })
|
||||
const failure = (status, code) => ({ statusCode: status, data: { error: { code, message: code } } })
|
||||
|
||||
// Same public API calls as the pages: a renewed token cannot change a submitted
|
||||
// form or create a fresh operation, including when platform storage removal fails.
|
||||
test('expired sessions renew and retain the exact submission', async () => {
|
||||
let exchange = 0
|
||||
const payloads = []
|
||||
const service = client(async request => {
|
||||
if (request.url.endsWith('/session')) return session(`token-${++exchange}`)
|
||||
payloads.push(request.data)
|
||||
if (request.header.Authorization === 'Bearer token-1') return failure(401, 'unauthorized')
|
||||
return ok({ id: 'record-1' })
|
||||
}, { storageRemovalFails: true })
|
||||
const input = { projectId: 'p', date: '2026-09-28', minutes: 90, note: '', requestId: 'same-submission-1' }
|
||||
assert.equal((await service.api.saveRecord(input)).id, 'record-1')
|
||||
assert.equal(exchange, 2)
|
||||
assert.deepEqual(payloads, [input, input])
|
||||
})
|
||||
|
||||
test('uncertain save stays recoverable and an explicit retry keeps its key', async () => {
|
||||
let lost = true
|
||||
const payloads = []
|
||||
const service = client(async request => {
|
||||
if (request.url.endsWith('/session')) return session('token')
|
||||
payloads.push(request.data)
|
||||
if (lost) { lost = false; throw new Error('response lost') }
|
||||
return ok({ id: 'record-1' })
|
||||
})
|
||||
const pending = { input: { projectId: 'p', date: '2026-09-28', minutes: 90, note: 'keep this', requestId: service.submissionID() } }
|
||||
service.retainPendingRecord(pending)
|
||||
await assert.rejects(service.api.saveRecord(pending.input), error => error.status === 0)
|
||||
const resumed = service.getPendingRecord()
|
||||
assert.equal(resumed, pending)
|
||||
assert.equal((await service.api.saveRecord(resumed.input)).id, 'record-1')
|
||||
assert.equal(payloads[0].requestId, payloads[1].requestId)
|
||||
service.clearPendingRecord(resumed)
|
||||
assert.equal(service.getPendingRecord(), undefined)
|
||||
assert.notEqual(service.submissionID(), pending.input.requestId)
|
||||
})
|
||||
|
||||
test('failed loads stay errors and only confirmed writes notify visible pages', async () => {
|
||||
let failWrite = true
|
||||
const service = client(async request => {
|
||||
if (request.url.endsWith('/session')) return session('token')
|
||||
if (request.method === 'GET') return failure(503, 'service_unavailable')
|
||||
if (failWrite) { failWrite = false; return failure(503, 'service_unavailable') }
|
||||
return ok({ id: 'record-1' })
|
||||
})
|
||||
let changes = 0
|
||||
const stop = service.onPracticeChange(() => { changes++ })
|
||||
await assert.rejects(service.api.records(), error => error.status === 503)
|
||||
await assert.rejects(service.api.saveRecord({}), error => error.status === 503)
|
||||
assert.equal(changes, 0)
|
||||
await service.api.saveRecord({})
|
||||
assert.equal(changes, 1)
|
||||
stop()
|
||||
})
|
||||
Reference in New Issue
Block a user