跳转至

案例:网页前端直连

React 前端走 REST + WebSocket,浏览器里直接发单。

需求

  • 给个人用户的交易面板,前端 React / Vue 无后端
  • 实时行情 + 手动下单(用户点击)
  • HTTPS + 自签 token(用户登录时发一个,前端存 localStorage)
  • 浏览器 WS 只能 ?token= 查询参数(WebSocket API 不支持自定义 header)

架构

浏览器 (React)
  ├── https://api.futuapi.com/api/*        ← REST,Authorization: Bearer
  ├── wss://api.futuapi.com/ws?token=...   ← 推送(?token= 因为浏览器 WS 限制)
  └── 用户点击 → POST /api/order
  Caddy (自动 HTTPS) → futu-opend (:22222)

key 策略

用户登录后后端签一个短期 key(30 分钟过期):

# 后端脚本(简化示例)
futucli gen-key \
  --id "user-${USER_ID}-session-$(date +%s)" \
  --scopes qot:read,acc:read,trade:real \
  --allowed-markets HK,US \
  --max-order-value 50000 \
  --max-orders-per-minute 10 \
  --expires 30m

前端拿到 plaintext 存 sessionStorage(不是 localStorage,关闭 tab 自动清)。

Caddy 反代 + CORS

/etc/caddy/Caddyfile
api.futuapi.com {
    # 浏览器前端跨域
    header {
        Access-Control-Allow-Origin "https://app.futuapi.com"
        Access-Control-Allow-Methods "GET, POST, OPTIONS"
        Access-Control-Allow-Headers "Authorization, Content-Type"
    }
    @preflight method OPTIONS
    respond @preflight 204

    reverse_proxy localhost:22222
}

React client 代码框架

const API = 'https://api.futuapi.com'
const WS_URL = 'wss://api.futuapi.com/ws'

// token 从 sessionStorage
const token = () => sessionStorage.getItem('futu_token')

// REST 调用
async function apiPost<T>(path: string, body: object): Promise<T> {
  const res = await fetch(`${API}${path}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${token()}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  })
  if (res.status === 401) throw new Error('token 过期,请重新登录')
  if (res.status === 403) throw new Error('scope 不够')
  if (res.status === 429) {
    const err = await res.json()
    throw new Error(`限额触发:${err.error}`)
  }
  if (!res.ok) throw new Error(await res.text())
  return res.json() as Promise<T>
}

// WS 推送
function useQuoteSubscription(symbols: string[]) {
  useEffect(() => {
    // 先订阅
    apiPost('/api/subscribe', {
      security_list: symbols.map(parseSymbol),
      sub_type_list: [1],  // Basic
    })

    // 再连 WS(浏览器只能 query 参数带 token)
    const ws = new WebSocket(`${WS_URL}?token=${token()}`)
    ws.onmessage = (ev) => {
      const event = JSON.parse(ev.data)
      // { type: 'quote', proto_id, sec_key, body_b64 }
      const body = Uint8Array.from(atob(event.body_b64), c => c.charCodeAt(0))
      const qot = decodeBasicQot(body)
      dispatchQuote(qot)
    }
    return () => ws.close()
  }, [symbols.join(',')])
}

// 下单按钮
async function onPlaceOrder(order: OrderForm) {
  try {
    const resp = await apiPost('/api/order', buildPlaceOrderBody(order))
    toast.success(`订单已提交,order_id=${resp.order_id}`)
  } catch (e) {
    toast.error(e.message)  // "限额触发:rate limit exceeded"
  }
}

安全注意事项

  • token 存 sessionStorage 不是 localStorage —— 关闭 tab 就清
  • 短过期 —— 30 分钟自动失效;前端过期前提前 refresh
  • CORS 白名单严格 —— Access-Control-Allow-Origin 只写你的 app 域名
  • CSP —— Content-Security-Policy: connect-src 'self' https://api.futuapi.com wss://api.futuapi.com
  • 下单二次确认 —— 前端 UI 上让用户再点一次"确认"
  • HSTS —— Caddy 自动开了

不推荐

  • ❌ 把长期 key 直接嵌 JS 代码 —— 任何打开 devtools 的人都能读
  • scope=trade:real 的 key 有效期 > 1 天
  • ❌ 不走 HTTPS
  • ❌ 关 CORS 所有来源都放