839 lines
30 KiB
TypeScript
839 lines
30 KiB
TypeScript
import type {
|
||
YinianAuthSession,
|
||
YinianConfigSnapshot,
|
||
YinianHotel,
|
||
YinianImageCaptcha,
|
||
YinianNotificationChannel,
|
||
YinianSkillEntitlement,
|
||
YinianLoginWithPasswordInput,
|
||
YinianLoginWithSmsInput,
|
||
YinianLocalSkill,
|
||
YinianServerStatus,
|
||
YinianSessionState,
|
||
YinianSkillSyncResult,
|
||
YinianUser,
|
||
} from '../../shared/yinian';
|
||
import { randomUUID } from 'node:crypto';
|
||
import type { YinianControlPlane } from './control-plane';
|
||
import { getYinianStorage, type YinianStorage } from './storage';
|
||
|
||
interface HttpYinianControlPlaneOptions {
|
||
apiBaseUrl: string;
|
||
storage?: YinianStorage;
|
||
}
|
||
|
||
type JsonObject = Record<string, unknown>;
|
||
|
||
const DEFAULT_ACCESS_TOKEN_TTL_MS = 30 * 60 * 1000;
|
||
const SKILL_CATEGORIES: YinianSkillEntitlement['category'][] = [
|
||
'ota-monitoring',
|
||
'reporting',
|
||
'guest-comm',
|
||
'ops-automation',
|
||
];
|
||
const SKILL_TRIGGERS: Array<YinianSkillEntitlement['triggers'][number]> = [
|
||
'manual',
|
||
'scheduled',
|
||
'webhook',
|
||
'reply',
|
||
];
|
||
const DEFAULT_OAUTH_CLIENT_ID = 'customPC';
|
||
const DEFAULT_OAUTH_CLIENT_SECRET = 'customPC';
|
||
const DEFAULT_OAUTH_SCOPE = 'server';
|
||
const DEFAULT_CONFIG_SYNC_PATH = '/config/sync';
|
||
const DEFAULT_SKILLS_MANIFEST_PATH = '/skills/manifest';
|
||
|
||
export class HttpYinianControlPlane implements YinianControlPlane {
|
||
private readonly apiBaseUrl: string;
|
||
private readonly storage: YinianStorage;
|
||
private accessToken: string | null = null;
|
||
private refreshToken: string | null = null;
|
||
private currentHotelId: string | null = null;
|
||
private localSkills: YinianLocalSkill[] = [];
|
||
|
||
constructor(options: HttpYinianControlPlaneOptions) {
|
||
this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, '');
|
||
this.storage = options.storage ?? getYinianStorage();
|
||
}
|
||
|
||
async getServerStatus(): Promise<YinianServerStatus> {
|
||
const checkedAt = Date.now();
|
||
try {
|
||
const health = await this.request<JsonObject>('/health', { auth: false });
|
||
return {
|
||
mode: 'http',
|
||
apiBaseUrl: this.apiBaseUrl,
|
||
reachable: true,
|
||
checkedAt,
|
||
serverTime: readNumber(health, 'serverTime', 'server_time'),
|
||
version: readString(health, 'version', 'appVersion', 'app_version'),
|
||
message: readString(health, 'message', 'status') ?? '服务端连接正常',
|
||
};
|
||
} catch (error) {
|
||
return {
|
||
mode: 'http',
|
||
apiBaseUrl: this.apiBaseUrl,
|
||
reachable: false,
|
||
checkedAt,
|
||
message: error instanceof Error ? error.message : String(error),
|
||
};
|
||
}
|
||
}
|
||
|
||
async createImageCaptcha(randomStr = createRandomString()): Promise<YinianImageCaptcha> {
|
||
const path = `/auth/code/image?randomStr=${encodeURIComponent(randomStr)}`;
|
||
const requestUrl = `${this.apiBaseUrl}${path}`;
|
||
const response = await fetch(requestUrl, {
|
||
method: 'GET',
|
||
headers: {
|
||
'X-YINIAN-App-Version': '0.1.0',
|
||
},
|
||
}).catch((error: unknown) => {
|
||
throw new Error(describeNetworkError(error, requestUrl));
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const data = await response.json().catch(() => ({})) as JsonObject;
|
||
const message = readErrorMessage(data) ?? `YINIAN API request failed: ${response.status}`;
|
||
throw new Error(message);
|
||
}
|
||
|
||
const contentType = response.headers.get('content-type') ?? '';
|
||
if (contentType.includes('application/json')) {
|
||
const data = await response.json().catch(() => ({})) as JsonObject;
|
||
return normalizeImageCaptcha(data, randomStr);
|
||
}
|
||
|
||
const imageBytes = Buffer.from(await response.arrayBuffer());
|
||
return {
|
||
randomStr,
|
||
imageBase64: imageBytes.toString('base64'),
|
||
mimeType: contentType.split(';')[0] || 'image/png',
|
||
raw: {
|
||
contentType,
|
||
size: imageBytes.byteLength,
|
||
},
|
||
};
|
||
}
|
||
|
||
async restoreSession(): Promise<YinianSessionState> {
|
||
const persisted = await this.storage.getSession();
|
||
if (!persisted || persisted.mode !== 'http') {
|
||
return { authenticated: false };
|
||
}
|
||
|
||
this.refreshToken = persisted.refreshToken ?? null;
|
||
this.currentHotelId = persisted.currentHotelId;
|
||
if (this.refreshToken) {
|
||
try {
|
||
return await this.refreshSession();
|
||
} catch {
|
||
await this.storage.clearSession();
|
||
this.refreshToken = null;
|
||
this.currentHotelId = null;
|
||
return { authenticated: false };
|
||
}
|
||
}
|
||
|
||
// Access tokens intentionally remain memory-only. Until refresh-token
|
||
// exchange is implemented, restore returns the persisted tenant snapshot so
|
||
// the shell can recover UI context without exposing provider/model keys.
|
||
return {
|
||
authenticated: true,
|
||
user: persisted.user,
|
||
hotels: persisted.hotels,
|
||
currentHotelId: persisted.currentHotelId,
|
||
accessTokenExpiresAt: persisted.accessTokenExpiresAt,
|
||
};
|
||
}
|
||
|
||
async getSessionState(): Promise<YinianSessionState> {
|
||
if (!this.accessToken) return this.restoreSession();
|
||
|
||
const persisted = await this.storage.getSession();
|
||
if (persisted?.mode === 'http') {
|
||
return {
|
||
authenticated: true,
|
||
user: persisted.user,
|
||
hotels: persisted.hotels,
|
||
currentHotelId: persisted.currentHotelId,
|
||
accessTokenExpiresAt: persisted.accessTokenExpiresAt,
|
||
};
|
||
}
|
||
|
||
return { authenticated: false };
|
||
}
|
||
|
||
async loginWithSms(input: YinianLoginWithSmsInput): Promise<YinianAuthSession> {
|
||
void input;
|
||
throw new Error('当前组织空间端仅支持账号密码登录');
|
||
}
|
||
|
||
async loginWithPassword(input: YinianLoginWithPasswordInput): Promise<YinianAuthSession> {
|
||
const response = await this.requestOAuthToken({
|
||
grant_type: 'password',
|
||
username: input.account,
|
||
account: input.account,
|
||
password: input.password,
|
||
code: input.captchaCode,
|
||
captchaCode: input.captchaCode,
|
||
captcha_code: input.captchaCode,
|
||
randomStr: input.randomStr,
|
||
random_str: input.randomStr,
|
||
});
|
||
|
||
return this.applyLoginResponse(response, input.account);
|
||
}
|
||
|
||
async logout(): Promise<YinianSessionState> {
|
||
const hotelId = this.currentHotelId;
|
||
this.accessToken = null;
|
||
this.refreshToken = null;
|
||
this.currentHotelId = null;
|
||
if (hotelId) {
|
||
await this.storage.clearConfig(hotelId);
|
||
await this.storage.clearSkillRegistry(hotelId);
|
||
}
|
||
await this.storage.clearSession();
|
||
this.localSkills = [];
|
||
return { authenticated: false };
|
||
}
|
||
|
||
async switchHotel(hotelId: string): Promise<YinianAuthSession> {
|
||
const session = await this.getSessionState();
|
||
if (!session.authenticated) throw new Error('请先登录');
|
||
const hotel = session.hotels.find((item) => item.id === hotelId);
|
||
if (!hotel) throw new Error('工作空间不存在或未开通');
|
||
this.currentHotelId = hotelId;
|
||
const nextSession = { ...session, currentHotelId: hotelId };
|
||
await this.persistSession(nextSession);
|
||
const registry = await this.storage.getSkillRegistry(hotelId);
|
||
this.localSkills = registry && 'skills' in registry ? registry.skills : [];
|
||
return nextSession;
|
||
}
|
||
|
||
async getConfigSnapshot(): Promise<YinianConfigSnapshot> {
|
||
const session = await this.getSessionState();
|
||
if (!session.authenticated) throw new Error('请先登录');
|
||
|
||
const hotelId = session.currentHotelId;
|
||
const configPath = buildScopedEndpoint(
|
||
process.env.YINIAN_CONFIG_SYNC_PATH?.trim() || DEFAULT_CONFIG_SYNC_PATH,
|
||
hotelId,
|
||
);
|
||
let response: JsonObject;
|
||
try {
|
||
response = await this.request<JsonObject>(configPath);
|
||
} catch (error) {
|
||
if (isMissingServerResourceError(error)) {
|
||
const snapshot = createDefaultConfigSnapshot(session);
|
||
await this.storage.setConfig(snapshot);
|
||
return snapshot;
|
||
}
|
||
throw error;
|
||
}
|
||
const hotel = normalizeHotel(response.hotel) ?? session.hotels.find((item) => item.id === hotelId) ?? session.hotels[0];
|
||
|
||
const snapshot = {
|
||
serverTime: readNumber(response, 'serverTime', 'server_time') ?? Date.now(),
|
||
user: session.user,
|
||
hotel,
|
||
hotels: session.hotels,
|
||
entitlements: normalizeEntitlements(readManifestItems(response, 'entitlements')),
|
||
notificationChannels: normalizeNotificationChannels(readArray(response, 'notificationChannels', 'notification_channels')),
|
||
featureFlags: normalizeBooleanRecord(readObject(response, 'featureFlags', 'feature_flags')),
|
||
uiPolicy: normalizeUiPolicy(readObject(response, 'uiPolicy', 'ui_policy')),
|
||
};
|
||
await this.storage.setConfig(snapshot);
|
||
return snapshot;
|
||
}
|
||
|
||
async syncSkills(): Promise<YinianSkillSyncResult> {
|
||
const session = await this.getSessionState();
|
||
if (!session.authenticated) throw new Error('请先登录');
|
||
|
||
const manifestPath = buildScopedEndpoint(
|
||
process.env.YINIAN_SKILLS_MANIFEST_PATH?.trim() || DEFAULT_SKILLS_MANIFEST_PATH,
|
||
session.currentHotelId,
|
||
);
|
||
const now = Date.now();
|
||
let response: JsonObject;
|
||
try {
|
||
response = await this.request<JsonObject>(manifestPath);
|
||
} catch (error) {
|
||
if (isMissingServerResourceError(error)) {
|
||
this.localSkills = [];
|
||
await this.storage.setSkillRegistry({
|
||
hotelId: session.currentHotelId,
|
||
updatedAt: now,
|
||
skills: [],
|
||
});
|
||
return {
|
||
hotelId: session.currentHotelId,
|
||
syncedAt: now,
|
||
skills: [],
|
||
};
|
||
}
|
||
throw error;
|
||
}
|
||
const skills = readManifestItems(response).filter(isObject).map((item) => normalizeLocalSkill(item, now));
|
||
|
||
const previousRegistry = await this.storage.getSkillRegistry(session.currentHotelId);
|
||
const previousSkills = previousRegistry && 'skills' in previousRegistry ? previousRegistry.skills : [];
|
||
this.localSkills = skills.map((skill) => {
|
||
const previous = previousSkills.find((item) => item.skillId === skill.skillId);
|
||
return {
|
||
...skill,
|
||
installedAt: previous?.installedAt ?? skill.installedAt,
|
||
status: !skill.enabled ? 'disabled' : previous?.version === skill.version ? 'skipped' : previous ? 'updated' : skill.status,
|
||
};
|
||
});
|
||
await this.storage.setSkillRegistry({
|
||
hotelId: session.currentHotelId,
|
||
updatedAt: now,
|
||
skills: this.localSkills,
|
||
});
|
||
return {
|
||
hotelId: session.currentHotelId,
|
||
syncedAt: now,
|
||
skills: this.localSkills,
|
||
};
|
||
}
|
||
|
||
async listLocalSkills(): Promise<YinianLocalSkill[]> {
|
||
const session = await this.getSessionState();
|
||
if (session.authenticated) {
|
||
const registry = await this.storage.getSkillRegistry(session.currentHotelId);
|
||
if (registry && 'skills' in registry) {
|
||
this.localSkills = registry.skills;
|
||
}
|
||
}
|
||
return this.localSkills;
|
||
}
|
||
|
||
async getSkillRegistry(hotelId?: string) {
|
||
return this.storage.getSkillRegistry(hotelId);
|
||
}
|
||
|
||
private async applyLoginResponse(response: JsonObject, accountHint?: string): Promise<YinianAuthSession> {
|
||
this.accessToken = readString(response, 'accessToken', 'access_token');
|
||
this.refreshToken = readString(response, 'refreshToken', 'refresh_token');
|
||
if (!this.accessToken) throw new Error('服务端未返回 access token');
|
||
|
||
const session = this.normalizeSessionFromPayload(response, accountHint) ?? await this.getSessionState();
|
||
if (!session.authenticated) throw new Error('登录成功但未获取到工作空间信息');
|
||
this.currentHotelId = session.currentHotelId;
|
||
await this.persistSession(session);
|
||
return session;
|
||
}
|
||
|
||
private async refreshSession(): Promise<YinianSessionState> {
|
||
if (!this.refreshToken) return { authenticated: false };
|
||
const response = await this.requestOAuthToken({
|
||
grant_type: 'refresh_token',
|
||
refreshToken: this.refreshToken,
|
||
refresh_token: this.refreshToken,
|
||
});
|
||
return this.applyLoginResponse(response);
|
||
}
|
||
|
||
private async persistSession(session: YinianAuthSession): Promise<void> {
|
||
await this.storage.setSession({
|
||
mode: 'http',
|
||
user: session.user,
|
||
hotels: session.hotels,
|
||
currentHotelId: session.currentHotelId,
|
||
accessTokenExpiresAt: session.accessTokenExpiresAt,
|
||
refreshToken: this.refreshToken ?? undefined,
|
||
updatedAt: Date.now(),
|
||
});
|
||
}
|
||
|
||
private async requestOAuthToken(fields: Record<string, string | undefined>): Promise<JsonObject> {
|
||
const clientId = process.env.YINIAN_AUTH_CLIENT_ID?.trim() || DEFAULT_OAUTH_CLIENT_ID;
|
||
const headers = createOAuthHeaders(clientId);
|
||
const clientBodyFields = headers.Authorization
|
||
? {}
|
||
: { clientId, client_id: clientId };
|
||
return this.request<JsonObject>('/auth/oauth2/token', {
|
||
method: 'POST',
|
||
auth: false,
|
||
form: {
|
||
scope: process.env.YINIAN_AUTH_SCOPE?.trim() || DEFAULT_OAUTH_SCOPE,
|
||
...clientBodyFields,
|
||
...fields,
|
||
},
|
||
headers,
|
||
});
|
||
}
|
||
|
||
private normalizeSessionFromPayload(payload: JsonObject, accountHint?: string): YinianAuthSession | null {
|
||
const tokenPayload = this.accessToken ? parseJwtPayload(this.accessToken) : undefined;
|
||
const sessionPayload = {
|
||
...(tokenPayload ?? {}),
|
||
...payload,
|
||
...(readObject(payload, 'session') ?? {}),
|
||
};
|
||
const userValue = readObject(sessionPayload, 'user', 'user_info', 'userInfo')
|
||
?? extractUserPayload(sessionPayload, accountHint);
|
||
|
||
const user = normalizeUser(userValue);
|
||
const hotelsValue = readArray(sessionPayload, 'hotels', 'workspaces', 'businesses', 'tenants');
|
||
const hotels = normalizeHotels(hotelsValue);
|
||
if (hotels.length === 0) {
|
||
hotels.push(createDefaultWorkspace(sessionPayload, userValue, user));
|
||
}
|
||
const currentHotelId = readString(sessionPayload, 'currentHotelId', 'current_hotel_id', 'currentWorkspaceId', 'current_workspace_id', 'workspaceId', 'workspace_id')
|
||
?? this.currentHotelId
|
||
?? hotels[0]?.id;
|
||
if (!currentHotelId) return null;
|
||
|
||
return {
|
||
authenticated: true,
|
||
user,
|
||
hotels,
|
||
currentHotelId,
|
||
accessTokenExpiresAt: readAccessTokenExpiresAt(sessionPayload),
|
||
};
|
||
}
|
||
|
||
private async request<T>(
|
||
path: string,
|
||
options: { method?: string; body?: JsonObject; form?: Record<string, string | string[] | undefined>; auth?: boolean; headers?: Record<string, string> } = {},
|
||
): Promise<T> {
|
||
const headers: Record<string, string> = {
|
||
'X-YINIAN-App-Version': '0.1.0',
|
||
...(options.headers ?? {}),
|
||
};
|
||
const body = options.form
|
||
? createFormBody(options.form)
|
||
: options.body
|
||
? JSON.stringify(options.body)
|
||
: undefined;
|
||
if (options.body && !headers['Content-Type']) {
|
||
headers['Content-Type'] = 'application/json';
|
||
}
|
||
if (options.form && !headers['Content-Type']) {
|
||
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||
}
|
||
if (options.auth !== false && this.accessToken) {
|
||
headers.Authorization = `Bearer ${this.accessToken}`;
|
||
}
|
||
|
||
const requestUrl = `${this.apiBaseUrl}${path}`;
|
||
const response = await fetch(requestUrl, {
|
||
method: options.method ?? 'GET',
|
||
headers,
|
||
body,
|
||
}).catch((error: unknown) => {
|
||
throw new Error(describeNetworkError(error, requestUrl));
|
||
});
|
||
|
||
const data = await response.json().catch(() => ({})) as JsonObject;
|
||
if (!response.ok) {
|
||
const message = readErrorMessage(data) ?? `YINIAN API request failed: ${response.status}`;
|
||
throw new Error(message);
|
||
}
|
||
|
||
const payload = unwrapApiPayload(data);
|
||
return payload as T;
|
||
}
|
||
}
|
||
|
||
function createDevicePayload(): JsonObject {
|
||
return {
|
||
device_id: 'dev_local_electron',
|
||
platform: process.platform,
|
||
app_version: '0.1.0',
|
||
machine_name: '智念助手',
|
||
};
|
||
}
|
||
|
||
function describeNetworkError(error: unknown, requestUrl: string): string {
|
||
const hostname = safeHostname(requestUrl);
|
||
const code = readNestedErrorString(error, 'code');
|
||
const syscall = readNestedErrorString(error, 'syscall');
|
||
const detail = code ? `(${code}${syscall ? `/${syscall}` : ''})` : '';
|
||
|
||
if (code === 'ENOTFOUND') {
|
||
return `无法连接服务端:域名 ${hostname} 解析失败${detail}。请检查服务端地址、网络或 VPN。`;
|
||
}
|
||
if (code === 'ECONNREFUSED') {
|
||
return `无法连接服务端:${hostname} 拒绝连接${detail}。请确认服务端已启动并允许当前网络访问。`;
|
||
}
|
||
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') {
|
||
return `无法连接服务端:连接 ${hostname} 超时${detail}。请检查网络、代理或 VPN。`;
|
||
}
|
||
if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || code === 'DEPTH_ZERO_SELF_SIGNED_CERT') {
|
||
return `无法连接服务端:${hostname} 的 HTTPS 证书校验失败${detail}。请检查证书配置。`;
|
||
}
|
||
|
||
return `无法连接服务端:请求 ${hostname} 失败${detail}。请检查网络、代理、VPN 或服务端地址。`;
|
||
}
|
||
|
||
function safeHostname(requestUrl: string): string {
|
||
try {
|
||
return new URL(requestUrl).hostname;
|
||
} catch {
|
||
return requestUrl;
|
||
}
|
||
}
|
||
|
||
function readNestedErrorString(error: unknown, key: string): string | undefined {
|
||
const visited = new Set<unknown>();
|
||
let current: unknown = error;
|
||
while (isObject(current) && !visited.has(current)) {
|
||
visited.add(current);
|
||
const value = current[key];
|
||
if (typeof value === 'string' && value.trim()) return value;
|
||
current = current.cause;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function createOAuthHeaders(clientId: string): Record<string, string> {
|
||
const explicitBasic = process.env.YINIAN_AUTH_BASIC?.trim();
|
||
if (explicitBasic) {
|
||
return { Authorization: explicitBasic.startsWith('Basic ') ? explicitBasic : `Basic ${explicitBasic}` };
|
||
}
|
||
|
||
const clientSecret = process.env.YINIAN_AUTH_CLIENT_SECRET?.trim() || DEFAULT_OAUTH_CLIENT_SECRET;
|
||
if (!clientSecret) return {};
|
||
return {
|
||
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
||
};
|
||
}
|
||
|
||
function createFormBody(fields: Record<string, string | string[] | undefined>): string {
|
||
const params = new URLSearchParams();
|
||
for (const [key, value] of Object.entries(fields)) {
|
||
if (value == null || value === '') continue;
|
||
if (Array.isArray(value)) {
|
||
value.forEach((item) => params.append(key, item));
|
||
} else {
|
||
params.append(key, value);
|
||
}
|
||
}
|
||
return params.toString();
|
||
}
|
||
|
||
function createRandomString(): string {
|
||
return randomUUID();
|
||
}
|
||
|
||
function parseJwtPayload(token: string): JsonObject | undefined {
|
||
const [, rawPayload] = token.split('.');
|
||
if (!rawPayload) return undefined;
|
||
|
||
try {
|
||
const normalized = rawPayload.replace(/-/g, '+').replace(/_/g, '/');
|
||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||
const parsed = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')) as unknown;
|
||
return isObject(parsed) ? parsed : undefined;
|
||
} catch {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
function buildScopedEndpoint(endpoint: string, workspaceId: string): string {
|
||
const [rawPath, rawQuery = ''] = endpoint.split('?');
|
||
const path = rawPath
|
||
.replaceAll('{workspaceId}', encodeURIComponent(workspaceId))
|
||
.replaceAll('{workspace_id}', encodeURIComponent(workspaceId))
|
||
.replaceAll('{hotelId}', encodeURIComponent(workspaceId))
|
||
.replaceAll('{hotel_id}', encodeURIComponent(workspaceId))
|
||
.replaceAll('{tenantId}', encodeURIComponent(workspaceId))
|
||
.replaceAll('{tenant_id}', encodeURIComponent(workspaceId));
|
||
const params = new URLSearchParams(rawQuery);
|
||
const hasExplicitScope = endpoint.includes('{workspaceId}')
|
||
|| endpoint.includes('{workspace_id}')
|
||
|| endpoint.includes('{hotelId}')
|
||
|| endpoint.includes('{hotel_id}')
|
||
|| endpoint.includes('{tenantId}')
|
||
|| endpoint.includes('{tenant_id}')
|
||
|| params.has('workspaceId')
|
||
|| params.has('workspace_id')
|
||
|| params.has('hotelId')
|
||
|| params.has('hotel_id')
|
||
|| params.has('tenantId')
|
||
|| params.has('tenant_id');
|
||
|
||
if (!hasExplicitScope) {
|
||
params.set('hotel_id', workspaceId);
|
||
}
|
||
|
||
const query = params.toString();
|
||
return query ? `${path}?${query}` : path;
|
||
}
|
||
|
||
function normalizeImageCaptcha(value: JsonObject, fallbackRandomStr: string): YinianImageCaptcha {
|
||
const payload = unwrapApiPayload(value);
|
||
const image = readString(payload, 'image', 'img', 'captcha', 'captchaImage', 'captcha_image');
|
||
const imageBase64 = readString(payload, 'imageBase64', 'image_base64', 'base64');
|
||
return {
|
||
randomStr: readString(payload, 'randomStr', 'random_str', 'key') ?? fallbackRandomStr,
|
||
image,
|
||
imageBase64,
|
||
mimeType: readString(payload, 'mimeType', 'mime_type') ?? (imageBase64 ? 'image/png' : undefined),
|
||
raw: payload,
|
||
};
|
||
}
|
||
|
||
function normalizeUser(value: unknown): YinianUser {
|
||
const object = isObject(value) ? value : {};
|
||
return {
|
||
id: readString(object, 'id', 'userId', 'user_id', 'userIdStr', 'user_id_str', 'sub', 'user_name') ?? 'user_unknown',
|
||
name: readString(object, 'name', 'username', 'userName', 'user_name', 'nickname', 'nickName', 'realName', 'real_name', 'account') ?? '未命名用户',
|
||
phone: readString(object, 'phone', 'mobile', 'phoneNumber', 'phone_number'),
|
||
email: readString(object, 'email'),
|
||
avatar: readString(object, 'avatar'),
|
||
};
|
||
}
|
||
|
||
function extractUserPayload(sessionPayload: JsonObject, accountHint?: string): JsonObject {
|
||
const account = accountHint?.trim()
|
||
|| readString(sessionPayload, 'username', 'userName', 'user_name', 'account', 'phone', 'mobile')
|
||
|| '当前用户';
|
||
return {
|
||
id: readString(sessionPayload, 'userId', 'user_id', 'id', 'sub', 'user_name') ?? account,
|
||
name: readString(sessionPayload, 'name', 'username', 'userName', 'user_name', 'nickname', 'nickName', 'realName', 'real_name') ?? account,
|
||
phone: readString(sessionPayload, 'phone', 'mobile', 'phoneNumber', 'phone_number'),
|
||
email: readString(sessionPayload, 'email'),
|
||
avatar: readString(sessionPayload, 'avatar'),
|
||
tenantId: readString(sessionPayload, 'tenantId', 'tenant_id', 'deptId', 'dept_id', 'orgId', 'org_id'),
|
||
};
|
||
}
|
||
|
||
function createDefaultWorkspace(sessionPayload: JsonObject, userPayload: JsonObject, user: YinianUser): YinianHotel {
|
||
const tenantId = readString(userPayload, 'tenantId', 'tenant_id')
|
||
?? readString(sessionPayload, 'tenantId', 'tenant_id')
|
||
?? readString(sessionPayload, 'deptId', 'dept_id', 'orgId', 'org_id')
|
||
?? 'default';
|
||
return {
|
||
id: `service_${tenantId}`,
|
||
name: readString(sessionPayload, 'tenantName', 'tenant_name', 'workspaceName', 'workspace_name', 'deptName', 'dept_name', 'orgName', 'org_name')
|
||
?? `${user.name}的组织空间`,
|
||
};
|
||
}
|
||
|
||
function createDefaultConfigSnapshot(session: YinianAuthSession): YinianConfigSnapshot {
|
||
return {
|
||
serverTime: Date.now(),
|
||
user: session.user,
|
||
hotel: session.hotels.find((item) => item.id === session.currentHotelId) ?? session.hotels[0],
|
||
hotels: session.hotels,
|
||
entitlements: [],
|
||
notificationChannels: [],
|
||
featureFlags: {},
|
||
uiPolicy: {
|
||
defaultPage: 'today',
|
||
showAdvancedSettings: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
function isMissingServerResourceError(error: unknown): boolean {
|
||
return error instanceof Error && /No static resource|404|not found/i.test(error.message);
|
||
}
|
||
|
||
function normalizeHotels(value: unknown): YinianHotel[] {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.map(normalizeHotel).filter((hotel): hotel is YinianHotel => Boolean(hotel));
|
||
}
|
||
|
||
function normalizeHotel(value: unknown): YinianHotel | null {
|
||
if (!isObject(value)) return null;
|
||
const hotel: YinianHotel = {
|
||
id: readString(value, 'id') ?? readString(value, 'hotelId', 'hotel_id', 'workspaceId', 'workspace_id', 'tenantId', 'tenant_id') ?? 'hotel_unknown',
|
||
name: readString(value, 'name') ?? '未命名工作空间',
|
||
brand: readString(value, 'brand'),
|
||
};
|
||
|
||
return hotel;
|
||
}
|
||
|
||
function normalizeLocalSkill(value: JsonObject, now: number): YinianLocalSkill {
|
||
const skillId = readString(
|
||
value,
|
||
'skillId',
|
||
'skill_id',
|
||
'appId',
|
||
'app_id',
|
||
'applicationId',
|
||
'application_id',
|
||
'code',
|
||
'skillCode',
|
||
'skill_code',
|
||
'id',
|
||
) ?? 'unknown-skill';
|
||
const version = readString(value, 'version', 'appVersion', 'app_version', 'currentVersion', 'current_version') ?? '0.0.0';
|
||
const status = readString(value, 'status', 'state');
|
||
const enabled = value.enabled !== false
|
||
&& value.kill_switch !== true
|
||
&& status !== 'disabled'
|
||
&& status !== 'disable'
|
||
&& status !== 'off';
|
||
return {
|
||
skillId,
|
||
name: readString(value, 'name', 'appName', 'app_name', 'applicationName', 'application_name', 'title') ?? skillId,
|
||
version,
|
||
enabled,
|
||
installedAt: now,
|
||
lastSyncedAt: now,
|
||
status: enabled ? 'installed' : 'disabled',
|
||
source: 'nianxx',
|
||
bundleSha256: readString(value, 'bundleSha256', 'bundle_sha256', 'sha256', 'checksum', 'digest', 'bundleHash', 'bundle_hash'),
|
||
};
|
||
}
|
||
|
||
function normalizeEntitlements(value: unknown[] | undefined): YinianSkillEntitlement[] {
|
||
if (!value) return [];
|
||
return value.filter(isObject).map((item) => {
|
||
const skillId = readString(item, 'skillId', 'skill_id', 'appId', 'app_id', 'applicationId', 'application_id', 'code', 'id') ?? 'unknown-skill';
|
||
const status = readString(item, 'status', 'state');
|
||
return {
|
||
skillId,
|
||
name: readString(item, 'name', 'appName', 'app_name', 'applicationName', 'application_name', 'title') ?? skillId,
|
||
version: readString(item, 'version', 'appVersion', 'app_version', 'currentVersion', 'current_version') ?? '0.0.0',
|
||
enabled: item.enabled !== false && status !== 'disabled' && status !== 'disable' && status !== 'off',
|
||
category: normalizeSkillCategory(readString(item, 'category')),
|
||
triggers: normalizeSkillTriggers(readArray(item, 'triggers')),
|
||
lastRunAt: readNumber(item, 'lastRunAt', 'last_run_at'),
|
||
};
|
||
});
|
||
}
|
||
|
||
function normalizeNotificationChannels(value: unknown[] | undefined): YinianNotificationChannel[] {
|
||
if (!value) return [];
|
||
return value.filter(isObject).map((item) => ({
|
||
id: readString(item, 'id') ?? 'channel_unknown',
|
||
kind: readString(item, 'kind') ?? 'unknown',
|
||
label: readString(item, 'label') ?? '未命名通知通道',
|
||
recipient: readString(item, 'recipient') ?? '',
|
||
enabled: item.enabled !== false,
|
||
source: readString(item, 'source') === 'kernel' ? 'kernel' : 'nianxx',
|
||
}));
|
||
}
|
||
|
||
function normalizeUiPolicy(value: JsonObject | undefined): YinianConfigSnapshot['uiPolicy'] {
|
||
const defaultPage = readString(value ?? {}, 'defaultPage', 'default_page');
|
||
return {
|
||
defaultPage: defaultPage === 'chat' ? 'chat' : 'today',
|
||
showAdvancedSettings: readBoolean(value ?? {}, 'showAdvancedSettings', 'show_advanced_settings') ?? false,
|
||
};
|
||
}
|
||
|
||
function normalizeSkillCategory(value: string | undefined): YinianSkillEntitlement['category'] {
|
||
return SKILL_CATEGORIES.includes(value as YinianSkillEntitlement['category'])
|
||
? value as YinianSkillEntitlement['category']
|
||
: 'ops-automation';
|
||
}
|
||
|
||
function normalizeSkillTriggers(value: unknown[] | undefined): YinianSkillEntitlement['triggers'] {
|
||
if (!value) return [];
|
||
return value.filter((item): item is YinianSkillEntitlement['triggers'][number] => (
|
||
typeof item === 'string'
|
||
&& SKILL_TRIGGERS.includes(item as YinianSkillEntitlement['triggers'][number])
|
||
));
|
||
}
|
||
|
||
function readAccessTokenExpiresAt(object: JsonObject): number {
|
||
return readNumber(object, 'accessTokenExpiresAt', 'access_token_expires_at', 'expiresAt', 'expires_at')
|
||
?? readExpiresIn(object)
|
||
?? (Date.now() + DEFAULT_ACCESS_TOKEN_TTL_MS);
|
||
}
|
||
|
||
function readExpiresIn(object: JsonObject): number | undefined {
|
||
const expiresIn = readNumber(object, 'expiresIn', 'expires_in');
|
||
if (!expiresIn) return undefined;
|
||
return Date.now() + expiresIn * 1000;
|
||
}
|
||
|
||
function isObject(value: unknown): value is JsonObject {
|
||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||
}
|
||
|
||
function readString(object: JsonObject, ...keys: string[]): string | undefined {
|
||
for (const key of keys) {
|
||
const value = object[key];
|
||
if (typeof value === 'string') return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function readNumber(object: JsonObject, ...keys: string[]): number | undefined {
|
||
for (const key of keys) {
|
||
const value = object[key];
|
||
if (typeof value === 'number') return value;
|
||
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function readBoolean(object: JsonObject, ...keys: string[]): boolean | undefined {
|
||
for (const key of keys) {
|
||
const value = object[key];
|
||
if (typeof value === 'boolean') return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function readObject(object: JsonObject, ...keys: string[]): JsonObject | undefined {
|
||
for (const key of keys) {
|
||
const value = object[key];
|
||
if (isObject(value)) return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function unwrapApiPayload(data: JsonObject): JsonObject {
|
||
const dataObject = readObject(data, 'data');
|
||
if (dataObject) {
|
||
return {
|
||
...data,
|
||
...dataObject,
|
||
};
|
||
}
|
||
return data;
|
||
}
|
||
|
||
function readManifestItems(object: JsonObject, preferredKey?: string): unknown[] {
|
||
if (preferredKey) {
|
||
const preferred = readArray(object, preferredKey);
|
||
if (preferred) return preferred;
|
||
}
|
||
|
||
const direct = readArray(object, 'skills', 'apps', 'applications', 'records', 'list', 'items', 'rows');
|
||
if (direct) return direct;
|
||
|
||
const data = object.data;
|
||
if (Array.isArray(data)) return data;
|
||
if (isObject(data)) {
|
||
return readManifestItems(data, preferredKey);
|
||
}
|
||
|
||
return [];
|
||
}
|
||
|
||
function readErrorMessage(data: JsonObject): string | undefined {
|
||
const errorObject = readObject(data, 'error');
|
||
return readString(errorObject ?? {}, 'message', 'msg', 'detail')
|
||
?? readString(data, 'message', 'msg', 'error_description')
|
||
?? (typeof data.error === 'string' ? data.error : undefined);
|
||
}
|
||
|
||
function readArray(object: JsonObject, ...keys: string[]): unknown[] | undefined {
|
||
for (const key of keys) {
|
||
const value = object[key];
|
||
if (Array.isArray(value)) return value;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
function normalizeBooleanRecord(value: JsonObject | undefined): Record<string, boolean> {
|
||
if (!value) return {};
|
||
return Object.fromEntries(
|
||
Object.entries(value).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'),
|
||
);
|
||
}
|