← 返回 4 轮看板

5 轮全栈特别篇 — flow_cc_0037 购物车接真实后端

同一 CC 会话五轮连续演进:建站 → 加购物车(草图)→ 勾选结算联动 → 批量移除 → 接真实后端(Express + JSON 持久化 + /api 代理)。223 calls · 上下文峰值 411K(41% of 1M,无 compact)· 五轮 final gate 全绿。
server/index.mjs 127 行 + db.json 落库
前端 diff 仅 5 文件新增 api.ts · 状态层换 fetch · 组件层不动
自检 69 条含 api-cart-persistence 等 3 条后端项(reload 持久化用例)
真实订单CK-1001 由自检通过 UI 下单、服务端写入

五轮 User Query

R1 · 由 flow 图建站
把这张手绘的交互流程图(多屏 + 箭头 + 交互标注)做成一个可运行的前端工程(React 或 Vue + Vite + TypeScript),把图里的每个屏都实现成组件,并把屏之间的跳转和草图里出现的所有交互用代码真正接上、一种都不要遗漏;最后 npm install 且 npm run build 必须通过。 流程图: /workspace/sketch.png 把工程建在并构建于: /workspace/app
R2 · 加购物车(附草图)
想让市场首页右上角的购物车图标可点击,进入“购物车”页,集中确认模板并结算。页面包含商品列表、优惠码、订单汇总和结算按钮;列表项须数据驱动,显示缩略图、模板名、平台、授权、价格、数量和删除操作。布局参考 /workspace/feature_sketch.png,新页面配色字体组件风格必须与现有产品完全一致,其他页面路由交互保持不变。改完补自检清单并完整重新自检,全部通过再交付。
R3 · 勾选结算联动
请调整 Shopping Cart 的勾选与结算联动:Select All 控制购物车内全部商品,部分勾选时显示半选态;订单摘要和底部总价仅统计已选商品,未选任何商品时 Checkout 置灰且不可点击。数量变化或移除商品后须即时重算,其他保持不变、改完补自检清单并完整重新自检全部通过再交付。
R4 · 批量移除
再加一个购物车批量移除功能,入口放在 selection row 右侧,仅在至少勾选一件商品时显示“Remove Selected”。点击后弹出确认层,确认后一次移除已选商品,并同步商品总数、Select All 状态、订单摘要和底部总价,取消则不改数据;若购物车被清空,原页展示带返回 Marketplace 按钮的空态。新增部分的配色字体组件风格与现有产品完全一致;补自检清单,完整重新自检全部通过再交付。
R5 · 接真实后端
现在把购物车和下单接到真实后端。在工程里加一个轻量 API 服务(app/server/ 目录,Express 或 Hono,数据存 server/db.json 这类 JSON 文件,端口 8787,package.json 加 "server" 脚本),提供这些接口:读取/保存购物车(含勾选状态和数量)、promo 码校验(折扣率由服务端返回,前端不再写死)、Checkout 提交订单(服务端生成订单号、记录订单并清空购物车)。前端把状态层里的内存操作改成调用 /api 接口,配好 vite 的 dev 和 preview 代理让页面地址不变,数据加载和出错要有诚实的 loading / 错误提示。验收标准:改动购物车后刷新页面内容仍在(真持久化);提交订单后能在服务端数据文件里看到订单记录。页面样式和其他交互保持不变,npm run build 必须过;自检清单补上后端相关项(至少包含一条"刷新后状态保留"的用例),起 server 和 preview 后完整重新自检,全部通过再交付。

五轮产物对照

R1 产物

打开网站 ↗

R2 产物

打开网站 ↗

R3 产物

打开网站 ↗

R4 产物

打开网站 ↗

R5 产物

打开网站 ↗
⚠️ 关于 R5 iframe:本看板是静态托管(没有跑 API server),所以 R5 页面里购物车会显示真实的网络错误态("Network error — is the API server running?")——这恰恰是后端接入的直接证据:R1–R4 的数据在前端内存里、静态托管照常工作;R5 的数据在服务端、拔掉后端就诚实报错。完整体验需在工程目录跑 npm run server + npm run preview。

后端体现在哪 — 代码证据

server/index.mjs(127 行 Express + JSON 文件持久化)

服务端负责: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) => {

server/db.json(自检时通过 UI 真实下单的记录)

订单 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": 

src/api.ts(60 行类型化 API 客户端,R5 新增)

复用既有 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

src/state/AppContext.tsx(状态层换心脏,组件不动)

首帧 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>

vite.config.ts(dev/preview 双代理,页面地址不变)

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' },
  },
})