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
}>
+
+
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+
+
+ );
+}
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 ? (
+
暂无数据。
+ ) : (
+
+
+
+ | 日期 |
+ 均价(元/kg) |
+ 指数 |
+ 版本 |
+
+
+
+ {resp.content.map((r) => (
+
+ | {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(type、metric、可选 bizDate
+ );环比为相对上一自然日。
+
+
+
+
+ 榜单类型
+
+
+
+
+ 度量
+
+
+
+
+
+ {bizDate ? (
+
+ ) : null}
+
+
+
+ {error ? {error}
: null}
+
+
+ {rows.length === 0 && !error ? (
+
暂无榜单数据。
+ ) : null}
+ {rows.length > 0 ? (
+
+
+
+ | # |
+ 品种 |
+ 交易日 |
+ 当日 |
+ 昨日 |
+ {type === "change_pct" ? 环比% | : null}
+
+
+
+ {rows.map((r, idx) => (
+
+ | {idx + 1} |
+
+
+ {r.displayName}
+
+ |
+ {r.bizDate} |
+ {r.valueToday == null ? "—" : r.valueToday} |
+ {r.valueYesterday == null ? "—" : r.valueYesterday} |
+ {type === "change_pct" ? (
+ {r.changePct == null ? "—" : r.changePct} |
+ ) : null}
+
+ ))}
+
+
+ ) : 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,
+ },
+ },
+ },
+});