mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
// 提供 Bright 受权会话读取 HTTP 边界
|
|
|
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
import {
|
|
ONETALK_CONVERSATIONS_ROUTE,
|
|
ONETALK_CONVERSATION_ROUTE,
|
|
ONETALK_HISTORY_ROUTE,
|
|
ONETALK_PROTOCOL_VERSION,
|
|
} from "@trade-message-center/onetalk-contract";
|
|
import type {
|
|
OneTalkAuthorizationDecision,
|
|
OneTalkAuthorizationReader,
|
|
OneTalkMindScope,
|
|
} from "@trade-message-center/onetalk-contract";
|
|
|
|
import {
|
|
OneTalkDatabaseError,
|
|
type CenterConversation,
|
|
type CenterMessage,
|
|
type OneTalkHistoryReadPurpose,
|
|
type OneTalkReadService,
|
|
} from "../onetalk/index.ts";
|
|
import type { OneTalkConnectionRegistry } from "../websocket/registry.ts";
|
|
import type { OneTalkCutoverPolicy } from "../cutover-policy.ts";
|
|
|
|
const HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS = 30;
|
|
const MINIMUM_PAGE_LIMIT = 1;
|
|
const MAXIMUM_PAGE_LIMIT = 100;
|
|
const CORS_ALLOWED_REQUEST_HEADERS = new Set(["content-type"]);
|
|
const CORS_ALLOWED_REQUEST_METHOD = "GET";
|
|
|
|
type AccountParams = {
|
|
channelAccountId: string;
|
|
};
|
|
|
|
type ConversationParams = AccountParams & {
|
|
conversationId: string;
|
|
};
|
|
|
|
export type BrightConversationListQuery = {
|
|
cursor?: unknown;
|
|
limit?: unknown;
|
|
query?: unknown;
|
|
};
|
|
|
|
export type BrightHistoryQuery = {
|
|
cursor?: unknown;
|
|
fromSentAtMs?: unknown;
|
|
limit?: unknown;
|
|
toSentAtMs?: unknown;
|
|
};
|
|
|
|
export type BrightReadErrorCode =
|
|
| "auth_required"
|
|
| "authorization_rejected"
|
|
| "authorization_unavailable"
|
|
| "authorization_version_changed"
|
|
| "binding_revoked"
|
|
| "conversation_not_found"
|
|
| "database_unavailable"
|
|
| "history_incomplete"
|
|
| "internal_error"
|
|
| "invalid_cursor"
|
|
| "invalid_limit"
|
|
| "invalid_time_range"
|
|
| "scope_mismatch";
|
|
|
|
export type BrightReadErrorResponse = {
|
|
error: {
|
|
code: BrightReadErrorCode;
|
|
};
|
|
};
|
|
|
|
export type BrightPluginStatus = {
|
|
status: "online" | "offline";
|
|
};
|
|
|
|
export type BrightConversationListResponse = {
|
|
scope: OneTalkMindScope;
|
|
plugin: BrightPluginStatus;
|
|
conversations: CenterConversation[];
|
|
page: {
|
|
hasMore: boolean;
|
|
nextCursor: string | null;
|
|
};
|
|
};
|
|
|
|
export type BrightConversationResponse = {
|
|
scope: OneTalkMindScope;
|
|
plugin: BrightPluginStatus;
|
|
conversation: CenterConversation;
|
|
};
|
|
|
|
export type BrightHistoryResponse = {
|
|
scope: OneTalkMindScope;
|
|
conversationId: string;
|
|
messages: CenterMessage[];
|
|
page: {
|
|
hasMore: boolean;
|
|
nextCursor: string | null;
|
|
};
|
|
};
|
|
|
|
export type BrightReadRouteOptions = {
|
|
authorization: OneTalkAuthorizationReader;
|
|
readService: OneTalkReadService;
|
|
registry?: OneTalkConnectionRegistry;
|
|
mindPageOrigin?: string;
|
|
cutoverPolicy: OneTalkCutoverPolicy;
|
|
};
|
|
|
|
type AuthorizationResult =
|
|
| { ok: true; scope: OneTalkMindScope }
|
|
| {
|
|
ok: false;
|
|
statusCode: 401 | 403 | 503;
|
|
code:
|
|
| "auth_required"
|
|
| "authorization_rejected"
|
|
| "authorization_unavailable"
|
|
| "authorization_version_changed"
|
|
| "binding_revoked"
|
|
| "scope_mismatch";
|
|
};
|
|
|
|
type AuthorizedReadScopeResult =
|
|
| { ok: true; scope: OneTalkMindScope }
|
|
| { ok: false; response: FastifyReply };
|
|
|
|
const authorizationCodeFor = (decision: OneTalkAuthorizationDecision): AuthorizationResult => {
|
|
if (decision.allowed) return { ok: true, scope: decision.mindScope };
|
|
const statusCode =
|
|
decision.code === "authorization_unavailable"
|
|
? 503
|
|
: decision.code === "auth_required"
|
|
? 401
|
|
: 403;
|
|
return { ok: false, statusCode, code: decision.code };
|
|
};
|
|
|
|
const authorizeRead = async (
|
|
request: FastifyRequest,
|
|
authorization: OneTalkAuthorizationReader,
|
|
channelAccountId: string,
|
|
): Promise<AuthorizationResult> => {
|
|
if (channelAccountId.trim() === "") {
|
|
return { ok: false, statusCode: 403, code: "scope_mismatch" };
|
|
}
|
|
const cookie = typeof request.headers.cookie === "string" ? request.headers.cookie : undefined;
|
|
let decision: OneTalkAuthorizationDecision;
|
|
try {
|
|
decision = await authorization.authorize({
|
|
connectionType: "mind_page",
|
|
operation: "read",
|
|
scope: { channelAccountId },
|
|
...(cookie === undefined ? {} : { cookie }),
|
|
});
|
|
} catch {
|
|
return { ok: false, statusCode: 503, code: "authorization_unavailable" };
|
|
}
|
|
|
|
if (!decision.allowed) return authorizationCodeFor(decision);
|
|
if (decision.mindScope.channelAccountId !== channelAccountId) {
|
|
return { ok: false, statusCode: 403, code: "scope_mismatch" };
|
|
}
|
|
if (!decision.permissions.includes("read")) {
|
|
return { ok: false, statusCode: 403, code: "authorization_rejected" };
|
|
}
|
|
return { ok: true, scope: decision.mindScope };
|
|
};
|
|
|
|
export const sendError = (
|
|
reply: FastifyReply,
|
|
statusCode: number,
|
|
code: BrightReadErrorCode,
|
|
): FastifyReply => reply.code(statusCode).send({ error: { code } });
|
|
|
|
const authorizeRequestScope = async (
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
authorization: OneTalkAuthorizationReader,
|
|
channelAccountId: string,
|
|
): Promise<AuthorizedReadScopeResult> => {
|
|
const authorizationResult = await authorizeRead(request, authorization, channelAccountId);
|
|
if (!authorizationResult.ok) {
|
|
return {
|
|
ok: false,
|
|
response: sendError(reply, authorizationResult.statusCode, authorizationResult.code),
|
|
};
|
|
}
|
|
return { ok: true, scope: authorizationResult.scope };
|
|
};
|
|
|
|
const applyCors = (reply: FastifyReply, request: FastifyRequest, origin?: string): void => {
|
|
if (origin === undefined || request.headers.origin !== origin) return;
|
|
reply.header("access-control-allow-origin", origin);
|
|
reply.header("access-control-allow-credentials", "true");
|
|
reply.header("vary", "Origin");
|
|
};
|
|
|
|
const rejectUnexpectedOrigin = (
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
origin?: string,
|
|
): FastifyReply | null => {
|
|
const requestOrigin = request.headers.origin;
|
|
if (origin === undefined || (requestOrigin !== undefined && requestOrigin !== origin)) {
|
|
return sendError(reply, 403, "scope_mismatch");
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const pluginStatusFor = (
|
|
registry: OneTalkConnectionRegistry | undefined,
|
|
scope: OneTalkMindScope,
|
|
): BrightPluginStatus => ({ status: registry?.isPluginOnline(scope) ? "online" : "offline" });
|
|
|
|
export const parseLimit = (value: unknown): number | undefined | null => {
|
|
if (value === undefined) return undefined;
|
|
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
|
|
const limit = Number(value);
|
|
return Number.isSafeInteger(limit) && limit >= MINIMUM_PAGE_LIMIT && limit <= MAXIMUM_PAGE_LIMIT
|
|
? limit
|
|
: null;
|
|
};
|
|
|
|
export const parseOptionalSafeInteger = (value: unknown): number | undefined | null => {
|
|
if (value === undefined) return undefined;
|
|
if (typeof value !== "string" || !/^-?\d+$/.test(value)) return null;
|
|
const parsed = Number(value);
|
|
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
};
|
|
|
|
export const parseOpaqueCursor = (value: unknown): string | undefined | null => {
|
|
if (value === undefined) return undefined;
|
|
return typeof value === "string" ? value : null;
|
|
};
|
|
|
|
const parseQuery = (value: unknown): string | undefined | null => {
|
|
if (value === undefined) return undefined;
|
|
return typeof value === "string" ? value.trim() : null;
|
|
};
|
|
|
|
const allowsCorsRequestHeaders = (value: string | string[] | undefined): boolean => {
|
|
if (value === undefined) return true;
|
|
if (Array.isArray(value)) return false;
|
|
const requestedHeaders = value.split(",").map((header) => header.trim().toLowerCase());
|
|
return (
|
|
requestedHeaders.length > 0 &&
|
|
requestedHeaders.every(
|
|
(header) => header.length > 0 && CORS_ALLOWED_REQUEST_HEADERS.has(header),
|
|
)
|
|
);
|
|
};
|
|
|
|
const allowsCorsRequestMethod = (value: string | string[] | undefined): boolean => {
|
|
if (value === undefined) return true;
|
|
return value === CORS_ALLOWED_REQUEST_METHOD;
|
|
};
|
|
|
|
export const handleReadFailure = (reply: FastifyReply, error: unknown): FastifyReply | null => {
|
|
if (error instanceof OneTalkDatabaseError) {
|
|
return sendError(reply, 503, "database_unavailable");
|
|
}
|
|
return null;
|
|
};
|
|
|
|
type HistoryReadSuccess = {
|
|
conversationId: string;
|
|
messages: CenterMessage[];
|
|
page: { hasMore: boolean; nextCursor: string | null };
|
|
};
|
|
|
|
export type HistoryReadReplyOptions = {
|
|
readService: OneTalkReadService;
|
|
scope: Pick<OneTalkMindScope, "channelAccountId">;
|
|
conversationId: string;
|
|
query: BrightHistoryQuery;
|
|
purpose: OneTalkHistoryReadPurpose;
|
|
isAdmitted: () => boolean;
|
|
toResponse: (result: HistoryReadSuccess) => unknown;
|
|
};
|
|
|
|
/** 执行历史读取并映射共享的参数、分页与稳定错误语义。 */
|
|
export const replyWithHistoryRead = async (
|
|
reply: FastifyReply,
|
|
options: HistoryReadReplyOptions,
|
|
): Promise<FastifyReply> => {
|
|
const fromSentAtMs = parseOptionalSafeInteger(options.query.fromSentAtMs);
|
|
const toSentAtMs = parseOptionalSafeInteger(options.query.toSentAtMs);
|
|
const limit = parseLimit(options.query.limit);
|
|
const cursor = parseOpaqueCursor(options.query.cursor);
|
|
if (fromSentAtMs === null || toSentAtMs === null) {
|
|
return sendError(reply, 400, "invalid_time_range");
|
|
}
|
|
if (fromSentAtMs !== undefined && toSentAtMs !== undefined && fromSentAtMs >= toSentAtMs) {
|
|
return sendError(reply, 400, "invalid_time_range");
|
|
}
|
|
if (
|
|
options.purpose === "communication_summary_read" &&
|
|
(fromSentAtMs === undefined || toSentAtMs === undefined)
|
|
) {
|
|
return sendError(reply, 400, "invalid_time_range");
|
|
}
|
|
if (limit === null) return sendError(reply, 400, "invalid_limit");
|
|
if (cursor === null) return sendError(reply, 400, "invalid_cursor");
|
|
|
|
try {
|
|
const result = await options.readService.readHistory({
|
|
scope: options.scope,
|
|
conversationId: options.conversationId,
|
|
...(fromSentAtMs === undefined ? {} : { fromSentAtMs }),
|
|
...(toSentAtMs === undefined ? {} : { toSentAtMs }),
|
|
...(limit === undefined ? {} : { limit }),
|
|
...(cursor === undefined ? {} : { cursor }),
|
|
purpose: options.purpose,
|
|
});
|
|
if (!options.isAdmitted()) return sendError(reply, 503, "authorization_unavailable");
|
|
if (result.status === "not_found") return sendError(reply, 404, "conversation_not_found");
|
|
if (result.status === "rejected") {
|
|
if (result.reason === "history_incomplete") {
|
|
reply.header("retry-after", HISTORY_INCOMPLETE_RETRY_AFTER_SECONDS);
|
|
return sendError(reply, 503, result.reason);
|
|
}
|
|
return sendError(reply, 400, result.reason);
|
|
}
|
|
return reply.send(options.toResponse(result));
|
|
} catch (error: unknown) {
|
|
if (!options.isAdmitted()) return sendError(reply, 503, "authorization_unavailable");
|
|
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
|
|
}
|
|
};
|
|
|
|
/** 安装会话列表、详情与基于领域 opaque cursor 的消息读取路由。 */
|
|
export const installOneTalkReadRoutes = (
|
|
app: FastifyInstance,
|
|
options: BrightReadRouteOptions,
|
|
): void => {
|
|
const requestEpochs = new WeakMap<FastifyRequest, number>();
|
|
const requestIsAdmitted = (request: FastifyRequest): boolean => {
|
|
const epoch = requestEpochs.get(request);
|
|
return (
|
|
epoch !== undefined &&
|
|
options.cutoverPolicy.isCurrent(epoch) &&
|
|
options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)
|
|
);
|
|
};
|
|
const mindOriginGuard = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
|
|
if (!options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)) {
|
|
return void sendError(reply, 503, "authorization_unavailable");
|
|
}
|
|
requestEpochs.set(request, options.cutoverPolicy.capture());
|
|
if (rejectUnexpectedOrigin(request, reply, options.mindPageOrigin)) return;
|
|
applyCors(reply, request, options.mindPageOrigin);
|
|
};
|
|
app.options("/api/bright/onetalk/*", async (request, reply) => {
|
|
if (!options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)) {
|
|
return sendError(reply, 503, "authorization_unavailable");
|
|
}
|
|
if (
|
|
options.mindPageOrigin === undefined ||
|
|
request.headers.origin !== options.mindPageOrigin ||
|
|
!allowsCorsRequestMethod(request.headers["access-control-request-method"]) ||
|
|
!allowsCorsRequestHeaders(request.headers["access-control-request-headers"])
|
|
) {
|
|
return reply.code(403).send();
|
|
}
|
|
reply.header("access-control-allow-origin", options.mindPageOrigin);
|
|
reply.header("access-control-allow-credentials", "true");
|
|
reply.header("vary", "Origin");
|
|
reply.header("access-control-allow-methods", "GET,OPTIONS");
|
|
reply.header("access-control-allow-headers", "content-type");
|
|
return reply.code(204).send();
|
|
});
|
|
app.get<{ Params: AccountParams; Querystring: BrightConversationListQuery }>(
|
|
ONETALK_CONVERSATIONS_ROUTE,
|
|
{ preHandler: mindOriginGuard },
|
|
async (request, reply) => {
|
|
const authorizedScope = await authorizeRequestScope(
|
|
request,
|
|
reply,
|
|
options.authorization,
|
|
request.params.channelAccountId,
|
|
);
|
|
if (!authorizedScope.ok) return authorizedScope.response;
|
|
if (!requestIsAdmitted(request))
|
|
return sendError(reply, 503, "authorization_unavailable");
|
|
|
|
const query = parseQuery(request.query.query);
|
|
const limit = parseLimit(request.query.limit);
|
|
const cursor = parseOpaqueCursor(request.query.cursor);
|
|
if (query === null || limit === null) return sendError(reply, 400, "invalid_limit");
|
|
if (cursor === null) return sendError(reply, 400, "invalid_cursor");
|
|
|
|
try {
|
|
const result = await options.readService.listConversations({
|
|
scope: authorizedScope.scope,
|
|
...(query === undefined ? {} : { query }),
|
|
...(limit === undefined ? {} : { limit }),
|
|
...(cursor === undefined ? {} : { cursor }),
|
|
});
|
|
if (!requestIsAdmitted(request)) {
|
|
return sendError(reply, 503, "authorization_unavailable");
|
|
}
|
|
if (result.status === "rejected") return sendError(reply, 400, result.reason);
|
|
return reply.send({
|
|
scope: authorizedScope.scope,
|
|
plugin: pluginStatusFor(options.registry, authorizedScope.scope),
|
|
conversations: result.conversations,
|
|
page: result.page,
|
|
} satisfies BrightConversationListResponse);
|
|
} catch (error: unknown) {
|
|
if (!requestIsAdmitted(request)) {
|
|
return sendError(reply, 503, "authorization_unavailable");
|
|
}
|
|
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
|
|
}
|
|
},
|
|
);
|
|
app.get<{ Params: ConversationParams }>(
|
|
ONETALK_CONVERSATION_ROUTE,
|
|
{ preHandler: mindOriginGuard },
|
|
async (request, reply) => {
|
|
const authorizedScope = await authorizeRequestScope(
|
|
request,
|
|
reply,
|
|
options.authorization,
|
|
request.params.channelAccountId,
|
|
);
|
|
if (!authorizedScope.ok) return authorizedScope.response;
|
|
if (!requestIsAdmitted(request))
|
|
return sendError(reply, 503, "authorization_unavailable");
|
|
|
|
try {
|
|
const result = await options.readService.readConversation({
|
|
scope: authorizedScope.scope,
|
|
conversationId: request.params.conversationId,
|
|
});
|
|
if (!requestIsAdmitted(request)) {
|
|
return sendError(reply, 503, "authorization_unavailable");
|
|
}
|
|
if (result.status === "not_found")
|
|
return sendError(reply, 404, "conversation_not_found");
|
|
return reply.send({
|
|
scope: authorizedScope.scope,
|
|
plugin: pluginStatusFor(options.registry, authorizedScope.scope),
|
|
conversation: result.conversation,
|
|
} satisfies BrightConversationResponse);
|
|
} catch (error: unknown) {
|
|
if (!requestIsAdmitted(request)) {
|
|
return sendError(reply, 503, "authorization_unavailable");
|
|
}
|
|
return handleReadFailure(reply, error) ?? sendError(reply, 500, "internal_error");
|
|
}
|
|
},
|
|
);
|
|
app.get<{ Params: ConversationParams; Querystring: BrightHistoryQuery }>(
|
|
ONETALK_HISTORY_ROUTE,
|
|
{ preHandler: mindOriginGuard },
|
|
async (request, reply) => {
|
|
const authorizedScope = await authorizeRequestScope(
|
|
request,
|
|
reply,
|
|
options.authorization,
|
|
request.params.channelAccountId,
|
|
);
|
|
if (!authorizedScope.ok) return authorizedScope.response;
|
|
if (!requestIsAdmitted(request))
|
|
return sendError(reply, 503, "authorization_unavailable");
|
|
return replyWithHistoryRead(reply, {
|
|
readService: options.readService,
|
|
scope: authorizedScope.scope,
|
|
conversationId: request.params.conversationId,
|
|
query: request.query,
|
|
purpose: "normal",
|
|
isAdmitted: () => requestIsAdmitted(request),
|
|
toResponse: (result) =>
|
|
({
|
|
scope: authorizedScope.scope,
|
|
conversationId: result.conversationId,
|
|
messages: result.messages,
|
|
page: result.page,
|
|
}) satisfies BrightHistoryResponse,
|
|
});
|
|
},
|
|
);
|
|
};
|