import { $fetch } from 'ofetch' import { defineEventHandler, getQuery } from 'h3' import { useRuntimeConfig } from '#imports' // 注意:本文件在 server/api/ 根下(比 server/api/case/list.get.ts 浅一层), // 故 utils 的相对路径是 ../utils/,不是 ../../utils/。 import { getTenantFromContext } from '../utils/tenant' import { normalizeCaseItem } from '../utils/case' /** * 统一搜索(文章 / 案例 / 标书) * GET /api/search?keywords=xxx * * 一次请求并发查询三个上游,归一化为统一结构: * { * article: { list, count } | { error: true }, * case: { list, count } | { error: true }, * tender: { list, count } | { error: true } * } * * 设计要点(见 .scratch/web-unified-search/issues/02-aggregation-strategy.md): * - 每类固定 limit=10,不做分页;结果页一次拿全,用户想看更多去各自模块页。 * - 用 Promise.allSettled(不是 all):某类上游失败时**只降级那一类**,其余照常返回, * 整页不 500。失败的那类返 { error: true } 而非 { list: [] }——后者会让前端把 * 「接口故障」渲染成「暂无结果」,等于对用户说谎(前端据 error 标记区分文案)。 * - 不改动既有的 article/case/tender 三个 list 代理,它们有大量现有消费方。 * - status / deleted 是内容安全边界(防草稿、下架、软删内容泄露到 C 端), * 与现有三个代理保持完全一致的取值,不可省略。 */ /** 每类返回的条数上限(结果页一次性展示,不做分页) */ const SEARCH_LIMIT = 10 /** 单类结果:成功为 { list, count },失败为 { error: true } */ type SectionResult = { list: T[]; count: number } | { error: true } /** 从上游各种包装形态里取出 { list, count }(与 case/product/list 代理同一套兜底逻辑) */ function unwrapList(res: any): { list: any[]; count: number } { const envelopeData = (res && res.data !== undefined ? res.data : res) || {} const list = Array.isArray(envelopeData.list) ? envelopeData.list : [] const count = envelopeData.count ?? envelopeData.total ?? list.length return { list, count } } export default defineEventHandler(async (event) => { const config = useRuntimeConfig() const ctx = getTenantFromContext(event, config) const query = getQuery(event) const modulesApiBase = config.public.modulesApiBase as string const keywords = String(query.keywords ?? '').trim() // 空关键词不是错误:不请求上游,直接返回三组空结果。 // (避免无谓的上游调用,也让搜索页的「未输入关键词」态有确定的响应形状。) if (!keywords) { const empty: SectionResult = { list: [], count: 0 } return { article: empty, case: empty, tender: empty } } const commonHeaders = { TenantId: ctx.tenantId } // 三个上游并发;每个任务内部自己兜底成 SectionResult,故 allSettled 的 rejected 分支 // 实际只会在「连 try/catch 都没兜住」的意外情况下触发,仍按 error 处理,不抛给调用方。 const [article, caseRes, tender] = await Promise.allSettled([ // 文章:/cms/cms-article/page // status 0=已发布 1=草稿 2=下架;deleted=0 排除软删。放在 keywords 之后覆盖,防前端传入。 $fetch('/cms/cms-article/page', { baseURL: modulesApiBase, headers: commonHeaders, query: { keywords, page: 1, limit: SEARCH_LIMIT, status: 0, deleted: 0 } }), // 案例:/cms/cms-case/page(注意是 cms-case 控制器,不是 cms-website 兜底路由) $fetch('/cms/cms-case/page', { baseURL: modulesApiBase, headers: commonHeaders, query: { keywords, page: 1, limit: SEARCH_LIMIT, TenantId: ctx.tenantId, status: 0, deleted: 0 } }), // 标书:/hjc/bid-project/list,status=1 只搜在售(与 /tender 列表口径一致)。 // 上游 XML 已写死 deleted=0,无需也不应再传 deleted(HjcBidProjectParam 上无该字段)。 $fetch('/hjc/bid-project/list', { baseURL: modulesApiBase, headers: commonHeaders, query: { keywords, page: 1, limit: SEARCH_LIMIT, status: 1, TenantId: ctx.tenantId } }) ]) const section = (settled: PromiseSettledResult, map: (raw: any) => T): SectionResult => { if (settled.status === 'rejected') { return { error: true } } const { list, count } = unwrapList(settled.value) return { list: list.map(map), count } } return { // 文章:前端直接消费上游字段(id/title/summary/publishTime/categoryName) article: section(article, (raw: any) => raw), // 案例:上游返回 caseId/caseName/customer/industry,模板按 id/title/clientName 渲染, // 复用 case/list 代理同款归一化,避免搜索页再写一份字段映射 case: section(caseRes, normalizeCaseItem), // 标书:前端直接消费上游字段(id/projectName/tenderPrice/deadlineTime/projectNo/tenderer) tender: section(tender, (raw: any) => raw) } })