diff --git a/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts b/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts index 9a059e5..ac46aae 100644 --- a/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts +++ b/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts @@ -9,6 +9,7 @@ import { ID_PREFIX, DATA_TYPE, ERR_CODE } from '../common/constants' import authz from '../middlewares/authz' import Service from '../common/service' import moment from 'moment' +import { annotate, buildTierClassifier, computeYpt, Tier } from '../common/material-ranking' // const PREDICT_FIELD: ((keyof PublicMaterial) | (keyof SelfMaterial))[] = [ // 'methanal', 'methanal_min_be', 'methanal_be_area', 'methanal_be_roc', @@ -21,6 +22,47 @@ import moment from 'moment' const materialRouter = new Router() materialRouter.prefix('/api/mtrl') +const BE_AREA_SELECT = { + methanal_be_area: true, tvoc_be_area: true, benzene_be_area: true, toluene_be_area: true, p_xylene_be_area: true +} as const + +// 查全库(公共+自建)be_area,建三分位分类器 +async function loadTierClassifier(): Promise<(ypt: number | null) => Tier | null> { + const [pub, self] = await Promise.all([ + db.publicMaterial.findMany({ select: BE_AREA_SELECT }), + db.selfMaterial.findMany({ select: BE_AREA_SELECT }) + ]) + return buildTierClassifier([...pub, ...self]) +} + +// 注解 + 排序(ypt / *_be_area, 恒升序; group_by_tier 时先按档) + 分页 +const TIER_ORDER: Record = { A: 0, B: 1, C: 2 } +function rankAndPaginate(materials: AnyObj[], query: AnyObj, classify: (ypt: number | null) => Tier | null): AnyObj[] { + let list: AnyObj[] = annotate(materials as never, classify) + const sk = query.sort_key as string | undefined + const group = query.group_by_tier == 1 || query.group_by_tier === true + if (sk || group) { + const tierRank = (t: Tier | null) => (t === null ? 3 : TIER_ORDER[t]) + list.sort((a, b) => { + if (group) { + const d = tierRank(a.health_tier) - tierRank(b.health_tier) + if (d !== 0) return d + } + const k = sk ?? 'ypt' + const av = a[k], bv = b[k] + // 升序;null 排最后 + if (av === null || av === undefined) return bv === null || bv === undefined ? 0 : 1 + if (bv === null || bv === undefined) return -1 + return av === bv ? 0 : av > bv ? 1 : -1 + }) + } + if (query.size) { + const page = query.page ?? 1 + list = list.slice((page - 1) * query.size, page * query.size) + } + return list +} + // 环保建材展示(公开,免认证):E0 优先,不足补 E1,最多 8 条 materialRouter.get('/eco', async ctx => { const take = 8 @@ -198,7 +240,7 @@ materialRouter.get( ] }, sort_key: { - enum: ['name', 'category', 'brand', 'factory', 'spec', 'eco_level', 'health_level', 'sort_order', 'methanal_be_area', 'tvoc_be_area', 'benzene_be_area', 'toluene_be_area', 'p_xylene_be_area', 'creator_name', 'created_at', 'updated_at'] + enum: ['name', 'category', 'brand', 'factory', 'spec', 'eco_level', 'health_level', 'sort_order', 'methanal_be_area', 'tvoc_be_area', 'benzene_be_area', 'toluene_be_area', 'p_xylene_be_area', 'creator_name', 'created_at', 'updated_at', 'ypt'] }, sort_val: { enum: ['asc', 'desc'] @@ -210,6 +252,10 @@ materialRouter.get( size: { type: 'number', minimum: 1 + }, + group_by_tier: { + type: 'number', + enum: [0, 1] } }, additionalProperties: false @@ -217,16 +263,20 @@ materialRouter.get( }, { coerceTypes: true }), async ctx => { const acctCert = ctx.state.acct_cert as AcctCert - const { type, collected, ...query } = ctx.state.query + // group_by_tier 不是 Prisma 列,需从传给 dynSearch 的 query 中剔除,否则 findMany 报未知字段 + const { type, collected, group_by_tier, ...query } = ctx.state.query let materials: AnyObj[] = [] // 查询公共材料 - let pubMtrlQty = 0 if (!type || type == DATA_TYPE.PUBLIC) { const conditions: AnyObj = dynSearch(query, { fuzzyFields: ['material_id', 'name', 'category', 'brand', 'factory', 'spec', 'creator_name'] }) + delete conditions.take; delete conditions.skip if (!query.sort_key) { conditions.orderBy = [{ sort_order: 'asc' }, { updated_at: 'desc' }] + } else if (query.sort_key === 'ypt') { + // ypt 是内存计算字段,非 Prisma 列,不能下推为 orderBy + delete conditions.orderBy } if (!isNone(collected) && !acctCert.is_admin) { if (!conditions.where) { @@ -243,11 +293,9 @@ materialRouter.get( } } const pubMtrls = await db.publicMaterial.findMany(conditions as never) - pubMtrlQty = pubMtrls.length materials.push(...pubMtrls) } // 查询自建材料 - let selfMtrlQty = 0 if (!type || type == DATA_TYPE.PRIVATE) { delete query.display delete query.from @@ -257,40 +305,19 @@ materialRouter.get( const selfConditions: AnyObj = dynSearch(query, { fuzzyFields: ['material_id', 'name', 'category', 'brand', 'factory', 'spec', 'creator_name'] }) + delete selfConditions.take; delete selfConditions.skip if (!query.sort_key) { selfConditions.orderBy = [{ sort_order: 'asc' }, { updated_at: 'desc' }] + } else if (query.sort_key === 'ypt') { + // ypt 是内存计算字段,非 Prisma 列,不能下推为 orderBy + delete selfConditions.orderBy } const selfMtrls = await db.selfMaterial.findMany(selfConditions as never) - selfMtrlQty = selfMtrls.length materials.push(...selfMtrls) } - // 排序和分页 - if (pubMtrlQty && selfMtrlQty) { - const sk = query.sort_key - const sv = query.sort_val == 'asc' ? 0 : 1 - materials.sort((a, b) => { - const keys = sk ? [sk] : ['sort_order', 'updated_at'] - for (const k of keys) { - const av = !sv ? a[k] : b[k] - const bv = sv ? a[k] : b[k] - const dir = sk ? (sv ? -1 : 1) : (k === 'updated_at' ? -1 : 1) - if (av && bv) { - if (av != bv) return av > bv ? dir : -dir - } else if (av && !bv) { - return dir - } else if (!av && bv) { - return -dir - } - } - return 0 - }) - if (query.size) { - const page = query.page ?? 1 - const size = query.size - materials = materials.slice((page - 1) * size, page * size) - } - } - ctx.body = materials + // 统一:注解 ypt/health_tier + 排序(含 ypt/分组) + 分页 + const classify = await loadTierClassifier() + ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }, classify) } )