npm run server + npm run preview。服务端负责:cart 读写、promo 码校验(折扣率不再写死在前端)、checkout 生成订单号并落库;normalizeCart 对客户端载荷做完整校验。
import express from 'express'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { catalog } from './catalog.mjs'
const PORT = 8787
const DB_PATH = fileURLToPath(new URL('./db.json', import.meta.url))
const DEFAULT_DB = {
cart: { items: [], promo: null },
orders: [],
promos: [
{ code: 'CRAFT10', percent: 10 },
{ code: 'LAUNCH25', percent: 25 },
],
seq: 1000,
}
function loadDb() {
if (!existsSync(DB_PATH)) return structuredClone(DEFAULT_DB)
try {
return { ...structuredClone(DEFAULT_DB), ...JSON.parse(readFileSync(DB_PATH, 'utf8')) }
} catch {
return structuredClone(DEFAULT_DB)
}
}
const db = loadDb()
const saveDb = () => writeFileSync(DB_PATH, JSON.stringify(db, null, 2))
const LICENSES = new Set(['Standard', 'Extended'])
/** Validate + normalize a client cart payload against the catalog. Returns null when malformed. */
function normalizeCart(body) {
if (!body || typeof body !== 'object' || !Array.isArray(body.items)) return null
const seen = new Set()
const items = []
for (const raw of body.items) {
if (!raw || typeof raw !== 'object') return null
const { templateId, license, qty, selected } = raw
if (typeof templateId !== 'string' || !(templateId in catalog) || seen.has(templateId)) return null
if (typeof license !== 'string' || !LICENSES.has(license)) return null
if (typeof qty !== 'number' || !Number.isInteger(qty)) return null
seen.add(templateId)
items.push({ templateId, license, qty: Math.min(9, Math.max(1, qty)), selected: Boolean(selected) })
}
let promo = null
if (body.promo != null) {
const code = typeof body.promo === 'object' ? body.promo.code : undefined
const def = typeof code === 'string' ? findPromo(code) : undefined
if (!def) return null
promo = { code: def.code, percent: def.percent }
}
return { items, promo }
}
const findPromo = (code) => db.promos.find((p) => p.code.toUpperCase() === String(code).trim().toUpperCase())
const app = express()
app.use(express.json())
app.get('/api/cart', (_req, res) => {
res.json(db.cart)
})
app.put('/api/cart', (req, res) => {
const cart = normalizeCart(req.body)
if (!cart) return res.status(400).json({ error: 'Malformed cart payload' })
db.cart = cart
saveDb()
res.json(db.cart)
})
app.post('/api/promo/validate', (req, res) => {
订单 CK-1001 是 selfcheck 脚本点 Checkout 后服务端写入的——真实副作用,非 mock。
{
"cart": {
"items": [],
"promo": null
},
"orders": [
{
"id": "CK-1001",
"createdAt": "2026-08-18T07:33:57.510Z",
"lines": [
{
"templateId": "pulse",
"name": "Pulse Fitness Widgets",
"license": "Standard",
"qty": 2,
"unitPrice": 39,
"lineTotal": 78
}
],
"subtotal": 78,
"promoCode": "CRAFT10",
"discount": 7.8,
"total": 70.2
},
{
"id": "CK-1002",
"createdAt": "2026-08-18T07:40:38.093Z",
"lines": [
{
"templateId": "pulse",
"name": "Pulse Fitness Widgets",
"license": "Standard",
"qty": 1,
"unitPrice": 39,
"lineTotal": 39
}
],
"subtotal": 39,
"promoCode": "CRAFT10",
"discount": 3.9,
"total": 35.1
},
{
"id": "CK-1003",
"createdAt": "2026-08-18T07:40:50.358Z",
"lines": [
{
"templateId": "ledger",
"name": "Ledger Finance Kit",
"license": "Standard",
"qty": 2,
"unitPrice": 49,
"lineTotal": 98
}
],
"subtotal": 复用既有 TS 类型作为 API contract;统一错误封装(ApiError),网络失败有诚实报错。
import type { CartItem, Promo } from './types'
export interface CartPayload {
items: CartItem[]
promo: Promo | null
}
export interface CheckoutResult {
orderId: string
total: number
cart: CartPayload
}
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response
try {
res = await fetch(path, { headers: { 'Content-Type': 'application/json' }, ...init })
} catch {
throw new ApiError('Network error — is the API server running?', 0)
}
if (!res.ok) {
let message = `Request failed (${res.status})`
try {
const body = (await res.json()) as { error?: string }
if (body.error) message = body.error
} catch {
/* non-JSON error body — keep the status message */
}
throw new ApiError(message, res.status)
}
return (await res.json()) as T
}
export const api = {
fetchCart: () => request<CartPayload>('/api/cart'),
saveCart: (payload: CartPayload) =>
request<CartPayload>('/api/cart', { method: 'PUT', body: JSON.stringify(payload) }),
validatePromo: async (code: string): Promise<Promo> => {
const res = await request<{ valid: boolean; code?: string; percent?: number; error?: string }>(
'/api/promo/validate',
{ method: 'POST', body: JSON.stringify({ code }) },
)
if (!res.valid || typeof res.code !== 'string' || typeof res.percent !== 'number') {
throw new ApiError(res.error ?? 'Invalid promo code', 404)
}
return { code: res.code, percent: res.percent }
},
checkout: (payload: CartPayload) =>
request<CheckoutResult>('/api/checkout', { method: 'POST', body: J首帧 fetchCart 水合 + 防抖 saveCart 回写 + checkout 走 API——内存操作原位替换成接口调用。
stateRef.current = state
// Background sync bookkeeping: skip the write that follows a server load,
// coalesce rapid mutations into one serial PUT chain.
const syncRef = useRef({ skip: true, dirty: false, running: false })
const reloadCart = useCallback(async () => {
dispatch({ type: 'cartLoading' })
try {
const data = await api.fetchCart()
syncRef.current.skip = true
dispatch({ type: 'cartLoaded', items: data.items, promo: data.promo })
} catch (e) {
dispatch({ type: 'cartLoadError', message: e instanceof Error ? e.message : 'Network error' })
}
}, [])
useEffect(() => {
void reloadCart()
}, [reloadCart])
useEffect(() => {
if (state.cartStatus !== 'ready') return
if (syncRef.current.skip) {
syncRef.current.skip = false
return
}
syncRef.current.dirty = true
if (syncRef.current.running) return
syncRef.current.running = true
void (async () => {
try {
while (syncRef.current.dirty) {
syncRef.current.dirty = false
await api.saveCart({ items: stateRef.current.cart, promo: stateRef.current.promo })
}
} catch {
dispatch({ type: 'toast', message: 'Cart sync failed — check the API server' })
} finally {
syncRef.current.running = false
}
})()
}, [state.cart, state.promo, state.cartStatus])
const checkout = useCallback(async () => {
const { cart, promo } = stateRef.current
const result = await api.checkout({ items: cart, promo })
syncRef.current.skip = true
dispatch({ type: 'checkoutDone', items: result.cart.items, promo: result.cart.promo, orderId: result.orderId })
}, [])
const value = useMemo(
() => ({ state, dispatch, reloadCart, checkout }),
[state, reloadCart, checkout],
)
return <AppContext.Provider value={value}>{children}</AppContext.Provider>
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: { '/api': 'http://localhost:8787' },
},
preview: {
proxy: { '/api': 'http://localhost:8787' },
},
})