Cirray Chat 前后端整理
今天把 cirray.cn 的 /chat 功能前后端整体实现了一遍,顺手整理成文档。
整体架构
用户 → zh.cirray.cn(Next.js 前端)→ token.cirray.cn(Rust/Axum 后端)→ AI 服务
前端是 Next.js 15 App Router,API Routes 充当代理层,解决 CORS 的同时也避免把后端地址暴露给浏览器。后端是 Rust + Axum,SQLite 做用户存储,对外暴露 OpenAI 兼容接口。
下面这张图是完整调用链路(时序图风格):
认证模块
登录token
没有用JWT.
现在用户量极小(家庭/团队级),直接用随机 hex token 存 SQLite,简单直接:
- 登录一次,token 永久有效
- 要踢人直接删数据库记录
- 没有任何过期逻辑需要维护
登录流程
用手机号+验证码,用户不需要记密码。后端实际上还是 username/password 体系,前端用 HMAC 从手机号派生一个确定性密码,对用户透明。
手机号 → 阿里云短信发验证码
用户输入验证码 → 前端调 /api/auth/verify
→ 后端校验验证码(内存 Map,5分钟过期)
→ HMAC-SHA256(phone, VERIFY_SECRET) 派生密码
→ 调后端 /auth/login,失败则先 /auth/register 再 login
→ 返回 token
前端 → localStorage.setItem("clawtoken_token", token)前端接口
发送验证码
POST /api/auth/send-code
Content-Type: application/json
{ "phone": "13800138000" }
→ 200: { "ok": true }
→ 500: { "error": "账户余额不足" }
验证 + 登录/注册
POST /api/auth/verify
Content-Type: application/json
{ "phone": "13800138000", "code": "123456" }
→ 200: { "token": "3f8a2c...64位hex" }
→ 400: { "error": "验证码错误或已过期" }
token 取到后存 localStorage["clawtoken_token"],后续所有请求带上:
Authorization: Bearer <token>
对话模块
前端接口
发起对话(流式)
POST /api/chat
Content-Type: application/json
Authorization: Bearer <token>
{
"model": "claude-sonnet-4-6",
"messages": [
{ "role": "user", "content": "你好" }
],
"stream": true,
"conversation_id": "057c23fe-..." // 可选,续聊时传
}
响应:SSE 流
data: {"choices":[{"delta":{"role":"assistant","content":"你"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":"好"},"finish_reason":null}]}
...
data: [DONE]
首次对话响应头里带有:
X-Conversation-Id: 057c23fe-xxxx-xxxx-xxxx-xxxxxxxxxxxx
前端存下这个 id,下次发消息带上,就能续上同一段对话。
SSE 解析关键代码
async function* sseStream(res: Response, abortRef: React.MutableRefObject<boolean>) {
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!abortRef.current) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const raw = line.slice(6).trim();
if (raw === "[DONE]") return;
const json = JSON.parse(raw);
const text = json.choices?.[0]?.delta?.content;
if (text) yield text;
}
}
}
核心点:buffer 处理 TCP 分包,lines.pop() 保留未完成的行留到下次拼接。
多轮对话设计
后端不存历史消息,历史由 AI 服务那边的 Project/Conversation 机制维护。前端只需要:
- 首次对话:传完整
messages数组,不传conversation_id - 收到响应头里的
X-Conversation-Id,存起来 - 后续对话:只传最新一条
user消息 +conversation_id
客户端不用存历史,后端也不用存历史,省事。
下一步
这套系统稳定跑起来之后,打算先分享给家人和最亲近的朋友用。
一方面,好东西本来就该先给最近的人。顶级 AI 能力现在还不是人手一个,能帮他们养成习惯、真正用起来,比什么都值。另一方面,这些人也是最真实的测试用户——他们不会客气,遇到问题会直接说,比任何 A/B test 都管用。
让家人先跑起来,就是最好的产品测试。