commit b2234bc4129ce1e55e47894a86be6c6604ca2837 Author: malei Date: Thu Apr 23 19:24:46 2026 +0800 init: fao project v0.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3403c1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.venv/ +venv/ +.pydeps/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.coverage +htmlcov/ +.mypy_cache/ +.ruff_cache/ +.env +*.egg-info/ +dist/ +build/ +.DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d53ecaf --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic" +} \ No newline at end of file diff --git a/config/.env.example b/config/.env.example new file mode 100644 index 0000000..43168ad --- /dev/null +++ b/config/.env.example @@ -0,0 +1,29 @@ +# 本地复制为 .env 后填写。勿将含真实密码/密钥的 .env 提交版本库。 +# +# Java 治理接口(POST /api/v1/internal/governance/consume-raw)请求头 X-Fao-Governance-Token +# FAO_GOVERNANCE_TOKEN= +# +# 定时消费 raw(对应 application.yml / fao.governance.scheduled.*,也可用 Spring 环境变量覆盖) +# FAO_GOVERNANCE_SCHEDULED_ENABLED=true +# FAO_GOVERNANCE_SCHEDULED_INITIALDELAYMS=120000 +# FAO_GOVERNANCE_SCHEDULED_FIXEDDELAYMS=300000 +# FAO_GOVERNANCE_SCHEDULED_BATCHLIMIT=50 +# +# Python 采集写 raw(ingest_raw_event),与 server Flyway 对齐: +FAO_MYSQL_HOST=127.0.0.1 +FAO_MYSQL_PORT=3306 +FAO_MYSQL_USER=fao +FAO_MYSQL_PASSWORD=changeme +FAO_MYSQL_DATABASE=fao +# +# 可选:覆盖默认样例路径(默认 data/samples/demo_vegetable_prices.json) +# FAO_SAMPLE_JSON_PATH= +# +# http_json 适配器(仅用于已授权 JSON 接口) +# FAO_HTTP_JSON_URL= +# FAO_HTTP_SOURCE_CODE=DEMO +# FAO_HTTP_TIMEOUT_SECONDS=30 +# FAO_HTTP_USER_AGENT=fao-ingest/0.1 +# +# 可选: Redis +# FAO_REDIS_URL=redis://127.0.0.1:6379/0 diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/samples/demo_vegetable_prices.json b/data/samples/demo_vegetable_prices.json new file mode 100644 index 0000000..43a33e9 --- /dev/null +++ b/data/samples/demo_vegetable_prices.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "items": [ + { + "variety_code": "tomato", + "variety_name": "西红柿", + "biz_date": "2026-04-23", + "avg_price_yuan_per_kg": 4.35, + "market_code": "DEMO_MARKET", + "unit": "yuan_per_kg" + }, + { + "variety_code": "cucumber", + "variety_name": "黄瓜", + "biz_date": "2026-04-23", + "avg_price_yuan_per_kg": 2.95, + "market_code": "DEMO_MARKET", + "unit": "yuan_per_kg" + } + ] +} diff --git a/db/migrations/.gitkeep b/db/migrations/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/deploy/.gitkeep b/deploy/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/产品设计-蔬菜价格指数平台.md b/docs/产品设计-蔬菜价格指数平台.md new file mode 100644 index 0000000..dfa95ec --- /dev/null +++ b/docs/产品设计-蔬菜价格指数平台.md @@ -0,0 +1,210 @@ +# 蔬菜价格指数平台 — 产品设计文档 + +> 版本:1.2 +> 状态:与《架构设计》对齐的 MVP 基线 +> 范围:价格查询、指数趋势、价格榜单、历史数据;兼顾普通用户与专业用户 + +--- + +## 1. 文档目的与边界 + +**目的**:为前台信息架构、页面逻辑、交互与迭代节奏提供可执行的产品基线,避免过度设计。 + +**边界**: + +- 采集、治理、指算法实现细节以《架构设计》与数据契约为准;本文不重复技术实现,仅约定**产品可见的数据契约**(口径、粒度、更新频率、异常说明)。 +- 展示层技术栈与架构一致:**Vue 或 React + ECharts**,请求 **Java(Spring Boot)REST API**;契约以 **OpenAPI** 为单一描述时可与研发对齐字段与版本。 + +**非目标(MVP 不做)**:复杂自定义 BI 看板、拖拽式报表编排、重社交/Feed、非必要的账号体系(除非合规或商业化明确要求)。 + +--- + +## 2. 演示数据与正式数据源策略 + +目标:**演示阶段快速跑通流程与界面**;**接正式源时仅更换数据供给与治理实现**,不重做前台信息架构与主路径(「演示可快、正式可换」)。 + +### 2.1 演示阶段 + +- **数据形态**:优先 **CSV / JSON 静态样例**,或「一次性从公开渠道导出的快照」;可由 **Java 提供 `/demo` 类只读接口**(与《架构设计》中「展示层请求 Spring Boot」一致)。页面须**显著标注**「**演示数据,非实时**」。 +- **字段与粒度**:样例的字段名、时间粒度、单位(如元/公斤)**按正式接入目标 schema 设计后再造数**,避免为演示单独发明一套键名,导致接正式源时前台与契约大面积返工。 +- **能力取舍**:**查询、趋势、榜单、历史** 四条主路径做全;**导出、多源合并、复杂权限** 可后放。 + +### 2.2 接正式源阶段 + +- **单一数据契约**:前台只依赖稳定字段(示例):标准**品种 ID**、**市场/区域**、**业务日期**、**价格类型**、**单位**、**数据来源 code**、**数据/配置版本**;新发地、部里信息系统、采购或合同数据等,均仅在 **采集 / 治理层** 映射到同一套结构。 +- **实现边界(与《架构设计》一致)**:**Python** 仅写 **raw**;**Java** 负责治理、指数与 **REST**;演示数据源将来替换为「读取同结构的 clean / 指标表」时,**前台路由与页面逻辑保持不变**。 +- **合规与运维**:正式源须落实 **授权或条款**(开放 API、采购数据协议、是否允许抓取等);界面与文档固定展示 **数据来源、更新时间、免责声明**;多源并存时禁止在无前缀说明的情况下 **静默混源**。 +- **上线门禁**:生产环境 **长期自动化** 拉取须满足《[数据源上线清单](./数据源上线清单.md)》中的授权、SLA、观测告警与降级话术。 + +### 2.3 来源标识约定(评审落地) + +- 演示数据:`source_code = DEMO`(或等价字段,以后端字典为准)。 +- 正式源:按接入顺序增加可枚举 code(示例:`XFD`、`MOA_…`,具体以后端维表为准)。 +- **API 与 UI 均须透传** `source_code`(及必要时的 `config_version`),便于对账、审计与用户理解,**避免混源时说不清口径**。 + +--- + +## 3. 用户画像 + +| 角色 | 典型目标 | 关键行为 | 主要痛点 | +|------|----------|----------|----------| +| **普通用户** | 判断「某菜今天贵不贵」、大致涨跌 | 搜索品种、扫榜单、看短期趋势 | 术语多、不知看哪个指标、要求快、易懂 | +| **专业用户** | 区域/时段对比、报告引用、假设验证 | 多条件筛选、历史区间、导出与可复现链接 | 口径不透明、缺元数据与版本、批量诉求 | +| **运营/内容(内部)** | 榜单规则、公告、异常说明与前台一致 | 管理端配置与审核(能力随管理端分期开放) | 前后口径不一致、难追溯 | + +**合规与信任(产品侧必显式满足)**: + +- 默认**最小化个人信息**;无必要不登录。 +- 前台固定提供:**指标定义、单位、数据来源说明、更新频率、免责声明**;指数与算法变更建议带 **config_version** 或等价说明(与后端域模型一致)。 +- 若存在地域/市场级敏感粒度,展示与导出遵循组织**脱敏与权限**策略(与架构中的鉴权/审计一致)。 + +--- + +## 4. 产品架构(信息架构) + +### 4.1 前台模块与核心业务映射 + +| 业务 | 用户价值 | 前台模块 | +|------|----------|----------| +| 蔬菜价格查询 | 单点现价/均价与指数快照 | **查询**(搜索 + 结果详情) | +| 指数趋势 | 时间维度变化与对比 | **趋势**(默认折线图,ECharts) | +| 价格榜单 | 横向比较涨跌与价位 | **榜单**(Tab:涨幅 / 跌幅 / 均价等,与数据就绪度对齐) | +| 历史数据 | 长区间查阅与摘录 | **历史**(表格为主,图为辅) | + +### 4.2 横切能力 + +- **口径与帮助**:图表/榜单旁统一「i」入口 → 指标定义、基期、滞后、异常值处理原则(文案与数据版本绑定)。 +- **深度链接**:筛选条件写入 **URL Query**(便于专业用户书签与协作);首屏性能由后端分页/汇总与可选缓存保障(见架构)。 +- **多端**:同一套 IA;移动端遵循「一屏一任务」:查询 → 结果 → 下钻趋势/榜单。 + +### 4.3 与管理端的关系(分期) + +- MVP 前台可依赖 **Java 侧已落库的指标与维表** 只读展示;榜单规则、别名映射等以**后端当前生效配置**为准。 +- 运营配置界面随 **管理端** 分期上线;产品需约定「配置变更 → 前台展示延迟与版本提示」行为。 + +--- + +## 5. 页面逻辑与用户路径 + +### 5.1 首页 + +- **入口**:搜索框(品种联想)、热门品种快捷入口、今日概览(少数字卡片,避免信息堆叠)。 +- **出口**:进入查询结果、榜单默认 Tab、或「数据说明/口径」页。 + +### 5.2 查询(品种详情骨架) + +1. 用户输入 → **联想列表**(标准品种名 + 别名命中规则由维表决定)。 +2. **结果页**:现价/均价/指数(以 API 实际字段为准)、最近更新时点、**看趋势**、**在榜单中的位置**(若榜单 API 支持锚点)。 +3. 子模块:**趋势**、**历史** 与详情共用同一品种上下文(路由或 Query 保持一致)。 + +### 5.3 趋势 + +- 默认展示 **近 30 天**(或与后端默认聚合粒度一致的可配置区间)。 +- **MVP**:单品种为主;**P1**:多品种对比、同比/环比(数据与公式就绪后开放,未就绪时灰显并链到口径说明)。 +- 图表类型默认 **折线**;大数据量时以后端降采样或聚合区间为准,前端避免一次渲染过量点。 + +### 5.4 榜单 + +- **Tab**:建议 MVP 含「涨幅榜」「均价榜」;「跌幅榜」与数据源同步上线。 +- **筛选器**:与查询共用维度(如区域、品类),**Sticky** 在列表顶部(桌面端);移动端收折为抽屉。 +- 行点击 → 进入与查询共用的**品种详情**。 + +### 5.5 历史数据 + +- **时间范围选择** → 表格 + 分页(或虚拟滚动,以后端分页契约为准)。 +- **图/表切换**:MVP 可二选一优先「表」;图用 ECharts 轻量折线即可。 +- **导出(P1)**:CSV 等能力依赖 Java API 与权限设计;MVP 可用「复制链接」替代部分场景。 + +### 5.6 异常与空态(统一文案策略) + +区分并分别引导:**无数据**、**维护/延迟**、**筛选过窄**;避免笼统「加载失败」。必要时链到状态页或公告(若 API 提供)。 + +--- + +## 6. 交互与视觉 + +### 6.1 布局原则 + +- **上**:筛选与控制;**中**:主视觉(图或表);**下**:口径、数据来源、免责声明。 +- 避免三栏复杂仪表盘;**一屏一主任务**。 + +### 6.2 图表与色彩(ECharts) + +- **涨跌语义色**全站统一(建议色盲友好调色板);图例可开关系列。 +- **坐标轴**:单位与时间格式可读;专业用户需支持 **tooltip 精确值** 与 **缩放/拖拽(P1)** 按性能评估开启。 + +### 6.3 性能与感知 + +- 首屏 **骨架屏**;列表与图表请求失败可重试;长时间查询显式进度或预估(若后端支持)。 + +### 6.4 无障碍(在成本可控内) + +- 关键趋势附 **数据摘要** 或表格式兜底;对比度符合规范;筛选控件尽量支持键盘操作(分期)。 + +--- + +## 7. 功能优先级 + +### 7.1 MVP(第一期必交付) + +1. 品种搜索 + 联想 + 详情页(价格/指数快照 + 更新时点)。 +2. 单品种趋势(默认区间 + ECharts 折线)。 +3. 榜单至少一类核心榜(涨幅或均价)+ 基础筛选。 +4. 历史数据表格 + 分页。 +5. 静态或半静态的 **数据说明 / 口径 / 免责声明**(可独立路由页 + 全局页脚入口)。 + +### 7.2 P1 + +- 多品种对比、同比环比;更多榜单 Tab。 +- 地域/市场维度扩展(与维表与 API 同步)。 +- CSV 导出、带参 URL 全量覆盖筛选态。 +- 简单反馈入口(非登录)。 + +### 7.3 刻意延后 + +- 自定义拖拽看板、复杂告警订阅、重运营活动页(除非有明确业务订单)。 + +--- + +## 8. 迭代规划(建议节奏) + +| 阶段 | 目标 | 产品交付物 | +|------|------|------------| +| 迭代 0 | 契约对齐 | 与研发确认 OpenAPI 首版字段:品种、时间粒度、指数 code、版本号、分页约定、**`source_code` 与演示/正式切换策略** | +| 迭代 1 | 前台闭环 MVP | 查询、趋势、榜单、历史、口径页、空/异常态;**演示数据标注与来源透传** | +| 迭代 2 | 专业增强 | 对比、导出、URL 状态、(如需)登录与权限可见范围 | +| 持续 | 信任与运维体验 | 数据延迟说明自动化、口径版本 Release Note、监控状态对用户可见(只读) | + +--- + +## 9. 成功指标(轻量) + +| 指标 | 说明 | +|------|------| +| 搜索成功率 | 有结果点击 / 搜索发起 | +| 详情二次行为率 | 从详情进入趋势或历史的比例 | +| 榜单点击率 | 反映横向比较需求是否被满足 | +| 口径页跳出率 | 过高则提示主界面指标仍不清晰 | +| 导出次数(P1) | 专业渗透度 | + +--- + +## 10. 与研发的协作清单(产品需在评审前带齐) + +- [ ] 默认时间粒度(日/周)与各页面默认区间。 +- [ ] 指数列表:`index_code` 与用户可见中文名映射表。 +- [ ] 地域/市场层级是否 MVP 开放及枚举来源。 +- [ ] 榜单排序定义(涨幅计算公式引用版本号)。 +- [ ] 延迟容忍:API `updated_at` 与前台展示策略。 +- [ ] 错误码与用户文案映射表(含维护态)。 +- [ ] **`source_code` 枚举**与「演示 / 正式」环境或开关策略;多源时混排规则与 UI 展示。 + +--- + +## 11. 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0 | 2026-04-23 | 初稿,与《架构设计》v1.1 对齐 | +| 1.1 | 2026-04-23 | 新增「演示数据与正式数据源策略」;协作清单与迭代 0/1 补充 `source_code` 与来源透传 | +| 1.2 | 2026-04-23 | 与《数据源上线清单》互链;正式源持续采集门禁引用 | diff --git a/docs/产品设计师智能体提示词(蔬菜价格指数平台专属).md b/docs/产品设计师智能体提示词(蔬菜价格指数平台专属).md new file mode 100644 index 0000000..0a9a78a --- /dev/null +++ b/docs/产品设计师智能体提示词(蔬菜价格指数平台专属).md @@ -0,0 +1,140 @@ +# 产品设计师智能体提示词(蔬菜价格指数平台专属) + +# 一、完整版提示词(详细全面,可直接复制使用) + +## 核心角色定位 + +你是一名资深产品设计师,专注于「数据可视化类平台」产品设计,拥有农产品/价格指数类产品设计经验,擅长从用户需求出发,平衡「功能实用性、操作便捷性、视觉简洁性」,兼顾C端普通用户(关注蔬菜价格、趋势)与B端潜在用户(商户、农业从业者、研究者)的核心需求,聚焦蔬菜价格指数平台的全流程产品设计,全程围绕「多源蔬菜价格采集、价格指数展示、趋势分析、榜单对比」核心业务,拒绝冗余设计,优先落地轻量化、易上手的产品方案。 + +## 角色约束 + +- 贴合业务核心:严格围绕「蔬菜价格指数平台」展开,核心业务包括多源蔬菜价格展示、价格指数趋势、当日/历史价格榜单、品类分类查询、基础数据统计,不偏离业务范围; + +- 用户导向优先:核心服务两类用户——普通用户(快速查价格、看趋势)、专业用户(看指数、做对比、查历史数据),设计需兼顾两者,优先简化普通用户操作,同时满足专业用户的深度需求; + +- 落地性优先:产品设计需匹配技术实现能力(贴合Python爬虫、Flask后端、ECharts可视化技术栈),不设计技术无法落地的功能,优先轻量化MVP版本,再考虑后续扩展; + +- 输出结构化:所有设计输出需遵循「用户画像→核心需求→产品架构→页面原型逻辑→交互设计→视觉规范→功能优先级→迭代规划」的固定格式,清晰可落地; + +- 主动延伸思考:自动考虑用户使用场景(日常查价、趋势跟踪、价格对比)、痛点(找不到对应蔬菜、看不懂指数、操作复杂)、合规性(数据来源标注)、多端适配(PC端为主,预留移动端适配)、后续可扩展性(功能迭代、用户分层)。 + +## 核心业务与用户需求拆解 + +### (一)核心业务目标 + +搭建一站式蔬菜价格指数可视化平台,实现「价格可查、指数可看、趋势可析、榜单可比」,让用户快速获取蔬菜价格信息、价格波动规律,同时为专业用户提供基础数据支撑。 + +### (二)用户画像与核心需求 + +1. 普通用户(占比70%):家庭用户、普通消费者,核心需求——快速查询当日热门蔬菜价格、查看价格涨跌趋势、了解当前价格高低榜单,操作简单、直观易懂,无需复杂操作; + +2. 专业用户(占比30%):蔬菜商户、农业从业者、研究者,核心需求——查看价格指数变化(日/周/月环比、同比)、历史价格回溯、品类分类查询、价格异常提醒,支持基础数据导出,满足分析需求。 + +## 能力输出要求 + +### 1. 产品架构设计 + +输出清晰的产品架构,分「核心功能模块+辅助功能模块」,明确模块间的关联,贴合技术实现逻辑,具体包括: + +- 核心模块:首页(指数+热门价格)、价格查询(按蔬菜品类、日期)、指数趋势(图表展示)、价格榜单(当日/历史涨跌、高低)、历史数据(回溯查询); + +- 辅助模块:数据来源标注、操作指引、基础设置(字体大小、主题切换)、异常反馈。 + +### 2. 页面原型逻辑 + +按页面优先级,拆解每一页的核心内容、布局逻辑、跳转关系,无需绘制原型图,用文字清晰描述,重点包括: + +- 首页:核心展示(价格指数趋势图、当日均价、热门蔬菜价格)、入口布局(查询、榜单、历史数据),突出核心信息,避免信息过载; + +- 查询页:搜索功能(蔬菜名称模糊搜索)、筛选条件(品类、日期、价格区间)、查询结果展示(价格、单位、日期、数据源); + +- 指数趋势页:多维度图表(指数曲线、均价柱状图)、时间筛选(日/周/月/自定义)、数据标注(涨跌幅度、基准值); + +- 榜单页:分类榜单(价格最高/最低、涨幅/跌幅最大)、时间切换、蔬菜品类筛选; + +- 历史数据页:日期范围选择、品类筛选、数据展示(表格/简化图表)、基础导出功能(Excel)。 + +### 3. 交互设计 + +贴合用户使用习惯,设计简洁、流畅的交互逻辑,重点关注: + +- 查询交互:搜索联想、筛选条件一键重置、结果快速切换展示形式(列表/简化图表); + +- 图表交互:鼠标悬浮显示详细数据(价格、指数、日期)、图表缩放、趋势对比(多品类叠加); + +- 通用交互:页面跳转流畅、加载状态提示、异常反馈(查询无结果、数据加载失败)、操作指引(首次使用弹窗提示核心功能)。 + +### 4. 视觉规范设计 + +贴合数据可视化平台调性,设计简洁、清晰、专业的视觉规范,无需过度美化,重点包括: + +- 色彩规范:主色调(清新绿色系,贴合蔬菜主题)、辅助色(涨跌区分:红色涨、绿色跌)、中性色(背景白、文字深灰),避免刺眼色彩; + +- 字体规范:清晰易读,标题加粗、正文常规,区分层级(页面标题、模块标题、正文、辅助文字); + +- 图表规范:曲线清晰、柱状图简洁,标注明确,避免复杂样式,重点突出数据本身; + +- 布局规范:统一边距、间距,模块划分清晰,避免杂乱,核心信息优先展示(顶部/左侧)。 + +### 5. 功能优先级与MVP设计 + +明确功能优先级,优先落地MVP版本(极简可用),再规划后续迭代,具体: + +- MVP核心功能(必须实现):首页指数+热门价格展示、简单蔬菜查询、基础趋势图、当日价格榜单; + +- 进阶功能(V2.0迭代):历史数据查询、多品类对比、数据导出、移动端适配; + +- 优化功能(V3.0迭代):用户收藏、价格异常提醒、专业数据分析报告、B端用户权限区分。 + +### 6. 痛点解决方案 + +针对用户核心痛点,设计对应解决方案,例如: + +- 痛点1:找不到对应蔬菜 → 解决方案:模糊搜索+品类分类筛选+搜索联想; + +- 痛点2:看不懂价格指数 → 解决方案:首页添加指数说明(简单文字解释)、鼠标悬浮标注指数含义; + +- 痛点3:操作复杂 → 解决方案:简化页面布局、核心功能入口突出、首次使用操作指引; + +- 痛点4:数据不直观 → 解决方案:优先用图表展示,辅以简洁文字说明,涨跌清晰区分。 + +### 7. 迭代规划与扩展方向 + +分阶段规划产品迭代,明确每阶段核心目标,同时提供后续扩展方向,贴合业务长远发展: + +- V1.0(MVP版):实现核心功能,完成PC端基础展示,保障操作流畅、数据准确; + +- V2.0(进阶版):完善查询、历史数据、多维度对比功能,优化视觉与交互,适配移动端; + +- V3.0(优化版):新增用户分层、数据导出、异常提醒,拓展专业分析功能; + +- 扩展方向:地区价格对比、蔬菜品类细分指数、行情预测、B端定制化功能。 + +### 8. 附加输出 + +主动提供辅助设计内容,助力产品落地: + +- 用户操作流程图(文字版); + +- 核心页面的信息架构表; + +- 功能需求清单(含优先级、验收标准); + +- 与技术、架构师的协作要点(明确产品需求与技术实现的衔接)。 + +## 回答风格 + +- 专业、简洁、落地,多用标题+列表,拒绝冗余废话,避免空泛的设计理论; + +- 兼顾新手友好与专业性,设计方案无需复杂技术支撑,同时预留扩展空间; + +- 若需补充某部分细节(如交互细节、视觉规范细则、需求清单),可直接响应指令补充。 + +# 二、精简短版提示词(适合AI平台字符限制,轻量化适配) + +你是资深产品设计师,专注数据可视化平台设计,核心负责蔬菜价格指数平台产品设计。需围绕「蔬菜价格查询、指数趋势、价格榜单、历史数据」核心业务,聚焦普通用户与专业用户需求,输出结构化设计方案(用户画像→产品架构→页面逻辑→交互视觉→优先级→迭代规划)。优先轻量化MVP落地,贴合Python+ECharts技术栈,兼顾操作便捷性与数据直观性,自动考虑用户痛点、多端适配、合规性,回答简洁专业、可落地,拒绝过度设计。 + +# 三、超短压缩版(适合快速塞入AI助手,极简适配) + +资深产品设计师,专注蔬菜价格指数平台,输出结构化设计:用户需求→产品架构→页面交互→视觉规范→优先级迭代,轻量化落地,贴合技术栈,兼顾普通/专业用户,简洁可落地,自动规避用户痛点。 +> (注:文档部分内容可能由 AI 生成) \ No newline at end of file diff --git a/docs/定时任务配置示例.md b/docs/定时任务配置示例.md new file mode 100644 index 0000000..5a1969f --- /dev/null +++ b/docs/定时任务配置示例.md @@ -0,0 +1,80 @@ +# 定时任务配置示例(治理批消费 + 采集 Cron) + +> 用途:上线后 **Python 定时写 raw** + **Java 定时消费 raw** 的参考配置。频率请按《数据源上线清单》中的授权与 SLA 调整。 + +--- + +## 1. Java:治理定时(`RawGovernanceScheduler`) + +### 1.1 方式 A — 直接写在 `application.yml` + +在现有 `fao.governance` 下覆盖(示例:**开启**、首次延迟 2 分钟、**上次跑完后**间隔 5 分钟、每批最多 50 条): + +```yaml +fao: + governance: + token: ${FAO_GOVERNANCE_TOKEN:} + # 建议生产填写白名单,避免误处理未就绪的 source + source-codes: [] # 例: ["XFD", "DEMO"] + scheduled: + enabled: true + initial-delay-ms: 120000 + fixed-delay-ms: 300000 + batch-limit: 50 +``` + +说明: + +- **`fixed-delay-ms`**:`fixedDelay`,**上一轮执行结束**后再等待该毫秒数;适合治理批任务避免重叠。 +- **`batch-limit`**:单次 `consume` 最多处理条数;代码中还会限制在 **1~200**。 +- **`source-codes`**:非空时,定时任务与未带 `sourceCodes` 的 HTTP 治理请求都只处理列出的 `source_code`。 + +### 1.2 方式 B — Spring Profile(推荐与默认配置分离) + +1. 将 `server/src/main/resources/application-scheduled.yml.example` **复制为** `application-scheduled.yml`(后者可加入 `.gitignore` 若含环境专有值)。 +2. 启动时增加:**`--spring.profiles.active=scheduled`**(或与 `default` 并列:`default,scheduled`)。 + +仅 profile 文件存在、未激活时,**不会**打开定时任务。 + +### 1.3 方式 C — 环境变量(容器 / systemd) + +与 `fao.governance.scheduled.*` 对应的环境变量示例(Spring Boot 宽松绑定,以实际运行镜像为准): + +| 配置项 | 环境变量示例 | +|--------|----------------| +| `scheduled.enabled` | `FAO_GOVERNANCE_SCHEDULED_ENABLED=true` | +| `scheduled.initial-delay-ms` | `FAO_GOVERNANCE_SCHEDULED_INITIALDELAYMS=120000` | +| `scheduled.fixed-delay-ms` | `FAO_GOVERNANCE_SCHEDULED_FIXEDDELAYMS=300000` | +| `scheduled.batch-limit` | `FAO_GOVERNANCE_SCHEDULED_BATCHLIMIT=50` | + +--- + +## 2. 与采集频率的配合(建议) + +| 环节 | 典型节奏(示例) | 说明 | +|------|------------------|------| +| **Python 写 raw** | 每 15~60 分钟(视源站授权) | 外网抓取;**仓库内无内置调度**,靠 Cron / K8s CronJob。 | +| **Java 消费 raw** | `fixed-delay` 5~15 分钟 | 应 **不显著快于** 单次治理耗时,避免空转;可略快于采集,以便积压尽快消化。 | + +若 raw 积压大,可临时提高 `batch-limit` 或缩短 `fixed-delay-ms`,并观察 DB 与 CPU。 + +--- + +## 3. Cron 示例(采集) + +见仓库 **`scripts/cron/fao-ingest.crontab.example`**。安装示例: + +```bash +# 编辑后安装到当前用户 crontab(路径、适配器、.env 务必按环境修改) +crontab scripts/cron/fao-ingest.crontab.example +``` + +生产更推荐 **K8s CronJob** 或统一调度平台,思路相同:定时执行 `scripts/run_ingest.sh --adapter `,并注入 `FAO_MYSQL_*` / `FAO_HTTP_JSON_URL` 等环境变量。 + +--- + +## 4. 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0 | 2026-04-23 | 初稿:application 片段、Profile 样例、Cron 引用 | diff --git a/docs/数据源上线清单.md b/docs/数据源上线清单.md new file mode 100644 index 0000000..8f6aff2 --- /dev/null +++ b/docs/数据源上线清单.md @@ -0,0 +1,116 @@ +# 数据源上线清单(上线环境持续采集) + +> 版本:1.0 +> 关联文档:《架构设计》《产品设计-蔬菜价格指数平台》 +> 适用:**生产环境长期、自动化**从外部网站或接口拉取价格类数据(非一次性演示快照)。 + +本文约定「能不能上」与「上了怎么运维」,**不**描述绕过风控或非授权抓取的技术细节。 + +--- + +## 1. 文档目的 + +- 把 **合规边界、运行 SLA、观测告警、降级与对外话术** 固化成评审与上线门禁,避免「能跑」但不可持续。 +- 与架构分层一致:**Python 仅写 raw**;**Java** 治理、指数与对外 API;前台透传 **`source_code`** 与更新时间。 + +--- + +## 2. 上线前:合规与授权(必过项) + +| 序号 | 检查项 | 说明 / 证据形式 | +|------|--------|------------------| +| 2.1 | **权利来源** | 官方 **API/数据产品合同**、**书面授权函**、或 **开放数据许可**(条款需可归档)。无书面依据的默认网页抓取,**不作为生产唯一依赖**。 | +| 2.2 | **授权范围** | 明确:用途(展示/指数/再加工)、字段、是否可缓存、**留存期限**、是否可向第三方披露、地域与账号限制。 | +| 2.3 | ** robots / 用户协议** | 已阅读并与法务或采购结论一致;若冲突以 **合同** 为准。 | +| 2.4 | **个人信息与敏感信息** | 采集字段清单评审;禁止纳入非必要个保;商户级敏感数据按组织脱敏与权限策略。 | +| 2.5 | **对外展示** | 产品文案:**数据来源、更新时间、免责声明**;多源时禁止无说明的 **静默混源**。 | +| 2.6 | **分包与外包** | 若采集由第三方实施,合同中约定 **安全、保密、删除与违约责任**。 | + +--- + +## 3. SLA 与数据契约(产品 + 研发对齐) + +建议在 OpenAPI / 内部契约中至少约定以下字段或等价物: + +| 项 | 说明 | +|----|------| +| **更新频率** | 例如 T+1 日批、日内每 N 小时;**延迟上限**(超过则前台显示「延迟」态)。 | +| **业务日期** | 价格对应的 **交易日/发布日** 与时区。 | +| **缺失策略** | 节假日无盘、源故障时是否补数、是否允许空窗。 | +| **口径变更** | 指数/品种映射变更需带 **`config_version`**;变更 **提前通知期**(若有)。 | +| **`source_code`** | 枚举值写入维表;每条指标结果可追溯到源。 | + +--- + +## 4. `source_code` 与多源规则 + +| 规则 | 说明 | +|------|------| +| **一源一码** | 每个授权通道对应稳定 `source_code`(如 `XFD`、`MOA_…`,以维表为准);**禁止**多源合并后丢失来源。 | +| **演示** | `DEMO` 仅用于非生产或明确标注的演示环境,**不得**与生产数据混写同一张对外事实表而不打标。 | +| **混排展示** | 若同一屏多源:UI/API 须 **分行或分卡** 标明来源;算法侧「综合指数」须单独版本说明与评审。 | + +--- + +## 5. 技术运行要求(采集层) + +| 序号 | 要求 | 说明 | +|------|------|------| +| 5.1 | **频控与退避** | 固定 QPS 上限、失败退避、错峰;禁止故障时放大并发「刷数」。 | +| 5.2 | **Raw 只增与指纹** | 原始层追加;去重键/指纹与《架构设计》一致,支持审计与重放。 | +| 5.3 | **适配器版本** | 一源一包;对方页面/接口变更时 **仅升适配器版本**,不动核心业务表结构。 | +| 5.4 | **密钥与账号** | 凭据走配置中心或密钥管理,**禁止**入库或进镜像明文。 | +| 5.5 | **任务幂等** | 同一业务日重复跑不产生重复对外指标(由 Java 批算幂等保证,与架构一致)。 | + +--- + +## 6. 观测与告警(建议阈值) + +以下为初始建议,上线后按源实际调参。 + +| 指标 | 建议阈值(可调) | 动作 | +|------|------------------|------| +| 任务连续失败 | **≥ 3 次**(同任务同窗口) | 告警 + 暂停该源自动重试(可选)+ 通知负责人 | +| HTTP 5xx 比例 | **> 20%** 持续 15 分钟 | 告警 | +| 解析成功率 | **< 95%**(日级) | 日报 + 评估对方改版 | +| 入库条数环比 | **±50%** 相对近 7 日均值(排除已知节假日) | 数据质量工单,防静默错抓 | +| 延迟 | 超过 SLA 约定 `max_lag` | 前台「延迟」态 + 运营公告模板 | + +日志与 trace:与《架构设计》中 **结构化日志 + trace id** 一致,便于与 Java 治理链路关联。 + +--- + +## 7. 降级与用户可见话术 + +### 7.1 降级策略(内部) + +1. **源不可用**:停止写入该源 raw 或标记失败批次;Java 侧不生成该源当日对外指标(或沿用上一成功日,**须产品事先约定**)。 +2. **部分品种失败**:单品种缺数,榜单/详情显示缺数原因码,不编造插值(除非有明确业务规则与版本)。 +3. **多源之一中断**:仅隐藏该源数据或切换至备用源展示,**界面须标明当前生效来源**。 + +### 7.2 用户侧话术(示例,需法务过目) + +- **延迟**:「数据来源更新延迟,所示价格为最近一次成功同步时间:{updated_at}。」 +- **维护/中断**:「{来源名称} 数据暂时无法更新,正在处理中。」 +- **免责声明**:「价格仅供参考,以实际交易为准。」(与数据源声明对齐) + +--- + +## 8. 上线评审门禁(Checklist) + +- [ ] 2.1~2.6 合规项已归档 +- [ ] 第 3 节 SLA 已写入契约或附件 +- [ ] `source_code` 已录入维表并与前台展示方案一致 +- [ ] 5.1~5.5 技术项已实现或已排期 +- [ ] 第 6 节告警已接入值班渠道 +- [ ] 第 7 节降级策略与话术已产品/法务确认 + +**签字角色建议**:产品负责人、研发负责人、运维/ SRE 或等价角色、法务或授权审批人(视组织要求)。 + +--- + +## 9. 修订记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0 | 2026-04-23 | 初稿:上线持续采集门禁与运维基线 | diff --git a/docs/架构设计.md b/docs/架构设计.md new file mode 100644 index 0000000..269455d --- /dev/null +++ b/docs/架构设计.md @@ -0,0 +1,225 @@ +# 蔬菜价格指数平台 — 架构设计 + +> 版本:1.2 +> 范围:多源价格采集、数据清洗、指数计算、MySQL 存储、接口服务、ECharts 可视化。 +> 目标:轻量落地、成本可控、模块解耦、可迭代扩容。 + +**语言与职责(已定稿)**: + +- **Python:仅负责采集层**(多源适配器、调度、反爬、raw/队列表落库),**不承担**管理后台、对外 API、治理批处理、指计算。 +- **Java:全栈后台**(**Spring Boot** 为主):治理与标准化、指数字典与批算、REST API、鉴权/审计、**管理端后端**、数据库迁移(**Flyway / Liquibase** 等,推荐与 Java 工程同仓)。 +- **集成面**:**MySQL 为事实源**;Python 写 raw(及约定内字段),Java 服务消费 raw 后写 clean、指标与维表。跨语言不共享运行时,**只共享表结构契约与(可选)OpenAPI/内部 HTTP**。 + +--- + +## 1. 设计目标与原则 + +| 目标 | 说明 | +|------|------| +| 轻量优先 | 单机或少量节点即可闭环 MVP,避免过早引入重中间件 | +| 可演进 | 原始层、治理规则、指算法、接口契约版本化,支持重算与追溯 | +| 解耦 | 按数据源隔离适配器,采集/治理/计算/服务异步与边界清晰 | +| 风险可控 | 反爬、去重、异常、合规与审计可落实在流程与表结构上 | + +**原则**:原始数据以追加为主;指数计算与抓取失败解耦;对外服务读「指标/维表」而非直扫原始大表(必要时走缓存/汇总表)。 + +--- + +## 2. 总体分层架构 + +| 层级 | 职责 | 轻量实现形态 | +|------|------|----------------| +| 采集层 | 多源页面/API 抓取、任务调度、反爬与频控 | Python 爬虫 + APScheduler 或 Cron | +| 接入/缓冲层 | 削峰、解耦、失败重试 | Redis List/Stream,或队列表 + 多 Worker | +| 治理层 | 去重、单位/品种归一、异常与质量打标 | **Java 定时任务**(如 Spring `@Scheduled` / XXL-Job 等)+ 可配置规则/维表 | +| 计算层 | 指数据模型、基期、权重、链式/环比等 | **Java 批算**,输入输出可幂等、可版本化 | +| 存储层 | 原始明细、清洗数据、指数结果、元数据 | **MySQL 8.0+(主库,推荐 InnoDB + utf8mb4)** | +| 服务层 | REST API、鉴权、限流、健康检查、**管理后台 API** | **Spring Boot**(可 Undertow/Tomcat;网关按组织规范) | +| 展示层 | 图表、看板、运营侧配置界面 | 前端 + ECharts,**与 Java API 联调** | + +**存储说明**:优先复用**现成 MySQL 实例**;大对象/审计快照可接 MinIO/对象存储。热查询与去重可叠加 **Redis**(可选)。 + +--- + +## 3. 技术选型 + +| 领域 | 建议 | 备注 | +|------|------|------| +| 爬虫 | **Python**:`httpx` / `aiohttp` + `selectolax` / `parsel`;强渲染页用 Playwright | 仅 `ingest` + `adapters/`,**禁止**在爬虫工程实现指算法/管理端 | +| 爬虫调度 | Cron 或 Python 内 APScheduler、独立 worker 进程 | 与 **Java 治理/指计算** 在时间上解耦(可 raw 落库后由 Java 轮询或消息触发) | +| 主库 | **MySQL 8.0+** | Java 与 Python **共用**(凭 DSN/账号分离读写权限更佳);InnoDB、utf8mb4、分区同前 | +| 后台与批算 | **Java 17+**,**Spring Boot 3.x** | 治理/指数/REST/管理端一体;批处理可用 Spring 自带任务或接调度平台 | +| 表结构迁移 | **Flyway** 或 **Liquibase**(**放在 `server/` 工程**) | Python 不持有权威 DDL,按迁移脚本对齐 | +| 缓存/去重 | Redis | Java 与(可选)Python 爬虫**共用**时,**键命名空间**须约定,避免互踩 | +| API 契约 | 对外/前后端以 **OpenAPI** 为单一描述(可 `server` 生成 spec) | Python 侧不暴露业务 API 时,可不建 OpenAPI | +| 前端 | Vue 或 React + ECharts | 请求 **Java 后端** | +| 可观测 | 统一标准:双栈**结构化日志** + trace id(可 OpenTelemetry) | Java/Python 用不同 app name 区分 | +| 部署 | Docker Compose:`java` 服务 + `crawl-worker` 镜像**分离**;扩容再上编排 | | + +--- + +## 4. 模块解耦 + +``` +各数据源适配器 (Source A/B/…N) + │ + ▼ + 统一采集契约:RawRecord + 来源元数据 (source_id、抓取时间、指纹等) + │ + ▼ + 原始层(raw,只增不改或软删策略) + │ + ▼ + 治理与标准化(品种/市场/单位/别名映射、质量 flag) + │ + ▼ + 指数计算引擎 ←── 基期/权重/算法版本配置 + │ + ▼ + 指标结果表 + 维表 + │ + ▼ + 对外/管理 API(Java)/ ECharts +``` + +- **源适配器(Python)**:一源一包;**仅** 输出统一 raw 契约;选择器、登录、限频不混入 Java 代码。 +- **治理/指数/服务(Java)**:消费 raw 表,**写** clean 与 `index_series` 等,对外提供 REST 与**运营配置能力**。 +- **指数字典与算法版本**:`index_code`、`config_version` 等字段在 **Java 域模型与 Flyway** 中一以贯之。 +- **计算幂等**:由 Java 批任务保证,按「业务日期 + 指数版本」可重算。 + +--- + +## 5. 数据流(端到端) + +1. 调度触发各源任务,拉取列表/详情。 +2. 写入 **raw**(可含 `LONGTEXT`/`JSON` 与关键检索列),记录 HTTP 状态与内容指纹。 +3. **去重(Java 治理)**:DB 唯一约束在 Flyway 中统一定义;Redis 去重与 Java/爬虫约定 key 前缀。 +4. **标准化(Java)**:单位、市场、品种别名 → 维表由 **管理端/导入** 维护。 +5. **异常与质量(Java)**:写 clean 与 `quality_flag`。 +6. **指数批算(Java)**:读清洗 + 当版本配置 → 写 `index_series`。 +7. **API(Java)**:读指标与维表;**管理端** 对规则、维表、重算任务 **发令**;缓存键与索引策略在 Java 侧实现。 + +--- + +## 6. MySQL 实践要点 + +- 版本与字符集:MySQL **8.0+**,`utf8mb4`,InnoDB。 +- 半结构化:JSON 存扩展字段,**高筛选字段尽量落普通列+索引**;8.0 可配合生成列建索引。 +- 大表:按**交易日期/入库日期** RANGE 分区,便于归档与冷数据迁出。 +- 多 Worker 队列表:使用 `SELECT … FOR UPDATE SKIP LOCKED`(8.0)实现任务抢占。 +- 备份:定期全量/增量,raw 与指标层策略与 RPO 对齐业务要求。 + +与 PostgreSQL 相比:若未来有极重 JSON 分析或强 GIS 需求,可再评估从库/分析库;当前以结构化为主的指数平台,**单 MySQL 可覆盖 MVP 至中期**。 + +--- + +## 7. 反爬、质量与合规 + +| 主题 | 建议 | +|------|------| +| 反爬 | 频控、退避、UA/协议合法范围内轮换;**优先**对接官方或开放 API。 | +| 去重 | DB 唯一约束 + 指纹/哈希;同日多价按产品规则(如中位、最新)在治理层定稿。 | +| 异常 | 硬规则(负值、超阈)→ 统计(分位/IQR)→ 业务产季/地域规则。 | +| 指算法 | 固定文档:公式、基期=100、缺权/缺价处理、品种替代;多版本可并存。 | +| 合规 | robots/条款与用途评估;**最小化**存个人类信息;对公示指数附方法论与免责声明。 | + +**生产环境长期自动化采集**:除上表原则外,须满足《[数据源上线清单](./数据源上线清单.md)》中的 **授权归档、SLA、观测告警、降级与对外话术** 等上线门禁;与《产品设计-蔬菜价格指数平台》中 `source_code`、来源透传约定一致。 + +--- + +## 8. 主要风险与缓解 + +| 风险 | 缓解 | +|------|------| +| 源站改版 | 监控解析失败率;适配器独立;**原始 HTML/快照** 可选入对象存储 | +| 数据损坏/误删 | raw 追加策略、备份、指数可重算 | +| 单库压力 | 分区、读写分离(从库只读 API)、热数据 Redis | +| 指争议与口径变更 | 配置版本化、**可重放**历史计算、对账报表 | + +--- + +## 9. 分阶段迭代 + +| 阶段 | 内容 | 验收参考 | +|------|------|----------| +| 阶段 1(MVP) | 1~2 个稳定源、Python 写 raw、Java 最小治理 + 1 套指数、**Spring Boot** + ECharts | 日任务成功率、指口径可说明、API P95 | +| 阶段 2 | 多源、Java 管理维表/规则、多指数、Redis、监控、指数重算入口 | 维表覆盖率、重算耗时、跨语言可观测 | +| 阶段 3 | 消息驱动、抓取/计算**水平扩展**、复杂地理层级、SLA | 扩展与容灾指标 | + +--- + +## 10. 扩展预留 + +- 表设计早期包含:`source_id`、`config_version`、**时间维度** 便于分区。 +- **表结构以 Java 侧迁移为权威**;若 Python 需 Pydantic 模型,**由 DDL/OpenAPI/手动同步**,避免两真相。 +- Java **API 无状态**,配置与密钥走 Spring Profile / 环境变量/中心配置。 +- Python 爬虫**无状态**、可水平扩展,仅依赖 DSN 与 `PYTHONPATH`/`pip install -e .`。 + +--- + +## 11. 文档维护 + +- 表结构、指数口径与**对外方法论文档**应与本文件同步更新(可拆子文档:`docs/指数字典与口径.md` 等)。 +- **生产采集门禁**与运维基线见《[数据源上线清单](./数据源上线清单.md)》;变更授权或 SLA 时同步评审本文件第 7 节是否需修订。 +- 重大架构变更时递增本文「版本」并记录变更摘要。 + +**版本摘要(1.2)**:增补对《数据源上线清单》的引用,明确上线环境持续采集的文档门禁。 + +--- + +## 12. 项目目录结构(与实现对齐) + +**双栈单仓**(**Python 仅爬取**,**Java 为后台主工程**): + +```text +fao/ +├── docs/ # 架构、口径、运维说明 +├── server/ # **Java 主工程**(Spring Boot 3 + Maven;`pom.xml` 在根下) +│ # `mvn -f server/pom.xml spring-boot:run`;迁移:`src/main/resources/db/migration/` +├── src/ +│ └── fao/ # **仅** Python 采集包(pyproject 可 `pip install -e .`) +│ ├── common/ # 爬虫子进程:配置、日志 +│ ├── db/ # 仅向 raw/队列写入所需的最小数据访问 +│ └── ingest/ +│ └── adapters/ # 各数据源一模块 +├── scripts/ # 爬虫/批抓入口(`python -m` 等),**不含**指计算 +├── config/ # 爬虫用 `.env.example` 等 +├── db/ +│ └── migrations/ # **可选**:与 `server` 中 Flyway **二选一为权威**;建议以 `server/.../db/migration` 为准后此处留空或删 +├── tests/ +│ ├── unit/ # 建议拆:server 内 surefire + Python pytest 各管各 +│ └── integration/ +├── web/ # 前端,对接 **Java API** +├── deploy/ # 含 `docker-compose`:服务 `app`(Java) + `crawl`(Python) 等 +├── pyproject.toml # 仅 fao 爬虫包 +├── setup.py +└── .gitignore +``` + +| 路径 | 放什么、不放什么 | +|------|------------------| +| `server/` | 全部 **HTTP、安全、域模型、批治理、指计算、迁移**;**不** 写爬虫。 | +| `src/fao/ingest` | 抓取、限频、**仅 raw/队列** 模型与写入;**禁止** 指算法与维表管理。 | +| `src/fao/db` | 爬虫用 DAO;表结构**消费** Java Flyway 产出。 | +| `scripts` | 只调度 **Python 爬取**;**指数重算/治理** 入口放在 **Java** 或企业调度调 Java 接口。 | +| `config` | 以爬虫侧为主;Java 用 `server` 内 `application-*.yml`。 | +| `web` | 管理端/大屏,BFF 为 **Java**。 | + +**可选**:`e2e/` 联调;`ops/` 运维脚本。前端可拆独立仓库,**`server` 为后台边界**。 + +--- + +## 13. 语言边界与数据所有权 + +| 项目 | 约定 | +|------|------| +| **表结构** | **Java 工程迁移脚本** 为唯一权威;Python 升级前 **拉取最新** DDL 或从文档生成模型。 | +| **raw 写入** | 仅 **Python**(或经审查的 ETL)写到约定列;**不** 写 clean/指数字段,除非团队明确开例外并文档化。 | +| **clean / 指标** | 仅 **Java**(及经同一鉴权的运维脚本)。 | +| **同一 Redis** | key 加前缀:`crawl:` / `app:` 等。 | +| **重算指数** | 管理端/定时器调 **Java** 内部服务或 `POST /internal/...`(**内网 + 鉴权**)。 | +| **故障** | 爬虫挂 → raw 空;**Java 报表仍可展示**历史指数据;**治理/批算**在 Java 侧可告警「无新 raw」。 | + +--- + +*本文档为蔬菜价格指数平台的技术架构说明,实现细节以各模块设计与数据库 DDL 为准。* diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1ea1a64 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "fao" +version = "0.1.0" +description = "蔬菜价格指数平台 — Python 采集子包(后台见 server/)" +requires-python = ">=3.10" +dependencies = [ + "httpx>=0.27,<1", + "pymysql>=1.1,<2", + "python-dotenv>=1.0,<2", +] + +[project.optional-dependencies] +# 后续按需拆分:dev、api、ingest 等 +dev = ["pytest>=8", "ruff>=0.6"] + +[project.scripts] +fao-ingest = "fao.ingest.cli:app" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] diff --git a/scripts/.gitkeep b/scripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/cron/fao-ingest.crontab.example b/scripts/cron/fao-ingest.crontab.example new file mode 100644 index 0000000..5b090b7 --- /dev/null +++ b/scripts/cron/fao-ingest.crontab.example @@ -0,0 +1,15 @@ +# 示例 crontab:FAO Python 采集(仅写 raw)。安装前请全文替换路径、适配器与日志路径。 +# 安装:crontab /path/to/fao/scripts/cron/fao-ingest.crontab.example +# +# 依赖:已配置 MySQL(FAO_MYSQL_*),http_json 适配器还需 FAO_HTTP_JSON_URL 等(见 config/.env.example)。 + +SHELL=/bin/bash +PATH=/usr/local/bin:/usr/bin:/bin +# GNU cron 支持时,建议固定为上海时区,避免与业务「交易日」错位 +CRON_TZ=Asia/Shanghai + +# 每 30 分钟执行一次 http_json 适配器(按授权与频控改为 15m / 60m 等) +*/30 * * * * cd /ABS/PATH/TO/fao && [ -f config/.env ] && set -a && . ./config/.env && set +a && ./scripts/run_ingest.sh --adapter http_json >> /var/log/fao/ingest.log 2>&1 + +# 备用:仅本地/演示用 demo_json(不访问外网) +# 15 * * * * cd /ABS/PATH/TO/fao && [ -f config/.env ] && set -a && . ./config/.env && set +a && ./scripts/run_ingest.sh --adapter demo_json >> /var/log/fao/ingest-demo.log 2>&1 diff --git a/scripts/run_ingest.sh b/scripts/run_ingest.sh new file mode 100644 index 0000000..19717a6 --- /dev/null +++ b/scripts/run_ingest.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" +export PYTHONPATH="${ROOT}/src:${PYTHONPATH:-}" +exec python -m fao.ingest.cli "$@" diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..154c358 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1,2 @@ +/target +!.mvn/wrapper/maven-wrapper.jar diff --git a/server/pom.xml b/server/pom.xml new file mode 100644 index 0000000..45986d7 --- /dev/null +++ b/server/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.fao + fao-server + 0.1.0-SNAPSHOT + fao-server + 蔬菜价格指数平台 — Java 后台(治理、指数、API、管理端) + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.5.0 + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/server/src/main/java/com/fao/platform/FaoServerApplication.java b/server/src/main/java/com/fao/platform/FaoServerApplication.java new file mode 100644 index 0000000..6be9a5b --- /dev/null +++ b/server/src/main/java/com/fao/platform/FaoServerApplication.java @@ -0,0 +1,12 @@ +package com.fao.platform; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class FaoServerApplication { + + public static void main(String[] args) { + SpringApplication.run(FaoServerApplication.class, args); + } +} diff --git a/server/src/main/java/com/fao/platform/api/GovernanceController.java b/server/src/main/java/com/fao/platform/api/GovernanceController.java new file mode 100644 index 0000000..69d0552 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/GovernanceController.java @@ -0,0 +1,42 @@ +package com.fao.platform.api; + +import com.fao.platform.api.dto.ConsumeRawRequest; +import com.fao.platform.api.dto.ConsumeRawResponse; +import com.fao.platform.governance.GovernanceProperties; +import com.fao.platform.governance.RawGovernanceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "治理(内部)") +@RestController +@RequestMapping("/api/v1/internal/governance") +public class GovernanceController { + + private final GovernanceProperties governanceProperties; + private final RawGovernanceService rawGovernanceService; + + public GovernanceController( + GovernanceProperties governanceProperties, RawGovernanceService rawGovernanceService) { + this.governanceProperties = governanceProperties; + this.rawGovernanceService = rawGovernanceService; + } + + @Operation( + summary = "消费 ingest_raw_event(price_quote_batch)写入 daily_market", + description = "可选 body: limit、sourceCodes(仅处理指定 source_code;不传则用配置 fao.governance.source-codes)") + @PostMapping("/consume-raw") + public ConsumeRawResponse consumeRaw( + @RequestHeader("X-Fao-Governance-Token") String token, + @RequestBody(required = false) ConsumeRawRequest body) { + governanceProperties.assertToken(token); + int limit = body != null && body.limit() != null ? body.limit() : 20; + List sourceCodes = body != null ? body.sourceCodes() : null; + return rawGovernanceService.consume(limit, sourceCodes); + } +} diff --git a/server/src/main/java/com/fao/platform/api/MarketMetric.java b/server/src/main/java/com/fao/platform/api/MarketMetric.java new file mode 100644 index 0000000..6d2b800 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/MarketMetric.java @@ -0,0 +1,22 @@ +package com.fao.platform.api; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public enum MarketMetric { + PRICE, + INDEX; + + public static MarketMetric fromParam(String raw) { + if (raw == null || raw.isBlank()) { + return INDEX; + } + return switch (raw.trim().toLowerCase()) { + case "price" -> PRICE; + case "index" -> INDEX; + default -> + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "metric 须为 price 或 index"); + }; + } +} diff --git a/server/src/main/java/com/fao/platform/api/PingController.java b/server/src/main/java/com/fao/platform/api/PingController.java new file mode 100644 index 0000000..696c11c --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/PingController.java @@ -0,0 +1,16 @@ +package com.fao.platform.api; + +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1") +public class PingController { + + @GetMapping("/ping") + public Map ping() { + return Map.of("status", "ok", "component", "fao-server"); + } +} diff --git a/server/src/main/java/com/fao/platform/api/RankingController.java b/server/src/main/java/com/fao/platform/api/RankingController.java new file mode 100644 index 0000000..1cf0619 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/RankingController.java @@ -0,0 +1,37 @@ +package com.fao.platform.api; + +import com.fao.platform.api.dto.RankingItemDto; +import com.fao.platform.service.MarketCatalogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "榜单") +@RestController +@RequestMapping("/api/v1/rankings") +public class RankingController { + + private final MarketCatalogService catalogService; + + public RankingController(MarketCatalogService catalogService) { + this.catalogService = catalogService; + } + + @Operation(summary = "榜单(默认最新交易日;环比用上一自然日)") + @GetMapping + public List list( + @RequestParam(defaultValue = "change_pct") String type, + @RequestParam(defaultValue = "price") String metric, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate bizDate, + @RequestParam(defaultValue = "20") int limit) { + RankingType t = RankingType.fromParam(type); + MarketMetric m = MarketMetric.fromParam(metric); + return catalogService.getRankings(t, m, bizDate, limit); + } +} diff --git a/server/src/main/java/com/fao/platform/api/RankingType.java b/server/src/main/java/com/fao/platform/api/RankingType.java new file mode 100644 index 0000000..07e41d3 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/RankingType.java @@ -0,0 +1,24 @@ +package com.fao.platform.api; + +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +public enum RankingType { + /** 环比涨跌幅度(相对上一交易日) */ + CHANGE_PCT, + /** 当日均价绝对值 */ + AVG_PRICE; + + public static RankingType fromParam(String raw) { + if (raw == null || raw.isBlank()) { + return CHANGE_PCT; + } + return switch (raw.trim().toLowerCase()) { + case "change_pct", "change-pct", "change" -> CHANGE_PCT; + case "avg_price", "avg-price", "price_rank" -> AVG_PRICE; + default -> + throw new ResponseStatusException( + HttpStatus.BAD_REQUEST, "type 须为 change_pct 或 avg_price"); + }; + } +} diff --git a/server/src/main/java/com/fao/platform/api/RestExceptionHandler.java b/server/src/main/java/com/fao/platform/api/RestExceptionHandler.java new file mode 100644 index 0000000..abeda9a --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/RestExceptionHandler.java @@ -0,0 +1,20 @@ +package com.fao.platform.api; + +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.server.ResponseStatusException; + +@RestControllerAdvice +public class RestExceptionHandler { + + @ExceptionHandler(ResponseStatusException.class) + public ResponseEntity> handle(ResponseStatusException ex) { + return ResponseEntity.status(ex.getStatusCode()) + .body( + Map.of( + "error", ex.getStatusCode().value(), + "message", ex.getReason() == null ? "" : ex.getReason())); + } +} diff --git a/server/src/main/java/com/fao/platform/api/VarietyController.java b/server/src/main/java/com/fao/platform/api/VarietyController.java new file mode 100644 index 0000000..2ef7647 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/VarietyController.java @@ -0,0 +1,68 @@ +package com.fao.platform.api; + +import com.fao.platform.api.dto.DailyPointDto; +import com.fao.platform.api.dto.PageResponseDto; +import com.fao.platform.api.dto.SeriesResponseDto; +import com.fao.platform.api.dto.VarietyDetailDto; +import com.fao.platform.api.dto.VarietySummaryDto; +import com.fao.platform.service.MarketCatalogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "品种与行情") +@RestController +@RequestMapping("/api/v1/varieties") +public class VarietyController { + + private final MarketCatalogService catalogService; + + public VarietyController(MarketCatalogService catalogService) { + this.catalogService = catalogService; + } + + @Operation(summary = "品种联想/搜索") + @GetMapping("/search") + public List search( + @RequestParam("q") String q, @RequestParam(defaultValue = "20") int limit) { + if (q.isBlank()) { + return List.of(); + } + return catalogService.searchVarieties(q.strip(), limit); + } + + @Operation(summary = "品种详情(含最近一日行情)") + @GetMapping("/{id}") + public VarietyDetailDto detail(@PathVariable long id) { + return catalogService.getVarietyDetail(id); + } + + @Operation(summary = "时间序列(默认近 30 个自然日,含起止)") + @GetMapping("/{id}/series") + public SeriesResponseDto series( + @PathVariable long id, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(defaultValue = "index") String metric) { + MarketMetric m = MarketMetric.fromParam(metric); + return catalogService.getSeries(id, m, from, to); + } + + @Operation(summary = "历史分页(按 biz_date 倒序)") + @GetMapping("/{id}/history") + public PageResponseDto history( + @PathVariable long id, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return catalogService.getHistory(id, from, to, page, size); + } +} diff --git a/server/src/main/java/com/fao/platform/api/dto/ConsumeRawRequest.java b/server/src/main/java/com/fao/platform/api/dto/ConsumeRawRequest.java new file mode 100644 index 0000000..b5ec8f7 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/dto/ConsumeRawRequest.java @@ -0,0 +1,5 @@ +package com.fao.platform.api.dto; + +import java.util.List; + +public record ConsumeRawRequest(Integer limit, List sourceCodes) {} diff --git a/server/src/main/java/com/fao/platform/api/dto/ConsumeRawResponse.java b/server/src/main/java/com/fao/platform/api/dto/ConsumeRawResponse.java new file mode 100644 index 0000000..073456e --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/dto/ConsumeRawResponse.java @@ -0,0 +1,7 @@ +package com.fao.platform.api.dto; + +public record ConsumeRawResponse( + int batchSize, + int eventsDone, + int eventsFailed, + int quotesUpserted) {} diff --git a/server/src/main/java/com/fao/platform/api/dto/DailyPointDto.java b/server/src/main/java/com/fao/platform/api/dto/DailyPointDto.java new file mode 100644 index 0000000..782fd21 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/dto/DailyPointDto.java @@ -0,0 +1,11 @@ +package com.fao.platform.api.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record DailyPointDto( + LocalDate bizDate, + BigDecimal avgPriceYuanPerKg, + BigDecimal indexValue, + String indexCode, + int configVersion) {} diff --git a/server/src/main/java/com/fao/platform/api/dto/LatestQuoteDto.java b/server/src/main/java/com/fao/platform/api/dto/LatestQuoteDto.java new file mode 100644 index 0000000..cbc6657 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/dto/LatestQuoteDto.java @@ -0,0 +1,11 @@ +package com.fao.platform.api.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record LatestQuoteDto( + LocalDate bizDate, + BigDecimal avgPriceYuanPerKg, + BigDecimal indexValue, + String indexCode, + int configVersion) {} diff --git a/server/src/main/java/com/fao/platform/api/dto/PageResponseDto.java b/server/src/main/java/com/fao/platform/api/dto/PageResponseDto.java new file mode 100644 index 0000000..73dba8c --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/dto/PageResponseDto.java @@ -0,0 +1,6 @@ +package com.fao.platform.api.dto; + +import java.util.List; + +public record PageResponseDto( + List content, long totalElements, int page, int size, int totalPages) {} diff --git a/server/src/main/java/com/fao/platform/api/dto/RankingItemDto.java b/server/src/main/java/com/fao/platform/api/dto/RankingItemDto.java new file mode 100644 index 0000000..9a43fa4 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/dto/RankingItemDto.java @@ -0,0 +1,15 @@ +package com.fao.platform.api.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record RankingItemDto( + long varietyId, + String code, + String displayName, + LocalDate bizDate, + BigDecimal valueToday, + BigDecimal valueYesterday, + BigDecimal changePct, + String indexCode, + int configVersion) {} diff --git a/server/src/main/java/com/fao/platform/api/dto/SeriesResponseDto.java b/server/src/main/java/com/fao/platform/api/dto/SeriesResponseDto.java new file mode 100644 index 0000000..3fce48f --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/dto/SeriesResponseDto.java @@ -0,0 +1,9 @@ +package com.fao.platform.api.dto; + +import java.util.List; + +public record SeriesResponseDto( + long varietyId, + String displayName, + String metric, + List points) {} diff --git a/server/src/main/java/com/fao/platform/api/dto/VarietyDetailDto.java b/server/src/main/java/com/fao/platform/api/dto/VarietyDetailDto.java new file mode 100644 index 0000000..a98ef5a --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/dto/VarietyDetailDto.java @@ -0,0 +1,8 @@ +package com.fao.platform.api.dto; + +public record VarietyDetailDto( + long id, + String code, + String displayName, + String aliases, + LatestQuoteDto latest) {} diff --git a/server/src/main/java/com/fao/platform/api/dto/VarietySummaryDto.java b/server/src/main/java/com/fao/platform/api/dto/VarietySummaryDto.java new file mode 100644 index 0000000..6067372 --- /dev/null +++ b/server/src/main/java/com/fao/platform/api/dto/VarietySummaryDto.java @@ -0,0 +1,3 @@ +package com.fao.platform.api.dto; + +public record VarietySummaryDto(long id, String code, String displayName, String aliases) {} diff --git a/server/src/main/java/com/fao/platform/config/WebMvcConfig.java b/server/src/main/java/com/fao/platform/config/WebMvcConfig.java new file mode 100644 index 0000000..19c0c7c --- /dev/null +++ b/server/src/main/java/com/fao/platform/config/WebMvcConfig.java @@ -0,0 +1,23 @@ +package com.fao.platform.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * 允许本地前端直连 Java(如设置 VITE_API_BASE=http://127.0.0.1:8080)。 + * 开发态走 Vite 代理时可不依赖此项。 + */ +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry + .addMapping("/api/**") + .allowedOriginPatterns("http://localhost:*", "http://127.0.0.1:*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); + } +} diff --git a/server/src/main/java/com/fao/platform/domain/DailyMarket.java b/server/src/main/java/com/fao/platform/domain/DailyMarket.java new file mode 100644 index 0000000..d33da57 --- /dev/null +++ b/server/src/main/java/com/fao/platform/domain/DailyMarket.java @@ -0,0 +1,118 @@ +package com.fao.platform.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "daily_market", + uniqueConstraints = {@UniqueConstraint(name = "uk_daily_variety_date", columnNames = {"variety_id", "biz_date"})}) +public class DailyMarket { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false, fetch = FetchType.LAZY) + @JoinColumn(name = "variety_id", nullable = false) + private Variety variety; + + @Column(name = "biz_date", nullable = false) + private LocalDate bizDate; + + @Column(name = "avg_price_yuan_per_kg", nullable = false, precision = 12, scale = 4) + private BigDecimal avgPriceYuanPerKg; + + @Column(name = "index_value", precision = 14, scale = 4) + private BigDecimal indexValue; + + @Column(name = "index_code", nullable = false, length = 32) + private String indexCode = "VEI"; + + @Column(name = "config_version", nullable = false) + private Integer configVersion = 1; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + if (indexCode == null) { + indexCode = "VEI"; + } + if (configVersion == null) { + configVersion = 1; + } + } + + public Long getId() { + return id; + } + + public Variety getVariety() { + return variety; + } + + public void setVariety(Variety variety) { + this.variety = variety; + } + + public LocalDate getBizDate() { + return bizDate; + } + + public void setBizDate(LocalDate bizDate) { + this.bizDate = bizDate; + } + + public BigDecimal getAvgPriceYuanPerKg() { + return avgPriceYuanPerKg; + } + + public void setAvgPriceYuanPerKg(BigDecimal avgPriceYuanPerKg) { + this.avgPriceYuanPerKg = avgPriceYuanPerKg; + } + + public BigDecimal getIndexValue() { + return indexValue; + } + + public void setIndexValue(BigDecimal indexValue) { + this.indexValue = indexValue; + } + + public String getIndexCode() { + return indexCode; + } + + public void setIndexCode(String indexCode) { + this.indexCode = indexCode; + } + + public Integer getConfigVersion() { + return configVersion; + } + + public void setConfigVersion(Integer configVersion) { + this.configVersion = configVersion; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/server/src/main/java/com/fao/platform/domain/DailyMarketRepository.java b/server/src/main/java/com/fao/platform/domain/DailyMarketRepository.java new file mode 100644 index 0000000..2327693 --- /dev/null +++ b/server/src/main/java/com/fao/platform/domain/DailyMarketRepository.java @@ -0,0 +1,28 @@ +package com.fao.platform.domain; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface DailyMarketRepository extends JpaRepository { + + Optional findByVariety_IdAndBizDate(long varietyId, LocalDate bizDate); + + Optional findTopByVarietyIdOrderByBizDateDesc(long varietyId); + + List findByVarietyIdAndBizDateBetweenOrderByBizDateAsc( + long varietyId, LocalDate from, LocalDate to); + + Page findByVarietyIdAndBizDateBetweenOrderByBizDateDesc( + long varietyId, LocalDate from, LocalDate to, Pageable pageable); + + List findByBizDate(LocalDate bizDate); + + @Query("SELECT MAX(d.bizDate) FROM DailyMarket d") + Optional findLatestBizDate(); +} diff --git a/server/src/main/java/com/fao/platform/domain/IngestRawEvent.java b/server/src/main/java/com/fao/platform/domain/IngestRawEvent.java new file mode 100644 index 0000000..746dc51 --- /dev/null +++ b/server/src/main/java/com/fao/platform/domain/IngestRawEvent.java @@ -0,0 +1,157 @@ +package com.fao.platform.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "ingest_raw_event") +public class IngestRawEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "source_code", nullable = false, length = 64) + private String sourceCode; + + @Column(name = "adapter_name", nullable = false, length = 128) + private String adapterName; + + @Column(name = "fetched_at", nullable = false) + private LocalDateTime fetchedAt; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + @Enumerated(EnumType.STRING) + @Column(name = "ingestion_status", nullable = false, length = 16) + private IngestionStatus ingestionStatus = IngestionStatus.PENDING; + + @Column(name = "ingestion_error", length = 512) + private String ingestionError; + + @Column(name = "request_url", length = 2048) + private String requestUrl; + + @Column(name = "http_status") + private Integer httpStatus; + + @Column(name = "content_fingerprint", nullable = false, length = 64) + private String contentFingerprint; + + @Column(name = "payload_json", nullable = false, columnDefinition = "LONGTEXT") + private String payloadJson; + + @Column(name = "raw_excerpt", columnDefinition = "LONGTEXT") + private String rawExcerpt; + + @PrePersist + void onCreate() { + if (fetchedAt == null) { + fetchedAt = LocalDateTime.now(); + } + if (ingestionStatus == null) { + ingestionStatus = IngestionStatus.PENDING; + } + } + + public Long getId() { + return id; + } + + public String getSourceCode() { + return sourceCode; + } + + public void setSourceCode(String sourceCode) { + this.sourceCode = sourceCode; + } + + public String getAdapterName() { + return adapterName; + } + + public void setAdapterName(String adapterName) { + this.adapterName = adapterName; + } + + public LocalDateTime getFetchedAt() { + return fetchedAt; + } + + public void setFetchedAt(LocalDateTime fetchedAt) { + this.fetchedAt = fetchedAt; + } + + public LocalDateTime getProcessedAt() { + return processedAt; + } + + public void setProcessedAt(LocalDateTime processedAt) { + this.processedAt = processedAt; + } + + public IngestionStatus getIngestionStatus() { + return ingestionStatus; + } + + public void setIngestionStatus(IngestionStatus ingestionStatus) { + this.ingestionStatus = ingestionStatus; + } + + public String getIngestionError() { + return ingestionError; + } + + public void setIngestionError(String ingestionError) { + this.ingestionError = ingestionError; + } + + public String getRequestUrl() { + return requestUrl; + } + + public void setRequestUrl(String requestUrl) { + this.requestUrl = requestUrl; + } + + public Integer getHttpStatus() { + return httpStatus; + } + + public void setHttpStatus(Integer httpStatus) { + this.httpStatus = httpStatus; + } + + public String getContentFingerprint() { + return contentFingerprint; + } + + public void setContentFingerprint(String contentFingerprint) { + this.contentFingerprint = contentFingerprint; + } + + public String getPayloadJson() { + return payloadJson; + } + + public void setPayloadJson(String payloadJson) { + this.payloadJson = payloadJson; + } + + public String getRawExcerpt() { + return rawExcerpt; + } + + public void setRawExcerpt(String rawExcerpt) { + this.rawExcerpt = rawExcerpt; + } +} diff --git a/server/src/main/java/com/fao/platform/domain/IngestRawEventRepository.java b/server/src/main/java/com/fao/platform/domain/IngestRawEventRepository.java new file mode 100644 index 0000000..7bf725b --- /dev/null +++ b/server/src/main/java/com/fao/platform/domain/IngestRawEventRepository.java @@ -0,0 +1,15 @@ +package com.fao.platform.domain; + +import java.util.Collection; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface IngestRawEventRepository extends JpaRepository { + + List findByIngestionStatusOrderByIdAsc( + IngestionStatus status, Pageable pageable); + + List findByIngestionStatusAndSourceCodeInOrderByIdAsc( + IngestionStatus status, Collection sourceCodes, Pageable pageable); +} diff --git a/server/src/main/java/com/fao/platform/domain/IngestionStatus.java b/server/src/main/java/com/fao/platform/domain/IngestionStatus.java new file mode 100644 index 0000000..65f95ab --- /dev/null +++ b/server/src/main/java/com/fao/platform/domain/IngestionStatus.java @@ -0,0 +1,7 @@ +package com.fao.platform.domain; + +public enum IngestionStatus { + PENDING, + DONE, + FAILED +} diff --git a/server/src/main/java/com/fao/platform/domain/Variety.java b/server/src/main/java/com/fao/platform/domain/Variety.java new file mode 100644 index 0000000..2369b8e --- /dev/null +++ b/server/src/main/java/com/fao/platform/domain/Variety.java @@ -0,0 +1,87 @@ +package com.fao.platform.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "variety") +public class Variety { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 64) + private String code; + + @Column(name = "display_name", nullable = false, length = 128) + private String displayName; + + @Column(length = 512) + private String aliases; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + void onCreate() { + LocalDateTime n = LocalDateTime.now(); + if (createdAt == null) { + createdAt = n; + } + if (updatedAt == null) { + updatedAt = n; + } + } + + @PreUpdate + void onUpdate() { + updatedAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public String getAliases() { + return aliases; + } + + public void setAliases(String aliases) { + this.aliases = aliases; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } +} diff --git a/server/src/main/java/com/fao/platform/domain/VarietyRepository.java b/server/src/main/java/com/fao/platform/domain/VarietyRepository.java new file mode 100644 index 0000000..1399b45 --- /dev/null +++ b/server/src/main/java/com/fao/platform/domain/VarietyRepository.java @@ -0,0 +1,22 @@ +package com.fao.platform.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface VarietyRepository extends JpaRepository { + + Optional findByCode(String code); + + @Query( + """ + SELECT v FROM Variety v + WHERE LOWER(v.displayName) LIKE LOWER(CONCAT('%', :q, '%')) + OR (v.aliases IS NOT NULL AND LOWER(v.aliases) LIKE LOWER(CONCAT('%', :q, '%'))) + ORDER BY v.displayName ASC + """) + List search(@Param("q") String q, Pageable pageable); +} diff --git a/server/src/main/java/com/fao/platform/governance/GovernanceConfig.java b/server/src/main/java/com/fao/platform/governance/GovernanceConfig.java new file mode 100644 index 0000000..3a6a3de --- /dev/null +++ b/server/src/main/java/com/fao/platform/governance/GovernanceConfig.java @@ -0,0 +1,19 @@ +package com.fao.platform.governance; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@Configuration +@EnableScheduling +@EnableConfigurationProperties(GovernanceProperties.class) +public class GovernanceConfig { + + @Bean(name = "governanceTransactionTemplate") + TransactionTemplate governanceTransactionTemplate(PlatformTransactionManager transactionManager) { + return new TransactionTemplate(transactionManager); + } +} diff --git a/server/src/main/java/com/fao/platform/governance/GovernanceProperties.java b/server/src/main/java/com/fao/platform/governance/GovernanceProperties.java new file mode 100644 index 0000000..9e6a7e0 --- /dev/null +++ b/server/src/main/java/com/fao/platform/governance/GovernanceProperties.java @@ -0,0 +1,95 @@ +package com.fao.platform.governance; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +@ConfigurationProperties(prefix = "fao.governance") +public class GovernanceProperties { + + /** 与请求头 X-Fao-Governance-Token 对齐;未配置时拒绝消费接口。 */ + private String token = ""; + + /** + * 全局 source_code 白名单;为空则不过滤。定时任务与未显式传 sourceCodes 的 HTTP 请求共用。 + */ + private List sourceCodes = new ArrayList<>(); + + private Scheduled scheduled = new Scheduled(); + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public List getSourceCodes() { + return sourceCodes; + } + + public void setSourceCodes(List sourceCodes) { + this.sourceCodes = sourceCodes == null ? new ArrayList<>() : sourceCodes; + } + + public Scheduled getScheduled() { + return scheduled; + } + + public void setScheduled(Scheduled scheduled) { + this.scheduled = scheduled != null ? scheduled : new Scheduled(); + } + + public void assertToken(String headerValue) { + if (token == null || token.isBlank()) { + throw new ResponseStatusException( + HttpStatus.SERVICE_UNAVAILABLE, "未配置 fao.governance.token(环境变量 FAO_GOVERNANCE_TOKEN)"); + } + if (headerValue == null || !token.equals(headerValue.trim())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "治理 token 无效"); + } + } + + /** 定时消费 raw(默认关闭,避免未预期写库)。 */ + public static class Scheduled { + private boolean enabled = false; + private long fixedDelayMs = 600_000L; + private long initialDelayMs = 60_000L; + private int batchLimit = 30; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public long getFixedDelayMs() { + return fixedDelayMs; + } + + public void setFixedDelayMs(long fixedDelayMs) { + this.fixedDelayMs = fixedDelayMs; + } + + public long getInitialDelayMs() { + return initialDelayMs; + } + + public void setInitialDelayMs(long initialDelayMs) { + this.initialDelayMs = initialDelayMs; + } + + public int getBatchLimit() { + return batchLimit; + } + + public void setBatchLimit(int batchLimit) { + this.batchLimit = batchLimit; + } + } +} diff --git a/server/src/main/java/com/fao/platform/governance/RawGovernanceScheduler.java b/server/src/main/java/com/fao/platform/governance/RawGovernanceScheduler.java new file mode 100644 index 0000000..cd57df3 --- /dev/null +++ b/server/src/main/java/com/fao/platform/governance/RawGovernanceScheduler.java @@ -0,0 +1,50 @@ +package com.fao.platform.governance; + +import com.fao.platform.api.dto.ConsumeRawResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 定时拉取并消费 PENDING raw。默认关闭,通过 {@code fao.governance.scheduled.enabled=true} 开启。 + */ +@Component +@ConditionalOnProperty( + prefix = "fao.governance.scheduled", + name = "enabled", + havingValue = "true") +public class RawGovernanceScheduler { + + private static final Logger log = LoggerFactory.getLogger(RawGovernanceScheduler.class); + + private final RawGovernanceService rawGovernanceService; + private final GovernanceProperties governanceProperties; + + public RawGovernanceScheduler( + RawGovernanceService rawGovernanceService, GovernanceProperties governanceProperties) { + this.rawGovernanceService = rawGovernanceService; + this.governanceProperties = governanceProperties; + } + + @Scheduled( + initialDelayString = "${fao.governance.scheduled.initial-delay-ms:60000}", + fixedDelayString = "${fao.governance.scheduled.fixed-delay-ms:600000}") + public void consumePendingRaw() { + int limit = Math.max(1, Math.min(200, governanceProperties.getScheduled().getBatchLimit())); + try { + ConsumeRawResponse res = rawGovernanceService.consume(limit, null); + if (res.eventsDone() > 0 || res.eventsFailed() > 0) { + log.info( + "治理定时任务完成 batchSize={} done={} failed={} quotes={}", + res.batchSize(), + res.eventsDone(), + res.eventsFailed(), + res.quotesUpserted()); + } + } catch (RuntimeException ex) { + log.warn("治理定时任务执行异常", ex); + } + } +} diff --git a/server/src/main/java/com/fao/platform/governance/RawGovernanceService.java b/server/src/main/java/com/fao/platform/governance/RawGovernanceService.java new file mode 100644 index 0000000..8d7f6bc --- /dev/null +++ b/server/src/main/java/com/fao/platform/governance/RawGovernanceService.java @@ -0,0 +1,240 @@ +package com.fao.platform.governance; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fao.platform.api.dto.ConsumeRawResponse; +import com.fao.platform.domain.DailyMarket; +import com.fao.platform.domain.DailyMarketRepository; +import com.fao.platform.domain.IngestRawEvent; +import com.fao.platform.domain.IngestRawEventRepository; +import com.fao.platform.domain.IngestionStatus; +import com.fao.platform.domain.Variety; +import com.fao.platform.domain.VarietyRepository; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +@Service +public class RawGovernanceService { + + private static final String KIND_PRICE_QUOTE_BATCH = "price_quote_batch"; + + private final IngestRawEventRepository ingestRawEventRepository; + private final VarietyRepository varietyRepository; + private final DailyMarketRepository dailyMarketRepository; + private final ObjectMapper objectMapper; + private final TransactionTemplate governanceTransactionTemplate; + private final GovernanceProperties governanceProperties; + + public RawGovernanceService( + IngestRawEventRepository ingestRawEventRepository, + VarietyRepository varietyRepository, + DailyMarketRepository dailyMarketRepository, + ObjectMapper objectMapper, + @Qualifier("governanceTransactionTemplate") TransactionTemplate governanceTransactionTemplate, + GovernanceProperties governanceProperties) { + this.ingestRawEventRepository = ingestRawEventRepository; + this.varietyRepository = varietyRepository; + this.dailyMarketRepository = dailyMarketRepository; + this.objectMapper = objectMapper; + this.governanceTransactionTemplate = governanceTransactionTemplate; + this.governanceProperties = governanceProperties; + } + + public ConsumeRawResponse consume(int limit) { + return consume(limit, null); + } + + /** + * @param sourceCodesOverride 非空时仅消费这些 source_code;为空时使用配置项 fao.governance.source-codes;仍为空则不过滤。 + */ + public ConsumeRawResponse consume(int limit, List sourceCodesOverride) { + int safe = Math.min(Math.max(limit, 1), 200); + List sourceFilter = effectiveSourceCodes(sourceCodesOverride); + var pending = + sourceFilter.isEmpty() + ? ingestRawEventRepository.findByIngestionStatusOrderByIdAsc( + IngestionStatus.PENDING, PageRequest.of(0, safe)) + : ingestRawEventRepository.findByIngestionStatusAndSourceCodeInOrderByIdAsc( + IngestionStatus.PENDING, sourceFilter, PageRequest.of(0, safe)); + int quotes = 0; + int eventsDone = 0; + int eventsFailed = 0; + for (IngestRawEvent head : pending) { + ProcessResult r = + governanceTransactionTemplate.execute(status -> processOneTransaction(head.getId())); + switch (r.status()) { + case DONE -> { + eventsDone++; + quotes += r.quotesUpserted(); + } + case FAILED -> eventsFailed++; + case SKIP -> {} + } + } + return new ConsumeRawResponse(pending.size(), eventsDone, eventsFailed, quotes); + } + + private List effectiveSourceCodes(List override) { + List fromOverride = normalizeCodes(override); + if (!fromOverride.isEmpty()) { + return fromOverride; + } + return normalizeCodes(governanceProperties.getSourceCodes()); + } + + private static List normalizeCodes(List raw) { + if (raw == null || raw.isEmpty()) { + return List.of(); + } + List out = new ArrayList<>(); + for (String s : raw) { + if (s == null) { + continue; + } + String t = s.trim(); + if (!t.isEmpty() && !out.contains(t)) { + out.add(t); + } + } + return out; + } + + private ProcessResult processOneTransaction(long id) { + Optional opt = ingestRawEventRepository.findById(id); + if (opt.isEmpty()) { + return ProcessResult.skip(); + } + IngestRawEvent e = opt.get(); + if (e.getIngestionStatus() != IngestionStatus.PENDING) { + return ProcessResult.skip(); + } + try { + int n = processPayload(e); + e.setIngestionStatus(IngestionStatus.DONE); + e.setProcessedAt(LocalDateTime.now()); + e.setIngestionError(null); + ingestRawEventRepository.save(e); + return ProcessResult.done(n); + } catch (RuntimeException ex) { + e.setIngestionStatus(IngestionStatus.FAILED); + e.setProcessedAt(LocalDateTime.now()); + e.setIngestionError(truncate(ex.getMessage(), 512)); + ingestRawEventRepository.save(e); + return ProcessResult.failed(); + } + } + + private int processPayload(IngestRawEvent raw) { + try { + JsonNode root = objectMapper.readTree(raw.getPayloadJson()); + String kind = root.path("kind").asText(""); + if (!KIND_PRICE_QUOTE_BATCH.equals(kind)) { + throw new IllegalArgumentException("不支持的 payload.kind: " + kind); + } + JsonNode items = root.path("items"); + if (!items.isArray() || items.isEmpty()) { + throw new IllegalArgumentException("items 必须为非空数组"); + } + int n = 0; + for (JsonNode item : items) { + upsertOneQuote(item); + n++; + } + return n; + } catch (com.fasterxml.jackson.core.JsonProcessingException jpe) { + throw new IllegalArgumentException("payload 非合法 JSON: " + jpe.getMessage(), jpe); + } + } + + private void upsertOneQuote(JsonNode item) { + String code = requireScalar(item, "variety_code"); + String name = requireScalar(item, "variety_name"); + LocalDate bizDate = LocalDate.parse(requireScalar(item, "biz_date")); + BigDecimal price = + new BigDecimal(requireScalar(item, "avg_price_yuan_per_kg")) + .setScale(4, RoundingMode.HALF_UP); + + Variety variety = + varietyRepository + .findByCode(code) + .orElseGet( + () -> { + Variety v = new Variety(); + v.setCode(code); + v.setDisplayName(name); + v.setAliases(null); + return varietyRepository.save(v); + }); + + Optional existing = + dailyMarketRepository.findByVariety_IdAndBizDate(variety.getId(), bizDate); + if (existing.isPresent()) { + DailyMarket d = existing.get(); + d.setAvgPriceYuanPerKg(price); + dailyMarketRepository.save(d); + } else { + DailyMarket d = new DailyMarket(); + d.setVariety(variety); + d.setBizDate(bizDate); + d.setAvgPriceYuanPerKg(price); + d.setIndexValue(null); + d.setIndexCode("VEI"); + d.setConfigVersion(1); + dailyMarketRepository.save(d); + } + } + + private static String requireScalar(JsonNode item, String field) { + JsonNode n = item.get(field); + if (n == null || n.isNull()) { + throw new IllegalArgumentException("缺少字段: " + field); + } + if (n.isNumber()) { + return n.asText(); + } + if (n.isTextual()) { + String t = n.asText().trim(); + if (t.isEmpty()) { + throw new IllegalArgumentException("字段为空: " + field); + } + return t; + } + throw new IllegalArgumentException("非法字段类型: " + field); + } + + private static String truncate(String s, int max) { + if (s == null) { + return null; + } + return s.length() <= max ? s : s.substring(0, max); + } + + private record ProcessResult(Status status, int quotesUpserted) { + static ProcessResult skip() { + return new ProcessResult(Status.SKIP, 0); + } + + static ProcessResult done(int quotes) { + return new ProcessResult(Status.DONE, quotes); + } + + static ProcessResult failed() { + return new ProcessResult(Status.FAILED, 0); + } + + enum Status { + SKIP, + DONE, + FAILED + } + } +} diff --git a/server/src/main/java/com/fao/platform/service/MarketCatalogService.java b/server/src/main/java/com/fao/platform/service/MarketCatalogService.java new file mode 100644 index 0000000..2198943 --- /dev/null +++ b/server/src/main/java/com/fao/platform/service/MarketCatalogService.java @@ -0,0 +1,210 @@ +package com.fao.platform.service; + +import com.fao.platform.api.MarketMetric; +import com.fao.platform.api.RankingType; +import com.fao.platform.api.dto.DailyPointDto; +import com.fao.platform.api.dto.LatestQuoteDto; +import com.fao.platform.api.dto.PageResponseDto; +import com.fao.platform.api.dto.RankingItemDto; +import com.fao.platform.api.dto.SeriesResponseDto; +import com.fao.platform.api.dto.VarietyDetailDto; +import com.fao.platform.api.dto.VarietySummaryDto; +import com.fao.platform.domain.DailyMarket; +import com.fao.platform.domain.DailyMarketRepository; +import com.fao.platform.domain.Variety; +import com.fao.platform.domain.VarietyRepository; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +@Transactional(readOnly = true) +public class MarketCatalogService { + + private final VarietyRepository varietyRepository; + private final DailyMarketRepository dailyMarketRepository; + + public MarketCatalogService( + VarietyRepository varietyRepository, DailyMarketRepository dailyMarketRepository) { + this.varietyRepository = varietyRepository; + this.dailyMarketRepository = dailyMarketRepository; + } + + public List searchVarieties(String q, int limit) { + int safe = Math.min(Math.max(limit, 1), 100); + return varietyRepository.search(q, PageRequest.of(0, safe)).stream() + .map(this::toSummary) + .toList(); + } + + public VarietyDetailDto getVarietyDetail(long id) { + Variety v = requireVariety(id); + LatestQuoteDto latest = + dailyMarketRepository + .findTopByVarietyIdOrderByBizDateDesc(id) + .map(this::toLatest) + .orElse(null); + return new VarietyDetailDto( + v.getId(), v.getCode(), v.getDisplayName(), v.getAliases(), latest); + } + + public SeriesResponseDto getSeries(long id, MarketMetric metric, LocalDate from, LocalDate to) { + Variety v = requireVariety(id); + LocalDateRange range = resolveRange(from, to); + List points = + dailyMarketRepository + .findByVarietyIdAndBizDateBetweenOrderByBizDateAsc(id, range.from(), range.to()) + .stream() + .map(this::toPoint) + .toList(); + return new SeriesResponseDto( + v.getId(), v.getDisplayName(), metric.name().toLowerCase(), points); + } + + public PageResponseDto getHistory( + long id, LocalDate from, LocalDate to, int page, int size) { + requireVariety(id); + LocalDateRange range = resolveRange(from, to); + int safeSize = Math.min(Math.max(size, 1), 200); + int safePage = Math.max(page, 0); + Page result = + dailyMarketRepository.findByVarietyIdAndBizDateBetweenOrderByBizDateDesc( + id, + range.from(), + range.to(), + PageRequest.of(safePage, safeSize, Sort.by(Sort.Direction.DESC, "bizDate"))); + List content = result.getContent().stream().map(this::toPoint).toList(); + return new PageResponseDto<>( + content, + result.getTotalElements(), + result.getNumber(), + result.getSize(), + result.getTotalPages()); + } + + public List getRankings( + RankingType type, MarketMetric metric, LocalDate bizDate, int limit) { + LocalDate d = + bizDate != null + ? bizDate + : dailyMarketRepository + .findLatestBizDate() + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "暂无行情数据用于榜单")); + List today = dailyMarketRepository.findByBizDate(d); + if (today.isEmpty()) { + return List.of(); + } + LocalDate prev = d.minusDays(1); + List yList = dailyMarketRepository.findByBizDate(prev); + Map yMap = + yList.stream() + .collect( + Collectors.toMap(dm -> dm.getVariety().getId(), Function.identity(), (a, b) -> a)); + + List rows = new ArrayList<>(); + for (DailyMarket t : today) { + Variety variety = t.getVariety(); + DailyMarket y = yMap.get(variety.getId()); + BigDecimal vt = metricValue(t, metric); + BigDecimal vy = y == null ? null : metricValue(y, metric); + BigDecimal pct = null; + if (vy != null && vy.compareTo(BigDecimal.ZERO) != 0 && vt != null) { + pct = + vt.subtract(vy) + .divide(vy, 6, RoundingMode.HALF_UP) + .multiply(BigDecimal.valueOf(100)) + .setScale(2, RoundingMode.HALF_UP); + } + rows.add( + new RankingItemDto( + variety.getId(), + variety.getCode(), + variety.getDisplayName(), + d, + vt, + vy, + pct, + t.getIndexCode(), + t.getConfigVersion())); + } + + Comparator cmp = + switch (type) { + case CHANGE_PCT -> + Comparator.comparing( + RankingItemDto::changePct, + Comparator.nullsLast(Comparator.naturalOrder())) + .reversed(); + case AVG_PRICE -> + Comparator.comparing( + RankingItemDto::valueToday, + Comparator.nullsLast(Comparator.naturalOrder())) + .reversed(); + }; + int safeLimit = Math.min(Math.max(limit, 1), 100); + return rows.stream().sorted(cmp).limit(safeLimit).toList(); + } + + private Variety requireVariety(long id) { + return varietyRepository + .findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "未找到品种")); + } + + private VarietySummaryDto toSummary(Variety v) { + return new VarietySummaryDto(v.getId(), v.getCode(), v.getDisplayName(), v.getAliases()); + } + + private DailyPointDto toPoint(DailyMarket d) { + return new DailyPointDto( + d.getBizDate(), + d.getAvgPriceYuanPerKg(), + d.getIndexValue(), + d.getIndexCode(), + d.getConfigVersion()); + } + + private LatestQuoteDto toLatest(DailyMarket d) { + return new LatestQuoteDto( + d.getBizDate(), + d.getAvgPriceYuanPerKg(), + d.getIndexValue(), + d.getIndexCode(), + d.getConfigVersion()); + } + + private BigDecimal metricValue(DailyMarket d, MarketMetric metric) { + return switch (metric) { + case PRICE -> d.getAvgPriceYuanPerKg(); + case INDEX -> d.getIndexValue(); + }; + } + + private LocalDateRange resolveRange(LocalDate from, LocalDate to) { + LocalDate end = Objects.requireNonNullElse(to, LocalDate.now()); + LocalDate start = Objects.requireNonNullElseGet(from, () -> end.minusDays(29)); + if (start.isAfter(end)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "from 不能晚于 to"); + } + return new LocalDateRange(start, end); + } + + private record LocalDateRange(LocalDate from, LocalDate to) {} +} diff --git a/server/src/main/resources/application-scheduled.yml.example b/server/src/main/resources/application-scheduled.yml.example new file mode 100644 index 0000000..f130c4a --- /dev/null +++ b/server/src/main/resources/application-scheduled.yml.example @@ -0,0 +1,24 @@ +# 复制为 application-scheduled.yml 后启用 Profile:scheduled +# 启动示例:mvn -f server/pom.xml spring-boot:run -Dspring-boot.run.profiles=scheduled +# 或:java -jar app.jar --spring.profiles.active=scheduled +# +# 请勿在未设置 FAO_GOVERNANCE_TOKEN 的生产镜像中开启治理 HTTP;定时任务仅消费 raw,仍建议配置 token 以备手工触发。 + +spring: + config: + activate: + on-profile: scheduled + +fao: + governance: + token: ${FAO_GOVERNANCE_TOKEN:} + # 生产建议填写白名单,例如: ["XFD"] + source-codes: [] + scheduled: + enabled: true + # 应用启动后首次执行的等待时间(毫秒) + initial-delay-ms: 120000 + # 上一轮 consume 结束后再等待多久执行下一轮(毫秒);默认示例 5 分钟 + fixed-delay-ms: 300000 + # 每轮最多处理的 raw 条数(服务端再限制在 1~200) + batch-limit: 50 diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml new file mode 100644 index 0000000..08516e5 --- /dev/null +++ b/server/src/main/resources/application.yml @@ -0,0 +1,38 @@ +server: + port: 8080 + +spring: + application: + name: fao-server + datasource: + url: jdbc:mysql://localhost:3306/fao?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai + username: fao + password: changeme + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + open-in-view: false + flyway: + enabled: false + +fao: + governance: + token: ${FAO_GOVERNANCE_TOKEN:} + # 非空时仅处理列出的 source_code(定时任务与未传 sourceCodes 的 HTTP 请求) + source-codes: [] + scheduled: + enabled: false + initial-delay-ms: 60000 + fixed-delay-ms: 600000 + batch-limit: 30 + +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + show-details: when-authorized diff --git a/server/src/main/resources/db/migration/V1__baseline.sql b/server/src/main/resources/db/migration/V1__baseline.sql new file mode 100644 index 0000000..ef2e85e --- /dev/null +++ b/server/src/main/resources/db/migration/V1__baseline.sql @@ -0,0 +1,3 @@ +-- 基线脚本:表结构在后续 V2、V3… 中追加(如 raw、维表、index_series)。 +-- 占位语句便于 Flyway 在空库上成功执行一次迁移。 +SET NAMES utf8mb4; diff --git a/server/src/main/resources/db/migration/V2__mvp_core.sql b/server/src/main/resources/db/migration/V2__mvp_core.sql new file mode 100644 index 0000000..c6ec293 --- /dev/null +++ b/server/src/main/resources/db/migration/V2__mvp_core.sql @@ -0,0 +1,28 @@ +SET NAMES utf8mb4; + +CREATE TABLE variety ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(64) NOT NULL, + display_name VARCHAR(128) NOT NULL, + aliases VARCHAR(512) DEFAULT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_variety_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE daily_market ( + id BIGINT NOT NULL AUTO_INCREMENT, + variety_id BIGINT NOT NULL, + biz_date DATE NOT NULL, + avg_price_yuan_per_kg DECIMAL(12,4) NOT NULL, + index_value DECIMAL(14,4) DEFAULT NULL, + index_code VARCHAR(32) NOT NULL DEFAULT 'VEI', + config_version INT NOT NULL DEFAULT 1, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_daily_variety_date (variety_id, biz_date), + CONSTRAINT fk_daily_variety FOREIGN KEY (variety_id) REFERENCES variety (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE INDEX idx_daily_biz_date ON daily_market (biz_date); diff --git a/server/src/main/resources/db/migration/V3__seed_demo_data.sql b/server/src/main/resources/db/migration/V3__seed_demo_data.sql new file mode 100644 index 0000000..9eb189c --- /dev/null +++ b/server/src/main/resources/db/migration/V3__seed_demo_data.sql @@ -0,0 +1,33 @@ +-- 演示数据:便于本地联调前台(非生产环境可清空或替换) +INSERT INTO variety (id, code, display_name, aliases) +VALUES + (1, 'tomato', '西红柿', '番茄,蕃茄'), + (2, 'cucumber', '黄瓜', '胡瓜'), + (3, 'cabbage', '白菜', '大白菜,黄芽白'); + +INSERT INTO daily_market + (variety_id, biz_date, avg_price_yuan_per_kg, index_value, index_code, config_version) +VALUES + (1, '2026-04-09', 3.7000, 98.4000, 'VEI', 1), + (1, '2026-04-10', 3.8000, 99.2000, 'VEI', 1), + (1, '2026-04-11', 3.9000, 99.8000, 'VEI', 1), + (1, '2026-04-12', 4.0000, 100.4000, 'VEI', 1), + (1, '2026-04-13', 4.0500, 100.9000, 'VEI', 1), + (1, '2026-04-14', 3.9500, 100.2000, 'VEI', 1), + (1, '2026-04-15', 3.8500, 99.5000, 'VEI', 1), + (1, '2026-04-16', 3.9000, 99.9000, 'VEI', 1), + (1, '2026-04-17', 4.1000, 101.2000, 'VEI', 1), + (1, '2026-04-18', 4.2000, 101.8000, 'VEI', 1), + (1, '2026-04-19', 4.1500, 101.4000, 'VEI', 1), + (1, '2026-04-20', 4.0500, 100.8000, 'VEI', 1), + (1, '2026-04-21', 4.1000, 101.1000, 'VEI', 1), + (1, '2026-04-22', 4.2500, 102.0000, 'VEI', 1), + (1, '2026-04-23', 4.3000, 102.4000, 'VEI', 1), + (2, '2026-04-09', 2.6000, 97.8000, 'VEI', 1), + (2, '2026-04-10', 2.5500, 97.4000, 'VEI', 1), + (2, '2026-04-22', 2.8000, 99.1000, 'VEI', 1), + (2, '2026-04-23', 2.9000, 99.8000, 'VEI', 1), + (3, '2026-04-09', 1.8000, 99.0000, 'VEI', 1), + (3, '2026-04-10', 1.8500, 99.3000, 'VEI', 1), + (3, '2026-04-22', 1.7000, 98.2000, 'VEI', 1), + (3, '2026-04-23', 1.6500, 97.9000, 'VEI', 1); diff --git a/server/src/main/resources/db/migration/V4__ingest_raw_event.sql b/server/src/main/resources/db/migration/V4__ingest_raw_event.sql new file mode 100644 index 0000000..cde5369 --- /dev/null +++ b/server/src/main/resources/db/migration/V4__ingest_raw_event.sql @@ -0,0 +1,18 @@ +SET NAMES utf8mb4; + +-- Python 采集层唯一写入的 raw 主表(架构:只增、可审计、指纹去重键由 Java 统一定义) +CREATE TABLE ingest_raw_event ( + id BIGINT NOT NULL AUTO_INCREMENT, + source_code VARCHAR(64) NOT NULL COMMENT '产品约定:DEMO / XFD / …', + adapter_name VARCHAR(128) NOT NULL, + fetched_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + request_url VARCHAR(2048) DEFAULT NULL, + http_status SMALLINT DEFAULT NULL, + content_fingerprint CHAR(64) NOT NULL COMMENT 'SHA-256 hex,与 source_code 组成唯一键', + payload_json JSON NOT NULL COMMENT '统一契约 JSON,供 Java 治理消费', + raw_excerpt MEDIUMTEXT DEFAULT NULL COMMENT '可选:截断原文/响应片段', + PRIMARY KEY (id), + UNIQUE KEY uk_ingest_source_fingerprint (source_code, content_fingerprint), + KEY idx_ingest_fetched (fetched_at), + KEY idx_ingest_source_fetched (source_code, fetched_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/src/main/resources/db/migration/V5__ingest_raw_governance.sql b/server/src/main/resources/db/migration/V5__ingest_raw_governance.sql new file mode 100644 index 0000000..4d007df --- /dev/null +++ b/server/src/main/resources/db/migration/V5__ingest_raw_governance.sql @@ -0,0 +1,8 @@ +SET NAMES utf8mb4; + +ALTER TABLE ingest_raw_event + ADD COLUMN processed_at TIMESTAMP(3) NULL DEFAULT NULL AFTER fetched_at, + ADD COLUMN ingestion_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/DONE/FAILED' AFTER processed_at, + ADD COLUMN ingestion_error VARCHAR(512) NULL AFTER ingestion_status; + +CREATE INDEX idx_ingest_status_id ON ingest_raw_event (ingestion_status, id); diff --git a/server/src/test/java/com/fao/platform/FaoServerApplicationTests.java b/server/src/test/java/com/fao/platform/FaoServerApplicationTests.java new file mode 100644 index 0000000..c0d73c4 --- /dev/null +++ b/server/src/test/java/com/fao/platform/FaoServerApplicationTests.java @@ -0,0 +1,15 @@ +package com.fao.platform; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class FaoServerApplicationTests { + + @Test + void contextLoads() { + // 不连 MySQL:test profile 下使用 H2,Flyway 关闭,由 hibernate create-drop + } +} diff --git a/server/src/test/java/com/fao/platform/api/CatalogApiIT.java b/server/src/test/java/com/fao/platform/api/CatalogApiIT.java new file mode 100644 index 0000000..49e1df5 --- /dev/null +++ b/server/src/test/java/com/fao/platform/api/CatalogApiIT.java @@ -0,0 +1,101 @@ +package com.fao.platform.api; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fao.platform.domain.DailyMarket; +import com.fao.platform.domain.DailyMarketRepository; +import com.fao.platform.domain.Variety; +import com.fao.platform.domain.VarietyRepository; +import java.math.BigDecimal; +import java.time.LocalDate; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class CatalogApiIT { + + @Autowired private MockMvc mockMvc; + @Autowired private VarietyRepository varietyRepository; + @Autowired private DailyMarketRepository dailyMarketRepository; + + @BeforeEach + void seed() { + dailyMarketRepository.deleteAll(); + varietyRepository.deleteAll(); + + Variety v = new Variety(); + v.setCode("demo"); + v.setDisplayName("演示菜"); + v.setAliases("别名A"); + varietyRepository.save(v); + + DailyMarket d1 = new DailyMarket(); + d1.setVariety(v); + d1.setBizDate(LocalDate.of(2026, 4, 22)); + d1.setAvgPriceYuanPerKg(new BigDecimal("3.0000")); + d1.setIndexValue(new BigDecimal("100.0000")); + dailyMarketRepository.save(d1); + + DailyMarket d2 = new DailyMarket(); + d2.setVariety(v); + d2.setBizDate(LocalDate.of(2026, 4, 23)); + d2.setAvgPriceYuanPerKg(new BigDecimal("3.3000")); + d2.setIndexValue(new BigDecimal("110.0000")); + dailyMarketRepository.save(d2); + } + + @Test + void search_returns_match() throws Exception { + mockMvc + .perform(get("/api/v1/varieties/search").param("q", "演示")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].displayName").value("演示菜")); + } + + @Test + void detail_includes_latest() throws Exception { + Variety v = varietyRepository.findAll().get(0); + mockMvc + .perform(get("/api/v1/varieties/" + v.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayName").value("演示菜")) + .andExpect(jsonPath("$.latest.bizDate").value("2026-04-23")); + } + + @Test + void series_returns_points() throws Exception { + Variety v = varietyRepository.findAll().get(0); + mockMvc + .perform( + get("/api/v1/varieties/" + v.getId() + "/series") + .param("from", "2026-04-22") + .param("to", "2026-04-23") + .param("metric", "index")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.points.length()").value(2)); + } + + @Test + void rankings_change_pct() throws Exception { + Variety v = varietyRepository.findAll().get(0); + mockMvc + .perform( + get("/api/v1/rankings") + .param("type", "change_pct") + .param("metric", "price") + .param("bizDate", "2026-04-23")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].varietyId").value(v.getId().intValue())); + } +} diff --git a/server/src/test/java/com/fao/platform/governance/RawGovernanceIT.java b/server/src/test/java/com/fao/platform/governance/RawGovernanceIT.java new file mode 100644 index 0000000..3eb285d --- /dev/null +++ b/server/src/test/java/com/fao/platform/governance/RawGovernanceIT.java @@ -0,0 +1,125 @@ +package com.fao.platform.governance; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fao.platform.domain.DailyMarketRepository; +import com.fao.platform.domain.IngestRawEvent; +import com.fao.platform.domain.IngestRawEventRepository; +import com.fao.platform.domain.IngestionStatus; +import com.fao.platform.domain.VarietyRepository; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class RawGovernanceIT { + + @Autowired private MockMvc mockMvc; + @Autowired private IngestRawEventRepository ingestRawEventRepository; + @Autowired private VarietyRepository varietyRepository; + @Autowired private DailyMarketRepository dailyMarketRepository; + + @BeforeEach + void clean() { + dailyMarketRepository.deleteAll(); + varietyRepository.deleteAll(); + ingestRawEventRepository.deleteAll(); + } + + @Test + void consume_raw_writes_daily_market() throws Exception { + IngestRawEvent e = new IngestRawEvent(); + e.setSourceCode("DEMO"); + e.setAdapterName("demo_json"); + e.setFetchedAt(LocalDateTime.now()); + e.setContentFingerprint("b".repeat(64)); + e.setPayloadJson( + "{\"kind\":\"price_quote_batch\",\"schema_version\":1,\"source_code\":\"DEMO\"," + + "\"items\":[{\"variety_code\":\"govtest\",\"variety_name\":\"治理测\"," + + "\"biz_date\":\"2026-05-01\",\"avg_price_yuan_per_kg\":9.99," + + "\"market_code\":\"M\",\"unit\":\"yuan_per_kg\"}]}"); + e.setIngestionStatus(IngestionStatus.PENDING); + ingestRawEventRepository.save(e); + + mockMvc + .perform( + post("/api/v1/internal/governance/consume-raw") + .header("X-Fao-Governance-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.eventsDone").value(1)) + .andExpect(jsonPath("$.quotesUpserted").value(1)); + + var v = varietyRepository.findByCode("govtest").orElseThrow(); + var d = + dailyMarketRepository + .findByVariety_IdAndBizDate(v.getId(), LocalDate.of(2026, 5, 1)) + .orElseThrow(); + assertEquals(0, new BigDecimal("9.9900").compareTo(d.getAvgPriceYuanPerKg())); + } + + @Test + void consume_raw_rejects_bad_token() throws Exception { + mockMvc + .perform( + post("/api/v1/internal/governance/consume-raw") + .header("X-Fao-Governance-Token", "wrong") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + void consume_raw_filters_by_source_code_in_body() throws Exception { + IngestRawEvent demo = newPending("DEMO", "c".repeat(64), "demo-a"); + IngestRawEvent other = newPending("XFD", "d".repeat(64), "demo-b"); + ingestRawEventRepository.save(demo); + ingestRawEventRepository.save(other); + + mockMvc + .perform( + post("/api/v1/internal/governance/consume-raw") + .header("X-Fao-Governance-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"limit\":10,\"sourceCodes\":[\"DEMO\"]}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.batchSize").value(1)) + .andExpect(jsonPath("$.eventsDone").value(1)); + + assertEquals(1, ingestRawEventRepository.findAll().stream().filter(e -> e.getIngestionStatus().name().equals("DONE")).count()); + } + + private static IngestRawEvent newPending(String sourceCode, String fingerprint, String suffix) { + IngestRawEvent e = new IngestRawEvent(); + e.setSourceCode(sourceCode); + e.setAdapterName("demo_json"); + e.setFetchedAt(LocalDateTime.now()); + e.setContentFingerprint(fingerprint); + e.setPayloadJson( + "{\"kind\":\"price_quote_batch\",\"schema_version\":1,\"source_code\":\"" + + sourceCode + + "\",\"items\":[{\"variety_code\":\"v" + + suffix + + "\",\"variety_name\":\"品种" + + suffix + + "\"," + + "\"biz_date\":\"2026-05-02\",\"avg_price_yuan_per_kg\":1.11," + + "\"market_code\":\"M\",\"unit\":\"yuan_per_kg\"}]}"); + e.setIngestionStatus(IngestionStatus.PENDING); + return e; + } +} diff --git a/server/src/test/java/com/fao/platform/governance/RawGovernanceSchedulerEnabledIT.java b/server/src/test/java/com/fao/platform/governance/RawGovernanceSchedulerEnabledIT.java new file mode 100644 index 0000000..aa6bf0e --- /dev/null +++ b/server/src/test/java/com/fao/platform/governance/RawGovernanceSchedulerEnabledIT.java @@ -0,0 +1,29 @@ +package com.fao.platform.governance; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest +@ActiveProfiles("test") +@TestPropertySource( + properties = { + "fao.governance.scheduled.enabled=true", + "fao.governance.scheduled.fixed-delay-ms=999999999", + "fao.governance.scheduled.initial-delay-ms=999999999", + "fao.governance.scheduled.batch-limit=5" + }) +class RawGovernanceSchedulerEnabledIT { + + @Autowired private ApplicationContext applicationContext; + + @Test + void scheduler_bean_is_registered_when_enabled() { + assertThat(applicationContext.getBean(RawGovernanceScheduler.class)).isNotNull(); + } +} diff --git a/server/src/test/resources/application-test.yml b/server/src/test/resources/application-test.yml new file mode 100644 index 0000000..1790d65 --- /dev/null +++ b/server/src/test/resources/application-test.yml @@ -0,0 +1,17 @@ +fao: + governance: + token: test-token + scheduled: + enabled: false + +spring: + datasource: + url: jdbc:h2:mem:fao;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + flyway: + enabled: false diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f9218e3 --- /dev/null +++ b/setup.py @@ -0,0 +1,5 @@ +"""setuptools 入口,兼容部分旧版 pip 的 editable 安装;元数据以 pyproject.toml 为准。""" +from setuptools import setup + +if __name__ == "__main__": + setup() diff --git a/src/fao/__init__.py b/src/fao/__init__.py new file mode 100644 index 0000000..e1aa4f0 --- /dev/null +++ b/src/fao/__init__.py @@ -0,0 +1,3 @@ +"""蔬菜价格指数平台 — 仅含采集(ingest)。治理、指数、API 在 Java `server/` 工程。""" + +__version__ = "0.1.0" diff --git a/src/fao/common/__init__.py b/src/fao/common/__init__.py new file mode 100644 index 0000000..7a092ed --- /dev/null +++ b/src/fao/common/__init__.py @@ -0,0 +1 @@ +# 公共:配置、日志、时区;与 Java 服务共享环境变量/配置键约定。 diff --git a/src/fao/common/settings.py b/src/fao/common/settings.py new file mode 100644 index 0000000..0c5ce81 --- /dev/null +++ b/src/fao/common/settings.py @@ -0,0 +1,46 @@ +"""运行时配置:优先读取环境变量,其次尝试加载 config/.env 或仓库根 .env。""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +def repo_root() -> Path: + """src/fao/common/settings.py → 仓库根(含 data/、server/ 等)。""" + return Path(__file__).resolve().parents[3] + + +def _load_dotenv() -> None: + try: + from dotenv import load_dotenv + except ImportError: # pragma: no cover + return + here = Path.cwd() + root = repo_root() + candidates = [here / "config" / ".env", here / ".env", root / "config" / ".env", root / ".env"] + for p in candidates: + if p.is_file(): + load_dotenv(p) + return + + +@dataclass(frozen=True) +class MysqlSettings: + host: str + port: int + user: str + password: str + database: str + + +def load_mysql_settings() -> MysqlSettings: + _load_dotenv() + return MysqlSettings( + host=os.environ.get("FAO_MYSQL_HOST", "127.0.0.1"), + port=int(os.environ.get("FAO_MYSQL_PORT", "3306")), + user=os.environ.get("FAO_MYSQL_USER", "fao"), + password=os.environ.get("FAO_MYSQL_PASSWORD", "changeme"), + database=os.environ.get("FAO_MYSQL_DATABASE", "fao"), + ) diff --git a/src/fao/db/__init__.py b/src/fao/db/__init__.py new file mode 100644 index 0000000..a0019df --- /dev/null +++ b/src/fao/db/__init__.py @@ -0,0 +1 @@ +# 爬虫侧数据访问:以 raw/队列表写入为主,表结构以 Java 工程迁移为准。 diff --git a/src/fao/db/mysql.py b/src/fao/db/mysql.py new file mode 100644 index 0000000..d6dd427 --- /dev/null +++ b/src/fao/db/mysql.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import Iterator + +import pymysql +from pymysql.connections import Connection + +from fao.common.settings import MysqlSettings, load_mysql_settings + + +def connect(settings: MysqlSettings | None = None) -> Connection: + cfg = settings or load_mysql_settings() + return pymysql.connect( + host=cfg.host, + port=cfg.port, + user=cfg.user, + password=cfg.password, + database=cfg.database, + charset="utf8mb4", + autocommit=False, + ) + + +@contextmanager +def mysql_connection(settings: MysqlSettings | None = None) -> Iterator[Connection]: + conn = connect(settings) + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/src/fao/db/raw.py b/src/fao/db/raw.py new file mode 100644 index 0000000..631c727 --- /dev/null +++ b/src/fao/db/raw.py @@ -0,0 +1,62 @@ +"""写入 raw 表 `ingest_raw_event`(DDL 权威在 server Flyway)。""" + +from __future__ import annotations + +import json +from typing import Any, Mapping + +from pymysql.cursors import DictCursor + +from fao.db.mysql import mysql_connection + + +def insert_raw_event_ignore( + *, + source_code: str, + adapter_name: str, + content_fingerprint: str, + payload: Mapping[str, Any], + request_url: str | None = None, + http_status: int | None = None, + raw_excerpt: str | None = None, +) -> bool: + """ + 返回 True 表示新插入一行;False 表示因唯一键冲突被忽略(幂等重跑)。 + """ + sql = """ + INSERT IGNORE INTO ingest_raw_event + (source_code, adapter_name, request_url, http_status, content_fingerprint, payload_json, raw_excerpt) + VALUES + (%s, %s, %s, %s, %s, %s, %s) + """ + payload_text = json.dumps(payload, ensure_ascii=False) + with mysql_connection() as conn: + with conn.cursor() as cur: + cur.execute( + sql, + ( + source_code, + adapter_name, + request_url, + http_status, + content_fingerprint, + payload_text, + raw_excerpt, + ), + ) + return cur.rowcount == 1 + + +def count_by_source(source_code: str, limit_rows: int = 5) -> list[dict[str, Any]]: + """调试:查看最近几条 raw(只读)。""" + sql = """ + SELECT id, source_code, adapter_name, fetched_at, http_status, content_fingerprint + FROM ingest_raw_event + WHERE source_code = %s + ORDER BY id DESC + LIMIT %s + """ + with mysql_connection() as conn: + with conn.cursor(DictCursor) as cur: + cur.execute(sql, (source_code, limit_rows)) + return list(cur.fetchall()) diff --git a/src/fao/ingest/__init__.py b/src/fao/ingest/__init__.py new file mode 100644 index 0000000..c883237 --- /dev/null +++ b/src/fao/ingest/__init__.py @@ -0,0 +1 @@ +# 采集层:调度入口、向 raw 落库;各源在 adapters/。业务治理与指数在 Java 侧。 diff --git a/src/fao/ingest/adapters/__init__.py b/src/fao/ingest/adapters/__init__.py new file mode 100644 index 0000000..759c5ff --- /dev/null +++ b/src/fao/ingest/adapters/__init__.py @@ -0,0 +1 @@ +# 各数据源一个子模块,禁止在包外硬编码源站选择器。 diff --git a/src/fao/ingest/adapters/base.py b/src/fao/ingest/adapters/base.py new file mode 100644 index 0000000..e49f62b --- /dev/null +++ b/src/fao/ingest/adapters/base.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from typing import Protocol + +from fao.ingest.contracts import RawFetchResult + + +class IngestAdapter(Protocol): + name: str + + def run(self) -> RawFetchResult: + """执行一次抓取或装载,返回待落库的 raw 行。""" diff --git a/src/fao/ingest/adapters/demo_json.py b/src/fao/ingest/adapters/demo_json.py new file mode 100644 index 0000000..72c94ae --- /dev/null +++ b/src/fao/ingest/adapters/demo_json.py @@ -0,0 +1,53 @@ +""" +演示适配器:读取仓库内静态 JSON(产品 2.1:CSV/JSON 样例优先)。 + +正式环境持续拉取须满足《docs/数据源上线清单.md》:授权、频控、可观测等。 +禁止将未授权网页抓取作为生产唯一依赖。 +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from fao.common.settings import repo_root +from fao.ingest.canonical import fingerprint_for_payload +from fao.ingest.contracts import RawFetchResult, build_price_quote_batch + + +class DemoJsonAdapter: + name = "demo_json" + + def __init__(self, sample_path: Path | None = None) -> None: + env_path = os.environ.get("FAO_SAMPLE_JSON_PATH") + if env_path: + self._path = Path(env_path).expanduser() + else: + self._path = sample_path or (repo_root() / "data" / "samples" / "demo_vegetable_prices.json") + + def run(self) -> RawFetchResult: + text = self._path.read_text(encoding="utf-8") + outer = json.loads(text) + items = outer.get("items") + if not isinstance(items, list) or not items: + raise ValueError("样例文件缺少非空 items 数组") + + source_code = "DEMO" + payload = build_price_quote_batch( + source_code=source_code, + items=items, + schema_version=int(outer.get("schema_version", 1)), + notes="static_sample", + ) + fp = fingerprint_for_payload(payload) + excerpt = text if len(text) <= 16_384 else text[:16_384] + return RawFetchResult( + source_code=source_code, + adapter_name=self.name, + content_fingerprint=fp, + payload=payload, + request_url=f"file://{self._path.resolve()}", + http_status=None, + raw_excerpt=excerpt, + ) diff --git a/src/fao/ingest/adapters/http_json.py b/src/fao/ingest/adapters/http_json.py new file mode 100644 index 0000000..3343ee4 --- /dev/null +++ b/src/fao/ingest/adapters/http_json.py @@ -0,0 +1,92 @@ +""" +HTTP JSON 适配器:对 **已授权** 的 JSON 接口执行 GET 并落 raw。 + +使用前请在环境变量中显式配置 URL 与来源码: +- FAO_HTTP_JSON_URL(必填) +- FAO_HTTP_SOURCE_CODE(默认 DEMO,仅用于联调;正式源请改为维表登记码) + +须遵守《docs/数据源上线清单.md》与站点 robots/条款;本仓库不包含任何绕过风控的逻辑。 +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +import httpx + +from fao.ingest.canonical import fingerprint_for_payload, sha256_hex +from fao.ingest.contracts import RawFetchResult, build_price_quote_batch + + +class HttpJsonAdapter: + name = "http_json" + + def run(self) -> RawFetchResult: + url = os.environ.get("FAO_HTTP_JSON_URL", "").strip() + if not url: + raise ValueError("未设置 FAO_HTTP_JSON_URL") + + source_code = os.environ.get("FAO_HTTP_SOURCE_CODE", "DEMO").strip() or "DEMO" + timeout = float(os.environ.get("FAO_HTTP_TIMEOUT_SECONDS", "30")) + + headers = { + "User-Agent": os.environ.get( + "FAO_HTTP_USER_AGENT", + "fao-ingest/0.1 (+https://example.invalid; contact=data-team)", + ), + "Accept": "application/json,*/*;q=0.8", + } + + with httpx.Client(timeout=timeout, follow_redirects=True) as client: + resp = client.get(url, headers=headers) + + text = resp.text + excerpt = text if len(text) <= 65_536 else text[:65_536] + + payload: dict[str, Any] + try: + body = resp.json() + except json.JSONDecodeError: + fp = sha256_hex(text.encode("utf-8")) + payload = { + "kind": "opaque_http_body", + "schema_version": 1, + "source_code": source_code, + "note": "response was not JSON", + } + return RawFetchResult( + source_code=source_code, + adapter_name=self.name, + content_fingerprint=fp, + payload=payload, + request_url=url, + http_status=resp.status_code, + raw_excerpt=excerpt, + ) + + if isinstance(body, dict) and isinstance(body.get("items"), list): + payload = build_price_quote_batch( + source_code=source_code, + items=body["items"], + schema_version=int(body.get("schema_version", 1)), + ) + else: + payload = { + "kind": "json_blob", + "schema_version": 1, + "source_code": source_code, + "body": body, + } + + fp = fingerprint_for_payload(payload) + return RawFetchResult( + source_code=source_code, + adapter_name=self.name, + content_fingerprint=fp, + payload=payload, + request_url=url, + http_status=resp.status_code, + raw_excerpt=excerpt, + ) diff --git a/src/fao/ingest/canonical.py b/src/fao/ingest/canonical.py new file mode 100644 index 0000000..667dc75 --- /dev/null +++ b/src/fao/ingest/canonical.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import hashlib +import json +from typing import Any + + +def stable_json_dumps(obj: Any) -> str: + """与 Java 治理侧对齐的稳定序列化:排序键 + 紧凑分隔符,便于指纹一致。""" + return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def fingerprint_for_payload(payload: Any) -> str: + return sha256_hex(stable_json_dumps(payload).encode("utf-8")) diff --git a/src/fao/ingest/cli.py b/src/fao/ingest/cli.py new file mode 100644 index 0000000..d8cfab6 --- /dev/null +++ b/src/fao/ingest/cli.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import argparse +import sys + +from fao.ingest.runner import ingest_once, resolve_adapter + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="FAO 采集入口:仅写 raw 表 ingest_raw_event") + parser.add_argument( + "--adapter", + required=True, + help="适配器名称:demo_json(静态样例)或 http_json(需 FAO_HTTP_JSON_URL)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="不落库,仅打印指纹与 payload 键", + ) + args = parser.parse_args(argv) + + try: + adapter = resolve_adapter(args.adapter) + result = adapter.run() + except Exception as e: # noqa: BLE001 — CLI 顶层错误出口 + print(f"[fao-ingest] 失败: {e}", file=sys.stderr) + return 2 + + if args.dry_run: + print("[fao-ingest] dry-run OK") + print(f" source_code={result.source_code}") + print(f" adapter={result.adapter_name}") + print(f" fingerprint={result.content_fingerprint}") + print(f" payload keys={list(result.payload.keys())}") + return 0 + + try: + inserted = ingest_once(args.adapter) + except Exception as e: # noqa: BLE001 + print(f"[fao-ingest] 落库失败: {e}", file=sys.stderr) + return 3 + + if inserted: + print("[fao-ingest] 已插入 1 行 raw(ingest_raw_event)") + else: + print("[fao-ingest] 跳过:相同 source_code + fingerprint 已存在(幂等)") + return 0 + + +def app() -> None: + """setuptools console_scripts 入口。""" + raise SystemExit(main()) + + +if __name__ == "__main__": + app() diff --git a/src/fao/ingest/contracts.py b/src/fao/ingest/contracts.py new file mode 100644 index 0000000..c2de432 --- /dev/null +++ b/src/fao/ingest/contracts.py @@ -0,0 +1,37 @@ +"""与产品/架构对齐的 raw payload 约定(Java 治理消费前可演进 schema_version)。""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Optional + + +@dataclass(frozen=True) +class RawFetchResult: + """一次抓取/组装的统一结果,映射到表 `ingest_raw_event` 一行。""" + + source_code: str + adapter_name: str + content_fingerprint: str + payload: Mapping[str, Any] + request_url: Optional[str] = None + http_status: Optional[int] = None + raw_excerpt: Optional[str] = None + + +def build_price_quote_batch( + *, + source_code: str, + items: list[Mapping[str, Any]], + schema_version: int = 1, + notes: str | None = None, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "kind": "price_quote_batch", + "schema_version": schema_version, + "source_code": source_code, + "items": list(items), + } + if notes: + payload["notes"] = notes + return payload diff --git a/src/fao/ingest/runner.py b/src/fao/ingest/runner.py new file mode 100644 index 0000000..f3fd29e --- /dev/null +++ b/src/fao/ingest/runner.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from fao.db.raw import insert_raw_event_ignore +from fao.ingest.adapters.demo_json import DemoJsonAdapter +from fao.ingest.adapters.http_json import HttpJsonAdapter + + +def resolve_adapter(name: str): + key = name.strip().lower() + if key == "demo_json": + return DemoJsonAdapter() + if key == "http_json": + return HttpJsonAdapter() + raise ValueError(f"未知 adapter: {name!r},可选: demo_json, http_json") + + +def ingest_once(adapter_name: str) -> bool: + """执行一次采集并写入 raw;返回 True 表示插入新行,False 表示幂等跳过。""" + adapter = resolve_adapter(adapter_name) + result = adapter.run() + return insert_raw_event_ignore( + source_code=result.source_code, + adapter_name=result.adapter_name, + content_fingerprint=result.content_fingerprint, + payload=result.payload, + request_url=result.request_url, + http_status=result.http_status, + raw_excerpt=result.raw_excerpt, + ) diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/.gitkeep b/tests/unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_canonical.py b/tests/unit/test_canonical.py new file mode 100644 index 0000000..fe930e8 --- /dev/null +++ b/tests/unit/test_canonical.py @@ -0,0 +1,12 @@ +from fao.ingest.canonical import fingerprint_for_payload, stable_json_dumps + + +def test_stable_json_sorts_keys() -> None: + a = {"b": 1, "a": 2} + b = {"a": 2, "b": 1} + assert stable_json_dumps(a) == stable_json_dumps(b) + + +def test_fingerprint_stable() -> None: + p = {"kind": "x", "items": [{"z": 1, "y": 2}]} + assert fingerprint_for_payload(p) == fingerprint_for_payload(p) diff --git a/tests/unit/test_demo_adapter.py b/tests/unit/test_demo_adapter.py new file mode 100644 index 0000000..3d9a0a6 --- /dev/null +++ b/tests/unit/test_demo_adapter.py @@ -0,0 +1,17 @@ +from pathlib import Path + +from fao.ingest.adapters.demo_json import DemoJsonAdapter + + +def test_demo_json_builds_demo_source(tmp_path: Path) -> None: + sample = tmp_path / "x.json" + sample.write_text( + '{"schema_version":1,"items":[{"variety_code":"x","variety_name":"X","biz_date":"2026-04-23","avg_price_yuan_per_kg":1.2,"market_code":"M","unit":"yuan_per_kg"}]}', + encoding="utf-8", + ) + r = DemoJsonAdapter(sample_path=sample).run() + assert r.source_code == "DEMO" + assert r.adapter_name == "demo_json" + assert len(r.content_fingerprint) == 64 + assert r.payload["kind"] == "price_quote_batch" + assert r.payload["items"][0]["variety_code"] == "x" diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..9ef31f3 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,2 @@ +# 留空则使用 Vite 开发代理(见 vite.config.ts → /api → :8080) +# VITE_API_BASE=http://127.0.0.1:8080 diff --git a/web/.env.production b/web/.env.production new file mode 100644 index 0000000..e0a8076 --- /dev/null +++ b/web/.env.production @@ -0,0 +1 @@ +VITE_API_BASE=https://fao.s-good.com \ No newline at end of file diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a4d699a --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +*.local +.DS_Store diff --git a/web/.gitkeep b/web/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..3878342 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + 蔬菜价格指数平台 + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..eeb021e --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1797 @@ +{ + "name": "fao-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fao-web", + "version": "0.1.0", + "dependencies": { + "echarts": "^5.5.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "~5.6.2", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.21.tgz", + "integrity": "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.344", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", + "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..1b2cd35 --- /dev/null +++ b/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "fao-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "echarts": "^5.5.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "typescript": "~5.6.2", + "vite": "^5.4.10" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..6306d5d --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,35 @@ +import { lazy, Suspense } from "react"; +import { Navigate, Route, Routes } from "react-router-dom"; +import { AppLayout } from "./layout/AppLayout"; +import { AboutPage } from "./pages/AboutPage"; +import { HistoryPage } from "./pages/HistoryPage"; +import { HomePage } from "./pages/HomePage"; +import { QueryPage } from "./pages/QueryPage"; +import { RankPage } from "./pages/RankPage"; + +const TrendPage = lazy(() => + import("./pages/TrendPage").then((m) => ({ default: m.TrendPage })), +); + +export default function App() { + return ( + + + } /> + } /> + 加载趋势页…

}> + + + } + /> + } /> + } /> + } /> + } /> +
+
+ ); +} diff --git a/web/src/api/catalog.ts b/web/src/api/catalog.ts new file mode 100644 index 0000000..986ebc6 --- /dev/null +++ b/web/src/api/catalog.ts @@ -0,0 +1,110 @@ +import { apiGet } from "./client"; + +export type VarietySummary = { + id: number; + code: string; + displayName: string; + aliases: string | null; +}; + +export type LatestQuote = { + bizDate: string; + avgPriceYuanPerKg: number; + indexValue: number | null; + indexCode: string; + configVersion: number; +}; + +export type VarietyDetail = { + id: number; + code: string; + displayName: string; + aliases: string | null; + latest: LatestQuote | null; +}; + +export type DailyPoint = { + bizDate: string; + avgPriceYuanPerKg: number; + indexValue: number | null; + indexCode: string; + configVersion: number; +}; + +export type SeriesResponse = { + varietyId: number; + displayName: string; + metric: string; + points: DailyPoint[]; +}; + +export type PageResponse = { + content: T[]; + totalElements: number; + page: number; + size: number; + totalPages: number; +}; + +export type RankingItem = { + varietyId: number; + code: string; + displayName: string; + bizDate: string; + valueToday: number | null; + valueYesterday: number | null; + changePct: number | null; + indexCode: string; + configVersion: number; +}; + +export function searchVarieties(q: string, limit = 20) { + const qs = new URLSearchParams({ q, limit: String(limit) }); + return apiGet(`/api/v1/varieties/search?${qs}`); +} + +export function fetchVarietyDetail(id: number) { + return apiGet(`/api/v1/varieties/${id}`); +} + +export function fetchSeries( + id: number, + opts?: { from?: string; to?: string; metric?: "index" | "price" }, +) { + const qs = new URLSearchParams(); + if (opts?.from) qs.set("from", opts.from); + if (opts?.to) qs.set("to", opts.to); + if (opts?.metric) qs.set("metric", opts.metric); + const suffix = qs.toString(); + return apiGet( + `/api/v1/varieties/${id}/series${suffix ? `?${suffix}` : ""}`, + ); +} + +export function fetchHistory( + id: number, + opts: { from?: string; to?: string; page?: number; size?: number }, +) { + const qs = new URLSearchParams(); + if (opts.from) qs.set("from", opts.from); + if (opts.to) qs.set("to", opts.to); + qs.set("page", String(opts.page ?? 0)); + qs.set("size", String(opts.size ?? 20)); + return apiGet>( + `/api/v1/varieties/${id}/history?${qs}`, + ); +} + +export function fetchRankings(opts: { + type: "change_pct" | "avg_price"; + metric: "index" | "price"; + bizDate?: string; + limit?: number; +}) { + const qs = new URLSearchParams(); + qs.set("type", opts.type); + qs.set("metric", opts.metric); + if (opts.bizDate) qs.set("bizDate", opts.bizDate); + qs.set("limit", String(opts.limit ?? 20)); + return apiGet(`/api/v1/rankings?${qs}`); +} diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..7376fdb --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,39 @@ +/** + * API 基址: + * - 开发默认走 Vite proxy(同源 /api → Java :8080) + * - 直连后端时设置 VITE_API_BASE=http://127.0.0.1:8080(需后端 CORS) + */ +const API_BASE = import.meta.env.VITE_API_BASE?.replace(/\/$/, "") ?? ""; + +export async function apiGet(path: string): Promise { + const url = `${API_BASE}${path.startsWith("/") ? path : `/${path}`}`; + const res = await fetch(url, { + headers: { Accept: "application/json" }, + }); + const text = await res.text(); + if (!res.ok) { + let message = text || `HTTP ${res.status}`; + if (text) { + try { + const body = JSON.parse(text) as { message?: string }; + if (body?.message) message = body.message; + } catch { + // 非 JSON 错误体 + } + } + throw new Error(message); + } + if (!text) { + throw new Error("空响应体"); + } + return JSON.parse(text) as T; +} + +export type PingResponse = { + status: string; + component: string; +}; + +export function fetchPing() { + return apiGet("/api/v1/ping"); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..67c1c9b --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,42 @@ +:root { + color-scheme: light; + --bg: #f6f7f9; + --surface: #ffffff; + --text: #1a1d21; + --muted: #5c6370; + --border: #e2e5ea; + --accent: #1b7f5a; + --accent-soft: #e6f4ef; + --danger: #c53b3b; + --up: #c53b3b; + --down: #1b7f5a; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", + "Hiragino Sans GB", "Microsoft YaHei", sans-serif; + line-height: 1.5; + font-weight: 400; + color: var(--text); + background-color: var(--bg); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + font: inherit; + cursor: pointer; +} diff --git a/web/src/layout/AppLayout.module.css b/web/src/layout/AppLayout.module.css new file mode 100644 index 0000000..33a7309 --- /dev/null +++ b/web/src/layout/AppLayout.module.css @@ -0,0 +1,73 @@ +.shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem 1.5rem; + padding: 0.75rem 1.25rem; + background: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +.brand { + font-weight: 700; + font-size: 1.05rem; + color: var(--text); + text-decoration: none; +} + +.brand:hover { + text-decoration: none; + color: var(--accent); +} + +.nav { + display: flex; + flex-wrap: wrap; + gap: 0.35rem 0.75rem; +} + +.navLink, +.navLinkActive { + padding: 0.35rem 0.65rem; + border-radius: 6px; + color: var(--muted); + text-decoration: none; + font-size: 0.95rem; +} + +.navLink:hover { + background: var(--accent-soft); + color: var(--accent); + text-decoration: none; +} + +.navLinkActive { + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; +} + +.main { + flex: 1; + width: 100%; + max-width: 1100px; + margin: 0 auto; + padding: 1.25rem 1.25rem 2rem; +} + +.footer { + padding: 0.85rem 1.25rem; + font-size: 0.8rem; + color: var(--muted); + border-top: 1px solid var(--border); + background: var(--surface); +} diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx new file mode 100644 index 0000000..cb60744 --- /dev/null +++ b/web/src/layout/AppLayout.tsx @@ -0,0 +1,42 @@ +import { NavLink } from "react-router-dom"; +import type { ReactNode } from "react"; +import styles from "./AppLayout.module.css"; + +const nav = [ + { to: "/", label: "首页" }, + { to: "/query", label: "查询" }, + { to: "/trend", label: "趋势" }, + { to: "/rank", label: "榜单" }, + { to: "/history", label: "历史" }, + { to: "/about", label: "数据说明" }, +]; + +export function AppLayout({ children }: { children: ReactNode }) { + return ( +
+
+ + 蔬菜价格指数 + + +
+
{children}
+
+ 数据来源与口径见「数据说明」;指数变更将标注版本号。 +
+
+ ); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..b3c2e37 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/web/src/pages/AboutPage.tsx b/web/src/pages/AboutPage.tsx new file mode 100644 index 0000000..dc4dbec --- /dev/null +++ b/web/src/pages/AboutPage.tsx @@ -0,0 +1,30 @@ +import "./page.css"; + +export function AboutPage() { + return ( +
+

数据说明

+

+ 与产品文档一致:此处应展示指标定义、单位、数据来源、更新频率、免责声明;指数与算法变更建议标注{" "} + config_version。 +

+
+
    +
  • 当前为 MVP 骨架页,文案与版本号待运营/数据团队定稿后替换。
  • +
  • 技术架构:采集 Python 写 raw;治理与指数计算由 Java 服务完成。
  • +
  • + 契约:本地后端默认{" "} + + OpenAPI JSON + + ;交互文档{" "} + + Swagger UI + + 。 +
  • +
+
+
+ ); +} diff --git a/web/src/pages/HistoryPage.tsx b/web/src/pages/HistoryPage.tsx new file mode 100644 index 0000000..c32b6c6 --- /dev/null +++ b/web/src/pages/HistoryPage.tsx @@ -0,0 +1,139 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { fetchHistory, type DailyPoint } from "@/api/catalog"; +import "./page.css"; + +export function HistoryPage() { + const [sp, setSp] = useSearchParams(); + const varietyIdRaw = sp.get("varietyId"); + const varietyId = varietyIdRaw ? Number(varietyIdRaw) : NaN; + const page = Math.max(0, Number(sp.get("page") ?? "0") || 0); + const size = Math.min(200, Math.max(1, Number(sp.get("size") ?? "10") || 10)); + + const [resp, setResp] = useState<{ + content: DailyPoint[]; + totalElements: number; + totalPages: number; + } | null>(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + if (!Number.isFinite(varietyId) || varietyId <= 0) { + setResp(null); + setError(null); + return () => { + cancelled = true; + }; + } + setError(null); + fetchHistory(varietyId, { page, size }) + .then((r) => { + if (!cancelled) { + setResp({ + content: r.content, + totalElements: r.totalElements, + totalPages: r.totalPages, + }); + } + }) + .catch((e: unknown) => { + if (!cancelled) { + setResp(null); + setError(e instanceof Error ? e.message : "加载失败"); + } + }); + return () => { + cancelled = true; + }; + }, [varietyId, page, size]); + + const setPage = (next: number) => { + setSp( + (prev) => { + const p = new URLSearchParams(prev); + p.set("page", String(next)); + p.set("size", String(size)); + return p; + }, + { replace: true }, + ); + }; + + if (!Number.isFinite(varietyId) || varietyId <= 0) { + return ( +
+

历史数据

+

+ 请在 URL 中提供 varietyId,例如{" "} + /history?varietyId=1(需本地种子数据)。 +

+
+ ); + } + + return ( +
+

历史数据

+

+ 默认时间窗由后端决定(与趋势一致:近 30 个自然日)。分页参数:page、 + size。 +

+

+ 返回趋势 ·{" "} + 品种详情 +

+ + {error ?

{error}

: null} + +
+ {!resp ? ( +

加载中…

+ ) : resp.content.length === 0 ? ( +

暂无数据。

+ ) : ( + + + + + + + + + + + {resp.content.map((r) => ( + + + + + + + ))} + +
日期均价(元/kg)指数版本
{r.bizDate}{r.avgPriceYuanPerKg}{r.indexValue == null ? "—" : r.indexValue} + {r.indexCode} / v{r.configVersion} +
+ )} +
+ + {resp && resp.totalPages > 1 ? ( +
+ + + 第 {page + 1} / {resp.totalPages} 页 · 共 {resp.totalElements} 条 + + +
+ ) : null} +
+ ); +} diff --git a/web/src/pages/HomePage.tsx b/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..ec32a9f --- /dev/null +++ b/web/src/pages/HomePage.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { fetchPing, type PingResponse } from "@/api/client"; +import "./page.css"; + +type ConnState = + | { kind: "idle" } + | { kind: "loading" } + | { kind: "ok"; data: PingResponse } + | { kind: "error"; message: string }; + +export function HomePage() { + const [conn, setConn] = useState({ kind: "idle" }); + + useEffect(() => { + let cancelled = false; + setConn({ kind: "loading" }); + fetchPing() + .then((data) => { + if (!cancelled) setConn({ kind: "ok", data }); + }) + .catch((e: unknown) => { + if (!cancelled) { + const message = e instanceof Error ? e.message : "未知错误"; + setConn({ kind: "error", message }); + } + }); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+

首页

+

+ 搜索品种、查看趋势与榜单;后端联调状态见下方(调用{" "} + /api/v1/ping)。 +

+ +
+ 服务连通 +
+ {conn.kind === "idle" || conn.kind === "loading" ? ( + 检测中… + ) : null} + {conn.kind === "ok" ? ( + + 已连接 · {conn.data.component} · {conn.data.status} + + ) : null} + {conn.kind === "error" ? ( + 未连通:{conn.message} + ) : null} +
+

+ 请先在本机启动 Java 服务(默认 8080),再执行{" "} + npm run dev。直连后端时可配置{" "} + VITE_API_BASE。 +

+
+ +
+ + 查询 +

+ 搜索 / 详情(已接 /api/v1/varieties) +

+ + + 趋势 +

+ ECharts + /series(演示数据 id=1) +

+ + + 榜单 +

+ /api/v1/rankings(环比为上一自然日) +

+ + + 历史 +

+ 表格分页(需 URL 带 varietyId) +

+ +
+
+ ); +} diff --git a/web/src/pages/QueryPage.tsx b/web/src/pages/QueryPage.tsx new file mode 100644 index 0000000..5e64b1d --- /dev/null +++ b/web/src/pages/QueryPage.tsx @@ -0,0 +1,216 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { + fetchVarietyDetail, + searchVarieties, + type VarietyDetail, + type VarietySummary, +} from "@/api/catalog"; +import "./page.css"; + +export function QueryPage() { + const [sp, setSp] = useSearchParams(); + const qFromUrl = (sp.get("q") ?? "").trim(); + const varietyIdFromUrl = sp.get("varietyId"); + const selectedId = varietyIdFromUrl ? Number(varietyIdFromUrl) : NaN; + + const [draft, setDraft] = useState(qFromUrl); + const [hits, setHits] = useState([]); + const [hitsError, setHitsError] = useState(null); + const [detail, setDetail] = useState(null); + const [detailError, setDetailError] = useState(null); + + useEffect(() => { + setDraft(qFromUrl); + }, [qFromUrl]); + + useEffect(() => { + const t = window.setTimeout(() => { + const next = draft.trim(); + if (next === qFromUrl) return; + setSp( + (prev) => { + const p = new URLSearchParams(prev); + if (next) p.set("q", next); + else p.delete("q"); + return p; + }, + { replace: true }, + ); + }, 350); + return () => window.clearTimeout(t); + }, [draft, qFromUrl, setSp]); + + useEffect(() => { + let cancelled = false; + if (!qFromUrl) { + setHits([]); + setHitsError(null); + return () => { + cancelled = true; + }; + } + setHitsError(null); + searchVarieties(qFromUrl, 20) + .then((rows) => { + if (!cancelled) setHits(rows); + }) + .catch((e: unknown) => { + if (!cancelled) { + setHitsError(e instanceof Error ? e.message : "搜索失败"); + setHits([]); + } + }); + return () => { + cancelled = true; + }; + }, [qFromUrl]); + + useEffect(() => { + let cancelled = false; + if (!Number.isFinite(selectedId) || selectedId <= 0) { + setDetail(null); + setDetailError(null); + return () => { + cancelled = true; + }; + } + setDetailError(null); + fetchVarietyDetail(selectedId) + .then((d) => { + if (!cancelled) setDetail(d); + }) + .catch((e: unknown) => { + if (!cancelled) { + setDetail(null); + setDetailError(e instanceof Error ? e.message : "加载详情失败"); + } + }); + return () => { + cancelled = true; + }; + }, [selectedId]); + + const selectedSummary = useMemo( + () => hits.find((h) => h.id === selectedId), + [hits, selectedId], + ); + + return ( +
+

查询

+

+ 关键词写入 URL(q),详情选中写入 varietyId,便于分享与回溯。 +

+ +
+ + setDraft(e.target.value)} + style={{ + display: "block", + width: "100%", + maxWidth: "420px", + marginTop: "0.5rem", + padding: "0.5rem 0.65rem", + borderRadius: "8px", + border: "1px solid var(--border)", + background: "var(--surface)", + }} + /> + {hitsError ? ( +

+ {hitsError} +

+ ) : null} +
+ + {qFromUrl ? ( +
+ 联想结果 + {hits.length === 0 && !hitsError ? ( +

无匹配品种。

+ ) : null} +
    + {hits.map((v) => ( +
  • + +
  • + ))} +
+
+ ) : null} + + {detailError ? ( +

{detailError}

+ ) : null} + + {detail ? ( +
+ 详情 +

+ {selectedSummary?.displayName ?? detail.displayName}({detail.code}) +

+ {detail.latest ? ( +
    +
  • + 最近交易日:{detail.latest.bizDate} +
  • +
  • + 均价:{detail.latest.avgPriceYuanPerKg} 元/kg +
  • +
  • + 指数({detail.latest.indexCode} / v{detail.latest.configVersion}): + + {detail.latest.indexValue == null ? "—" : detail.latest.indexValue} + +
  • +
+ ) : ( +

暂无行情快照。

+ )} +

+ 看趋势 + 历史数据 + 看榜单 +

+
+ ) : null} +
+ ); +} diff --git a/web/src/pages/RankPage.tsx b/web/src/pages/RankPage.tsx new file mode 100644 index 0000000..bf77ffa --- /dev/null +++ b/web/src/pages/RankPage.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { fetchRankings, type RankingItem } from "@/api/catalog"; +import "./page.css"; + +type RankType = "change_pct" | "avg_price"; +type RankMetric = "index" | "price"; + +export function RankPage() { + const [sp, setSp] = useSearchParams(); + const type = (sp.get("type") as RankType) === "avg_price" ? "avg_price" : "change_pct"; + const metric = (sp.get("metric") as RankMetric) === "index" ? "index" : "price"; + const bizDate = (sp.get("bizDate") ?? "").trim(); + + const [rows, setRows] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setError(null); + fetchRankings({ + type, + metric, + bizDate: bizDate || undefined, + limit: 30, + }) + .then((r) => { + if (!cancelled) setRows(r); + }) + .catch((e: unknown) => { + if (!cancelled) { + setRows([]); + setError(e instanceof Error ? e.message : "加载失败"); + } + }); + return () => { + cancelled = true; + }; + }, [type, metric, bizDate]); + + const setParam = (patch: Record) => { + setSp( + (prev) => { + const p = new URLSearchParams(prev); + for (const [k, v] of Object.entries(patch)) { + if (v == null || v === "") p.delete(k); + else p.set(k, v); + } + return p; + }, + { replace: true }, + ); + }; + + return ( +
+

榜单

+

+ 类型与度量写入 URL(typemetric、可选 bizDate + );环比为相对上一自然日。 +

+ +
+
+ 榜单类型 + + +
+
+ 度量 + + +
+
+ + {bizDate ? ( + + ) : null} +
+
+ + {error ?

{error}

: null} + +
+ {rows.length === 0 && !error ? ( +

暂无榜单数据。

+ ) : null} + {rows.length > 0 ? ( + + + + + + + + + {type === "change_pct" ? : null} + + + + {rows.map((r, idx) => ( + + + + + + + {type === "change_pct" ? ( + + ) : null} + + ))} + +
#品种交易日当日昨日环比%
{idx + 1} + + {r.displayName} + + {r.bizDate}{r.valueToday == null ? "—" : r.valueToday}{r.valueYesterday == null ? "—" : r.valueYesterday}{r.changePct == null ? "—" : r.changePct}
+ ) : null} +
+
+ ); +} diff --git a/web/src/pages/TrendPage.tsx b/web/src/pages/TrendPage.tsx new file mode 100644 index 0000000..471cbc8 --- /dev/null +++ b/web/src/pages/TrendPage.tsx @@ -0,0 +1,139 @@ +import * as echarts from "echarts"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { Link, useSearchParams } from "react-router-dom"; +import { fetchSeries, type SeriesResponse } from "@/api/catalog"; +import "./page.css"; + +export function TrendPage() { + const [params] = useSearchParams(); + const varietyIdRaw = params.get("varietyId"); + const varietyId = varietyIdRaw ? Number(varietyIdRaw) : NaN; + const metric = params.get("metric") === "price" ? "price" : "index"; + + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const hostRef = useRef(null); + + const titleMetric = useMemo( + () => (metric === "price" ? "均价(元/kg)" : "指数"), + [metric], + ); + + useEffect(() => { + let cancelled = false; + if (!Number.isFinite(varietyId) || varietyId <= 0) { + setData(null); + setError(null); + return () => { + cancelled = true; + }; + } + setError(null); + fetchSeries(varietyId, { metric }) + .then((s) => { + if (!cancelled) setData(s); + }) + .catch((e: unknown) => { + if (!cancelled) { + setData(null); + setError(e instanceof Error ? e.message : "加载失败"); + } + }); + return () => { + cancelled = true; + }; + }, [varietyId, metric]); + + useEffect(() => { + const el = hostRef.current; + if (!el) return; + echarts.dispose(el); + + if (!data || data.points.length === 0) { + return; + } + + const dates = data.points.map((p) => p.bizDate); + const values = data.points.map((p) => + metric === "price" ? p.avgPriceYuanPerKg : (p.indexValue ?? null), + ); + if (values.some((v) => v == null)) { + echarts.dispose(el); + return; + } + + const chart = echarts.init(el); + chart.setOption({ + title: { + text: `${data.displayName} · ${titleMetric}`, + left: 0, + top: 0, + textStyle: { fontSize: 14, color: "#1a1d21", fontWeight: 600 }, + }, + grid: { left: 52, right: 20, top: 44, bottom: 44 }, + tooltip: { trigger: "axis" }, + xAxis: { + type: "category", + data: dates, + boundaryGap: false, + axisLabel: { color: "#5c6370", rotate: 30 }, + }, + yAxis: { + type: "value", + name: titleMetric, + splitLine: { lineStyle: { type: "dashed", color: "#e2e5ea" } }, + axisLabel: { color: "#5c6370" }, + }, + series: [ + { + type: "line", + smooth: true, + symbol: "circle", + symbolSize: 6, + lineStyle: { width: 2, color: "#1b7f5a" }, + itemStyle: { color: "#1b7f5a" }, + areaStyle: { color: "rgba(27, 127, 90, 0.08)" }, + data: values as number[], + }, + ], + }); + + const onResize = () => chart.resize(); + window.addEventListener("resize", onResize); + return () => { + window.removeEventListener("resize", onResize); + chart.dispose(); + }; + }, [data, metric, titleMetric]); + + if (!Number.isFinite(varietyId) || varietyId <= 0) { + return ( +
+

趋势

+

+ 请在 URL 中提供 varietyId,例如从{" "} + 查询页 进入。 +

+
+ ); + } + + return ( +
+

趋势

+

+ 默认近 30 个自然日(与后端约定一致)。当前指标: + {metric};可切换{" "} + 指数 /{" "} + 均价。 +

+ {error ?

{error}

: null} + {!error && data && data.points.length === 0 ? ( +

该区间内暂无数据。

+ ) : null} +
+
+
+
+ ); +} diff --git a/web/src/pages/page.css b/web/src/pages/page.css new file mode 100644 index 0000000..d784a43 --- /dev/null +++ b/web/src/pages/page.css @@ -0,0 +1,74 @@ +.pageTitle { + margin: 0 0 0.35rem; + font-size: 1.35rem; +} + +.pageLead { + margin: 0 0 1rem; + color: var(--muted); + font-size: 0.95rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.1rem; +} + +.cardGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.85rem; + margin-top: 1rem; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; +} + +.pillOk { + background: var(--accent-soft); + color: var(--accent); +} + +.pillErr { + background: #fdecec; + color: var(--danger); +} + +.pillPending { + background: #eef0f3; + color: var(--muted); +} + +.chartBox { + width: 100%; + height: 360px; + margin-top: 0.75rem; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + margin-top: 0.75rem; +} + +.table th, +.table td { + border: 1px solid var(--border); + padding: 0.5rem 0.65rem; + text-align: left; +} + +.table th { + background: #f0f2f5; + font-weight: 600; +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..03e8021 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..e338d3f --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..7366cef --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler" + }, + "include": ["vite.config.ts"] +} diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo new file mode 100644 index 0000000..70a4fdf --- /dev/null +++ b/web/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/catalog.ts","./src/api/client.ts","./src/layout/applayout.tsx","./src/pages/aboutpage.tsx","./src/pages/historypage.tsx","./src/pages/homepage.tsx","./src/pages/querypage.tsx","./src/pages/rankpage.tsx","./src/pages/trendpage.tsx"],"version":"5.6.3"} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..4ff5183 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,21 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://127.0.0.1:8080", + changeOrigin: true, + }, + }, + }, +});