Appearance
模块手册 · 交易与资产域(trade-assets)
收录 7 个模块的权威开发手册(源自各模块
CLAUDE.md,随仓库同步)。
本文目录
- wallet
- points
- coupon
- cart
- mall
- group_buy
- 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}(V1POST /api/wallet路径平移,协议 100% 兼容) - 3 action:
get/transactions/recharge(recharge = V1.0 强制 featureDisabled('payment')) - 1 内部钩子(无 action 路由 — 工厂已装配):
consume_in_tx(V1walletService.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.jstoString()口径:去尾随零 / 非指数形式)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}(V1POST /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}(V1POST /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_TEMPLATES4 套(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}(V1POST /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(V1toFixed(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}(V1POST /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}(V1POST /api/group-buy路径平移,协议 100% 兼容) - 4 action:
list/detail/join/myJoined(全部需登录 — V1 整 endpointif (!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 逐行)
- 事务外查团购(无 → 404
团购不存在) - 显式拦
status === 'reached'→ 400团购已成团(⚠️ V1 无 'ended' 状态 — 文档声称有但源码无写入路径,V2 不引入;reached 检查先于成员检查) - 事务外查成员记录(有 → 409
已参与该团购) - 事务:member 创建 +
currentCount+1(无 status guard);currentCount >= targetCount && status === 'active'→status='reached'(判定函数cz_group_buy_just_reached单一权威) - 事务外重查:已成团 → 查全部成员 → 循环通知每个成员(逐条 try/catch 吞错;加入者本人跳过 — V1
notify()内userId === actorId语义;内容逐字「${productName}」团购已成团,去下单吧!,type='system',targetType='groupBuy',targetId=团购 id,actorId=加入者;商品缺失 fallback'商品') - 返回固定体
{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}(V1POST /api/distribution路径平移,协议 100% 兼容) - 8 action:
mySummary/myOrders/myTeam/myCommissionLogs/myLevel/inviteInfo/withdrawRequest/myWithdrawals(全部需登录 — V1 整 endpointif (!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 侧同五步(typesettle_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 真仓储桥接) |