отказ от лишнего реста

This commit is contained in:
gorbenko.pavel
2026-08-21 14:47:03 +05:00
parent b75a0758e7
commit 7f08a819a9
2 changed files with 191 additions and 142 deletions
@@ -49,10 +49,10 @@ const SEARCH_PAGE_SIZE = 500;
const SEARCH_HARD_CAP = 5000;
// Сколько Id максимум перечисляем в одном REST $filter (`Id eq 1 or Id eq 2…`)
// при дочитывании полей по найденным через Search элементам (см. loadByIds).
// SharePoint REST не резиновый по длине URL — несколько сотен ИЛИ уже рискуют
// упереться в лимит, поэтому набор Id одного типа режется на такие пачки,
// а не уходит одним запросом целиком.
// при дочитывании строк маршрута/заявки по найденным через Search Id (см.
// finalizeApprovalTarget). SharePoint REST не резиновый по длине URL —
// несколько сотен ИЛИ уже рискуют упереться в лимит, поэтому набор Id одного
// типа режется на такие пачки, а не уходит одним запросом целиком.
const REST_ID_FILTER_CHUNK = 100;
// Сколько последних заявок автора дочитываем через REST в довесок к Search
@@ -83,6 +83,42 @@ const MP_STATUS = 'ApprovalStatusOWSCHCS';
// SharePoint — сама схема поиска исправна.
const MP_REQUEST_STATUS = 'RequestStatusOWSCHCS';
// Автор заявки (Person-поле Author списка ДАННЫХ) — суффикс OWSUSER, без
// префикса группы колонок (поле стандартное, не сайтовое, как и RequestStatus
// выше). Значение — «<логин> | <ФИО>» (см. parseAuthorOwsUser). Уже
// использовался в KQL (AuthorOWSUSER:"...") для отбора «своих» заявок; здесь
// то же свойство дочитывается из ответа Search вместо REST.
const MP_AUTHOR = 'AuthorOWSUSER';
// Уникальный код заявки (колонка RequestUniqueCode) — суффикс OWSTEXT, тип
// исходного поля Text.
const MP_CODE = 'RequestUniqueCodeOWSTEXT';
// Поля заявки, отдаваемые ПРЯМО индексом, без REST-дочитывания после Search —
// подтверждено вручную (tools/sp props, 2026-08-20): Queryable ≠ Retrievable у
// managed property, поэтому то, что раньше проверялось только на фильтрацию
// (см. комментарий у MP_REQUEST_STATUS), пришлось перепроверять отдельно на
// то, что значение вообще приходит в ответе. Пришло — все поля, что нужны для
// строки таблицы «Мои заявки» (title/created — стандартные MP 'Title'/'Created',
// их так же безусловно отдаёт любой список). 'Author' (без суффикса) отдаёт
// готовое отображаемое имя — короче и надёжнее выковыривать его из
// MP_AUTHOR/parseAuthorOwsUser, там он нужен только ради почты.
const OWN_SELECT_PROPERTIES = ['ListItemID', 'Path', 'Title', 'Created', 'Author', MP_AUTHOR, MP_REQUEST_STATUS, MP_CODE];
/**
* Managed property Person-поля хранит и логин, и ФИО одной строкой:
* «<логин> | <ФИО>» (проверено вручную, tools/sp props, 2026-08-20). Разделитель
* — именно " | " С ПРОБЕЛАМИ: сам логин при claims-аутентификации выглядит как
* `i:0#.f|membership|user@domain` и содержит "|" без пробелов вокруг, поэтому
* делить по ПЕРВОМУ "|" нельзя — часть логина отрезалась бы вместе с почтой.
*/
function parseAuthorOwsUser(value: unknown): { email: string; name: string } {
const text = typeof value === 'string' ? value : '';
const sep = text.indexOf(' | ');
if (sep < 0) return { email: text.trim(), name: '' };
return { email: text.slice(0, sep).trim(), name: text.slice(sep + 3).trim() };
}
// effectiveUserEmail, а не pageContext напрямую: при отладочной подмене личности
// (см. shared/impersonation.ts) «мои заявки» и очереди согласования должны
// отбираться по подменённому сотруднику — иначе подмена показывала бы чужой
@@ -687,8 +723,7 @@ async function finalizeApprovalTarget(
? []
: (
await Promise.all(
// Id режем на пачки — тот же предел длины URL REST $filter, что и в
// loadByIds (см. REST_ID_FILTER_CHUNK).
// Id режем на пачки — предел длины URL REST $filter (см. REST_ID_FILTER_CHUNK).
chunk(searchHitIds, REST_ID_FILTER_CHUNK).map((idsChunk) =>
approvalList.items
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
@@ -762,11 +797,12 @@ async function finalizeApprovalTarget(
// по переданным строкам. Мутирует переданные объекты (проставляет
// authorEmail/authorName).
//
// Строки, у которых автор УЖЕ известен, пропускаются: и 'own' (loadByIds), и
// 'approval' (finalizeApprovalTarget) теперь получают его тем же запросом, что
// и остальные поля, поэтому на обычных путях сюда доходить нечему и запрос не
// уходит вовсе. Функция оставлена страховкой на случай источника строк без
// автора — молча показывать пустого инициатора хуже, чем один лишний запрос.
// Строки, у которых автор УЖЕ известен, пропускаются: 'own' получает его прямо
// из ответа Search (см. toMyRequestFromSearch), 'approval' (finalizeApprovalTarget)
// — тем же REST-запросом, что и остальные поля, поэтому на обычных путях сюда
// доходить нечему и запрос не уходит вовсе. Функция оставлена страховкой на
// случай источника строк без автора — молча показывать пустого инициатора
// хуже, чем один лишний запрос.
async function hydrateAuthors(
context: WebPartContext,
types: IRequestType[],
@@ -1058,7 +1094,7 @@ function hitsToEntries(
): ISearchEntry[] {
const seen = new Set<string>();
return (results.PrimarySearchResults as Array<Record<string, unknown>>)
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || '') }))
.map((r) => ({ id: Number(r.ListItemID), path: String(r.Path || ''), raw: r }))
.filter((h) => !isNaN(h.id) && h.id > 0)
.map((h) => {
// Сравнение по границе сегмента (см. listPathPrefix): при «голом»
@@ -1068,7 +1104,7 @@ function hitsToEntries(
// из того же списка, где его нашёл Search (см. targetForPath).
for (const type of types) {
const target = targetForPath(h.path, type);
if (target) return { id: h.id, type, target };
if (target) return { id: h.id, type, target, raw: h.raw };
}
return undefined;
})
@@ -1081,72 +1117,50 @@ function hitsToEntries(
});
}
/** Найденный хит: Id, его тип и партиция, в которой он лежит. */
/**
* Найденный хит: Id, его тип, партиция, в которой он лежит, и СЫРАЯ строка
* ответа Search — она же несёт остальные поля заявки (см. OWN_SELECT_PROPERTIES
* и toMyRequestFromSearch), дочитывать их REST-ом больше не нужно.
*/
interface ISearchEntry {
id: number;
type: IRequestType;
target: IListTarget;
raw: Record<string, unknown>;
}
// Дочитать поля заявок по найденным Id (Search отдаёт не все колонки, и для
// кастомных полей нет гарантии, что они проиндексированы как managed property).
//
// Id одного типа режем на пачки REST_ID_FILTER_CHUNK: теперь, когда Search
// собирает результат целиком (см. fetchAllSearchRows), их может набраться
// заметно больше сотни — один $filter из тысяч «Id eq N or …» упёрся бы в
// предел длины URL SharePoint REST.
async function loadByIds(
context: WebPartContext,
entries: ISearchEntry[],
kind: 'own' | 'approval'
): Promise<Map<string, IMyRequest>> {
// Группируем по ПАРТИЦИИ, а не по типу: Id из архивной партиции в текущем
// списке либо не найдётся, либо укажет на чужую заявку с тем же Id.
const byPartition = new Map<string, { type: IRequestType; target: IListTarget; ids: number[] }>();
entries.forEach((e) => {
const key = e.target.listId.toLowerCase();
const bucket = byPartition.get(key) || { type: e.type, target: e.target, ids: [] };
bucket.ids.push(e.id);
byPartition.set(key, bucket);
});
const byKey = new Map<string, IMyRequest>();
await Promise.all(
Array.from(byPartition.values()).map(async ({ type, target, ids }) => {
// Ошибку ловим НА ПАРТИЦИЮ, а не на весь вызов: список серии может быть
// недоступен (удалён, переименован, остался в реестре после смены списка у
// типа заявки) — SharePoint отвечает 404 «Файл не найден». Без изоляции
// один такой список валил бы ВСЮ выдачу «Моих заявок», и пользователь не
// увидел бы ничего вместо «всё, кроме одного архива».
try {
const sp = spForSite(target.siteUrl, context);
const list = sp.web.getList(target.listId);
await Promise.all(
chunk(ids, REST_ID_FILTER_CHUNK).map(async (idsChunk) => {
const rows = (await list.items
.filter(idsChunk.map((id) => `Id eq ${id}`).join(' or '))
.select('Id', 'RequestUniqueCode', 'Title', 'RequestStatus', 'Created', 'Author/EMail', 'Author/Title')
.expand('Author')
.top(idsChunk.length)()) as Array<Record<string, unknown>>;
rows.forEach((r) => {
const author = r.Author as { EMail?: string; Title?: string } | undefined;
byKey.set(
requestKey(target.listId, r.Id as number),
toMyRequest(r, author, type, kind, target)
);
});
})
);
} catch (e) {
console.error(
`[Текущие заявки] «${type.name}»: не удалось прочитать список ${target.listId} — заявки из него в выдачу не попадут:`,
(e as Error).message
);
}
})
);
return byKey;
/**
* Строка «Моих заявок» ПРЯМО из ответа Search — без второго прохода по REST.
*
* Раньше здесь был REST-запрос по найденным Id (loadByIds): считалось, что
* поисковый индекс отдаёт не все колонки. Проверено вручную (tools/sp props,
* 2026-08-20) — отдаёт все нужные, см. OWN_SELECT_PROPERTIES. Оставался вопрос
* СВЕЖЕСТИ: значения теперь из индекса, который переиндексируется с задержкой,
* а не «только что созданный элемент» напрямую. Для 'own' это уже было принятым
* компромиссом — searchOwnRequests и раньше не подмешивал REST-подстраховку
* (в отличие от getOwnRequests, где её явно добавляет getFreshOwnRowsForType).
*/
function toMyRequestFromSearch(entry: ISearchEntry): IMyRequest {
const r = entry.raw;
const author = parseAuthorOwsUser(r[MP_AUTHOR]);
return {
id: entry.id,
code: (r[MP_CODE] as string) || '',
title: (r.Title as string) || '',
status: (r[MP_REQUEST_STATUS] as string) || '',
authorEmail: author.email,
// 'Author' без суффикса — готовое отображаемое имя (см. OWN_SELECT_PROPERTIES);
// из MP_AUTHOR парсим только как запасной вариант, если 'Author' пуст.
authorName: ((r.Author as string) || author.name || '').trim(),
created: (r.Created as string) || '',
requestTypeId: entry.type.id,
requestTypeName: entry.type.name,
section: entry.type.section,
formKey: entry.type.pageUrl,
kind: 'own',
listId: entry.target.listId,
siteUrl: entry.target.siteUrl,
};
}
// Последние OWN_REST_TOP заявок автора одного типа обычным REST-ом, в обход
@@ -1287,7 +1301,7 @@ export async function searchOwnRequests(
const clauses = [
pathClauseForTypes(types),
`AuthorOWSUSER:"${claimsLogin}"`,
`${MP_AUTHOR}:"${claimsLogin}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
@@ -1298,7 +1312,7 @@ export async function searchOwnRequests(
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: ['ListItemID', 'Title', 'Path'],
SelectProperties: OWN_SELECT_PROPERTIES,
TrimDuplicates: false,
// Свежие сверху (по умолчанию Search сортирует по релевантности, не по дате).
SortList: [{ Property: 'Created', Direction: 1 }],
@@ -1306,13 +1320,9 @@ export async function searchOwnRequests(
);
const totalRows = results.TotalRows || 0;
const entries = hitsToEntries(results, types);
const byKey =
entries.length > 0
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
: new Map<string, IMyRequest>();
// Порядок задаёт релевантность поиска, а не порядок ответов REST.
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
// Поля — прямо из ответа индекса (см. toMyRequestFromSearch), REST-дочитывания
// здесь больше нет. Порядок задаёт релевантность поиска.
const rows = hitsToEntries(results, types).map(toMyRequestFromSearch);
return { rows, totalRows };
}
@@ -1331,16 +1341,18 @@ export async function getOwnRequestsByStatus(
page: number,
query: string = ''
): Promise<IOwnRequestsPage> {
return ownRequestsByStatus(context, types, status, page, query, true);
return ownRequestsByStatus(context, types, status, page, query);
}
/**
* Только СКОЛЬКО таких заявок — для счётчика «Доработка» в боковом меню.
*
* Отличие от getOwnRequestsByStatus одно: не дочитываются поля найденных заявок
* (loadByIds). Счётчику они не нужны — ему нужен totalRows, — а замер 2026-08-20
* показал, что этот шаг стоит ~200-300 мс и идёт СТРОГО ПОСЛЕ Search, то есть
* целиком лежит на критическом пути открытия портала.
* До 2026-08-20 у этого была отдельная REST-развилка (withRows): дочитывание
* полей найденных заявок стоило отдельного запроса, и счётчику, которому нужен
* только totalRows, было жаль на него тратиться. Теперь поля идут прямо из
* ответа Search (см. toMyRequestFromSearch) — построить rows настолько же
* дёшево, насколько и посчитать totalRows, отдельная ветка была бы одной лишь
* сложностью без выигрыша.
*
* Условия отбора и довесок по свежести — те же самые, поэтому счётчик и вкладка
* по-прежнему не расходятся (ради чего они и считаются одним кодом).
@@ -1350,7 +1362,7 @@ export async function countOwnRequestsByStatus(
types: IRequestType[],
status: string
): Promise<number> {
return (await ownRequestsByStatus(context, types, status, 1, '', false)).totalRows;
return (await ownRequestsByStatus(context, types, status, 1, '')).totalRows;
}
async function ownRequestsByStatus(
@@ -1358,8 +1370,7 @@ async function ownRequestsByStatus(
types: IRequestType[],
status: string,
page: number,
query: string,
withRows: boolean
query: string
): Promise<IOwnRequestsPage> {
if (types.length === 0) return { rows: [], totalRows: 0 };
@@ -1367,7 +1378,7 @@ async function ownRequestsByStatus(
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
pathClauseForTypes(types),
`AuthorOWSUSER:"${claimsLogin}"`,
`${MP_AUTHOR}:"${claimsLogin}"`,
`${MP_REQUEST_STATUS}:"${status}"`,
];
const text = freeTextClause(query);
@@ -1379,7 +1390,7 @@ async function ownRequestsByStatus(
Querytext: kql,
RowLimit: PAGE_SIZE,
StartRow: (page - 1) * PAGE_SIZE,
SelectProperties: ['ListItemID', 'Title', 'Path'],
SelectProperties: OWN_SELECT_PROPERTIES,
TrimDuplicates: false,
// Свежие сверху — как в searchOwnRequests.
SortList: [{ Property: 'Created', Direction: 1 }],
@@ -1388,11 +1399,7 @@ async function ownRequestsByStatus(
const totalRows = results.TotalRows || 0;
const entries = hitsToEntries(results, types);
const byKey =
withRows && entries.length > 0
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
: new Map<string, IMyRequest>();
const searchRows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
const searchRows = entries.map(toMyRequestFromSearch);
if (page !== 1 || query.trim()) return { rows: searchRows, totalRows };
@@ -1407,9 +1414,6 @@ async function ownRequestsByStatus(
.reduce((acc, r) => acc.concat(r), [] as IMyRequest[])
.filter((r) => r.status === status);
// Ключи для дедупа берём из ХИТОВ индекса, а не из дочитанных строк: пара
// «партиция + Id» известна уже по ответу Search, и режиму счётчика (withRows
// = false) не приходится ради этого дочитывать поля.
const searchKeys = new Set(entries.map((e) => requestKey(e.target.listId, e.id)));
const onlyFresh = freshRows.filter((r) => !searchKeys.has(rowKey(r)));
@@ -1616,24 +1620,19 @@ export async function searchOwnAll(
const claimsLogin = `i:0#.f|membership|${currentUserEmail(context)}`;
const clauses = [
pathClauseForTypes(types),
`AuthorOWSUSER:"${claimsLogin}"`,
`${MP_AUTHOR}:"${claimsLogin}"`,
];
const text = freeTextClause(query);
if (text) clauses.push(text);
const kql = clauses.join(' AND ');
const hits = await fetchAllSearchRows(sp, kql, ['ListItemID', 'Title', 'Path'], 'Свои заявки');
const hits = await fetchAllSearchRows(sp, kql, OWN_SELECT_PROPERTIES, 'Свои заявки');
const results = { PrimarySearchResults: hits, TotalRows: hits.length };
traceSearchHits('Свои заявки', kql, types, results, listAbsUrl);
const entries = hitsToEntries(results, types);
const byKey =
entries.length > 0
? await traced('дочитывание полей по Id (REST)', () => loadByIds(context, entries, 'own'))
: new Map<string, IMyRequest>();
const rows = entries.map((e) => byKey.get(requestKey(e.target.listId, e.id))).filter((r): r is IMyRequest => !!r);
const rows = entries.map(toMyRequestFromSearch);
traceResultRows('Свои заявки', types, entries, rows);
return rows;