503 lines
19 KiB
JavaScript
503 lines
19 KiB
JavaScript
(function (root, factory) {
|
|
"use strict";
|
|
|
|
const api = Object.freeze(factory());
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
if (root) root.MbnExpertWorkflow = api;
|
|
})(typeof globalThis === "object" ? globalThis : this, function () {
|
|
"use strict";
|
|
|
|
const DEFAULT_FADE_DURATION = 6;
|
|
const DEFAULT_MAXIMUM_RESULTS = 100;
|
|
const MAXIMUM_RESULTS = 500;
|
|
const MAXIMUM_QUERY_LENGTH = 64;
|
|
const DEFAULT_MAXIMUM_PREVIEW_ITEMS = 100;
|
|
const MAXIMUM_PREVIEW_ITEMS = 100;
|
|
const MAXIMUM_PAGE_COUNT = 20;
|
|
const PAGE_SIZE = 5;
|
|
const SCHEMA_VERSION = 1;
|
|
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
|
const expertCodePattern = /^[0-9]{4}$/;
|
|
const stockCodePattern = /^[A-Za-z0-9]{1,32}$/;
|
|
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
|
|
|
const bridgeContract = Object.freeze({
|
|
searchRequestType: "search-experts",
|
|
searchResultType: "expert-search-results",
|
|
searchErrorType: "expert-search-error",
|
|
previewRequestType: "request-expert-preview",
|
|
previewResultType: "expert-preview-results",
|
|
previewErrorType: "expert-preview-error",
|
|
defaultMaximumResults: DEFAULT_MAXIMUM_RESULTS,
|
|
maximumResults: MAXIMUM_RESULTS,
|
|
maximumQueryLength: MAXIMUM_QUERY_LENGTH,
|
|
defaultMaximumPreviewItems: DEFAULT_MAXIMUM_PREVIEW_ITEMS,
|
|
maximumPreviewItems: MAXIMUM_PREVIEW_ITEMS
|
|
});
|
|
|
|
const sourceContract = Object.freeze({
|
|
originalControl: "Control/UC6.cs",
|
|
provider: "oracle",
|
|
expertTable: "EXPERT_LIST",
|
|
recommendationTable: "RECOMMEND_LIST",
|
|
originalGroupLabel: "전문가 추천",
|
|
originalCutLabel: "5단 표그래프",
|
|
originalValueLabel: "현재가",
|
|
builderKey: "s5074",
|
|
cutCode: "5074",
|
|
resolverGroup: "PAGED_EXPERT",
|
|
pageSize: PAGE_SIZE,
|
|
maximumPageCount: MAXIMUM_PAGE_COUNT
|
|
});
|
|
|
|
const actions = Object.freeze([
|
|
Object.freeze({
|
|
id: "five-row-current",
|
|
label: "전문가 추천 · 5단 표그래프 · 현재가",
|
|
builderKey: "s5074",
|
|
code: "5074",
|
|
aliases: Object.freeze(["5074"]),
|
|
pageSize: PAGE_SIZE
|
|
})
|
|
]);
|
|
const action = actions[0];
|
|
const trustedPreviewResponses = new WeakSet();
|
|
|
|
function hasExactKeys(value, keys) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const actual = Object.keys(value).sort();
|
|
const expected = [...keys].sort();
|
|
return actual.length === expected.length &&
|
|
actual.every((key, index) => key === expected[index]);
|
|
}
|
|
|
|
function hasOnlyKeys(value, keys) {
|
|
return value && typeof value === "object" && !Array.isArray(value) &&
|
|
Object.keys(value).every(key => keys.includes(key));
|
|
}
|
|
|
|
function isSafeCanonicalText(value, maximumLength) {
|
|
return typeof value === "string" &&
|
|
value.length > 0 && value.length <= maximumLength &&
|
|
value === value.trim() && !unsafeTextPattern.test(value);
|
|
}
|
|
|
|
function isSafeRequestId(value) {
|
|
return isSafeCanonicalText(value, 128) && identifierPattern.test(value);
|
|
}
|
|
|
|
function normalizeQuery(value) {
|
|
if (typeof value !== "string" || unsafeTextPattern.test(value)) return null;
|
|
const query = value.trim();
|
|
return query.length <= MAXIMUM_QUERY_LENGTH && !unsafeTextPattern.test(query)
|
|
? query
|
|
: null;
|
|
}
|
|
|
|
function normalizeExpertIdentity(value) {
|
|
if (!hasExactKeys(value, ["expertCode", "expertName"]) ||
|
|
!expertCodePattern.test(value.expertCode) ||
|
|
!isSafeCanonicalText(value.expertName, 128)) return null;
|
|
return Object.freeze({
|
|
expertCode: value.expertCode,
|
|
expertName: value.expertName
|
|
});
|
|
}
|
|
|
|
function normalizeExpert(value) {
|
|
if (!hasExactKeys(value, ["expertCode", "expertName", "source"]) ||
|
|
value.source !== "oracle") return null;
|
|
const identity = normalizeExpertIdentity({
|
|
expertCode: value.expertCode,
|
|
expertName: value.expertName
|
|
});
|
|
return identity && Object.freeze({ ...identity, source: "oracle" });
|
|
}
|
|
|
|
function createExpertSearchRequest(requestId, query = "", maximumResults) {
|
|
if (!isSafeRequestId(requestId)) {
|
|
throw new TypeError("A safe expert-search request id is required.");
|
|
}
|
|
const normalizedQuery = normalizeQuery(query);
|
|
if (normalizedQuery === null) {
|
|
throw new TypeError(`An expert query must contain at most ${MAXIMUM_QUERY_LENGTH} safe characters.`);
|
|
}
|
|
if (maximumResults !== undefined &&
|
|
(!Number.isInteger(maximumResults) || maximumResults < 1 || maximumResults > MAXIMUM_RESULTS)) {
|
|
throw new RangeError(`Expert results must be limited from 1 through ${MAXIMUM_RESULTS}.`);
|
|
}
|
|
const request = { requestId, query: normalizedQuery };
|
|
if (maximumResults !== undefined) request.maximumResults = maximumResults;
|
|
return Object.freeze(request);
|
|
}
|
|
|
|
function compareExperts(left, right) {
|
|
if (left.expertName < right.expertName) return -1;
|
|
if (left.expertName > right.expertName) return 1;
|
|
if (left.expertCode < right.expertCode) return -1;
|
|
if (left.expertCode > right.expertCode) return 1;
|
|
return 0;
|
|
}
|
|
|
|
function normalizeExpertSearchResponse(value, expectedRequest) {
|
|
if (!hasExactKeys(value, [
|
|
"requestId", "query", "retrievedAt", "totalRowCount", "truncated", "results"
|
|
]) || !isSafeRequestId(value.requestId) || normalizeQuery(value.query) !== value.query ||
|
|
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
|
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
|
value.totalRowCount > MAXIMUM_RESULTS || typeof value.truncated !== "boolean" ||
|
|
!Array.isArray(value.results) || value.results.length !== value.totalRowCount) return null;
|
|
|
|
let expected = null;
|
|
if (expectedRequest !== undefined) {
|
|
try {
|
|
expected = createExpertSearchRequest(
|
|
expectedRequest?.requestId,
|
|
expectedRequest?.query,
|
|
expectedRequest?.maximumResults);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const bound = expected.maximumResults ?? DEFAULT_MAXIMUM_RESULTS;
|
|
if (value.requestId !== expected.requestId || value.query !== expected.query ||
|
|
value.results.length > bound || (value.truncated && value.results.length !== bound)) return null;
|
|
}
|
|
|
|
const results = [];
|
|
const codes = new Set();
|
|
const names = new Set();
|
|
for (const raw of value.results) {
|
|
const expert = normalizeExpert(raw);
|
|
if (!expert || codes.has(expert.expertCode) || names.has(expert.expertName) ||
|
|
(results.length && compareExperts(results[results.length - 1], expert) > 0)) return null;
|
|
codes.add(expert.expertCode);
|
|
names.add(expert.expertName);
|
|
results.push(expert);
|
|
}
|
|
return Object.freeze({
|
|
requestId: value.requestId,
|
|
query: value.query,
|
|
retrievedAt: value.retrievedAt,
|
|
totalRowCount: value.totalRowCount,
|
|
truncated: value.truncated,
|
|
results: Object.freeze(results)
|
|
});
|
|
}
|
|
|
|
function normalizeExpertSearchError(value, expectedRequest) {
|
|
if (!hasExactKeys(value, ["requestId", "query", "message"]) ||
|
|
!isSafeRequestId(value.requestId) || normalizeQuery(value.query) !== value.query ||
|
|
!isSafeCanonicalText(value.message, 512)) return null;
|
|
if (expectedRequest) {
|
|
let expected;
|
|
try {
|
|
expected = createExpertSearchRequest(
|
|
expectedRequest.requestId,
|
|
expectedRequest.query,
|
|
expectedRequest.maximumResults);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (value.requestId !== expected.requestId || value.query !== expected.query) return null;
|
|
}
|
|
return Object.freeze({ requestId: value.requestId, query: value.query, message: value.message });
|
|
}
|
|
|
|
function createExpertPreviewRequest(requestId, expertValue, maximumItems) {
|
|
if (!isSafeRequestId(requestId)) {
|
|
throw new TypeError("A safe expert-preview request id is required.");
|
|
}
|
|
const expert = normalizeExpert(expertValue);
|
|
if (!expert) throw new TypeError("A typed Oracle expert is required.");
|
|
if (maximumItems !== undefined &&
|
|
(!Number.isInteger(maximumItems) || maximumItems < 1 || maximumItems > MAXIMUM_PREVIEW_ITEMS)) {
|
|
throw new RangeError(`Expert preview items must be limited from 1 through ${MAXIMUM_PREVIEW_ITEMS}.`);
|
|
}
|
|
const request = {
|
|
requestId,
|
|
expertCode: expert.expertCode,
|
|
expertName: expert.expertName
|
|
};
|
|
if (maximumItems !== undefined) request.maximumItems = maximumItems;
|
|
return Object.freeze(request);
|
|
}
|
|
|
|
function normalizeRecommendation(value) {
|
|
if (!hasExactKeys(value, ["playIndex", "stockCode", "stockName", "buyAmount"]) ||
|
|
!Number.isInteger(value.playIndex) || value.playIndex < 0 || value.playIndex > 9999 ||
|
|
typeof value.stockCode !== "string" || !stockCodePattern.test(value.stockCode) ||
|
|
!isSafeCanonicalText(value.stockName, 200) ||
|
|
!Number.isSafeInteger(value.buyAmount) || value.buyAmount <= 0) return null;
|
|
return Object.freeze({
|
|
playIndex: value.playIndex,
|
|
stockCode: value.stockCode,
|
|
stockName: value.stockName,
|
|
buyAmount: value.buyAmount
|
|
});
|
|
}
|
|
|
|
function normalizeExpertPreviewResponse(value, expectedRequest) {
|
|
if (!hasExactKeys(value, [
|
|
"requestId", "selection", "retrievedAt", "totalRowCount", "truncated", "items"
|
|
]) || !isSafeRequestId(value.requestId) ||
|
|
!isSafeCanonicalText(value.retrievedAt, 64) || !Number.isFinite(Date.parse(value.retrievedAt)) ||
|
|
!Number.isInteger(value.totalRowCount) || value.totalRowCount < 0 ||
|
|
value.totalRowCount > MAXIMUM_PREVIEW_ITEMS || typeof value.truncated !== "boolean" ||
|
|
!Array.isArray(value.items) || value.items.length !== value.totalRowCount) return null;
|
|
const selection = normalizeExpertIdentity(value.selection);
|
|
if (!selection) return null;
|
|
|
|
if (expectedRequest !== undefined) {
|
|
let expected;
|
|
try {
|
|
expected = createExpertPreviewRequest(
|
|
expectedRequest?.requestId,
|
|
{
|
|
expertCode: expectedRequest?.expertCode,
|
|
expertName: expectedRequest?.expertName,
|
|
source: "oracle"
|
|
},
|
|
expectedRequest?.maximumItems);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const bound = expected.maximumItems ?? DEFAULT_MAXIMUM_PREVIEW_ITEMS;
|
|
if (value.requestId !== expected.requestId ||
|
|
selection.expertCode !== expected.expertCode ||
|
|
selection.expertName !== expected.expertName ||
|
|
value.items.length > bound || (value.truncated && value.items.length !== bound)) return null;
|
|
}
|
|
|
|
const items = [];
|
|
const indexes = new Set();
|
|
const codes = new Set();
|
|
const names = new Set();
|
|
for (const raw of value.items) {
|
|
const item = normalizeRecommendation(raw);
|
|
if (!item || indexes.has(item.playIndex) || codes.has(item.stockCode) || names.has(item.stockName) ||
|
|
(items.length && items[items.length - 1].playIndex >= item.playIndex)) return null;
|
|
indexes.add(item.playIndex);
|
|
codes.add(item.stockCode);
|
|
names.add(item.stockName);
|
|
items.push(item);
|
|
}
|
|
const result = Object.freeze({
|
|
requestId: value.requestId,
|
|
selection,
|
|
retrievedAt: value.retrievedAt,
|
|
totalRowCount: value.totalRowCount,
|
|
truncated: value.truncated,
|
|
items: Object.freeze(items)
|
|
});
|
|
trustedPreviewResponses.add(result);
|
|
return result;
|
|
}
|
|
|
|
function normalizeExpertPreviewError(value, expectedRequest) {
|
|
if (!hasExactKeys(value, ["requestId", "selection", "message"]) ||
|
|
!isSafeRequestId(value.requestId) || !isSafeCanonicalText(value.message, 512)) return null;
|
|
const selection = normalizeExpertIdentity(value.selection);
|
|
if (!selection) return null;
|
|
if (expectedRequest) {
|
|
let expected;
|
|
try {
|
|
expected = createExpertPreviewRequest(
|
|
expectedRequest.requestId,
|
|
{
|
|
expertCode: expectedRequest.expertCode,
|
|
expertName: expectedRequest.expertName,
|
|
source: "oracle"
|
|
},
|
|
expectedRequest.maximumItems);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (value.requestId !== expected.requestId ||
|
|
selection.expertCode !== expected.expertCode ||
|
|
selection.expertName !== expected.expertName) return null;
|
|
}
|
|
return Object.freeze({ requestId: value.requestId, selection, message: value.message });
|
|
}
|
|
|
|
function createSelectableExpert(expertValue, previewValue) {
|
|
const expert = normalizeExpert(expertValue);
|
|
if (!expert || !previewValue || typeof previewValue !== "object" ||
|
|
!trustedPreviewResponses.has(previewValue) ||
|
|
!normalizeExpertIdentity(previewValue.selection) ||
|
|
previewValue.selection.expertCode !== expert.expertCode ||
|
|
previewValue.selection.expertName !== expert.expertName ||
|
|
!Number.isInteger(previewValue.totalRowCount) || previewValue.totalRowCount < 0 ||
|
|
previewValue.totalRowCount > MAXIMUM_PREVIEW_ITEMS ||
|
|
typeof previewValue.truncated !== "boolean") return null;
|
|
return Object.freeze({
|
|
...expert,
|
|
itemCount: previewValue.totalRowCount,
|
|
previewTruncated: previewValue.truncated
|
|
});
|
|
}
|
|
|
|
function normalizeSelectableExpert(value) {
|
|
if (!hasExactKeys(value, [
|
|
"expertCode", "expertName", "source", "itemCount", "previewTruncated"
|
|
]) || !Number.isInteger(value.itemCount) || value.itemCount < 0 ||
|
|
value.itemCount > MAXIMUM_PREVIEW_ITEMS || typeof value.previewTruncated !== "boolean") return null;
|
|
const expert = normalizeExpert({
|
|
expertCode: value.expertCode,
|
|
expertName: value.expertName,
|
|
source: value.source
|
|
});
|
|
return expert && Object.freeze({
|
|
...expert,
|
|
itemCount: value.itemCount,
|
|
previewTruncated: value.previewTruncated
|
|
});
|
|
}
|
|
|
|
function calculatePagePreview(expertValue) {
|
|
const expert = normalizeSelectableExpert(expertValue);
|
|
if (!expert) return null;
|
|
return Object.freeze({
|
|
itemCount: expert.itemCount,
|
|
accessibleItemCount: expert.itemCount,
|
|
pageSize: PAGE_SIZE,
|
|
pageCount: Math.ceil(expert.itemCount / PAGE_SIZE),
|
|
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
|
isTruncated: expert.previewTruncated
|
|
});
|
|
}
|
|
|
|
function buildExpertSelection(expertValue) {
|
|
const expert = normalizeSelectableExpert(expertValue);
|
|
if (!expert) throw new TypeError("A previewed expert selection is required.");
|
|
return Object.freeze({
|
|
builderKey: action.builderKey,
|
|
code: action.code,
|
|
aliases: action.aliases,
|
|
selection: Object.freeze({
|
|
groupCode: "PAGED_EXPERT",
|
|
subject: expert.expertName,
|
|
graphicType: "INPUT_ORDER",
|
|
subtype: "CURRENT",
|
|
dataCode: expert.expertCode
|
|
})
|
|
});
|
|
}
|
|
|
|
function requireOptions(options) {
|
|
if (!hasOnlyKeys(options, ["id", "fadeDuration"]) || !isSafeRequestId(options.id)) {
|
|
throw new TypeError("Safe expert playlist options are required.");
|
|
}
|
|
const fadeDuration = options.fadeDuration === undefined
|
|
? DEFAULT_FADE_DURATION
|
|
: options.fadeDuration;
|
|
if (!Number.isInteger(fadeDuration) || fadeDuration < 0 || fadeDuration > 60) {
|
|
throw new RangeError("Fade duration must be an integer from 0 through 60.");
|
|
}
|
|
return Object.freeze({ id: options.id, fadeDuration });
|
|
}
|
|
|
|
function createExpertPlaylistEntry(expertValue, options) {
|
|
const expert = normalizeSelectableExpert(expertValue);
|
|
if (!expert) throw new TypeError("A previewed expert selection is required.");
|
|
if (expert.itemCount === 0) throw new RangeError("An expert with no recommendation rows cannot create a cut.");
|
|
const normalizedOptions = requireOptions(options);
|
|
const mapping = buildExpertSelection(expert);
|
|
const pagePreview = calculatePagePreview(expert);
|
|
return {
|
|
id: normalizedOptions.id,
|
|
builderKey: mapping.builderKey,
|
|
code: mapping.code,
|
|
aliases: [...mapping.aliases],
|
|
title: expert.expertName,
|
|
detail: action.label,
|
|
category: "expert",
|
|
source: expert.source,
|
|
expertCode: expert.expertCode,
|
|
expertName: expert.expertName,
|
|
itemCount: expert.itemCount,
|
|
previewTruncated: expert.previewTruncated,
|
|
pageSize: pagePreview.pageSize,
|
|
pageCount: pagePreview.pageCount,
|
|
selection: mapping.selection,
|
|
enabled: true,
|
|
fadeDuration: normalizedOptions.fadeDuration,
|
|
graphicType: mapping.selection.graphicType,
|
|
subtype: mapping.selection.subtype,
|
|
operator: Object.freeze({
|
|
source: "legacy-expert-workflow",
|
|
schemaVersion: SCHEMA_VERSION,
|
|
actionId: action.id,
|
|
expert,
|
|
pagePreview
|
|
})
|
|
};
|
|
}
|
|
|
|
function sameStringArray(left, right) {
|
|
return Array.isArray(left) && Array.isArray(right) && left.length === right.length &&
|
|
left.every((value, index) => value === right[index]);
|
|
}
|
|
|
|
function sameSelection(left, right) {
|
|
return left && right && ["groupCode", "subject", "graphicType", "subtype", "dataCode"]
|
|
.every(key => typeof left[key] === "string" && left[key] === right[key]);
|
|
}
|
|
|
|
function samePagePreview(left, right) {
|
|
return left && right && [
|
|
"itemCount", "accessibleItemCount", "pageSize", "pageCount",
|
|
"maximumPageCount", "isTruncated"
|
|
].every(key => left[key] === right[key]);
|
|
}
|
|
|
|
function restoreExpertPlaylistEntry(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
typeof value.enabled !== "boolean") return null;
|
|
const operator = value.operator;
|
|
if (!hasExactKeys(operator, ["source", "schemaVersion", "actionId", "expert", "pagePreview"]) ||
|
|
operator.source !== "legacy-expert-workflow" || operator.schemaVersion !== SCHEMA_VERSION ||
|
|
operator.actionId !== action.id || !normalizeSelectableExpert(operator.expert)) return null;
|
|
|
|
let recreated;
|
|
try {
|
|
recreated = createExpertPlaylistEntry(operator.expert, {
|
|
id: value.id,
|
|
fadeDuration: value.fadeDuration
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
const scalars = [
|
|
"id", "builderKey", "code", "title", "detail", "category", "source",
|
|
"expertCode", "expertName", "itemCount", "previewTruncated", "pageSize",
|
|
"pageCount", "fadeDuration", "graphicType", "subtype"
|
|
];
|
|
if (!scalars.every(key => value[key] === recreated[key]) ||
|
|
!sameStringArray(value.aliases, recreated.aliases) ||
|
|
!sameSelection(value.selection, recreated.selection) ||
|
|
!samePagePreview(operator.pagePreview, recreated.operator.pagePreview)) return null;
|
|
return { ...recreated, enabled: value.enabled };
|
|
}
|
|
|
|
return {
|
|
bridgeContract,
|
|
sourceContract,
|
|
actions,
|
|
normalizeExpertIdentity,
|
|
normalizeExpert,
|
|
createExpertSearchRequest,
|
|
normalizeExpertSearchResponse,
|
|
normalizeExpertSearchError,
|
|
createExpertPreviewRequest,
|
|
normalizeRecommendation,
|
|
normalizeExpertPreviewResponse,
|
|
normalizeExpertPreviewError,
|
|
createSelectableExpert,
|
|
calculatePagePreview,
|
|
buildExpertSelection,
|
|
createExpertPlaylistEntry,
|
|
restoreExpertPlaylistEntry,
|
|
refreshExpertPlaylistEntry: restoreExpertPlaylistEntry
|
|
};
|
|
});
|