From 536199e4368771d387f84ebcfee9fc77589f6539 Mon Sep 17 00:00:00 2001 From: yuhaochen Date: Sun, 2 Aug 2026 13:28:28 +0800 Subject: [PATCH] docs: split bank & commerce template READMEs into bilingual (EN/ZH) Each template README is now split into English (README.md) and Chinese (README.zh-CN.md), following the root project's existing i18n convention. - bank: translate the original Chinese README to English; preserve Chinese content as README.zh-CN.md - commerce: translate the original English README to Chinese; keep English content in README.md (language switcher link added) - Both pairs cross-reference each other with language-switcher links - Root README.zh-CN.md now links to both language versions per template Co-Authored-By: Claude --- README.zh-CN.md | 4 +- templates/bank/README.md | 403 +++++++++++++++-------------- templates/bank/README.zh-CN.md | 375 +++++++++++++++++++++++++++ templates/commerce/README.md | 2 + templates/commerce/README.zh-CN.md | 324 +++++++++++++++++++++++ 5 files changed, 906 insertions(+), 202 deletions(-) create mode 100644 templates/bank/README.zh-CN.md create mode 100644 templates/commerce/README.zh-CN.md diff --git a/README.zh-CN.md b/README.zh-CN.md index 7cf9c65..bd3d76b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,8 +12,8 @@ | 模板 | 是什么 | 文档 | |------|--------|------| -| `bank` | 银行核心系统缩影——7 个 Go 服务、7 个独立 PostgreSQL 库、内部 gRPC 读取、RabbitMQ 命令/事件、Traefik 网关(:18000)、复式记账总账、逐日滚存余额、**durable 支付 saga**(风控→冻结→过账 forward,逆序补偿,运营者 admin gRPC)、**全栈可观测性**(OTel/Jaeger/Prometheus/Grafana + 5 告警)、**dev/prod Kubernetes overlay**(dev = 状态化,prod = 外部状态 + SecretProviderClass)。 | [templates/bank/README.md](templates/bank/README.md) · [ARCHITECTURE.md](templates/bank/ARCHITECTURE.md) | -| `commerce` | 电商后端缩影——6 个 Go 服务、6 个 PostgreSQL 库、RabbitMQ saga、Traefik 网关。 | [templates/commerce/README.md](templates/commerce/README.md) · [ARCHITECTURE.md](templates/commerce/ARCHITECTURE.md) | +| `bank` | 银行核心系统缩影——7 个 Go 服务、7 个独立 PostgreSQL 库、内部 gRPC 读取、RabbitMQ 命令/事件、Traefik 网关(:18000)、复式记账总账、逐日滚存余额、**durable 支付 saga**(风控→冻结→过账 forward,逆序补偿,运营者 admin gRPC)、**全栈可观测性**(OTel/Jaeger/Prometheus/Grafana + 5 告警)、**dev/prod Kubernetes overlay**(dev = 状态化,prod = 外部状态 + SecretProviderClass)。 | [中文](templates/bank/README.zh-CN.md) · [English](templates/bank/README.md) · [ARCHITECTURE.md](templates/bank/ARCHITECTURE.md) | +| `commerce` | 电商后端缩影——6 个 Go 服务、6 个 PostgreSQL 库、RabbitMQ saga、Traefik 网关。 | [中文](templates/commerce/README.zh-CN.md) · [English](templates/commerce/README.md) · [ARCHITECTURE.md](templates/commerce/ARCHITECTURE.md) | ```bash jiade init --template --dir ./myproj diff --git a/templates/bank/README.md b/templates/bank/README.md index 335cadf..3a79711 100644 --- a/templates/bank/README.md +++ b/templates/bank/README.md @@ -1,90 +1,92 @@ -# bank(jiade 模板:7 服务纵切——core-banking + customer + payment + reward + risk + loan + wealth) +# bank — jiade template: 7-service vertical slice (core-banking + customer + payment + reward + risk + loan + wealth) -简化版银行核心系统——「现实世界大工程的缩影」。本工程由 `jiade init --template bank` 生成,**自包含**:离开 jiade 也可独立运行(仅需 docker + go)。 +[中文文档](README.zh-CN.md) -本模板属于 [jiade](../../README.md) 项目;架构细节见 [ARCHITECTURE.md](ARCHITECTURE.md)。 +A simplified core-banking system — *a microcosm of a large real-world engineering project*. Generated by `jiade init --template bank` and **self-contained**: it runs independently without jiade (only Docker and Go required). -工程包含 **7 服务 + 7 独立 PostgreSQL 库 + Traefik 网关 + RabbitMQ + 内部 gRPC + 逐日滚存/三因子 fixture + durable 支付 saga + 全栈可观测性 + dev/prod Kubernetes overlay**。每个服务只访问自己的数据库: +This template is part of the [jiade](../../README.md) project. For architectural details, see [ARCHITECTURE.md](ARCHITECTURE.md). -| 服务 | 容器端口 | 库 | 默认副本 | 内容 | -|------|----------|----|----------|------| -| core-banking | 8080 (REST) / 9090 (gRPC) | core_db | 2 | 活期/定存账户、复式记账总账、逐日余额、写接口(过账/冲正)、**资金冻结(hold/release/capture)** | -| customer | 8080 (REST) / 9090 (gRPC) | cust_db | 1 | 客户信息、账户关系 | -| payment | 8080 (REST) / 9091 (admin gRPC) | pay_db | 2 | 商户、消费流水、**durable 支付工作流引擎(saga 编排 + 不可变审计)** | -| reward | 8080 (REST) | reward_db | 1 | 积分账户/流水、优惠券、活动 | -| risk | 8080 (REST) | risk_db | 2 | 风控规则、事件、黑名单、**支付授权(authorize/void)** | -| loan | 8080 (REST) | loan_db | 1 | 借据、放款、月度还款、五级分类逾期、**逐日余额快照** | -| wealth | 8080 (REST) | wealth_db | 1 | 理财产品、**逐日净值游走**、持仓、申赎订单、每日利息 | +The project comprises **7 services + 7 independent PostgreSQL databases + Traefik gateway + RabbitMQ + internal gRPC + daily-rolling / three-factor fixtures + a durable payment saga + full-stack observability + dev/prod Kubernetes overlays**. Each service accesses only its own database: -**网关**:Traefik 是唯一对外发布的端口——`http://localhost:18000`。所有公开 REST 路径(`/api/v1/...`)经网关路由到对应服务的 8080 端口;`/internal/*`、gRPC 9090(读取)与 admin gRPC 9091(运营)**不暴露给宿主机**。 +| Service | Container ports | Database | Default replicas | Responsibilities | +|---------|----------------|----------|-----------------|-----------------| +| core-banking | 8080 (REST) / 9090 (gRPC) | core_db | 2 | Current/deposit accounts, double-entry general ledger, daily balances, write API (post/reverse), **funds hold (hold/release/capture)** | +| customer | 8080 (REST) / 9090 (gRPC) | cust_db | 1 | Customer records, account relationships | +| payment | 8080 (REST) / 9091 (admin gRPC) | pay_db | 2 | Merchants, transaction records, **durable payment workflow engine (saga orchestration + immutable audit)** | +| reward | 8080 (REST) | reward_db | 1 | Points accounts/transactions, coupons, campaigns | +| risk | 8080 (REST) | risk_db | 2 | Risk rules, events, blocklist, **payment authorization (authorize/void)** | +| loan | 8080 (REST) | loan_db | 1 | Loan instruments, disbursements, monthly repayments, five-tier classification, **daily balance snapshots** | +| wealth | 8080 (REST) | wealth_db | 1 | Wealth products, **daily NAV walk**, holdings, subscriptions/redemptions, daily interest | -**服务间通信**:同步只读查询走内部 gRPC(customer、core-banking 在 :9090 提供 `CustomerQueryService` / `AccountQueryService`);支付 saga 的异步命令与领域事件走 RabbitMQ(事务发件箱 + 有限重试 + DLQ + 运营者冲正)。详见 [ARCHITECTURE.md](ARCHITECTURE.md)。 +**Gateway**: Traefik is the only port published to the host — `http://localhost:18000`. All public REST paths (`/api/v1/...`) are routed through the gateway to each service's port 8080; `/internal/*`, gRPC 9090 (reads), and admin gRPC 9091 (operations) are **never exposed to the host**. -## 数据引擎要点 +**Inter-service communication**: synchronous read-only queries use internal gRPC (customer and core-banking expose `CustomerQueryService` / `AccountQueryService` on `:9090`); the payment saga's asynchronous commands and domain events flow through RabbitMQ (transactional outbox + bounded retries + DLQ + operator reconciliation). See [ARCHITECTURE.md](ARCHITECTURE.md) for details. -每个服务都是同一个四层纵切(`api → service → repo → domain`)。数据引擎要点: +## Data engine highlights -- **确定性 fixture**:同 seed + scale → 完全相同的行。确定性 ID(无 UUID),逐日独立 rng(`seed + 偏移 + 日序`)。 -- **两种数据形态**:三因子事件流(`趋势 × 季节 × 周期`——周末单量 < 工作日)与路径依赖的**逐日滚存快照**(账户余额、借据余额、净值游走)。 -- **数据库按服务隔离**:每个服务独占一个 PostgreSQL 实例与卷,只查自己的库;跨域只读数据通过内部 gRPC 获取(如 loan 调 customer 的 `CustomerQueryService`)。 -- **金额 int64 分,禁 float**;利率/净值/份额等非货币小数按 NUMERIC 文本直存。 -- **生成物自包含**:离开 jiade 也能构建运行——只需 Docker 和 Go。 +Every service follows the same four-layer vertical slice (`api → service → repo → domain`). Data engine highlights: -## 快速开始 +- **Deterministic fixtures**: identical seed + scale → byte-identical rows. Deterministic IDs (no UUIDs); per-day independent RNG (`seed + offset + day-sequence`). +- **Two data shapes**: three-factor event streams (`trend × seasonality × cycle` — weekend volumes < weekdays) and path-dependent **daily-rolling snapshots** (account balances, loan balances, NAV walks). +- **Database-per-service isolation**: each service owns a dedicated PostgreSQL instance and volume and queries only its own database; cross-domain read-only data is fetched via internal gRPC (e.g. loan calls customer's `CustomerQueryService`). +- **Money as int64 minor units, no floats**; non-monetary decimals (rates, NAV, shares) are stored as NUMERIC text. +- **Self-contained output**: builds and runs without jiade — only Docker and Go are needed. + +## Quick start ```bash -make up # docker compose up -d --build --wait,然后 make seed -make seed # 建 7 库 → 建 7 库表 → 灌 7 域 fixture(9 步,幂等:--reset) +make up # docker compose up -d --build --wait, then make seed +make seed # create 7 databases → run 7 migrations → seed 7 domains (9 steps, idempotent: --reset) ``` -灌数规模:`--scale=dev`(约 1/4 量,默认)或 `--scale=full`。同 seed 重跑 `make seed`(或 `jiade seed`)产出完全相同的数据。`make seed` 走 `--reset`,会重建全部 7 库。 +Seed scale: `--scale=dev` (≈¼ volume, default) or `--scale=full`. Re-running `make seed` (or `jiade seed`) with the same seed produces byte-identical data. `make seed` passes `--reset`, which recreates all 7 databases. ```bash -make seed # dev 规模(默认) -SCALE=full make seed # full 规模 -go test -tags=integration -p 1 ./... # 集成测试,需本机 15432 有 postgres(DB_PORT 可覆盖) +make seed # dev scale (default) +SCALE=full make seed # full scale +go test -tags=integration -p 1 ./... # integration tests; needs postgres on localhost:15432 (DB_PORT to override) ``` -所有公开 REST 端点经网关 `http://localhost:18000` 访问;服务容器端口不发布到宿主机。`make up` 使用 `--wait`,会等到全部 healthcheck 就绪再返回。查看单个服务健康状态: +All public REST endpoints are accessed through the gateway at `http://localhost:18000`; service container ports are never published to the host. `make up` uses `--wait` and blocks until every healthcheck is ready. Inspect individual service health: ```bash -docker compose ps # 各服务 health 列 -docker compose exec core-banking wget -qO- :8080/healthz # 容器内探针 +docker compose ps # health column for each service +docker compose exec core-banking wget -qO- :8080/healthz # in-container probe ``` -core-banking 只读查询(Spec A,经网关): +core-banking read-only queries (Spec A, via gateway): ```bash curl -sf localhost:18000/api/v1/accounts/D0000000001 curl -sf localhost:18000/api/v1/accounts/D0000000001/balance ``` -core-banking 记账/冲正写接口(Spec B-3;复式过账强制 sum(借)==sum(贷),`LedgerService.Post` 已内部化,客户端只见业务意图): +core-banking posting / reversal write API (Spec B-3; double-entry posting enforces sum(debit)==sum(credit), `LedgerService.Post` is internalised — clients see only business intent): ```bash -# 记账:存入 100 元(deposit / withdraw / transfer) +# Post: deposit 100 CNY (deposit / withdraw / transfer) curl -sf -X POST localhost:18000/api/v1/txns \ -H 'Content-Type: application/json' \ -d '{"action":"deposit","account_no":"D0000000001","amount":"100.00","ccy":"CNY"}' -# → 201 {"voucher_no":"V...","biz_date":"...","txns":[{借/贷两条分录}]} +# → 201 {"voucher_no":"V...","biz_date":"...","txns":[{two debit/credit entries}]} -# 冲正:蓝冲(默认,改状态+回滚余额,不新增流水) +# Reverse: blue reversal (default; status change + balance rollback, no new journal lines) curl -sf -X POST 'localhost:18000/api/v1/vouchers/V.../reverse?mode=blue' # → 200 {"voucher_no":"V...","mode":"blue","status":"reversed"} -# mode=red 走反向分录(新增反向流水,返回 reversed_voucher_no) +# mode=red creates reverse entries (new reversal journal lines, returns reversed_voucher_no) ``` -## 支付工作流(durable saga) +## Payment workflow (durable saga) -**入口**:`POST /api/v1/payments/workflows`(payment 服务,经网关 18000 → payment:8080)。 +**Entry point**: `POST /api/v1/payments/workflows` (payment service, via gateway 18000 → payment:8080). -工作流是 payment 服务内置的 durable saga 编排器(`internal/platform/workflow`):每次请求落库为一个不可变 Instance,按顺序执行三个 Action;任一 Action 终态失败时按**逆序**触发补偿;运营者可经 admin gRPC 干预卡住的补偿。所有 saga 命令/事件经 RabbitMQ 投递,落库前与业务写在同一 PostgreSQL 事务中(事务发件箱),保证「状态变更」与「事件发出」原子一致。 +The workflow is a durable saga orchestrator built into the payment service (`internal/platform/workflow`): each request is persisted as an immutable Instance that executes three Actions in sequence; when any Action fails terminally, compensation is triggered in **reverse order**; operators can intervene on stuck compensations via admin gRPC. All saga commands/events are delivered through RabbitMQ and, before publishing, are written in the same PostgreSQL transaction as the business state change (transactional outbox), guaranteeing atomic consistency between state transitions and event emission. -### 提交一个支付 +### Submit a payment ```bash -# Idempotency-Key 必填;同 key + 同 body 重放返回原 workflow_id(200, replayed=true); -# 同 key + 不同 body 返回 409 idempotency_conflict。 +# Idempotency-Key is required; same key + same body replay returns the original workflow_id (200, replayed=true); +# same key + different body returns 409 idempotency_conflict. curl -sf -X POST localhost:18000/api/v1/payments/workflows \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: my-key-0001' \ @@ -96,93 +98,94 @@ curl -sf -X POST localhost:18000/api/v1/payments/workflows \ "amount_minor":5000 }' # → 201 {"workflow_id":"wf-...","status":"preparing","replayed":false} -# amount_minor 是 int64 分(5000 = 50.00 CNY);必须 > 0。 +# amount_minor is int64 minor units (5000 = 50.00 CNY); must be > 0. ``` -查询状态: +Query status: ```bash curl -sf localhost:18000/api/v1/payments/workflows/wf-... # → 200 {"workflow_id":"wf-...","status":"succeeded","reversed":false,...} ``` -对已 `succeeded` 的工作流发起冲正(触发补偿 saga): +Reverse a `succeeded` workflow (triggers the compensation saga): ```bash curl -sf -X POST localhost:18000/api/v1/payments/workflows/wf-.../reverse # → 200 {"workflow_id":"wf-...","reversal_workflow_id":"wf-...","status":"compensating"} ``` -### 工作流状态机(Instance.Status) +### Workflow state machine (Instance.Status) ``` preparing ──► ready ──► running ──┬─► succeeded │ - ├─► rejected (业务终态:风控拒、KYC 失败) + ├─► rejected (terminal: risk rejection, KYC failure) │ └─► compensating ──┬─► compensated │ └─► compensation_failed - (需运营者 gRPC 介入; - 详见「运营者冲正」) + (requires operator gRPC + intervention; see "Operator + reconciliation") ``` -| 状态 | 含义 | -|------|------| -| `preparing` | 准备中:读客户/账户快照、KYC/黑名单校验、构造不可变 `TransferContext`。 | -| `ready` | 准备完成,等待引擎首次调度。 | -| `running` | 已分发当前 Action 命令,等待下游结果事件(每个 Action 15 秒操作超时;超时由恢复循环重发,不抛弃实例)。 | -| `succeeded` | 三个 Action 全部成功;账务已过账、冻结已 capture。终态。 | -| `rejected` | 业务终态失败(风控拒绝、活动状态无效、 insufficient funds);已按需补偿。终态。 | -| `compensating` | 触发了逆序补偿(任一 forward Action 终态失败或显式 `reverse`)。 | -| `compensated` | 所有已成功 Action 均已逆序补偿。终态。 | -| `compensation_failed` | 补偿在 `CompensationMaxAttempts=5` 次内未成功;实例卡住等待运营者冲正。 | +| State | Meaning | +|-------|---------| +| `preparing` | Preparing: reading customer/account snapshots, KYC/blocklist validation, constructing the immutable `TransferContext`. | +| `ready` | Preparation complete; awaiting first engine scheduling. | +| `running` | Current Action command dispatched; awaiting downstream result event (15-second per-Action operation timeout; timeouts are retried by the recovery loop, never abandoned). | +| `succeeded` | All three Actions succeeded; ledger posted, hold captured. Terminal. | +| `rejected` | Terminal business failure (risk rejection, invalid account status, insufficient funds); compensated as needed. Terminal. | +| `compensating` | Reverse-order compensation triggered (any forward Action failed terminally or explicit `reverse`). | +| `compensated` | All previously-succeeded Actions compensated in reverse order. Terminal. | +| `compensation_failed` | Compensation did not succeed within `CompensationMaxAttempts=5`; instance stuck, awaiting operator reconciliation. | -引擎默认:`ExecuteMaxAttempts=3`、`CompensationMaxAttempts=5`、`OperationalDeadline=2m`、Action 操作超时 15 s。超过 `OperationalDeadline` 不删除/不抛弃实例,仅记录错误并安排 wake-up——**durable 工作流从不静默丢失**。 +Engine defaults: `ExecuteMaxAttempts=3`, `CompensationMaxAttempts=5`, `OperationalDeadline=2m`, per-Action operation timeout 15 s. Instances past `OperationalDeadline` are never deleted or abandoned — the engine only records the error and schedules a wake-up. **Durable workflows are never silently lost.** -### Action / Compensation 序列 +### Action / compensation sequence -forward 三个 Action(顺序执行,下游 consumer 在 risk / core-banking): +Three forward Actions (executed sequentially; downstream consumers live in risk / core-banking): -| # | Action | 下发命令(routing key) | 接受的结果事件 | 含义 | -|---|--------|------------------------|----------------|------| -| 0 | `AuthorizeRisk` | `risk.authorize-payment.v1` | `risk.payment-authorized.v1` / `risk.payment-rejected.v1` | 风控授权(KYC、黑名单、规则) | -| 1 | `PlaceFundsHold` | `core.place-hold.v1` | `core.hold-placed.v1` / `core.hold-failed.v1` | 在 core-banking 预冻结付款人账户金额 | -| 2 | `PostLedgerTransfer` | `core.post-held-transfer.v1` | `core.transfer-posted.v1` / `core.transfer-failed.v1` | 复式过账转账(冻结转 capture,借贷同时落账) | +| # | Action | Dispatched command (routing key) | Accepted result events | Meaning | +|---|--------|----------------------------------|------------------------|---------| +| 0 | `AuthorizeRisk` | `risk.authorize-payment.v1` | `risk.payment-authorized.v1` / `risk.payment-rejected.v1` | Risk authorization (KYC, blocklist, rules) | +| 1 | `PlaceFundsHold` | `core.place-hold.v1` | `core.hold-placed.v1` / `core.hold-failed.v1` | Pre-freeze the payer's account amount in core-banking | +| 2 | `PostLedgerTransfer` | `core.post-held-transfer.v1` | `core.transfer-posted.v1` / `core.transfer-failed.v1` | Double-entry transfer posting (freeze → capture, debit and credit posted atomically) | -补偿(任一 forward Action 终态失败时,按**逆序**对每个已 `succeeded` 的 Action 单独下发补偿命令;不会自动跳过任何金融步骤): +Compensation (when any forward Action fails terminally, each previously-`succeeded` Action receives a compensation command in **reverse order**; no financial step is ever skipped): -| 原 Action | 补偿命令(routing key) | 接受的结果事件 | 含义 | -|-----------|------------------------|----------------|------| -| `PostLedgerTransfer` | `core.reverse-transfer.v1` | `core.transfer-reversed.v1` / `core.transfer-reverse-failed.v1` | 反向分录冲销已过账的转账 | -| `PlaceFundsHold` | `core.release-hold.v1` | `core.hold-released.v1` / `core.hold-release-failed.v1` | 释放此前预冻结的金额 | -| `AuthorizeRisk` | `risk.void-payment-authorization.v1` | `risk.payment-authorization-voided.v1` | 作废风控授权记录 | +| Original Action | Compensation command (routing key) | Accepted result events | Meaning | +|----------------|-----------------------------------|------------------------|---------| +| `PostLedgerTransfer` | `core.reverse-transfer.v1` | `core.transfer-reversed.v1` / `core.transfer-reverse-failed.v1` | Reverse-entry reversal of the posted transfer | +| `PlaceFundsHold` | `core.release-hold.v1` | `core.hold-released.v1` / `core.hold-release-failed.v1` | Release the previously pre-frozen amount | +| `AuthorizeRisk` | `risk.void-payment-authorization.v1` | `risk.payment-authorization-voided.v1` | Void the risk authorization record | -Action 之间通过 `priorActionOutput(actions, name)` 按语义名读取上游 Output(例如 `PostLedgerTransfer` 从 `PlaceFundsHold` 的 Output 中取 `hold_id`),不依赖硬编码位置索引。 +Actions read upstream Output via `priorActionOutput(actions, name)` by semantic name (e.g. `PostLedgerTransfer` reads `hold_id` from `PlaceFundsHold`'s Output) — no hardcoded positional indices. -失败分类(`ErrorClass`,由 consumer 在失败 payload 上盖戳,引擎根据类别决定 retry/compensate/leave-running): +Failure classification (`ErrorClass`, stamped by the consumer on failure payloads; the engine decides retry / compensate / leave-running based on the class): -- `business_rejected` → 终态,触发补偿(如风控拒、余额不足) -- `transient_failure` → 可重试,实例保持 `running`(broker/依赖暂时不可用) -- `invariant_violation` → 终态(账务不平、hold 状态错),触发补偿 -- `invalid_message` → 终态结构错(未知消息类型;forward 方向保持 running 待恢复,不立即补偿) -- `unknown_outcome` → 不识别的 payload,保持 running 等待恢复循环重发 +- `business_rejected` → terminal, triggers compensation (e.g. risk rejection, insufficient funds) +- `transient_failure` → retryable, instance stays `running` (broker/dependency temporarily unavailable) +- `invariant_violation` → terminal (ledger imbalance, hold state error), triggers compensation +- `invalid_message` → terminal structural error (unknown message type; forward direction stays running awaiting recovery, no immediate compensation) +- `unknown_outcome` → unrecognised payload, stays running awaiting recovery-loop re-dispatch -## 跨服务聚合端点 +## Cross-service aggregation endpoints -服务经内部 gRPC 协作,**不跨库查询**: +Services collaborate via internal gRPC — **no cross-database queries**: ```bash -# customer 查本库账户关系,再调用 core-banking gRPC 获取账户资料 +# customer queries its own account relationships, then calls core-banking gRPC for account details curl -sf localhost:18000/api/v1/customers/C0000001/accounts -# payment 查本库转账,再调用 core-banking 和 customer gRPC 获取双方资料 +# payment queries its own transfer record, then calls core-banking and customer gRPC for both parties' details curl -sf localhost:18000/api/v1/payments/transfers/PT000000000001/parties ``` -预期:`/accounts` 返回该客户的 core 账户资料;`/parties` 返回转账双方账号 + 户主客户姓名。 +Expected: `/accounts` returns the customer's core account details; `/parties` returns both transfer parties' account numbers + owner customer names. -loan/wealth 只读端点示例(Spec B-4b): +loan/wealth read-only endpoint examples (Spec B-4b): ```bash curl -sf localhost:18000/api/v1/loan/accounts @@ -190,184 +193,184 @@ curl -sf localhost:18000/api/v1/loan/accounts/{loan_no}/profile curl -sf localhost:18000/api/v1/wealth/holdings/{holding_id}/profile ``` -## 服务调用拓扑与边界 +## Service call topology and boundaries -| 边界 | 用途 | 协议/交换机 | 谁用 | -|------|------|-----------|------| -| 同步只读 | 跨域读取(客户、账户) | **内部 gRPC**(customer/core-banking 在 `:9090`) | reward/risk/loan/wealth/payment.preparation 都经 `platform/serviceclient` 拨号 | -| 异步命令(saga forward + compensation) | payment 下发到下游执行 | **`bank.commands`(topic)** | payment 发;risk / core-banking 消费 | -| 异步结果事件 | 下游回报 Action 成败 | **`bank.events`(topic)** | risk / core-banking 发;payment 消费(`payment.workflow.events` 队列) | -| 领域完成事件 | payment 完成后通知下游 | **`bank.events`**,`payment.completed` 路由键 | payment 发;reward 消费(`reward.payment-events` 队列,发积分) | -| 运营者 gRPC | 干预卡住的补偿(运维面) | **admin gRPC `:9091`**(`payment-admin` headless Service) | 仅 label `role=bank-operator` 的 Pod 可达 | +| Boundary | Purpose | Protocol / exchange | Who uses it | +|----------|---------|---------------------|-------------| +| Synchronous read-only | Cross-domain reads (customer, account) | **Internal gRPC** (customer/core-banking on `:9090`) | reward/risk/loan/wealth/payment.preparation all dial via `platform/serviceclient` | +| Async commands (saga forward + compensation) | payment dispatches to downstream | **`bank.commands` (topic)** | payment publishes; risk / core-banking consume | +| Async result events | Downstream reports Action success/failure | **`bank.events` (topic)** | risk / core-banking publish; payment consumes (`payment.workflow.events` queue) | +| Domain completion events | payment notifies downstream after completion | **`bank.events`**, `payment.completed` routing key | payment publishes; reward consumes (`reward.payment-events` queue, awards points) | +| Operator gRPC | Intervene on stuck compensations (ops plane) | **admin gRPC `:9091`** (`payment-admin` headless Service) | Reachable only by Pods labelled `role=bank-operator` | -**服务发现**(DNS,round-robin 负载均衡): +**Service discovery** (DNS, round-robin load balancing): -- Compose:服务名(如 `core-banking`、`customer`)在 `bank-data` 网络上直接解析为多 IP。 -- Kubernetes base:7 个 REST ClusterIP(``, http=8080)+ 7 个 headless gRPC(`-grpc`, clusterIP=None, publishNotReadyAddresses=false, grpc=9090)+ 1 个 headless admin(`payment-admin`, admin-grpc=9091)。headless + publishNotReadyAddresses=false 确保 gRPC 客户端只拨到已就绪的 Pod。 -- gRPC 拨号字符串:`CUSTOMER_GRPC_TARGET=dns:///customer:9090`、`CORE_BANKING_GRPC_TARGET=dns:///core-banking:9090`、`ADMIN_GRPC_ADDR=:9091`。 +- Compose: service names (e.g. `core-banking`, `customer`) resolve directly to multiple IPs on the `bank-data` network. +- Kubernetes base: 7 REST ClusterIP Services (``, http=8080) + 7 headless gRPC Services (`-grpc`, clusterIP=None, publishNotReadyAddresses=false, grpc=9090) + 1 headless admin (`payment-admin`, admin-grpc=9091). headless + publishNotReadyAddresses=false ensures gRPC clients only dial ready Pods. +- gRPC dial strings: `CUSTOMER_GRPC_TARGET=dns:///customer:9090`, `CORE_BANKING_GRPC_TARGET=dns:///core-banking:9090`, `ADMIN_GRPC_ADDR=:9091`. -## 扩缩容 +## Scaling ```bash -# 仅扩缩指定服务,不动依赖 +# Scale a single service without touching its dependencies make scale SERVICE=payment REPLICAS=3 make scale SERVICE=core-banking REPLICAS=2 ``` -副本默认(见上表):core-banking / payment / risk = 2;customer / reward / loan / wealth = 1。Kubernetes base 对 core-banking/payment/risk 设了 PDB(`minAvailable=1`),所有 7 服务都挂了 CPU 80% 驱动的 HPA,`minReplicas` 对齐 compose 默认。 +Default replicas (see table above): core-banking / payment / risk = 2; customer / reward / loan / wealth = 1. The Kubernetes base sets PDBs (`minAvailable=1`) for core-banking/payment/risk; all 7 services have CPU-80%-driven HPAs with `minReplicas` aligned to the compose defaults. -## 运行拓扑 +## Runtime topology -- **网关**:Traefik v3 是唯一发布到宿主机的端口(`18000:8080`);只路由公开 `/api/v1/...` REST 前缀。 -- **专用数据库**:七个 PostgreSQL 16 实例(`core-banking-db` … `wealth-db`),各自独立卷,不存在跨库 SQL / 外部表 / 共享库权限。 -- **消息中间件**:RabbitMQ 4,四个交换机:`bank.commands` / `bank.events`(topic)+ `bank.retry` / `bank.dlx`(direct)。命令/事件队列各带 `.retry`(2 秒 TTL)+ `.dlq` 伴侣。完整绑定见 `deploy/rabbitmq/definitions.json`。 -- **内部 gRPC**:`customer:9090` 与 `core-banking:9090` 对内暴露只读查询;其余五服务仅起 REST,作为 gRPC 消费方。 -- **副本默认**:core-banking / payment / risk = 2;customer / reward / loan / wealth = 1。 -- **本地资源预算**:全栈约 6–8 GB 内存。应用容器限 512 MB / 1 CPU,PostgreSQL 限 768 MB,RabbitMQ 限 768 MB,Traefik 限 256 MB。 -- **弹性扩缩**:`make scale SERVICE=payment REPLICAS=3`(仅扩缩指定服务,不动依赖)。 -- **安全基线**:应用容器 `read_only` + `tmpfs:/tmp` + `cap_drop: ALL` + `no-new-privileges`。 +- **Gateway**: Traefik v3 is the only port published to the host (`18000:8080`); it routes only the public `/api/v1/...` REST prefix. +- **Dedicated databases**: seven PostgreSQL 16 instances (`core-banking-db` … `wealth-db`), each with an independent volume — no cross-database SQL, foreign tables, or shared-database permissions. +- **Message broker**: RabbitMQ 4 with four exchanges: `bank.commands` / `bank.events` (topic) + `bank.retry` / `bank.dlx` (direct). Each command/event queue has a `.retry` companion (2-second TTL) + `.dlq` (terminal dead-letter). See `deploy/rabbitmq/definitions.json` for full bindings. +- **Internal gRPC**: `customer:9090` and `core-banking:9090` expose read-only queries internally; the other five services run only REST and act as gRPC consumers. +- **Default replicas**: core-banking / payment / risk = 2; customer / reward / loan / wealth = 1. +- **Local resource budget**: full stack ≈ 6–8 GB RAM. Application containers limited to 512 MB / 1 CPU, PostgreSQL 768 MB, RabbitMQ 768 MB, Traefik 256 MB. +- **Elastic scaling**: `make scale SERVICE=payment REPLICAS=3` (scales only the specified service, dependencies untouched). +- **Security baseline**: application containers run `read_only` + `tmpfs:/tmp` + `cap_drop: ALL` + `no-new-privileges`. -## 可观测性 +## Observability -每个服务在 8080 上暴露 `/livez` / `/readyz` / `/metrics`(Prometheus 格式)+ `/healthz`。`/readyz` 在优雅关闭 drain 期间返回 503,Traefik 与 Kubernetes Ingress 用它做 drain。 +Every service exposes `/livez` / `/readyz` / `/metrics` (Prometheus format) + `/healthz` on port 8080. `/readyz` returns 503 during graceful-shutdown drain; Traefik and the Kubernetes Ingress use it for drain. ```bash -make observability # 拉 otel-collector / prometheus / grafana / jaeger(独立 bank-obs 网络) -make trace-smoke # 提交一笔支付,断言 Jaeger 收到 REST+gRPC+messaging+workflow 跨层 trace -make observability-check # jq 校验 dashboard JSON + promtool 校验 Prometheus 配置 + alerts -make observability-down # 仅下掉可观测性 overlay 容器 +make observability # pull otel-collector / prometheus / grafana / jaeger (separate bank-obs network) +make trace-smoke # submit a payment, assert Jaeger receives REST+gRPC+messaging+workflow cross-layer trace +make observability-check # jq-validate dashboard JSON + promtool-validate Prometheus config + alerts +make observability-down # tear down only the observability overlay containers ``` -**注意**:`make trace-smoke` 需要先 `make observability` + `make smoke`(后者会 apply smoke overlay,开启 `BANK_TEST_FAILURES_ENABLED=true`,使 payment 能用确定性 idempotency-key 前缀提交)。trace-smoke 本身走 success 路径,不依赖故障注入。 +**Note**: `make trace-smoke` requires `make observability` + `make smoke` first (the latter applies the smoke overlay, enabling `BANK_TEST_FAILURES_ENABLED=true`, allowing payment to accept deterministic idempotency-key prefixes). The trace-smoke itself runs the success path and does not depend on failure injection. -**Jaeger**:host 端口**不**发布——通过 `bank-obs` 网络内 `docker run --rm --network bank-obs curlimages/curl:8.10.1` 查询,符合「内部观测面不外暴露」的安全基线。 +**Jaeger**: the host port is **not** published — query it via `docker run --rm --network bank-obs curlimages/curl:8.10.1` inside the `bank-obs` network, consistent with the "internal observability plane never exposed externally" security baseline. -**Grafana**:3 个 dashboard(`deploy/grafana/dashboards/`)——`payment-workflows`(状态计数、Action p95 时延、补偿失败、最长等待)、`message-reliability`(发件箱年龄、收件箱去重、consumer lag)、`core-ledger`(过账/冲正计数、不变量失败)。 +**Grafana**: 3 dashboards (`deploy/grafana/dashboards/`) — `payment-workflows` (status counts, Action p95 latency, compensation failures, longest wait), `message-reliability` (outbox age, inbox dedup, consumer lag), `core-ledger` (posting/reversal counts, invariant failures). -**Prometheus 告警**(`deploy/prometheus/alerts.yaml`,5 条;`for` 0–1m): +**Prometheus alerts** (`deploy/prometheus/alerts.yaml`, 5 rules; `for` 0–1m): -| 告警 | 表达式 | 触发含义 | -|------|--------|---------| -| `WorkflowWaitingStuck` | `max(workflow_waiting_age_seconds) > 60` | 1 分钟内有工作流等待结果超过 60 秒 | -| `WorkflowCompensationFailure` | `sum(workflow_compensation_failures_total) > 0` | 1 分钟内出现补偿失败 | -| `OutboxBacklog` | `max(outbox_oldest_age_seconds) > 30` | 发件箱积压超 30 秒未投递 | -| `ConsumerLagHigh` | `sum(rabbitmq_consumer_lag) > 100` | 消费者滞后超 100 条 | -| `LedgerInvariantFailure` | `sum(ledger_invariant_failures_total) > 0` | 账务不变量失败(**`for: 0m`**——立即触发) | +| Alert | Expression | Trigger | +|-------|-----------|---------| +| `WorkflowWaitingStuck` | `max(workflow_waiting_age_seconds) > 60` | A workflow has been waiting for a result > 60 s within 1 minute | +| `WorkflowCompensationFailure` | `sum(workflow_compensation_failures_total) > 0` | A compensation failure occurred within 1 minute | +| `OutboxBacklog` | `max(outbox_oldest_age_seconds) > 30` | Outbox backlog undelivered > 30 s | +| `ConsumerLagHigh` | `sum(rabbitmq_consumer_lag) > 100` | Consumer lag exceeds 100 messages | +| `LedgerInvariantFailure` | `sum(ledger_invariant_failures_total) > 0` | Ledger invariant failure (**`for: 0m`** — fires immediately) | -## 死信队列与故障恢复 +## Dead-letter queues and failure recovery -**RabbitMQ 拓扑**(`deploy/rabbitmq/definitions.json`): +**RabbitMQ topology** (`deploy/rabbitmq/definitions.json`): -- **topic**:`bank.commands`(命令)、`bank.events`(事件)——通配符绑定(`risk.#`、`core.#` 等)确保 saga 所有 routing key 都能命中 consumer。 -- **direct**:`bank.retry`(重试)、`bank.dlx`(死信终点)。 -- 每个消费队列都有 `.retry` 伴侣(`x-message-ttl: 2000` + `x-dead-letter-exchange` 指回源 topic)+ `.dlq`(终端死信)。 +- **topic**: `bank.commands` (commands), `bank.events` (events) — wildcard bindings (`risk.#`, `core.#`, etc.) ensure every saga routing key reaches a consumer. +- **direct**: `bank.retry` (retry), `bank.dlx` (dead-letter sink). +- Each consumer queue has a `.retry` companion (`x-message-ttl: 2000` + `x-dead-letter-exchange` pointing back to the source topic) + a `.dlq` (terminal dead-letter). -**投递语义**:**至少一次**,安全由两件事保证: +**Delivery semantics**: **at least once**, made safe by two mechanisms: -- **事务发件箱**(publisher 侧):状态变更和发件箱行在同一 PostgreSQL 事务里写。每服务一个 drain 循环 poll 发件箱,`claim_token`/`claimed_at` 让多实例 drain 不双发。RabbitMQ Publisher Confirm 确认投递成功后再标 dispatched。 -- **幂等收件箱**(consumer 侧):每个 consumer 在 apply 前先写 `inbox_event(event_id, ...)`,重复投递被去重。这是 at-least-once 安全的核心。 +- **Transactional outbox** (publisher side): state changes and outbox rows are written in the same PostgreSQL transaction. Each service runs a drain loop that polls the outbox; `claim_token`/`claimed_at` prevent multi-instance double-dispatch. RabbitMQ Publisher Confirms mark a row as dispatched only after confirmed delivery. +- **Idempotent inbox** (consumer side): every consumer writes an `inbox_event(event_id, ...)` before applying its mutation; duplicate deliveries are deduped. This is the core of at-least-once safety. -**重试与死信路径**:consumer 处理失败时按 `RetryPolicy`(默认 `MaxAttempts=5`)路由到 `bank.retry` 对应队列;2 秒 TTL 到期后 dead-letter 回源 topic 重新投递。重试次数耗尽后,消息路由到对应 `.dlq` 队列供人工检查(**不再自动重投**)。 +**Retry and dead-letter path**: when a consumer fails, it routes to the corresponding `bank.retry` queue per its `RetryPolicy` (default `MaxAttempts=5`); after the 2-second TTL expires, the message is dead-lettered back to the source topic for re-delivery. Once retries are exhausted, the message is routed to the corresponding `.dlq` for manual inspection (**no further automatic re-delivery**). -**workflow 实例层面的对应关系**: -- consumer 侧的 `transient_failure`(broker/依赖暂不可用)→ 消息进 retry → 重新投递。工作流 Action 的 15 秒操作超时由 payment 引擎的恢复循环重发命令。 -- 终态失败(`business_rejected` / `invariant_violation` / `invalid_message`)→ 工作流进入 `compensating`。 -- 补偿 `transient_failure` 次数耗尽(`CompensationMaxAttempts=5`)→ 工作流 `compensation_failed`,等待运营者冲正(见下)。 +**Workflow-instance-level correspondence**: +- Consumer-side `transient_failure` (broker/dependency temporarily unavailable) → message enters retry → re-delivered. The 15-second per-Action operation timeout is retried by the payment engine's recovery loop re-dispatching the command. +- Terminal failures (`business_rejected` / `invariant_violation` / `invalid_message`) → the workflow enters `compensating`. +- Compensation `transient_failure` retries exhausted (`CompensationMaxAttempts=5`) → workflow `compensation_failed`, awaiting operator reconciliation (see below). -## 运营者冲正(admin gRPC,:9091) +## Operator reconciliation (admin gRPC, :9091) -支付工作流提供**两个** protected RPC(`proto/bank/payment/v1/workflow_admin.proto`),由 payment 服务在独立 admin gRPC 端口 `:9091` 上发布(`payment-admin` headless Service),**不出现在网关**: +The payment workflow exposes **two** protected RPCs (`proto/bank/payment/v1/workflow_admin.proto`), published by the payment service on a dedicated admin gRPC port `:9091` (`payment-admin` headless Service), **not surfaced through the gateway**: -| RPC | 用途 | -|-----|------| -| `RetryCompensation(workflow_id, reason)` | 对卡在 `compensation_failed` 的实例重发补偿命令。完全自动化路径。 | -| `RecordReconciliation(workflow_id, action_name, external_reference, reason)` | 对卡住的补偿 Action 用一份**不可变外部对账引用**强制 resolve。调用前 server 会先 `Reconciler.ValidateReconciliation` 校验 core-banking 当前真实状态(funds-hold Action:hold 已释放;ledger-transfer Action:反向凭证已存在 + 借贷平衡),校验通过才落审计 + resolve。 | +| RPC | Purpose | +|-----|---------| +| `RetryCompensation(workflow_id, reason)` | Re-dispatch compensation commands for an instance stuck in `compensation_failed`. Fully automated path. | +| `RecordReconciliation(workflow_id, action_name, external_reference, reason)` | Force-resolve a stuck compensation Action with an **immutable external reconciliation reference**. Before the call, the server runs `Reconciler.ValidateReconciliation` to verify core-banking's current real state (funds-hold Action: hold released; ledger-transfer Action: reversal voucher exists + debit/credit balanced); resolution + audit are persisted only after validation passes. | -**访问控制(三层)**: +**Access control (three layers)**: -1. **NetworkPolicy**(`deploy/k8s/base/networking.yaml`):`payment-admin-ingress` 默认拒绝 9091 入站;只有带 `role=bank-operator` label 的 Pod 可达。 -2. **token 校验**:调用方必须在 gRPC metadata 里带 `x-bank-operator-token`;server 用 `crypto/subtle.ConstantTimeCompare` 常数时间比较。token 从环境变量 `BANK_OPERATOR_TOKEN` 读取;**空值即 fail-closed**(startup 警告,所有 RPC 拒绝)。 -3. **不可变审计**:`workflow_operator_audit` 表带 `BEFORE UPDATE OR DELETE` 触发器;每次 `RetryCompensation` / `RecordReconciliation` 与状态变更在**同一事务**内 INSERT 一条审计记录(operator / action / reference / reason / prev_state / new_state / created_at),永不修改。 +1. **NetworkPolicy** (`deploy/k8s/base/networking.yaml`): `payment-admin-ingress` denies all inbound 9091 by default; only Pods labelled `role=bank-operator` can reach it. +2. **Token validation**: callers must include `x-bank-operator-token` in gRPC metadata; the server uses `crypto/subtle.ConstantTimeCompare` for constant-time comparison. The token is read from the `BANK_OPERATOR_TOKEN` environment variable; **an empty value fails closed** (startup warning, all RPCs rejected). +3. **Immutable audit**: the `workflow_operator_audit` table has a `BEFORE UPDATE OR DELETE` trigger; every `RetryCompensation` / `RecordReconciliation` INSERTs an audit record (operator / action / reference / reason / prev_state / new_state / created_at) in the **same transaction** as the state change — never modified. -`RecordReconciliation` 需要 core-banking 暴露 hold 状态与反向凭证查询 RPC 才能完整工作;模板带一个 fail-closed placeholder,未注入真实 inspector 时返回 `FailedPrecondition`。`RetryCompensation` 立即可用。 +`RecordReconciliation` requires core-banking to expose hold-status and reversal-voucher query RPCs to fully function; the template ships a fail-closed placeholder that returns `FailedPrecondition` when no real inspector is injected. `RetryCompensation` is immediately usable. -**注入 token**: +**Injecting the token**: -- Compose:默认不设;如需本地演练 admin RPC,在 `.env` 或 compose override 里设 `BANK_OPERATOR_TOKEN`。 -- Dev overlay:`deploy/k8s/overlays/dev/secret.yaml` 内置可读字面量 `dev-operator-token`(标 `dev-only`)。 -- Prod overlay:`deploy/k8s/overlays/prod/secret-contract.yaml` 用 SecretProviderClass 契约(无明文)从外部 vault 投射。 +- Compose: not set by default; for local admin RPC exercises, set `BANK_OPERATOR_TOKEN` in `.env` or a compose override. +- Dev overlay: `deploy/k8s/overlays/dev/secret.yaml` contains a readable plaintext `dev-operator-token` (labelled `dev-only`). +- Prod overlay: `deploy/k8s/overlays/prod/secret-contract.yaml` uses a SecretProviderClass contract (no plaintext) projected from an external vault. -## 失败注入 smoke 套件(10 gate) +## Failure-injection smoke suite (10 gates) -`make smoke`(`test/smoke.sh`)运行 10 个 gate,覆盖 saga 的成功/失败/恢复路径。失败注入由 `internal/platform/testfail` 提供,**仅**当 `BANK_TEST_FAILURES_ENABLED=true` 且 workflow id 以特定前缀(`smoke-reject-` / `smoke-insuff-` / `smoke-transient-` / `smoke-compfail-`)开头时触发;`compose.smoke.yaml` 是唯一开启该 env 的地方,且只由 smoke 脚本 apply。 +`make smoke` (`test/smoke.sh`) runs 10 gates covering the saga's success/failure/recovery paths. Failure injection is provided by `internal/platform/testfail` and triggers **only** when `BANK_TEST_FAILURES_ENABLED=true` and the workflow ID starts with a specific prefix (`smoke-reject-` / `smoke-insuff-` / `smoke-transient-` / `smoke-compfail-`); `compose.smoke.yaml` is the only place that enables this env, and it is applied solely by the smoke script. -| # | Gate | 断言 | -|---|------|------| -| 1 | replicas | core-banking / payment / risk 各 ≥2 健康容器 | -| 2 | success | 提交一笔 happy-path,poll 至 `succeeded` | -| 3 | risk-reject | `smoke-reject-` → `compensated`/`rejected`;无 hold、无 voucher | -| 4 | insufficient | `smoke-insuff-` → `compensated`;授权作废 | -| 5 | transient | `smoke-transient-` → `compensated`;hold 释放 | -| 6 | duplicate | 同 Idempotency-Key 两次 → 一个 workflow_id、一个 voucher | -| 7 | takeover | 中途 kill 一个 payment 容器 → 工作流仍成功 | -| 8 | reverse | 冲正一个 succeeded 支付 → `reversed=true`;voucher_reversal 行存在 | +| # | Gate | Assertion | +|---|------|-----------| +| 1 | replicas | core-banking / payment / risk each have ≥2 healthy containers | +| 2 | success | submit a happy-path payment, poll until `succeeded` | +| 3 | risk-reject | `smoke-reject-` → `compensated`/`rejected`; no hold, no voucher | +| 4 | insufficient | `smoke-insuff-` → `compensated`; authorization voided | +| 5 | transient | `smoke-transient-` → `compensated`; hold released | +| 6 | duplicate | same Idempotency-Key twice → one workflow_id, one voucher | +| 7 | takeover | kill a payment container mid-flight → workflow still succeeds | +| 8 | reverse | reverse a succeeded payment → `reversed=true`; voucher_reversal row exists | | 9 | compensation-failed | `smoke-compfail-` → `compensation_failed` | -| 10 | negative probes | `/internal/*` 经网关 404;host 9090/9091 关闭;admin gRPC 仅 in-network 受 token 保护 | +| 10 | negative probes | `/internal/*` via gateway → 404; host 9090/9091 closed; admin gRPC protected by token only in-network | -## Kubernetes 拓扑(base + dev + prod overlay) +## Kubernetes topology (base + dev + prod overlay) -base(`deploy/k8s/base`)+ 两个 overlay(`deploy/k8s/overlays/{dev,prod}`)。 +base (`deploy/k8s/base`) + two overlays (`deploy/k8s/overlays/{dev,prod}`). -### base —— 纯应用层 +### base — pure application layer -`kubectl kustomize deploy/k8s/base` 渲染**纯应用层**清单:7 Deployment + 15 Service(7 REST ClusterIP + 7 headless gRPC + 1 headless admin)+ 1 Ingress(仅公开 REST)+ 3 PDB(core-banking/payment/risk)+ 7 HPA(CPU 驱动)+ 1 ConfigMap + 10 NetworkPolicy(默认拒绝 + allow matrix)。 +`kubectl kustomize deploy/k8s/base` renders **pure application-layer** manifests: 7 Deployments + 15 Services (7 REST ClusterIP + 7 headless gRPC + 1 headless admin) + 1 Ingress (public REST only) + 3 PDBs (core-banking/payment/risk) + 7 HPAs (CPU-driven) + 1 ConfigMap + 10 NetworkPolicies (default-deny + allow matrix). -**base 不包含任何有状态资源**——没有 StatefulSet、PV/PVC、Secret、PostgreSQL、RabbitMQ;可运行的运行态依赖由 dev/prod overlay 注入。 +**base contains no stateful resources** — no StatefulSets, PVs/PVCs, Secrets, PostgreSQL, or RabbitMQ; runnable runtime dependencies are injected by the dev/prod overlays. -### dev overlay —— 自包含可运行 +### dev overlay — self-contained and runnable -`kubectl apply -k deploy/k8s/overlays/dev` 拉起整套可运行拓扑:base + 8 StatefulSet(7 PostgreSQL + 1 RabbitMQ,各自 PVC + headless Service + readiness/liveness 探针)+ 1 dev Secret(`stringData` 可读字面量)+ 2 data-plane NetworkPolicy。所有 stateful 资源和 Secret 标 `bank.jiade/unsafe: dev-only`。 +`kubectl apply -k deploy/k8s/overlays/dev` brings up the full runnable topology: base + 8 StatefulSets (7 PostgreSQL + 1 RabbitMQ, each with PVC + headless Service + readiness/liveness probes) + 1 dev Secret (`stringData` readable plaintext) + 2 data-plane NetworkPolicies. All stateful resources and Secrets are labelled `bank.jiade/unsafe: dev-only`. -### prod overlay —— 外部状态 + SecretProviderClass +### prod overlay — external state + SecretProviderClass -`kubectl kustomize deploy/k8s/overlays/prod` 渲染为生产形状:base + 8 ExternalName Service(指向外部托管 PostgreSQL/RabbitMQ DNS)+ 1 ConfigMap(可配置的 DNS 字面量)+ 1 SecretProviderClass(secrets-store-csi 驱动契约,无明文,operator 填 `parameters`)。**0 StatefulSet、0 Secret、0 PVC**;Kustomize `replacements` 把 ConfigMap 字面量映射到 ExternalName Service 的 `spec.externalName`。 +`kubectl kustomize deploy/k8s/overlays/prod` renders a production shape: base + 8 ExternalName Services (pointing at external managed PostgreSQL/RabbitMQ DNS) + 1 ConfigMap (configurable DNS literals) + 1 SecretProviderClass (secrets-store-csi driver contract, no plaintext; the operator fills `parameters`). **0 StatefulSets, 0 Secrets, 0 PVCs**; Kustomize `replacements` map ConfigMap literals to ExternalName Service `spec.externalName`. -三个 overlay 都保持 base 的安全基线:公开 Ingress 只路由 `/api/v1/...` REST,**绝不**路由 `/internal/*`、gRPC 9090、admin 9091;`payment-admin-ingress` NetworkPolicy 把 admin gRPC 锁在 `role=bank-operator` label 之后。 +All three overlays preserve the base security baseline: the public Ingress routes only `/api/v1/...` REST and **never** routes `/internal/*`, gRPC 9090, or admin 9091; the `payment-admin-ingress` NetworkPolicy locks admin gRPC behind the `role=bank-operator` label. -## 状态化高可用非目标 +## Stateful HA non-goals -本模板**不声称状态化高可用**。具体: +This template **does not claim stateful high availability**. Specifically: -- 本地 Compose:PostgreSQL 与 RabbitMQ 各为**单副本**,仅用于开发与集成测试。 -- dev overlay:PostgreSQL/RabbitMQ 各自单副本 StatefulSet(`replicas: 1`)——可运行,不 HA。 -- prod overlay:把 PG/RabbitMQ DNS 完全委托给外部托管服务(ExternalName);**不**包含 StatefulSet、不声称 PostgreSQL/RabbitMQ HA,亦不内置 Patroni/quorum queue/federation。把 ExternalName 指向你的 operator-managed StatefulSet 或云托管服务后再上正式流量。 +- Local Compose: PostgreSQL and RabbitMQ are each **single-replica**, for development and integration testing only. +- dev overlay: PostgreSQL/RabbitMQ are each single-replica StatefulSets (`replicas: 1`) — runnable, not HA. +- prod overlay: delegates PG/RabbitMQ DNS entirely to external managed services (ExternalName); **contains no StatefulSet, makes no PostgreSQL/RabbitMQ HA claim, and includes no Patroni/quorum-queue/federation**. Point the ExternalNames at your operator-managed StatefulSets or managed cloud services before serving real traffic. -无状态服务的 HA 由 Deployment + PDB(core-banking/payment/risk,`minAvailable=1`)+ HPA 提供。 +Stateless service HA is provided by Deployment + PDB (core-banking/payment/risk, `minAvailable=1`) + HPA. -## 清理 +## Cleanup ```bash -make down # compose down:停 + 删容器 + 删卷(--volumes) -make observability-down # 仅下掉可观测性 overlay 容器 +make down # compose down: stop + remove containers + delete volumes (--volumes) +make observability-down # tear down only the observability overlay containers ``` -**警告**:`make down` 会执行 `--volumes --remove-orphans`,**抹掉全部 7 个 PostgreSQL 数据卷和 RabbitMQ mnesia 卷**。RabbitMQ 的 `definitions.json`(交换机/队列/绑定)仅在**首次卷初始化**时载入——`make down` 后再 `make up` 是干净拓扑,但**手动改过 `definitions.json` 后必须 `make down` 才能生效**(仅重启容器不会重读)。 +**Warning**: `make down` runs `--volumes --remove-orphans`, **wiping all 7 PostgreSQL data volumes and the RabbitMQ mnesia volume**. RabbitMQ's `definitions.json` (exchanges/queues/bindings) is loaded **only during first volume initialisation** — after `make down`, `make up` produces a clean topology, but **manually editing `definitions.json` requires `make down` to take effect** (merely restarting the container will not re-read it). -Kubernetes 集群清理(独立集群,如果你 apply 过): +Kubernetes cluster cleanup (separate cluster, if you applied the manifests): ```bash -kubectl delete -k deploy/k8s/overlays/dev # 或 overlays/prod +kubectl delete -k deploy/k8s/overlays/dev # or overlays/prod ``` -dev overlay 的 PVC 不会随 `kubectl delete -k` 自动删除;手动 `kubectl delete pvc -l bank.jiade/unsafe=dev-only -n bank` 才能彻底清掉卷。 +dev overlay PVCs are not automatically deleted by `kubectl delete -k`; run `kubectl delete pvc -l bank.jiade/unsafe=dev-only -n bank` to fully remove the volumes. -## 金融不变量 +## Financial invariants -- 金额用 int64 分表示,禁 float。 -- 复式记账只在 core:过账强制 sum(借)==sum(贷),不平回滚——既护 seed 也护 B-3 运行时记账/冲正,亦护 saga `PostLedgerTransfer` 的 forward/补偿路径。customer/payment 无总账。 -- 工作流引擎从不静默删除或抛弃实例;超过 `OperationalDeadline` 仅记录并安排 wake-up。 -- 运营者操作永远留下不可变审计行(同一事务),不可 UPDATE/DELETE。 +- Money is represented as int64 minor units; floats are prohibited. +- Double-entry posting lives only in core: posting enforces sum(debit)==sum(credit), rolling back on imbalance — protecting both seed data and B-3 runtime posting/reversal, as well as the saga's `PostLedgerTransfer` forward/compensation paths. customer/payment have no ledger. +- The workflow engine never silently deletes or abandons instances; past `OperationalDeadline` it only records and schedules a wake-up. +- Operator actions always leave an immutable audit row (same transaction), never UPDATE/DELETE-able. -## 架构 +## Architecture -见 [ARCHITECTURE.md](ARCHITECTURE.md)。7 进程 + 7 独立 PostgreSQL + RabbitMQ + Traefik 网关;同步跨域读取走内部 gRPC,异步命令/事件走 RabbitMQ;每服务分层 `api → service → repo → domain`,domain 零外部依赖。支付 durable saga 由 payment 内的 `internal/platform/workflow` 引擎编排。 +See [ARCHITECTURE.md](ARCHITECTURE.md). 7 processes + 7 independent PostgreSQL databases + RabbitMQ + Traefik gateway; synchronous cross-domain reads via internal gRPC, asynchronous commands/events via RabbitMQ; each service layers as `api → service → repo → domain` with zero external dependencies in the domain layer. The payment durable saga is orchestrated by the `internal/platform/workflow` engine inside payment. diff --git a/templates/bank/README.zh-CN.md b/templates/bank/README.zh-CN.md new file mode 100644 index 0000000..a0acd92 --- /dev/null +++ b/templates/bank/README.zh-CN.md @@ -0,0 +1,375 @@ +# bank(jiade 模板:7 服务纵切——core-banking + customer + payment + reward + risk + loan + wealth) + +[English README](README.md) + +简化版银行核心系统——「现实世界大工程的缩影」。本工程由 `jiade init --template bank` 生成,**自包含**:离开 jiade 也可独立运行(仅需 docker + go)。 + +本模板属于 [jiade](../../README.zh-CN.md) 项目;架构细节见 [ARCHITECTURE.md](ARCHITECTURE.md)。 + +工程包含 **7 服务 + 7 独立 PostgreSQL 库 + Traefik 网关 + RabbitMQ + 内部 gRPC + 逐日滚存/三因子 fixture + durable 支付 saga + 全栈可观测性 + dev/prod Kubernetes overlay**。每个服务只访问自己的数据库: + +| 服务 | 容器端口 | 库 | 默认副本 | 内容 | +|------|----------|----|----------|------| +| core-banking | 8080 (REST) / 9090 (gRPC) | core_db | 2 | 活期/定存账户、复式记账总账、逐日余额、写接口(过账/冲正)、**资金冻结(hold/release/capture)** | +| customer | 8080 (REST) / 9090 (gRPC) | cust_db | 1 | 客户信息、账户关系 | +| payment | 8080 (REST) / 9091 (admin gRPC) | pay_db | 2 | 商户、消费流水、**durable 支付工作流引擎(saga 编排 + 不可变审计)** | +| reward | 8080 (REST) | reward_db | 1 | 积分账户/流水、优惠券、活动 | +| risk | 8080 (REST) | risk_db | 2 | 风控规则、事件、黑名单、**支付授权(authorize/void)** | +| loan | 8080 (REST) | loan_db | 1 | 借据、放款、月度还款、五级分类逾期、**逐日余额快照** | +| wealth | 8080 (REST) | wealth_db | 1 | 理财产品、**逐日净值游走**、持仓、申赎订单、每日利息 | + +**网关**:Traefik 是唯一对外发布的端口——`http://localhost:18000`。所有公开 REST 路径(`/api/v1/...`)经网关路由到对应服务的 8080 端口;`/internal/*`、gRPC 9090(读取)与 admin gRPC 9091(运营)**不暴露给宿主机**。 + +**服务间通信**:同步只读查询走内部 gRPC(customer、core-banking 在 :9090 提供 `CustomerQueryService` / `AccountQueryService`);支付 saga 的异步命令与领域事件走 RabbitMQ(事务发件箱 + 有限重试 + DLQ + 运营者冲正)。详见 [ARCHITECTURE.md](ARCHITECTURE.md)。 + +## 数据引擎要点 + +每个服务都是同一个四层纵切(`api → service → repo → domain`)。数据引擎要点: + +- **确定性 fixture**:同 seed + scale → 完全相同的行。确定性 ID(无 UUID),逐日独立 rng(`seed + 偏移 + 日序`)。 +- **两种数据形态**:三因子事件流(`趋势 × 季节 × 周期`——周末单量 < 工作日)与路径依赖的**逐日滚存快照**(账户余额、借据余额、净值游走)。 +- **数据库按服务隔离**:每个服务独占一个 PostgreSQL 实例与卷,只查自己的库;跨域只读数据通过内部 gRPC 获取(如 loan 调 customer 的 `CustomerQueryService`)。 +- **金额 int64 分,禁 float**;利率/净值/份额等非货币小数按 NUMERIC 文本直存。 +- **生成物自包含**:离开 jiade 也能构建运行——只需 Docker 和 Go。 + +## 快速开始 + +```bash +make up # docker compose up -d --build --wait,然后 make seed +make seed # 建 7 库 → 建 7 库表 → 灌 7 域 fixture(9 步,幂等:--reset) +``` + +灌数规模:`--scale=dev`(约 1/4 量,默认)或 `--scale=full`。同 seed 重跑 `make seed`(或 `jiade seed`)产出完全相同的数据。`make seed` 走 `--reset`,会重建全部 7 库。 + +```bash +make seed # dev 规模(默认) +SCALE=full make seed # full 规模 +go test -tags=integration -p 1 ./... # 集成测试,需本机 15432 有 postgres(DB_PORT 可覆盖) +``` + +所有公开 REST 端点经网关 `http://localhost:18000` 访问;服务容器端口不发布到宿主机。`make up` 使用 `--wait`,会等到全部 healthcheck 就绪再返回。查看单个服务健康状态: + +```bash +docker compose ps # 各服务 health 列 +docker compose exec core-banking wget -qO- :8080/healthz # 容器内探针 +``` + +core-banking 只读查询(Spec A,经网关): + +```bash +curl -sf localhost:18000/api/v1/accounts/D0000000001 +curl -sf localhost:18000/api/v1/accounts/D0000000001/balance +``` + +core-banking 记账/冲正写接口(Spec B-3;复式过账强制 sum(借)==sum(贷),`LedgerService.Post` 已内部化,客户端只见业务意图): + +```bash +# 记账:存入 100 元(deposit / withdraw / transfer) +curl -sf -X POST localhost:18000/api/v1/txns \ + -H 'Content-Type: application/json' \ + -d '{"action":"deposit","account_no":"D0000000001","amount":"100.00","ccy":"CNY"}' +# → 201 {"voucher_no":"V...","biz_date":"...","txns":[{借/贷两条分录}]} + +# 冲正:蓝冲(默认,改状态+回滚余额,不新增流水) +curl -sf -X POST 'localhost:18000/api/v1/vouchers/V.../reverse?mode=blue' +# → 200 {"voucher_no":"V...","mode":"blue","status":"reversed"} +# mode=red 走反向分录(新增反向流水,返回 reversed_voucher_no) +``` + +## 支付工作流(durable saga) + +**入口**:`POST /api/v1/payments/workflows`(payment 服务,经网关 18000 → payment:8080)。 + +工作流是 payment 服务内置的 durable saga 编排器(`internal/platform/workflow`):每次请求落库为一个不可变 Instance,按顺序执行三个 Action;任一 Action 终态失败时按**逆序**触发补偿;运营者可经 admin gRPC 干预卡住的补偿。所有 saga 命令/事件经 RabbitMQ 投递,落库前与业务写在同一 PostgreSQL 事务中(事务发件箱),保证「状态变更」与「事件发出」原子一致。 + +### 提交一个支付 + +```bash +# Idempotency-Key 必填;同 key + 同 body 重放返回原 workflow_id(200, replayed=true); +# 同 key + 不同 body 返回 409 idempotency_conflict。 +curl -sf -X POST localhost:18000/api/v1/payments/workflows \ + -H 'Content-Type: application/json' \ + -H 'Idempotency-Key: my-key-0001' \ + -d '{ + "payer_customer_id":"C0000001", + "payer_account_no":"A000000001", + "payee_account_no":"A000000002", + "currency":"CNY", + "amount_minor":5000 + }' +# → 201 {"workflow_id":"wf-...","status":"preparing","replayed":false} +# amount_minor 是 int64 分(5000 = 50.00 CNY);必须 > 0。 +``` + +查询状态: + +```bash +curl -sf localhost:18000/api/v1/payments/workflows/wf-... +# → 200 {"workflow_id":"wf-...","status":"succeeded","reversed":false,...} +``` + +对已 `succeeded` 的工作流发起冲正(触发补偿 saga): + +```bash +curl -sf -X POST localhost:18000/api/v1/payments/workflows/wf-.../reverse +# → 200 {"workflow_id":"wf-...","reversal_workflow_id":"wf-...","status":"compensating"} +``` + +### 工作流状态机(Instance.Status) + +``` +preparing ──► ready ──► running ──┬─► succeeded + │ + ├─► rejected (业务终态:风控拒、KYC 失败) + │ + └─► compensating ──┬─► compensated + │ + └─► compensation_failed + (需运营者 gRPC 介入; + 详见「运营者冲正」) +``` + +| 状态 | 含义 | +|------|------| +| `preparing` | 准备中:读客户/账户快照、KYC/黑名单校验、构造不可变 `TransferContext`。 | +| `ready` | 准备完成,等待引擎首次调度。 | +| `running` | 已分发当前 Action 命令,等待下游结果事件(每个 Action 15 秒操作超时;超时由恢复循环重发,不抛弃实例)。 | +| `succeeded` | 三个 Action 全部成功;账务已过账、冻结已 capture。终态。 | +| `rejected` | 业务终态失败(风控拒绝、活动状态无效、 insufficient funds);已按需补偿。终态。 | +| `compensating` | 触发了逆序补偿(任一 forward Action 终态失败或显式 `reverse`)。 | +| `compensated` | 所有已成功 Action 均已逆序补偿。终态。 | +| `compensation_failed` | 补偿在 `CompensationMaxAttempts=5` 次内未成功;实例卡住等待运营者冲正。 | + +引擎默认:`ExecuteMaxAttempts=3`、`CompensationMaxAttempts=5`、`OperationalDeadline=2m`、Action 操作超时 15 s。超过 `OperationalDeadline` 不删除/不抛弃实例,仅记录错误并安排 wake-up——**durable 工作流从不静默丢失**。 + +### Action / Compensation 序列 + +forward 三个 Action(顺序执行,下游 consumer 在 risk / core-banking): + +| # | Action | 下发命令(routing key) | 接受的结果事件 | 含义 | +|---|--------|------------------------|----------------|------| +| 0 | `AuthorizeRisk` | `risk.authorize-payment.v1` | `risk.payment-authorized.v1` / `risk.payment-rejected.v1` | 风控授权(KYC、黑名单、规则) | +| 1 | `PlaceFundsHold` | `core.place-hold.v1` | `core.hold-placed.v1` / `core.hold-failed.v1` | 在 core-banking 预冻结付款人账户金额 | +| 2 | `PostLedgerTransfer` | `core.post-held-transfer.v1` | `core.transfer-posted.v1` / `core.transfer-failed.v1` | 复式过账转账(冻结转 capture,借贷同时落账) | + +补偿(任一 forward Action 终态失败时,按**逆序**对每个已 `succeeded` 的 Action 单独下发补偿命令;不会自动跳过任何金融步骤): + +| 原 Action | 补偿命令(routing key) | 接受的结果事件 | 含义 | +|-----------|------------------------|----------------|------| +| `PostLedgerTransfer` | `core.reverse-transfer.v1` | `core.transfer-reversed.v1` / `core.transfer-reverse-failed.v1` | 反向分录冲销已过账的转账 | +| `PlaceFundsHold` | `core.release-hold.v1` | `core.hold-released.v1` / `core.hold-release-failed.v1` | 释放此前预冻结的金额 | +| `AuthorizeRisk` | `risk.void-payment-authorization.v1` | `risk.payment-authorization-voided.v1` | 作废风控授权记录 | + +Action 之间通过 `priorActionOutput(actions, name)` 按语义名读取上游 Output(例如 `PostLedgerTransfer` 从 `PlaceFundsHold` 的 Output 中取 `hold_id`),不依赖硬编码位置索引。 + +失败分类(`ErrorClass`,由 consumer 在失败 payload 上盖戳,引擎根据类别决定 retry/compensate/leave-running): + +- `business_rejected` → 终态,触发补偿(如风控拒、余额不足) +- `transient_failure` → 可重试,实例保持 `running`(broker/依赖暂时不可用) +- `invariant_violation` → 终态(账务不平、hold 状态错),触发补偿 +- `invalid_message` → 终态结构错(未知消息类型;forward 方向保持 running 待恢复,不立即补偿) +- `unknown_outcome` → 不识别的 payload,保持 running 等待恢复循环重发 + +## 跨服务聚合端点 + +服务经内部 gRPC 协作,**不跨库查询**: + +```bash +# customer 查本库账户关系,再调用 core-banking gRPC 获取账户资料 +curl -sf localhost:18000/api/v1/customers/C0000001/accounts + +# payment 查本库转账,再调用 core-banking 和 customer gRPC 获取双方资料 +curl -sf localhost:18000/api/v1/payments/transfers/PT000000000001/parties +``` + +预期:`/accounts` 返回该客户的 core 账户资料;`/parties` 返回转账双方账号 + 户主客户姓名。 + +loan/wealth 只读端点示例(Spec B-4b): + +```bash +curl -sf localhost:18000/api/v1/loan/accounts +curl -sf localhost:18000/api/v1/loan/accounts/{loan_no}/profile +curl -sf localhost:18000/api/v1/wealth/holdings/{holding_id}/profile +``` + +## 服务调用拓扑与边界 + +| 边界 | 用途 | 协议/交换机 | 谁用 | +|------|------|-----------|------| +| 同步只读 | 跨域读取(客户、账户) | **内部 gRPC**(customer/core-banking 在 `:9090`) | reward/risk/loan/wealth/payment.preparation 都经 `platform/serviceclient` 拨号 | +| 异步命令(saga forward + compensation) | payment 下发到下游执行 | **`bank.commands`(topic)** | payment 发;risk / core-banking 消费 | +| 异步结果事件 | 下游回报 Action 成败 | **`bank.events`(topic)** | risk / core-banking 发;payment 消费(`payment.workflow.events` 队列) | +| 领域完成事件 | payment 完成后通知下游 | **`bank.events`**,`payment.completed` 路由键 | payment 发;reward 消费(`reward.payment-events` 队列,发积分) | +| 运营者 gRPC | 干预卡住的补偿(运维面) | **admin gRPC `:9091`**(`payment-admin` headless Service) | 仅 label `role=bank-operator` 的 Pod 可达 | + +**服务发现**(DNS,round-robin 负载均衡): + +- Compose:服务名(如 `core-banking`、`customer`)在 `bank-data` 网络上直接解析为多 IP。 +- Kubernetes base:7 个 REST ClusterIP(``, http=8080)+ 7 个 headless gRPC(`-grpc`, clusterIP=None, publishNotReadyAddresses=false, grpc=9090)+ 1 个 headless admin(`payment-admin`, admin-grpc=9091)。headless + publishNotReadyAddresses=false 确保 gRPC 客户端只拨到已就绪的 Pod。 +- gRPC 拨号字符串:`CUSTOMER_GRPC_TARGET=dns:///customer:9090`、`CORE_BANKING_GRPC_TARGET=dns:///core-banking:9090`、`ADMIN_GRPC_ADDR=:9091`。 + +## 扩缩容 + +```bash +# 仅扩缩指定服务,不动依赖 +make scale SERVICE=payment REPLICAS=3 +make scale SERVICE=core-banking REPLICAS=2 +``` + +副本默认(见上表):core-banking / payment / risk = 2;customer / reward / loan / wealth = 1。Kubernetes base 对 core-banking/payment/risk 设了 PDB(`minAvailable=1`),所有 7 服务都挂了 CPU 80% 驱动的 HPA,`minReplicas` 对齐 compose 默认。 + +## 运行拓扑 + +- **网关**:Traefik v3 是唯一发布到宿主机的端口(`18000:8080`);只路由公开 `/api/v1/...` REST 前缀。 +- **专用数据库**:七个 PostgreSQL 16 实例(`core-banking-db` … `wealth-db`),各自独立卷,不存在跨库 SQL / 外部表 / 共享库权限。 +- **消息中间件**:RabbitMQ 4,四个交换机:`bank.commands` / `bank.events`(topic)+ `bank.retry` / `bank.dlx`(direct)。命令/事件队列各带 `.retry`(2 秒 TTL)+ `.dlq` 伴侣。完整绑定见 `deploy/rabbitmq/definitions.json`。 +- **内部 gRPC**:`customer:9090` 与 `core-banking:9090` 对内暴露只读查询;其余五服务仅起 REST,作为 gRPC 消费方。 +- **副本默认**:core-banking / payment / risk = 2;customer / reward / loan / wealth = 1。 +- **本地资源预算**:全栈约 6–8 GB 内存。应用容器限 512 MB / 1 CPU,PostgreSQL 限 768 MB,RabbitMQ 限 768 MB,Traefik 限 256 MB。 +- **弹性扩缩**:`make scale SERVICE=payment REPLICAS=3`(仅扩缩指定服务,不动依赖)。 +- **安全基线**:应用容器 `read_only` + `tmpfs:/tmp` + `cap_drop: ALL` + `no-new-privileges`。 + +## 可观测性 + +每个服务在 8080 上暴露 `/livez` / `/readyz` / `/metrics`(Prometheus 格式)+ `/healthz`。`/readyz` 在优雅关闭 drain 期间返回 503,Traefik 与 Kubernetes Ingress 用它做 drain。 + +```bash +make observability # 拉 otel-collector / prometheus / grafana / jaeger(独立 bank-obs 网络) +make trace-smoke # 提交一笔支付,断言 Jaeger 收到 REST+gRPC+messaging+workflow 跨层 trace +make observability-check # jq 校验 dashboard JSON + promtool 校验 Prometheus 配置 + alerts +make observability-down # 仅下掉可观测性 overlay 容器 +``` + +**注意**:`make trace-smoke` 需要先 `make observability` + `make smoke`(后者会 apply smoke overlay,开启 `BANK_TEST_FAILURES_ENABLED=true`,使 payment 能用确定性 idempotency-key 前缀提交)。trace-smoke 本身走 success 路径,不依赖故障注入。 + +**Jaeger**:host 端口**不**发布——通过 `bank-obs` 网络内 `docker run --rm --network bank-obs curlimages/curl:8.10.1` 查询,符合「内部观测面不外暴露」的安全基线。 + +**Grafana**:3 个 dashboard(`deploy/grafana/dashboards/`)——`payment-workflows`(状态计数、Action p95 时延、补偿失败、最长等待)、`message-reliability`(发件箱年龄、收件箱去重、consumer lag)、`core-ledger`(过账/冲正计数、不变量失败)。 + +**Prometheus 告警**(`deploy/prometheus/alerts.yaml`,5 条;`for` 0–1m): + +| 告警 | 表达式 | 触发含义 | +|------|--------|---------| +| `WorkflowWaitingStuck` | `max(workflow_waiting_age_seconds) > 60` | 1 分钟内有工作流等待结果超过 60 秒 | +| `WorkflowCompensationFailure` | `sum(workflow_compensation_failures_total) > 0` | 1 分钟内出现补偿失败 | +| `OutboxBacklog` | `max(outbox_oldest_age_seconds) > 30` | 发件箱积压超 30 秒未投递 | +| `ConsumerLagHigh` | `sum(rabbitmq_consumer_lag) > 100` | 消费者滞后超 100 条 | +| `LedgerInvariantFailure` | `sum(ledger_invariant_failures_total) > 0` | 账务不变量失败(**`for: 0m`**——立即触发) | + +## 死信队列与故障恢复 + +**RabbitMQ 拓扑**(`deploy/rabbitmq/definitions.json`): + +- **topic**:`bank.commands`(命令)、`bank.events`(事件)——通配符绑定(`risk.#`、`core.#` 等)确保 saga 所有 routing key 都能命中 consumer。 +- **direct**:`bank.retry`(重试)、`bank.dlx`(死信终点)。 +- 每个消费队列都有 `.retry` 伴侣(`x-message-ttl: 2000` + `x-dead-letter-exchange` 指回源 topic)+ `.dlq`(终端死信)。 + +**投递语义**:**至少一次**,安全由两件事保证: + +- **事务发件箱**(publisher 侧):状态变更和发件箱行在同一 PostgreSQL 事务里写。每服务一个 drain 循环 poll 发件箱,`claim_token`/`claimed_at` 让多实例 drain 不双发。RabbitMQ Publisher Confirm 确认投递成功后再标 dispatched。 +- **幂等收件箱**(consumer 侧):每个 consumer 在 apply 前先写 `inbox_event(event_id, ...)`,重复投递被去重。这是 at-least-once 安全的核心。 + +**重试与死信路径**:consumer 处理失败时按 `RetryPolicy`(默认 `MaxAttempts=5`)路由到 `bank.retry` 对应队列;2 秒 TTL 到期后 dead-letter 回源 topic 重新投递。重试次数耗尽后,消息路由到对应 `.dlq` 队列供人工检查(**不再自动重投**)。 + +**workflow 实例层面的对应关系**: +- consumer 侧的 `transient_failure`(broker/依赖暂不可用)→ 消息进 retry → 重新投递。工作流 Action 的 15 秒操作超时由 payment 引擎的恢复循环重发命令。 +- 终态失败(`business_rejected` / `invariant_violation` / `invalid_message`)→ 工作流进入 `compensating`。 +- 补偿 `transient_failure` 次数耗尽(`CompensationMaxAttempts=5`)→ 工作流 `compensation_failed`,等待运营者冲正(见下)。 + +## 运营者冲正(admin gRPC,:9091) + +支付工作流提供**两个** protected RPC(`proto/bank/payment/v1/workflow_admin.proto`),由 payment 服务在独立 admin gRPC 端口 `:9091` 上发布(`payment-admin` headless Service),**不出现在网关**: + +| RPC | 用途 | +|-----|------| +| `RetryCompensation(workflow_id, reason)` | 对卡在 `compensation_failed` 的实例重发补偿命令。完全自动化路径。 | +| `RecordReconciliation(workflow_id, action_name, external_reference, reason)` | 对卡住的补偿 Action 用一份**不可变外部对账引用**强制 resolve。调用前 server 会先 `Reconciler.ValidateReconciliation` 校验 core-banking 当前真实状态(funds-hold Action:hold 已释放;ledger-transfer Action:反向凭证已存在 + 借贷平衡),校验通过才落审计 + resolve。 | + +**访问控制(三层)**: + +1. **NetworkPolicy**(`deploy/k8s/base/networking.yaml`):`payment-admin-ingress` 默认拒绝 9091 入站;只有带 `role=bank-operator` label 的 Pod 可达。 +2. **token 校验**:调用方必须在 gRPC metadata 里带 `x-bank-operator-token`;server 用 `crypto/subtle.ConstantTimeCompare` 常数时间比较。token 从环境变量 `BANK_OPERATOR_TOKEN` 读取;**空值即 fail-closed**(startup 警告,所有 RPC 拒绝)。 +3. **不可变审计**:`workflow_operator_audit` 表带 `BEFORE UPDATE OR DELETE` 触发器;每次 `RetryCompensation` / `RecordReconciliation` 与状态变更在**同一事务**内 INSERT 一条审计记录(operator / action / reference / reason / prev_state / new_state / created_at),永不修改。 + +`RecordReconciliation` 需要 core-banking 暴露 hold 状态与反向凭证查询 RPC 才能完整工作;模板带一个 fail-closed placeholder,未注入真实 inspector 时返回 `FailedPrecondition`。`RetryCompensation` 立即可用。 + +**注入 token**: + +- Compose:默认不设;如需本地演练 admin RPC,在 `.env` 或 compose override 里设 `BANK_OPERATOR_TOKEN`。 +- Dev overlay:`deploy/k8s/overlays/dev/secret.yaml` 内置可读字面量 `dev-operator-token`(标 `dev-only`)。 +- Prod overlay:`deploy/k8s/overlays/prod/secret-contract.yaml` 用 SecretProviderClass 契约(无明文)从外部 vault 投射。 + +## 失败注入 smoke 套件(10 gate) + +`make smoke`(`test/smoke.sh`)运行 10 个 gate,覆盖 saga 的成功/失败/恢复路径。失败注入由 `internal/platform/testfail` 提供,**仅**当 `BANK_TEST_FAILURES_ENABLED=true` 且 workflow id 以特定前缀(`smoke-reject-` / `smoke-insuff-` / `smoke-transient-` / `smoke-compfail-`)开头时触发;`compose.smoke.yaml` 是唯一开启该 env 的地方,且只由 smoke 脚本 apply。 + +| # | Gate | 断言 | +|---|------|------| +| 1 | replicas | core-banking / payment / risk 各 ≥2 健康容器 | +| 2 | success | 提交一笔 happy-path,poll 至 `succeeded` | +| 3 | risk-reject | `smoke-reject-` → `compensated`/`rejected`;无 hold、无 voucher | +| 4 | insufficient | `smoke-insuff-` → `compensated`;授权作废 | +| 5 | transient | `smoke-transient-` → `compensated`;hold 释放 | +| 6 | duplicate | 同 Idempotency-Key 两次 → 一个 workflow_id、一个 voucher | +| 7 | takeover | 中途 kill 一个 payment 容器 → 工作流仍成功 | +| 8 | reverse | 冲正一个 succeeded 支付 → `reversed=true`;voucher_reversal 行存在 | +| 9 | compensation-failed | `smoke-compfail-` → `compensation_failed` | +| 10 | negative probes | `/internal/*` 经网关 404;host 9090/9091 关闭;admin gRPC 仅 in-network 受 token 保护 | + +## Kubernetes 拓扑(base + dev + prod overlay) + +base(`deploy/k8s/base`)+ 两个 overlay(`deploy/k8s/overlays/{dev,prod}`)。 + +### base —— 纯应用层 + +`kubectl kustomize deploy/k8s/base` 渲染**纯应用层**清单:7 Deployment + 15 Service(7 REST ClusterIP + 7 headless gRPC + 1 headless admin)+ 1 Ingress(仅公开 REST)+ 3 PDB(core-banking/payment/risk)+ 7 HPA(CPU 驱动)+ 1 ConfigMap + 10 NetworkPolicy(默认拒绝 + allow matrix)。 + +**base 不包含任何有状态资源**——没有 StatefulSet、PV/PVC、Secret、PostgreSQL、RabbitMQ;可运行的运行态依赖由 dev/prod overlay 注入。 + +### dev overlay —— 自包含可运行 + +`kubectl apply -k deploy/k8s/overlays/dev` 拉起整套可运行拓扑:base + 8 StatefulSet(7 PostgreSQL + 1 RabbitMQ,各自 PVC + headless Service + readiness/liveness 探针)+ 1 dev Secret(`stringData` 可读字面量)+ 2 data-plane NetworkPolicy。所有 stateful 资源和 Secret 标 `bank.jiade/unsafe: dev-only`。 + +### prod overlay —— 外部状态 + SecretProviderClass + +`kubectl kustomize deploy/k8s/overlays/prod` 渲染为生产形状:base + 8 ExternalName Service(指向外部托管 PostgreSQL/RabbitMQ DNS)+ 1 ConfigMap(可配置的 DNS 字面量)+ 1 SecretProviderClass(secrets-store-csi 驱动契约,无明文,operator 填 `parameters`)。**0 StatefulSet、0 Secret、0 PVC**;Kustomize `replacements` 把 ConfigMap 字面量映射到 ExternalName Service 的 `spec.externalName`。 + +三个 overlay 都保持 base 的安全基线:公开 Ingress 只路由 `/api/v1/...` REST,**绝不**路由 `/internal/*`、gRPC 9090、admin 9091;`payment-admin-ingress` NetworkPolicy 把 admin gRPC 锁在 `role=bank-operator` label 之后。 + +## 状态化高可用非目标 + +本模板**不声称状态化高可用**。具体: + +- 本地 Compose:PostgreSQL 与 RabbitMQ 各为**单副本**,仅用于开发与集成测试。 +- dev overlay:PostgreSQL/RabbitMQ 各自单副本 StatefulSet(`replicas: 1`)——可运行,不 HA。 +- prod overlay:把 PG/RabbitMQ DNS 完全委托给外部托管服务(ExternalName);**不**包含 StatefulSet、不声称 PostgreSQL/RabbitMQ HA,亦不内置 Patroni/quorum queue/federation。把 ExternalName 指向你的 operator-managed StatefulSet 或云托管服务后再上正式流量。 + +无状态服务的 HA 由 Deployment + PDB(core-banking/payment/risk,`minAvailable=1`)+ HPA 提供。 + +## 清理 + +```bash +make down # compose down:停 + 删容器 + 删卷(--volumes) +make observability-down # 仅下掉可观测性 overlay 容器 +``` + +**警告**:`make down` 会执行 `--volumes --remove-orphans`,**抹掉全部 7 个 PostgreSQL 数据卷和 RabbitMQ mnesia 卷**。RabbitMQ 的 `definitions.json`(交换机/队列/绑定)仅在**首次卷初始化**时载入——`make down` 后再 `make up` 是干净拓扑,但**手动改过 `definitions.json` 后必须 `make down` 才能生效**(仅重启容器不会重读)。 + +Kubernetes 集群清理(独立集群,如果你 apply 过): + +```bash +kubectl delete -k deploy/k8s/overlays/dev # 或 overlays/prod +``` + +dev overlay 的 PVC 不会随 `kubectl delete -k` 自动删除;手动 `kubectl delete pvc -l bank.jiade/unsafe=dev-only -n bank` 才能彻底清掉卷。 + +## 金融不变量 + +- 金额用 int64 分表示,禁 float。 +- 复式记账只在 core:过账强制 sum(借)==sum(贷),不平回滚——既护 seed 也护 B-3 运行时记账/冲正,亦护 saga `PostLedgerTransfer` 的 forward/补偿路径。customer/payment 无总账。 +- 工作流引擎从不静默删除或抛弃实例;超过 `OperationalDeadline` 仅记录并安排 wake-up。 +- 运营者操作永远留下不可变审计行(同一事务),不可 UPDATE/DELETE。 + +## 架构 + +见 [ARCHITECTURE.md](ARCHITECTURE.md)。7 进程 + 7 独立 PostgreSQL + RabbitMQ + Traefik 网关;同步跨域读取走内部 gRPC,异步命令/事件走 RabbitMQ;每服务分层 `api → service → repo → domain`,domain 零外部依赖。支付 durable saga 由 payment 内的 `internal/platform/workflow` 引擎编排。 diff --git a/templates/commerce/README.md b/templates/commerce/README.md index c140b52..c931a60 100644 --- a/templates/commerce/README.md +++ b/templates/commerce/README.md @@ -1,5 +1,7 @@ # Commerce Template +[中文文档](README.zh-CN.md) + A complete commerce backend microcosm: catalog/SKUs, customers, inventory reservations, orders, payments/refunds, split fulfillment, and tracking. Six Go microservices, six service-owned PostgreSQL databases, RabbitMQ, and a diff --git a/templates/commerce/README.zh-CN.md b/templates/commerce/README.zh-CN.md new file mode 100644 index 0000000..44559b1 --- /dev/null +++ b/templates/commerce/README.zh-CN.md @@ -0,0 +1,324 @@ +# Commerce 模板 + +[English README](README.md) + +一个完整的电商后端缩影:商品/SKU、客户、库存预占、订单、支付/退款、分仓发货与物流追踪。六个 +Go 微服务、六个服务独占的 PostgreSQL 库、RabbitMQ,以及一个 Traefik 网关——小到可以完整理解, +真到能端到端运行。 + +默认全栈面向**单台 Docker 主机(或 kind/minikube 节点)的 4–6 GB 内存预算**。更大的规模供 +压测使用;见 [Seed 规模](#seed-规模)。 + +本模板属于 [jiade](../../README.zh-CN.md) 项目。用以下命令生成可运行副本: + +```bash +jiade init --template commerce --dir ./myshop +cd myshop && make up +``` + +## 快速开始 + +前置条件:Docker(含 compose)、`make`、`curl`、`jq`。 + +```bash +# 1. 构建、迁移并按默认副本灌入拓扑。Makefile 目标会等待每个服务健康后返回。 +make up + +# 2. 探一下网关(主机端口 18100 是唯一发布到宿主机的端口)。 +curl -fsS http://localhost:18100/api/v1/products?limit=1 | jq . +curl -fsS http://localhost:18100/api/v1/customers?limit=1 | jq . + +# 3. 运行 Phase-B 验收脚本(见下方「失败注入」)。 +make smoke + +# 4. 全部拆除(含数据卷)。 +make down +``` + +`make up` 一步完成 **构建 → 迁移 → 灌数 → 等待健康**。该操作幂等:重复运行会重建变更过的镜像、 +重新应用迁移,并以 `--reset`(丢弃并重建确定性 fixture)重新灌数。 + +## 拓扑 + +六个 Go 服务及其后端存储。Traefik 是唯一对外可达的入口——没有任何服务或数据库端口发布到宿主机。 + +| 服务 | 副本 | 数据库 | 职责 | +|------|-----:|--------|------| +| catalog | 2 | catalog | 商品、SKU、价格快照 | +| customer | 1 | customer | 客户、地址 | +| inventory | 2 | inventory | SKU 库存、预占状态机 | +| order | 2 | order | 购物车、结账 saga、订单投影 | +| payment | 1 | payment | 支付意向/尝试、退款、webhook | +| fulfillment | 1 | fulfillment | 仓库分单、发货、物流追踪 | + +每个服务独占一个 PostgreSQL 16 容器、数据库、schema 和命名卷(无共享库)。跨服务读取走 HTTP; +状态传播走 RabbitMQ topic 事件。数据流详见 [ARCHITECTURE.md](ARCHITECTURE.md)。 + +## 端点 + +外部路由(仅可通过 Traefik 网关 `:18100` 访问): + +| 方法 | 路径 | 服务 | 说明 | +|------|------|------|------| +| GET | `/api/v1/products` | catalog | 商品列表/搜索 | +| GET | `/api/v1/customers` | customer | 客户列表 | +| GET | `/api/v1/inventory` | inventory | SKU 库存水位 | +| POST/GET | `/api/v1/reservations/{order_id}` | inventory | 预占状态 | +| POST | `/api/v1/carts` / `/api/v1/carts/{id}/items` | order | 购物车生命周期 | +| POST | `/api/v1/checkouts` | order | 结账 saga 入口 | +| GET | `/api/v1/orders` / `/api/v1/orders/{id}` | order | 订单投影 | +| GET | `/api/v1/payments/orders/{id}` | payment | 支付意向视图 | +| POST | `/api/v1/payments/webhooks` | payment | 幂等 webhook 接入 | +| GET | `/api/v1/fulfillment/orders/{id}` | fulfillment | 发货 + 物流 | + +内部路由(`/internal/v1/...`)仅可通过服务网络中的服务 DNS 访问。Traefik 与 Kubernetes Ingress +刻意不对这些路径配置规则。它们是服务间契约,不是网关兜底:客户端必须使用公开的 `/api/v1/...` +路由,而需要内部路由的工作负载必须在私有网络上按 DNS 调用所属服务。 + +每个响应都包含一个 `X-Service-Instance` 响应头,标明处理请求的副本。该响应头是负载均衡验证 +探针——见下一节。 + +## 扩缩容 + +每个服务都有已批准的默认副本数(见上表)。可以按调用覆盖任意一个: + +```bash +# 仅扩缩单个服务,不影响其他服务。 +make scale SERVICE=order REPLICAS=4 + +# 或为单次 make up 覆盖默认值。 +make up ORDER_REPLICAS=4 INVENTORY_REPLICAS=3 +``` + +Makefile 将 `--scale =` 标志传给 `docker compose up`。compose 文件中的 +`deploy.replicas` 是默认值的唯一真相来源。 + +有状态后端服务(PostgreSQL、RabbitMQ)始终单副本——见[非目标](#非目标)。 + +### 负载均衡验证 + +Traefik 在各副本间 round-robin 负载均衡(无会话亲和)。由于每个副本设置了唯一的 +`INSTANCE_ID`,且服务通过 `X-Service-Instance` 响应头返回该值,因此可以通过多次请求读取 +该响应头来验证均衡器是否在分发流量: + +```bash +# 当 catalog replicas=2 时,预期看到两个或更多不同的 instance ID。 +for i in $(seq 1 12); do + curl -fsS -D - http://localhost:18100/api/v1/products?limit=1 -o /dev/null \ + | awk -F': ' 'tolower($1)=="x-service-instance" {gsub("\r","",$2); print $2}' + sleep 0.2 +done | sort -u +``` + +这是 `test/smoke.sh` 的 gate 1。`make smoke` 端到端运行相同的检查(外加五个 gate)。 + +## 结账流程 + +Happy-path 结账是 order 服务编排的一个简短 saga: + +1. **购物车** —— `POST /api/v1/carts` 创建购物车; + `POST /api/v1/carts/{id}/items` 添加 SKU + 数量。 +2. **结账** —— `POST /api/v1/checkouts`(带 `Idempotency-Key` 请求头) + 对照 customer/catalog/inventory 校验购物车并启动 saga。 + 该端点立即返回新的 `order_id`;saga 异步执行。 +3. **预占** —— inventory 为每行商品预占库存(状态 + `active` → `committed`)。 +4. **支付** —— payment 捕获支付意向;成功后产生 + `payment.captured.v1` 事件。 +5. **发货** —— fulfillment 跨仓库分单并创建发货;成功后订单标记为 + `paid` → `fulfilled`。 + +失败会触发补偿:支付失败会发布 `payment.failed.v1` 事件,order consumer 捕获该事件后触发 +inventory 释放预占(`active` → `released`)。见 `test/smoke.sh` 中的 gate 5。 + +## 事件保障 + +事件投递是**至少一次**的。Outbox/Inbox 模式提供了使此投递安全的两项保障: + +- **事务发件箱** —— 领域写操作在与状态变更相同的数据库事务中插入一条 `outbox_event` 行。 + 一个轮询式 dispatcher 读取新行并发布到 RabbitMQ。这意味着一个服务永远不会在提交状态变更时 + 不同时记录描述它的事件。 +- **幂等收件箱** —— 每个 consumer 在应用变更之前先记录一个 `inbox_event` 键。 + 相同 `event_id` 的重复投递会被检测并跳过。这是 at-least-once 投递安全的核心。 + +运维影响: + +- 重新投递是安全的。seed CLI 和 smoke 测试都依赖此特性—— + duplicate-webhook gate(gate 4)用同一个 `Idempotency-Key` 重放两次,并断言返回的 payment id + 完全一致。 +- 发件箱分发是 at-least-once 的边界。如果一个 Pod 在发布后、标记行为已分发之前崩溃, + 该行会在重启时重新发布;consumer 侧的 Inbox 负责去重。 + +Outbox/Inbox schema 见 `db/migrations/shared.sql`——每个服务迁移逐字包含。 + +## 失败注入 + +seed 数据确定性地产生多种订单生命周期组合,包括一部分 `payment_status=failed` 的订单。 +这就是失败注入路径:无需手动 chaos engineering。 + +```bash +# 找一个 seed 中支付失败的订单。 +curl -fsS http://localhost:18100/api/v1/orders?page_size=100 \ + | jq -r '.items[] | select(.payment_status=="failed") | .order_id' | head -n1 + +# 检查其失败的支付意向及失败尝试的 failure_code。 +ORDER_ID=... # 来自上一条命令 +curl -fsS http://localhost:18100/api/v1/payments/orders/${ORDER_ID} | jq . + +# 断言其库存预占已非 active(补偿已释放)。 +curl -fsS http://localhost:18100/api/v1/reservations/${ORDER_ID} | jq . +``` + +seed 数据中观察到的失败码:`card_declined`、`insufficient_funds`、`risk_rejection`、 +`provider_timeout`。smoke 测试的 gate 3 断言一个确定性的失败码存在。 + +## Broker 检查 + +RabbitMQ 启用了管理 UI(`rabbitmq:4.0-management`)。compose 文件将 broker 映射到内部 +`commerce-data` 网络上——不发布管理端口。检查 broker: + +```bash +# 在 rabbitmq 容器中打开 shell,使用 rabbitmqctl / rabbitmqadmin。 +docker compose exec rabbitmq rabbitmqctl list_queues name messages consumers +docker compose exec rabbitmq rabbitmqctl list_exchanges name type +docker compose exec rabbitmq rabbitmqctl list_bindings source_name routing_key destination_name + +# 或通过端口转发临时启用管理 UI(一次性,临时): +docker compose port rabbitmq 15672 # 然后 docker run -p 15672:15672 ...(如需) +``` + +拓扑(对应 `deploy/rabbitmq/definitions.json`): + +- topic exchange **`commerce.events`** —— 主总线。 +- topic exchange **`commerce.events.dlx`** —— 死信终点。 +- direct exchange **`commerce.events.retry`** —— 支撑每队列重试模式 + (TTL 2000ms,到期后 dead-letter 回 `commerce.events`)。 +- 每服务队列:`order.saga`、`payment.intents`、`fulfillment.orders`, + 各带 `.retry`(TTL + DLX)+ `.dlq` 伴侣。 + +## Seed 规模 + +seed 数据是确定性的:相同的 seed 值 + scale 产出逐字节相同的行。内置三个规模: + +| Scale | 订单数 | 用途 | +|-------|------:|------| +| `dev` | 100 | 默认;完整生命周期组合含失败;适配 4–6 GB 预算 | +| `demo` | 10 000 | 演示 / 集成测试 | +| `load` | 1 000 000 | 压测;通过 `COPY FROM` 以有界内存流式灌入 | + +```bash +make seed # dev 规模,seed 42(--reset 幂等) +make verify-seed # 校验灌入数据完整性 +SEED=99 make seed # 不同 seed → 不同的确定性数据集 + +# load 规模使用独立的 compose overlay,提高每副本内存,并将 order 扩到 4 副本、inventory 扩到 3 副本。 +make load +``` + +### Load 规模资源警告:4–6 GB 预算不适用 + +默认 `make up` profile 面向** 4–6 GB** 内存预算。load profile(`make load`、`--scale load`) +刻意提高每服务内存(order 限制 2 GiB,Postgres shared_buffers 512 MB 等)并通过 +`COPY FROM` 灌入 1 000 000 笔订单。**load 规模请按远超 4–6 GB 来规划**——具体 override 见 +`compose.load.yaml`。不要在一台同时运行其他重度工作负载的笔记本上运行 load 规模。 + +## 可观测性 + +每个 Go 服务暴露: + +- `/livez` —— 存活探针。进程启动后始终返回 200。 +- `/readyz` —— 就绪探针。优雅关闭 drain 期间返回 503,其余时间返回 200。 + Traefik 和 Ingress controller 用它做 drain。 +- `/metrics` —— Prometheus 格式指标(请求时延、发件箱分发滞后、 + 收件箱去重计数、pgx 连接池统计)。 + +用 overlay 拉起完整可观测性栈(otel collector、Prometheus、Grafana、Jaeger): + +```bash +make observability # 拉起 otel/prometheus/grafana/jaeger +make trace-smoke # 断言一个 catalog 请求的 trace 到达 Jaeger +make observability-down # 仅移除可观测性容器 +``` + +overlay 会添加可观测性容器,覆盖应用遥测环境变量,并可能在 Compose 应用这些环境变量时重建 +服务。Prometheus 通过内部网络抓取每个服务的 `/metrics`,otel collector 通过 OTLP 接收 trace。 +在 `make observability` 之后运行 `make trace-smoke`;它会经网关发送一个 catalog 请求并检查 +Jaeger 是否收到该请求的 trace。 + +## CI 门控 + +本地静态门控是 `make commerce-ci`。它构建并测试所有 Commerce 包,对 `internal/platform` 运行 +race 检测,校验每个 Compose 配置,并将 Kubernetes kustomization 渲染到 `/tmp/commerce-k8s.yaml` +(不 apply)。 + +GitHub Actions 在 `commerce` job 中运行相同的检查。独立的 `commerce-e2e` job 各起一个可变服务的 +副本,运行 `make smoke`,启用可观测性 overlay,并运行 `make trace-smoke`。失败时捕获合并的 +Compose 日志;runner 退出前始终移除容器、卷和孤立资源。 + +## Kubernetes 映射 + +`deploy/k8s/` 中的 Kubernetes 清单为相同的应用契约提供了部署映射。它们不是 Compose 的一一对应: +集群网络、基础设施归属和滚动发布行为仍是集群特定的。 + +```bash +# 渲染清单但不 apply(Phase A 门控): +kubectl kustomize deploy/k8s > /tmp/commerce-k8s.yaml + +# Apply 整个 bundle(假定已安装 ingress controller): +kubectl apply -k deploy/k8s +``` + +| Compose 概念 | Kubernetes 对应 | 文件 | +|-------------|----------------|------| +| project name `commerce` | Namespace `commerce` | `namespace.yaml` | +| `x-service-env` anchor | ConfigMap `commerce-shared` | `config.yaml` | +| 每服务 `environment:` | ConfigMap `-env`(每服务一个) | `config.yaml` | +| `DB_PASSWORD` / `BROKER_URL` | Secret `commerce-dev-secret`(仅 DEV,标 unsafe) | `config.yaml` | +| 服务容器 | 副本数匹配的 Deployment | `apps.yaml` | +| `healthcheck: wget /livez` | 基于 `/livez` 和 `/readyz` 的 startup/readiness/liveness 探针 | `apps.yaml` | +| `mem_reservation`/`mem_limit`/`cpus` | resource `requests`/`limits` | `apps.yaml` | +| `read_only: true` + `tmpfs:/tmp` | `readOnlyRootFilesystem: true` + `/tmp` 的 `emptyDir` Memory | `apps.yaml` | +| `security_opt: no-new-privileges` + `cap_drop: ALL` | runAsNonRoot, seccompProfile, drop ALL caps | `apps.yaml` | +| Traefik router 标签 | 路径规则匹配的 Ingress | `gateway.yaml` | +| 每服务 DNS 名 | 每应用的 ClusterIP Service | `services.yaml` | +| `*-db` service / `rabbitmq` service | ExternalName Service(非 dev 前替换) | `services.yaml` | +| `deploy.replicas: N` (N >= 2) | PodDisruptionBudget `minAvailable: 1` | `availability.yaml` | +| (水平扩缩) | 每无状态服务的 HorizontalPodAutoscaler | `availability.yaml` | + +**有状态后端服务不在此重新部署。** `*-db` 和 `rabbitmq` Service 是指向 default namespace 的 +ExternalName 别名——在服务任何真实流量之前,请将它们替换为你的 operator 管理的 StatefulSet +(或云托管服务)。清单刻意**不**声称运维 PostgreSQL 或 RabbitMQ 的 HA。 + +组件图及每个部署形态决策的依据见 [ARCHITECTURE.md](ARCHITECTURE.md)。 + +## 清理 + +```bash +make down # compose:停 + 删 + 含卷 +make observability-down # 仅可观测性 overlay + +# Kubernetes(独立集群,如果你 apply 过清单): +kubectl delete -k deploy/k8s +``` + +`make down` 传递 `--volumes --remove-orphans`,因此会抹除全部六个 PostgreSQL 数据卷和 +RabbitMQ mnesia 卷。`make down` 后重新 `make up` 会得到干净拓扑。 + +## 非目标 + +本模板刻意**不**实现以下任何一项——它们超出范围,添加它们会模糊模板旨在展示的模式: + +- **认证 / 授权。** 无 JWT、OAuth、mTLS 或 API key。所有路由刻意开放。 +- **真实支付提供商。** payment 服务用确定性结果模拟 capture/refund; + 没有 Stripe/Adyen/Braintree 集成。 +- **搜索。** 商品列表是 SQL `LIMIT/OFFSET` 查询,不是 Elasticsearch / OpenSearch / Algolia。 +- **Redis。** 无缓存层。库存预占存在于 PostgreSQL 中,使用 + `SELECT ... FOR UPDATE` 加状态状态机。 +- **促销 DSL。** 无优惠券引擎,无购物车级折扣规则。 +- **PostgreSQL HA。** 每服务单副本。无流复制、无 Patroni、无自动故障切换。 + (Kubernetes 上同理——见上。) +- **RabbitMQ 集群。** 单 broker 节点。无跨多节点的 quorum queue、无 federation。 +- **跨集群 federation / 多地域。** 超出范围。 + +如果你在真实部署中需要以上任何一项,请将本模板视为起点并在此基础上叠加—— +每服务独占边界使得每一项新增都是一处局部变更。