From 3da1fb12825ef5a6eccb3790ea799a82f9b7c6c3 Mon Sep 17 00:00:00 2001 From: zty Date: Sun, 12 Jul 2026 21:48:08 -0400 Subject: [PATCH] =?UTF-8?q?refactor(material):=20=E5=81=A5=E5=BA=B7?= =?UTF-8?q?=E6=A1=A3=E5=AE=9A=E6=A0=BC=E5=88=B0=20health=5Flevel,=20?= =?UTF-8?q?=E5=8E=BB=E6=8E=89=E5=AE=9E=E6=97=B6=20health=5Ftier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增一次性迁移脚本 migrate-health-level.ts: 按当前全库三分位写入 health_level(覆盖手动值, 公共材料补齐) - rankAndPaginate 改为只注解 ypt(排序用), 分组改按持久化 health_level; 移除 loadTierClassifier - annotate -> annotateYpt; 单测同步更新 --- .../iapip-svr/scripts/migrate-health-level.ts | 56 +++++++++++++++++++ .../iapip-svr/src/common/material-ranking.ts | 15 ++--- .../iapip-svr/src/controllers/material.ts | 37 ++++-------- .../iapip-svr/test/material-ranking.test.ts | 17 +++--- 4 files changed, 83 insertions(+), 42 deletions(-) create mode 100644 空气质量预测/源码/服务端/iapip-svr/scripts/migrate-health-level.ts diff --git a/空气质量预测/源码/服务端/iapip-svr/scripts/migrate-health-level.ts b/空气质量预测/源码/服务端/iapip-svr/scripts/migrate-health-level.ts new file mode 100644 index 0000000..5f124c5 --- /dev/null +++ b/空气质量预测/源码/服务端/iapip-svr/scripts/migrate-health-level.ts @@ -0,0 +1,56 @@ +/** + * 一次性迁移:把「当前全库 Ypt 三分位」定格写入每个材料的 health_level 字段。 + * + * - 公共 + 自建材料合为一个总体计算三分位(与线上分档口径一致) + * - 数据完整的材料写入 A/B/C;任一 be_area 缺失的材料写入 null("—") + * - 覆盖自建材料原有的手动 health_level,并给公共材料补上 + * - 幂等:可重复运行(每次按当前数据重新定格) + * + * 运行(本地):npx dotenv -e .env.development -- npx ts-node scripts/migrate-health-level.ts + * 运行(线上 iapips2):npx dotenv -e .env -- npx ts-node scripts/migrate-health-level.ts + * (DATABASE_URL 由 dotenv 注入;确认指向目标库后再执行) + */ +import { PrismaClient } from '@prisma/client' +import { computeYpt, buildTierClassifier } from '../src/common/material-ranking' + +const db = new PrismaClient() + +const BE_SELECT = { + methanal_be_area: true, + tvoc_be_area: true, + benzene_be_area: true, + toluene_be_area: true, + p_xylene_be_area: true +} as const + +async function main() { + // SelfMaterial 主键是复合键 [material_id, creator_id],需一并取出 + const [pub, self] = await Promise.all([ + db.publicMaterial.findMany({ select: { material_id: true, ...BE_SELECT } }), + db.selfMaterial.findMany({ select: { material_id: true, creator_id: true, ...BE_SELECT } }) + ]) + const classify = buildTierClassifier([...pub, ...self]) + + const dist: Record = { A: 0, B: 0, C: 0, 'null': 0 } + + for (const m of pub) { + const tier = classify(computeYpt(m)) + dist[tier ?? 'null']++ + await db.publicMaterial.update({ where: { material_id: m.material_id }, data: { health_level: tier } }) + } + for (const m of self) { + const tier = classify(computeYpt(m)) + dist[tier ?? 'null']++ + await db.selfMaterial.updateMany({ where: { material_id: m.material_id, creator_id: m.creator_id }, data: { health_level: tier } }) + } + + console.log(`migrate-health-level done: public=${pub.length}, self=${self.length}`) + console.log(`分档分布 -> A:${dist.A} B:${dist.B} C:${dist.C} 缺值(null):${dist['null']}`) +} + +main() + .catch(e => { + console.error('migrate failed:', e) + process.exitCode = 1 + }) + .finally(() => db.$disconnect()) diff --git a/空气质量预测/源码/服务端/iapip-svr/src/common/material-ranking.ts b/空气质量预测/源码/服务端/iapip-svr/src/common/material-ranking.ts index e653195..eb24476 100644 --- a/空气质量预测/源码/服务端/iapip-svr/src/common/material-ranking.ts +++ b/空气质量预测/源码/服务端/iapip-svr/src/common/material-ranking.ts @@ -58,13 +58,10 @@ export function buildTierClassifier(all: BeAreaSource[]): (ypt: number | null) = } } -// 给材料数组挂上 ypt 与 health_tier -export function annotate( - materials: T[], - classify: (ypt: number | null) => Tier | null -): (T & { ypt: number | null; health_tier: Tier | null })[] { - return materials.map(m => { - const ypt = computeYpt(m) - return { ...m, ypt, health_tier: classify(ypt) } - }) +// 给材料数组挂上 ypt(用于排序)。健康档已改为持久化到 health_level 字段, +// 由一次性迁移脚本 (scripts/migrate-health-level.ts) 写入,运行时不再实时分档。 +export function annotateYpt( + materials: T[] +): (T & { ypt: number | null })[] { + return materials.map(m => ({ ...m, ypt: computeYpt(m) })) } diff --git a/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts b/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts index 0b1e93b..e61a0d0 100644 --- a/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts +++ b/空气质量预测/源码/服务端/iapip-svr/src/controllers/material.ts @@ -9,7 +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, Tier } from '../common/material-ranking' +import { annotateYpt } from '../common/material-ranking' // const PREDICT_FIELD: ((keyof PublicMaterial) | (keyof SelfMaterial))[] = [ // 'methanal', 'methanal_min_be', 'methanal_be_area', 'methanal_be_roc', @@ -22,31 +22,19 @@ import { annotate, buildTierClassifier, Tier } from '../common/material-ranking' 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, 方向随 sort_val; 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) +// 注解 ypt(用于排序)+ 排序(ypt / *_be_area / 其它字段, 方向随 sort_val) +// + 分组(group_by_tier 时先按持久化的 health_level 分档 A→B→C→(空),档序固定不随方向翻转) + 分页 +// 健康档已定格到 health_level 字段(由 scripts/migrate-health-level.ts 一次性写入),运行时不再实时分档。 +function rankAndPaginate(materials: AnyObj[], query: AnyObj): AnyObj[] { + let list: AnyObj[] = annotateYpt(materials as never) 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]) + const levelRank = (l: unknown) => (l === 'A' ? 0 : l === 'B' ? 1 : l === 'C' ? 2 : 3) const dir = query.sort_val === 'desc' ? -1 : 1 list.sort((a, b) => { if (group) { - const d = tierRank(a.health_tier) - tierRank(b.health_tier) + const d = levelRank(a.health_level) - levelRank(b.health_level) if (d !== 0) return d } const k = sk ?? 'ypt' @@ -317,8 +305,7 @@ materialRouter.get( materials.push(...selfMtrls) } // 统一:注解 ypt/health_tier + 排序(含 ypt/分组) + 分页 - const classify = await loadTierClassifier() - ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }, classify) + ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }) } ) @@ -1145,8 +1132,7 @@ materialRouter.get( mtrl.collectors = [] }) } - const classify = await loadTierClassifier() - ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }, classify) + ctx.body = rankAndPaginate(materials, { ...query, group_by_tier }) } ) @@ -1880,8 +1866,7 @@ materialRouter.get( delete conditions.orderBy } const selfMaterials = await db.selfMaterial.findMany(conditions as never) - const classify = await loadTierClassifier() - ctx.body = rankAndPaginate(selfMaterials, { ...query, group_by_tier }, classify) + ctx.body = rankAndPaginate(selfMaterials, { ...query, group_by_tier }) } ) diff --git a/空气质量预测/源码/服务端/iapip-svr/test/material-ranking.test.ts b/空气质量预测/源码/服务端/iapip-svr/test/material-ranking.test.ts index 1357fc8..3ef454b 100644 --- a/空气质量预测/源码/服务端/iapip-svr/test/material-ranking.test.ts +++ b/空气质量预测/源码/服务端/iapip-svr/test/material-ranking.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { computeYpt, buildTierClassifier, annotate } from '../src/common/material-ranking' +import { computeYpt, buildTierClassifier, annotateYpt } from '../src/common/material-ranking' const M = (methanal: number|null, tvoc: number|null, benzene: number|null, toluene: number|null, pxylene: number|null) => ({ methanal_be_area: methanal, tvoc_be_area: tvoc, benzene_be_area: benzene, toluene_be_area: toluene, p_xylene_be_area: pxylene @@ -42,13 +42,16 @@ describe('buildTierClassifier', () => { }) }) -describe('annotate', () => { - it('挂上 ypt 与 health_tier', () => { +describe('annotateYpt', () => { + it('只挂上 ypt(健康档不再实时计算,已定格到 health_level)', () => { const all = [M(1,0,0,0,0), M(2,0,0,0,0), M(3,0,0,0,0)] - const classify = buildTierClassifier(all) - const out = annotate(all, classify) - expect(out[0].health_tier).toBe('A') + const out = annotateYpt(all) expect(out[0].ypt).toBeCloseTo(1.8, 6) - expect(out[2].health_tier).toBe('C') + expect(out[2].ypt).toBeCloseTo(5.4, 6) + expect(out[0]).not.toHaveProperty('health_tier') + }) + it('缺值材料 ypt=null', () => { + const out = annotateYpt([M(1, null, 0, 0, 0)]) + expect(out[0].ypt).toBeNull() }) })