security-camera-sdk
Version:
Universal SDK for interfacing with various security camera vendors including Hikvision, Dahua, Uniview and others
633 lines (556 loc) • 21.2 kB
JavaScript
/**
* 宇视平台 OpenAPI 客户端
*/
const axios = require("axios");
const https = require("https");
const { UniviewAuth } = require("./auth");
const { ApiError, AuthError, NetworkError, ParameterError } = require("../../utils/errors/cameraErrors");
const { Logger } = require("../../utils/logger");
const { UniviewAPI, API_PATHS } = require("./api");
// 创建logger实例
const logger = new Logger();
class UniviewClient {
/**
* 初始化宇视平台客户端
* @param {Object} config 配置对象
* @param {string} config.host 宇视平台地址
* @param {number} config.port 端口号,默认80
* @param {string} config.protocol 协议,默认http
* @param {string} config.username 用户名
* @param {string} config.password 密码
* @param {string} config.defaultOrg 默认组织编码
* @param {boolean} config.debug 是否开启调试模式,默认false
* @param {number} config.timeout 请求超时时间(ms),默认10000
* @param {boolean} config.rejectUnauthorized 是否验证SSL证书,默认false
*/
constructor(config) {
// 验证必需参数
this.validateConfig(config);
// 配置属性
this.host = config.host.trim();
this.port = config.port || 80;
this.protocol = config.protocol || "http";
this.username = config.username;
this.password = config.password;
this.defaultOrg = config.defaultOrg || 'iccsid';
this.debug = config.debug || false;
this.timeout = config.timeout || 10000;
// 构建基础URL
this.baseURL = `${this.protocol}://${this.host}:${this.port}`;
// 认证相关
this.accessToken = null;
this.tokenExpiresAt = null;
this.keepAliveInterval = null;
// 初始化认证处理器
this.auth = new UniviewAuth(
this.username,
this.password,
this.debug
);
// 创建logger实例
this.logger = new Logger(this.debug);
// 初始化HTTP客户端
this.initHttpClient(config);
// 初始化API封装
this.api = new UniviewAPI(this);
// 自动代理API方法,使可以直接调用client.methodName()而不是client.api.methodName()
this.setupAPIMethodProxy();
if (this.debug) {
this.logger.info("宇视SDK初始化成功", {
baseURL: this.baseURL,
username: this.username,
defaultOrg: this.defaultOrg,
timeout: this.timeout,
});
}
}
/**
* 验证配置参数
* @param {Object} config 配置对象
*/
validateConfig(config) {
if (!config) {
throw new Error("配置对象不能为空");
}
if (!config.host) {
throw new Error("host参数不能为空");
}
if (!config.username) {
throw new Error("username参数不能为空");
}
if (!config.password) {
throw new Error("password参数不能为空");
}
}
/**
* 初始化HTTP客户端
* @param {Object} config 配置对象
*/
initHttpClient(config) {
const httpsAgent = new https.Agent({
rejectUnauthorized: config.rejectUnauthorized || false,
keepAlive: true,
timeout: this.timeout,
});
// 创建带拦截器的 axios 实例
this.httpClient = axios.create({
baseURL: this.baseURL,
timeout: this.timeout,
httpsAgent: httpsAgent,
headers: {
'Accept': 'application/json,text/plain, */*',
'Content-Type': 'application/json',
'User-Agent': 'Uniview-SDK/1.0.0'
},
});
// 创建无拦截器的 rawAxios(用于登录)
this.rawAxios = axios.create({
baseURL: this.baseURL,
timeout: this.timeout,
httpsAgent: httpsAgent,
headers: {
'Accept': 'application/json,text/plain, */*',
'User-Agent': 'Uniview-SDK/1.0.0'
},
});
// 请求拦截器:注入 Authorization
this.httpClient.interceptors.request.use(
async (requestConfig) => {
// 自动确保认证
await this.ensureAuthenticated();
if (this.accessToken) {
requestConfig.headers['Authorization'] = this.accessToken;
}
if (this.debug) {
console.log("请求配置", {
method: requestConfig.method?.toUpperCase(),
url: requestConfig.baseURL ? requestConfig.baseURL + requestConfig.url : requestConfig.url,
headers: requestConfig.headers,
params: requestConfig.params,
data: requestConfig.data,
});
}
return requestConfig;
},
(error) => {
return Promise.reject(new NetworkError("请求配置失败", error));
}
);
// 添加响应拦截器
this.httpClient.interceptors.response.use(
(response) => {
if (this.debug) {
this.logger.debug("响应数据", {
status: response.status,
headers: response.headers,
data: response.data,
});
}
return response;
},
async (error) => {
// 如果是认证错误,尝试重新登录
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
if (this.debug) {
this.logger.info("检测到认证错误,尝试重新登录...");
}
const loginResult = await this.login();
if (loginResult.success) {
// 重新发送请求
try {
const retryResponse = await this.httpClient.request(error.config);
return retryResponse;
} catch (retryError) {
return Promise.reject(this.handleResponseError(retryError));
}
}
}
return Promise.reject(this.handleResponseError(error));
}
);
// 为rawAxios添加响应拦截器
this.rawAxios.interceptors.response.use(
(response) => {
if (this.debug) {
this.logger.debug("Raw响应数据", {
status: response.status,
headers: response.headers,
data: response.data,
});
}
return response;
},
(error) => {
return Promise.reject(this.handleResponseError(error));
}
);
}
/**
* 处理响应错误
* @param {Error} error 错误对象
*/
handleResponseError(error) {
if (this.debug) {
this.logger.error("响应错误", error);
}
// axios 1.x 版本的错误对象结构有所不同
if (error.response) {
// 服务器返回了错误状态码
const { status, data } = error.response;
if (status === 401 || status === 403) {
return new AuthError("认证失败", data);
}
return new ApiError(`API错误 (${status})`, data, status);
} else if (error.request) {
// 请求已发出但没有收到响应
return new NetworkError("网络错误,请检查网络连接", error);
} else {
// 其他错误
return new NetworkError("未知错误", error);
}
}
/**
* 发送GET请求
* @param {string} path API路径
* @param {Object} params 查询参数
* @returns {Promise} 响应数据
*/
async get(path, params = {}) {
try {
// 使用axios的params选项来处理查询参数
const config = {};
if (Object.keys(params).length > 0) {
config.params = params;
}
const response = await this.httpClient.get(path, config);
return this.processResponse(response);
} catch (error) {
throw this.enhanceError(error, "GET", path);
}
}
/**
* 发送POST请求
* @param {string} path API路径
* @param {Object} data 请求体数据
* @param {Object} config 请求配置
* @returns {Promise} 响应数据
*/
async post(path, data = {}, config = {}) {
try {
const response = await this.httpClient.post(path, data, config);
return this.processResponse(response);
} catch (error) {
throw this.enhanceError(error, "POST", path);
}
}
/**
* 发送原始POST请求(用于登录等特殊场景)
* @param {string} path API路径
* @param {Object} data 请求体数据
* @param {Object} config 请求配置
* @returns {Promise} 响应数据
*/
async rawPost(path, data = {}, config = {}) {
try {
const response = await this.rawAxios.post(path, data, config);
return this.processResponse(response);
} catch (error) {
throw this.enhanceError(error, "POST", path);
}
}
/**
* 发送PUT请求
* @param {string} path API路径
* @param {Object} data 请求体数据
* @returns {Promise} 响应数据
*/
async put(path, data = {}) {
try {
const response = await this.httpClient.put(path, data);
return this.processResponse(response);
} catch (error) {
throw this.enhanceError(error, "PUT", path);
}
}
/**
* 发送DELETE请求
* @param {string} path API路径
* @returns {Promise} 响应数据
*/
async delete(path) {
try {
const response = await this.httpClient.delete(path);
return this.processResponse(response);
} catch (error) {
throw this.enhanceError(error, "DELETE", path);
}
}
/**
* 通用请求方法,允许用户直接发送请求
* @param {string} method HTTP方法 (GET, POST, PUT, DELETE)
* @param {string} path API路径
* @param {Object} options 请求选项
* @param {Object} options.params 查询参数
* @param {Object} options.data 请求体数据
* @returns {Promise} 响应数据
*/
async request(method, path, options = {}) {
try {
const config = {
method: method.toUpperCase(),
url: path,
params: options.params || {},
data: options.data || {}
};
const response = await this.httpClient.request(config);
return this.processResponse(response);
} catch (error) {
throw this.enhanceError(error, method, path);
}
}
/**
* 处理响应数据
* @param {Object} response axios响应对象
* @returns {Object} 处理后的响应数据
*/
processResponse(response) {
const { status, data } = response;
// 检查宇视API返回的业务状态
if (data && data.hasOwnProperty('ErrCode') && data.ErrCode !== 0) {
throw new ApiError(
`API业务错误: ${data.ErrMsg || "未知错误"}`,
data,
status
);
}
return {
success: true,
status: status,
data: data,
message: data.ErrMsg || "success",
};
}
/**
* 增强错误信息
* @param {Error} error 原始错误
* @param {string} method HTTP方法
* @param {string} path API路径
* @returns {Error} 增强后的错误
*/
enhanceError(error, method, path) {
if (error.isCameraError) {
return error;
}
error.method = method;
error.path = path;
error.timestamp = new Date().toISOString();
return error;
}
/**
* 判断是否已认证且 token 未过期
* @returns {boolean}
*/
isAuthenticated() {
return this.accessToken && this.tokenExpiresAt && Date.now() < this.tokenExpiresAt;
}
/**
* 登录
* @returns {Promise<{ success: boolean, data?: object, msg?: string }>}
*/
async login() {
if (this.debug) {
this.logger.info('开始登录流程...');
}
// 清除旧 token
this.accessToken = null;
this.tokenExpiresAt = null;
let attempts = 0;
const maxRetries = 3;
while (attempts <= maxRetries) {
try {
// 第一步:获取 AccessCode
const res = await this.rawPost(API_PATHS.LOGIN);
if (this.debug) {
this.logger.info('获取 AccessCode 响应:', res.data);
}
if (res.data?.AccessCode && !res.data.AccessToken) {
const accessCode = res.data.AccessCode;
if (this.debug) {
this.logger.info('获取到 AccessCode,准备提交登录信息');
}
// 构建登录数据
const loginData = this.auth.buildLoginData(accessCode);
if (this.debug) {
this.logger.info('构建的登录数据:', loginData);
}
// 第二步:提交登录
const loginRes = await this.rawPost(API_PATHS.LOGIN, JSON.stringify(loginData), {
headers: { 'Content-Type': 'text/plain' },
});
if (this.debug) {
this.logger.info('登录响应:', loginRes.data);
}
const data = loginRes.data;
// 兼容无 ErrCode 或 ErrCode === 0
if ((data.ErrCode == null || data.ErrCode === 0) && data.AccessToken) {
this.accessToken = data.AccessToken;
this.tokenExpiresAt = Date.now() + (48 * 3600 - 60) * 1000; // 47小时59分
if (this.debug) {
const expireTime = new Date(this.tokenExpiresAt);
this.logger.info(`登录成功!Token 有效期至: ${expireTime.toLocaleString()}`);
}
this.startKeepAlive();
return { success: true, data };
} else {
throw new Error(`登录失败 [${data.ErrCode}]: ${data.ErrMsg || '未知错误'}`);
}
}
else if (res.data?.AccessToken) {
// 直接返回 token(会话复用)
this.accessToken = res.data.AccessToken;
this.tokenExpiresAt = Date.now() + (48 * 3600 - 60) * 1000;
if (this.debug) {
const expireTime = new Date(this.tokenExpiresAt);
this.logger.info(`会话复用成功,Token 有效期至: ${expireTime.toLocaleString()}`);
}
this.startKeepAlive();
return { success: true, data: res.data };
}
else {
throw new Error(`登录失败:响应格式错误 ${JSON.stringify(res.data)}`);
}
} catch (err) {
attempts++;
if (attempts > maxRetries) {
if (this.debug) {
this.logger.error('登录失败(已达最大重试次数):', err.message);
}
return { success: false, msg: err.message };
}
if (this.debug) {
this.logger.warn(`登录尝试 ${attempts} 失败,${1000 * attempts}ms 后重试...`);
}
await new Promise(r => setTimeout(r, 1000 * attempts)); // 指数退避
}
}
return { success: false, msg: '登录失败:重试次数耗尽' };
}
/**
* 确保已认证
* @returns {Promise<{ success: boolean, msg?: string }>}
*/
async ensureAuthenticated() {
if (this.isAuthenticated()) {
return { success: true, msg: '已认证' };
}
if (this.debug) {
this.logger.info('token 无效或未登录,正在重新登录...');
}
const result = await this.login();
return result;
}
/**
* 确保Token有效
* @returns {Promise<string>} 访问令牌
*/
async ensureToken() {
const result = await this.ensureAuthenticated();
if (!result.success) {
throw new AuthError(`认证失败: ${result.msg}`);
}
return this.accessToken;
}
/**
* 保持Token活跃
* @returns {Promise<boolean>} 是否成功
*/
async keepTokenAlive() {
try {
if (!this.isAuthenticated()) {
if (this.debug) {
this.logger.warn('Token 已失效,尝试重新登录');
}
const result = await this.login();
return result.success;
}
const res = await this.get(API_PATHS.TOKEN_KEEP_ALIVE);
if (res.data.ErrCode === 0) {
if (this.debug) {
this.logger.info('Token 保活成功');
}
return true;
} else {
if (this.debug) {
this.logger.warn('保活失败:', res.data.ErrMsg);
}
const result = await this.login();
return result.success;
}
} catch (err) {
if (this.debug) {
this.logger.error('保活请求失败:', err.message);
}
const result = await this.login();
return result.success;
}
}
/**
* 启动自动保活
*/
startKeepAlive() {
if (this.keepAliveInterval) {
if (this.debug) {
this.logger.debug('保活定时器已存在');
}
return;
}
const intervalMs = 24 * 60 * 60 * 1000; // 24小时
this.keepAliveInterval = setInterval(async () => {
if (this.debug) {
this.logger.info('执行自动 token 保活...');
}
await this.keepTokenAlive();
}, intervalMs);
if (this.debug) {
this.logger.info('自动保活已启动,每 24 小时执行一次');
}
}
/**
* 停止自动保活
*/
stopKeepAlive() {
if (this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
this.keepAliveInterval = null;
if (this.debug) {
this.logger.info('自动保活已停止');
}
}
}
/**
* 关闭客户端
*/
async close() {
this.stopKeepAlive();
this.accessToken = null;
this.tokenExpiresAt = null;
if (this.debug) {
this.logger.info('宇视 SDK 已关闭');
}
}
/**
* 设置API方法代理,自动将API对象上的方法代理到客户端实例上
*/
setupAPIMethodProxy() {
// 遍历API对象的所有属性
Object.getOwnPropertyNames(Object.getPrototypeOf(this.api)).forEach(key => {
// 跳过构造函数、非函数属性和login方法(避免与客户端login方法冲突)
if (key === 'constructor' || typeof this.api[key] !== 'function' || key === 'login') {
return;
}
// 将API方法绑定到客户端实例上
this[key] = this.api[key].bind(this.api);
});
}
}
module.exports = { UniviewClient };