secure-crypto-top-zod-sdk
v1.0.8
Published
🔐 端到端加密 SDK - 融合版:多协议(custom/TOP) + 多算法(AES-GCM/SM4/SM3/MD5/RSA-OAEP) + zod 校验 + 浏览器零 polyfill(Web Crypto 原生)
Maintainers
Readme
🔐 secure-crypto-top-zod-sdk
融合版端到端加密 SDK:多协议(custom / TOP / binary) + 多算法(AES-GCM/CBC/SM4/SM3/MD5/SHA-512/RSA-OAEP) + zod 校验 + 浏览器零 polyfill(Web Crypto 原生) + 分片上传 v1.3.0(秒传 / 断点续传 / 进度 / 取消,单文件 5GB) + 智能上传 v1.4.0(SmartClient 唯一上传入口,任意大小/任意数量文件,服务端智能分流 ONE_SHOT / MULTIPART) + 信封走私防护 v1.0.4
⚠️ v1.4.x 方案 A 清理:
requestBinary()方法与POST /upload/{private,public,batch}三个老端点已删除,业务统一用SmartClient.upload()。binary协议与MultipartClient仍保留(SmartClient 内部复用)。 吸收了secure-crypto-sdk(老)和secure-crypto-top-sdk(新)的所有优点
✨ 融合特性一览
| 维度 | 旧 secure-crypto-sdk | 旧 secure-crypto-top-sdk | 新 secure-crypto-top-zod-sdk v1.0.4 |
|------|------------------------|---------------------------|------------------------------------------|
| 协议 | 1 个 (custom) | 2 个 (custom + top) | ✅ 3 个(custom + top + binary) |
| 加密算法 | AES-256-GCM | AES-GCM/CBC/SM4/RSA-OAEP | ✅ 5 种 全保留 |
| 摘要算法 | HMAC-SHA256 | HMAC-SHA256/512/MD5/SM3 | ✅ 6 种 全保留(新增 sha256/sha512 浏览器原生) |
| 分片上传 | ❌ | ❌ | ✅ v1.0.3 新增:MultipartClient + 5 端点 + 秒传/续传/重试/进度/取消 |
| 智能上传 (Smart) | ❌ | ❌ | ✅ v1.4.0 新增:SmartClient 一行调用,唯一上传入口,服务端按 totalSize 智能分流 ONE_SHOT(≤1MB)/ MULTIPART(1MB/5MB/10MB 动态 partSize),后续 part/complete 复用 multipart/*。v1.0.8+ maxFilesPerBatch 可配(默认 50) |
| 批量上传 v1.2.0(POST /upload/batch) | ❌ | ❌ | ⚠️ v1.4.x 方案 A 已删除,老代码请迁 SmartClient.upload() |
| requestBinary() 单文件 binary 上传 | ❌ | ❌ | ⚠️ v1.4.x 方案 A 已删除,内部分片/任意大小文件用 MultipartClient 或 SmartClient |
| 信封走私防护 | ❌ | ❌ | ✅ v1.0.4 新增:envelope.method/http_method 强校验 + IP 自动封禁 + fail closed |
| 多 app 隔离 | ❌ | ✅ | ✅ 保留 |
| 浏览器运行 | ✅ Web Crypto 原生(零依赖) | ❌ 需 crypto-browserify polyfill | ✅ 零 polyfill(Web Crypto) |
| 浏览器 bundle | ~50KB | ~1.4MB (带 polyfill) | ✅ ~232KB(自包含,无 polyfill,含分片 + 智能上传 + v1.0.8 可配批量上限) |
| 启动校验密钥 | ✅ | ❌ | ✅ 融合双方 |
| zod 字段校验 | ✅ | ❌ | ✅ 融合 |
| 响应验签 | ✅ | ❌ | ✅ 融合(verifyResponse: true 选项) |
| 国密 SM4/SM3 | ❌ | ✅ | ✅ 保留(服务端用) |
| Fastify 集成 | ✅ | ✅ | ✅ 保留 |
| 跨运行时抽象 | ❌(直接 Web Crypto) | ❌(直接 node:crypto) | ✅ CryptoBackend 抽象层 |
| 框架集成示例 | — | — | ✅ Vue 3 / React 18 一线集成示例 |
| SSR / Edge 支持 | — | — | ✅ Nuxt 3 · Next.js · Cloudflare Workers BFF 示例 |
🚀 快速开始
服务端(Fastify)
import Fastify from 'fastify';
import cors from '@fastify/cors';
import secureCryptoPlugin from 'secure-crypto-top-zod-sdk/server';
const app = Fastify({ logger: true });
app.register(cors, { origin: true });
app.register(secureCryptoPlugin, {
apps: [
{
appKey: 'demo-app',
appSecret: 'demo-app-secret-1234567890abcdef', // >= 32 字符
allowedCiphers: ['aes-256-gcm', 'aes-256-cbc', 'sm4-cbc', 'rsa-oaep-aes-256-gcm'],
allowedDigests: ['hmac-sha256', 'hmac-sha512', 'sm3'],
},
],
defaultProtocol: 'top', // 或 'custom'
bypassPaths: ['/health'],
});
// 启动后任何 app.post 都自动加密/解密
app.post('/api/v1/top/login', async (req) => {
return { user: { id: 1, name: 'alice' }, biz: (req as any).secureRequest.biz };
});
app.listen({ port: 3000 });客户端(浏览器 / Node)
import { SecureClient } from 'secure-crypto-top-zod-sdk/client';
const client = new SecureClient({
baseURL: 'https://api.example.com',
appKey: 'demo-app',
// encryptionKey = 64 字符 hex(32 字节),用于 AES-256-GCM 加解密业务数据
// 推荐:和服务端 appSecret 独立(多 app 隔离 / 密钥轮换更安全)
encryptionKey: 'a'.repeat(64), // ★ 32 字节对称密钥,不要和 appSecret 混用
appSecret: 'demo-app-secret-1234567890abcdef', // ★ 0.2.x 推荐字段名(兼容旧 hmacSecret)
protocol: 'top', // 或 'custom'
cipher: 'aes-256-gcm',
digest: 'hmac-sha256',
// 融合后新增(可选):
verifyResponse: true, // 是否验签响应(需要客户端持有 secret)
});
const result = await client.post('/api/v1/top/login', {
username: 'alice',
password: 's3cret',
});字段命名:0.2.x 推荐使用
appSecret,老字段hmacSecret仍兼容;响应验签可选独立responseAppSecret(兼容旧responseHmacSecret)。
🧩 框架集成(Vue 3 / React 18)
下面给两个最常见的 SPA 框架的"开箱即用"示例。核心思路相同:
- 单例
SecureClient(全局只 new 一次,避免重复握手/派生密钥) - 拦截器层面统一处理加签/解密,业务代码完全无感
- 错误抛
SecureError,UI 层 toast 即可 - 配合后端
secureCryptoPlugin一行 register 即可
⚡ Vue 3(Composition API + Axios 拦截器)
适合:Vue 3 + Vite + Pinia + Axios 项目。
1. src/lib/secure-client.ts —— 单例
import { SecureClient, SecureError } from 'secure-crypto-top-zod-sdk/client';
// ⚠️ 浏览器场景:appSecret 暴露在前端,务必配合后端风控(短 token、IP 限流、签名有效期)
export const secureClient = new SecureClient({
baseURL: import.meta.env.VITE_API_BASE ?? 'https://api.example.com',
appKey: import.meta.env.VITE_APP_KEY ?? 'demo-app',
// 推荐:把 appSecret 单独部署(下文有"为什么 secret 不能进 bundle"的说明)
appSecret: import.meta.env.VITE_APP_SECRET ?? 'demo-app-secret-1234567890abcdef',
// 64 字符 hex(= SHA256(appSecret)),如果不想算可以后端给一个预生成值
encryptionKey: import.meta.env.VITE_ENCRYPTION_KEY ?? '',
protocol: 'top',
cipher: 'aes-256-gcm',
digest: 'hmac-sha256',
verifyResponse: true,
});
export { SecureError };2. src/lib/api.ts —— 拦截器里改用 SecureClient
import axios, { AxiosError, type InternalAxiosRequestConfig } from 'axios';
import { secureClient, SecureError } from './secure-client';
export const http = axios.create({ baseURL: secureClient['baseURL'] });
// 请求拦截:把业务参数先交给 SecureClient 加密,再把 envelope 塞进 body
http.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
const method = (config.method?.toUpperCase() ?? 'GET') as
| 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
const url = config.url ?? '/';
// SecureClient 内部会发自己的 POST + 解析响应,所以走它自己的 request
// 这里把 axios 适配成一个"加密通道":用 adapter 替换真正的网络层
config.adapter = async (axiosConfig) => {
try {
const data = await secureClient.request(method, url, {
body: axiosConfig.data,
headers: axiosConfig.headers as Record<string, string>,
});
return {
data,
status: 200,
statusText: 'OK',
headers: {},
config: axiosConfig,
};
} catch (e) {
if (e instanceof SecureError) {
throw new AxiosError(e.message, String(e.code), axiosConfig);
}
throw e;
}
};
return config;
});
// 响应拦截:把业务错误统一抛
http.interceptors.response.use(
(resp) => resp.data,
(err: AxiosError) => {
// 业务层 try/catch 即可,这里只打 log
console.error('[API]', err.code, err.message);
return Promise.reject(err);
}
);3. src/composables/useApi.ts —— 业务层 hook
import { ref } from 'vue';
import { http } from '@/lib/api';
import { SecureError } from '@/lib/secure-client';
export function useApi<TReq, TRes>(url: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' = 'POST') {
const data = ref<TRes | null>(null);
const error = ref<string | null>(null);
const loading = ref(false);
const run = async (body?: TReq) => {
loading.value = true;
error.value = null;
try {
const resp = await http.request<TRes>({ url, method, data: body });
data.value = resp.data;
} catch (e) {
error.value = e instanceof SecureError ? e.message : String(e);
} finally {
loading.value = false;
}
};
return { data, error, loading, run };
}4. 组件里使用(<script setup>)
<script setup lang="ts">
import { useApi } from '@/composables/useApi';
interface LoginReq { username: string; password: string; }
interface LoginRes { user: { id: number; name: string }; token: string; }
const { data, error, loading, run } = useApi<LoginReq, LoginRes>('/api/v1/top/login', 'POST');
const submit = () => run({ username: 'alice', password: 's3cret' });
</script>
<template>
<button :disabled="loading" @click="submit">登录</button>
<p v-if="error" style="color:red">{{ error }}</p>
<pre v-else-if="data">{{ data }}</pre>
</template>5. 配合 Pinia 存 token
// src/stores/auth.ts
import { defineStore } from 'pinia';
import { http } from '@/lib/api';
export const useAuthStore = defineStore('auth', {
state: () => ({ token: '' as string }),
actions: {
async login(username: string, password: string) {
const data = await http.post('/api/v1/top/login', { username, password });
this.token = (data.data as any).token;
},
},
});⚛️ React 18(Hooks + 自定义 fetch 封装)
适合:React 18 + Vite/Webpack + Zustand/Redux 项目。
1. src/lib/secure-client.ts —— 单例
import { SecureClient, SecureError } from 'secure-crypto-top-zod-sdk/client';
export const secureClient = new SecureClient({
baseURL: import.meta.env.VITE_API_BASE ?? 'https://api.example.com',
appKey: import.meta.env.VITE_APP_KEY ?? 'demo-app',
appSecret: import.meta.env.VITE_APP_SECRET ?? 'demo-app-secret-1234567890abcdef',
encryptionKey: import.meta.env.VITE_ENCRYPTION_KEY ?? '',
protocol: 'top',
cipher: 'aes-256-gcm',
digest: 'hmac-sha256',
verifyResponse: true,
});
export { SecureError };2. src/lib/api.ts —— 通用 apiFetch 封装
import { secureClient, SecureError } from './secure-client';
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
export async function apiFetch<T = unknown>(
method: Method,
path: string,
body?: unknown,
init?: RequestInit
): Promise<T> {
try {
return await secureClient.request<T>(method, path, {
body,
headers: init?.headers as Record<string, string>,
signal: init?.signal,
});
} catch (e) {
if (e instanceof SecureError) {
// 业务错误(签名错 / 业务失败)统一抛
const err = new Error(e.message);
(err as any).code = e.code;
throw err;
}
throw e;
}
}
// 几个语法糖
export const api = {
get: <T>(p: string, init?: RequestInit) => apiFetch<T>('GET', p, undefined, init),
post: <T>(p: string, b?: unknown, init?: RequestInit) => apiFetch<T>('POST', p, b, init),
put: <T>(p: string, b?: unknown, init?: RequestInit) => apiFetch<T>('PUT', p, b, init),
delete: <T>(p: string, init?: RequestInit) => apiFetch<T>('DELETE', p, undefined, init),
patch: <T>(p: string, b?: unknown, init?: RequestInit) => apiFetch<T>('PATCH', p, b, init),
};3. src/hooks/useApi.ts —— 业务 hook
import { useCallback, useState } from 'react';
import { api } from '@/lib/api';
export function useApi<Req, Res>(method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', path: string) {
const [data, setData] = useState<Res | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const run = useCallback(async (body?: Req) => {
setLoading(true);
setError(null);
try {
const r = await api[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete' | 'patch']<Res>(path, body as any);
setData(r);
return r;
} catch (e: any) {
setError(e?.message ?? String(e));
throw e;
} finally {
setLoading(false);
}
}, [method, path]);
return { data, error, loading, run };
}4. 组件里使用
import { useApi } from '@/hooks/useApi';
interface LoginReq { username: string; password: string; }
interface LoginRes { user: { id: number; name: string }; token: string; }
export function LoginButton() {
const { data, error, loading, run } = useApi<LoginReq, LoginRes>('POST', '/api/v1/top/login');
return (
<div>
<button disabled={loading} onClick={() => run({ username: 'alice', password: 's3cret' })}>
{loading ? '登录中...' : '登录'}
</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</div>
);
}5. SWR / React Query 适配(可选)
如果你已经在用 @tanstack/react-query,只需把 queryFn / mutationFn 替换成 apiFetch 即可:
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api';
export function useLogin() {
return useMutation({
mutationFn: (req: LoginReq) => api.post<LoginRes>('/api/v1/top/login', req),
});
}
export function useProfile() {
return useQuery({
queryKey: ['profile'],
queryFn: () => api.get<{ id: number; name: string }>('/api/v1/top/profile'),
});
}🔐 "secret 在前端"是不是有问题?
是的,生产环境强烈不推荐 把 appSecret 写进前端 bundle。本 SDK 的浏览器场景定位是:
- 内网 / 后台管理系统(用户登录态本身就有 RBAC,加签是"防抓包/防重放")
- 混合方案:secret 放在 BFF(Backend-For-Frontend)层,前端只调 BFF;BFF 再用本 SDK 的 Node 入口调真实业务
如果一定要放在浏览器:
- 启用后端 nonce 短窗口 + IP 限流
- 缩短 accessToken 有效期(配合
refresh) - 关闭
verifyResponse(浏览器拿到 secret 验签没意义,除非你愿意接受泄露)
简单说:secret 进 bundle = 等于明文 HTTPS。要真正的端到端,放 BFF。
📤 分片上传(Multipart Upload)v1.0.3 新增
单文件最大 5GB · 单片 5-50MB · 断点续传 · 秒传 · 并发可控 · 可取消 浏览器 / Node / RN / Web Worker 一套代码搞定
🎯 适用场景
| 场景 | 推荐 API | 备注 |
|------|---------|------|
| 单文件 > 200MB(单文件直传 bodyLimit 兜不住) | MultipartClient | 5GB 上限,按 part 切 |
| 网络不稳,需要断点续传 | MultipartClient | 自动跳过已收到的 part |
| 同一文件多人传,需要秒传 | MultipartClient | 整文件 SHA-256 命中即返回 |
| 大文件要 UI 进度条 | MultipartClient + onProgress | 5 个 phase 都打点 |
| 要中途取消 | MultipartClient + AbortController | 失败自动 abort 清理 |
| 自定义传输层(已有 axios/fetch 封装) | 5 个低阶方法 | 自己控制每一步 |
🚀 两种使用方式
| 方式 | 适合 | 代码量 | 推荐度 |
|------|------|--------|--------|
| 高阶封装 MultipartClient | 90% 场景 | 一行 new ...upload(file) | ⭐⭐⭐⭐⭐ |
| 低阶 5 方法 | 特殊定制(已有传输层/自定义并发) | ~30 行手写 | ⭐⭐⭐ |
✅ 方式一:MultipartClient(强烈推荐)
MultipartClient 把分片上传"应该是什么样"封装成一行 new MultipartClient(client).upload(file)。
自动处理清单:
- ✅ 整文件 SHA-256(用于秒传)
- ✅ 断点续传(启动前先
status,跳过已收到的 part) - ✅ 并发上传(默认 3,可配
concurrency) - ✅ 失败重试(指数退避,默认 5 次,500ms 起步)
- ✅ 智能重试(参数错 / 状态错 / 签名错不重试;网络错 / 限流 / 5xx 才重试)
- ✅ 进度回调(
onProgress({ bytesUploaded, partsDone, phase })) - ✅ 中途取消(
AbortSignal) - ✅ 失败自动 abort(不留 uploadId 尸体)
- ✅ 跨环境(浏览器 / Node / RN / Web Worker 一套代码,零运行时依赖)
浏览器 + <input type="file">
<input type="file" id="picker" />
<progress id="bar" max="100" value="0"></progress>
<pre id="out"></pre>
<script type="module">
import { SecureClient, MultipartClient, fromBlob } from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/client.mjs';
const client = new SecureClient({
baseURL: 'https://api.example.com',
appKey: 'demo-app',
appSecret: 'demo-app-secret-1234567890abcdef', // 浏览器场景见下文"secret 安全"
encryptionKey: 'a'.repeat(64), // 32 字节对称密钥
protocol: 'top',
cipher: 'aes-256-gcm',
digest: 'hmac-sha256',
});
const mc = new MultipartClient(client);
document.getElementById('picker').addEventListener('change', async (e) => {
const file = e.target.files[0];
const bar = document.getElementById('bar');
const out = document.getElementById('out');
try {
const result = await mc.upload(fromBlob(file), {
isPublic: false,
session: localStorage.getItem('jwt'),
concurrency: 3,
computePartHash: true, // 让 server 校验每个 part
onProgress: (p) => {
// p: { bytesUploaded, totalBytes, partsDone, partsTotal, phase }
// phase: 'hashing' | 'init' | 'uploading' | 'merging' | 'done'
const pct = Math.min(100, (p.bytesUploaded / p.totalBytes) * 100);
bar.value = pct;
out.textContent = `[${p.phase}] ${pct.toFixed(1)}% (${p.partsDone}/${p.partsTotal} parts)`;
},
});
out.textContent = `✅ 上传完成\n${JSON.stringify(result, null, 2)}`;
// result = { fileId, filename, size, contentType, url, dedup, resumed, finalHash }
} catch (e) {
out.textContent = `❌ ${e.message}`;
}
});
</script>Node + Buffer
import { SecureClient, MultipartClient, fromBuffer } from 'secure-crypto-top-zod-sdk';
import { promises as fs } from 'node:fs';
const client = new SecureClient({
baseURL: 'http://localhost:8080',
appKey: 'demo-app',
appSecret: process.env.APP_SECRET!,
encryptionKey: process.env.APP_ENCRYPTION_KEY_HEX!,
protocol: 'top',
cipher: 'aes-256-gcm',
digest: 'hmac-sha256',
});
const buf = await fs.readFile('./big-video.mp4');
const result = await new MultipartClient(client).upload(
fromBuffer(buf, 'big-video.mp4', 'video/mp4'),
{
isPublic: true,
concurrency: 5,
onProgress: (p) => console.log(`[${p.phase}] ${(p.bytesUploaded / p.totalBytes * 100).toFixed(1)}%`),
}
);
console.log('fileId:', result.fileId);中途取消(AbortController)
const ac = new AbortController();
const uploadPromise = mc.upload(fromBlob(file), {
isPublic: false,
session: jwt,
signal: ac.signal, // ← 透传 AbortSignal
onProgress: (p) => console.log(p.phase, p.bytesUploaded),
});
// 3 秒后用户点了"取消"
setTimeout(() => ac.abort(), 3000);
try {
await uploadPromise;
} catch (e) {
if (e.name === 'AbortError') {
console.log('用户主动取消,server 端 uploadId 已被自动清理');
}
}断点续传(浏览器刷新后恢复)
// 关键:把 uploadId 持久化到 localStorage / sessionStorage
// MultipartClient 内部会自动调 status 跳过已收到的 part,
// 业务代码只要把 uploadId 缓存起来传给下次 upload() 即可
// ★ v1.0.3:MultipClient.upload() 每次都 new uploadId,断点续传由 SDK 透明处理
// 进阶场景(自己控制 uploadId 生命周期)请用低阶 5 方法所有选项
interface MultipartUploadOptions {
isPublic?: boolean; // private 还是 public(默认 false)
session?: string; // JWT,isPublic=false 必填
partSize?: number; // 自定义 part 字节数(默认让 server 决定,5-50MB)
concurrency?: number; // 并发 part 数(默认 3)
maxRetries?: number; // 单 part 失败重试次数(默认 5)
retryBaseMs?: number; // 重试基础退避 ms(默认 500,指数退避)
signal?: AbortSignal; // 取消信号
meta?: Record<string, unknown>; // 业务元信息,透传到 server
onProgress?: (p: MultipartProgress) => void;
fileHash?: string; // 客户端预计算的整文件 SHA-256(可选)
computePartHash?: boolean; // 是否算 part 级别 hash(默认 false)
}
interface MultipartProgress {
bytesUploaded: number;
totalBytes: number;
partsDone: number;
partsTotal: number;
phase: 'hashing' | 'init' | 'uploading' | 'merging' | 'done';
}
interface MultipartUploadResult {
fileId: string;
filename: string;
size: number;
contentType: string;
url: string; // /api/v1/upload/files/{fileId}
dedup: boolean; // 是否秒传命中
resumed: boolean; // 是否断点续传完成
finalHash: string; // 整文件 SHA-256
}主动查询 / 终止(高阶方法)
const mc = new MultipartClient(client);
// 查状态(返回 { status, receivedParts, totalParts, ... })
const st = await mc.status('upload_abc123', jwt);
console.log(`已收到 ${st.receivedParts.length}/${st.totalParts} 片`);
// 主动终止(清理磁盘 + 标记 ABORTED)
await mc.abort('upload_abc123', jwt);⚙️ 方式二:低阶 5 方法(自定义传输层时用)
// 1. init(可带 expectedHash 争秒传)
const init = await client.multipartInit({
filename: 'big.mp4',
contentType: 'video/mp4',
totalSize: file.byteLength,
isPublic: false,
session: jwt,
// expectedHash: precomputedSha256, // 客户端已算好可以传
});
if (init.dedup) {
console.log('秒传命中,fileId =', init.fileId);
return init.fileId;
}
const { uploadId, partSize, totalParts } = init;
// 2. part × N(可并发,可重复幂等)
const CONCURRENCY = 3;
const uploadPart = async (partNumber: number) => {
const start = (partNumber - 1) * partSize;
const end = Math.min(start + partSize, file.byteLength);
const chunk = file.slice(start, end);
await client.multipartPart({
uploadId,
partNumber,
data: new Uint8Array(await chunk.arrayBuffer()),
// partHash: await sha256Hex(chunk), // 可选
session: jwt,
});
};
// 简易并发池
const queue = Array.from({ length: totalParts }, (_, i) => i + 1);
await Promise.all(
Array.from({ length: CONCURRENCY }, async () => {
while (queue.length) {
const n = queue.shift()!;
await uploadPart(n);
}
})
);
// 3. complete(可带 finalHash 完整性校验)
const result = await client.multipartComplete({
uploadId,
finalHash: precomputedSha256,
session: jwt,
});
console.log('完成,fileId =', result.fileId);
// 4. (可选)主动终止
await client.multipartAbort({ uploadId, session: jwt });
// 5. (可选)查状态(断点续传)
const status = await client.multipartStatus({ uploadId, session: jwt });
// status.receivedParts = [1, 2, 3, 5] ← 第 4 片没收到📡 对应服务端 5 端点
| HTTP 端点 | 协议 | SDK 方法 | 用途 |
|-----------|------|---------|------|
| POST /api/v1/upload/multipart/init | TOP | client.multipartInit | 创建 uploadId,可选秒传 |
| POST /api/v1/upload/multipart/part | binary | client.multipartPart | 上传分片(可重复幂等) |
| POST /api/v1/upload/multipart/complete | TOP | client.multipartComplete | 合并分片,生成 fileId |
| POST /api/v1/upload/multipart/abort | TOP | client.multipartAbort | 主动终止(清理磁盘) |
| POST /api/v1/upload/multipart/status | TOP | client.multipartStatus | 查已收到的 part(续传,body 传 uploadId) |
服务端实现见
backend/apps/upload-service/src/routes/multipart.ts(~700 行,含 5min 清理任务 + 24h TTL)。
⚠️ 错误码(MULTIPART_* 1520-1590)
| code | 名称 | 含义 | 可重试 |
|------|------|------|--------|
| 1520 | MULTIPART_NOT_FOUND | uploadId 不存在(可能已 TTL 过期) | ❌ |
| 1521 | MULTIPART_ALREADY_COMPLETED | uploadId 已完成 | ❌ |
| 1522 | MULTIPART_ALREADY_ABORTED | uploadId 已终止 | ❌ |
| 1523 | MULTIPART_EXPIRED | uploadId 超过 24h TTL | ❌ |
| 1524 | MULTIPART_STATUS_INVALID | 状态机非法转移(并发冲突) | ❌ |
| 1530 | MULTIPART_TOTAL_TOO_LARGE | 文件总大小 > 5GB | ❌ |
| 1531 | MULTIPART_PART_TOO_LARGE | 单片 > 50MB | ❌ |
| 1532 | MULTIPART_PART_TOO_SMALL | 单片 < 5MB(最后一篇可豁免) | ❌ |
| 1533 | MULTIPART_INVALID_PART_NUMBER | partNumber 越界 | ❌ |
| 1534 | MULTIPART_DUPLICATE_PART | 同 partNumber 重复且 hash 不一致 | ❌ |
| 1535 | MULTIPART_PART_HASH_MISMATCH | 客户端声明的 part hash 与实际不一致 | ❌ |
| 1536 | MULTIPART_FINAL_HASH_MISMATCH | 整文件 hash 不一致 | ❌ |
| 1537 | MULTIPART_INCOMPLETE_PARTS | 收齐前调 complete | ❌ |
| 1538 | MULTIPART_AUTH_REQUIRED | private 上传未传 JWT | ❌ |
| 1539 | MULTIPART_INVALID_ARG | 参数非法(partSize<=0 等) | ❌ |
| 1590 | MULTIPART_CLIENT_ABORTED | 用户主动取消(SDK 内部抛) | ❌ |
| 1591 | MULTIPART_CLIENT_HASH_MISMATCH | 客户端 hash 校验失败 | ❌ |
| 1600 | SERVICE_UNAVAILABLE | 服务降级/不可用(如 Redis 宕机、nonce 存储不可达) | ❌(请稍后重试) |
MultipartClient默认只对可重试错误(RATE_LIMITED / 网络错 / 5xx)做重试,上表全部 ❌ 错误会立即抛SecureError给业务。
🔄 与 SecureClient 主链路的关系
| 维度 | SecureClient.post() 主链路 | MultipartClient.upload() 分片链路 |
|------|------------------------------|-----------------------------------|
| 单文件上限 | ~200MB(bodyLimit) | 5GB |
| 协议 | TOP / custom / binary(整 envelope) | init/status 用 TOP,part 用 binary |
| 进度 | ❌(一次性请求) | ✅ 5 phase 实时进度 |
| 断点续传 | ❌ | ✅ |
| 秒传 | ❌ | ✅ 整文件 SHA-256 |
| 取消 | ❌ | ✅ AbortController |
| 重试 | ❌(业务层做) | ✅ 指数退避,智能判断 |
| 鉴权 | session | session(私有)/ 匿名(公共) |
推荐选型:
- 任意大小 + 任意数量文件(主推) →
SmartClient(1 行,服务端智能分流)- 单文件 + 想要完全控制 partSize →
MultipartClient- 完全自定义传输层 → 5 个低阶
client.multipart*- ~~
SecureClient.post('/upload/private')~~ → v1.4.x 方案 A 已删除,请用MultipartClient(单文件 ≤ 5GB)或SmartClient- ~~
client.requestBinary()/client.post('/upload/batch')~~ → v1.4.x 方案 A 已删除,请用SmartClient
🔌 Binary 协议(底层,v1.0.4 引入)
背景:
binary协议是 SDK 内部使用的第三个协议,把加密后的原始字节直接放在x-secure-raw-base64header,body 留给纯业务数据(JSON),避免 BASE64 编码膨胀 33%。⚠️ v1.4.x 方案 A 状态:
SecureClient.requestBinary()业务方法已删除(它内部硬编码拼/upload/{private,public},服务端这 2 个端点已下线)。binary协议本身仍保留,仅供 SDK 内部MultipartEndpoints的multipart/part端点使用。
🎯 适用场景
| 场景 | 推荐 | 原因 |
|------|------|------|
| 任意大小 + 任意数量文件上传(主推) | SmartClient | 1 行调用,服务端智能分流 ONE_SHOT/MULTIPART |
| 单文件 + 想要完全控制 partSize | MultipartClient | 5-50MB partSize 可调,秒传/续传/进度/取消 |
| 分片上传的 part 端点(SDK 内部) | binary 协议(走 client.multipartPart()) | 5GB 大文件,BASE64 编码不可接受 |
| ~~单文件 1MB-200MB~~ | ~~requestBinary~~ | ⚠️ v1.4.x 已删除,改用 MultipartClient(哪怕 1KB) |
| 简单 JSON 业务 | client.post()(TOP) | TOP 协议已经够用 |
📐 协议格式(SDK 内部)
请求示例(以 multipart/part 端点为例,SDK 内部自动拼装):
POST /api/v1/upload/multipart/part
Content-Type: application/json
X-Secure-Raw-Base64: <iv|cipherText|authTag, base64 编码>
{
"app_key": "demo-app",
"method": "/api/v1/upload/multipart/part",
"http_method": "POST",
"biz_content": "{}",
"sign": "...",
"iv": "...",
"authTag": "...",
"timestamp": 1715824800000,
"nonce": "uuid"
}服务端处理:onRequest hook 检测到 X-Secure-Raw-Base64 header 时:
- 用 appKey 派生 AES key
- 解密 header 里的密文 → 原始二进制
Uint8Array - 把解密后的
Uint8Array挂到(req as any).secureRaw biz_content解密后挂到(req as any).secureRequest.biz- 业务 handler 直接用
secureRaw即可
// 后端 multipart/part 端点(SDK 内部已封装,业务侧无需手写)
app.post('/multipart/part', async (req, reply) => {
const sr = (req as any).secureRequest;
const raw: Uint8Array = (req as any).secureRaw;
// raw 就是解密后的 part 字节
await savePart(raw, sr.biz.uploadId, sr.biz.partNumber);
return { uploadId: sr.biz.uploadId, partNumber: sr.biz.partNumber, ok: true };
});⚠️ 注意事项(SDK 内部维护者用)
- body 业务字段必须为空(
biz_content: '{}'),所有业务信息(meta/uploadId/partNumber)通过 query 或 header 传 - 浏览器上传
File对象前必须new Uint8Array(await file.arrayBuffer()) - 不要在 body 放业务 JSON,否则会与 raw 冲突
- binary 协议只在
MultipartEndpoints.sendBinary内部使用,业务代码不要自己构造 envelope - v1.4.x 之前
SecureClient.requestBinary()业务方法已删除,新代码不要调用
⚠️ v1.4.x 已删除的接口(迁移必看)
背景:
/api/v1/upload/private/public/batch三个老端点 +SecureClient.requestBinary()业务方法在 v1.4.x 方案 A 清理中已彻底删除(不是 Deprecation),请求会直接 404。 替代品:SmartClient.upload()一站式搞定任意大小 + 任意数量文件上传。 服务端同时删除:backend/apps/upload-service/src/routes/upload.ts(487 行,3 个端点)已删除,SmartClient内部走/smart/init+/multipart/*。
❌ 删除清单(不要再用)
| 已删除 | 替代 | 说明 |
|------|------|------|
| SecureClient.requestBinary({ method, data, filename, contentType, ... }) | new MultipartClient(client).upload(fromBlob(file), { isPublic, session }) | 单文件场景(1KB~5GB) |
| client.post('/api/v1/upload/private', { data, filename }) | 同上,加 session: jwt | 单文件私有上传 |
| client.post('/api/v1/upload/public', { data, filename }) | 同上,isPublic: true | 单文件公共上传 |
| client.post('/api/v1/upload/batch', { files: [...] }) | new SmartClient(client).upload(files.map(smartFromBlob), { isPublic, session }) | 批量场景,主替代 |
✅ 迁移示例
// ❌ 旧:requestBinary(v1.4.x 已删除)
const r = await client.requestBinary({
method: 'file.upload',
data: fileBuf,
filename: 'photo.png',
contentType: 'image/png',
session: jwt,
});
// ✅ 新:MultipartClient(单文件)
const r = await new MultipartClient(client).upload(fromBlob(file), {
isPublic: false,
session: jwt,
onProgress: (p) => console.log(`[${p.phase}] ${p.percent}%`),
});
// ❌ 旧:post('/upload/batch')(v1.4.x 已删除)
const r = await client.post('/api/v1/upload/batch', {
files: [{ filename: 'a.png', contentType: 'image/png', data: base64str }, ...],
isPublic: false,
});
// ✅ 新:SmartClient(批量,任意大小)
const r = await new SmartClient(client).upload(
files.map((f) => smartFromBlob(f)),
{ isPublic: false, session: jwt, onProgress: (p) => { /* ... */ } }
);🧠 智能上传(Smart Upload)v1.4.0 新增
统一入口:
POST /api/v1/upload/smart/init(TOP 协议),后续 part/complete/abort/status 复用 multipart/* 服务端智能分流:1KB 走 ONE_SHOT,5GB 走 MULTIPART(动态 partSize) 一站式封装:new SmartClient(client).upload(files, { isPublic, session })—— N 个文件 + 任意大小,1 行调用
🎯 适用场景
| 场景 | 推荐 API | 原因 |
|------|---------|------|
| 任意数量 + 任意大小文件混合上传(主推) | SmartClient.upload() | 1 个 init 搞定批量,服务端按文件大小选策略,SDK 自动处理 ONE_SHOT/MULTIPART/秒传 |
| 单文件 ≤ 1MB(纯 ONE_SHOT) | SmartClient 或 MultipartClient | Smart 默认走 ONE_SHOT,2 RTT 完成 |
| 单文件 > 200MB 但只有 1 个 | MultipartClient | 直接分片,不需要批量的 init 配额 |
| 想要完全自定义传输层 | client.multipartInit/Part/Complete | 5 个低阶方法裸用 |
✅ 推荐:所有新业务直接用
SmartClient(1 行,任意大小/任意数量,服务端智能分流)。MultipartClient适合"单文件 + 想完全控制 partSize"的场景。requestBinary()业务方法与/upload/{private,public,batch}三个老端点已在 v1.4.x 方案 A 中彻底删除,直接请求会 404。
🧠 服务端智能分流策略
SmartClient 把"上传什么文件"完全交给 服务端决定:
| 文件大小 | 策略 | partSize | totalParts | 服务端 RTT | 适用 | |---------|------|----------|-----------|-----------|------| | 0 字节 | ONE_SHOT | 0 | 1 | 2 (part+complete) | 空文件 | | ≤ 1MB | ONE_SHOT | = 文件大小 | 1 | 2 | 小文件,直传最快 | | 1MB ~ 1GB | MULTIPART | 1MB | 1 ~ 1024 | N+1 | 中等文件 | | 1GB ~ 10GB | MULTIPART | 5MB | 200 ~ 2048 | N+1 | 大文件(动态放大减少 part 数) | | > 10GB | MULTIPART | 10MB | > 1024 | N+1 | 超大文件 |
为什么动态放大 partSize?
- 5GB 用 1MB/part → 5120 part,DB/Redis/客户端并发调度压力大
- 5GB 用 5MB/part → 1024 part,开销可接受,网络利用率更高
🚀 快速开始(浏览器 + <input type="file" multiple>)
<input type="file" id="picker" multiple />
<progress id="bar" max="100" value="0"></progress>
<pre id="out"></pre>
<script type="module">
import { SecureClient, SmartClient, smartFromBlob } from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/client.mjs';
const client = new SecureClient({
baseURL: 'https://api.example.com',
appKey: 'demo-app',
appSecret: 'demo-app-secret-1234567890abcdef',
encryptionKey: 'a'.repeat(64),
protocol: 'top',
cipher: 'aes-256-gcm',
digest: 'hmac-sha256',
});
const smart = new SmartClient(client);
document.getElementById('picker').addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
const bar = document.getElementById('bar');
const out = document.getElementById('out');
try {
const results = await smart.upload(
files.map((f) => smartFromBlob(f)),
{
isPublic: false, // 私有批量
session: localStorage.getItem('jwt'), // ★ 必传,否则服务端 401 BINARY_AUTH_REQUIRED
onProgress: (p) => {
const pct = (p.totalBytesUploaded / p.totalBytes) * 100;
bar.value = pct;
out.textContent =
`[${p.filePhase}] file ${p.fileIndex + 1}/${p.totalFiles} ` +
`(${p.fileBytesUploaded}/${p.fileTotalBytes}B) ` +
`aggregate ${pct.toFixed(1)}%`;
},
}
);
out.textContent = `✅ ${results.filter((r) => r.ok).length}/${results.length} 成功\n` +
results.map((r) => r.ok
? ` ${r.dedup ? '⚡' : '📤'} ${r.filename} → ${r.url}${r.dedup ? ' (秒传)' : ''}`
: ` ❌ ${r.filename}: ${r.message}`).join('\n');
} catch (e) {
out.textContent = `❌ ${e.message}`;
}
});
</script>📋 完整 API
import { SmartClient, smartFromBlob, smartFromBuffer } from 'secure-crypto-top-zod-sdk/client';
// 方式 1:默认 50(向后兼容,v1.0.7 及之前的所有调用方都不用改)
const smart = new SmartClient(secureClient);
// 方式 2:v1.0.8+ 构造时调高默认(整个 SmartClient 实例都生效)
const smart = new SmartClient(secureClient, { maxFilesPerBatch: 200 });
// 方式 3:v1.0.8+ per-call 覆盖(更灵活,不影响其他调用)
const smart = new SmartClient(secureClient);
const results = await smart.upload(largeBatch, { maxFilesPerBatch: 100 });
// 一站式上传:N 个 SmartInput + 选项 → N 个 SmartUploadResult
const results = await smart.upload(inputs, {
// 基础
isPublic: false, // 默认 false(私有)
session: jwt, // ★ isPublic=false 必填;批量内混用 isPublic 时由 file.isPublic 决定
meta: { source: 'profile-photos' }, // 业务元信息,透传到 File.meta
// 重试
maxRetries: 3, // 单文件失败重试次数(默认 3)
retryBaseMs: 500, // 指数退避基数(默认 500ms)
// 批量上限(v1.0.8 新增):覆盖构造时的配置,默认 50
// · 实际通过量取 client / server 两边较小值
// · 设为 1000+ 需确认 server 端 secure-upload-fastify-sdk 的 smartMaxFiles 也调高
maxFilesPerBatch: 200,
// 取消
signal: ac.signal, // AbortSignal,整批粒度取消
// 进度(聚合:perFile + perPart + aggregate)
onProgress: (p) => { /* p 见下方 */ },
// 优化:客户端预计算的整文件 hash(避免 SDK 自己扫)
fileHashes: {
'photo1.jpg': 'a1b2c3...64位hex',
},
});
// 结果是数组:每个文件一项
results.forEach((r) => {
if (r.ok) {
console.log(`${r.filename} → fileId=${r.fileId}, url=${r.url}, dedup=${r.dedup}, strategy=${r.strategy}`);
} else {
console.error(`${r.filename} 失败: code=${r.code}, message=${r.message}`);
}
});🆕 v1.0.8+ 批量上限 maxFilesPerBatch 配置(完整说明)
| 维度 | 规则 |
|------|------|
| SDK 默认值 | 50(SmartClient.DEFAULT_MAX_FILES_PER_BATCH) |
| 三层覆盖优先级(per-call > 构造器 > SDK 默认) | 1) smart.upload(files, { maxFilesPerBatch: 200 })2) new SmartClient(client, { maxFilesPerBatch: 200 })3) SmartClient.DEFAULT_MAX_FILES_PER_BATCH = 50 |
| server 端限制 | 由 secure-upload-fastify-sdk 的 smartMaxFiles 配置控制,取 client / server 两边较小值 |
| 推荐值 | 浏览器相册批量 100~500 · Node 后台批量 200~1000 · ≥ 1000 需谨慎(init 配额压力大) |
| 不传 / 设为 0 / 负数 / 小数 | 抛 INVALID_REQUEST(防御非法配置) |
| 不传 vs 传 0 | 不传走默认 50;传 0 / 负数 / 小数 → 立即抛错,不会"静默用 50" |
| 典型场景 | 74 个文件想一把传(原 50 限制)→ new SmartClient(client, { maxFilesPerBatch: 200 })某些调用临时 100 个,其他保持 50 → smart.upload(files, { maxFilesPerBatch: 100 })服务端后台导入 500 个文件 → new SmartClient(client, { maxFilesPerBatch: 600 }) + server smartMaxFiles: 600 |
📐 SmartInput 适配器
// 浏览器:File / Blob
const input = smartFromBlob(file, 'photo.png'); // filename 可选,默认用 file.name
// Node:Buffer / Uint8Array
const buf = await fs.readFile('./big.mp4');
const input = smartFromBuffer(buf, 'big.mp4', 'video/mp4');
// 自定义:实现 read(offset, length) → Uint8Array
const myInput: SmartInput = {
filename: 'data.bin',
contentType: 'application/octet-stream',
size: 1024 * 1024,
async read(offset, length) {
return new Uint8Array(length); // 你的 IO
},
};📊 进度回调 SmartProgress
interface SmartProgress {
fileIndex: number; // 当前正在处理的文件 index(0-based)
fileBytesUploaded: number; // 当前文件已上传字节
fileTotalBytes: number; // 当前文件总大小
filePartsDone: number; // 当前文件 part 进度
filePartsTotal: number;
filePhase: 'hashing' | 'init' | 'uploading' | 'merging' | 'done' | 'failed';
totalBytesUploaded: number; // 整批聚合
totalBytes: number;
totalFiles: number;
filesDone: number;
}📤 SmartUploadResult 单文件结果
interface SmartUploadResult {
index: number;
ok: boolean;
filename: string;
size: number;
// 成功字段
dedup?: boolean; // 是否秒传命中
fileId?: string;
url?: string; // /api/v1/upload/files/{fileId}
contentType?: string;
finalHash?: string;
strategy?: 'ONE_SHOT' | 'MULTIPART';
totalParts?: number;
partSize?: number;
uploadId?: string;
// 失败字段
error?: string;
code?: number;
message?: string;
}🛰️ 服务端对应端点
| HTTP 端点 | 协议 | SDK 方法 | 用途 |
|-----------|------|---------|------|
| POST /api/v1/upload/smart/init | TOP | client.smartInit() | 唯一新增。批量 init,服务端智能分流 |
| POST /api/v1/upload/multipart/part | binary | client.multipartPart() | 上传分片(复用,所有 part 都走这里) |
| POST /api/v1/upload/multipart/complete | TOP | client.multipartComplete() | 合并(复用) |
| POST /api/v1/upload/multipart/abort | TOP | client.multipartAbort() | 终止清理(复用) |
| POST /api/v1/upload/multipart/status | TOP | client.multipartStatus() | 续传查询(复用) |
设计原则:
smart/init只做"批量 + 智能策略",part/complete/abort/status 4 个端点完全复用 multipart/*(协议、限流、状态机、合并逻辑全相同)。SDK 内部SmartClient也是:init 调client.smartInit,后续 part/complete/abort/status 调client.multipart*。
⚠️ 鉴权 & 限流
| 维度 | 规则 |
|------|------|
| 私有批量 | isPublic: false 时必须传 session: jwt,服务端从 x-secure-session header 拿 userId(挂到 file.userId) |
| 公共批量 | isPublic: true 可不传 session,userId=null,任意人可读 |
| 混合批量(部分公开 + 部分私有) | 仍传 session,服务端按每个文件自己的 isPublic 决定是否校验 session |
| 批量上限 | 1 次 init 默认最多 50 个文件(v1.0.8+ 可通过 SmartClientConfig.maxFilesPerBatch 或 upload({ maxFilesPerBatch }) 调高;server 端 smartMaxFiles 独立配置,取较小值) |
| 限流 | 60s 滑动窗口,默认 30 次配额,按 files.length 消耗(批量 30 个 = 30 次配额);配额与 /multipart/init 共享 key 前缀,避免双入口绕过 |
| JWT 校验 | 服务端 base64-decode payload,exp 校验,sub 作为 userId |
| 文件大小 | size > UPLOAD_MULTIPART_FILE_MAX_SIZE(默认 5GB)→ 单文件标记 MULTIPART_TOTAL_TOO_LARGE 失败,不阻塞其他文件 |
⚠️ 错误码
| code | 名称 | 含义 | 可重试 |
|------|------|------|--------|
| 1503 | BINARY_AUTH_REQUIRED | 私有上传未传 JWT(session 缺失) | ❌ |
| 1530 | MULTIPART_TOTAL_TOO_LARGE | 文件 > 5GB | ❌ |
| 1429 | RATE_LIMITED | 60s 配额用尽(批量场景:第 N+1 个文件起) | ✅(等 retryAfterMs) |
| 1400 | NOT_FOUND | 私有秒传命中但 userId 不匹配 | ❌ |
| — | INVALID_REQUEST | files 为空 / 超过 maxFilesPerBatch(v1.0.8+ 默认 50,可配) / maxFilesPerBatch 非正整数 / size 非法 | ❌ |
单文件失败(
ok: false)不阻塞其他文件,整体 HTTP 仍 200;业务代码只需遍历results数组按r.ok分支。
🛠️ 低阶:client.smartInit()(自定义传输层时用)
// 只想用 smart 的"批量 init"能力,后续 part/complete 自己写?用这个
const initResp = await client.smartInit({
files: [
{ filename: 'a.png', size: 1024, isPublic: true, expectedHash: 'abc...' },
{ filename: 'b.zip', size: 5 * 1024**3, isPublic: false, session: jwt }, // session 在 file 级别也支持
],
session: jwt, // ★ 顶层 session(私有批量必填,走 x-secure-session header)
});
for (const r of initResp.files) {
if (!r.ok) console.error('init 失败', r.index, r.message);
else if (r.dedup) console.log('秒传', r.fileId);
else console.log(`分片 ${r.totalParts} 个,partSize=${r.partSize},strategy=${r.strategy}`);
}🔄 与 MultipartClient 的关系
| 维度 | MultipartClient.upload() | SmartClient.upload() |
|------|----------------------------|------------------------|
| 输入 | 1 个 MultipartInput | N 个 SmartInput(批量) |
| init 端点 | /multipart/init | /smart/init(批量 + 智能策略) |
| 后续 part/complete | 复用 multipart/* | 复用 multipart/* |
| 适用大小 | 由客户端决定 partSize(5-50MB) | 由服务端决定(1MB/5MB/10MB 动态) |
| 一次性 1MB 文件 | 1 part(浪费并发) | ONE_SHOT(2 RTT 完成) |
| 5GB 文件 | 1MB/part → 5120 part(压力大) | 5MB/part → 1024 part(更优) |
| 批量支持 | ❌(需循环) | ✅(1 个 init 完成 N 个文件) |
| 鉴权 | 顶层 session | 顶层 session(混合时按 file.isPublic) |
| 限流 key | rl:mp:init:{userId} | rl:mp:init:{userId}(共用配额) |
| 推荐场景 | 单文件 + 完全控制 partSize | 任意数量 + 任意大小(主推) |
💡 选型速记:
- 批量 / 任意大小 →
SmartClient- 单文件 + 想要完全控制 partSize →
MultipartClient- 完全自定义传输层 → 5 个低阶
client.multipart*
🛰️ SSR / Edge 场景(Nuxt 3 · Next.js · Cloudflare Workers)
SSR 框架的优势在于 appSecret / encryptionKey 永远不会进前端 bundle —— 浏览器只调 BFF,BFF 持有 secret。下面是 3 个最常见平台的最小可用示例。
🟢 Nuxt 3(server/api/*.ts + Nitro)
适合:Nuxt 3 + Vue 3 SSR 项目。BFF 用 Nitro 的 defineEventHandler 手动跑加密/解密(不走 Fastify 插件)。
1. 安装
pnpm add secure-crypto-top-zod-sdk2. server/utils/secure.ts —— 服务端工具函数
// server/utils/secure.ts
import { getProtocol, pickProtocol, SecureError, ErrorCode } from 'secure-crypto-top-zod-sdk';
import { setBackend } from 'secure-crypto-top-zod-sdk/runtime';
import { NodeCryptoBackend, NODE_CAPABILITIES } from 'secure-crypto-top-zod-sdk/runtime';
// 显式注入 Node backend(SSR 容器里 isNode 探测可能不准)
setBackend(new NodeCryptoBackend(), NODE_CAPABILITIES);
// 多 app 配置(实际项目应该从环境变量 / Secret Manager 读取)
export const APPS = {
'demo-app': {
appSecret: process.env.APP_SECRET!,
encryptionKey: Buffer.from(process.env.APP_ENCRYPTION_KEY_HEX!, 'hex'), // 32 字节
},
} as const;
export function getApp(appKey: string) {
const app = APPS[appKey as keyof typeof APPS];
if (!app) throw new SecureError(ErrorCode.APP_NOT_FOUND, `[BFF] 未注册 app: ${appKey}`);
return app;
}
/** 解密请求 envelope */
export async function decryptRequest(rawBody: any) {
const protocol = pickProtocol(rawBody);
const ctx = getProtocol(protocol.kind);
// ★ 真正负责验签+解密的逻辑(下面 Next.js 章节有对应 Node 写法)
return ctx.parseAndVerify(rawBody, {
appKey: rawBody.app_key ?? rawBody.appKey,
appSecret: getApp(rawBody.app_key ?? rawBody.appKey).appSecret,
encryptionKey: getApp(rawBody.app_key ?? rawBody.appKey).encryptionKey,
});
}
/** 加密响应 envelope */
export async function encryptResponse(
appKey: string,
plainBiz: unknown,
method: string,
path: string,
) {
const app = getApp(appKey);
const protocol = pickProtocol({ protocol: 'top' });
return protocol.buildResponse(
{ appKey, method, path, biz: plainBiz, body: plainBiz },
{ appSecret: app.appSecret, encryptionKey: app.encryptionKey, hmacSecret: app.appSecret }
);
}3. server/api/secure/[...path].ts —— 通用加密代理
// server/api/secure/[...path].ts
import { defineEventHandler, readBody, getRequestURL, setResponseHeader } from 'h3';
import { decryptRequest, encryptResponse } from '~/server/utils/secure';
import { SecureError } from 'secure-crypto-top-zod-sdk';
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
const body = await readBody(event);
const appKey = (body?.app_key ?? body?.appKey ?? 'demo-app') as string;
try {
const sr = await decryptRequest(body);
// ★ 在这里把解密后的业务数据交给真实业务
// 例如:调内部 service / 查 DB / 转发到后端
const biz = sr.biz as Record<string, unknown>;
const data = await handleBusiness(sr.method, sr.path, biz);
const envelope = await encryptResponse(appKey, { success: true, data }, sr.method, sr.path);
setResponseHeader(event, 'Content-Type', 'application/json');
return envelope;
} catch (e) {
if (e instanceof SecureError) {
// ★ 业务错误:返回明文,前端 SecureClient 会识别
return { success: false, code: e.code, message: e.message };
}
throw e;
}
});
async function handleBusiness(method: string, path: string, biz: any) {
// TODO: 接你自己的业务
if (path === '/api/login') {
return { user: { id: 1, name: biz.username }, token: 'mock-token' };
}
return { echo: biz };
}4. 客户端(composables/useApi.ts)
// composables/useApi.ts
import { secureClient } from '@/lib/secure-client'; // 上一节 Vue 3 那个单例
export const useApi = () => secureClient;流量:
浏览器 → /api/secure/login(POST) → Nitro 解密 → 业务 → 加密响应 → 浏览器解密整个过程appSecret一直在服务端容器里,前端 bundle 看不到。
🔵 Next.js(App Router + Route Handler)
适合:Next.js 14+ App Router / Edge Runtime。两种 Runtime 都给示例。
A. Node Runtime(完整能力,推荐)
// app/api/secure/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { pickProtocol, getProtocol, SecureError } from 'secure-crypto-top-zod-sdk';
export const runtime = 'nodejs'; // 显式 Node,SM4/SM3 才能用
// export const runtime = 'edge'; // 改 edge 时记得用 WebCryptoBackend
const APPS: Record<string, { appSecret: string; encryptionKey: Buffer }> = {
'demo-app': {
appSecret: process.env.APP_SECRET!,
encryptionKey: Buffer.from(process.env.APP_ENCRYPTION_KEY_HEX!, 'hex'),
},
};
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path } = await ctx.params;
const body = await req.json();
const appKey = body?.app_key ?? body?.appKey ?? 'demo-app';
const app = APPS[appKey];
if (!app) return NextResponse.json({ success: false, code: 1004, message: 'app not found' });
try {
const protocol = pickProtocol(body);
const protoImpl = getProtocol(protocol.kind);
const sr = await protoImpl.parseAndVerify(body, {
appKey,
appSecret: app.appSecret,
encryptionKey: app.encryptionKey,
});
// 业务处理
const data = await handleBusiness(sr.method, '/' + path.join('/'), sr.biz);
const envelope = await protoImpl.buildResponse(
{ appKey, method: sr.method, path: sr.path, biz: { success: true, data }, body: data },
{ appSecret: app.appSecret, encryptionKey: app.encryptionKey, hmacSecret: app.appSecret }
);
return NextResponse.json(envelope);
} catch (e) {
if (e instanceof SecureError) {
return NextResponse.json({ success: false, code: e.code, message: e.message });
}
return NextResponse.json({ success: false, code: 5000, message: String(e) }, { status: 500 });
}
}
async function handleBusiness(method: string, path: string, biz: any) {
if (path === '/api/login') return { user: { id: 1, name: biz.username }, token: 'mock' };
return { echo: biz };
}B. Edge Runtime(用 Web Crypto,体积更小冷启动更快)
// app/api/secure-edge/[...path]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { pickProtocol, getProtocol, SecureError } from 'secure-crypto-top-zod-sdk';
import { setBackend } from 'secure-crypto-top-zod-sdk/runtime';
import { WebCryptoBackend, WEB_CAPABILITIES } from 'secure-crypto-top-zod-sdk/runtime';
export const runtime = 'edge';
setBackend(new WebCryptoBackend(), WEB_CAPABILITIES); // ★ Edge 必须显式注入
// encryptionKey 在 Edge 里是 Uint8Array 而不是 Buffer
const APPS: Record<string, { appSecret: string; encryptionKey: Uint8Array }> = {
'demo-app': {
appSecret: process.env.APP_SECRET!,
encryptionKey: new Uint8Array(Buffer.from(process.env.APP_ENCRYPTION_KEY_HEX!, 'hex')),
},
};
// 其余逻辑和 Node 版一样,只是 encryptionKey 类型从 Buffer 换成 Uint8Array
// ...⚠️ Edge Runtime 没有
node:crypto,所以 SM4 / SM3 / MD5 / RSA-OAEP 不可用。AES-256-GCM / HMAC-SHA256 走 Web Crypto OK。
C. 中间件做统一解密(可选)
如果想所有 /api/* 都自动解密,可以在 middleware.ts 里拦截:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { pickProtocol, getProtocol } from 'secure-crypto-top-zod-sdk';
export async function middleware(req: NextRequest) {
if (!req.nextUrl.pathname.startsWith('/api/')) return NextResponse.next();
// 中间件里只能读 body 一次,后面 route.ts 还要用
// 实际工程建议:把 envelope 解析放在 route.ts,中间件只做粗粒度过滤(签名头、时间戳窗口)
// 这里只演示思路:
const ct = req.headers.get('content-type') ?? '';
if (ct.includes('application/json')) {
const cloned = req.clone();
const body = await cloned.json().catch(() => null);
if (body?.app_key || body?.encryptedPayload) {
// 标记一下,route.ts 看到此 header 就不再走 Fastify 插件
const headers = new Headers(req.headers);
headers.set('x-secure-envelope', '1');
return NextResponse.next({ request: { headers } });
}
}
return NextResponse.next();
}
export const config = { matcher: ['/api/:path*'] };🟠 Cloudflare Workers / Vercel Edge Functions
Workers 没有 Fastify,直接用 fetch handler 写。BFF 模式最纯粹的形态。
// src/index.ts (Cloudflare Workers)
import { pickProtocol, getProtocol, SecureError } from 'secure-crypto-top-zod-sdk';
import { setBackend } from 'secure-crypto-top-zod-sdk/runtime';
import { WebCryptoBackend, WEB_CAPABILITIES } from 'secure-crypto-top-zod-sdk/runtime';
setBackend(new WebCryptoBackend(), WEB_CAPABILITIES); // ★ Workers 必加
export interface Env {
APP_SECRET: string;
APP_ENCRYPTION_KEY_HEX: string; // 64 字符 hex
}
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
if (req.method !== 'POST') {
return new Response('method not allowed', { status: 405 });
}
const body = await req.json().catch(() => null);
if (!body) return new Response('invalid json', { status: 400 });
const appKey = body.app_key ?? body.appKey ?? 'demo-app';
const encryptionKey = new Uint8Array(
Buffer.from(env.APP_ENCRYPTION_KEY_HEX, 'hex')
);
try {
const protocol = pickProtocol(body);
const protoImpl = getProtocol(protocol.kind);
const sr = await protoImpl.parseAndVerify(body, {
appKey,
appSecret: env.APP_SECRET,
encryptionKey,
});
// 业务:转发到内部 origin / KV / D1
const upstreamUrl = new URL(req.url);
const upstream = await fetch(
new URL(sr.path, 'https://your-internal-service.example.com'),
{
method: sr.method as any,
headers: { 'content-type': 'application/json', 'x-forwarded-from-bff': '1' },
body: sr.biz ? JSON.stringify(sr.biz) : undefined,
}
);
const data = await upstream.json();
const envelope = await protoImpl.buildResponse(
{ appKey, method: sr.method, path: sr.path, biz: { success: true, data }, body: data },
{ appSecret: env.APP_SECRET, encryptionKey, hmacSecret: env.APP_SECRET }
);
return new Response(JSON.stringify(envelope), {
headers: { 'content-type': 'application/json' },
});
} catch (e) {
const code = e instanceof SecureError ? e.code : 5000;
const message = e instanceof SecureError ? e.message : String(e);
return new Response(JSON.stringify({ success: false, code, message }), {
status: 200, // 业务错误用 200 + 业务 success:false
headers: { 'content-type': 'application/json' },
});
}
},
};# wrangler.toml
name = "my-secure-bff"
main = "src/index.ts"
compatibility_date = "2024-09-01"
[vars]
APP_SECRET = "demo-app-secret-1234567890abcdef"
APP_ENCRYPTION_KEY_HEX = "aaaa...64个a"Worker 端用
env.APP_SECRET而不是import.meta.env,这是 Cloudflare 的标准 secrets 管理。
📊 三种 SSR 平台对比
| 维度 | Nuxt 3 (Nitro) | Next.js (Node) | Next.js (Edge) | Cloudflare Workers |
|---|---|---|---|---|
| 冷启动 | 中 | 慢 | 快 | 最快 |
| 可用算法 | 全部 | 全部 | AES-GCM/HMAC only | AES-GCM/HMAC only |
| appSecret 暴露面 | 服务端 | 服务端 | 服务端 | 服务端(env) |
| 与 Fastify 插件复用 | ❌ 自实现 | ❌ 自实现 | ❌ 自实现 | ❌ 自实现 |
| 推荐场景 | Vue SSR 全栈 | Next 全栈 | 边缘 SSR | 全球边缘 BFF |
⚠️ SSR 平台不直接用
secureCryptoPlugin插件(它是 Fastify 专用),而是手写 ~30 行的parseAndVerify+buildResponse,逻辑和插件完全等价。
🌐 跨运行时能力
浏览器(零 polyfill)
SecureClient 在浏览器完全无需 polyfill:
| 特性 | 实现 |
|------|------|
| AES-256-GCM | crypto.subtle.encrypt/decrypt |
| HMAC-SHA256/512 | crypto.subtle.sign/verify |
| SHA-256 摘要 | crypto.subtle.digest |
| 随机数 | crypto.getRandomValues |
| UUID v4 | crypto.randomUUID |
| 时序安全比较 | 自实现(常数时间) |
<!-- 浏览器使用,直接 ESM import,不需要打包 -->
<script type="importmap">
{
"imports": {
"secure-crypto-top-zod-sdk/client": "https://cdn.jsdelivr.net/npm/[email protected]/dist/client.mjs"
}
}
</script>
<script type="module">
import { SecureClient } from 'secure-crypto-top-zod-sdk/client';
const client = new SecureClient({
baseURL: 'https://api.example.com',
appKey: 'demo-app',
encryptionKey: 'a'.repeat(64), // 64 字符 hex(32 字节)
appSecret: 'demo-app-secret-1234567890abcdef', // 签名密钥(>= 32 字符)
protocol: 'top',
cipher: 'aes-256-gcm',
digest: 'hmac-sha256',
});
const r = await client.post('/api/v1/secure-gateway', { foo: 'bar' });
console.log(r);
</script>Node.js(完整能力)
支持所有算法,包括 SM4 / SM3 / MD5 / RSA-OAEP / AES-CBC。
import { getCipher, getDigest, isNode, isBrowser, listSupportedAlgorithms } from 'secure-crypto-top-zod-sdk';
// 显式探测运行时
console.log(isNode); // true
console.log(isBrowser); // false
// 算法注册表自动按能力过滤
const ciphers = listSupportedAlgorithms();
console.log(ciphers);
// { ciphers: ['aes-256-gcm', 'aes-256-cbc', 'aes-128-cbc', 'sm4-cbc', 'rsa-oaep-aes-256-gcm'], ... }跨运行时抽象
import { getBackend, setBackend, CryptoBackend } from 'secure-crypto-top-zod-sdk/runtime';
// 默认自动检测
const b = getBackend();
const uuid = b.randomUUID();
const bytes = b.randomBytes(16);
// 自定义 backend(测试场景)
setBackend(myCustomBackend, myCapabilities);📐 架构
┌─────────────────────────────────────────────────────┐
│ 应用层 (User code) │
│ Vue 3 / React 18 / Vanilla JS / Node.js │
└─────────────────────────────────────────────────────┘
↓
┌──────────────┬──────────────┬─────────────────────┐
│ SecureClient │ Fastify │ Standalone (Node) │
│ (Browser) │ Plugin │ (build/parse/verify)│
└──────────────┴──────────────┴─────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Protocol Strategies │
│ customProtocol | topProtocol | binaryProtocol │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ Algorithms (Cipher | Digest) │
│ AES-GCM AES-CBC SM4 RSA-OAEP │ HMAC SM3 MD5 │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ CryptoBackend (跨运行时抽象) │
│ NodeCryptoBackend | WebCryptoBackend │
│ (node:crypto) (crypto.subtle) │
└─────────────────────────────────────────────────────┘🔐 安全特性
启动校验(吸收老 SDK)
new KeyStore().set('app1', { encryptionKey: Buffer.alloc(8), hmacSecret: 'short' });
// 立即抛错:app app1 的 encryptionKey 至少 16 字节(当前 8)
new SecureClient({ encryptionKey: 'short', ... });
// 立即抛错:[SecureClient] encryptionKey 必须是 64 字符 hex (32 字节)zod 字段校验(吸收老 SDK)
import { CustomRequestSchema, TopRequestSchema } from 'secure-crypto-top-zod-sdk';
const r = CustomRequestSchema.safeParse(rawBody);
if (!r.success) {
// 详细错误:encryptedPayload: encryptedPayload 不能为空;iv: iv 不能为空; ...
}响应验签(吸收老 SDK)
const client = new SecureClient({
// ...
verifyResponse: true, // 启用响应签名校验
responseAppSecret: '...', // 可选,默认复用 appSecret(兼容旧 responseHmacSecret)
});防重放 / 时序攻击
- Nonce 默认 30s 窗口(可配
REQUEST_WINDOW_MS,生产建议 ≤ 60s) + Redis NX SET 拦截 timingSafeEqual常数时间比较(防时序攻击)- fail closed:Redis 不可用 → 直接抛
SERVICE_UNAVAILABLE(1600)+ 503 拒服务,不再静默放行
🛡️ 信封走私防护(v1.0.4 新增)
攻击场景:攻击者抓到合法 envelope,把
method/http_method改成其他接口,试图越权访问或绕过权限。SDK 用 4 道闸防这种攻击:
| 层 | 机制 | 配置项 |
|----|------|--------|
| 协议层 | envelope.method + envelope.http_method 进签名源 | 改了就 SIGNATURE_MISMATCH |
| gateway 层 | 强校验 envelope.method === req.url.path(剥 query) | ENFORCE_ENVELOPE_METHOD_MATCH / ENFORCE_ENVELOPE_PATH_MATCH |
| IP 层 | Redis 滑动窗口计数,60s 失败超阈值 → 自动封禁 5min | IP_BAN_THRESHOLD / IP_BAN_TTL_SEC |
| fail closed | Redis 不可用 → 直接 503 拒服务,攻击窗口被切断 | SERVICE_UNAVAILABLE(1600) |
// 危险场景举例(被 SDK 完全阻断):
// 1. 攻击者抓到 POST /api/user/login 的合法 envelope
// 2. 改成 GET /api/user/list 转发,试图越权读用户列表
// 3. 结果:
// - envelope.method 改了 → 签名 mismatch,直接拒
// - envelope.http_method 改了 → 签名 mismatch,直接拒
// - 1 分钟内连续失败 → IP 封禁 5min
// - Redis 挂了想绕过? → 整个服务 fail closed,所有请求 503📦 体积对比(v1.0.8,含分片 + 批量 + binary + 信封走私防护 + 智能上传 + 可配批量上限)
| 场景 | bundle 大小 | 依赖 |
|------|-------------|------|
| 浏览器 ESM (index.browser.mjs) | ~247 KB | 0 外部依赖(无 crypto-browserify) |
| 客户端 (client.mjs) | ~232 KB | 0 |
| 服务端 (server.mjs) | ~191 KB | fastify-plugin(可选) |
| runtime only (runtime.mjs) | ~17 KB | 0(只含 backend 抽象 + Web Crypto) |
对比之前 secure-crypto-top-sdk 的浏览器 bundle:
- 之前:1.4MB (esbuild + 6 个 polyfill + 整个 crypto-browserify)
- 现在:~226KB(自包含,无 polyfill,含分片 + 智能上传完整代码)
- 节省 ~85%
版本体积变化:
- v0.2.2 → v1.0.3 增长约 47KB(166KB → 213KB,主要是
MultipartClient+MultipartEndpoints~38KB)- v1.0.3 → v1.0.5 再增约 13KB(213KB → 226KB,主要是
SmartClient+SmartEndpoints+ 智能上传错误码)- v1.4.x 方案 A 减少约 4KB(226KB → ~222KB,删
requestBinary方法 +BinaryRequestOptionsinterface + 相关 import)- v1.0.7 增 ~2KB(流式 hash 实现:用
backend.createHasher替代一次性 buffer 累加,SDK 端 O(1) 内存)- v1.0.8 增 ~6KB(
SmartClientConfig+maxFilesPerBatch校验逻辑 + 类型导出,222KB → 232KB)仍在"自包含零 polyfill" 范畴内。如果不需要分片 / 智能上传,可按
import { SecureClient } from '...'只引入客户端入口。
🛠️ 开发
# 安装依赖
pnpm install
# 编译(快速,跳过 dts)
pnpm build
# 完整编译(含 dts,首次发布前)
pnpm build:full
# 类型检查
pnpm typecheck
# 跑测试
pnpm test
# 启动 demo 服务
pnpm demo:server📝 融合点详细对照
吸收自 secure-crypto-sdk(老)的优点
- Web Crypto API 原生 - 浏览器走
crypto.subtle,零 polyfill - zod 字段校验 - 入参/出参 schema 验证,字段级错误信息
- 启动密钥校验 -
new SecureClient({...})立即抛错,不等运行时 - 响应验签 -
verifyResponse: true选项 - Web Crypto AES key 缓存 -
aesKeyPromise模式(在web.ts后端)
保留自 secure-crypto-top-sdk(新)的优点
- 双协议 - custom + top,客户端
protocol选项切换 - 多算法 - SM4/SM3/AES-CBC/RSA-OAEP 国密混合
- 多 app 隔离 -
apps[]注册,KeyStore派生 - Fastify 集成 - 零配置 hook
- 服务端 standalone 工具 - 不依赖 Fastify 也能用
新增(secure-crypto-top-zod-sdk 独有)
CryptoBackend抽象层 - 算法层零import 'crypto',跨运行时- 能力探测 -
getCapabilities()/listSupportedAlgorithms()诊断 - 算法过滤 - 浏览器自动跳过 Node-only 算法(SM4/SM3/MD5/RSA-OAEP)
- async API - 所有
encrypt/decrypt/sign/verify全 async,统一行为 - 类型化枚举 -
CipherAlgorithm/DigestAlgorithm联合类型 - Vue 3 / React 18 一线集成示例 - 见上文"框架集成"章节
📜 Changelog
1.0.8(SmartClient 单次批量上限改为可配置)
本版主推:SDK 客户端 50 文件硬限制改为可配置(
SmartClientConfig.maxFilesPerBatch+ per-callupload({ maxFilesPerBatch })),治本解决"74 个文件被 1301 拒绝"问题 破坏性变更:无,完全向后兼容(默认仍是 50)
✨ 新增功能
- ✅
SmartClientConfig构造参数 ——new SmartClient(client, { maxFilesPerBatch: 200 }),对整个 SmartClient 实例生效// 方式 1:构造时配置(整个实例都生效) const smart = new SmartClient(client, { maxFilesPerBatch: 200 }); // 方式 2:per-call 覆盖(更灵活,不影响其他调用) const smart = new SmartClient(client); await smart.upload(files, { maxFilesPerBatch: 100 }); // 不传 / 旧代码:完全兼容,默认 50 const smart = new SmartClient(client); - ✅
SmartUploadOptions.maxFilesPerBatch——smart.upload(files, { maxFilesPerBatch: 100 }),per-call 覆盖构造器配置 - ✅
SmartInitParams.maxFilesPerBatch——client.smartInit({ files, maxFilesPerBatch: 200 }),低阶 API 也支持 - ✅ 三层优先级:
per-call opts>SmartClientConfig>SmartClient.DEFAULT_MAX_FILES_PER_BATCH = 50 - ✅
SmartClient.DEFAULT_MAX_FILES_PER_BATCH暴露为 static readonly 常量,业务可引用 - ✅ 非法值防御:
maxFilesPerBatch非正整数 / 小数 / 0 / 负数 → 抛INVALID_REQUEST,不会"静默回退 50" - ✅ 底层透传:
SmartClient.upload()把 effectiveMax 自动传给client.smartInit(),SDK 内部两层校验(client.upload + client.smartInit)用同一阈值
🆕 为什么需要这个?
- 之前:74 个文件上传 → 客户端直接
1301 INVALID_REQUEST: [SmartClient] 单次最多 50 个文件 - 现在:
new SmartClient(client, { maxFilesPerBatch: 200 })→ 200 个一把传,SDK 不再硬挡 - 实际通过量仍受 server 端
secure-upload-fastify-sdk的smartMaxFiles控制(取较小值,server 端要同步调高)
📚 文档
- ✅ README "智能上传" 章节加 3 种构造/调用示例 + 完整
maxFilesPerBatch配置说明表(覆盖优先级 / 推荐值 / server 端协同) - ✅ 限流表"批量上限"行从硬编码 50 改为"v1.0.8+ 可配,默认 50"
- ✅ 错误码表
INVALID_REQUEST行更新说明 - ✅ 体积表更新(222KB → 232KB,SmartClientConfig + 校验逻辑 + 类型导出)
- ✅ CDN 链接升到
@1.0.8
🔧 内部
- ✅
SmartClient加private readonly defaultMaxFilesPerBatch字段 - ✅
SmartClient.upload()入口先解effectiveMax,非法值立即抛错 - ✅
SmartEndpoints.smartInit同步支持maxFilesPerBatch透传 - ✅
client/index.ts+ 主入口src/index.ts都 exportSmartClientConfig类型 - ✅ d.ts 验证:
SmartClientConfig/SmartUploadOptions.maxFilesPerBatch/SmartInitParams.maxFilesPerBatch全部正确暴露
💡 升级建议:
- 老项目用
new SmartClient(client)没传配置 → 零改动直接升级,完全向后兼容(默认仍是 50)- 之前因为 50 限制被迫分批(>50 个文件拆 2 次调) → 用
maxFilesPerBatch: 200一次传完,效率显著提升- 用了
client.smartInit()直接调低阶 API → 也可以传maxFilesPerBatch,SDK 内部校验更精准
1.0.7(SmartClient / MultipartClient 流式 hash,治本修 5×30MB OOM)
本版主推:
SmartClient/MultipartClient整文件 hash 改流式实现,SDK 端 O(1) 内存,治本修"5 个 30MB 文件并发上传进程消失(端口 8083 ECONNREFUSED)" 破坏性变更:无,完全向后兼容
🐛 治本修复(根因)
- ✅
SmartClient.computeFileHash改流式 —— 用backend.createHasher真增量实现- 之前:逐 4MB 块读入 +
sha256.update,但旧实现内部把所有 chunk push 到parts[]数组,再new Uint8Array(全部) + digest.update一次算 → 3× size 内存峰值 × 并发数 → 5 个 30MB 并发 = 9×30MB 峰值 → Node 24 触发 OOM / native crash - 现在:
backend.createHasher→ 4MB 块读完立即hasher.update(buf),buffer 立刻可被 GC 回收 → 真正的 O(1) 内存 - 验证:5×30MB 文件上传 → RSS 仅 110.4MB(原 OOM 9×30MB 峰值)
- 之前:逐 4MB 块读入 +
- ✅
MultipartClient同步修复 ——computeFileHash同样改流式,单文件大文件上传不再 OOM - ✅ Node 端:
backend.createHasher走crypto.createHash(原生 C 实现,O(1) 内部 state) - ✅ Web 端:
backend.createHasher走@noble/hashes/sha256真增量(浏览器 Web Crypto 不支持流式 digest)
📚 文档
- ✅ 体积表更新(222KB → ~224KB,流式 hash 实现)
- ✅ v1.0.8 进一步扩到 232KB(
SmartClientConfig+ 校验逻辑)
💡 升级建议:
- 所有用到
SmartClient.upload/MultipartClient.upload的项目 → 强烈建议升级,彻底解决"5 文件 OOM"问题- 之前为了绕开 OOM 改的"手动分批"代码 → 现在可以放心一次传 ≥ 50 个文件(
maxFilesPerBatch: 200)
本版主推:代码净清理,统一上传入口(
SmartClient) 破坏性变更:有。SecureClient.requestBinary()与POST /upload/{private,public,batch}三个老端点已彻底删除(不是 Deprecation)。业务侧必须迁到SmartClient或MultipartClient。 配套后端:backendapps/upload-service/src/routes/upload.ts已删除(489 行),/upload/smart/init+/multipart/*是唯一上传入口。
❌ 删除清单
- ✅
SecureClient.requestBinary()方法 +BinaryRequestOptionsinterface(~120 行,含 binary 协议内部 envelope 拼装) - ✅ 服务端
/api/v1/upload/private/upload/public/upload/batch三个端点(489 行,backend/apps/upload-service/src/routes/upload.ts整个删除) - ✅
app.ts的onSendDeprecation hook(老端点已删,警告无意义)
✅ 保留(SDK 内部依赖)
- ✅
binary协议本身(MultipartEndpoints.sendBinary走/multipart/part端点,SmartClient 内部复用) - ✅
MultipartClient+MultipartEndpoints+SmartClient+SmartEndpoints全部保留 - ✅
pickProtocol/getProtocol/parseKeyHex/parseSecret等工具函数
🔧 内部
- ✅
SecureClient删getProtocolimport(原 requestBinary 唯一使用点) - ✅ 顶部 doc 注释加 v1.4.x 方案 A 历史说明
- ✅
frontend-demo/upload.html大改:从 2042 行精简到 ~660 行,只留 SmartClient 入口
📚 文档
- ✅ README 顶部加
v1.4.x 方案 A 清理警告 - ✅ 新增
## ⚠️ v1.4.x 已删除的接口(迁移必看)章节 - ✅
## 📦 二进制上传章节改名为## 🔌 Binary 协议(底层),标记requestBinary已删除 - ✅ 智能上传章节"适用场景"表删除
client.requestBinary()引用 - ✅ 体积表更新(226KB → ~222KB)
💡 升级步骤:
- 搜代码里所有
client.requestBinary、client.post('/upload/private')、client.post('/upload/public')、client.post('/upload/batch')的引用- 单文件 → 改
new MultipartClient(client).upload(fromBlob(file), { isPublic, session })- 多文件批量 → 改
new SmartClient(client).upload(files.map(smartFromBlob), { isPublic, session })- 重新
pnpm buildSDK,pnpm install --force secure-crypto-top-zod-sdk,重启 gateway
1.0.5(智能上传 SmartClient + 私有批量 session 透传修复)
本版主推:一站式
SmartClient(服务端智能分流,任意大小/任意数量文件) 破坏性变更:无,纯新增 + 1 个 bug 修复,完全向后兼容
✨ 新增功能
- ✅
SmartClient高阶封装 ——new SmartClient(client).upload(files, { isPublic, session }),N 个文件 + 任意大小 1 行调用- 整文件 SHA-256 秒传(命中直接返回 fileId)
- 服务端按 totalSize 智能分流:≤1MB ONE_SHOT(2 RTT) / 1MB~1GB 1MB·part / 1GB~10GB 5MB·part / >10GB 10MB·part
- 单文件内 part 并发(
suggestedConcurrency由 server 返回) - 失败重试(指数退避,默认 3 次,500ms 起步)
- 进度回调(
onProgress({ fileIndex, fileBytesUploaded, totalBytesUploaded, filePhase, ... }),perFile + perPart + aggregate 三层聚合) - 中途取消(
AbortSignal,整批粒度) - 跨环境(浏览器 / Node / RN / Web Worker 一套代码,零运行时依赖)
- 混合批量(部分
isPublic=true部分isPublic=false)按 file.isPublic 各自鉴权
- ✅
SecureClient.smartInit()低阶方法 —— 通过 module augmentation 挂载,对应/api/v1/upload/smart/init- 显式传
protocol: 'top',避免defaultProtocol='binary'时把 JSON 端点当 binary 发 SmartInitParams.session走x-secure-sessionheader(私有批量透传 JWT)- 完整类型:
SmartInitParams/SmartInitResponse/SmartInitFileItem/SmartInitFileResult
- 显式传
- ✅ 适配器:
smartFromBlob(file)/smartFromBuffer(buf, name)—— 与fromBlob/fromBuffer对称
🐛 修复
- ✅ 私有批量 session 透传 ——
SmartClient.upload()把opts.session透传给client.smartInit(),服务端才能从x-secure-sessionheader 拿到 userId,否则 5GB 私有文件批量时全部1503 BINARY_AUTH_REQUIRED - ✅ SmartEndpoints.sendTop 显式传 protocol ——
client.request(..., { protocol: 'top' }),避免 client 配置defaultProtocol='binary'时把/smart/init错误地当 binary 发
📚 文档
- ✅ 新增 "🧠 智能上传 (Smart Upload)" 章节(API + 服务端策略表 + 鉴权 + 错误码 + 与 MultipartClient 对比)
- ✅ 体积表更新(213KB → 226KB,含 SmartClient)
- ✅ CDN 链接更新到
@1.0.5
🔧 内部
- ✅
SmartClient内部:init 用client.smartInit,part/complete/abort/status 复用client.multipart*(避免代码重复) - ✅
ConcurrencyPool+retryWithBackoff抽成模块级工具(与 MultipartClient 共享设计) - ✅ 进度回调统一为
SmartProgress(perFile + perPart + aggregate 三层聚合)
💡 **升级建议
