---
url: /docs/modules/sport-health.md
---
# 模块手册 · 运动健康域（sport-health）

> 收录 **6** 个模块的权威开发手册（源自各模块 `CLAUDE.md`，随仓库同步）。

## 本文目录

* 1. sport
* 2. training
* 3. goal
* 4. stats
* 5. shoes
* 6. 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 一览）

| 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_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.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}`（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 一览）

| 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}`（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 一览）

| 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}`（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 一览）

| 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**）
* 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）**：
  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.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 解析） |
