first commit
This commit is contained in:
11
async-agent-lab/fixture/order-service/README.md
Normal file
11
async-agent-lab/fixture/order-service/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# order-service
|
||||
|
||||
订单服务。对外提供 `GET /orders/:id`,返回体由 `src/order-dto.js` 生成。
|
||||
|
||||
- `src/order.js`:订单领域模型和状态机,内部状态字段叫 `state`
|
||||
- `src/order-dto.js`:把订单转成接口返回体
|
||||
- `src/order-api.js`:接口处理函数
|
||||
- `src/consumers/`:仓库内读取接口返回体的调用方
|
||||
- `docs/api-contract.md`:对外接口约定,以及仓库外的调用方
|
||||
|
||||
`npm test` 跑全部测试。`test/order-flow.test.js` 是集成测试,用真实计时器走超时关单和发货重试,整套大约 15 秒。只跑单元测试用 `npm run test:unit`。
|
||||
30
async-agent-lab/fixture/order-service/docs/api-contract.md
Normal file
30
async-agent-lab/fixture/order-service/docs/api-contract.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 订单接口约定
|
||||
|
||||
## GET /orders/:id
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "o_1001",
|
||||
"amount": 12900,
|
||||
"status": "PAID",
|
||||
"updatedAt": 1794448946000
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` | 订单号 |
|
||||
| `amount` | 金额,单位分 |
|
||||
| `status` | 订单状态,取值 `CREATED`、`PAID`、`SHIPPED`、`CANCELLED` |
|
||||
| `updatedAt` | 最后一次状态变化的毫秒时间戳 |
|
||||
|
||||
## 调用方
|
||||
|
||||
仓库内的调用方在 `src/consumers/`,随本仓库一起发版。
|
||||
|
||||
仓库外还有两个调用方直接读取这个返回体,它们有各自的发版节奏,本仓库改不到:
|
||||
|
||||
- **App 订单详情页**(iOS、Android),按 App 版本灰度,旧版本会在线上留存一段时间。
|
||||
- **合作方对账服务**,由合作方维护,接口变更需要提前通知。
|
||||
|
||||
接口字段的增删要先确认这两个调用方的兼容安排。
|
||||
11
async-agent-lab/fixture/order-service/package.json
Normal file
11
async-agent-lab/fixture/order-service/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "order-service",
|
||||
"version": "2.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "订单服务(演示用样例仓库)",
|
||||
"scripts": {
|
||||
"test": "node --test --test-concurrency=1 --test-reporter=spec",
|
||||
"test:unit": "node --test --test-reporter=spec test/order-dto.test.js test/consumers.test.js"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// 运营看板:按状态统计订单数量。
|
||||
export function countByStatus(orderDTOs) {
|
||||
const counts = {};
|
||||
for (const dto of orderDTOs) {
|
||||
counts[dto.status] = (counts[dto.status] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// 通知服务:按订单状态生成推送文案。
|
||||
const TEXT = {
|
||||
CREATED: '订单已创建,请尽快支付',
|
||||
PAID: '支付成功,等待发货',
|
||||
SHIPPED: '订单已发货',
|
||||
CANCELLED: '订单已取消',
|
||||
};
|
||||
|
||||
export function buildNotification(orderDTO) {
|
||||
return { orderId: orderDTO.id, text: TEXT[orderDTO.status] ?? '订单状态更新' };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 退款服务:拿接口返回体判断订单能不能退款。
|
||||
export function canRefund(orderDTO) {
|
||||
return orderDTO.status === 'PAID' || orderDTO.status === 'SHIPPED';
|
||||
}
|
||||
12
async-agent-lab/fixture/order-service/src/order-api.js
Normal file
12
async-agent-lab/fixture/order-service/src/order-api.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { toOrderDTO } from './order-dto.js';
|
||||
|
||||
export function createOrderApi(store) {
|
||||
return {
|
||||
// GET /orders/:id
|
||||
getOrder(id) {
|
||||
const order = store.get(id);
|
||||
if (!order) return { code: 404, message: '订单不存在' };
|
||||
return { code: 200, data: toOrderDTO(order) };
|
||||
},
|
||||
};
|
||||
}
|
||||
9
async-agent-lab/fixture/order-service/src/order-dto.js
Normal file
9
async-agent-lab/fixture/order-service/src/order-dto.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// 把订单转成 GET /orders/:id 的返回体。字段约定见 docs/api-contract.md。
|
||||
export function toOrderDTO(order) {
|
||||
return {
|
||||
id: order.id,
|
||||
amount: order.amount,
|
||||
status: order.state,
|
||||
updatedAt: order.updatedAt,
|
||||
};
|
||||
}
|
||||
12
async-agent-lab/fixture/order-service/src/order-store.js
Normal file
12
async-agent-lab/fixture/order-service/src/order-store.js
Normal file
@@ -0,0 +1,12 @@
|
||||
export function createStore() {
|
||||
const orders = new Map();
|
||||
return {
|
||||
save(order) {
|
||||
orders.set(order.id, order);
|
||||
return order;
|
||||
},
|
||||
get(id) {
|
||||
return orders.get(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
30
async-agent-lab/fixture/order-service/src/order.js
Normal file
30
async-agent-lab/fixture/order-service/src/order.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// 订单领域模型和状态机。内部状态字段叫 state,接口字段名由 order-dto.js 决定。
|
||||
export const OrderState = Object.freeze({
|
||||
CREATED: 'CREATED',
|
||||
PAID: 'PAID',
|
||||
SHIPPED: 'SHIPPED',
|
||||
CANCELLED: 'CANCELLED',
|
||||
});
|
||||
|
||||
const TRANSITIONS = {
|
||||
CREATED: ['PAID', 'CANCELLED'],
|
||||
PAID: ['SHIPPED', 'CANCELLED'],
|
||||
SHIPPED: [],
|
||||
CANCELLED: [],
|
||||
};
|
||||
|
||||
let seq = 1000;
|
||||
|
||||
export function createOrder({ amount }) {
|
||||
seq += 1;
|
||||
return { id: `o_${seq}`, amount, state: OrderState.CREATED, updatedAt: Date.now() };
|
||||
}
|
||||
|
||||
export function transition(order, next) {
|
||||
if (!TRANSITIONS[order.state].includes(next)) {
|
||||
throw new Error(`订单 ${order.id} 不能从 ${order.state} 变为 ${next}`);
|
||||
}
|
||||
order.state = next;
|
||||
order.updatedAt = Date.now();
|
||||
return order;
|
||||
}
|
||||
16
async-agent-lab/fixture/order-service/src/payment.js
Normal file
16
async-agent-lab/fixture/order-service/src/payment.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { OrderState, transition } from './order.js';
|
||||
|
||||
// 下单后在 timeoutMs 内没有支付就自动关单。返回取消定时器的函数。
|
||||
export function scheduleAutoCancel(order, timeoutMs) {
|
||||
const timer = setTimeout(() => {
|
||||
if (order.state === OrderState.CREATED) transition(order, OrderState.CANCELLED);
|
||||
}, timeoutMs);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
// 支付回调。同一笔回调重复到达时只处理一次。
|
||||
export function onPaymentCallback(order) {
|
||||
if (order.state === OrderState.PAID) return { handled: false };
|
||||
transition(order, OrderState.PAID);
|
||||
return { handled: true };
|
||||
}
|
||||
19
async-agent-lab/fixture/order-service/src/shipping.js
Normal file
19
async-agent-lab/fixture/order-service/src/shipping.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { OrderState, transition } from './order.js';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// 调仓库接口发货,失败按 backoffMs 退避重试。
|
||||
export async function shipWithRetry(order, callWarehouse, { retries = 2, backoffMs = 1000 } = {}) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
await callWarehouse(order.id);
|
||||
transition(order, OrderState.SHIPPED);
|
||||
return { attempts: attempt + 1 };
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < retries) await sleep(backoffMs);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
35
async-agent-lab/fixture/order-service/test/consumers.test.js
Normal file
35
async-agent-lab/fixture/order-service/test/consumers.test.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOrder, transition, OrderState } from '../src/order.js';
|
||||
import { toOrderDTO } from '../src/order-dto.js';
|
||||
import { canRefund } from '../src/consumers/refund.js';
|
||||
import { buildNotification } from '../src/consumers/notify.js';
|
||||
import { countByStatus } from '../src/consumers/dashboard.js';
|
||||
|
||||
const dtoIn = (state) => {
|
||||
const order = createOrder({ amount: 100 });
|
||||
if (state === OrderState.SHIPPED) transition(order, OrderState.PAID);
|
||||
if (state !== OrderState.CREATED) transition(order, state);
|
||||
return toOrderDTO(order);
|
||||
};
|
||||
|
||||
test('已支付和已发货的订单可以退款', () => {
|
||||
assert.equal(canRefund(dtoIn(OrderState.PAID)), true);
|
||||
assert.equal(canRefund(dtoIn(OrderState.SHIPPED)), true);
|
||||
assert.equal(canRefund(dtoIn(OrderState.CREATED)), false);
|
||||
assert.equal(canRefund(dtoIn(OrderState.CANCELLED)), false);
|
||||
});
|
||||
|
||||
test('通知文案跟随订单状态', () => {
|
||||
assert.equal(buildNotification(dtoIn(OrderState.PAID)).text, '支付成功,等待发货');
|
||||
assert.equal(buildNotification(dtoIn(OrderState.CANCELLED)).text, '订单已取消');
|
||||
});
|
||||
|
||||
test('看板按状态计数', () => {
|
||||
const counts = countByStatus([
|
||||
dtoIn(OrderState.PAID),
|
||||
dtoIn(OrderState.PAID),
|
||||
dtoIn(OrderState.CREATED),
|
||||
]);
|
||||
assert.deepEqual(counts, { PAID: 2, CREATED: 1 });
|
||||
});
|
||||
27
async-agent-lab/fixture/order-service/test/order-dto.test.js
Normal file
27
async-agent-lab/fixture/order-service/test/order-dto.test.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOrder, transition, OrderState } from '../src/order.js';
|
||||
import { toOrderDTO } from '../src/order-dto.js';
|
||||
import { createStore } from '../src/order-store.js';
|
||||
import { createOrderApi } from '../src/order-api.js';
|
||||
|
||||
test('返回体带订单状态', () => {
|
||||
const order = transition(createOrder({ amount: 12900 }), OrderState.PAID);
|
||||
const dto = toOrderDTO(order);
|
||||
assert.equal(dto.status, 'PAID');
|
||||
assert.equal(dto.amount, 12900);
|
||||
});
|
||||
|
||||
test('GET /orders/:id 返回订单', () => {
|
||||
const store = createStore();
|
||||
const order = store.save(createOrder({ amount: 500 }));
|
||||
const api = createOrderApi(store);
|
||||
const res = api.getOrder(order.id);
|
||||
assert.equal(res.code, 200);
|
||||
assert.equal(res.data.status, 'CREATED');
|
||||
});
|
||||
|
||||
test('订单不存在返回 404', () => {
|
||||
const api = createOrderApi(createStore());
|
||||
assert.equal(api.getOrder('o_missing').code, 404);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// 集成测试:用真实计时器走完整流程,所以整个文件要跑十几秒。
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createOrder, OrderState } from '../src/order.js';
|
||||
import { createStore } from '../src/order-store.js';
|
||||
import { createOrderApi } from '../src/order-api.js';
|
||||
import { scheduleAutoCancel, onPaymentCallback } from '../src/payment.js';
|
||||
import { shipWithRetry } from '../src/shipping.js';
|
||||
import { canRefund } from '../src/consumers/refund.js';
|
||||
import { buildNotification } from '../src/consumers/notify.js';
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function setup() {
|
||||
const store = createStore();
|
||||
const api = createOrderApi(store);
|
||||
const order = store.save(createOrder({ amount: 12900 }));
|
||||
const read = () => api.getOrder(order.id).data;
|
||||
return { order, read };
|
||||
}
|
||||
|
||||
test('超时未支付自动关单(等 3 秒)', async () => {
|
||||
const { order, read } = setup();
|
||||
scheduleAutoCancel(order, 3000);
|
||||
await sleep(3200);
|
||||
assert.equal(order.state, OrderState.CANCELLED);
|
||||
assert.equal(canRefund(read()), false);
|
||||
assert.equal(buildNotification(read()).text, '订单已取消');
|
||||
});
|
||||
|
||||
test('支付发生在关单之前,订单保持已支付(等 3 秒)', async () => {
|
||||
const { order, read } = setup();
|
||||
scheduleAutoCancel(order, 2500);
|
||||
await sleep(1000);
|
||||
assert.equal(onPaymentCallback(order).handled, true);
|
||||
await sleep(2000);
|
||||
assert.equal(order.state, OrderState.PAID);
|
||||
assert.equal(canRefund(read()), true);
|
||||
});
|
||||
|
||||
test('重复的支付回调只处理一次(等 2 秒)', async () => {
|
||||
const { order } = setup();
|
||||
assert.equal(onPaymentCallback(order).handled, true);
|
||||
await sleep(2000);
|
||||
assert.equal(onPaymentCallback(order).handled, false);
|
||||
});
|
||||
|
||||
test('发货失败两次后重试成功(等 4 秒)', async () => {
|
||||
const { order, read } = setup();
|
||||
onPaymentCallback(order);
|
||||
let calls = 0;
|
||||
const flakyWarehouse = async () => {
|
||||
calls += 1;
|
||||
if (calls < 3) throw new Error('仓库接口超时');
|
||||
};
|
||||
const result = await shipWithRetry(order, flakyWarehouse, { retries: 2, backoffMs: 2000 });
|
||||
assert.equal(result.attempts, 3);
|
||||
assert.equal(buildNotification(read()).text, '订单已发货');
|
||||
});
|
||||
|
||||
test('发货重试用尽后订单保持已支付(等 3 秒)', async () => {
|
||||
const { order, read } = setup();
|
||||
onPaymentCallback(order);
|
||||
const downWarehouse = async () => {
|
||||
throw new Error('仓库接口不可用');
|
||||
};
|
||||
await assert.rejects(shipWithRetry(order, downWarehouse, { retries: 2, backoffMs: 1500 }));
|
||||
assert.equal(order.state, OrderState.PAID);
|
||||
assert.equal(canRefund(read()), true);
|
||||
});
|
||||
Reference in New Issue
Block a user