Appearance
模块手册 · 运动健康域(sport-health)
收录 6 个模块的权威开发手册(源自各模块
CLAUDE.md,随仓库同步)。
本文目录
- sport
- training
- goal
- stats
- shoes
- upload
1. sport 模块
源:
src/app/sport/CLAUDE.md(随仓库同步)
src/app/sport/
sport 模块 — V1 运动打卡 + 跑群 11 use case 迁移(V2 DDD 4 层 + InMemory + 行为兼容)。
模块职责
sport 承接 C 端运动打卡 + 跑群域(对齐 V1 server/src/modules/sport/),路由风格 = action 协议:
- 入口:
POST /api/v1/sport+body {action, payload}(V1POST /api/sport路径平移,协议 100% 兼容) - 11 action:
today/checkin/myStats/myGroups/createGroup/joinGroup/quitGroup/groupRanking/groupDetail/groupMembers/announceGroup - 鉴权:整条路由不 public(V1 同款 —
Authorization: Bearer <access_token>必需) - 错误码区段:40418xxx(与 suunto 40417xxx / upload 40419xxx 相邻隔离)
- ⚠️ 2026-09-16 撞码治理时从
40413xxx迁出(该段现属 group_buy)
- ⚠️ 2026-09-16 撞码治理时从
- HTTP 状态:默认 400 / 401 / 403 / 404 / 409(V1 行为 100% 兼容 — conflict → 409)
Checkin 表是全项目核心聚合源(V1 注解:被 stats/goal/family/feed/shoes/ranking/weekly-report 7 个模块复用)— 本模块负责写入,goal 读路径经桥接适配器。
DDD 4 层结构
src/app/sport/
├── domain/ # 零 web 依赖
│ ├── entities/sport.py # Checkin / Group / GroupMember + 用户/群主/榜单投影
│ ├── services/
│ │ ├── sport_calc.py # todayCN / periodSince(JS setMonth 溢出语义)/ cnMonthRange / js_round
│ │ ├── pace.py # V1 pace.util.ts 逐行移植(Riegel / Daniels VDOT / PB 识别)
│ │ └── track.py # V1 track.util.ts 逐行移植(Haversine / 抽稀 / 分段 / sanitize)
│ └── exceptions.py # SportErrorCode (40418xxx) + cz_sport_error_to_http_status
│
├── application/
│ ├── ports/
│ │ ├── repositories.py # Checkin / Group(群+成员合一)/ User 三端口
│ │ └── services.py # AccessTokenDecoder / Config / Weather / ShoeMileage /
│ │ # Ludong / GoalAchievement / Notification 七端口
│ ├── usecase/
│ │ ├── sport_checkin.py # today / checkin / myStats(3)+ cz_validate_checkin_input
│ │ ├── sport_groups.py # myGroups / createGroup / joinGroup / quitGroup(4)
│ │ └── sport_group_detail.py # groupRanking / groupDetail / groupMembers / announceGroup(4)
│ └── factories/create_sport_usecases.py
│
├── infrastructure/
│ ├── persistence/inmemory_sport_repositories.py # 8 组 InMemory 实现 + 种子
│ └── adapters/goal_achievement_adapter.py # goal 桥接(真实现 — 见下)
└── interface/{api/sport_actions.py, handlers.py}对外接口(11 action 一览)
| action | payload | 返回(data) | 说明 |
|---|---|---|---|
today | — | {date, done, checkin|null} | 今日打卡状态(V1 Cache.wrap 60s → V2 不模拟) |
checkin | CheckinInput | {points, todayDone, date} | 防作弊 + 积分事务 + 跑鞋里程 + 律动 outbox + goal 达成检测 |
myStats | {period}(默认 month) | {totalDistance, count, avgPace, period} | Cache.wrap 60s → V2 不模拟 |
myGroups | — | {groups: [{id,name,memberCount,role,joinedAt}]} | joinedAt desc |
createGroup | {name} 1-32 | {group: {...role:'owner'}} | 会员等级上限校验 |
joinGroup | {groupId, opengid?} | {ok: true} | opengid 首次绑定(不覆盖) |
quitGroup | {groupId} | {ok: true} | 群主不可退 |
groupRanking | {groupId, period}(默认 week) | {groupId, period, members, champion, totals} | top 50;鉴权在缓存外 → V2 鉴权先置 |
groupDetail | {groupId} | 群卡 + owner + announce + summary | 活跃天数 = distinct date 数 |
groupMembers | {groupId} | {members: [...monthDistance]} | 本月跑量降序(joinedAt asc 原序稳定) |
announceGroup | {groupId, announce?} | {ok: true} | 仅 owner;空串/缺省 = 清空 |
三方对齐:
routes switch↔sport_actions.py分发 ↔create_sport_usecasesdict — 11 action 全覆盖。
关键行为约束(V1 对拍)
防作弊三件套(V1 01 审查 P1-1/P1-2 + 02 §5.3)
distance服务端校验[0.5, 50]— 文案逐字:distance: 距离至少 0.5 km/distance: 距离最多 50 km(防作弊)- 前端传
points字段直接忽略(CzCheckinInput.points存而不读) - 同日同 user 限 1 次(
cz_find_today→今日已打卡,请明天再来409) - 两段校验:pydantic 类型层(
.strict()→extra="forbid")+cz_validate_checkin_input域校验层(V1 route parseOrBadRequest + service 二次 parse 同构;自定义戳文案单点维护)
checkin 事务五件套(V1 V0.1.26 扩)
1. 写 Checkin(sportRepo.checkinInTx — sportType/dataSource 接受但不落库,V1 同款)
2. user.addPoints(points, 'checkin')(默认 InMemory 落账 + 流水快照)
3. 跑鞋里程累计(incrementShoeKm — shoeId 空跳过)
4. 律动 outbox('checkin.batch' — LUDONG_SYNC_ENABLED 开关控制,默认关)
(事务外)5. 缓存失效(V2 不模拟)+ goal 达成检测(失败不阻塞 — try/catch 静默)⚠️ InMemory 事务等价(V1 $transaction):端口方法按事务参与者粒度切分、顺序执行 (asyncio 单线程无并发交错);生产 Prisma 阶段恢复真事务包裹。
JS 数值 / 时间语义(不可用 Python 原生)
points = floor(distance × perKm)(perKm 默认 1 — V1 POINTS_RULES_DEFAULT)cz_js_round=floor(v×10^d + 0.5)/10^d(62.5 → 63 — 非银行家舍入)periodSince:month/year复刻 JS setMonth/setFullYear 溢出滚动(3/31 − 1 月 → 3/3 非 2/28)todayCN= UTC + 8h 历日;cnMonthRange= CN 本月[start, end)- myStats
avgPace:avgPace ? round(v,1) : null— 0 为 falsy → null(JS 同款) - groupMembers 排序:
round(monthDistance,2)后稳定降序(并列保持 joinedAt asc)
会员等级群上限(V1 V0.1.42 #7)
memberLevels[user.memberLevel].maxGroups ?? 2(free=2 / monthly=5 / quarterly=8 / yearly=15)- createGroup / joinGroup 同一上限两路径;文案:
你当前可加入/创建 N 个群,升级会员可加更多(403)
goal 达成检测桥接(生产即真实现)
- V1 checkin 步骤 6:
goalService.detectAndMarkJustAchieved+notifyGoalAchieved(失败静默) - V2 装配:
SimpleSportGoalAchievementAdapter(CzDetectAndMarkJustAchievedUseCase(goal_repo, SimpleSportGoalCheckinAdapter(sport_checkins)))— goal 真 use case + sport 打卡存储桥接 (V1 同一张 Checkin 表的 InMemory 等价;生产 Prisma 共库后等价于 goal 侧直读 checkins 表) - 达成标记落共享 goal store(goal.list 可见 completed);通知文案逐字:
🎯 目标「title」已达成!{target}km(target 为整数浮点时按 JS${number}格式化:5.0 → '5')
缓存差异(V1 → V2)
V1 给 today/myStats/groupRanking 接 Cache.wrap 60s + checkin 写后精准失效(today 精确 + myStats/groupRanking/weeklyReport pattern)。V2 InMemory 阶段不模拟缓存(与 shoes/training/goal/wallet 同决策 — 外部语义仅剩 ≤60s staleness);生产按需经端口接入。
序列化口径差异(V1 → V2 — 已接受的全局换算)
- 时间字段:V1
toISOString()(UTC 带 Z)→ V2 naive 本地isoformat()(显示层等价) - payload 校验错误文案:V1 zod 英文 message → V2 pydantic message(自定义戳/业务文案除外 — 距离/配速/所有业务错误逐字对齐 V1);
.strict()→extra="forbid"语义等价文案不同
未迁移项(标注)
pace.util.ts/track.util.ts:V1 消费方为 run-summary job / 前端 GPS 链路(无 action 暴露)— 纯函数已随模块迁移(domain/services/pace.py + track.py),生产接线时直接复用- V1
assertNotBanned(admin.service 跨模块):V2 以is_banned投影 + 403 文案承接 - V1 限流 / 缓存:见上(生产阶段接入)
测试与质量
| 文件 | 范围 |
|---|---|
tests/integration/test_sport_actions.py | 11 action E2E(含 400/401/403/404/409 全错误分支 + goal 桥接 E2E) |
tests/unit/application/test_sport_usecases.py | 11 use case + checkin 全分支(天气/里程/outbox/goal/通知静默) |
tests/unit/domain/test_sport_calc.py | JS 数值 / 东八区日期 / 周期(含 setMonth 溢出) |
tests/unit/domain/test_sport_pace_track.py | Riegel / VDOT(Daniels 校准)/ PB / Haversine / 抽稀 / 分段 |
bash
PYTHONPATH=src python3 -m pytest tests/unit/application/test_sport_usecases.py \
tests/unit/domain/test_sport_calc.py tests/unit/domain/test_sport_pace_track.py \
tests/integration/test_sport_actions.py -q相关文件
src/app/sport/ ← 本文件所在模块
tests/integration/test_sport_actions.py
tests/unit/application/test_sport_usecases.py
tests/unit/domain/{test_sport_calc.py, test_sport_pace_track.py}变更记录 (Changelog)
| 日期 (Asia/Shanghai) | 版本 | 变更 |
|---|---|---|
| 2026-09-16 | 1.0.0 | 首版 — V1 运动打卡 + 跑群 11 use case 迁移(DDD 4 层 + 40413xxx + action 协议;goal 达成检测真桥接 + pace/track 纯函数随迁) |
2. training 模块
源:
src/app/training/CLAUDE.md(随仓库同步)
src/app/training/
training 模块 — V1 训练中心 6 use case 迁移(V2 DDD 4 层 + InMemory + 行为兼容)。
模块职责
training 承接 C 端训练计划域(对齐 V1 server/src/modules/training/),路由风格 = action 协议:
- 入口:
POST /api/v1/training+body {action, payload}(V1POST /api/training路径平移) - 6 action:
myPlans/mySportRecords/joinPlan/myActivePlan/leavePlan/getPlanWeeklyProgress - 鉴权:全部需已登录(
Authorization: Bearer <access_token>) - 错误码区段:40406xxx(joinPlan 两个错误分支;其余 action 无错误分支)
- HTTP 状态:默认 400 / 401 / 404(V1 行为 100% 兼容)
DDD 4 层结构
src/app/training/
├── domain/ # 零 web 依赖
│ ├── entities/training_plan.py # TrainingPlan + PlanEnrollment(kind: running|strength)
│ ├── services/pace.py # cz_calc_pace(V1 device.schema.calcPace 逐行)
│ ├── services/progress.py # cz_running_progress / cz_strength_progress(JS round)
│ └── exceptions.py # TrainingErrorCode (40406xxx)
│
├── application/
│ ├── ports/
│ │ ├── repositories.py # TrainingPlan / Enrollment / RunCheckin /
│ │ │ # ImportedRunningActivity / StrengthSession 五端口
│ │ └── services.py # AccessTokenDecoderPort
│ ├── usecase/
│ │ ├── plan_mgmt.py # myPlans / joinPlan / myActivePlan / leavePlan /
│ │ │ # getPlanWeeklyProgress(5)
│ │ └── sport_records.py # mySportRecords(1)
│ └── factories/create_training_usecases.py
│
├── infrastructure/persistence/inmemory_training_repositories.py
└── interface/{api/training_actions.py, handlers.py}对外接口(6 action 一览)
| action | payload | 返回(data) | 说明 |
|---|---|---|---|
myPlans | {kind?} | { plans: [...] } | active 计划;kind ∈ {running, strength} 过滤;排序 kind asc + weeks asc |
mySportRecords | {limit?} | { records, summary } | Checkin(run) + RawActivity(garmin) 聚合去重;limit 1-50 默认 10 |
joinPlan | {planId} | { id, planId, planName, joinedAt } | 不存在 404 '计划不存在' / archived 400 '计划已下架';upsert 1 人 1 活跃 |
myActivePlan | — | { plan, joinedAt, daysJoined, ...进度 } | 无 enrollment → { plan: null };running 跑量 / strength 场次 |
leavePlan | — | { ok: true } | deleteMany 幂等 |
getPlanWeeklyProgress | — | { planId, ..., weeks: [...] } 或 null | 仅 kind=strength 有效;perWeek = ceil(targetSessions / weeks) |
关键行为约束(V1 对拍)
myPlans 输出字段
id / key / name / weeks / level / goal / desc / weeklyMileage / targetKm / kind (不含 targetSessions — V1 map 口径;myActivePlan 的 plan 子对象才含)
kind-aware 进度(V0.2.128/129)
- running:
Checkin(run).sum(distance) since joinedAt→ percent = min(100, round(cur/target*100)) - strength:
StrengthSession容量 + 场次 → percent = min(100, round(sessions/targetSessions*100)); 出口含targetKm: 0 - 无 enrollment:精确返回
{ plan: null }(use case 出口为 dict — 三形态动态字段)
mySportRecords 去重
- 佳明活动导入会生成 Checkin(
importCheckinId关联)→ 同一运动只保留 RawActivity - 两源各多取 limit×2(补齐去重损耗)
- date 输出 "YYYY-MM-DD HH:MM";pace 失配返回 null(V1 calcPace)
- summary 按去重后 top N 计算(totalRuns / totalDistanceKm / avgDistanceKm)
周切片(getPlanWeeklyProgress / V0.2.133)
- week N (1-based) 范围 =
[joinedAt + (N-1)×7d, joinedAt + N×7d) - 越界场次忽略;currentWeek = clamp(floor((now-joinedAt)/7d)+1, 1, weeks)
缓存差异(V1 → V2)
V1:myPlans / myActivePlan 120s + mySportRecords 60s(纯性能层)。 V2 InMemory 阶段不模拟缓存(与 shoes 同决策)。
未迁移项(标注)
V1 detectAndMarkPlanCompleted(V0.2.129 力量计划达成检测 — 由 strength.finishSession 调用的内部协作接口)不在本批 6 action 范围;V2 strength 模块迁移时按需迁入。
测试与质量
| 文件 | 范围 |
|---|---|
tests/integration/test_training_actions.py | 6 action E2E(含错误分支) |
tests/unit/application/test_training_usecases.py | 6 use case 单元 |
tests/unit/domain/test_training_pace_progress.py | calcPace / 进度纯函数 |
相关文件
src/app/training/ ← 本文件所在模块
tests/integration/test_training_actions.py
tests/unit/{application/test_training_usecases.py, domain/test_training_pace_progress.py}变更记录 (Changelog)
| 日期 (Asia/Shanghai) | 版本 | 变更 |
|---|---|---|
| 2026-09-16 | 1.0.0 | 首版 — V1 训练中心 6 use case 迁移(DDD 4 层 + 40406xxx + action 协议) |
3. goal 模块
源:
src/app/goal/CLAUDE.md(随仓库同步)
src/app/goal/
goal 模块 — V1 目标系统 14 use case 迁移(V2 DDD 4 层 + InMemory + 行为兼容)。
模块职责
goal 承接 C 端跑步/健康目标域(对齐 V1 server/src/modules/goal/),路由风格 = action 协议:
- 入口:
POST /api/v1/goal+body {action, payload}(V1POST /api/goal路径平移,协议 100% 兼容) - 14 action:
list/add/remove/pauseGoal/resumeGoal/myProgress/addFamilyGoal/myFamilyGoals/addCustomMilestone/removeCustomMilestone/listCustomMilestones/checkMilestoneAchievement/updateProgress/recommend - 2 达成钩子(无 action 路由 — 工厂已装配):
detect_and_mark_just_achieved/detect_and_mark_strength_just_achieved(V1 sport.checkin / strength.finishSession 调用点) - 鉴权:全部需已登录(
Authorization: Bearer <access_token>— access token 解码桥接 oauth token 服务) - 错误码区段:40407xxx(与 shoes 40405xxx / training 40406xxx 相邻隔离)
- HTTP 状态:默认 400 / 401 / 403 / 404 / 409(V1 行为 100% 兼容 — conflict → 409)
DDD 4 层结构
src/app/goal/
├── domain/ # 零 web 依赖
│ ├── entities/goal.py # Goal + CzCustomMilestone(8 kind / 4 status / 3 type)
│ ├── services/goal_calc.py # 纯函数:JS round / 周期计算 / CN 日期范围 / 进度判定
│ └── exceptions.py # GoalErrorCode (40407xxx) + cz_goal_error_to_http_status
│
├── application/
│ ├── ports/
│ │ ├── repositories.py # Goal / GoalUser / GoalCheckin / GoalStrength /
│ │ │ # GoalHealthMetrics / GoalFamily 六端口
│ │ └── services.py # AccessTokenDecoderPort(结构同 user/shoes 端口)
│ ├── usecase/
│ │ ├── goal_progress.py # 共享进度 helper(calcGoalProgress/calcHealth/compute 三层对拍)
│ │ ├── goal_crud.py # list / add / remove / pauseGoal / resumeGoal /
│ │ │ # myProgress / updateProgress(7)
│ │ ├── family_goals.py # addFamilyGoal / myFamilyGoals(2)
│ │ ├── custom_milestones.py # add / remove / list / check 里程碑(4)
│ │ ├── recommend.py # 画像规则引擎 8 规则(1)
│ │ └── goal_achievement.py # 达成钩子 2(无 action 路由)
│ └── factories/create_goal_usecases.py
│
├── infrastructure/persistence/inmemory_goal_repositories.py # 六端口 InMemory + 种子
└── interface/{api/goal_actions.py, handlers.py}对外接口(14 action 一览)
| action | payload | 返回(data) | 说明 |
|---|---|---|---|
list | — | { goals: [...进度] } | 个人目标(familyId=null 全状态)status asc + createdAt desc |
add | AddGoalInput | { id } | type 自动算周期;custom 必传 periodStart/End;kind 三形态校验 |
remove | {id} | { ok: true } | 硬删(个人/家庭通用;'goal not found' 404) |
pauseGoal | {id} | { ok, status: 'paused' } | 仅 active 可暂停(V0.3.9) |
resumeGoal | {id} | { ok, status: 'active' } | 仅 paused 可恢复 |
myProgress | — | { goals: [...active] } | 仅 active 个人目标 + createdAt desc |
addFamilyGoal | +familyId | { id } | 成员鉴权('未加入家庭' 404 / '无权操作此家庭' 403) |
myFamilyGoals | — | { goals: [...进度] } | 无家庭 {goals: []};进度按成员 userIds 聚合 |
addCustomMilestone | {km,title,icon?} | { milestone, achievement } | 上限 20 → km 唯一;写回 + 即时达成查询 |
removeCustomMilestone | {km} | { ok: true } | '里程碑不存在' 404 |
listCustomMilestones | — | { milestones: [...] } | 按 km 升序 |
checkMilestoneAchievement | {km} | { km,title,icon,achieved,currentKm,achievedAt } | achievedAt = 累计扫描首个达标 Checkin 日期 |
updateProgress | {goalId,currentValue} | { ok, currentValue } | 仅 health kind(其余 'kind=X 目标进度由系统自动计算' 400) |
recommend | — | { recommendations, profile } | 画像 8 规则(V0.3.16;无 DB 写) |
关键行为约束(V1 对拍)
进度三形态(动态 dict — 字段集不同,键名即 V1 camelCase 契约)
- distance:
targetDistance / targetVolume(null) / currentDistance / percent / completed - volume:
targetDistance(0 占位) / targetVolume / currentVolume / ... - health(6 kind):
targetValue / currentValue / unit / judgeCriteria / percent / completed
list / myProgress 附带 expired(periodEnd < now 且未完成)+ suggestedTarget(V0.3.8 降难度); myFamilyGoals 直调 calcGoalProgress — 无 expired/suggestedTarget(V1 口径差异,逐位对齐)。
达成判定
- distance/volume:
completed = current >= target;percent =min(100, round(cur/target*100)) - health 降指标(weight_loss/mood/sugar/dampness):
current ≤ target;升指标(weight_gain/sleep):current ≥ target - 达成自动 completed:detect 钩子(V0.2.121/124)把「刚刚达成」目标 status 改 completed — 算法
before = after - 本次增量,before < target && after >= target;仅 period 含今天的 active 个人目标;V2 sport/strength 模块迁移时调用(本批无 action 路由)
周期计算(V1 getUTCMonth 边界语义)
- monthly = 「UTC 月」1 号 → 下月 1 号(东八区 0 点边界 — V1
Date.UTC(...) - 8h); 东八区 1 号 0-8 点属 UTC 上月 → 周期取上月(V1 生产行为逐位复刻) - yearly = 本 UTC 年 1/1 → 明年 1/1;custom = 手传(缺 → 'custom 类型需提供 periodStart/End' 400)
cnDateRange:周期 datetime → Checkin.date "YYYY-MM-DD" 字符串范围(gte 含 / lt 不含)
JS 数值语义(不可用 Python round — 银行家舍入不同)
percent / currentKm = floor(v + 0.5)(62.5 → 63)currentDistance/Volume/Value、bmi、suggestedTarget = floor(v * 10 + 0.5) / 10(1 位小数)profile.monthlyVolumeKg = floor(v + 0.5)(整数 — 与 monthlyDistanceKm 1 位小数不同口径)
V1 ?? 语义复刻(suggestedTarget volume 怪癖)
result.targetDistance ?? result.targetVolume ?? 0 — volume 目标 targetDistance=0(占位非 nullish) → 恒取 0 → 过期 volume 目标 suggestedTarget = 0(看似有违直觉,属 V1 生产行为,逐位复刻并有单测锁定)。
自定义里程碑(V1 User.customMilestones Json — 零新表)
- 走 GoalUserPort(读 + 整体覆盖写);校验链顺序:user 存在 → 上限 20(先) → km 唯一(后)
- 已满 20 时加重复 km → 报「上限」而非「已存在」(V1 顺序,单测锁定)
- check:总跑量(全量 sum)+ achievedAt(date asc 累计扫描首个 ≥ km 的 Checkin 日期)
family 集成(family 模块未迁 V2)
GoalFamilyPort(最小端口:cz_find_member / cz_list_member_ids)+ InMemory 实现模拟 family 成员语义(1 人 1 家庭 — V1 familyMember.userId @unique)- family 模块迁移时接真实现(生产 Prisma 阶段共读 familyMembers 表); V1 Family 删除级联删家庭目标(onDelete: Cascade)由 DB 约束承载(InMemory 无级联)
- addFamilyGoal 不写 kind/health 字段(Prisma @default("distance") 语义 — V2 显式置 distance)
缓存差异(V1 → V2)
V1 给 list / myProgress 接了 Cache.wrap 120s(TTL 自然过期,无写后失效)。 V2 InMemory 阶段不模拟缓存(与 shoes/training 同决策 — 外部语义仅剩 ≤120s staleness)。
序列化口径差异(V1 → V2 — 已接受的全局换算)
- 时间字段:V1
toISOString()(UTC 带 Z)→ V2 naive 本地 isoformat(全局口径,显示层等价) - 可选键:V1 JS
undefined键被 JSON.stringify 丢弃 → V2 dataclass 序列化输出null(JS falsy 等价;recommendations 为 dict — 保持 V1 精确键集不受影响)
未迁移项(标注)
- V1
detectAndMarkJustAchieved/detectAndMarkStrengthJustAchieved的调用方 (sport.service / strength.service)未迁 — 钩子实现已在工厂装配(detect_and_mark_*), sport/strength 模块迁移时注入调用 - V1 goal 无 celery job(侦察确认 —
src/jobs/无 goal 任务;达成检测为同步钩子)
测试与质量
| 文件 | 范围 |
|---|---|
tests/integration/test_goal_actions.py | 14 action E2E(含 400/401/403/404/409 错误分支) |
tests/unit/application/test_goal_usecases.py | 14 action + 2 钩子单元(recommend 冻结时间注入) |
tests/unit/domain/test_goal_calc.py | JS 数值 / 周期(含 UTC 月边界)/ 进度判定纯函数 |
bash
PYTHONPATH=src python3 -m pytest tests/unit/application/test_goal_usecases.py \
tests/unit/domain/test_goal_calc.py tests/integration/test_goal_actions.py -q相关文件
src/app/goal/ ← 本文件所在模块
tests/integration/test_goal_actions.py
tests/unit/{application/test_goal_usecases.py, domain/test_goal_calc.py}变更记录 (Changelog)
| 日期 (Asia/Shanghai) | 版本 | 变更 |
|---|---|---|
| 2026-09-16 | 1.0.0 | 首版 — V1 目标系统 14 use case 迁移(DDD 4 层 + 40407xxx + action 协议;含 2 达成钩子 + family 最小端口) |
4. stats 模块
源:
src/app/stats/CLAUDE.md(随仓库同步)
src/app/stats/
V1 跑者数据中心读模型 — 三旗舰(跑者汇总/年度报告/证书)+ 天气 + 热量引擎 + 埋点。纯读,零写表。
模块职责
聚合打卡(sport/数据包源)→ 生成跑者汇总、年度报告、证书(动态生成零建表)、今日热量环。 数据来源单一(Checkin),V1 缓存 120s 不模拟(先例同款)。
已迁移 action(7/15 — 其余 9 个依赖未迁模块,见依赖图)
| action | 鉴权 | 说明 |
|---|---|---|
myRunnerStats | 登录 | 年/月/总跑量 + 打卡 + avgPace "m:ss"(cz_js_round 语义) |
myAnnualReport | 登录 | 年汇总 + 12 月分布 + 最长单次 + 活跃天数(round1) |
myCertificates | 登录 | 里程碑(100/500/1000/3000km)+ 连续打卡(7/30/100)+ 赛事 |
weather | 登录 | 和风 API;无 KEY 走 stub(长沙晴 25°C 逐字);icon emoji 映射 |
getTodayOverview | 登录 | 今日聚合「1 API 拉全」(热量环 + 打卡 + 画像完整度) |
calorieBalance | 登录 | 热量收支(BMR Mifflin-St Jeor / TDEE×1.3 / 缺口 400 / 下限 1500·1200) |
track | 登录 | 埋点批量上报(≤50 条 + eventId 去重) |
延后 action(9 个 — 依赖未迁模块,模块迁移时接线)
| action | 依赖 |
|---|---|
| healthScore | 步数/心率/睡眠(设备线数据) |
| dailyReport / dailyReportList | AI 简报 + MQTT 推送 + 报告表 |
| weatherAir | 和风空气质量端点 |
| weatherAnalysis | Checkin weatherTemp/humidity 快照字段 |
| userProfile | BodyComp 聚合(device 共享域可接) |
| weightTrend | BodyComp 趋势 |
| getUnifiedOverview / getDailyTrainingOverview | 多源聚合(enrollment/training 接线后) |
| calorieBalance 深度版 | strength 模块力量分钟 |
DDD 4 层结构
src/app/stats/
├── domain/services/stats_calc.py # 纯函数:avgPace "m:ss" / cz_js_round / round1 /
│ # 里程碑+连击证书 / BMR 引擎 / cn_today / icon 映射
├── application/ports/repositories.py # StatsCheckinPort / StatsEnrollmentPort /
│ # StatsMealPort / StatsProfilePort / StatsTrackPort
├── application/usecase/ # stats_runner(三旗舰)/ stats_weather /
│ # stats_today(热量引擎+埋点)
├── infrastructure/ # InMemory 仓储 ×5 + 种子(跨年/连击/画像缺失兜底)
└── interface/api/stats_actions.py # POST /api/v1/stats action 分发(全部需登录)错误码段
40422xxx(PARAM_INVALID 001→400 / UNAUTHORIZED 002→401 / NOT_FOUND 004→404 / INSUFFICIENT_DATA 005→200)— 全表见 docs/ai-contexts/error-code-registry.md。
热量引擎公式(V1 逐式对拍)
BMR (Mifflin-St Jeor) = 10w + 6.25h − 5a + (male:+5 / female:−161 / unknown:−78)
TDEE = BMR × 1.3 预算 = TDEE − 400(下限 male 1500 / female·unknown 1200)
跑步消耗 = weight × km × 1.036 力量消耗 = 0.105 × weight × 分钟
画像缺失 → 默认 30 岁/165cm/60kg,profileComplete:false 引导补全变更记录
| 日期 | 内容 |
|---|---|
| 2026-09-17 | 首版:7/15 action 迁移(三旗舰 + weather stub + 热量引擎 + 埋点);9 action 随 food/sleep/AI 模块迁移接线 |
5. shoes 模块
源:
src/app/shoes/CLAUDE.md(随仓库同步)
src/app/shoes/
shoes 模块 — V1 鞋库 9 use case 迁移(V2 DDD 4 层 + InMemory + 行为兼容)。
模块职责
shoes 承接 C 端跑鞋管理域(对齐 V1 server/src/modules/shoes/),路由风格 = action 协议:
- 入口:
POST /api/v1/shoes+body {action, payload}(V1POST /api/shoes路径平移,协议 100% 兼容) - 9 action:
list/add/update/retire/myStats/getDetail/getMileageHistory/updateThreshold/compareShoes - 鉴权:全部需已登录(
Authorization: Bearer <access_token>— access token 解码桥接 oauth token 服务) - 错误码区段:40405xxx(与 training 40406xxx 相邻隔离)
- HTTP 状态:非 admin「默认 200」口径 —— 默认 400 / 401 / 404(V1 行为 100% 兼容)
DDD 4 层结构
src/app/shoes/
├── domain/ # 零 web 依赖
│ ├── entities/shoe.py # Shoe 实体 + cz_health_ratio()(JS round 语义)
│ ├── services/mileage.py # 纯函数:单位分流 / ISO 周分桶 / JS 数值语义
│ └── exceptions.py # ShoeErrorCode (40405xxx) + cz_shoe_error_to_http_status
│
├── application/
│ ├── ports/
│ │ ├── repositories.py # ShoeRepositoryPort / ShoeCheckinQueryPort
│ │ └── services.py # AccessTokenDecoderPort(结构同 user/device)
│ ├── usecase/
│ │ ├── shoe_crud.py # list / add / update / retire / myStats(5)
│ │ └── shoe_detail.py # getDetail / getMileageHistory / updateThreshold / compareShoes(4)
│ └── factories/create_shoe_usecases.py
│
├── infrastructure/persistence/inmemory_shoe_repositories.py
└── interface/{api/shoe_actions.py, handlers.py}对外接口(9 action 一览)
| action | payload | 返回(data) | 说明 |
|---|---|---|---|
list | — | { shoes: [{ healthRatio, ... }] } | active 在前 + createdAt desc;healthRatio 后端算 |
add | {brand, model, nickname?, thresholdKm?, purchasedAt?, note?} | { id, brand, model } | thresholdKm 100-2000 默认 800 |
update | {id, brand, model, ...} | { id } | 全字段替换(nickname/purchasedAt/note 缺省重置;thresholdKm 缺省回落 800) |
retire | {id} | { ok: true } | 已退役 → 40405005 '跑鞋已退役'(不可逆) |
myStats | — | { total, activeCount, retiredCount, totalKm, retiringSoonCount } | retiringSoon = active 且 healthRatio ≥ 70% |
getDetail | {id} | ShoeDetail | + totalCheckins / latestCheckinAt / daysSincePurchase |
getMileageHistory | {id} | { weekly, monthly, totalKm, totalCheckins } | garmin cm→km 单位分流;distance>0 过滤 |
updateThreshold | {id, thresholdKm} | { id, thresholdKm } | 单字段原子更新 |
compareShoes | {ids: [a, b]} | { shoes: [...] } | 限 2 双;非本人 → 40405007 |
关键行为约束(V1 对拍)
JS 数值语义(不可用 Python round — 银行家舍入不同)
healthRatio = floor(currentKm / thresholdKm * 100 + 0.5)(900/800 → 112.5 → 113)totalKm / 分桶 distanceKm = floor(v * 10 + 0.5) / 10(1 位小数)- ISO 周 key:周四锚 +
ceil((days + 1) / 7)(与 user.cz_iso_week_key 同款公式)
Checkin.distance 单位混用(V1 V0.1.133 最坑)
dataSource='garmin'→ cm →/100000转 km- 其他(sport/manual)→ km 直通
缓存差异(V1 → V2)
V1 给 list / myStats 接了 Cache.wrap 120s(TTL 自然过期,无写后失效)。 V2 InMemory 阶段不模拟缓存(无 IO 成本 — 外部语义差异仅 ≤120s staleness); 生产 Prisma 阶段按需经端口接入。
测试与质量
| 文件 | 范围 |
|---|---|
tests/integration/test_shoe_actions.py | 9 action E2E(含错误分支) |
tests/unit/application/test_shoe_usecases.py | 9 use case 单元 |
tests/unit/domain/test_shoe_mileage.py | 单位分流 / 分桶 / JS 数值语义 |
相关文件
src/app/shoes/ ← 本文件所在模块
tests/integration/test_shoe_actions.py
tests/unit/{application/test_shoe_usecases.py, domain/test_shoe_mileage.py}变更记录 (Changelog)
| 日期 (Asia/Shanghai) | 版本 | 变更 |
|---|---|---|
| 2026-09-16 | 1.0.0 | 首版 — V1 鞋库 9 use case 迁移(DDD 4 层 + 40405xxx + action 协议) |
6. upload 模块
源:
src/app/upload/CLAUDE.md(随仓库同步)
src/app/upload/
upload 模块 — V1 文件上传(COS 混合模式)+ UploadRecord 异步解析 pipeline 迁移(DDD 4 层 + InMemory)。
模块职责
upload 接收小程序文件上传(multipart),统一接入腾讯云 COS 对象存储 + 本地 fallback 混合模式,并承载数据包/截图上传后的 UploadRecord 状态机(pending → parsing → parsed/failed) 异步解析 pipeline(对齐 V1 server/src/modules/upload/ + jobs/upload-parse.job.ts)。
- 入口(V1 路径平移 — 纯 multipart 非 action 协议):
POST /api/v1/upload?type={type}&localFallback={0|1}(multipart 字段file)POST /api/v1/upload/records(body{page?}— myUploads)
- 鉴权:全部路由需登录(V1 同款 —
Authorization: Bearer <access_token>) - 错误码区段:40419xxx(与 sport 40418xxx / feed 40420xxx 相邻隔离)
- ⚠️ 2026-09-16 撞码治理时从
40414xxx迁出(该段现属 distribution)
- ⚠️ 2026-09-16 撞码治理时从
- HTTP 状态:400 / 401 / 404 / 413(V1:badRequest / unauthorized / notFound / fileSize 超限 413)
DDD 4 层结构
src/app/upload/
├── domain/ # 零 web 依赖
│ ├── entities/upload_record.py # UploadRecord + 状态机常量(pending/parsing/parsed/failed)
│ ├── services/upload_rules.py # MAX_SIZE / ALLOWED_MIME / RECORD_TYPES / mimeToExt /
│ │ # sanitize_type / object_key_from_url
│ └── exceptions.py # UploadErrorCode (40419xxx) + cz_upload_error_to_http_status
│
├── application/
│ ├── ports/
│ │ ├── repositories.py # UploadRecordRepositoryPort(create/分页/按 id 查/状态流转)
│ │ └── services.py # AccessTokenDecoder / ObjectStorage(COS+本地)/
│ │ # ParserRegistry / ParserFn / ParseQueue 五端口
│ ├── usecase/
│ │ ├── upload_file.py # CzUploadFileUseCase(混合派发 — V1 uploadFile)
│ │ ├── upload_records.py # createUploadRecord / myUploads / getUpload(3)
│ │ └── upload_parse.py # CzProcessUploadParseUseCase(worker — 状态机 + 30s 超时)
│ └── factories/create_upload_usecases.py
│
├── infrastructure/persistence/inmemory_upload_repositories.py
│ # 记录仓储 + COS/本地存储 + 解析器注册表 + 解析队列(5 组 InMemory + 种子)
└── interface/{api/upload_actions.py, handlers.py}API 一览
| 路由 | 入参 | 返回(data) | 说明 |
|---|---|---|---|
POST /api/v1/upload | multipart file + ?type= + ?localFallback=1 + header x-upload-password | {url, size, mime, source, uploadRecordId?} | source=cos|local |
POST /api/v1/upload/records | {page?} | {items, total, page, pageSize:20} | createdAt desc;item 含 status/parsedResult/errorMsg |
关键行为约束(V1 对拍)
COS 混合模式(V0.1.149 — 核心)
| 触发条件 | 行为 |
|---|---|
| COS 配置齐全 | 走 COS putObject → COS URL(优先 CDN 域名 cos-cdn.qingmulife.cn) |
配置缺失(cz_is_configured()=False) | 静默走本地 uploads/{type}/... |
| COS putObject 运行时抛错 | 静默 fallback 本地(韧性优先,不抛错 — 上传链路 100% 可用) |
?localFallback=1 | 强制本地(应急/调试) |
- object key 规则:
{type}/{userId}-{ts}-{8chars}{ext}(ext 优先 filename 推断,兜底 mimeToExt — octet-stream 数据包靠 filename.fit) - type 净化:
/^[a-z0-9_-]{1,32}$/(下划线合法)→ 不符兜底misc(防 path traversal) - MIME 白名单:jpeg/png/webp/zip/octet-stream(50MB 上限 — V0.1.150 数据包扩展)
UploadRecord 状态机 + 异步解析 pipeline(V0.1.150)
- 建记录时机:仅
source=cos且type ∈ RECORD_TYPES(7 数据包/截图 type)— 本地 fallback / 图片不建 - createUploadRecord:有 parser →
status=pending+ 入队;无 parser →status=parsed不入队(留底审计) - worker(process_upload_parse):
- 记录缺失 → 抛错(触发队列重试)
status=parsed→ 幂等直接返回({ok: true})- 无 parser →
failed+errorMsg='no parser for type X'({ok: false}) - 正常 →
parsing→ 拉取对象 → parser 解析(30s 超时保护 — 超时文案parser 超时(30s))→parsed+ parsedResult + errorMsg 清空 - 解析抛错 →
failed+ errorMsg + 向上抛(队列重试 attempts=2 — 接真实现时机)
- 幂等键:入队 jobId=
parse-{recordId}(同 record 多次入队只跑一次 — InMemory 去重等价)
接入时机标注(InMemory → 生产)
| 能力 | InMemory 现状 | 生产接真实现时机 |
|---|---|---|
| COS 存储(put/get) | 进程内对象记账(可注入 configured/fail 测分支) | 腾讯云 COS SDK(四环境变量 + CDN) |
| 本地存储 | 进程内记账(URL 格式同 V1) | 落盘 uploads/{type}/ + static 暴露 |
| 解析器(7 数据包 type) | 标注 stub(summary 明示「未迁 V2」) | device 模块解析链路迁移时注册真实现 |
| 解析队列 | 进程内待办(cz_run_pending 驱动) | BullMQ → Celery(jobId 幂等 + attempts=2) |
| 路由限流(5/min/user) | 未逐路由模拟 | 全局限流体系承接 |
multipart 解析(不引入新依赖决策)
V1 用 fastify busboy;V2 不引入 python-multipart(FastAPI File()/UploadFile 依赖它)—— 路由用 stdlib email BytesParser 解析 multipart/form-data 单 file 字段 (filename/mimetype/buffer 三件套 + 二进制往返测试锁定;无 Content-Type 的 part → application/octet-stream busboy 缺省口径)。
差异(V1 → V2 — 已接受)
- 超限响应:V1 fastify 413
FST_REQ_FILE_TOO_LARGE→ V2 413 + 业务码 40419005 + service 层文案file too large: n > max(语义对齐) - records 空 body:V1 fastify 对空 JSON body 报 400 → V2 宽容
page缺省(?? {}宽松口径) page非正整数 → 钳制为 1(V1 Prisma skip 负数会 500 — V2 显式化)- 时间字段/序列化:同全局口径(naive isoformat)
测试与质量
| 文件 | 范围 |
|---|---|
tests/integration/test_upload_actions.py | 双路由 E2E(鉴权/multipart/混合模式/记录/分页 + pipeline drain) |
tests/unit/application/test_upload_usecases.py | 5 use case 全分支(派发/fallback/状态机/超时/重抛) |
tests/unit/domain/test_upload_domain.py | 规则常量 / mimeToExt / type 净化 / URL→objectKey |
bash
PYTHONPATH=src python3 -m pytest tests/unit/application/test_upload_usecases.py \
tests/unit/domain/test_upload_domain.py tests/integration/test_upload_actions.py -q相关文件
src/app/upload/ ← 本文件所在模块
tests/integration/test_upload_actions.py
tests/unit/application/test_upload_usecases.py
tests/unit/domain/test_upload_domain.py变更记录 (Changelog)
| 日期 (Asia/Shanghai) | 版本 | 变更 |
|---|---|---|
| 2026-09-16 | 1.0.0 | 首版 — V1 文件上传(COS 混合模式)+ UploadRecord 状态机异步解析 pipeline 迁移(DDD 4 层 + 40414xxx + stdlib multipart 解析) |