Skip to content

模块手册 · 运动健康域(sport-health) ​

收录 6 个模块的权威开发手册(源自各模块 CLAUDE.md,随仓库同步)。

本文目录 ​

    1. sport
    1. training
    1. goal
    1. stats
    1. shoes
    1. 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}(V1 POST /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)
  • 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 一览) ​

actionpayload返回(data)说明
today—{date, done, checkin|null}今日打卡状态(V1 Cache.wrap 60s → V2 不模拟)
checkinCheckinInput{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_usecases dict — 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.py11 action E2E(含 400/401/403/404/409 全错误分支 + goal 桥接 E2E)
tests/unit/application/test_sport_usecases.py11 use case + checkin 全分支(天气/里程/outbox/goal/通知静默)
tests/unit/domain/test_sport_calc.pyJS 数值 / 东八区日期 / 周期(含 setMonth 溢出)
tests/unit/domain/test_sport_pace_track.pyRiegel / 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-161.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}(V1 POST /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 一览) ​

actionpayload返回(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.py6 action E2E(含错误分支)
tests/unit/application/test_training_usecases.py6 use case 单元
tests/unit/domain/test_training_pace_progress.pycalcPace / 进度纯函数

相关文件 ​

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-161.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}(V1 POST /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 一览) ​

actionpayload返回(data)说明
list—{ goals: [...进度] }个人目标(familyId=null 全状态)status asc + createdAt desc
addAddGoalInput{ 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.py14 action E2E(含 400/401/403/404/409 错误分支)
tests/unit/application/test_goal_usecases.py14 action + 2 钩子单元(recommend 冻结时间注入)
tests/unit/domain/test_goal_calc.pyJS 数值 / 周期(含 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-161.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 / dailyReportListAI 简报 + MQTT 推送 + 报告表
weatherAir和风空气质量端点
weatherAnalysisCheckin weatherTemp/humidity 快照字段
userProfileBodyComp 聚合(device 共享域可接)
weightTrendBodyComp 趋势
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}(V1 POST /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 一览) ​

actionpayload返回(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.py9 action E2E(含错误分支)
tests/unit/application/test_shoe_usecases.py9 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-161.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)
  • 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/uploadmultipart 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):
    1. 记录缺失 → 抛错(触发队列重试)
    2. status=parsed → 幂等直接返回({ok: true})
    3. 无 parser → failed + errorMsg='no parser for type X'({ok: false})
    4. 正常 → parsing → 拉取对象 → parser 解析(30s 超时保护 — 超时文案 parser 超时(30s))→ parsed + parsedResult + errorMsg 清空
    5. 解析抛错 → 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.py5 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-161.0.0首版 — V1 文件上传(COS 混合模式)+ UploadRecord 状态机异步解析 pipeline 迁移(DDD 4 层 + 40414xxx + stdlib multipart 解析)