chore: directory migration - gateway→server, web→client/electron

This commit is contained in:
2026-09-08 19:16:35 +08:00
parent fd9f99a3f9
commit f9d757b5e5
243 changed files with 5095 additions and 228 deletions

View File

@ -0,0 +1,191 @@
/*
* AgentMail 鸿蒙客户端 — 多账号管理器
* 支持多账号存储、切换、删除
* 每个账号存储: server, username, token, displayName
*/
import { preferences } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN = 0x0001;
const TAG = 'AccountManager';
const PREF_NAME = 'agentmail_accounts';
const KEY_ACCOUNTS = 'accounts_json';
const KEY_ACTIVE = 'active_account_id';
/** 单个账号信息 */
export class AccountInfo {
id: string = '';
server: string = '';
username: string = '';
token: string = '';
displayName: string = '';
createdAt: number = 0;
}
export class AccountManager {
private static instance: AccountManager | null = null;
private context: Context;
private accounts: AccountInfo[] = [];
private activeId: string = '';
private loaded: boolean = false;
static getInstance(context: Context): AccountManager {
if (AccountManager.instance === null || AccountManager.instance.context !== context) {
AccountManager.instance = new AccountManager(context);
}
return AccountManager.instance;
}
private constructor(context: Context) {
this.context = context;
}
/** 从 preferences 加载账号列表 */
async load(): Promise<void> {
if (this.loaded) {
return;
}
try {
const pref = await preferences.getPreferences(this.context, PREF_NAME);
const accountsJson: string = pref.getSync(KEY_ACCOUNTS, '[]') as string;
this.accounts = JSON.parse(accountsJson) as AccountInfo[];
this.activeId = pref.getSync(KEY_ACTIVE, '') as string;
// 如果有账号但没有活跃账号,默认选第一个
if (this.accounts.length > 0 && this.activeId.length === 0) {
this.activeId = this.accounts[0].id;
}
hilog.info(DOMAIN, TAG, 'loaded %{public}d accounts, active: %{public}s', this.accounts.length, this.activeId);
} catch (e) {
this.accounts = [];
this.activeId = '';
}
this.loaded = true;
}
/** 持久化账号列表 */
private async persist(): Promise<void> {
try {
const pref = await preferences.getPreferences(this.context, PREF_NAME);
pref.putSync(KEY_ACCOUNTS, JSON.stringify(this.accounts));
pref.putSync(KEY_ACTIVE, this.activeId);
await pref.flush();
} catch (e) {
hilog.error(DOMAIN, TAG, 'persist failed');
}
}
/** 获取所有账号。返回副本,避免页面直接改动内部持久化数组。 */
getAccounts(): AccountInfo[] {
return this.accounts.slice();
}
/** 获取当前活跃账号 */
getActiveAccount(): AccountInfo | null {
for (let i = 0; i < this.accounts.length; i++) {
if (this.accounts[i].id === this.activeId) {
return this.accounts[i];
}
}
if (this.accounts.length > 0) {
return this.accounts[0];
}
return null;
}
/** 获取当前活跃账号 ID */
getActiveId(): string {
return this.activeId;
}
/** 按 ID 获取账号;路由携带来源账号时使用。 */
getAccount(accountId: string): AccountInfo | null {
for (let i = 0; i < this.accounts.length; i++) {
if (this.accounts[i].id === accountId) {
return this.accounts[i];
}
}
return null;
}
/** 添加新账号 */
async addAccount(server: string, username: string, token: string, displayName: string): Promise<AccountInfo> {
const account: AccountInfo = new AccountInfo();
account.id = this.generateId();
account.server = server;
account.username = username;
account.token = token;
account.displayName = displayName.length > 0 ? displayName : username;
account.createdAt = Date.now();
this.accounts.push(account);
// 如果是第一个账号,自动设为活跃
if (this.accounts.length === 1) {
this.activeId = account.id;
}
await this.persist();
hilog.info(DOMAIN, TAG, 'added account: %{public}s', account.username);
return account;
}
/** 删除账号 */
async removeAccount(accountId: string): Promise<boolean> {
const idx: number = this.findIndex(accountId);
if (idx < 0) {
return false;
}
this.accounts.splice(idx, 1);
// 如果删除的是活跃账号,切换到第一个
if (this.activeId === accountId) {
this.activeId = this.accounts.length > 0 ? this.accounts[0].id : '';
}
await this.persist();
hilog.info(DOMAIN, TAG, 'removed account: %{public}s', accountId);
return true;
}
/** 切换活跃账号 */
async switchAccount(accountId: string): Promise<boolean> {
const idx: number = this.findIndex(accountId);
if (idx < 0) {
return false;
}
this.activeId = accountId;
await this.persist();
hilog.info(DOMAIN, TAG, 'switched to: %{public}s', accountId);
return true;
}
/** 更新账号信息(如 token 过期重新登录) */
async updateAccount(accountId: string, token: string, displayName: string): Promise<boolean> {
const idx: number = this.findIndex(accountId);
if (idx < 0) {
return false;
}
this.accounts[idx].token = token;
if (displayName.length > 0) {
this.accounts[idx].displayName = displayName;
}
await this.persist();
return true;
}
/** 获取账号数量 */
getCount(): number {
return this.accounts.length;
}
private findIndex(accountId: string): number {
for (let i = 0; i < this.accounts.length; i++) {
if (this.accounts[i].id === accountId) {
return i;
}
}
return -1;
}
private generateId(): string {
const now: number = Date.now();
const rand: number = Math.floor(Math.random() * 10000);
return 'acct_' + now.toString() + '_' + rand.toString();
}
}

View File

@ -0,0 +1,291 @@
/*
* AgentMail 鸿蒙客户端 — 统一 API 客户端
* 对应 WebUI src/api/client.ts:base + headers + 错误归一化 + 401 统一回落登录
*/
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { DEFAULT_API_BASE, PREF_KEY_API_BASE, PREF_KEY_TOKEN } from '../common/Config';
import { preferences } from '@kit.ArkData';
const DOMAIN = 0x0001;
const TAG = 'AgentMailClient';
/** 统一错误类型:code = HTTP 状态码,message = 服务端中文文案 */
export class ApiError extends Error {
code: number = 0;
message: string = '';
constructor(code: number, message: string) {
super(message);
this.code = code;
this.message = message;
}
}
/** 空响应 2xx 的占位类型 */
export class EmptyResult {
ok: boolean = true;
}
/** 请求选项 */
export class RequestOptions {
method: string = 'GET';
path: string = '';
body: string = '';
query: string = '';
useKeyAuth: boolean = true;
}
export class ApiClient {
private static instance: ApiClient | null = null;
private apiBase: string = DEFAULT_API_BASE;
private token: string = '';
private context: Context;
/** 复用的 HTTP 请求实例:保持 Cookie 会话(登录后跨请求有效) */
private httpRequest: http.HttpRequest | null = null;
static getInstance(context: Context): ApiClient {
if (ApiClient.instance === null || ApiClient.instance.context !== context) {
if (ApiClient.instance !== null && ApiClient.instance.context !== context) {
// 不同 context 重建(测试环境),否则复用
}
ApiClient.instance = new ApiClient(context);
}
return ApiClient.instance;
}
constructor(context: Context) {
this.context = context;
}
/** 初始化:从 preferences 读 apiBase 与 token */
async init(): Promise<void> {
try {
const pref = await preferences.getPreferences(this.context, 'agentmail');
this.apiBase = pref.getSync(PREF_KEY_API_BASE, DEFAULT_API_BASE) as string;
this.token = pref.getSync(PREF_KEY_TOKEN, '') as string;
} catch (e) {
this.apiBase = DEFAULT_API_BASE;
this.token = '';
}
}
getBase(): string {
return this.apiBase;
}
setBase(base: string): void {
this.apiBase = base;
}
getToken(): string {
return this.token;
}
setToken(token: string): void {
this.token = token;
}
/** 持久化凭证 */
async persistToken(token: string): Promise<void> {
this.token = token;
try {
const pref = await preferences.getPreferences(this.context, 'agentmail');
pref.putSync(PREF_KEY_TOKEN, token);
await pref.flush();
} catch (e) {
// 持久化失败不阻断登录
}
}
async persistBase(base: string): Promise<void> {
this.apiBase = base;
try {
const pref = await preferences.getPreferences(this.context, 'agentmail');
pref.putSync(PREF_KEY_API_BASE, base);
await pref.flush();
} catch (e) {
// 忽略
}
}
/** 统一请求入口 */
async request<T>(opts: RequestOptions): Promise<T> {
const url: string = this.apiBase + opts.path + (opts.query.length > 0 ? '?' + opts.query : '');
// 复用会话级 http 实例,保住 Cookie(login 后 set-cookie 才能用于后续请求)
let httpRequest: http.HttpRequest;
if (this.httpRequest === null) {
httpRequest = http.createHttp();
this.httpRequest = httpRequest;
} else {
httpRequest = this.httpRequest;
}
try {
const header: Record<string, string> = {
'Content-Type': 'application/json'
};
if (this.token.length > 0 && opts.useKeyAuth) {
header['Authorization'] = 'Bearer ' + this.token;
}
hilog.info(DOMAIN, TAG, '→ %{public}s %{public}s', opts.method, url);
const response = await httpRequest.request(url, {
method: opts.method as http.RequestMethod,
header: header,
extraData: opts.body.length > 0 ? opts.body : undefined,
connectTimeout: 15000,
readTimeout: 30000
});
const code = response.responseCode;
const rawText = response.result as string;
if (code >= 200 && code < 300) {
if (rawText.length === 0) {
const empty = new EmptyResult();
return empty as T;
}
return JSON.parse(rawText) as T;
}
// 错误归一化:从服务端 {"error": "..."} 取文案
let message: string = 'HTTP ' + code;
try {
const parsed = JSON.parse(rawText) as Record<string, string>;
if (parsed['error'] !== undefined) {
message = parsed['error'];
}
} catch (e) {
message = rawText.length > 0 ? rawText : ('HTTP ' + code);
}
if (code === 401) {
// 清除本地凭证,交由登录页处理
this.clearAuth();
}
throw new ApiError(code, message);
} catch (e) {
if (e instanceof ApiError) {
throw e as ApiError;
}
const be = e as BusinessError;
const msg: string = be.message !== undefined ? be.message : '网络错误';
hilog.error(DOMAIN, TAG, '← %{public}s failed: %{public}s', url, msg);
throw new ApiError(0, msg);
}
// 不复用销毁:会话级实例保留 Cookie
}
/** GET 便捷 */
async get<T>(path: string, query?: string): Promise<T> {
const opts = new RequestOptions();
opts.method = 'GET';
opts.path = path;
opts.query = query ?? '';
return this.request<T>(opts);
}
/** POST 便捷(JSON body) */
async post<T>(path: string, bodyObj: Object, useKeyAuth?: boolean): Promise<T> {
const opts = new RequestOptions();
opts.method = 'POST';
opts.path = path;
opts.body = JSON.stringify(bodyObj);
opts.useKeyAuth = useKeyAuth ?? true;
return this.request<T>(opts);
}
/** PUT 便捷 */
async put<T>(path: string, bodyObj: Object): Promise<T> {
const opts = new RequestOptions();
opts.method = 'PUT';
opts.path = path;
opts.body = JSON.stringify(bodyObj);
return this.request<T>(opts);
}
/** DELETE 便捷 */
async del<T>(path: string): Promise<T> {
const opts = new RequestOptions();
opts.method = 'DELETE';
opts.path = path;
return this.request<T>(opts);
}
/** 上传文件(multipart/form-data)→ attachment_id */
async uploadFile(path: string, filePath: string, fileName: string): Promise<string> {
const url: string = this.apiBase + path;
const httpRequest = http.createHttp();
try {
const header: Record<string, string> = {};
const token: string = this.token;
if (token.length > 0) {
header['Authorization'] = 'Bearer ' + token;
}
const multiFormData: http.MultiFormData = {
name: 'file',
contentType: 'application/octet-stream',
remoteFileName: fileName,
filePath: filePath
};
const options: http.HttpRequestOptions = {
method: http.RequestMethod.POST,
header: header,
multiFormDataList: [multiFormData],
connectTimeout: 30000,
readTimeout: 60000
};
hilog.info(DOMAIN, TAG, '→ UPLOAD %{public}s', url);
const response = await httpRequest.request(url, options);
const code: number = response.responseCode;
const rawText: string = response.result as string;
if (code >= 200 && code < 300) {
// 服务端返回 {"attachment_id": "..."} 或直接返回 id 字符串
if (rawText.length === 0) {
return '';
}
try {
const parsed = JSON.parse(rawText) as Record<string, string>;
if (parsed['attachment_id'] !== undefined) {
return parsed['attachment_id'];
}
if (parsed['id'] !== undefined) {
return parsed['id'];
}
} catch (e) {
// 可能直接返回 id 字符串
}
return rawText;
}
throw new ApiError(code, rawText.length > 0 ? rawText : 'Upload failed');
} catch (e) {
if (e instanceof ApiError) {
throw e;
}
const be = e as BusinessError;
throw new ApiError(0, be.message !== undefined ? be.message : 'Upload error');
} finally {
httpRequest.destroy();
}
}
/** 清除本地认证态(401 时调用) */
clearAuth(): void {
this.token = '';
try {
const pref = preferences.getPreferencesSync(this.context, { name: 'agentmail' });
pref.putSync(PREF_KEY_TOKEN, '');
pref.flush();
} catch (e) {
// 忽略
}
}
}

View File

@ -0,0 +1,106 @@
/*
* AgentMail 鸿蒙客户端 — 认证 API
* POST /auth/login / logout / GET /auth/me / POST /me/keys
*/
import { ApiClient, ApiError } from './ApiClient';
import { Me, UserKey } from '../model/Models';
/** 登录请求体 */
export class LoginPayload {
username: string = '';
password: string = '';
}
/** 创建密钥请求体 */
export class CreateKeyPayload {
label: string = '';
key_type: string = 'permanent';
expires_hours: number = 0;
}
/** 登录响应(含用户) */
export class MeResponse {
user: Me = new Me();
}
/** 创建密钥响应 */
export class CreateKeyResponse {
key: UserKey = new UserKey();
}
/** 密钥列表响应 */
export class KeyListResponse {
keys: UserKey[] = [];
}
/** 空请求体(logout 等无 body 场景) */
export class EmptyPayload {
empty: boolean = true;
}
export class AuthApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/** 账号密码登录(Cookie 模式) */
async login(username: string, password: string): Promise<Me> {
const payload: LoginPayload = new LoginPayload();
payload.username = username;
payload.password = password;
// Cookie 由 http 模块自动管理;此处仍拿回 user
const resp = await this.client.post<MeResponse>('/auth/login', payload, false);
return resp.user;
}
/** 用户密钥登录(Bearer 模式):直接用 key 调 /auth/me */
async loginWithKey(key: string): Promise<Me> {
this.client.setToken(key);
try {
const resp = await this.client.get<MeResponse>('/auth/me');
await this.client.persistToken(key);
return resp.user;
} catch (e) {
this.client.clearAuth();
throw e as ApiError;
}
}
/** 当前用户 */
async me(): Promise<Me> {
const resp = await this.client.get<MeResponse>('/auth/me');
return resp.user;
}
/** 退出登录 */
async logout(): Promise<void> {
try {
const empty: EmptyPayload = new EmptyPayload();
await this.client.post<EmptyPayload>('/auth/logout', empty);
} catch (e) {
// 忽略退出失败,本地清 token
}
this.client.clearAuth();
}
/** 创建客户端密钥 */
async createKey(label: string): Promise<UserKey> {
const payload: CreateKeyPayload = new CreateKeyPayload();
payload.label = label;
const resp = await this.client.post<CreateKeyResponse>('/me/keys', payload);
return resp.key;
}
/** 我的密钥列表 */
async listKeys(): Promise<UserKey[]> {
const resp = await this.client.get<KeyListResponse>('/me/keys');
return resp.keys;
}
/** 吊销密钥 */
async revokeKey(keyId: string): Promise<void> {
await this.client.del<Object>('/me/keys/' + keyId);
}
}

View File

@ -0,0 +1,155 @@
/*
* AgentMail 鸿蒙客户端 — 邮件与会话 API
* GET /me/inbox, /me/sessions, /me/contacts, /me/mail/{id}, /sessions/{id}/thread
* POST /me/mail/send, /me/mail/{id}/forward
* PUT /sessions/{id}/permission, /sessions/{id}/alias, /sessions/{id}/budget
*/
import { ApiClient } from './ApiClient';
import { MailSummary, Session, Contact, MailDetail, ThreadResponse, AttachmentInfo, SendMailRequest, SendMailResult } from '../model/Models';
/** 收件箱响应 */
export class InboxResponse {
mails: MailSummary[] = [];
total: number = 0;
unread: number = 0;
}
/** 会话列表响应 */
export class SessionListResponse {
sessions: Session[] = [];
total: number = 0;
}
/** 联系人列表响应 */
export class ContactListResponse {
contacts: Contact[] = [];
total: number = 0;
}
/** 邮件详情响应 */
export class MailDetailResponse {
mail: MailDetail = new MailDetail();
}
/** 对话树响应 */
export class ThreadApiResponse {
thread: ThreadResponse = new ThreadResponse();
}
/** 发信响应 */
export class SendMailResponse {
result: SendMailResult = new SendMailResult();
}
/** 附件列表响应 */
export class AttachmentListResponse {
attachments: AttachmentInfo[] = [];
}
/** 地址补全响应 */
export class AddressSuggestionResponse {
suggestions: string[] = [];
}
/** 改权限请求体 */
export class PermissionModePayload {
permission_mode: string = 'workspace';
}
/** 改别名请求体 */
export class AliasPayload {
alias: string = '';
}
/** 改预算请求体 */
export class BudgetPayload {
max_rounds: number = 0;
}
export class MailApi {
private client: ApiClient;
constructor(client: ApiClient) {
this.client = client;
}
/** 收件箱(status + limit) */
async inbox(status: string, limit: number): Promise<InboxResponse> {
const query: string = 'status=' + status + '&limit=' + limit;
return this.client.get<InboxResponse>('/me/mail/inbox', query);
}
/** 会话列表 */
async sessions(): Promise<SessionListResponse> {
return this.client.get<SessionListResponse>('/me/sessions');
}
/** 联系人列表 */
async contacts(): Promise<ContactListResponse> {
return this.client.get<ContactListResponse>('/contacts');
}
/** 邮件详情(API 返回裸对象) */
async mailDetail(mailId: string): Promise<MailDetail> {
return this.client.get<MailDetail>('/mail/' + mailId);
}
/** 对话树 */
async thread(mailId: string, dir?: string, limit?: number): Promise<ThreadApiResponse> {
let query: string = '';
const parts: string[] = [];
if (dir !== undefined) {
parts.push('dir=' + dir);
}
if (limit !== undefined) {
parts.push('limit=' + limit);
}
if (parts.length > 0) {
query = parts.join('&');
}
return this.client.get<ThreadApiResponse>('/mail/' + mailId + '/thread', query);
}
/** 发信 */
async send(req: SendMailRequest): Promise<SendMailResponse> {
return this.client.post<SendMailResponse>('/me/mail/send', req);
}
/** 转发 */
async forward(mailId: string, req: SendMailRequest): Promise<SendMailResponse> {
return this.client.post<SendMailResponse>('/me/mail/' + mailId + '/forward', req);
}
/** 改权限档位 */
async setPermissionMode(sessionId: string, mode: string): Promise<void> {
const payload: PermissionModePayload = new PermissionModePayload();
payload.permission_mode = mode;
await this.client.put<Object>('/sessions/' + sessionId + '/permission', payload);
}
/** 改会话别名 */
async setAlias(sessionId: string, alias: string): Promise<void> {
const payload: AliasPayload = new AliasPayload();
payload.alias = alias;
await this.client.put<Object>('/sessions/' + sessionId + '/alias', payload);
}
/** 改预算 */
async setBudget(sessionId: string, maxRounds: number): Promise<void> {
const payload: BudgetPayload = new BudgetPayload();
payload.max_rounds = maxRounds;
await this.client.put<Object>('/sessions/' + sessionId + '/budget', payload);
}
/** 归档联系人 */
async archiveContact(name: string, path: string): Promise<void> {
await this.client.del<Object>('/me/contacts/' + name + '/' + path);
}
/** 上传附件(multipart/form-data,字段名 file)→ attachment_id */
async uploadAttachment(filePath: string, fileName: string): Promise<string> {
const resp = await this.client.uploadFile('/me/attachments', filePath, fileName);
return resp;
}
}

View File

@ -0,0 +1,308 @@
/*
* AgentMail 鸿蒙客户端 — SSE 多账号实时推送服务
* 每个账号各建一条 SSE 连接(各带自己的 user_key)
* AccountManager 维护连接集合,按 accountId 分发事件
*/
import { http } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { AccountManager, AccountInfo } from './AccountManager';
const DOMAIN = 0x0001;
const TAG = 'SseService';
/** SSE 事件数据 */
export class SseEvent {
type: string = '';
data: string = '';
accountId: string = '';
}
/** SSE 回调 */
export type SseListener = (event: SseEvent) => void;
/** SSE 连接状态 */
export type SseStatus = 'disconnected' | 'connecting' | 'connected';
/** SSE 状态回调 */
export type SseStatusListener = (status: SseStatus) => void;
/** 单个账号的 SSE 连接状态(纯数据) */
export class SseConnection {
accountId: string = '';
server: string = '';
token: string = '';
httpRequest: http.HttpRequest | null = null;
listeners: SseListener[] = [];
status: SseStatus = 'disconnected';
reconnectTimer: number = 0;
buffer: string = '';
connected: boolean = false;
}
export class SseService {
private static instance: SseService | null = null;
private connections: Map<string, SseConnection> = new Map();
private globalListeners: SseListener[] = [];
private globalStatusListeners: SseStatusListener[] = [];
static getInstance(): SseService {
if (SseService.instance === null) {
SseService.instance = new SseService();
}
return SseService.instance;
}
private constructor() {}
/** 添加全局事件监听(所有账号的事件都会收到) */
addListener(listener: SseListener): void {
this.globalListeners.push(listener);
}
/** 移除全局事件监听 */
removeListener(listener: SseListener): void {
const idx: number = this.globalListeners.indexOf(listener);
if (idx >= 0) {
this.globalListeners.splice(idx, 1);
}
}
/** 添加全局状态监听 */
addStatusListener(listener: SseStatusListener): void {
this.globalStatusListeners.push(listener);
}
/** 移除全局状态监听 */
removeStatusListener(listener: SseStatusListener): void {
const idx: number = this.globalStatusListeners.indexOf(listener);
if (idx >= 0) {
this.globalStatusListeners.splice(idx, 1);
}
}
/** 为指定账号建立 SSE 连接 */
connectForAccount(accountId: string, server: string, token: string): void {
let conn: SseConnection | undefined = this.connections.get(accountId);
if (conn !== undefined && conn.connected) {
hilog.info(DOMAIN, TAG, 'account %{public}s already connected', accountId);
return;
}
if (conn === undefined) {
conn = new SseConnection();
conn.accountId = accountId;
this.connections.set(accountId, conn);
}
conn.server = server;
conn.token = token;
conn.connected = true;
this.setConnStatus(conn, 'connecting');
this.doConnect(conn);
}
/** 断开指定账号的 SSE 连接 */
disconnectAccount(accountId: string): void {
const conn: SseConnection | undefined = this.connections.get(accountId);
if (conn === undefined) {
return;
}
conn.connected = false;
if (conn.reconnectTimer !== 0) {
clearTimeout(conn.reconnectTimer);
conn.reconnectTimer = 0;
}
if (conn.httpRequest !== null) {
conn.httpRequest.destroy();
conn.httpRequest = null;
}
this.setConnStatus(conn, 'disconnected');
this.connections.delete(accountId);
}
/** 断开所有连接 */
disconnectAll(): void {
const keys: string[] = [];
this.connections.forEach((_conn: SseConnection, key: string) => {
keys.push(key);
});
for (let i = 0; i < keys.length; i++) {
this.disconnectAccount(keys[i]);
}
}
/** 根据 AccountManager 连接所有账号 */
async connectAll(acctMgr: AccountManager): Promise<void> {
await acctMgr.load();
const accounts: AccountInfo[] = acctMgr.getAccounts();
for (let i = 0; i < accounts.length; i++) {
const acct: AccountInfo = accounts[i];
this.connectForAccount(acct.id, acct.server, acct.token);
}
}
/** 获取指定账号的连接状态 */
getStatusForAccount(accountId: string): SseStatus {
const conn: SseConnection | undefined = this.connections.get(accountId);
if (conn === undefined) {
return 'disconnected';
}
return conn.status;
}
/** 给指定账号添加事件监听 */
addListenerForAccount(accountId: string, listener: SseListener): void {
let conn: SseConnection | undefined = this.connections.get(accountId);
if (conn === undefined) {
conn = new SseConnection();
conn.accountId = accountId;
this.connections.set(accountId, conn);
}
conn.listeners.push(listener);
}
/** 移除指定账号的事件监听 */
removeListenerForAccount(accountId: string, listener: SseListener): void {
const conn: SseConnection | undefined = this.connections.get(accountId);
if (conn !== undefined) {
const idx: number = conn.listeners.indexOf(listener);
if (idx >= 0) {
conn.listeners.splice(idx, 1);
}
}
}
private setConnStatus(conn: SseConnection, status: SseStatus): void {
if (conn.status !== status) {
conn.status = status;
hilog.info(DOMAIN, TAG, 'status[%{public}s]: %{public}s', conn.accountId, status);
for (let i = 0; i < this.globalStatusListeners.length; i++) {
this.globalStatusListeners[i](status);
}
}
}
private doConnect(conn: SseConnection): void {
if (!conn.connected) {
return;
}
const httpRequest = http.createHttp();
conn.httpRequest = httpRequest;
const url: string = conn.server + '/events/stream';
const header: Record<string, string> = {};
if (conn.token.length > 0) {
header['Authorization'] = 'Bearer ' + conn.token;
}
hilog.info(DOMAIN, TAG, 'connecting account %{public}s to %{public}s', conn.accountId, url);
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
const text: string = this.arrayBufferToString(data);
conn.buffer += text;
this.processBuffer(conn);
});
httpRequest.on('dataEnd', () => {
hilog.info(DOMAIN, TAG, 'dataEnd for account %{public}s', conn.accountId);
this.setConnStatus(conn, 'disconnected');
this.scheduleReconnect(conn);
});
httpRequest.on('headersReceive', (_headers: Object) => {
hilog.info(DOMAIN, TAG, 'headers received for account %{public}s', conn.accountId);
});
const options: http.HttpRequestOptions = {
method: http.RequestMethod.GET,
header: header,
connectTimeout: 10000,
readTimeout: 0
};
httpRequest.requestInStream(url, options, (err: BusinessError, code: number) => {
if (err !== undefined && err !== null) {
hilog.error(DOMAIN, TAG, 'requestInStream error account %{public}s: %{public}s', conn.accountId, err.message);
this.setConnStatus(conn, 'disconnected');
this.scheduleReconnect(conn);
return;
}
hilog.info(DOMAIN, TAG, 'requestInStream code=%{public}d account=%{public}s', code, conn.accountId);
if (code === 200) {
this.setConnStatus(conn, 'connected');
} else {
hilog.error(DOMAIN, TAG, 'SSE failed code=%{public}d account=%{public}s', code, conn.accountId);
this.setConnStatus(conn, 'disconnected');
this.scheduleReconnect(conn);
}
});
}
private processBuffer(conn: SseConnection): void {
const lines: string[] = conn.buffer.split('\n');
conn.buffer = lines.pop() ?? '';
let eventType: string = '';
let eventData: string = '';
for (let i = 0; i < lines.length; i++) {
const line: string = lines[i];
if (line.length === 0) {
if (eventType.length > 0 || eventData.length > 0) {
const event: SseEvent = new SseEvent();
event.type = eventType.length > 0 ? eventType : 'message';
event.data = eventData;
event.accountId = conn.accountId;
this.dispatchEvent(conn, event);
}
eventType = '';
eventData = '';
} else if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
} else if (line.startsWith('data:')) {
const newData: string = line.substring(5).trim();
if (eventData.length > 0) {
eventData += '\n' + newData;
} else {
eventData = newData;
}
}
}
}
private dispatchEvent(conn: SseConnection, event: SseEvent): void {
hilog.info(DOMAIN, TAG, 'event[%{public}s]: %{public}s data: %{public}s', conn.accountId, event.type,
event.data.substring(0, 100));
// 分发给账号级监听
for (let i = 0; i < conn.listeners.length; i++) {
conn.listeners[i](event);
}
// 分发给全局监听
for (let i = 0; i < this.globalListeners.length; i++) {
this.globalListeners[i](event);
}
}
private scheduleReconnect(conn: SseConnection): void {
if (!conn.connected) {
return;
}
const timer: number | undefined = setTimeout(() => {
conn.reconnectTimer = 0;
this.doConnect(conn);
}, 3000);
conn.reconnectTimer = timer ?? 0;
}
private arrayBufferToString(buffer: ArrayBuffer): string {
const uint8Array: Uint8Array = new Uint8Array(buffer);
let result: string = '';
for (let i = 0; i < uint8Array.length; i++) {
result += String.fromCharCode(uint8Array[i]);
}
return result;
}
}