---
url: /docs/modules/trade-assets.md
---
# 模块手册 · 交易与资产域（trade-assets）

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

## 本文目录

* 1. wallet
* 2. points
* 3. coupon
* 4. cart
* 5. mall
* 6. group\_buy
* 7. distribution

***

## 1. wallet 模块

> 源：`src/app/wallet/CLAUDE.md`（随仓库同步）

## src/app/wallet/

> wallet 模块 — V1 钱包/流水/充值 3 use case 迁移（V2 DDD 4 层 + InMemory + 行为兼容），L2-A 资产域。

### 模块职责

`wallet` 承接 C 端钱包域（对齐 V1 `server/src/modules/wallet/`），**路由风格 = action 协议**：

* 入口：`POST /api/v1/wallet` + `body {action, payload}`（V1 `POST /api/wallet` 路径平移，协议 100% 兼容）
* 3 action：`get` / `transactions` / `recharge`（recharge = V1.0 强制 featureDisabled('payment')）
* 1 内部钩子（**无 action 路由** — 工厂已装配）：`consume_in_tx`（V1 `walletService.consumeInTx` 对拍）
* 鉴权：全部需已登录（`Authorization: Bearer <access_token>`）
* 错误码区段：**40408xxx**（与 goal 40407xxx / points 40409xxx 相邻隔离）
* HTTP 状态：400 / 401 / 403 / 404（V1 行为 100% 兼容）

### DDD 4 层结构

```
src/app/wallet/
├── domain/                          # 零 web 依赖
│   ├── entities/wallet.py           # Wallet / WalletTransaction（Decimal 金额 + 运行时多态 type）
│   ├── services/money.py            # cz_js_decimal_string（decimal.js toString 口径）+ cz_quantize_money
│   └── exceptions.py                # WalletErrorCode (40408xxx) + cz_wallet_error_to_http_status
│
├── application/
│   ├── ports/{repositories.py, services.py}   # WalletRepositoryPort + AccessTokenDecoderPort
│   ├── usecase/
│   │   ├── wallet_queries.py        # get / transactions（2）
│   │   ├── wallet_recharge.py       # recharge（1 — 恒 featureDisabled）
│   │   └── wallet_consume.py        # consume_in_tx（1 — 内部钩子，无路由）
│   └── factories/create_wallet_usecases.py
│
├── infrastructure/persistence/inmemory_wallet_repositories.py   # InMemory + 种子
└── interface/{api/wallet_actions.py, handlers.py}
```

### 对外接口（3 action 一览）

| action | payload | 返回（data） | 说明 |
|---|---|---|---|
| `get` | — | `{ balance: "100.5", status, updatedAt }` | 自动建空钱包；balance decimal.js 字符串 |
| `transactions` | `{page?, pageSize?}` | `{ list: [...], total, page, pageSize }` | 分页（page≥1 默认 1 / pageSize 1-50 默认 20）；amount 字符串 |
| `recharge` | `{amount, payChannel?}` | — | **恒 403** `功能「payment」尚未开通`（V1.0 口径） |

内部钩子 `consume_in_tx`（无路由）：`(user_id, amount, type_, order_id?, wx_transaction_id?, allow_negative?, out_refund_no?)`
→ 钱包不存在 `wallet not found`(404) / 冻结 `wallet frozen`(403) / 余额不足 `余额不足`(400) / 正常原子增减 + 流水落库。

### 关键行为约束（V1 对拍）

#### 金额精度（跨模块范式）

* balance / amount 一律 **Decimal**（禁止 float 传递金额）
* 序列化出口 `cz_js_decimal_string`（decimal.js `toString()` 口径：去尾随零 / 非指数形式）
  * `Decimal('100.50')` → `'100.5'`；`Decimal('10.00')` → `'10'`
* 余额写库前 `cz_quantize_money`（PG numeric(10,2) 语义 — round half away from zero）

#### 原子 increment（V1 asTransactionClient 对拍 — 02 §6 铁律）

* 普通扣减（amount<0 且非 allowNegative）：**条件原子扣减**（V1 `updateMany where balance >= -amount`）
  — 未命中即余额不足（原子，无 TOCTOU）；InMemory 单方法内 check+update（无 await 间隙）
* 充值（amount>0）/ 退款（allowNegative）：**无条件原子增减**（V1 `update increment`）
* **绝不能**「读出 balance → 算新值 → 覆盖写」（并发 lost update）

#### 退款 allowNegative 范式（V1 P0-2）

微信退款不可逆 → 本地如实记账（余额可为负 = 用户欠款）；**绝不能**因余额不足回滚（账实漂移）。
调用方 = refund.service（type='refund'）；其他扣款场景禁止传 allow\_negative。

#### ensureWallet 幂等（get / transactions 前置）

V1 findUnique → 不存在 create；**故意不用 upsert**（并发由 userId @unique 兜底 fail-fast）。

#### feature gate 双层守门

V1 外层 `requireFeature: 'wallet'` 为**部署态 flag 中间件** — V2 InMemory 阶段不模拟（与缓存
不模拟同决策）；内层 recharge 的 `featureDisabled('payment')` 保留（防 flag 误开 — V1 设计意图）。

#### WalletTransaction.type 运行时多态

V1 schema 为 String 无 enum（recharge/consume/refund + commission/withdraw/content\_enroll 等扩展）——
V2 不硬编码白名单（与 V1 一致）；新增 type 遵守 V1 约定。

### 与 V1 行为差异（已接受）

| 项 | V1 | V2 |
|---|---|---|
| feature gate（wallet=false → 全 403） | 部署 flag 中间件 | 不模拟（InMemory 阶段无部署态）；recharge 内层 403 保留 |
| 时间序列化 | `toISOString()`（UTC 带 Z） | naive 本地 isoformat（全局换算 — 显示层等价） |
| 可选键 null | JS `undefined` 键被 JSON.stringify 丢弃 | 输出 `null`（JS falsy 等价） |
| consumeInTx 事务 | tx 参与者（跨表强一致） | InMemory 顺序编排（生产 Prisma 阶段恢复真事务） |

### 未迁移项（标注）

* V1 `consumeInTx` 的**调用方**（wxpay.notify / mall refund / distribution / content.enroll /
  admin.approveWithdrawal）未迁 — 钩子实现已在工厂装配（`consume_in_tx`），对应模块迁移时注入调用
* V1.1 recharge 接 cloudPay（支付未开通 — 本批不涉及）

### 测试与质量

| 文件 | 范围 |
|---|---|
| `tests/integration/test_wallet_actions.py` | 3 action E2E（鉴权 / 分页 / featureDisabled / payload 校验） |
| `tests/unit/application/test_wallet_usecases.py` | 3 action + consume\_in\_tx 全分支（含金额精度 / PG 量化 / 走负退款） |
| `tests/unit/domain/test_wallet_money.py` | decimal.js toString / PG numeric 量化纯函数 |

```bash
PYTHONPATH=src python3 -m pytest tests/integration/test_wallet_actions.py \
  tests/unit/application/test_wallet_usecases.py tests/unit/domain/test_wallet_money.py -q
```

### 相关文件

```
src/app/wallet/                     ← 本文件所在模块
tests/{integration/test_wallet_actions.py, unit/application/test_wallet_usecases.py, unit/domain/test_wallet_money.py}
```

### 变更记录 (Changelog)

| 日期 (Asia/Shanghai) | 版本 | 变更 |
| --- | --- | --- |
| 2026-09-16 | 1.0.0 | 首版 — V1 wallet 3 use case + consumeInTx 钩子迁移（DDD 4 层 + 40408xxx + action 协议 + Decimal 金额口径） |

***

## 2. points 模块

> 源：`src/app/points/CLAUDE.md`（随仓库同步）

## src/app/points/

> points 模块 — V1 积分中心 4 use case 迁移（V2 DDD 4 层 + InMemory + 行为兼容），L2-A 资产域。

### 模块职责

`points` 承接 C 端积分域（对齐 V1 `server/src/modules/points/`），**路由风格 = action 协议**：

* 入口：`POST /api/v1/points` + `body {action, payload}`（V1 `POST /api/points` 路径平移，协议 100% 兼容）
* 4 action：`myBalance` / `signin` / `myTasks` / `awardShare`（V0.2.6 分享得积分）
* 鉴权：全部需已登录（`Authorization: Bearer <access_token>`）
* 错误码区段：**40409xxx**（与 wallet 40408xxx / coupon 40410xxx 相邻隔离）
* HTTP 状态：400 / 401 / 404（V1 行为 100% 兼容）

### DDD 4 层结构

```
src/app/points/
├── domain/                          # 零 web 依赖
│   ├── entities/points.py           # PointsRecord / SigninRecord + 签到/分享常量 + TASKS（4 任务）
│   ├── services/cn_dates.py         # todayCN / yesterdayCN / monthStart（东八区语义）
│   └── exceptions.py                # PointsErrorCode (40409xxx) + cz_points_error_to_http_status
│
├── application/
│   ├── ports/{repositories.py, services.py}
│   │     # PointsUserPort / PointsRecordRepositoryPort / SigninRecordRepositoryPort /
│   │     # PointsOrderQueryPort（mall 未迁 — 最小端口）/ ShareCounterPort + AccessTokenDecoderPort
│   ├── usecase/points_center.py     # myBalance / signin / myTasks / awardShare（4）
│   └── factories/create_points_usecases.py
│
├── infrastructure/persistence/inmemory_points_repositories.py   # 五端口 InMemory + 种子
└── interface/{api/points_actions.py, handlers.py}
```

### 对外接口（4 action 一览）

| action | payload | 返回（data） | 说明 |
|---|---|---|---|
| `myBalance` | — | `{ balance, todaySigned, continuousDays, records: [...20] }` | 余额 + 今日签到 + 连续天数 + 近期流水 |
| `signin` | — | `{ ok, pointsAwarded, continuousDays, newBalance, bonus }` | 防重 + 连续 7 天 +50 |
| `myTasks` | — | `{ tasks: [...4] }` | 静态 4 任务 + 动态 done（签到/本月订单） |
| `awardShare` | — | `{ awarded, todayCount, quota }` | 日限 3 次 = 15 分（防刷） |

⚠️ V1 points 路由**不解析 payload**（4 action 均无入参 — SigninInputSchema 定义但未使用）→ V2 同。

### 关键行为约束（V1 对拍）

#### 签到规则（常量逐字）

* 基础 `BASE_SIGNIN_POINTS=10` / 天；连续 7 天 `CONTINUOUS_7D_BONUS=50`（`continuousDays % 7 === 0`）
* 连续天数 = 昨日记录 continuous + 1（无昨日 → 1；今日已签 → `今日已签到` 400）
* 副作用三件套（V1 事务）：user.points 覆盖写 + signinRecord.create + pointsRecord.create（balance 快照）

#### myBalance 短路语义

今日已签 → 取今日 continuous；**不查昨日**（V1 findUnique 仅 1 次的断言锁定 — 单测 spy 验证）。

#### 东八区日期（cn\_dates.py）

* `todayCN = (now + 8h) 按 UTC 截断`（与服务器时区无关）；边界 UTC 16:00 = 东八区次日 00:00
* 签到防重 key = `userId_date`（@@unique 复合）；V2 InMemory 前置防重 + create 兜底 fail-fast

#### 分享日限（V0.2.6 — Redis 计数）

* key = `share:pt:{userId}:{todayCN()}`；`incr` + 首次 `expire 86400`（逐字对拍）
* `cnt > 3` → `{awarded: false, todayCount: cnt}`（仍返回自增后计数，不发分）
* 发分 = `addPoints(5, 'share')`（原子 increment + ACCUMULATE 累计 totalPointsEarned）+ PointsRecord 快照
* ⚠️ 计数器是**业务限流**非缓存 — 必须模拟（与 Cache.wrap 不模拟的决策区分）

#### myTasks 月窗口

* 订单计数 `createdAt >= 当月 1 号 00:00`（naive 本地口径）
* done：signin = 今日已签；purchase/order = 本月订单 > 0（status ∈ paid/shipped/done）

### 与 V1 行为差异（已接受）

| 项 | V1 | V2 |
|---|---|---|
| signin 无用户行 | P2025 未捕获（500） | 显式 404 `用户不存在`（40409004 — 显式化） |
| addPoints 流水写入 | userRepo.addPoints 内置 pointsRecord.create | port 主自增（返回余额）+ 本模块 records 写（user 模块无流水存储；快照等价） |
| 月窗口时区 | 服务器本地（Docker=UTC） | naive 本地口径（差异 = 仅月初 ≤8h 窗口的展示性 done 标记） |
| 时间序列化 | `toISOString()`（UTC 带 Z） | naive 本地 isoformat（全局换算） |

### 未迁移项（标注）

* `PointsOrderQueryPort`（myTasks 月订单）— **mall 未迁 V2** 最小端口 + InMemory；mall 迁移时接真实现
* V1 signin 的调用集成方（mall 订单完成 +30 / content 报名送分 / 首登 +100 由 user 模块承接）— 对应模块迁移时经 PointsUserPort 接入
* V1 signin 接 Cache 无（实时读）— 无缓存差异

### 测试与质量

| 文件 | 范围 |
|---|---|
| `tests/integration/test_points_actions.py` | 4 action E2E（鉴权 / 签到防重 / 分享日限 / 任务 done） |
| `tests/unit/application/test_points_usecases.py` | 4 action 全分支（含短路 spy / 流水快照链 / 计数器 TTL） |
| `tests/unit/domain/test_points_cn_dates.py` | 东八区日期纯函数（UTC 16:00 边界 / 跨月） |

```bash
PYTHONPATH=src python3 -m pytest tests/integration/test_points_actions.py \
  tests/unit/application/test_points_usecases.py tests/unit/domain/test_points_cn_dates.py -q
```

### 相关文件

```
src/app/points/                     ← 本文件所在模块
tests/{integration/test_points_actions.py, unit/application/test_points_usecases.py, unit/domain/test_points_cn_dates.py}
```

### 变更记录 (Changelog)

| 日期 (Asia/Shanghai) | 版本 | 变更 |
| --- | --- | --- |
| 2026-09-16 | 1.0.0 | 首版 — V1 points 4 use case 迁移（DDD 4 层 + 40409xxx + action 协议 + 分享日限计数器） |

***

## 3. coupon 模块

> 源：`src/app/coupon/CLAUDE.md`（随仓库同步）

## src/app/coupon/

> coupon 模块 — V1 优惠券 4 use case 迁移（V2 DDD 4 层 + InMemory + 行为兼容），L2-A 资产域。

### 模块职责

`coupon` 承接 C 端优惠券域（对齐 V1 `server/src/modules/coupon/`），**路由风格 = action 协议**：

* 入口：`POST /api/v1/coupon` + `body {action, payload}`（V1 `POST /api/coupon` 路径平移，协议 100% 兼容）
* 4 action：`templates` / `myCoupons` / `availableCount` / `receive`
* 鉴权：全部需已登录（`Authorization: Bearer <access_token>`）
* 错误码区段：**40410xxx**（与 points 40409xxx / cart 40411xxx 相邻隔离）
* HTTP 状态：400 / 401 / 404（V1 行为 100% 兼容）

### DDD 4 层结构

```
src/app/coupon/
├── domain/                          # 零 web 依赖
│   ├── entities/coupon.py           # Coupon / CouponTemplate + COUPON_TEMPLATES（4 套常量）
│   └── exceptions.py                # CouponErrorCode (40410xxx) + cz_coupon_error_to_http_status
│
├── application/
│   ├── ports/{repositories.py, services.py}   # CouponRepositoryPort + AccessTokenDecoderPort
│   ├── usecase/coupon_center.py     # templates / myCoupons / availableCount / receive（4）
│   └── factories/create_coupon_usecases.py
│
├── infrastructure/persistence/inmemory_coupon_repositories.py   # InMemory + 种子
└── interface/{api/coupon_actions.py, handlers.py}
```

### 对外接口（4 action 一览）

| action | payload | 返回（data） | 说明 |
|---|---|---|---|
| `templates` | — | `{ templates: [...4 + received] }` | 领券中心（fixed amount 整数字面量 — JSON `10`） |
| `myCoupons` | `{status?}` | `{ list: [...], count }` | 状态过滤（unused/used/expired/all）+ 懒标过期 |
| `availableCount` | — | `{ count }` | mine 红点（unused 计数） |
| `receive` | `{templateId}` | `{ id, expireAt }` | 同 title 防重 |

### 关键行为约束（V1 对拍）

#### 模板常量 + 实例表（零新表）

* `COUPON_TEMPLATES` 4 套（newuser-10 / full100-20 / full200-50 / run-9zhe）— service 常量
* 领取 = 从模板复制创建 Coupon 实例（title/type/amount/minSpend/expireAt）
* ⚠️ `Coupon.amount / min_spend` 为 **Float**（V1 schema — 与 wallet/cart 的 Decimal 不同）；
  fixed 模板存**整数字面量**（JSON 输出 `10` 而非 `10.0`）

#### 同 title 防重（非 templateId）

V1 用 `title` 匹配（模板可改 ID 但 title 不变 — 兼容模板扩展）→ `已领取过此券` 400。

#### 懒标过期（无定时任务）

`myCoupons` / `availableCount` 调用前 `markExpired`（unused + expireAt\<now → expired；返回翻转数）。
`count = list.length`（过滤后长度 — V1 口径）。

#### expireAt 计算

`receive`：`now + validDays`（V1 `Date.now() + validDays * 24 * 3600 * 1000` ms 等价）。

### 与 V1 行为差异（已接受）

| 项 | V1 | V2 |
|---|---|---|
| 时间序列化 | `toISOString()`（UTC 带 Z） | naive 本地 isoformat（全局换算） |
| 过期时间基准 | 服务器时间（未跨时区处理 — V1 已知坑） | 同 V1（naive 本地口径） |

### 未迁移项（标注）

* V1 未对接 `mall.createOrder` 券抵扣（GAP-6 二次上线预留）— 本批同 V1（领看不集成下单）
* 模板写死（V1 已知坑 — 后续可建 CouponTemplate 表 + admin CRUD）— 本批同 V1

### 测试与质量

| 文件 | 范围 |
|---|---|
| `tests/integration/test_coupon_actions.py` | 4 action E2E（received 标记 / 懒过期 / 防重 / amount 整数口径） |
| `tests/unit/application/test_coupon_usecases.py` | 4 action 全分支（status 过滤矩阵 / expireAt 区间 / 模板字段逐字） |

```bash
PYTHONPATH=src python3 -m pytest tests/integration/test_coupon_actions.py \
  tests/unit/application/test_coupon_usecases.py -q
```

### 相关文件

```
src/app/coupon/                     ← 本文件所在模块
tests/{integration/test_coupon_actions.py, unit/application/test_coupon_usecases.py}
```

### 变更记录 (Changelog)

| 日期 (Asia/Shanghai) | 版本 | 变更 |
| --- | --- | --- |
| 2026-09-16 | 1.0.0 | 首版 — V1 coupon 4 use case 迁移（DDD 4 层 + 40410xxx + action 协议 + 懒过期范式） |

***

## 4. cart 模块

> 源：`src/app/cart/CLAUDE.md`（随仓库同步）

## src/app/cart/

> cart 模块 — V1 购物车 5 use case 迁移（V2 DDD 4 层 + InMemory + 行为兼容），L2-A 资产域。

### 模块职责

`cart` 承接 C 端购物车域（对齐 V1 `server/src/modules/cart/`），**路由风格 = action 协议**：

* 入口：`POST /api/v1/cart` + `body {action, payload}`（V1 `POST /api/cart` 路径平移，协议 100% 兼容）
* 5 action：`add` / `remove` / `list` / `updateQty` / `clear`
* 鉴权：全部需已登录（`Authorization: Bearer <access_token>`）
* 错误码区段：**40411xxx**（与 coupon 40410xxx 相邻 — L2-A 资产域收官段）
* HTTP 状态：400 / 401 / 404（V1 行为 100% 兼容）

### DDD 4 层结构

```
src/app/cart/
├── domain/                          # 零 web 依赖
│   ├── entities/cart.py             # CartItem + ProductSnapshot（mall 未迁读投影）
│   ├── services/money.py            # cz_js_decimal_string（price）+ cz_js_to_fixed2（totalAmount）
│   └── exceptions.py                # CartErrorCode (40411xxx) + cz_cart_error_to_http_status
│
├── application/
│   ├── ports/{repositories.py, services.py}
│   │     # CartRepositoryPort + CartProductQueryPort（mall 未迁 — 最小端口）+ AccessTokenDecoderPort
│   ├── usecase/cart_crud.py         # add / remove / list / updateQty / clear（5）
│   └── factories/create_cart_usecases.py
│
├── infrastructure/persistence/inmemory_cart_repositories.py   # 双端口 InMemory + 种子
└── interface/{api/cart_actions.py, handlers.py}
```

### 对外接口（5 action 一览）

| action | payload | 返回（data） | 说明 |
|---|---|---|---|
| `add` | `{productId, qty?}` | `{ productId, qty }` | 同商品 qty 累加；商品不存在 404 / 已下架 400 |
| `remove` | `{productId}` | `{ ok, deleted }` | deleteMany 幂等（不存在也 ok） |
| `list` | — | `{ items, totalAmount, count }` | 商品详情 + 合计（恒 2 位小数）+ 件数 |
| `updateQty` | `{productId, qty}` | `{ productId, qty }` 或 `{ ok, deleted }` | `qty<=0` 自动走 remove 路径（V1 `return this.remove`） |
| `clear` | — | `{ ok, deleted }` | 清空（行数） |

### 关键行为约束（V1 对拍）

#### 合计口径（金额精度）

* `totalAmount` = Σ(on 商品 `price × qty`) → `cz_js_to_fixed2`（V1 `toFixed(2)` — **恒 2 位小数字符串**）
* 下架商品**仍展示**（status/红字提示）但**不计入合计**；`count` = Σqty **全部商品**（V1 reduce 口径）
* `price / originalPrice` 序列化 `cz_js_decimal_string`（decimal.js toString：`Decimal('99.90')` → `'99.9'`）
* 合计为 **Decimal 精确求和**（V1 float 累积误差 ~1e-13 无法跨越 .005 舍入边界 → 2 位小数口径恒等）

#### upsert 合并（加购）

V1 `cart.upsert({create, update: {qty: {increment}}})` — InMemory 单方法内读改写（无 await 间隙 → 原子）。

#### qty<=0 软限流

`updateQty(qty<=0)` 走 remove 路径（返回 `{ok, deleted}` 形态 — V1 行为逐位）。

#### deleteMany 幂等

`remove / clear` 用 deleteMany 而非 delete（不存在也返 ok — 前端无需先查）。

### 与 V1 行为差异（已接受）

| 项 | V1 | V2 |
|---|---|---|
| updateQty 行不存在 | Prisma P2025 未捕获 → 500 | 显式 404 `购物车项不存在`（40411006 — 显式化） |
| Product 数据源 | prisma.product（FK 保证存在） | CartProductQueryPort 最小端口（**mall 迁移时接真实现**；InMemory 防御跳过缺失行） |
| 时间序列化 | `toISOString()`（UTC 带 Z） | naive 本地 isoformat（全局换算） |

### 未迁移项（标注）

* V1 `mall.createOrder` 下单前 `cart.clear(userId)` 集成 — mall 迁移时经 CartRepositoryPort 接入
* 商品读投影（ProductSnapshot）生产 Prisma 阶段共读 products 表（mall 迁移时接真实现）

### 测试与质量

| 文件 | 范围 |
|---|---|
| `tests/integration/test_cart_actions.py` | 5 action E2E（合计 2 位小数 / price 去尾随零 / 下架过滤 / qty=0 路由） |
| `tests/unit/application/test_cart_usecases.py` | 5 action 全分支（合并累加 / 空车 / Decimal 精确合计 / 404 分支） |
| `tests/unit/domain/test_cart_money.py` | decimal.js toString / JS toFixed(2) 纯函数 |

```bash
PYTHONPATH=src python3 -m pytest tests/integration/test_cart_actions.py \
  tests/unit/application/test_cart_usecases.py tests/unit/domain/test_cart_money.py -q
```

### 相关文件

```
src/app/cart/                       ← 本文件所在模块
tests/{integration/test_cart_actions.py, unit/application/test_cart_usecases.py, unit/domain/test_cart_money.py}
```

### 变更记录 (Changelog)

| 日期 (Asia/Shanghai) | 版本 | 变更 |
| --- | --- | --- |
| 2026-09-16 | 1.0.0 | 首版 — V1 cart 5 use case 迁移（DDD 4 层 + 40411xxx + action 协议 + toFixed(2) 合计口径） |

***

## 5. mall 模块

> 源：`src/app/mall/CLAUDE.md`（随仓库同步）

## src/app/mall/

> mall 模块 — V1 商城 6 action + refundOrder 迁移（V2 DDD 4 层 + InMemory + 行为兼容），L2-A 资产域。

### 模块职责

`mall` 承接 C 端商城域（对齐 V1 `server/src/modules/mall/`），**路由风格 = action 协议**：

* 入口：`POST /api/v1/mall` + `body {action, payload}`（V1 `POST /api/mall` 路径平移，协议 100% 兼容）
* 6 action：`listCategories` / `listProducts` / `productDetail`（公开）+ `createOrder` / `myOrders` / `cancelOrder`（需登录）
* 1 应用层方法（**无路由**）：`refundOrder`（V1 refund.service — admin 调用方迁移时注入）
* 鉴权：V1 整 endpoint 标 public，受保护 action 内部 requireLogin（无 token → 401 `未登录`）
* 错误码区段：**40412xxx**（与 cart 40411xxx 相邻隔离）
* HTTP 状态：400 / 401 / 403 / 404 / 409（V1 行为 100% 兼容）

### DDD 4 层结构

```
src/app/mall/
├── domain/                          # 零 web 依赖
│   ├── entities/mall.py             # Product / OrderItem / Order / GroupBuySnapshot / 用户投影 + 状态常量
│   ├── services/order_state.py      # 订单状态机（7 态 — TRANSITIONS 单一权威）
│   ├── services/pickup.py           # generatePickupCode（订单号末 6 位 + 3 位随机）
│   ├── services/money.py            # decimal.js toString / toFixed(2) / round2 / ceilPoints
│   └── exceptions.py                # MallErrorCode (40412xxx) + cz_mall_error_to_http_status
│
├── application/
│   ├── ports/repositories.py        # MallProductRepositoryPort / MallOrderRepositoryPort /
│   │                                #   MallUserPort / GroupBuyQueryPort / DistributionPort
│   ├── ports/services.py            # WxPayPort / PaymentConfigPort / CloseOrderJobPort /
│   │                                #   WalletConsumePort + AccessTokenDecoderPort
│   ├── usecase/mall_catalog.py      # listCategories / listProducts / productDetail（3）
│   ├── usecase/mall_orders.py       # createOrder / myOrders / cancelOrder + refundOrder
│   └── factories/create_mall_usecases.py
│
├── infrastructure/
│   ├── persistence/inmemory_mall_repositories.py  # 商品/订单/用户/团购/分销/支付/配置/关单 + 种子
│   └── adapters/wallet_consume_adapter.py         # SimpleWalletConsumeAdapter（wallet 真实现桥接）
└── interface/{api/mall_actions.py, handlers.py}
```

### 对外接口（6 action + 1 应用层方法）

| action | 鉴权 | payload | 返回（data） |
|---|---|---|---|
| `listCategories` | 公开 | `{includeCount?=false}` | `{categories: [{name, count}]}` |
| `listProducts` | 公开 | `{category?, brand?, keyword?(≤64), page=1, pageSize=20(1..50)}` | `{list, total, page, pageSize}` |
| `productDetail` | 公开 | `{id}` | `{product}`（全字段 spread） |
| `createOrder` | 需登录 | `{items 1..20, address?, pointsUsed?=0, inviteCode?(4..16), groupBuyId?, deliveryType?}` | `{orderId, totalAmount, pointsUsed, payAmount, status, payChannel, payParams, message, pickupCode, pickupExpiresAt}` |
| `myOrders` | 需登录 | `{status?, page=1, pageSize=20}` | `{list, total, page, pageSize}` |
| `cancelOrder` | 需登录 | `{orderId}` | `{ok: true}` |
| `refundOrder`（方法） | — | `{orderId, amountFen?, reason?, refundedBy}` | `{orderId, refundId, refundYuan, status, refundedBy}` |

### 关键行为约束（V1 对拍）

#### createOrder 校验链（**顺序严格不可调换**）

① 团购校验（**最先**，早于商品查询）：不存在 404 → 未成团 400 → 未参与 403 → 多商品 / 非团购商品 400
② 商品查询（status='on'，**按 id 去重** — V1 findMany {in} 语义）→ 数量不等 400 `部分商品已下架`（重复 productId 也命中）
③ 金额合计 Σ(price × qty)（团购场景用 groupPrice 替换）
④ payment flag（V2：PaymentConfigPort 恒 False）
⑤ 用户查询（无 → 401）→ 封禁 403 `账号已被封禁`
⑥ 积分分支：`pointsValue = pointsUsed × 0.01`；≥ total → `pointsUsed=ceil(total/0.01)` + payAmount=0 + status='paid' + payChannel='points'；否则 payAmount=round2(total−pointsValue)（payment ON 才 wxpay）
⑦ inviteCode：inviter 存在且 `inviter.id !== userId` 才落 sourceUserId
⑧ 事务（顺序编排）：order(+items) → 自提码回写 → 扣积分（`-pointsUsed`）→ 分销（commission=round2(payAmount×rate)，仅 >0 落单；team level=1 + 间推 level=2 需 `grandfatherId !== userId`）
⑨ 事务外：unifiedOrder（payment=OFF → payChannel 恒非 wxpay → 跳过）；pending\_pay 时 enqueueCloseOrder

#### message 三态（逐字）

* status='paid'（积分全额）→ `兑换成功，客服会安排发货`
* payChannel='wxpay'（payments ON — 不可达）→ `订单已创建，请完成支付`
* 其余 → `订单已创建，支付功能开通中，客服会联系您`

#### 订单状态机（7 态 — domain/services/order\_state.py 单一权威）

白名单：`pending_pay→[paid, cancelled]` / `paid→[shipped, refunding, refunded]` / `shipped→[done]` / `refunding→[refunded, paid]` / `done|cancelled|refunded→[]`
非法迁移 → 409，msg 逐字 `illegal_state: {from} → {to}`（Unicode 箭头）
cancel 仅 pending\_pay 可取消（paid 必须走 refund — V1 业务收紧）；已扣积分退回（`+pointsUsed`）

#### 自提（deliveryType='pickup'）

`pickupCode = 订单号末 6 位大写 + 3 位随机字符`（字符集 32 = 26 字母去 I/O + 10 数字去 0/1；
⚠️ V1 行内注释写 "28 chars" 为笔误 — 逐字符实测 32）；`pickupExpiresAt = now + 30 天`；订单号回写

#### refundOrder（应用层方法 — V2 当前不可达为"无微信交易号"）

限制：status 必须 `paid`；**须有 wxTransactionId**；`amountFen` 提供则 /100 否则 payAmount；越界 400 `refundYuan ({x}) 必须在 (0, {payAmount}] 范围内`
事务：assertTransition(paid→refunded) → status='refunded'（paidAt 保持）→ wallet consume\_in\_tx（`-refundYuan`, type='refund', **allowNegative=true**）→ clawbackCommission（sourceUserId 存在时）

#### 金额口径（Decimal — 禁止 float）

| 场景 | V1 | V2 |
|---|---|---|
| createOrder totalAmount/payAmount | `toFixed(2)` → 恒 2 位（`"19.90"`） | cz\_js\_to\_fixed2 |
| myOrders / items price | decimal.js `toString()` → **去尾随零**（`"99.9"` / `"1"`） | cz\_js\_decimal\_string |
| 部分抵扣 payAmount / 佣金 | `round2(n) = Math.round(n*100)/100` | cz\_round2（ROUND\_HALF\_UP） |
| 全额兑换积分 | `Math.ceil(total / 0.01)` | cz\_ceil\_points（Decimal 精确） |

### 跨模块接线（mall = 商品/订单唯一数据源）

| 消费方 | 接线方式 |
|---|---|
| cart `InMemoryCartProductAdapter` | 直读 `InMemoryMallProductRepository._products`（mall Product → cart ProductSnapshot 投影） |
| points `InMemoryPointsOrderQueryAdapter` | 直读 `InMemoryMallOrderRepository._orders`（月订单计数过滤） |
| cart `cz_seed_cart_fixtures` | 联动调 `cz_seed_mall_product_fixtures()`（商品种子定义在 mall） |
| points `cz_seed_points_fixtures` | 联动调 `cz_seed_mall_order_fixtures()`（U\_POINTS\_ORDERS/U\_POINTS\_OLD\_ORDERS 订单种子在 mall） |
| mall refundOrder | 经 `SimpleWalletConsumeAdapter` 桥接 wallet `consume_in_tx` 真实现 |

### 最小端口（对应模块未迁 V2 — 生产接入时替换）

| 端口 | InMemory 实现 | 将来接入 |
|---|---|---|
| `GroupBuyQueryPort` | InMemoryGroupBuyAdapter（种子 gb-984001/002） | group-buy 模块迁移 |
| `DistributionPort` | InMemoryDistributionAdapter（levelRate V0-V3 / team / dist order / clawback） | distribution 模块迁移 |
| `WxPayPort` | InMemoryWxPayAdapter（unifiedOrder fail-fast / refund 占位成功） | wxpay 模块迁移 |
| `PaymentConfigPort` | InMemoryPaymentConfigAdapter（恒 False） | app-config 模块迁移 |
| `CloseOrderJobPort` | InMemoryCloseOrderJobAdapter（仅记录） | close-order job 迁移 |
| `MallUserPort` | InMemoryMallUserAdapter（points/封禁/邀请码/分销等级） | user 模块共库 |

### 与 V1 行为差异（已接受）

| 项 | V1 | V2 |
|---|---|---|
| productDetail 缺 id | Zod 报错经 setErrorHandler → **500**（怪癖，V1 测试如此期望） | 400 PARAM\_INVALID（不复刻 500 怪癖） |
| myOrders status 枚举 | Zod 枚举 5 态（refunding/refunded 入参 → 400） | 7 态全集合（对齐状态机；差异 = refunding/refunded 筛选在 V1 是 400） |
| 时间序列化 | `toISOString()`（UTC 带 Z） | naive 本地 isoformat（全局口径） |
| 缓存 | Cache.wrap（listProducts 60s / productDetail 5min / listCategories withCount 60s） | 不模拟（与 goal/shoes/training 同决策） |
| 事务 | Prisma $transaction | InMemory 顺序编排（生产 Prisma 阶段经端口恢复真事务） |
| 退款 refundYuan 类型 | number | Decimal（V2 金额全局口径；admin 接入时经序列化转字符串） |
| wxTransactionId=null 的用户行 | P2025 未捕获（500） | 显式 404 `用户不存在`（显式化，同 points 口径） |
| 自提码字符集注释 | 行内注释 "28 chars"（笔误） | 实际 32 字符（按字符串对齐） |

### 未迁移项 / 待支付接入（标注）

* **refundOrder 生产不可达**：payment=OFF → wxTransactionId 恒无 → 恒抛"订单无微信交易号"；成功路径已实现 + 单测覆盖（预置 wxTransactionId 种子），wxpay 接入后自动可达
* `unifiedOrder` 分支（payChannel='wxpay'）：预留调点，payment 接入后补 prepayId 落库端口 + payParams 组装 + openid 带入
* V1 的 admin 调用方（退款入口 / 发货状态推进）：admin 模块接入时经 `use_cases["refund_order"]` / 状态机调用
* 物流状态推进（shipped/done）：V1 占位无实现，同

### 测试与质量

| 文件 | 范围 |
|---|---|
| `tests/integration/test_mall_actions.py` | 6 action E2E（鉴权 / 团购三错误 / 积分分支 / 状态机 409 / refund 进程内） |
| `tests/unit/application/test_mall_usecases.py` | 6 action + refund 全分支（校验链顺序 / 自提码 / 分销落库条件 / allowNegative） |
| `tests/unit/domain/test_mall_domain.py` | 状态机全白名单 + pickup code + 金额纯函数 |
| `tests/unit/infrastructure/test_mall_inmemory.py` | InMemory 仓储 + 最小端口 + 跨模块接线 |

```bash
PYTHONPATH=src python3 -m pytest tests/integration/test_mall_actions.py \
  tests/unit/application/test_mall_usecases.py tests/unit/domain/test_mall_domain.py \
  tests/unit/infrastructure/test_mall_inmemory.py -q
```

### 相关文件

```
src/app/mall/                       ← 本文件所在模块
src/app/cart/infrastructure/persistence/inmemory_cart_repositories.py    # 商品接线（读 mall）
src/app/points/infrastructure/persistence/inmemory_points_repositories.py  # 订单接线（读 mall）
src/main.py                         # 装配（"── 12. L2-A 资产域（mall …）"段）
```

### 变更记录 (Changelog)

| 日期 (Asia/Shanghai) | 版本 | 变更 |
| --- | --- | --- |
| 2026-09-16 | 1.0.0 | 首版 — V1 商城 6 action + refundOrder 迁移（DDD 4 层 + 40412xxx + 状态机 7 态 + 最小端口 ×5 + cart/points 接线） |

***

## 6. group\_buy 模块

> 源：`src/app/group_buy/CLAUDE.md`（随仓库同步）

## src/app/group\_buy/

> group\_buy 模块 — V1 团购 4 action 迁移（V2 DDD 4 层 + InMemory + 行为兼容），电商域。

### 模块职责

`group_buy` 承接 C 端团购域（对齐 V1 `server/src/modules/group-buy/`），**路由风格 = action 协议**：

* 入口：`POST /api/v1/group-buy` + `body {action, payload}`（V1 `POST /api/group-buy` 路径平移，协议 100% 兼容）
* 4 action：`list` / `detail` / `join` / `myJoined`（**全部需登录** — V1 整 endpoint `if (!req.user)`）
* 鉴权：必选 token（Bearer access token 桥接 oauth）；无/无效 token → 401 `未登录`（**先于 action 分发与 payload parse**）
* 错误码区段：**40413xxx**（与 mall 40412xxx 相邻隔离）
* HTTP 状态：400 / 401 / 404 / 409（V1 行为 100% 兼容）

### DDD 4 层结构

```
src/app/group_buy/
├── domain/                          # 零 web 依赖
│   ├── entities/group_buy.py        # GroupBuy / GroupBuyMember / 商品投影 + 状态常量
│   │                                #   + cz_group_buy_just_reached（成团判定单一权威）
│   ├── services/money.py            # cz_js_decimal_string（decimal.js toString 去尾零）
│   └── exceptions.py                # GroupBuyErrorCode (40413xxx) + cz_group_buy_error_to_http_status
│
├── application/
│   ├── ports/repositories.py        # GroupBuyRepositoryPort / GroupBuyMemberRepositoryPort /
│   │                                #   GroupBuyProductQueryPort
│   ├── ports/services.py            # NotificationPort / AccessTokenDecoderPort
│   ├── usecase/group_buy_actions.py # list / detail / join / myJoined（4）
│   └── factories/create_group_buy_usecases.py
│
├── infrastructure/
│   ├── persistence/inmemory_group_buy_repositories.py  # 团购/参与/商品投影/通知 + 种子
│   └── adapters/mall_group_buy_query_adapter.py        # 实现 mall GroupBuyQueryPort（桥接真实现）
└── interface/{api/group_buy_actions.py, handlers.py}
```

### 对外接口（4 action — 全部需登录）

| action | payload | 返回（data） |
|---|---|---|
| `list` | `{page=1, pageSize=20(1..50)}` | `{list, total, page, pageSize, hasMore}`（仅 active，createdAt desc） |
| `detail` | `{id}` | list 项字段 + `createdAt` + `product.description` |
| `join` | `{id}` | **固定** `{ok: true, joined: true}`（V1 真实返回 — 不增强） |
| `myJoined` | `{page=1, pageSize=20(1..50)}` | `{list, total, page, pageSize, hasMore}`（joinedAt desc） |

list 项字段**恰为 8 项**：`{id, groupPrice(字符串去尾零), targetCount, currentCount, status, endDate(ISO|null), product: {id, name, price, images, status}, isJoined}`

### 关键行为约束（V1 对拍）

#### join 流程（**顺序严格不可调换** — V1 :82-132 逐行）

1. 事务外查团购（无 → 404 `团购不存在`）
2. 显式拦 `status === 'reached'` → 400 `团购已成团`（⚠️ **V1 无 'ended' 状态** — 文档声称有但源码无写入路径，**V2 不引入**；reached 检查先于成员检查）
3. 事务外查成员记录（有 → 409 `已参与该团购`）
4. 事务：member 创建 + `currentCount+1`（**无 status guard**）；`currentCount >= targetCount && status === 'active'` → `status='reached'`（判定函数 `cz_group_buy_just_reached` 单一权威）
5. **事务外**重查：已成团 → 查全部成员 → **循环通知每个成员**（逐条 try/catch 吞错；**加入者本人跳过** — V1 `notify()` 内 `userId === actorId` 语义；内容逐字 `「${productName}」团购已成团，去下单吧！`，type='system'，targetType='groupBuy'，targetId=团购 id，actorId=加入者；商品缺失 fallback `'商品'`）
6. 返回固定体 `{ok: true, joined: true}`

#### 关键语义（务必对齐）

* **成团判定 = 人数**（`currentCount >= targetCount`）— **不是订单数**
* **无订单/支付/库存联动**；退款不减 currentCount；**endDate 从不校验**（仅展示）
* join 返回体固定 `{ok, joined}` — 不"增强"（V1 真实返回比其文档简单）
* 成团通知失败不阻塞 join（逐条 try/catch — V1 :125-127）

#### 金额口径（Decimal — 禁止 float）

`groupPrice.toString()` / `product.price.toString()` → 去尾随零（`'15.00'` → `'15'` / `'79.90'` → `'79.9'`）— `cz_js_decimal_string` 单一权威。

### 跨模块接线

| 方向 | 接线方式 |
|---|---|
| group\_buy 读商品 | `InMemoryGroupBuyProductAdapter` 直读 `InMemoryMallProductRepository._products`（mall = 商品唯一数据源；种子联动调 `cz_seed_mall_product_fixtures()`） |
| mall 读团购 | `MallGroupBuyQueryAdapter`（本模块 `infrastructure/adapters/`）实现 mall 的 `GroupBuyQueryPort` — main.py 装配已从 `InMemoryGroupBuyAdapter`（deprecated 保留）切换为真实现 |

### 最小端口（对应模块未迁 V2 — 生产接入时替换）

| 端口 | InMemory 实现 | 将来接入 |
|---|---|---|
| `NotificationPort` | InMemoryGroupBuyNotificationAdapter（自己触发自己跳过；`_calls` 全量 / `_records` 送达 — 可断言） | notification 模块迁移 |
| `GroupBuyProductQueryPort` | InMemoryGroupBuyProductAdapter（✅ 已接 mall 真实现） | — |

（无用户端口 — V1 group-buy 不读用户行，通知内容无昵称。）

### 与 V1 行为差异（已接受）

| 项 | V1 | V2 |
|---|---|---|
| detail/join 缺 id（payload 非法） | Zod 报错经 setErrorHandler → **500**（怪癖） | 400 PARAM\_INVALID（不复刻 500 怪癖 — 同 mall productDetail 口径） |
| 未知 action 响应体 | `{code: 400, msg}`（非全局信封） | `{code: "40413001", msg: "unknown action: x"}`（V2 信封归一 — msg 逐字） |
| 时间序列化 | `toISOString()`（UTC 带 Z） | naive 本地 isoformat（全局口径） |
| 缓存 | Cache.wrap（list 30s） | 不模拟（与 mall/goal 全局决策） |
| 事务 | Prisma $transaction | InMemory 顺序编排（生产 Prisma 阶段经端口恢复真事务） |
| 商品缺失（FK 不可达） | include 恒有商品；notify fallback '商品' | list/detail/myJoined → product null 直通（防御性）；join 通知 fallback '商品'（同 V1） |

### 测试与质量

| 文件 | 范围 |
|---|---|
| `tests/integration/test_group_buy_actions.py` | 4 action E2E（鉴权全量需登录 / 分页 / join 成团翻转 + 通知断言 / mall 接线回归） |
| `tests/unit/application/test_group_buy_usecases.py` | 4 action 全分支（校验顺序 / 通知跳过 actor / 失败吞错 / fallback / 商品缺失 / 孤儿成员） |
| `tests/unit/domain/test_group_buy_domain.py` | 成团判定边界（< == > / reached 恒 False）+ 金额纯函数 |
| `tests/unit/infrastructure/test_group_buy_inmemory.py` | InMemory 仓储 + 通知适配器 + mall 桥接适配器 + 种子幂等 |

```bash
PYTHONPATH=src python3 -m pytest tests/integration/test_group_buy_actions.py \
  tests/unit/application/test_group_buy_usecases.py tests/unit/domain/test_group_buy_domain.py \
  tests/unit/infrastructure/test_group_buy_inmemory.py -q
```

### 相关文件

```
src/app/group_buy/                  ← 本文件所在模块
src/app/mall/                       # 商品真实现（直读）+ GroupBuyQueryPort 消费方
src/main.py                         # 装配（"── 13. 电商域（group-buy …）"段）
```

### 变更记录 (Changelog)

| 日期 (Asia/Shanghai) | 版本 | 变更 |
| --- | --- | --- |
| 2026-09-16 | 1.0.0 | 首版 — V1 团购 4 action 迁移（DDD 4 层 + 40413xxx + 成团判定域函数 + 通知最小端口 + 商品接 mall 真实现 + mall GroupBuyQueryPort 接线） |

***

## 7. distribution 模块

> 源：`src/app/distribution/CLAUDE.md`（随仓库同步）

## src/app/distribution/

> distribution 模块 — V1 分销中心 8 action 迁移（V2 DDD 4 层 + InMemory + 行为兼容），L2-A 资产域。

### 模块职责

`distribution` 承接 C 端分销域（对齐 V1 `server/src/modules/distribution/`），**路由风格 = action 协议**：

* 入口：`POST /api/v1/distribution` + `body {action, payload}`（V1 `POST /api/distribution` 路径平移，协议 100% 兼容）
* 8 action：`mySummary` / `myOrders` / `myTeam` / `myCommissionLogs` / `myLevel` / `inviteInfo` /
  `withdrawRequest` / `myWithdrawals`（**全部需登录** — V1 整 endpoint `if (!req.user) throw unauthorized`）
* 2 应用层方法（**无路由** — 跨模块触发钩子）：`settle_commission`（V1 settleCommission — **wxpay 迁移时接线**）/
  `clawback_commission`（V1 clawbackCommission — mall refundOrder 经 `MallDistributionAdapter` 调用）
* 鉴权：`Depends(cz_require_login)`（无 token → 401 `未登录`）
* 错误码区段：**40414xxx**（与 mall 40412xxx 相邻隔离）
* HTTP 状态：400 / 401 / 404 / 409（V1 行为 100% 兼容）

**范围**：仅 C 端 8 action。admin 侧团购管理/分销报表另行批次（V2 admin 已有独立提现审批
`admin/withdrawal` — 与本模块 C 端「申请记录」互不影响：V1 语义 pending 不扣余额，
审核通过后 admin 事务内扣减）。

### DDD 4 层结构

```
src/app/distribution/
├── domain/                              # 零 web 依赖
│   ├── entities/distribution.py         # DistributionOrder / Team / CommissionLog / WithdrawalRequest /
│   │                                    #   用户投影 + LEVEL_RULES（V3/V2/V1/V0）+ 状态常量 + note 逐字
│   ├── services/distribution_calc.py    # computeLevel（OR）/ levelRate（严格 ===）/
│   │                                    #   cz_js_round（Math.round 复刻）/ 间推佣金浮点舍入 /
│   │                                    #   cz_fixed2（toFixed(2)）/ 东八区月范围 / 邀请码生成
│   └── exceptions.py                    # DistributionErrorCode (40414xxx) + HTTP 映射
│
├── application/
│   ├── ports/repositories.py            # DistributionOrder / Team / CommissionLog /
│   │                                    #   WithdrawalRequest / DistributionUser / MallOrderRef 六端口
│   ├── ports/services.py                # CommissionWalletPort（钱包记账三步）+ AccessTokenDecoderPort
│   ├── usecase/distribution_queries.py  # 8 action（含 cz_ensure_invite_code 重试链）
│   ├── usecase/commission_settlement.py # settle / settle_indirect / clawback / clawback_indirect
│   └── factories/create_distribution_usecases.py
│
├── infrastructure/
│   ├── persistence/inmemory_distribution_repositories.py  # 五仓储 + 用户投影 + mall 订单投影 + 种子
│   └── adapters/
│       ├── wallet_commission_adapter.py    # SimpleCommissionWalletAdapter（wallet 真仓储桥接）
│       └── mall_distribution_adapter.py    # MallDistributionAdapter（mall DistributionPort 真实现）
└── interface/{api/distribution_actions.py, handlers.py}
```

### 对外接口（8 action）

| action | payload | 返回（data） |
|---|---|---|
| `mySummary` | — | `{inviteCode, level, monthCommission(字符串'x.xx'), monthSales(字符串), orderCount(**全量非本月**)}` |
| `myOrders` | `{page=1, pageSize=20(1..50)}` | `{list, total, page, pageSize}`（**无 hasMore**）；item `{id, orderId, orderAmount, commissionAmount, commissionRate(**Number**), status, settledAt?, createdAt, orderStatus, items: [{name, qty}]}` |
| `myTeam` | `{page, pageSize, level?仅 1/2}` | `{list, directCount, indirectCount, total, page, pageSize}`；item `{id, level, joinedAt, nickname(无则 '跑友'), avatarUrl, inviteeLevel}` |
| `myCommissionLogs` | `{page, pageSize}` | `{list, total, page, pageSize}`；item `{id, amount(**Number**), type, balanceAfter(**Number**), note, createdAt}` |
| `myLevel` | — | `{current, title, rate(**Number**), totalCommission(字符串), teamCount, next?{level, title, needCommission(字符串), needTeam}}` |
| `inviteInfo` | — | `{inviteCode, invitePath: '/pages/index/index?inviteCode=<code>', shareTitle, rules: [5 条]}` |
| `withdrawRequest` | `{amount(数字 ≥10)}` | `{id, amount(**Number**), status, createdAt}` |
| `myWithdrawals` | `{page, pageSize}` | `{list, total, page, pageSize, **hasMore**}`；item `{id, amount, status, reason?, processedAt?, createdAt}` |

### 分销算法（domain/services/distribution\_calc.py — 单一权威）

#### 等级判定 computeLevel（**OR** 条件，从高到低首个命中）

| 等级 | 佣金线 | 团队线 | 直推费率 rate | title |
|---|---|---|---|---|
| V3 | ≥ 2000 | ≥ 50 人 | 0.2 | 王牌分销 |
| V2 | ≥ 500 | ≥ 10 人 | 0.15 | 金牌分销 |
| V1 | ≥ 100 | ≥ 3 人 | 0.1 | 银牌分销 |
| V0 | — | — | 0 | 普通用户 |

`levelRate`：**严格 `===`** — 未匹配 / 大小写异体（'v1'）/ None → 0。

#### 舍入（JS 浮点语义 — **禁止 Python round**）

| 场景 | V1 | V2 |
|---|---|---|
| 间推佣金 | `Math.round(direct × 0.5 × 100) / 100`（IEEE754 double） | cz\_indirect\_commission（float 运算 + `floor(v+0.5)`） |
| Math.round 复刻 | JS half-up | `cz_js_round = math.floor(v + 0.5)`（goal 同款先例） |
| toFixed(2) 输出 | `Number(x).toFixed(2)` 恒 2 位 | cz\_fixed2（Decimal quantize ROUND\_HALF\_UP） |
| 提现入库量化 | PG numeric(10,2) 半进位 | cz\_quantize\_money |

⚠️ **测试锁定怪癖**：`0.01 × 0.5 = 0.005 → ×100 = 0.5 → half-up = 1 分 → 0.01`（V1 deep :385-406）。

#### 邀请码生成（V1 `("0." + random)` base36 风格）

对随机尾数做 base36 展开取前 6 位（精确展开终止即止）→ 大写。重试 5 次抢占
（`updateMany where inviteCode null` count>0），并发冲突重读，5 次全失败 → 兜底
`U + userId 末 5 位大写`。⚠️ 短尾数（如 0.5 → `0.i`）生成 **<6 位**为 V1 潜在缺陷 — 逐位复刻（见差异表）。

#### settle / clawback（commission\_settlement.py）

* **settle**（触发方 = wxpay notify 事务内 — **当前不可达，迁移时接线**）：
  非 pending → 幂等 return；`commission ≤ 0` → 直标 settled+settledAt；否则五步 = 钱包 increment →
  WalletTransaction(commission) → CommissionLog(settle, balanceAfter=last+c, note `订单佣金结算`) →
  distOrder settled+settledAt → 等级重算（**用 balanceAfter**，含间推 — V1 口径）→ 间推结算
* **间推结算**：2-hop grandfather（`team.findByInvitee(直推上线)` — **不看 level**）；幂等（settle\_indirect 存在
  即跳过）；自环 guard；≤0 跳过；grandpa 侧同五步（type `settle_indirect`，note `间推佣金 50%`）
* **clawback**（触发方 = mall refundOrder）：
  不存在/已 cancelled → return；**pending → 置 cancelled → return（不动钱包/不写 log/不触发间推冲红）**；
  settled → 钱包 decrement（**允许负**）→ WalletTransaction(commission\_clawback, -c) →
  CommissionLog(clawback, -c, note `订单退款佣金冲红`) → distOrder cancelled（**settledAt 保持 / 等级不重算**）
  → 间推冲红
* **间推冲红**：无 settle\_indirect → return；已冲红 → 幂等 return；amount = **log.amount（非重算）**；
  grandfather = log.userId；钱包 decrement + WalletTransaction + CommissionLog（note `间推佣金冲红`）；
  **不动 distributionOrder**

### 跨模块接线（distribution = 分销数据唯一权威）

| 消费方/被消费 | 接线方式 |
|---|---|
| mall `DistributionPort` | `MallDistributionAdapter`（main.py §12 装配注入 — 替换旧 `InMemoryDistributionAdapter`，后者保留 deprecated 供 mall 单测） |
| mall createOrder 下单分销 | 经上：落本模块 dist order + **只建 level=1 团队关系**（见差异表 V2 修正） |
| mall refundOrder 佣金冲红 | 经上 → `clawback_commission` use case（完整冲红） |
| 钱包佣金记账 | `SimpleCommissionWalletAdapter` 桥接 wallet 模块**真仓储**（ensure + increment + 流水；**允许负**） |
| myOrders 订单投影 | `InMemoryMallOrderRefAdapter` 直读 mall 真仓储 `InMemoryMallOrderRepository._orders`（单向依赖 — mall 不感知本模块） |
| 用户投影 | `InMemoryDistributionUserAdapter` 自持 store（inviteCode/distributorLevel/nickname/avatarUrl — **user 模块真实现接入时替换**；V2 user 实体无分销字段，生产 Prisma 阶段共读 users 表） |

### 最小端口（其余未迁 V2 — 生产接入时替换）

| 端口 | InMemory 实现 | 将来接入 |
|---|---|---|
| `DistributionUserPort` | InMemoryDistributionUserAdapter（种子 13 profile） | user 模块共库/真实现 |
| `CommissionWalletPort` | SimpleCommissionWalletAdapter（**✅ 已接 wallet 真仓储**） | —（已真实现） |
| `MallOrderRefPort` | InMemoryMallOrderRefAdapter（**✅ 已接 mall 真仓储**） | —（已真实现） |

### 与 V1 行为差异（已接受/已拍板）

| 项 | V1 | V2 |
|---|---|---|
| **Team level=2 间推行**（⚠️ 用户拍板修正） | mall 下单时同建 level=2 行 → 必撞 `inviteeId @unique` → **整个下单事务回滚**（"有上线关系的人下单会失败"缺陷） | **只建 level=1 记录**（`MallDistributionAdapter.cz_create_team` 对 level≠1 静默跳过）；间推照 V1 意图走 grandfather 查询结算（`findByInvitee` 不看 level）— 不再崩溃 |
| **三处口径不一致**（逐位复刻 — 不统一） | ① mySummary 月佣金 = settle+clawback（排除 indirect）② myLevel totalCommission = settle+clawback（排除 indirect）③ settle 等级重算用 balanceAfter（**含**间推） | 同 V1（① ② ③ 各按其道 — 测试锁定） |
| mySummary orderCount | 全量非本月（`count {userId}`） | 同 V1（契约要点注释） |
| 邀请码短码 | `Math.random().toString(36).slice(2,8)` 短尾数 → <6 位（潜在缺陷） | 逐位复刻（不修正 — 低危改进点标注） |
| 时间序列化 | `toISOString()`（UTC 带 Z） | naive 本地 isoformat（全局口径） |
| 缓存 | 无（本模块 V1 无 Cache.wrap） | 不模拟（同源决策） |
| 事务 | Prisma $transaction（wxpay/refund 内嵌） | InMemory 顺序编排（生产 Prisma 阶段经端口恢复真事务包裹） |
| unknown action | Fastify ajv body enum 前置 → 400（ajv msg） | 400 PARAM\_INVALID `unknown action: x`；**且未登录 + unknown → 401**（V2 登录依赖前置 — V1 ajv 会先 400，登录客户端下语义一致） |
| 最低提现金额 400 msg | zod 前置 → `amount: 最低提现金额 10 元`（带前缀） | 业务层裸 msg `最低提现金额 10 元`（V1 service 层同字 — 前缀差异） |
| withdraw amount 字符串数字 | `z.number()` 拒收串 → 400 | pydantic lax 数字串纳入（更宽 — 有效客户端不受影响） |
| myOrders 引用订单缺失 | Prisma FK 保证不可达（理论 NPE 500） | 显式 404 `订单不存在`（显式化决策） |
| Team 重复建关系 | P2002 未捕获 → 500 | 显式 409 `已存在上线关系`（40414008） |
| user.update ghost 用户 | P2025 未捕获 → 500 | 显式 404 `用户不存在`（settle 等级重算路径） |
| settle 可达性 | wxpay notify 触发 | **当前不可达**（payment=OFF → wxpay 未迁移）；实现+单测完整 — **wxpay 迁移时接线** |

### 未迁移项 / 待接线（标注）

* **settle\_commission 生产不可达**：payment=OFF → wxpay notify 未迁移；wxpay 接入后在其 notify 事务内调用 `use_cases["settle_commission"].execute(order_id)` 即自动可达（间推链路随之启用）
* **admin 侧**：团购管理/分销报表为独立批次（本模块只做 C 端 8 action）；C 端提现申请与 V2 admin 提现审批（`src/app/admin/` withdrawal 子域）各自独立存储 — 生产共表时对接
* **邀请码落库耦合**：`ensureInviteCode` 写本模块用户投影 store（mall 侧 `InMemoryMallUserAdapter._invite_codes` 为独立种子）— 生产共库后由 users 表统一

### 测试与质量

| 文件 | 范围 |
|---|---|
| `tests/integration/test_distribution_actions.py` | 8 action E2E（鉴权 401 / payload 400 / 提现三错误分支 / mall createOrder+refundOrder 跨模块接线） |
| `tests/unit/application/test_distribution_usecases.py` | 8 action 全分支（月口径 / desc / level 过滤 / 邀请码重试链 / 提现校验顺序） |
| `tests/unit/application/test_distribution_settlement.py` | settle/clawback 全分支（三态幂等 / 0.005 舍入怪癖 / 间推冲红 amount=log / 允许负 / settledAt 保持） |
| `tests/unit/domain/test_distribution_calc.py` | 等级 OR 边界 + 费率严格匹配 + JS 浮点舍入 + 邀请码 + 月范围 |
| `tests/unit/infrastructure/test_distribution_inmemory.py` | 五仓储 + 钱包桥接 + mall 端口适配器（level=2 跳过） |

```bash
PYTHONPATH=src python3 -m pytest tests/integration/test_distribution_actions.py \
  tests/unit/application/test_distribution_usecases.py \
  tests/unit/application/test_distribution_settlement.py \
  tests/unit/domain/test_distribution_calc.py \
  tests/unit/infrastructure/test_distribution_inmemory.py -q
```

### 种子（集成/单测引用 — inmemory\_distribution\_repositories.py）

* 用户：`U_DIST_A`（V1/3 直推+1 间推/订单链+流水链）/ `U_DIST_FRESH`（无码无级）/
  `U_DIST_TOP`（V3 达顶）/ `U_DIST_RICH|POOR|PEND|NOWALLET`（提现四态）/ `U_DIST_M1..M4`（团队成员）
* 分销订单：`do-9850001`（settled）/ `do-9850002`（pending）/ `do-9850003`（cancelled）/
  `do-9850004`（o-984025 — mall refund 冲红链路）
* 流水：本月 settle 120/30 + 旧 settle\_indirect 75（20 天前）+ 旧 clawback -50（40 天前）
* 提现：`wr-9850001`（approved）/ `wr-9850002`（rejected+reason）/ `wr-9850003`（pending — 409）
* 跨模块：mall 订单基线重放（引用 o-984003/4/5/25）+ 钱包余额种子（RICH 100.50 / POOR 5.00 / PEND 100.00）

### 相关文件

```
src/app/distribution/               ← 本文件所在模块
src/app/mall/application/ports/repositories.py        # DistributionPort（被本模块实现）
src/app/mall/infrastructure/persistence/inmemory_mall_repositories.py  # 旧 InMemoryDistributionAdapter（deprecated 保留）
src/app/wallet/application/ports/repositories.py      # 钱包真仓储（佣金记账对接面）
src/main.py                         # 装配（"分销真实现预装配" + "── 13." 段）
```

### 变更记录 (Changelog)

| 日期 (Asia/Shanghai) | 版本 | 变更 |
| --- | --- | --- |
| 2026-09-16 | 1.0.0 | 首版 — V1 分销中心 8 action + settle/clawback 迁移（DDD 4 层 + 40414xxx + JS 浮点舍入复刻 + mall 端口真实现替换（level=2 修正）+ wallet 真仓储桥接） |
