案例:网页前端直连¶
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 所有来源都放