Skip to content

模块手册 · 交易与资产域(trade-assets) ​

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

本文目录 ​

    1. wallet
    1. points
    1. coupon
    1. cart
    1. mall
    1. group_buy
    1. 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 一览) ​

actionpayload返回(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 行为差异(已接受) ​

项V1V2
feature gate(wallet=false → 全 403)部署 flag 中间件不模拟(InMemory 阶段无部署态);recharge 内层 403 保留
时间序列化toISOString()(UTC 带 Z)naive 本地 isoformat(全局换算 — 显示层等价)
可选键 nullJS 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.py3 action E2E(鉴权 / 分页 / featureDisabled / payload 校验)
tests/unit/application/test_wallet_usecases.py3 action + consume_in_tx 全分支(含金额精度 / PG 量化 / 走负退款)
tests/unit/domain/test_wallet_money.pydecimal.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-161.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 一览) ​

actionpayload返回(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 行为差异(已接受) ​

项V1V2
signin 无用户行P2025 未捕获(500)显式 404 用户不存在(40409004 — 显式化)
addPoints 流水写入userRepo.addPoints 内置 pointsRecord.createport 主自增(返回余额)+ 本模块 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.py4 action E2E(鉴权 / 签到防重 / 分享日限 / 任务 done)
tests/unit/application/test_points_usecases.py4 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-161.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 一览) ​

actionpayload返回(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 行为差异(已接受) ​

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

未迁移项(标注) ​

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

测试与质量 ​

文件范围
tests/integration/test_coupon_actions.py4 action E2E(received 标记 / 懒过期 / 防重 / amount 整数口径)
tests/unit/application/test_coupon_usecases.py4 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-161.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 一览) ​

actionpayload返回(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 行为差异(已接受) ​

项V1V2
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.py5 action E2E(合计 2 位小数 / price 去尾随零 / 下架过滤 / qty=0 路由)
tests/unit/application/test_cart_usecases.py5 action 全分支(合并累加 / 空车 / Decimal 精确合计 / 404 分支)
tests/unit/domain/test_cart_money.pydecimal.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-161.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) ​

场景V1V2
createOrder totalAmount/payAmounttoFixed(2) → 恒 2 位("19.90")cz_js_to_fixed2
myOrders / items pricedecimal.js toString() → 去尾随零("99.9" / "1")cz_js_decimal_string
部分抵扣 payAmount / 佣金round2(n) = Math.round(n*100)/100cz_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 实现将来接入
GroupBuyQueryPortInMemoryGroupBuyAdapter(种子 gb-984001/002)group-buy 模块迁移
DistributionPortInMemoryDistributionAdapter(levelRate V0-V3 / team / dist order / clawback)distribution 模块迁移
WxPayPortInMemoryWxPayAdapter(unifiedOrder fail-fast / refund 占位成功)wxpay 模块迁移
PaymentConfigPortInMemoryPaymentConfigAdapter(恒 False)app-config 模块迁移
CloseOrderJobPortInMemoryCloseOrderJobAdapter(仅记录)close-order job 迁移
MallUserPortInMemoryMallUserAdapter(points/封禁/邀请码/分销等级)user 模块共库

与 V1 行为差异(已接受) ​

项V1V2
productDetail 缺 idZod 报错经 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 $transactionInMemory 顺序编排(生产 Prisma 阶段经端口恢复真事务)
退款 refundYuan 类型numberDecimal(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.py6 action E2E(鉴权 / 团购三错误 / 积分分支 / 状态机 409 / refund 进程内)
tests/unit/application/test_mall_usecases.py6 action + refund 全分支(校验链顺序 / 自提码 / 分销落库条件 / allowNegative)
tests/unit/domain/test_mall_domain.py状态机全白名单 + pickup code + 金额纯函数
tests/unit/infrastructure/test_mall_inmemory.pyInMemory 仓储 + 最小端口 + 跨模块接线
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-161.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 — 全部需登录) ​

actionpayload返回(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 实现将来接入
NotificationPortInMemoryGroupBuyNotificationAdapter(自己触发自己跳过;_calls 全量 / _records 送达 — 可断言)notification 模块迁移
GroupBuyProductQueryPortInMemoryGroupBuyProductAdapter(✅ 已接 mall 真实现)—

(无用户端口 — V1 group-buy 不读用户行,通知内容无昵称。)

与 V1 行为差异(已接受) ​

项V1V2
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 $transactionInMemory 顺序编排(生产 Prisma 阶段经端口恢复真事务)
商品缺失(FK 不可达)include 恒有商品;notify fallback '商品'list/detail/myJoined → product null 直通(防御性);join 通知 fallback '商品'(同 V1)

测试与质量 ​

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

actionpayload返回(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 条件,从高到低首个命中) ​

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

levelRate:严格 === — 未匹配 / 大小写异体('v1')/ None → 0。

舍入(JS 浮点语义 — 禁止 Python round) ​

场景V1V2
间推佣金Math.round(direct × 0.5 × 100) / 100(IEEE754 double)cz_indirect_commission(float 运算 + floor(v+0.5))
Math.round 复刻JS half-upcz_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 DistributionPortMallDistributionAdapter(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 实现将来接入
DistributionUserPortInMemoryDistributionUserAdapter(种子 13 profile)user 模块共库/真实现
CommissionWalletPortSimpleCommissionWalletAdapter(✅ 已接 wallet 真仓储)—(已真实现)
MallOrderRefPortInMemoryMallOrderRefAdapter(✅ 已接 mall 真仓储)—(已真实现)

与 V1 行为差异(已接受/已拍板) ​

项V1V2
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 actionFastify ajv body enum 前置 → 400(ajv msg)400 PARAM_INVALID unknown action: x;且未登录 + unknown → 401(V2 登录依赖前置 — V1 ajv 会先 400,登录客户端下语义一致)
最低提现金额 400 msgzod 前置 → amount: 最低提现金额 10 元(带前缀)业务层裸 msg 最低提现金额 10 元(V1 service 层同字 — 前缀差异)
withdraw amount 字符串数字z.number() 拒收串 → 400pydantic 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.py8 action E2E(鉴权 401 / payload 400 / 提现三错误分支 / mall createOrder+refundOrder 跨模块接线)
tests/unit/application/test_distribution_usecases.py8 action 全分支(月口径 / desc / level 过滤 / 邀请码重试链 / 提现校验顺序)
tests/unit/application/test_distribution_settlement.pysettle/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-161.0.0首版 — V1 分销中心 8 action + settle/clawback 迁移(DDD 4 层 + 40414xxx + JS 浮点舍入复刻 + mall 端口真实现替换(level=2 修正)+ wallet 真仓储桥接)