## 构建、打包与分发系统重构 (build/) - 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本 - electron-builder 配置从 package.json 提取为 build/electron-builder.js - 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数 - OSS 路径按渠道隔离 - 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验 - 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成 - updater.ts 发送 channel/edition/deviceFingerprint - 新增 src/shared/utils/rollout.ts 灰度测试工具 - 新增 10 条 NPM 脚本 ## VCHSM 五层架构迁移 - 7 个业务模块 + ~500 处导入路径同步 ## 修复 - MediaCardContextMenu 改用共享 ContextMenu 组件 - ComboBox 尺寸参数显示统一 (size-format.ts) ## 文档 - UpdateA.md / build/README.md / README.md / .env.example 更新 Co-Authored-By: Claude <noreply@anthropic.com>
231 lines
7.2 KiB
TypeScript
231 lines
7.2 KiB
TypeScript
// ============================================================
|
||
// NetRequest — Electron net.request 的 axios 风格封装
|
||
//
|
||
// 用法:
|
||
// import { request, get, post } from './net-request';
|
||
// const { data } = await get<UpdateCheckResult>('/api/v1/update/check', {
|
||
// baseURL: 'https://www.heixiu.com',
|
||
// params: { platform: 'win32', version: '1.0.0' },
|
||
// });
|
||
//
|
||
// 设计:
|
||
// - 底层使用 Electron net.request(不引入额外依赖)
|
||
// - Promise 封装,支持 async/await
|
||
// - JSON 自动解析、查询参数自动拼接
|
||
// - 流式下载场景仍直接用 net.request(本模块不封装 stream)
|
||
// ============================================================
|
||
|
||
import { net } from 'electron';
|
||
import { logger } from './logger';
|
||
|
||
// ---------- 类型 ----------
|
||
|
||
export interface RequestConfig {
|
||
/** 请求方法(默认 GET) */
|
||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||
/** 基础 URL(可选,方便切换环境) */
|
||
baseURL?: string;
|
||
/** 查询参数(自动拼接 ?key=value&...) */
|
||
params?: Record<string, string | number | undefined>;
|
||
/** 请求头 */
|
||
headers?: Record<string, string>;
|
||
/** 请求体(对象自动 JSON.stringify,字符串原样发送) */
|
||
data?: unknown;
|
||
/** 响应类型(默认 json,设置 text 跳过解析) */
|
||
responseType?: 'json' | 'text';
|
||
/** 超时时间(毫秒,默认 30s) */
|
||
timeout?: number;
|
||
}
|
||
|
||
export interface RequestResponse<T = unknown> {
|
||
/** HTTP 状态码 */
|
||
status: number;
|
||
/** 状态文本(如 "OK") */
|
||
statusText: string;
|
||
/** 响应头 */
|
||
headers: Record<string, string | string[]>;
|
||
/** 响应体 */
|
||
data: T;
|
||
}
|
||
|
||
/** 网络请求错误 */
|
||
export class RequestError extends Error {
|
||
status?: number;
|
||
constructor(message: string, status?: number) {
|
||
super(message);
|
||
this.name = 'NetRequestError';
|
||
this.status = status;
|
||
}
|
||
}
|
||
|
||
// ---------- 工具 ----------
|
||
|
||
const DEFAULT_TIMEOUT = 30_000;
|
||
|
||
/** 拼接 URL(baseURL + path + query) */
|
||
function buildUrl(url: string, config: RequestConfig): string {
|
||
// 如果没有 baseURL 且 url 不是绝对地址,直接报错(便于定位问题)
|
||
if (!config.baseURL && !/^https?:\/\//i.test(url)) {
|
||
throw new RequestError(
|
||
`URL 缺少 baseURL,无法构造绝对地址。url="${url}", config=${JSON.stringify({ ...config, params: config.params ? '[object]' : undefined })}`,
|
||
);
|
||
}
|
||
|
||
const base = (config.baseURL || '').trim().replace(/\/+$/, '');
|
||
const path = url.startsWith('/') ? url : `/${url}`;
|
||
let fullUrl = base ? `${base}${path}` : url;
|
||
|
||
if (config.params) {
|
||
const search = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(config.params)) {
|
||
if (value !== undefined && value !== null) {
|
||
search.append(key, String(value));
|
||
}
|
||
}
|
||
const qs = search.toString();
|
||
if (qs) {
|
||
fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs;
|
||
}
|
||
}
|
||
|
||
// 调试:在 net.request 调用之前验证 URL 合法性
|
||
try {
|
||
void new URL(fullUrl);
|
||
} catch {
|
||
throw new RequestError(
|
||
`构造的 URL 无法通过 Node.js 校验: "${fullUrl}"`,
|
||
);
|
||
}
|
||
|
||
console.log('[net-request] buildUrl:', fullUrl);
|
||
return fullUrl;
|
||
}
|
||
|
||
/** 序列化请求体 */
|
||
function serializeBody(data: unknown): { body: string; contentType: string } {
|
||
if (typeof data === 'string') {
|
||
return { body: data, contentType: 'text/plain' };
|
||
}
|
||
return { body: JSON.stringify(data), contentType: 'application/json' };
|
||
}
|
||
|
||
// ---------- 核心 ----------
|
||
|
||
/**
|
||
* 发起请求(axios 风格 API)
|
||
*
|
||
* 流式下载场景请直接使用 Electron net.request(本模块不封装)
|
||
*/
|
||
export async function request<T = unknown>(
|
||
url: string,
|
||
config: RequestConfig = {},
|
||
): Promise<RequestResponse<T>> {
|
||
const fullUrl = buildUrl(url, config);
|
||
const method = (config.method || 'GET').toUpperCase();
|
||
const timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
// 调试:验证 URL 是否合法
|
||
try {
|
||
void new URL(fullUrl);
|
||
} catch (urlErr) {
|
||
logger.warn('request', `非法 URL: ${fullUrl}`, urlErr instanceof Error ? urlErr : undefined);
|
||
reject(new RequestError(`非法 URL: ${(urlErr as Error).message}`));
|
||
return;
|
||
}
|
||
|
||
const req = net.request({
|
||
method,
|
||
url: fullUrl,
|
||
});
|
||
|
||
// 超时处理
|
||
let timedOut = false;
|
||
const timer = setTimeout(() => {
|
||
timedOut = true;
|
||
req.abort();
|
||
reject(new RequestError(`请求超时 (${timeout}ms)`));
|
||
}, timeout);
|
||
|
||
// 请求头
|
||
if (config.headers) {
|
||
for (const [key, value] of Object.entries(config.headers)) {
|
||
req.setHeader(key, value);
|
||
}
|
||
}
|
||
|
||
// 请求体
|
||
if (config.data !== undefined) {
|
||
const { body, contentType } = serializeBody(config.data);
|
||
req.setHeader('Content-Type', contentType);
|
||
req.write(body);
|
||
}
|
||
|
||
req.on('response', (response) => {
|
||
clearTimeout(timer);
|
||
|
||
const status = response.statusCode;
|
||
const statusText = response.statusMessage ?? '';
|
||
|
||
// 读取响应头
|
||
const headers: Record<string, string | string[]> = {};
|
||
// Electron IncomingMessage.headers 是 Record<string, string[]>
|
||
const rawHeaders = response.headers;
|
||
if (rawHeaders) {
|
||
for (const [key, value] of Object.entries(rawHeaders)) {
|
||
headers[key] = value.length === 1 ? value[0] : value;
|
||
}
|
||
}
|
||
|
||
// 读取响应体
|
||
let body = '';
|
||
response.on('data', (chunk: Buffer) => (body += chunk.toString()));
|
||
response.on('end', () => {
|
||
try {
|
||
const asJson = config.responseType !== 'text';
|
||
const data = asJson ? JSON.parse(body) : body;
|
||
resolve({ status, statusText, headers, data: data as T });
|
||
} catch {
|
||
// JSON 解析失败 → 返回原始文本
|
||
resolve({ status, statusText, headers, data: body as T });
|
||
}
|
||
});
|
||
|
||
response.on('error', (err) => {
|
||
clearTimeout(timer);
|
||
reject(new RequestError(`响应读取失败: ${err.message}`));
|
||
});
|
||
});
|
||
|
||
req.on('error', (err) => {
|
||
clearTimeout(timer);
|
||
if (timedOut) return;
|
||
reject(new RequestError(`网络请求失败: ${err.message}`));
|
||
});
|
||
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
// ---------- 默认导出:类 axios 命名空间 ----------
|
||
|
||
export const netRequest = {
|
||
request,
|
||
|
||
get<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'GET' });
|
||
},
|
||
|
||
post<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'POST', data });
|
||
},
|
||
|
||
put<T>(url: string, data?: unknown, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'PUT', data });
|
||
},
|
||
|
||
del<T>(url: string, config?: Omit<RequestConfig, 'method' | 'data'>) {
|
||
return request<T>(url, { ...config, method: 'DELETE' });
|
||
},
|
||
};
|