Compare commits
2
Commits
5e866d08f4
...
e10cae5789
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e10cae5789 | ||
|
|
c51e96ae41 |
@@ -16,3 +16,4 @@ dist/
|
||||
.kimi-code/
|
||||
.omp/
|
||||
.opencode/
|
||||
.omc/
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
|
||||
## 布局与所有权
|
||||
|
||||
- `apps/api` — Rust 后端(crate `vmall-api`)。迁移在 `apps/api/migrations/`(sqlx,启动时自动执行,只增不改)。代码按限界上下文放在 `src/modules/<ctx>/`(handler → service → repo);HTTP 抽取器在 `src/http/`。新功能写进对应模块,不要把 SQL 堆回 handler。简单 CRUD 允许 handler 直接调 repo。Service 返回 `ApiResult<Dto>`,不依赖 `Json`/`StatusCode`。Repo 接受 `&mut PgConnection` / `&mut Transaction` 以便组合事务。不引入泛型 Repository trait。
|
||||
- `apps/api` — Rust 后端(crate `vmall-api`)。迁移在 `apps/api/migrations/`(sqlx,启动时自动执行,只增不改)。分层约定见英文:`docs/adr/`、`docs/tech-specs/rust-api.md`、`openspec/specs/api-architecture/spec.md`。代码按限界上下文放在 `src/modules/<ctx>/`(handler → service → repo);HTTP 抽取器在 `src/http/`。新功能写进对应模块,不要把 SQL 堆回 handler。简单 CRUD 允许 handler 直接调 repo。Service 返回 `ApiResult<Dto>`,不依赖 `Json`/`StatusCode`。Repo 接受 `&mut PgConnection` / `&mut Transaction` 以便组合事务。不引入泛型 Repository trait。
|
||||
- `apps/mall` / `apps/shop-admin` / `apps/admin` — 三个 Nuxt 3 应用。端口固定 3000/3001/3002。
|
||||
- `packages/shared` — `@vmall/shared`:**唯一** API 契约(`src/types.ts` + `src/api.ts`)、en/zh 语言包、共享样式 `ui.css`。前端禁止自建 API 封装;契约变更只在这里改,且三个前端都要过构建。
|
||||
- `openspec/` — 规范。`specs/` 是已归档能力规范(auth, rbac, catalog, currency, cart, order, shipment, invoice, frontend-*)。
|
||||
- `openspec/` — 规范。`specs/` 是已归档能力规范(auth, rbac, catalog, currency, cart, order, shipment, invoice, api-architecture, frontend-*)。
|
||||
- `scripts/seed-demo.mjs` — 幂等演示数据。
|
||||
|
||||
## 硬性约定
|
||||
|
||||
@@ -13,6 +13,8 @@ apps/
|
||||
packages/
|
||||
shared/ @vmall/shared:TS 类型、API client、en/zh 语言包、共享样式
|
||||
openspec/ OpenSpec 规范(specs/ 为归档后的能力规范)
|
||||
docs/adr/ Architecture Decision Records(英文)
|
||||
docs/tech-specs/ 内部技术规格(英文)
|
||||
scripts/
|
||||
seed-demo.mjs 演示数据脚本
|
||||
```
|
||||
@@ -71,15 +73,11 @@ openspec validate --all --strict # 规范校验
|
||||
|
||||
## 商城的 mock 边界
|
||||
|
||||
`apps/mall` 通过 `apps/mall/plugins/api.ts` 的 `liveDomains` 按域选择适配器。已有 API 的域全部走真实后端:catalog、currency、content、brands、shops、auth、cart、orders、shipments、invoices。
|
||||
`apps/mall` 通过 `apps/mall/plugins/api.ts` 的 `liveDomains` 按域选择适配器。已有 API 的域全部走真实后端:catalog、currency、content、brands、shops、auth、cart、orders、shipments、invoices、addresses。
|
||||
|
||||
仍来自 `~/mock/data` 的部分是**有意保留**的展示内容,不是待办:
|
||||
仍来自 `~/mock/data` 的部分都是没有后端能力的营销/账户域,逐项记录在 `docs/TBD-marketing.md`(优惠券、收藏、账户统计、秒杀、拼团、积分商城、评价等)。每一项都是一次新的能力建设,不是适配层切换。
|
||||
|
||||
- **收货地址**(`MOCK_ADDRESSES`):`Address` 内嵌在订单里,结算直接在请求体里携带,不需要地址表。
|
||||
- **优惠券、收藏、账户统计**:纯展示,不参与交易。
|
||||
- **seckill / collective / integral 营销页**:纯展示内容。
|
||||
- **商品评价**:没有评价模型,所以商城不再展示任何评价——卡片上的评价数与详情页的评价页签、评分、回复都已移除,而不是继续展示虚构的评论者与评分。评价是独立功能,不是迁移的一部分。
|
||||
- **fixed-data 适配器本身**:`Mock API adapter` 规范要求它仍能服务每个域,因此它是回滚路径,删除它会破坏回滚。
|
||||
- **fixed-data 适配器本身**:`Mock API adapter` 规范要求它仍能服务每个域,因此它是回滚路径,删除它会破坏回滚。
|
||||
|
||||
## 环境变量(后端)
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export default {
|
||||
backToCart: "Back to cart",
|
||||
loadError: "Unable to load the order.",
|
||||
submitError: "Unable to place the order.",
|
||||
addressRequired: "Please select or fill in a shipping address.",
|
||||
noSavedAddress: "No saved address. Please enter one below.",
|
||||
paymentTitle: "Choose payment",
|
||||
balance: "Balance payment",
|
||||
wechat: "WeChat Pay",
|
||||
@@ -48,6 +50,8 @@ export default {
|
||||
backToCart: "返回购物车",
|
||||
loadError: "订单信息加载失败,请稍后重试。",
|
||||
submitError: "订单提交失败,请稍后重试。",
|
||||
addressRequired: "请选择或填写收货地址。",
|
||||
noSavedAddress: "暂无收货地址,请在下方填写。",
|
||||
paymentTitle: "选择支付方式",
|
||||
balance: "余额支付",
|
||||
wechat: "微信支付",
|
||||
|
||||
@@ -68,6 +68,8 @@ export default {
|
||||
defaultAddress: "Default",
|
||||
setDefault: "Set as default",
|
||||
saveAddress: "Save address",
|
||||
country: "Country",
|
||||
saveFailed: "Save failed. Please try again.",
|
||||
noAddresses: "No shipping addresses yet.",
|
||||
favoritesTitle: "Favorites",
|
||||
favoriteProductsTab: "Products",
|
||||
@@ -159,6 +161,8 @@ export default {
|
||||
defaultAddress: "默认",
|
||||
setDefault: "设为默认",
|
||||
saveAddress: "保存地址",
|
||||
country: "国家",
|
||||
saveFailed: "保存失败,请重试。",
|
||||
noAddresses: "暂无收货地址。",
|
||||
favoritesTitle: "我的收藏",
|
||||
favoriteProductsTab: "商品",
|
||||
|
||||
+94
-4
@@ -5,6 +5,8 @@
|
||||
import { ApiError } from "@vmall/shared";
|
||||
import type {
|
||||
Address,
|
||||
AddressBookEntry,
|
||||
AddressInput,
|
||||
ApiClient,
|
||||
AuthTokens,
|
||||
Cart,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
MOCK_QUICK_LINKS,
|
||||
MOCK_STORES,
|
||||
MOCK_USER,
|
||||
MOCK_ADDRESSES,
|
||||
defaultAddress,
|
||||
mockConvertMinor,
|
||||
productById,
|
||||
@@ -42,15 +45,16 @@ interface MockState {
|
||||
orders: Order[];
|
||||
shipments: Shipment[];
|
||||
invoices: Invoice[];
|
||||
addresses: AddressBookEntry[];
|
||||
addressSeq: number;
|
||||
orderSeq: number;
|
||||
invoiceSeq: number;
|
||||
}
|
||||
|
||||
// v2: cart lines gained shop_id/shop_name/stock, so state saved by an older
|
||||
// build is no longer a valid CartItem[].
|
||||
const STORAGE_KEY = "vmall.mock.state.v2";
|
||||
// v3: address book joined the persisted state.
|
||||
const STORAGE_KEY = "vmall.mock.state.v3";
|
||||
|
||||
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "orderSeq" | "invoiceSeq">;
|
||||
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "addresses" | "orderSeq" | "invoiceSeq" | "addressSeq">;
|
||||
|
||||
// Load cart/order session state persisted by a previous page load (client only).
|
||||
function loadPersisted(): PersistedState | null {
|
||||
@@ -64,6 +68,7 @@ function loadPersisted(): PersistedState | null {
|
||||
if (!Array.isArray(p.cart) || !Array.isArray(p.orders)) return null;
|
||||
if (!Array.isArray(p.shipments) || !Array.isArray(p.invoices)) return null;
|
||||
if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null;
|
||||
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
|
||||
return p as PersistedState;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -74,12 +79,27 @@ function initialState(): MockState {
|
||||
const persisted = loadPersisted();
|
||||
if (persisted) return { token: null, ...persisted };
|
||||
const seed = seedOrders(MOCK_USER.id);
|
||||
const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({
|
||||
id: a.id,
|
||||
user_id: MOCK_USER.id,
|
||||
recipient: a.recipient,
|
||||
phone: a.phone,
|
||||
country: "US",
|
||||
region: a.region,
|
||||
city: a.city,
|
||||
line1: a.line1,
|
||||
postal_code: a.postalCode,
|
||||
is_default: a.isDefault,
|
||||
created_at: `2026-08-0${i + 1}T09:00:00.000Z`,
|
||||
}));
|
||||
return {
|
||||
token: null,
|
||||
cart: [],
|
||||
orders: seed.orders,
|
||||
shipments: seed.shipments,
|
||||
invoices: seed.invoices,
|
||||
addresses: seededAddresses,
|
||||
addressSeq: 100,
|
||||
orderSeq: 100,
|
||||
invoiceSeq: 100,
|
||||
};
|
||||
@@ -132,6 +152,8 @@ export function createMockApi(): ApiClient {
|
||||
invoices: state.invoices,
|
||||
orderSeq: state.orderSeq,
|
||||
invoiceSeq: state.invoiceSeq,
|
||||
addresses: state.addresses,
|
||||
addressSeq: state.addressSeq,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
@@ -386,6 +408,74 @@ export function createMockApi(): ApiClient {
|
||||
return Promise.resolve(toShopProfile(store));
|
||||
},
|
||||
|
||||
listMyAddresses: () =>
|
||||
Promise.resolve(
|
||||
[...state.addresses].sort(
|
||||
(a, b) => Number(b.is_default) - Number(a.is_default) || b.created_at.localeCompare(a.created_at),
|
||||
),
|
||||
),
|
||||
|
||||
createAddress: (input: AddressInput) => {
|
||||
const makeDefault = input.is_default === true || state.addresses.length === 0;
|
||||
if (makeDefault) for (const a of state.addresses) a.is_default = false;
|
||||
state.addressSeq += 1;
|
||||
const entry: AddressBookEntry = {
|
||||
id: `a-${state.addressSeq}`,
|
||||
user_id: MOCK_USER.id,
|
||||
recipient: input.recipient,
|
||||
phone: input.phone,
|
||||
country: input.country,
|
||||
region: input.region,
|
||||
city: input.city,
|
||||
line1: input.line1,
|
||||
postal_code: input.postal_code,
|
||||
is_default: makeDefault,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
state.addresses.push(entry);
|
||||
persist();
|
||||
return Promise.resolve(entry);
|
||||
},
|
||||
|
||||
updateAddress: (id: string, input: AddressInput) => {
|
||||
const entry = state.addresses.find((a) => a.id === id);
|
||||
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
|
||||
if (input.is_default === true && !entry.is_default) {
|
||||
for (const a of state.addresses) a.is_default = false;
|
||||
}
|
||||
entry.recipient = input.recipient;
|
||||
entry.phone = input.phone;
|
||||
entry.country = input.country;
|
||||
entry.region = input.region;
|
||||
entry.city = input.city;
|
||||
entry.line1 = input.line1;
|
||||
entry.postal_code = input.postal_code;
|
||||
entry.is_default = input.is_default === true || entry.is_default;
|
||||
persist();
|
||||
return Promise.resolve({ ...entry });
|
||||
},
|
||||
|
||||
deleteAddress: (id: string) => {
|
||||
const entry = state.addresses.find((a) => a.id === id);
|
||||
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
|
||||
const wasDefault = entry.is_default;
|
||||
state.addresses = state.addresses.filter((a) => a.id !== id);
|
||||
if (wasDefault && state.addresses.length > 0) {
|
||||
const latest = state.addresses.reduce((a, b) => (a.created_at > b.created_at ? a : b));
|
||||
latest.is_default = true;
|
||||
}
|
||||
persist();
|
||||
return Promise.resolve([...state.addresses]);
|
||||
},
|
||||
|
||||
setDefaultAddress: (id: string) => {
|
||||
const entry = state.addresses.find((a) => a.id === id);
|
||||
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
|
||||
for (const a of state.addresses) a.is_default = a.id === id;
|
||||
persist();
|
||||
return Promise.resolve({ ...entry });
|
||||
},
|
||||
|
||||
shop: {
|
||||
getMyShop: () => unsupported(),
|
||||
listMyProducts: () => unsupported(),
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineNuxtConfig({
|
||||
// Domains served by the live backend; every other domain stays on the
|
||||
// fixed-data adapter. Override with NUXT_PUBLIC_LIVE_DOMAINS='["catalog"]'.
|
||||
// See openspec/changes/replace-mock-api-wave-1/design.md and waves 2-3.
|
||||
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "cart", "orders", "shipments", "invoices"],
|
||||
liveDomains: ["catalog", "currency", "content", "shops", "brands", "auth", "cart", "orders", "shipments", "invoices", "addresses"],
|
||||
appName: "mall",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { t as pick } from "@vmall/shared";
|
||||
import type { Address, CartItem, LocalizedText } from "@vmall/shared";
|
||||
import { MOCK_ADDRESSES } from "~/mock/data";
|
||||
import type { MockAddress } from "~/mock/data";
|
||||
import type { Address, AddressBookEntry, CartItem, LocalizedText } from "@vmall/shared";
|
||||
|
||||
type CheckoutGroup = {
|
||||
shopId: string;
|
||||
@@ -20,12 +18,24 @@ const router = useRouter();
|
||||
const pendingOrderIds = useState<string[]>("checkout-orders", () => []);
|
||||
|
||||
const items = ref<CartItem[]>([]);
|
||||
const selectedAddressId = ref(MOCK_ADDRESSES.find((address) => address.isDefault)?.id ?? MOCK_ADDRESSES[0]?.id ?? "");
|
||||
const addresses = ref<AddressBookEntry[]>([]);
|
||||
const selectedAddressId = ref("");
|
||||
const remark = ref("");
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
// Inline manual form, used only when the customer has no saved address.
|
||||
const manual = reactive<Address>({
|
||||
recipient: "",
|
||||
phone: "",
|
||||
country: "US",
|
||||
region: "",
|
||||
city: "",
|
||||
line1: "",
|
||||
postal_code: "",
|
||||
});
|
||||
|
||||
const stepLabels = computed(() => [
|
||||
t("checkout.steps.cart"),
|
||||
t("checkout.steps.order"),
|
||||
@@ -33,10 +43,6 @@ const stepLabels = computed(() => [
|
||||
t("checkout.steps.complete"),
|
||||
]);
|
||||
|
||||
const selectedAddress = computed<MockAddress | null>(
|
||||
() => MOCK_ADDRESSES.find((address) => address.id === selectedAddressId.value) ?? null,
|
||||
);
|
||||
|
||||
// Grouped by the cart line's own shop, which the cart API returns.
|
||||
const groups = computed<CheckoutGroup[]>(() => {
|
||||
const grouped = new Map<string, CheckoutGroup>();
|
||||
@@ -61,24 +67,36 @@ function imageFor(item: CartItem): string {
|
||||
return item.image ?? "/mock/product-1.svg";
|
||||
}
|
||||
|
||||
function toAddress(address: MockAddress): Address {
|
||||
return {
|
||||
recipient: address.recipient,
|
||||
phone: address.phone,
|
||||
country: "US",
|
||||
region: address.region,
|
||||
city: address.city,
|
||||
line1: address.line1,
|
||||
postal_code: address.postalCode,
|
||||
};
|
||||
function selectedAddress(): Address | null {
|
||||
const saved = addresses.value.find((a) => a.id === selectedAddressId.value);
|
||||
if (saved) {
|
||||
return {
|
||||
recipient: saved.recipient,
|
||||
phone: saved.phone,
|
||||
country: saved.country,
|
||||
region: saved.region,
|
||||
city: saved.city,
|
||||
line1: saved.line1,
|
||||
postal_code: saved.postal_code,
|
||||
};
|
||||
}
|
||||
if (addresses.value.length > 0) return null;
|
||||
// Manual fallback: every required field must be filled.
|
||||
if (!manual.recipient || !manual.phone || !manual.country || !manual.city || !manual.line1) {
|
||||
return null;
|
||||
}
|
||||
return { ...manual };
|
||||
}
|
||||
|
||||
async function loadCart(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const snapshot = await $api.getCart();
|
||||
const [snapshot, list] = await Promise.all([$api.getCart(), $api.listMyAddresses()]);
|
||||
items.value = snapshot.items;
|
||||
addresses.value = list;
|
||||
selectedAddressId.value =
|
||||
list.find((address) => address.is_default)?.id ?? list[0]?.id ?? "";
|
||||
if (items.value.length === 0) {
|
||||
await router.replace("/cart");
|
||||
}
|
||||
@@ -90,11 +108,15 @@ async function loadCart(): Promise<void> {
|
||||
}
|
||||
|
||||
async function submitOrder(): Promise<void> {
|
||||
if (!selectedAddress.value || items.value.length === 0) return;
|
||||
const address = selectedAddress();
|
||||
if (!address || items.value.length === 0) {
|
||||
error.value = t("checkout.addressRequired");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const orders = await $api.checkout(toAddress(selectedAddress.value), currency.value);
|
||||
const orders = await $api.checkout(address, currency.value);
|
||||
pendingOrderIds.value = orders.map((order) => order.id);
|
||||
const snapshot = await $api.getCart();
|
||||
items.value = snapshot.items;
|
||||
@@ -122,9 +144,9 @@ onMounted(() => void loadCart());
|
||||
<template v-if="!loading && items.length > 0">
|
||||
<section class="mpanel address-panel">
|
||||
<h2 class="section-title">{{ t("checkout.address") }}</h2>
|
||||
<div class="address-list">
|
||||
<div v-if="addresses.length > 0" class="address-list">
|
||||
<label
|
||||
v-for="address in MOCK_ADDRESSES"
|
||||
v-for="address in addresses"
|
||||
:key="address.id"
|
||||
class="address-option"
|
||||
:class="{ selected: address.id === selectedAddressId }"
|
||||
@@ -133,11 +155,23 @@ onMounted(() => void loadCart());
|
||||
<span class="address-main">
|
||||
<strong>{{ address.recipient }}</strong>
|
||||
<span>{{ address.phone }}</span>
|
||||
<span>{{ address.region }} {{ address.city }} {{ address.line1 }} {{ address.postalCode }}</span>
|
||||
<span>{{ address.region }} {{ address.city }} {{ address.line1 }} {{ address.postal_code }}</span>
|
||||
</span>
|
||||
<span v-if="address.isDefault" class="default-tag">{{ t("checkout.defaultAddress") }}</span>
|
||||
<span v-if="address.is_default" class="default-tag">{{ t("checkout.defaultAddress") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else class="manual-form">
|
||||
<p class="muted">{{ t("checkout.noSavedAddress") }}</p>
|
||||
<div class="manual-grid">
|
||||
<input v-model.trim="manual.recipient" class="minput" type="text" :placeholder="t('user.recipient')" />
|
||||
<input v-model.trim="manual.phone" class="minput" type="text" :placeholder="t('user.phone')" />
|
||||
<input v-model.trim="manual.country" class="minput" type="text" :placeholder="t('user.country')" />
|
||||
<input v-model.trim="manual.region" class="minput" type="text" :placeholder="t('user.region')" />
|
||||
<input v-model.trim="manual.city" class="minput" type="text" :placeholder="t('user.city')" />
|
||||
<input v-model.trim="manual.postal_code" class="minput" type="text" :placeholder="t('user.postalCode')" />
|
||||
<input v-model.trim="manual.line1" class="minput span-2" type="text" :placeholder="t('user.line1')" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mpanel order-panel">
|
||||
@@ -243,6 +277,18 @@ onMounted(() => void loadCart());
|
||||
.address-main span:last-child {
|
||||
color: var(--mall-muted);
|
||||
}
|
||||
.manual-form {
|
||||
padding: 8px 18px 16px;
|
||||
}
|
||||
.manual-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.manual-grid .span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.default-tag {
|
||||
color: var(--mall-red);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,39 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import type { MockAddress } from "~/mock/data";
|
||||
import { MOCK_ADDRESSES } from "~/mock/data";
|
||||
import type { AddressBookEntry, AddressInput } from "@vmall/shared";
|
||||
|
||||
definePageMeta({ middleware: "auth" });
|
||||
|
||||
const { t } = useI18n();
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
const rows = ref<MockAddress[]>(MOCK_ADDRESSES.map((address) => ({ ...address })));
|
||||
const rows = ref<AddressBookEntry[]>([]);
|
||||
const loading = ref(true);
|
||||
const open = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingId = ref<string | null>(null);
|
||||
|
||||
const form = reactive<MockAddress>({
|
||||
id: "",
|
||||
interface AddressForm {
|
||||
recipient: string;
|
||||
phone: string;
|
||||
country: string;
|
||||
region: string;
|
||||
city: string;
|
||||
line1: string;
|
||||
postal_code: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
const form = reactive<AddressForm>({
|
||||
recipient: "",
|
||||
phone: "",
|
||||
country: "US",
|
||||
region: "",
|
||||
city: "",
|
||||
line1: "",
|
||||
postalCode: "",
|
||||
isDefault: false,
|
||||
postal_code: "",
|
||||
is_default: false,
|
||||
});
|
||||
|
||||
const errorKey = ref("");
|
||||
|
||||
const modalTitle = computed(() => (editingId.value ? t("user.editAddress") : t("user.addAddress")));
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
rows.value = await $api.listMyAddresses();
|
||||
} catch {
|
||||
errorKey.value = "user.loadFailed";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load());
|
||||
|
||||
function resetForm(): void {
|
||||
form.id = "";
|
||||
form.recipient = "";
|
||||
form.phone = "";
|
||||
form.country = "US";
|
||||
form.region = "";
|
||||
form.city = "";
|
||||
form.line1 = "";
|
||||
form.postalCode = "";
|
||||
form.isDefault = false;
|
||||
form.postal_code = "";
|
||||
form.is_default = false;
|
||||
errorKey.value = "";
|
||||
}
|
||||
|
||||
@@ -43,61 +70,67 @@ function openAdd(): void {
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: MockAddress): void {
|
||||
function openEdit(row: AddressBookEntry): void {
|
||||
editingId.value = row.id;
|
||||
form.id = row.id;
|
||||
form.recipient = row.recipient;
|
||||
form.phone = row.phone;
|
||||
form.country = row.country;
|
||||
form.region = row.region;
|
||||
form.city = row.city;
|
||||
form.line1 = row.line1;
|
||||
form.postalCode = row.postalCode;
|
||||
form.isDefault = row.isDefault;
|
||||
form.postal_code = row.postal_code;
|
||||
form.is_default = row.is_default;
|
||||
errorKey.value = "";
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function setDefault(id: string): void {
|
||||
rows.value = rows.value.map((row) => ({ ...row, isDefault: row.id === id }));
|
||||
async function setDefault(id: string): Promise<void> {
|
||||
try {
|
||||
await $api.setDefaultAddress(id);
|
||||
await load();
|
||||
} catch {
|
||||
errorKey.value = "user.saveFailed";
|
||||
}
|
||||
}
|
||||
|
||||
function save(): void {
|
||||
if (!form.recipient || !form.phone || !form.region || !form.city || !form.line1 || !form.postalCode) {
|
||||
async function remove(row: AddressBookEntry): Promise<void> {
|
||||
try {
|
||||
rows.value = await $api.deleteAddress(row.id);
|
||||
} catch {
|
||||
errorKey.value = "user.saveFailed";
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!form.recipient || !form.phone || !form.country || !form.region || !form.city || !form.line1 || !form.postal_code) {
|
||||
errorKey.value = "user.validationRequired";
|
||||
return;
|
||||
}
|
||||
if (editingId.value) {
|
||||
rows.value = rows.value.map((row) =>
|
||||
row.id === editingId.value
|
||||
? {
|
||||
...row,
|
||||
recipient: form.recipient,
|
||||
phone: form.phone,
|
||||
region: form.region,
|
||||
city: form.city,
|
||||
line1: form.line1,
|
||||
postalCode: form.postalCode,
|
||||
isDefault: form.isDefault,
|
||||
}
|
||||
: form.isDefault
|
||||
? { ...row, isDefault: false }
|
||||
: row,
|
||||
);
|
||||
} else {
|
||||
const nextId = `a-local-${Date.now()}`;
|
||||
const next: MockAddress = {
|
||||
id: nextId,
|
||||
recipient: form.recipient,
|
||||
phone: form.phone,
|
||||
region: form.region,
|
||||
city: form.city,
|
||||
line1: form.line1,
|
||||
postalCode: form.postalCode,
|
||||
isDefault: form.isDefault,
|
||||
};
|
||||
rows.value = [next, ...rows.value.map((row) => ({ ...row, isDefault: form.isDefault ? false : row.isDefault }))];
|
||||
const input: AddressInput = {
|
||||
recipient: form.recipient,
|
||||
phone: form.phone,
|
||||
country: form.country,
|
||||
region: form.region,
|
||||
city: form.city,
|
||||
line1: form.line1,
|
||||
postal_code: form.postal_code,
|
||||
is_default: form.is_default,
|
||||
};
|
||||
saving.value = true;
|
||||
errorKey.value = "";
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await $api.updateAddress(editingId.value, input);
|
||||
} else {
|
||||
await $api.createAddress(input);
|
||||
}
|
||||
open.value = false;
|
||||
await load();
|
||||
} catch {
|
||||
errorKey.value = "user.saveFailed";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
open.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -107,12 +140,13 @@ function save(): void {
|
||||
{{ t("user.addresses") }}
|
||||
<button class="mbtn red" type="button" @click="openAdd">{{ t("user.addAddress") }}</button>
|
||||
</h1>
|
||||
<UiEmptyState v-if="rows.length === 0" :text="t('user.noAddresses')" />
|
||||
<UiEmptyState v-if="!loading && rows.length === 0" :text="t('user.noAddresses')" />
|
||||
<table v-else class="mtable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t("user.recipient") }}</th>
|
||||
<th>{{ t("user.phone") }}</th>
|
||||
<th>{{ t("user.country") }}</th>
|
||||
<th>{{ t("user.region") }}</th>
|
||||
<th>{{ t("user.city") }}</th>
|
||||
<th>{{ t("user.line1") }}</th>
|
||||
@@ -125,16 +159,18 @@ function save(): void {
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.recipient }}</td>
|
||||
<td>{{ row.phone }}</td>
|
||||
<td>{{ row.country }}</td>
|
||||
<td>{{ row.region }}</td>
|
||||
<td>{{ row.city }}</td>
|
||||
<td>{{ row.line1 }}</td>
|
||||
<td>{{ row.postalCode }}</td>
|
||||
<td>{{ row.postal_code }}</td>
|
||||
<td>
|
||||
<span v-if="row.isDefault" class="default-tag">{{ t("user.defaultAddress") }}</span>
|
||||
<span v-if="row.is_default" class="default-tag">{{ t("user.defaultAddress") }}</span>
|
||||
<button v-else class="mbtn" type="button" @click="setDefault(row.id)">{{ t("user.setDefault") }}</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="mbtn" type="button" @click="openEdit(row)">{{ t("common.edit") }}</button>
|
||||
<button class="mbtn" type="button" @click="remove(row)">{{ t("common.delete") }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -150,6 +186,10 @@ function save(): void {
|
||||
<span>{{ t("user.phone") }}</span>
|
||||
<input v-model.trim="form.phone" class="minput" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.country") }}</span>
|
||||
<input v-model.trim="form.country" class="minput" type="text" />
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.region") }}</span>
|
||||
<input v-model.trim="form.region" class="minput" type="text" />
|
||||
@@ -164,17 +204,17 @@ function save(): void {
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t("user.postalCode") }}</span>
|
||||
<input v-model.trim="form.postalCode" class="minput" type="text" />
|
||||
<input v-model.trim="form.postal_code" class="minput" type="text" />
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input v-model="form.isDefault" type="checkbox" />
|
||||
<input v-model="form.is_default" type="checkbox" />
|
||||
<span>{{ t("user.defaultAddress") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="errorKey" class="error">{{ t(errorKey) }}</p>
|
||||
<template #footer>
|
||||
<button class="mbtn" type="button" @click="open = false">{{ t("common.cancel") }}</button>
|
||||
<button class="mbtn red" type="button" @click="save">{{ t("user.saveAddress") }}</button>
|
||||
<button class="mbtn red" type="button" :disabled="saving" @click="save">{{ t("user.saveAddress") }}</button>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
|
||||
@@ -17,7 +17,8 @@ type LiveDomain =
|
||||
| "cart"
|
||||
| "orders"
|
||||
| "shipments"
|
||||
| "invoices";
|
||||
| "invoices"
|
||||
| "addresses";
|
||||
|
||||
/**
|
||||
* Explicit per-domain method picks rather than a string allowlist: indexing
|
||||
@@ -56,6 +57,13 @@ const LIVE_PICKS = {
|
||||
requestInvoice: a.requestInvoice,
|
||||
listMyInvoices: a.listMyInvoices,
|
||||
}),
|
||||
addresses: (a: ApiClient) => ({
|
||||
listMyAddresses: a.listMyAddresses,
|
||||
createAddress: a.createAddress,
|
||||
updateAddress: a.updateAddress,
|
||||
deleteAddress: a.deleteAddress,
|
||||
setDefaultAddress: a.setDefaultAddress,
|
||||
}),
|
||||
} satisfies Record<LiveDomain, (a: ApiClient) => Partial<ApiClient>>;
|
||||
|
||||
const KNOWN_DOMAINS = Object.keys(LIVE_PICKS) as LiveDomain[];
|
||||
@@ -72,6 +80,7 @@ const DEFAULT_LIVE_DOMAINS: LiveDomain[] = [
|
||||
"orders",
|
||||
"shipments",
|
||||
"invoices",
|
||||
"addresses",
|
||||
];
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# TBD — marketing capabilities with no backend (mall mock holdouts)
|
||||
|
||||
The mock→live migration finished at `replace-mock-api-wave-7` (address book, 2026-09-18).
|
||||
Every domain that has an API is live; what follows is what still renders from
|
||||
`apps/mall/mock/data.ts` because **no backend capability exists for it**. Each entry is a
|
||||
new capability (schema + routes + contract + pages), not a domain flip — pick one up by
|
||||
opening an OpenSpec change, the same way waves 1–7 did.
|
||||
|
||||
**How to use:** check a box only once the behaviour is implemented *and* verified against
|
||||
the live backend (`cargo run -p vmall-api`, `node scripts/seed-demo.mjs`), then remove the
|
||||
mock data and the page's `~/mock/data` import in the same change.
|
||||
|
||||
**Delete this file** once every box is checked, or consciously dropped and recorded. The
|
||||
"deliberately out of scope" list at the bottom does not block deleting it.
|
||||
|
||||
---
|
||||
|
||||
## Mock holdouts with a mall UI today
|
||||
|
||||
- [ ] **Coupons** — `user/coupons.vue` lists `MOCK_COUPONS`; `goods/[id].vue` shows a
|
||||
claim strip off the same fixture.
|
||||
Missing: `coupon_templates` (shop-issued: amount/threshold/window/stock), `coupons`
|
||||
(user-held, order-bound status). APIs: `GET /api/coupons` (mine),
|
||||
`POST /api/coupons/claim` (from a template), shop-admin template CRUD, and checkout
|
||||
application (select → discount minor → bind to order). Money math stays minor units.
|
||||
- [ ] **Favorites** — `user/favorites.vue` lists `MOCK_FAVORITES` (products tab +
|
||||
stores tab); `user/index.vue` derives counts from it.
|
||||
Missing: `favorites(user_id, product_id | shop_id)` with a partial unique index per
|
||||
target kind. APIs: `GET/POST/DELETE /api/favorites` (product & shop variants).
|
||||
- [ ] **Account stats** — `user/index.vue` shows `USER_STATS` (balance 128.00, points
|
||||
2680, frozen 0) and `integral.vue` reuses the points figure.
|
||||
Missing: balance/points accounts and ledgers (`money_logs` in the reference).
|
||||
MVP shape: `GET /api/me/stats` returning `{ balance_minor, points, frozen_minor }`;
|
||||
real ledgers only when a flow (recharge, refund-to-balance, points earn/spend) needs them.
|
||||
|
||||
## Marketing pages that are display-only mock
|
||||
|
||||
These exist as full pages (`seckill.vue`, `collective.vue`, `integral.vue`) linked from
|
||||
the home navigation; all three read fixtures directly.
|
||||
|
||||
- [ ] **Seckill (秒杀)** — `SECKILL_SESSIONS` + `seckillProducts()` (price override,
|
||||
sold %).
|
||||
Missing: `seckill_sessions`, `seckill_products` (activity price, isolated stock),
|
||||
`GET /api/seckill/sessions`, and checkout price resolution honouring the active session.
|
||||
- [ ] **Collective / 拼团** — `collectiveProducts()` (need/joined counts).
|
||||
Missing: `collective_activities`, `collective_groups` (open/join/expire, success on
|
||||
fill); orders bind to a group; refund/rollback policy on expiry.
|
||||
- [ ] **Integral mall / 积分商城** — `INTEGRAL_PRODUCTS` + points from `USER_STATS`.
|
||||
Missing: points ledger (earn/spend), `integral_products`, points-denominated checkout
|
||||
(`integral/orders` in the reference).
|
||||
|
||||
## Domains the reference has and this MVP does not (no UI here)
|
||||
|
||||
Recorded so the gap is explicit, not because all of them belong in scope:
|
||||
|
||||
- [ ] **Reviews / 评价** — no model; the mall presents none (review counts, the detail
|
||||
page's review tab/summary/replies were removed in wave 6 rather than kept invented).
|
||||
Missing: `order_comments` (order-item bound, rated, replyable), public read on product
|
||||
pages, shop reply, admin moderation. Writing/moderating/displaying reviews is a feature
|
||||
with its own lifecycle.
|
||||
- [ ] **Distribution / 分销**, **cashes / 提现**, **money logs** — qwshop user-center
|
||||
modules; no mock, no UI, no model here.
|
||||
- [ ] **Help center / articles** — nav links exist in the footer (`帮助中心`); no article
|
||||
model. Cheap version: static content pages; full version: admin-managed articles.
|
||||
- [ ] **OAuth login, SMS/captcha** — reference `users/oauth` + captcha plugin; here auth
|
||||
is email+password only.
|
||||
- [ ] **Freight templates / 运费模板** — shop-side shipping-fee rules; checkout currently
|
||||
charges no shipping at all.
|
||||
|
||||
## Deliberately out of scope — does not block deleting this file
|
||||
|
||||
- **The fixed-data adapter itself** (`apps/mall/mock/api.ts`, `~/mock/data`): the
|
||||
Mock API adapter spec requires it to keep serving every domain as the rollback path.
|
||||
Removing the fixtures above means pages stop *reading* them; the adapter stays.
|
||||
|
||||
## Invariants to keep when implementing any box
|
||||
|
||||
- Money is `i64` minor units + currency code; no float math anywhere.
|
||||
- New user-facing content fields are `{en, zh}` JSONB; UI copy goes through `$t()`.
|
||||
- Contract changes land only in `packages/shared` and all three frontends must still build.
|
||||
- State transitions validate preconditions (`UPDATE ... WHERE status = ...` pattern).
|
||||
- Each capability gets its own `openspec/changes/<name>/` and archives green.
|
||||
@@ -0,0 +1,51 @@
|
||||
# 0001. Modular monolith with handler / service / repository
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-09-18
|
||||
- Deciders: VMall maintainers
|
||||
- Related: [0002](0002-keep-http-rest-not-graphql.md), [tech spec](../tech-specs/rust-api.md)
|
||||
|
||||
## Context
|
||||
|
||||
`vmall-api` started as Axum route modules with SQL, validation, and HTTP mapping in the same handler. That was fine for an MVP of a few files. Checkout, stock, shipment status, and invoices then lived in 200–400 line handlers, duplicated across customer / shop / admin surfaces, with `SELECT *` leaking `password_hash` behind `skip_serializing`.
|
||||
|
||||
Rails-style MVC does not map cleanly onto Axum: there is no View layer, and “Controller” is just the handler. Full hexagonal / DDD (ports, adapters, domain events) would add compile time and indirection without a second persistence backend.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep a **single crate** (`vmall-api`) as a **modular monolith**. Split code by **bounded context** under `apps/api/src/modules/<context>/`, with three roles:
|
||||
|
||||
| Layer | Owns | Must not own |
|
||||
|-------|------|----------------|
|
||||
| Handler | Axum extracts, RBAC, HTTP status, JSON envelope | SQL, Redis, state machines |
|
||||
| Service | Use cases (checkout, default address, publish product) | `Json`, `StatusCode`, path params |
|
||||
| Repository / store | sqlx and Redis | HTTP types |
|
||||
|
||||
Simple CRUD may skip the service file and call the repository from the handler. Do **not** introduce a generic `Repository` trait unless a second backend exists.
|
||||
|
||||
Shared crate roots stay small: `error`, `auth`, `models`, `money`, `state`, `http` (pagination / query DTOs), `config`, `seed`.
|
||||
|
||||
HTTP paths, JSON field names, and `{"error":{"code","message"}}` stay unchanged so `@vmall/shared` and the three Nuxt apps do not move.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Customer, shop, and admin order lists share `order::service` with an `OrderScope`.
|
||||
- Checkout and fulfillment can be unit-tested against `AppState` without HTTP.
|
||||
- New features land in an existing module instead of growing `routes/*.rs`.
|
||||
|
||||
Negative:
|
||||
|
||||
- More files per use case; trivial list endpoints look heavier than a single handler.
|
||||
- Cross-module calls (fulfillment → order repo) must stay explicit; no hidden event bus.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep fat handlers.** Rejected: checkout and shipment transitions were already hard to reuse.
|
||||
|
||||
**Classic MVC packages (`controllers/`, `services/`, `models/`).** Rejected: splits a use case across three top-level trees; Axum has no views.
|
||||
|
||||
**Hexagonal architecture + domain events.** Rejected for current size: one Postgres, one Redis, one process.
|
||||
|
||||
**Framework switch (Loco, Actix).** Rejected: Axum 0.8 already matches the stack; a rewrite would not fix layering.
|
||||
@@ -0,0 +1,38 @@
|
||||
# 0002. Keep HTTP REST; do not replace the API with GraphQL
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-09-18
|
||||
- Deciders: VMall maintainers
|
||||
- Related: [0001](0001-rust-api-modular-monolith.md)
|
||||
|
||||
## Context
|
||||
|
||||
The mall, shop-admin, and platform-admin apps share one typed REST client in `@vmall/shared`. Handlers already return composed DTOs (`ProductWithSkus`, `OrderView`, `CartView`). Command flows (checkout, pay, cancel, partial ship, issue invoice) are state machines with 409 conflicts and idempotent `UPDATE … WHERE status = …`.
|
||||
|
||||
A GraphQL rewrite was proposed to “modernize” the API.
|
||||
|
||||
## Decision
|
||||
|
||||
**Keep REST** on `/api/*`. Do not replace the public contract with GraphQL.
|
||||
|
||||
A **read-only GraphQL** endpoint for catalog browsing may be considered later if a third-party or mobile client needs arbitrary field sets. Write paths (checkout, stock, fulfillment, invoices) stay REST commands.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Existing OpenSpec HTTP scenarios, integration tests, and the mock adapter remain valid.
|
||||
- Role checks stay on routes (`AuthUser::require_*`), not per GraphQL field.
|
||||
- GET caching and payment/webhook-style POSTs stay straightforward.
|
||||
|
||||
Negative:
|
||||
|
||||
- Clients that want a custom nested graph still make several REST calls (already the case; DTOs cover storefront needs).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Full GraphQL (`async-graphql`) as the only API.** Rejected: would rewrite three apps, `@vmall/shared`, seed scripts, and all HTTP tests; field-level auth for three roles on one schema is harder to audit; N+1 needs DataLoaders; uploads and webhooks still want REST.
|
||||
|
||||
**JSON:API / sparse fieldsets.** Not needed while composed DTOs match the UIs.
|
||||
|
||||
**BFF per frontend.** Unnecessary while all three apps share one contract package.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
ADRs in this directory record **why** the Rust API (`apps/api`, crate `vmall-api`) is structured the way it is. They are written in English and do not replace OpenSpec capability specs (`openspec/specs/`), which describe **what** HTTP behavior clients may rely on.
|
||||
|
||||
| ID | Title | Status |
|
||||
|----|--------|--------|
|
||||
| [0001](0001-rust-api-modular-monolith.md) | Modular monolith with handler / service / repository | Accepted |
|
||||
| [0002](0002-keep-http-rest-not-graphql.md) | Keep HTTP REST; do not replace the API with GraphQL | Accepted |
|
||||
|
||||
Template (MADR-inspired): Context → Decision → Consequences → Alternatives.
|
||||
|
||||
New ADRs: next unused number, `NNNN-kebab-title.md`, Status `Proposed` until accepted.
|
||||
|
||||
Companion: [tech spec — Rust API](../tech-specs/rust-api.md), [OpenSpec — api-architecture](../../openspec/specs/api-architecture/spec.md).
|
||||
@@ -0,0 +1,104 @@
|
||||
Rust API tech spec
|
||||
==================
|
||||
|
||||
Companion to [ADR 0001](../adr/0001-rust-api-modular-monolith.md) and [ADR 0002](../adr/0002-keep-http-rest-not-graphql.md). This document describes the **current** `vmall-api` layout and the rules new code must follow. HTTP behavior for each domain remains in `openspec/specs/` (auth, catalog, cart, order, …).
|
||||
|
||||
Context and motivation
|
||||
----------------------
|
||||
|
||||
Handlers previously mixed Axum extracts, business rules, and sqlx. The crate is now a modular monolith so checkout, fulfillment, and identity can be shared across the three role surfaces without changing REST URLs or JSON.
|
||||
|
||||
Goals:
|
||||
|
||||
- One crate, one process, Postgres + Redis.
|
||||
- Handler → service → repository (or store) per bounded context.
|
||||
- Stable REST contract for `@vmall/shared` and HTTP integration tests.
|
||||
|
||||
Non-goals:
|
||||
|
||||
- GraphQL as the primary API ([ADR 0002](../adr/0002-keep-http-rest-not-graphql.md)).
|
||||
- Hexagonal ports/adapters, a generic Repository trait, or a second web framework.
|
||||
- Changing error envelope, money representation, or RBAC roles.
|
||||
|
||||
Implementation considerations
|
||||
-----------------------------
|
||||
|
||||
- **Crate:** `apps/api`, package `vmall-api`, Axum 0.8, sqlx 0.8, Redis connection manager.
|
||||
- **Migrations:** `apps/api/migrations/`, append-only; run on boot against a **single** `PgPool` shared with the server (see `main.rs` + `state::assemble`).
|
||||
- **Money:** `i64` minor units + ISO code; `money::convert_minor` is a pure function, not a repository.
|
||||
- **SQL:** `sqlx::query*` / `query_as` with explicit binds. Prefer column lists over `SELECT *` on `users` (use `USER_COLUMNS` + `User` row vs `UserPublic` JSON).
|
||||
- **State machines:** `UPDATE … WHERE status = …`; zero rows → `ApiError::Conflict`, never a silent no-op success.
|
||||
- **RBAC:** `AuthUser::require`, `require_customer`, `require_admin`, `require_shop` / `own_shop`. Cross-shop resource access is 404, not 403.
|
||||
|
||||
High-level request flow
|
||||
-----------------------
|
||||
|
||||
```
|
||||
Nuxt + @vmall/shared → Handler (Axum)
|
||||
→ Service (use case)
|
||||
→ Repo / cart store
|
||||
→ Postgres | Redis
|
||||
```
|
||||
|
||||
1. Handler extracts `State<AppState>`, `AuthUser`, path/query/JSON.
|
||||
2. Handler maps HTTP-only concerns (`StatusCode::CREATED`) after the service returns `ApiResult<Dto>`.
|
||||
3. Service opens transactions when more than one write must commit together (checkout, default address, shipment create).
|
||||
4. Repository functions take `&PgPool`, `&mut PgConnection`, or `&mut Transaction` so a service can compose them.
|
||||
|
||||
Module map
|
||||
----------
|
||||
|
||||
| Module | Bounded context | Typical routes |
|
||||
|--------|-----------------|----------------|
|
||||
| `identity` | Auth, users, role assignment | `/auth/*`, `/admin/users` |
|
||||
| `catalog` | Products, SKUs, categories, brands | `/products`, `/shop/products`, `/brands` |
|
||||
| `cart` | Redis cart + purchasable snapshots | `/cart` |
|
||||
| `order` | Checkout, pay, cancel, lists by scope | `/orders`, `/shop/orders`, `/admin/orders` |
|
||||
| `fulfillment` | Shipments, delivery confirmation | `/shipments`, `/shop/shipments` |
|
||||
| `billing` | Invoice request / issue | `/invoices`, `/shop/invoices` |
|
||||
| `address` | Customer address book | `/addresses` |
|
||||
| `shop` | Shop CRUD, profiles, `/shop/profile` | `/shops`, `/admin/shops` |
|
||||
| `content` | Home banners / promos / links | `/content/home`, `/admin/content` |
|
||||
| `currency` | Rates and convert | `/currencies` |
|
||||
| `health` | Liveness / readiness | `/health`, `/ready` |
|
||||
|
||||
Each module typically contains `mod.rs`, `handlers.rs`, `service.rs`, and optionally `repo.rs` / `dto.rs` / `store.rs`. Merge routers in `modules::api_router()`.
|
||||
|
||||
Shared types live in `models.rs` (sqlx `FromRow` + enums). API-facing user JSON is `UserPublic` (no `password_hash`).
|
||||
|
||||
Error handling
|
||||
--------------
|
||||
|
||||
`ApiError` serializes as `{"error":{"code","message"}}`:
|
||||
|
||||
| Variant | HTTP | `code` |
|
||||
|---------|------|--------|
|
||||
| `NotFound` | 404 | `NOT_FOUND` |
|
||||
| `BadRequest` | 400 | `BAD_REQUEST` |
|
||||
| `Unauthorized` | 401 | `UNAUTHORIZED` |
|
||||
| `Forbidden` | 403 | `FORBIDDEN` |
|
||||
| `Conflict` | 409 | `CONFLICT` |
|
||||
| `Internal` | 500 | `INTERNAL` (generic message; details in logs) |
|
||||
|
||||
`From<sqlx::Error>`: `RowNotFound` → 404; unique / check violations → 409. Domain-specific unique messages use `unique_conflict(err, "email already registered")`.
|
||||
|
||||
Future-proofing
|
||||
---------------
|
||||
|
||||
- New capabilities get a new or existing `modules/<ctx>` plus OpenSpec; they do not add SQL to handlers.
|
||||
- A later read-only GraphQL surface would sit beside REST and call the same services.
|
||||
- Compile-time `query_as!` may replace string SQL incrementally; it is not required for new queries.
|
||||
|
||||
Testing approach
|
||||
----------------
|
||||
|
||||
- **HTTP contract:** `apps/api/tests/*.rs` via `tests/common/mod.rs` (`spawn_app`, unique slugs). These tests are the REST regression gate.
|
||||
- **Service:** `tests/order_service.rs` (and similar) call services with `spawn_state()` — empty cart, stock 409, split-by-shop, illegal status transitions.
|
||||
- **Pure functions:** `money`, `http::pagination` unit tests in-module.
|
||||
|
||||
Acceptance criteria
|
||||
-------------------
|
||||
|
||||
- New write use cases live in a service; handlers do not embed sqlx except trivial reads if a service would be empty ceremony.
|
||||
- `cargo test -p vmall-api` stays green and repeatable against `vmall_test` + Redis.
|
||||
- REST paths and JSON shapes used by `@vmall/shared` do not change without an OpenSpec change.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Proposal: replace-mock-api-wave-7
|
||||
|
||||
## Why
|
||||
|
||||
The address book is the last piece of the core purchase chain still on fixed mock data: `user/addresses.vue` renders `MOCK_ADDRESSES` with local-only edits, and checkout picks from the same static list. A real address entity closes the chain end-to-end against the live API, following the established wave pattern (per-domain live pick in the mall's api plugin, mock adapter stays as fallback).
|
||||
|
||||
## What Changes
|
||||
|
||||
- New `addresses` table: `user_id` FK (cascade), recipient/phone/country/region/city/line1/postal_code, `is_default`, timestamps; partial unique index enforces one default per user.
|
||||
- New route module `apps/api/src/routes/addresses.rs`: `GET/POST /api/addresses`, `PUT/DELETE /api/addresses/:id`, `POST /api/addresses/:id/default` — customer role only, ownership via `WHERE user_id =` (cross-user → 404). Default management is transactional (unset siblings in the same statement/transaction); the first address becomes default automatically; deleting the default promotes the most recent remaining address.
|
||||
- `packages/shared`: `AddressBookEntry` type + `listMyAddresses` / `createAddress` / `updateAddress` / `deleteAddress` / `setDefaultAddress` on `ApiClient`.
|
||||
- Mall mock adapter implements the same methods against its persisted state (seeded from `MOCK_ADDRESSES`), so mock mode keeps working and the rollback flag stays meaningful.
|
||||
- Mall pages: `user/addresses.vue` switches to real CRUD (existing `UiModal` form pattern, plus delete + set-default); `checkout/index.vue` loads the address list from the API (falls back to an inline manual form when the list is empty) and submits the selected one; `addresses` joins `LIVE_PICKS`/`DEFAULT_LIVE_DOMAINS`.
|
||||
- `scripts/seed-demo.mjs` seeds two demo addresses for `customer@vmall.local`, idempotently.
|
||||
- Integration tests in `apps/api/tests/addresses.rs` (fixtures from `tests/common/mod.rs`): CRUD happy path, cross-user 404, single-default invariant, delete-default promotion, role guard.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `address-book`: per-customer saved shipping addresses with a single default.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `frontend-mall`: the buyer center address page and checkout address selection use the live address API.
|
||||
|
||||
## Impact
|
||||
|
||||
`apps/api/migrations/0009_addresses.sql`, `apps/api/src/routes/{addresses.rs,mod.rs}`, router registration, `packages/shared/src/{types.ts,api.ts}`, `apps/mall/{mock/api.ts,plugins/api.ts,pages/user/addresses.vue,pages/checkout/index.vue,locales/user.ts,locales/checkout.ts}`, `scripts/seed-demo.mjs`, `apps/api/tests/addresses.rs`. The contract change rebuilds all three frontends.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No address auto-geocoding, no region cascader data (free-text region/city stays), no address selector inside the order-before flow beyond radio pick.
|
||||
- Coupons, favorites, wallet stats, marketing subsystems (seckill/collective/integral) and reviews remain mock/future work, unchanged.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Spec delta: address-book
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Saved shipping addresses
|
||||
A customer SHALL be able to list, create, update and delete their own shipping addresses through the API. Every address operation SHALL be scoped to the authenticated customer; operating on another user's address SHALL return 404. Non-customer roles SHALL be rejected with 403.
|
||||
|
||||
#### Scenario: manage own addresses
|
||||
- **WHEN** a customer creates, updates or deletes an address they own
|
||||
- **THEN** the change persists and appears in their address list
|
||||
|
||||
#### Scenario: cross-user access
|
||||
- **WHEN** a customer requests an address id owned by a different user
|
||||
- **THEN** the API returns 404 as if the address did not exist
|
||||
|
||||
### Requirement: Single default address
|
||||
Each customer SHALL have at most one default address, enforced by the database. The first saved address SHALL become the default. Setting a new default SHALL unset the previous one atomically. Deleting the default address SHALL promote the most recently created remaining address to default.
|
||||
|
||||
#### Scenario: switch default
|
||||
- **WHEN** a customer marks address B as default while address A was default
|
||||
- **THEN** B is default and A is not, after one request
|
||||
|
||||
#### Scenario: delete the default
|
||||
- **WHEN** a customer deletes their default address and other addresses remain
|
||||
- **THEN** the most recently created remaining address becomes the default
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Spec delta: frontend-mall
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Address book management
|
||||
The mall buyer center SHALL list, add, edit, delete and set-default the signed-in customer's saved addresses through the selected API adapter, replacing the previous fixed mock list.
|
||||
|
||||
#### Scenario: manage addresses in the buyer center
|
||||
- **WHEN** a signed-in shopper opens `/user/addresses` and adds or edits an address
|
||||
- **THEN** the change persists through the adapter and survives a full page reload
|
||||
|
||||
### Requirement: Checkout address selection
|
||||
Checkout SHALL offer the customer's saved addresses for selection, defaulting to their default address, and SHALL submit the selected address with the order. When the customer has no saved address, checkout SHALL provide an inline manual address form instead.
|
||||
|
||||
#### Scenario: checkout with a saved address
|
||||
- **WHEN** a signed-in shopper with saved addresses checks out
|
||||
- **THEN** the default address is preselected and the created order carries the chosen address
|
||||
@@ -0,0 +1,29 @@
|
||||
# Tasks: replace-mock-api-wave-7
|
||||
|
||||
## 1. Backend
|
||||
- [x] `apps/api/migrations/0009_addresses.sql`: table + `addresses_one_default` partial unique index
|
||||
- [x] `apps/api/src/routes/addresses.rs`: list/create/update/delete/set-default; customer role; `WHERE user_id` ownership (404 cross-user); transactional default switching; first address auto-default; delete-default promotes latest remaining
|
||||
- [x] Register module + router; `AddressBookEntry` row model
|
||||
|
||||
## 2. Shared contract
|
||||
- [x] `packages/shared/src/types.ts`: `AddressBookEntry`
|
||||
- [x] `packages/shared/src/api.ts`: `listMyAddresses`, `createAddress`, `updateAddress`, `deleteAddress`, `setDefaultAddress`
|
||||
|
||||
## 3. Mock adapter
|
||||
- [x] `apps/mall/mock/api.ts`: same five methods over persisted mock state (seed from `MOCK_ADDRESSES`)
|
||||
- [x] `apps/mall/plugins/api.ts`: `addresses` live pick + `DEFAULT_LIVE_DOMAINS` entry
|
||||
|
||||
## 4. Mall pages
|
||||
- [x] `user/addresses.vue`: real CRUD via `$api` (modal add/edit, delete, set-default)
|
||||
- [x] `checkout/index.vue`: load addresses from `$api`; radio select; inline manual form fallback when empty; submit selected address
|
||||
- [x] Locale keys (user/checkout domains, en+zh)
|
||||
|
||||
## 5. Seed + tests
|
||||
- [x] `scripts/seed-demo.mjs`: two idempotent demo customer addresses
|
||||
- [x] `apps/api/tests/addresses.rs`: CRUD, cross-user 404, single-default invariant, delete-default promotion, non-customer 403
|
||||
|
||||
## 6. Verify
|
||||
- [x] `cargo test -p vmall-api` green (docker vmall_test + Redis)
|
||||
- [x] `pnpm --filter @vmall/mall build` + shop-admin + admin builds green (contract change)
|
||||
- [x] `openspec validate replace-mock-api-wave-7 --strict` green
|
||||
- [x] Browser smoke: address CRUD in user center; checkout with saved address end-to-end
|
||||
@@ -4,7 +4,7 @@ context: |
|
||||
VMall: B2B2C e-commerce MVP, spec-driven in phases.
|
||||
|
||||
Tech stack:
|
||||
- Backend: Rust (axum 0.8, sqlx 0.8 + Postgres 18, redis 8, JWT, argon2) at apps/api, crate vmall-api. Money is stored as integer minor units + ISO currency code; never floats.
|
||||
- Backend: Rust (axum 0.8, sqlx 0.8 + Postgres 18, redis 8, JWT, argon2) at apps/api, crate vmall-api. Modular monolith: handler → service → repository under src/modules/<context> (docs/adr/0001, docs/tech-specs/rust-api.md). HTTP REST is the public contract; GraphQL is not the primary API (docs/adr/0002). Money is stored as integer minor units + ISO currency code; never floats.
|
||||
- Frontends: three Nuxt 3 apps in pnpm workspace: apps/mall (customer storefront, port 3000), apps/shop-admin (merchant console, 3001), apps/admin (platform console, 3002).
|
||||
- Shared contract: packages/shared (@vmall/shared) — TS types, API client, en/zh locales, ui.css. Frontends must use it; no per-app API reimplementation.
|
||||
- Dev infra: Postgres + Redis run in local docker (containers pg18, rdb8); databases vmall / vmall_test; API runs migrations on boot (sqlx migrate).
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# address-book Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change replace-mock-api-wave-7. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Saved shipping addresses
|
||||
A customer SHALL be able to list, create, update and delete their own shipping addresses through the API. Every address operation SHALL be scoped to the authenticated customer; operating on another user's address SHALL return 404. Non-customer roles SHALL be rejected with 403.
|
||||
|
||||
#### Scenario: manage own addresses
|
||||
- **WHEN** a customer creates, updates or deletes an address they own
|
||||
- **THEN** the change persists and appears in their address list
|
||||
|
||||
#### Scenario: cross-user access
|
||||
- **WHEN** a customer requests an address id owned by a different user
|
||||
- **THEN** the API returns 404 as if the address did not exist
|
||||
|
||||
### Requirement: Single default address
|
||||
Each customer SHALL have at most one default address, enforced by the database. The first saved address SHALL become the default. Setting a new default SHALL unset the previous one atomically. Deleting the default address SHALL promote the most recently created remaining address to default.
|
||||
|
||||
#### Scenario: switch default
|
||||
- **WHEN** a customer marks address B as default while address A was default
|
||||
- **THEN** B is default and A is not, after one request
|
||||
|
||||
#### Scenario: delete the default
|
||||
- **WHEN** a customer deletes their default address and other addresses remain
|
||||
- **THEN** the most recently created remaining address becomes the default
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# api-architecture Specification
|
||||
|
||||
## Purpose
|
||||
Internal layout of `vmall-api`: a modular monolith (handler → service → repository) over a stable HTTP REST contract. Client-visible behavior of each domain remains in the other OpenSpec capabilities.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Bounded-context modules
|
||||
Domain code SHALL live under `apps/api/src/modules/<context>/` (identity, catalog, cart, order, fulfillment, billing, address, shop, content, currency, health). Shared HTTP helpers SHALL live under `apps/api/src/http/`. New features MUST NOT add sqlx to a deleted-style `routes/` tree.
|
||||
|
||||
#### Scenario: new use case
|
||||
- **WHEN** a developer adds a write use case (for example checkout or set-default-address)
|
||||
- **THEN** the SQL and transaction live in a service and/or repository, and the Axum handler only authenticates, deserializes, and maps `ApiResult` to status + JSON
|
||||
|
||||
#### Scenario: simple list
|
||||
- **WHEN** the endpoint is a single SELECT with no extra rules
|
||||
- **THEN** the handler MAY call the repository directly without a dedicated service function
|
||||
|
||||
### Requirement: Layer contracts
|
||||
Services SHALL return `ApiResult<Dto>` and MUST NOT depend on `axum::Json` or `StatusCode`. Repositories SHALL accept `&PgPool`, `&mut PgConnection`, or `&mut Transaction` so one service can compose several writes. The crate MUST NOT introduce a generic persistence trait unless a second backend exists.
|
||||
|
||||
#### Scenario: checkout transaction
|
||||
- **WHEN** checkout locks SKUs, inserts orders, decrements stock, and clears the cart
|
||||
- **THEN** Postgres writes share one transaction in `order::service`, and Redis cart clear happens after commit
|
||||
|
||||
### Requirement: Stable REST envelope
|
||||
The public API SHALL remain resource-oriented REST under `/api`. Errors SHALL use `{"error":{"code","message"}}`. Unique and check constraint violations SHALL map to HTTP 409 unless the handler substitutes a domain message via `unique_conflict`.
|
||||
|
||||
#### Scenario: duplicate email
|
||||
- **WHEN** registration hits a unique email constraint
|
||||
- **THEN** the client receives 409 with code `CONFLICT` and a domain message, not a 500
|
||||
|
||||
### Requirement: Shared use cases across surfaces
|
||||
Customer, shop, and platform-admin reads of the same aggregate SHALL call one service with an explicit scope (for example `OrderScope::{User, Shop, Admin}`) instead of copying SQL per router.
|
||||
|
||||
#### Scenario: order list
|
||||
- **WHEN** `GET /api/orders`, `GET /api/shop/orders`, and `GET /api/admin/orders` run
|
||||
- **THEN** they share `order::service::list` and differ only by auth and scope
|
||||
|
||||
### Requirement: Tests
|
||||
HTTP integration tests under `apps/api/tests/` SHALL remain the contract suite. State-machine use cases (checkout stock, illegal pay/cancel) SHALL also be covered at the service layer without requiring GraphQL.
|
||||
|
||||
#### Scenario: service checkout
|
||||
- **WHEN** a cart qty exceeds SKU stock
|
||||
- **THEN** `order::checkout` returns `ApiError::Conflict` and no order row is committed
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
## Purpose
|
||||
The buyer-facing storefront: shell, home page, discovery, shopping and transaction flows, and the buyer center.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Localized storefront
|
||||
The mall SHALL render every UI string and all catalog/store/marketing mock content in en or zh from one switcher, defaulting to en. Switching locale SHALL update the desktop shell and current page without a full reload.
|
||||
|
||||
@@ -138,3 +136,18 @@ The mall SHALL provide a store directory and store home reading live shops from
|
||||
#### Scenario: a shop without a profile still renders
|
||||
- **WHEN** a shop has no profile row
|
||||
- **THEN** the directory and store home render it without inventing logo, scores or copy
|
||||
|
||||
### Requirement: Address book management
|
||||
The mall buyer center SHALL list, add, edit, delete and set-default the signed-in customer's saved addresses through the selected API adapter, replacing the previous fixed mock list.
|
||||
|
||||
#### Scenario: manage addresses in the buyer center
|
||||
- **WHEN** a signed-in shopper opens `/user/addresses` and adds or edits an address
|
||||
- **THEN** the change persists through the adapter and survives a full page reload
|
||||
|
||||
### Requirement: Checkout address selection
|
||||
Checkout SHALL offer the customer's saved addresses for selection, defaulting to their default address, and SHALL submit the selected address with the order. When the customer has no saved address, checkout SHALL provide an inline manual address form instead.
|
||||
|
||||
#### Scenario: checkout with a saved address
|
||||
- **WHEN** a signed-in shopper with saved addresses checks out
|
||||
- **THEN** the default address is preselected and the created order carries the chosen address
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Address,
|
||||
AddressBookEntry,
|
||||
AuthTokens,
|
||||
Brand,
|
||||
BrandInput,
|
||||
@@ -77,6 +78,10 @@ export interface ShipmentItemBody {
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface AddressInput extends Address {
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface CurrencyUpsertBody {
|
||||
code: string;
|
||||
name: LocalizedText;
|
||||
@@ -175,6 +180,11 @@ export interface ApiClient {
|
||||
/** Public store directory: active shops with whatever profile they have. */
|
||||
listShops(): Promise<ShopProfile[]>;
|
||||
getShop(slug: string): Promise<ShopProfile>;
|
||||
listMyAddresses(): Promise<AddressBookEntry[]>;
|
||||
createAddress(address: AddressInput): Promise<AddressBookEntry>;
|
||||
updateAddress(id: string, address: AddressInput): Promise<AddressBookEntry>;
|
||||
deleteAddress(id: string): Promise<AddressBookEntry[]>;
|
||||
setDefaultAddress(id: string): Promise<AddressBookEntry>;
|
||||
shop: {
|
||||
getMyShop(): Promise<Shop>;
|
||||
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
|
||||
@@ -250,6 +260,11 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
getHomeContent: () => r("GET", "/content/home"),
|
||||
listShops: () => r("GET", "/shops"),
|
||||
getShop: (slug) => r("GET", `/shops/${slug}`),
|
||||
listMyAddresses: () => r("GET", "/addresses"),
|
||||
createAddress: (address) => r("POST", "/addresses", address),
|
||||
updateAddress: (id, address) => r("PUT", `/addresses/${id}`, address),
|
||||
deleteAddress: (id) => r("DELETE", `/addresses/${id}`),
|
||||
setDefaultAddress: (id) => r("POST", `/addresses/${id}/default`),
|
||||
shop: {
|
||||
getMyShop: () => r("GET", "/shop/profile"),
|
||||
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
|
||||
|
||||
@@ -176,6 +176,13 @@ export interface Address {
|
||||
postal_code: string;
|
||||
}
|
||||
|
||||
export interface AddressBookEntry extends Address {
|
||||
id: string;
|
||||
user_id: string;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ShipmentStatus = "pending" | "shipped" | "delivered";
|
||||
|
||||
export interface Shipment {
|
||||
|
||||
@@ -177,6 +177,24 @@ for (const def of SHOPS) {
|
||||
await call("POST", "/auth/register", {
|
||||
body: { email: "customer@vmall.local", password: "customer123", display_name: "Demo Customer" },
|
||||
});
|
||||
{
|
||||
r = await call("POST", "/auth/login", {
|
||||
body: { email: "customer@vmall.local", password: "customer123" },
|
||||
});
|
||||
if (r.status !== 200) fail("customer login", r);
|
||||
const customerToken = r.data.token;
|
||||
const existing = (await call("GET", "/addresses", { token: customerToken })).data;
|
||||
if (Array.isArray(existing) && existing.length === 0) {
|
||||
const demoAddresses = [
|
||||
{ recipient: "Demo Customer", phone: "+1 555 0100", country: "US", region: "California", city: "Cupertino", line1: "1 Infinite Loop", postal_code: "95014", is_default: true },
|
||||
{ recipient: "Demo Customer", phone: "+1 555 0100", country: "US", region: "New York", city: "New York", line1: "88 Fifth Ave", postal_code: "10011" },
|
||||
];
|
||||
for (const address of demoAddresses) {
|
||||
r = await call("POST", "/addresses", { token: customerToken, body: address });
|
||||
if (r.status !== 200 && r.status !== 201) fail("seed address", r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. categories (reference data from the migrations)
|
||||
const cats = (await call("GET", "/categories")).data;
|
||||
|
||||
Reference in New Issue
Block a user