init: fao project v0.1

This commit is contained in:
malei 2026-04-23 19:24:46 +08:00
commit b2234bc412
106 changed files with 6397 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -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

4
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,4 @@
{
"java.compile.nullAnalysis.mode": "automatic",
"java.configuration.updateBuildConfiguration": "automatic"
}

29
config/.env.example Normal file
View File

@ -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 采集写 rawingest_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

0
config/.gitkeep Normal file
View File

View File

@ -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"
}
]
}

0
db/migrations/.gitkeep Normal file
View File

0
deploy/.gitkeep Normal file
View File

View File

@ -0,0 +1,210 @@
# 蔬菜价格指数平台 — 产品设计文档
> 版本1.2
> 状态:与《架构设计》对齐的 MVP 基线
> 范围:价格查询、指数趋势、价格榜单、历史数据;兼顾普通用户与专业用户
---
## 1. 文档目的与边界
**目的**:为前台信息架构、页面逻辑、交互与迭代节奏提供可执行的产品基线,避免过度设计。
**边界**
- 采集、治理、指算法实现细节以《架构设计》与数据契约为准;本文不重复技术实现,仅约定**产品可见的数据契约**(口径、粒度、更新频率、异常说明)。
- 展示层技术栈与架构一致:**Vue 或 React + ECharts**,请求 **JavaSpring BootREST 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 | 与《数据源上线清单》互链;正式源持续采集门禁引用 |

View File

@ -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.0MVP版实现核心功能完成PC端基础展示保障操作流畅、数据准确
- V2.0(进阶版):完善查询、历史数据、多维度对比功能,优化视觉与交互,适配移动端;
- V3.0(优化版):新增用户分层、数据导出、异常提醒,拓展专业分析功能;
- 扩展方向地区价格对比、蔬菜品类细分指数、行情预测、B端定制化功能。
### 8. 附加输出
主动提供辅助设计内容,助力产品落地:
- 用户操作流程图(文字版);
- 核心页面的信息架构表;
- 功能需求清单(含优先级、验收标准);
- 与技术、架构师的协作要点(明确产品需求与技术实现的衔接)。
## 回答风格
- 专业、简洁、落地,多用标题+列表,拒绝冗余废话,避免空泛的设计理论;
- 兼顾新手友好与专业性,设计方案无需复杂技术支撑,同时预留扩展空间;
- 若需补充某部分细节(如交互细节、视觉规范细则、需求清单),可直接响应指令补充。
# 二、精简短版提示词适合AI平台字符限制轻量化适配
你是资深产品设计师专注数据可视化平台设计核心负责蔬菜价格指数平台产品设计。需围绕「蔬菜价格查询、指数趋势、价格榜单、历史数据」核心业务聚焦普通用户与专业用户需求输出结构化设计方案用户画像→产品架构→页面逻辑→交互视觉→优先级→迭代规划。优先轻量化MVP落地贴合Python+ECharts技术栈兼顾操作便捷性与数据直观性自动考虑用户痛点、多端适配、合规性回答简洁专业、可落地拒绝过度设计。
# 三、超短压缩版适合快速塞入AI助手极简适配
资深产品设计师,专注蔬菜价格指数平台,输出结构化设计:用户需求→产品架构→页面交互→视觉规范→优先级迭代,轻量化落地,贴合技术栈,兼顾普通/专业用户,简洁可落地,自动规避用户痛点。
> (注:文档部分内容可能由 AI 生成)

View File

@ -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` 最多处理条数;代码中还会限制在 **1200**
- **`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** | 每 1560 分钟(视源站授权) | 外网抓取;**仓库内无内置调度**,靠 Cron / K8s CronJob。 |
| **Java 消费 raw** | `fixed-delay` 515 分钟 | 应 **不显著快于** 单次治理耗时,避免空转;可略快于采集,以便积压尽快消化。 |
若 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 <name>`,并注入 `FAO_MYSQL_*` / `FAO_HTTP_JSON_URL` 等环境变量。
---
## 4. 修订记录
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0 | 2026-04-23 | 初稿application 片段、Profile 样例、Cron 引用 |

View File

@ -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.12.6 合规项已归档
- [ ] 第 3 节 SLA 已写入契约或附件
- [ ] `source_code` 已录入维表并与前台展示方案一致
- [ ] 5.15.5 技术项已实现或已排期
- [ ] 第 6 节告警已接入值班渠道
- [ ] 第 7 节降级策略与话术已产品/法务确认
**签字角色建议**:产品负责人、研发负责人、运维/ SRE 或等价角色、法务或授权审批人(视组织要求)。
---
## 9. 修订记录
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0 | 2026-04-23 | 初稿:上线持续采集门禁与运维基线 |

225
docs/架构设计.md Normal file
View File

@ -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
指数计算引擎 ←── 基期/权重/算法版本配置
指标结果表 + 维表
对外/管理 APIJava/ 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. **APIJava**:读指标与维表;**管理端** 对规则、维表、重算任务 **发令**;缓存键与索引策略在 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. 分阶段迭代
| 阶段 | 内容 | 验收参考 |
|------|------|----------|
| 阶段 1MVP | 12 个稳定源、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 为准。*

28
pyproject.toml Normal file
View File

@ -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"]

0
scripts/.gitkeep Normal file
View File

View File

@ -0,0 +1,15 @@
# 示例 crontabFAO Python 采集(仅写 raw。安装前请全文替换路径、适配器与日志路径。
# 安装crontab /path/to/fao/scripts/cron/fao-ingest.crontab.example
#
# 依赖:已配置 MySQLFAO_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

6
scripts/run_ingest.sh Normal file
View File

@ -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 "$@"

2
server/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/target
!.mvn/wrapper/maven-wrapper.jar

80
server/pom.xml Normal file
View File

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<groupId>com.fao</groupId>
<artifactId>fao-server</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>fao-server</name>
<description>蔬菜价格指数平台 — Java 后台治理、指数、API、管理端</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.5.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -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);
}
}

View File

@ -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_eventprice_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<String> sourceCodes = body != null ? body.sourceCodes() : null;
return rawGovernanceService.consume(limit, sourceCodes);
}
}

View File

@ -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");
};
}
}

View File

@ -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<String, String> ping() {
return Map.of("status", "ok", "component", "fao-server");
}
}

View File

@ -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<RankingItemDto> 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);
}
}

View File

@ -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");
};
}
}

View File

@ -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<Map<String, Object>> handle(ResponseStatusException ex) {
return ResponseEntity.status(ex.getStatusCode())
.body(
Map.of(
"error", ex.getStatusCode().value(),
"message", ex.getReason() == null ? "" : ex.getReason()));
}
}

View File

@ -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<VarietySummaryDto> 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<DailyPointDto> 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);
}
}

View File

@ -0,0 +1,5 @@
package com.fao.platform.api.dto;
import java.util.List;
public record ConsumeRawRequest(Integer limit, List<String> sourceCodes) {}

View File

@ -0,0 +1,7 @@
package com.fao.platform.api.dto;
public record ConsumeRawResponse(
int batchSize,
int eventsDone,
int eventsFailed,
int quotesUpserted) {}

View File

@ -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) {}

View File

@ -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) {}

View File

@ -0,0 +1,6 @@
package com.fao.platform.api.dto;
import java.util.List;
public record PageResponseDto<T>(
List<T> content, long totalElements, int page, int size, int totalPages) {}

View File

@ -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) {}

View File

@ -0,0 +1,9 @@
package com.fao.platform.api.dto;
import java.util.List;
public record SeriesResponseDto(
long varietyId,
String displayName,
String metric,
List<DailyPointDto> points) {}

View File

@ -0,0 +1,8 @@
package com.fao.platform.api.dto;
public record VarietyDetailDto(
long id,
String code,
String displayName,
String aliases,
LatestQuoteDto latest) {}

View File

@ -0,0 +1,3 @@
package com.fao.platform.api.dto;
public record VarietySummaryDto(long id, String code, String displayName, String aliases) {}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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<DailyMarket, Long> {
Optional<DailyMarket> findByVariety_IdAndBizDate(long varietyId, LocalDate bizDate);
Optional<DailyMarket> findTopByVarietyIdOrderByBizDateDesc(long varietyId);
List<DailyMarket> findByVarietyIdAndBizDateBetweenOrderByBizDateAsc(
long varietyId, LocalDate from, LocalDate to);
Page<DailyMarket> findByVarietyIdAndBizDateBetweenOrderByBizDateDesc(
long varietyId, LocalDate from, LocalDate to, Pageable pageable);
List<DailyMarket> findByBizDate(LocalDate bizDate);
@Query("SELECT MAX(d.bizDate) FROM DailyMarket d")
Optional<LocalDate> findLatestBizDate();
}

View File

@ -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;
}
}

View File

@ -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<IngestRawEvent, Long> {
List<IngestRawEvent> findByIngestionStatusOrderByIdAsc(
IngestionStatus status, Pageable pageable);
List<IngestRawEvent> findByIngestionStatusAndSourceCodeInOrderByIdAsc(
IngestionStatus status, Collection<String> sourceCodes, Pageable pageable);
}

View File

@ -0,0 +1,7 @@
package com.fao.platform.domain;
public enum IngestionStatus {
PENDING,
DONE,
FAILED
}

View File

@ -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;
}
}

View File

@ -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<Variety, Long> {
Optional<Variety> 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<Variety> search(@Param("q") String q, Pageable pageable);
}

View File

@ -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);
}
}

View File

@ -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<String> sourceCodes = new ArrayList<>();
private Scheduled scheduled = new Scheduled();
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public List<String> getSourceCodes() {
return sourceCodes;
}
public void setSourceCodes(List<String> 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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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<String> sourceCodesOverride) {
int safe = Math.min(Math.max(limit, 1), 200);
List<String> 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<String> effectiveSourceCodes(List<String> override) {
List<String> fromOverride = normalizeCodes(override);
if (!fromOverride.isEmpty()) {
return fromOverride;
}
return normalizeCodes(governanceProperties.getSourceCodes());
}
private static List<String> normalizeCodes(List<String> raw) {
if (raw == null || raw.isEmpty()) {
return List.of();
}
List<String> 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<IngestRawEvent> 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<DailyMarket> 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
}
}
}

View File

@ -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<VarietySummaryDto> 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<DailyPointDto> 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<DailyPointDto> 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<DailyMarket> result =
dailyMarketRepository.findByVarietyIdAndBizDateBetweenOrderByBizDateDesc(
id,
range.from(),
range.to(),
PageRequest.of(safePage, safeSize, Sort.by(Sort.Direction.DESC, "bizDate")));
List<DailyPointDto> content = result.getContent().stream().map(this::toPoint).toList();
return new PageResponseDto<>(
content,
result.getTotalElements(),
result.getNumber(),
result.getSize(),
result.getTotalPages());
}
public List<RankingItemDto> getRankings(
RankingType type, MarketMetric metric, LocalDate bizDate, int limit) {
LocalDate d =
bizDate != null
? bizDate
: dailyMarketRepository
.findLatestBizDate()
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND, "暂无行情数据用于榜单"));
List<DailyMarket> today = dailyMarketRepository.findByBizDate(d);
if (today.isEmpty()) {
return List.of();
}
LocalDate prev = d.minusDays(1);
List<DailyMarket> yList = dailyMarketRepository.findByBizDate(prev);
Map<Long, DailyMarket> yMap =
yList.stream()
.collect(
Collectors.toMap(dm -> dm.getVariety().getId(), Function.identity(), (a, b) -> a));
List<RankingItemDto> 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<RankingItemDto> 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) {}
}

View File

@ -0,0 +1,24 @@
# 复制为 application-scheduled.yml 后启用 Profilescheduled
# 启动示例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 条数(服务端再限制在 1200
batch-limit: 50

View File

@ -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

View File

@ -0,0 +1,3 @@
-- 基线脚本:表结构在后续 V2、V3… 中追加(如 raw、维表、index_series
-- 占位语句便于 Flyway 在空库上成功执行一次迁移。
SET NAMES utf8mb4;

View File

@ -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);

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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() {
// 不连 MySQLtest profile 下使用 H2Flyway 关闭 hibernate create-drop
}
}

View File

@ -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()));
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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

5
setup.py Normal file
View File

@ -0,0 +1,5 @@
"""setuptools 入口,兼容部分旧版 pip 的 editable 安装;元数据以 pyproject.toml 为准。"""
from setuptools import setup
if __name__ == "__main__":
setup()

3
src/fao/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""蔬菜价格指数平台 — 仅含采集ingest。治理、指数、API 在 Java `server/` 工程。"""
__version__ = "0.1.0"

View File

@ -0,0 +1 @@
# 公共:配置、日志、时区;与 Java 服务共享环境变量/配置键约定。

View File

@ -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"),
)

1
src/fao/db/__init__.py Normal file
View File

@ -0,0 +1 @@
# 爬虫侧数据访问:以 raw/队列表写入为主,表结构以 Java 工程迁移为准。

35
src/fao/db/mysql.py Normal file
View File

@ -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()

62
src/fao/db/raw.py Normal file
View File

@ -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())

View File

@ -0,0 +1 @@
# 采集层:调度入口、向 raw 落库;各源在 adapters/。业务治理与指数在 Java 侧。

View File

@ -0,0 +1 @@
# 各数据源一个子模块,禁止在包外硬编码源站选择器。

View File

@ -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 行。"""

View File

@ -0,0 +1,53 @@
"""
演示适配器读取仓库内静态 JSON产品 2.1CSV/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,
)

View File

@ -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,
)

View File

@ -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"))

57
src/fao/ingest/cli.py Normal file
View File

@ -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 行 rawingest_raw_event")
else:
print("[fao-ingest] 跳过:相同 source_code + fingerprint 已存在(幂等)")
return 0
def app() -> None:
"""setuptools console_scripts 入口。"""
raise SystemExit(main())
if __name__ == "__main__":
app()

View File

@ -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

29
src/fao/ingest/runner.py Normal file
View File

@ -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,
)

View File

0
tests/unit/.gitkeep Normal file
View File

View File

@ -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)

View File

@ -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"

2
web/.env.example Normal file
View File

@ -0,0 +1,2 @@
# 留空则使用 Vite 开发代理(见 vite.config.ts → /api → :8080
# VITE_API_BASE=http://127.0.0.1:8080

1
web/.env.production Normal file
View File

@ -0,0 +1 @@
VITE_API_BASE=https://fao.s-good.com

4
web/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
dist
*.local
.DS_Store

0
web/.gitkeep Normal file
View File

12
web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>蔬菜价格指数平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1797
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
web/package.json Normal file
View File

@ -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"
}
}

35
web/src/App.tsx Normal file
View File

@ -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 (
<AppLayout>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/query" element={<QueryPage />} />
<Route
path="/trend"
element={
<Suspense fallback={<p style={{ color: "var(--muted)" }}></p>}>
<TrendPage />
</Suspense>
}
/>
<Route path="/rank" element={<RankPage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/about" element={<AboutPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppLayout>
);
}

110
web/src/api/catalog.ts Normal file
View File

@ -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<T> = {
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<VarietySummary[]>(`/api/v1/varieties/search?${qs}`);
}
export function fetchVarietyDetail(id: number) {
return apiGet<VarietyDetail>(`/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<SeriesResponse>(
`/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<PageResponse<DailyPoint>>(
`/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<RankingItem[]>(`/api/v1/rankings?${qs}`);
}

39
web/src/api/client.ts Normal file
View File

@ -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<T>(path: string): Promise<T> {
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<PingResponse>("/api/v1/ping");
}

42
web/src/index.css Normal file
View File

@ -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;
}

View File

@ -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);
}

View File

@ -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 (
<div className={styles.shell}>
<header className={styles.header}>
<NavLink to="/" className={styles.brand}>
</NavLink>
<nav className={styles.nav}>
{nav.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === "/"}
className={({ isActive }) =>
isActive ? styles.navLinkActive : styles.navLink
}
>
{item.label}
</NavLink>
))}
</nav>
</header>
<main className={styles.main}>{children}</main>
<footer className={styles.footer}>
<span></span>
</footer>
</div>
);
}

13
web/src/main.tsx Normal file
View File

@ -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(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

View File

@ -0,0 +1,30 @@
import "./page.css";
export function AboutPage() {
return (
<section>
<h1 className="pageTitle"></h1>
<p className="pageLead">
{" "}
<code>config_version</code>
</p>
<div className="card">
<ul style={{ margin: 0, paddingLeft: "1.2rem", color: "var(--muted)" }}>
<li> MVP /稿</li>
<li> Python raw Java </li>
<li>
{" "}
<a href="http://127.0.0.1:8080/v3/api-docs" target="_blank" rel="noreferrer">
OpenAPI JSON
</a>
{" "}
<a href="http://127.0.0.1:8080/swagger-ui/index.html" target="_blank" rel="noreferrer">
Swagger UI
</a>
</li>
</ul>
</div>
</section>
);
}

View File

@ -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<string | null>(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 (
<section>
<h1 className="pageTitle"></h1>
<p className="pageLead">
URL <code>varietyId</code>{" "}
<Link to="/history?varietyId=1">/history?varietyId=1</Link>
</p>
</section>
);
}
return (
<section>
<h1 className="pageTitle"></h1>
<p className="pageLead">
30 <code>page</code>
<code>size</code>
</p>
<p style={{ marginTop: 0 }}>
<Link to={`/trend?varietyId=${varietyId}&metric=index`}></Link> ·{" "}
<Link to={`/query?q=&varietyId=${varietyId}`}></Link>
</p>
{error ? <p style={{ color: "var(--danger)" }}>{error}</p> : null}
<div className="card">
{!resp ? (
<p style={{ margin: 0, color: "var(--muted)" }}></p>
) : resp.content.length === 0 ? (
<p style={{ margin: 0, color: "var(--muted)" }}></p>
) : (
<table className="table">
<thead>
<tr>
<th></th>
<th>/kg</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{resp.content.map((r) => (
<tr key={r.bizDate}>
<td>{r.bizDate}</td>
<td>{r.avgPriceYuanPerKg}</td>
<td>{r.indexValue == null ? "—" : r.indexValue}</td>
<td>
{r.indexCode} / v{r.configVersion}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{resp && resp.totalPages > 1 ? (
<div style={{ display: "flex", gap: "0.5rem", marginTop: "0.85rem", flexWrap: "wrap" }}>
<button type="button" disabled={page <= 0} onClick={() => setPage(page - 1)}>
</button>
<span style={{ color: "var(--muted)", alignSelf: "center" }}>
{page + 1} / {resp.totalPages} · {resp.totalElements}
</span>
<button
type="button"
disabled={page + 1 >= resp.totalPages}
onClick={() => setPage(page + 1)}
>
</button>
</div>
) : null}
</section>
);
}

View File

@ -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<ConnState>({ 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 (
<section>
<h1 className="pageTitle"></h1>
<p className="pageLead">
{" "}
<code>/api/v1/ping</code>
</p>
<div className="card">
<strong></strong>
<div style={{ marginTop: "0.5rem" }}>
{conn.kind === "idle" || conn.kind === "loading" ? (
<span className="pill pillPending"></span>
) : null}
{conn.kind === "ok" ? (
<span className="pill pillOk">
· {conn.data.component} · {conn.data.status}
</span>
) : null}
{conn.kind === "error" ? (
<span className="pill pillErr">{conn.message}</span>
) : null}
</div>
<p style={{ margin: "0.65rem 0 0", fontSize: "0.85rem", color: "var(--muted)" }}>
Java <code>8080</code>{" "}
<code>npm run dev</code>{" "}
<code>VITE_API_BASE</code>
</p>
</div>
<div className="cardGrid">
<Link to="/query?q=西红柿" className="card" style={{ textDecoration: "none", color: "inherit" }}>
<strong></strong>
<p style={{ margin: "0.4rem 0 0", fontSize: "0.9rem", color: "var(--muted)" }}>
/ /api/v1/varieties
</p>
</Link>
<Link to="/trend?varietyId=1&metric=index" className="card" style={{ textDecoration: "none", color: "inherit" }}>
<strong></strong>
<p style={{ margin: "0.4rem 0 0", fontSize: "0.9rem", color: "var(--muted)" }}>
ECharts + /series id=1
</p>
</Link>
<Link to="/rank" className="card" style={{ textDecoration: "none", color: "inherit" }}>
<strong></strong>
<p style={{ margin: "0.4rem 0 0", fontSize: "0.9rem", color: "var(--muted)" }}>
/api/v1/rankings
</p>
</Link>
<Link to="/history" className="card" style={{ textDecoration: "none", color: "inherit" }}>
<strong></strong>
<p style={{ margin: "0.4rem 0 0", fontSize: "0.9rem", color: "var(--muted)" }}>
URL varietyId
</p>
</Link>
</div>
</section>
);
}

216
web/src/pages/QueryPage.tsx Normal file
View File

@ -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<VarietySummary[]>([]);
const [hitsError, setHitsError] = useState<string | null>(null);
const [detail, setDetail] = useState<VarietyDetail | null>(null);
const [detailError, setDetailError] = useState<string | null>(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 (
<section>
<h1 className="pageTitle"></h1>
<p className="pageLead">
URL<code>q</code> <code>varietyId</code>便
</p>
<div className="card">
<label htmlFor="q" style={{ fontWeight: 600 }}>
</label>
<input
id="q"
type="search"
placeholder="例如:西红柿、黄瓜"
value={draft}
onChange={(e) => 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 ? (
<p style={{ margin: "0.5rem 0 0", color: "var(--danger)", fontSize: "0.9rem" }}>
{hitsError}
</p>
) : null}
</div>
{qFromUrl ? (
<div className="card" style={{ marginTop: "0.85rem" }}>
<strong></strong>
{hits.length === 0 && !hitsError ? (
<p style={{ margin: "0.5rem 0 0", color: "var(--muted)" }}></p>
) : null}
<ul style={{ listStyle: "none", padding: 0, margin: "0.65rem 0 0" }}>
{hits.map((v) => (
<li key={v.id} style={{ borderTop: "1px solid var(--border)" }}>
<button
type="button"
onClick={() =>
setSp(
(prev) => {
const p = new URLSearchParams(prev);
p.set("varietyId", String(v.id));
return p;
},
{ replace: true },
)
}
style={{
width: "100%",
textAlign: "left",
padding: "0.55rem 0.25rem",
border: "none",
background:
Number.isFinite(selectedId) && selectedId === v.id
? "var(--accent-soft)"
: "transparent",
borderRadius: "6px",
}}
>
<div style={{ fontWeight: 600 }}>{v.displayName}</div>
<div style={{ fontSize: "0.85rem", color: "var(--muted)" }}>
{v.code}
{v.aliases ? ` · 别名:${v.aliases}` : ""}
</div>
</button>
</li>
))}
</ul>
</div>
) : null}
{detailError ? (
<p style={{ margin: "0.85rem 0 0", color: "var(--danger)" }}>{detailError}</p>
) : null}
{detail ? (
<div className="card" style={{ marginTop: "0.85rem" }}>
<strong></strong>
<p style={{ margin: "0.35rem 0 0", color: "var(--muted)", fontSize: "0.9rem" }}>
{selectedSummary?.displayName ?? detail.displayName}{detail.code}
</p>
{detail.latest ? (
<ul style={{ margin: "0.5rem 0 0", paddingLeft: "1.1rem" }}>
<li>
<strong>{detail.latest.bizDate}</strong>
</li>
<li>
<strong>{detail.latest.avgPriceYuanPerKg}</strong> /kg
</li>
<li>
{detail.latest.indexCode} / v{detail.latest.configVersion}
<strong>
{detail.latest.indexValue == null ? "—" : detail.latest.indexValue}
</strong>
</li>
</ul>
) : (
<p style={{ margin: "0.5rem 0 0", color: "var(--muted)" }}></p>
)}
<p style={{ margin: "0.85rem 0 0", display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
<Link to={`/trend?varietyId=${detail.id}&metric=index`}></Link>
<Link to={`/history?varietyId=${detail.id}`}></Link>
<Link to={`/rank`}></Link>
</p>
</div>
) : null}
</section>
);
}

169
web/src/pages/RankPage.tsx Normal file
View File

@ -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<RankingItem[]>([]);
const [error, setError] = useState<string | null>(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<string, string | undefined>) => {
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 (
<section>
<h1 className="pageTitle"></h1>
<p className="pageLead">
URL<code>type</code><code>metric</code> <code>bizDate</code>
</p>
<div className="card" style={{ marginBottom: "0.85rem" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}></span>
<button
type="button"
className={type === "change_pct" ? "pill pillOk" : "pill pillPending"}
style={{ border: "none" }}
onClick={() => setParam({ type: "change_pct" })}
>
/%
</button>
<button
type="button"
className={type === "avg_price" ? "pill pillOk" : "pill pillPending"}
style={{ border: "none" }}
onClick={() => setParam({ type: "avg_price" })}
>
</button>
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
alignItems: "center",
marginTop: "0.65rem",
}}
>
<span style={{ fontWeight: 600 }}></span>
<button
type="button"
className={metric === "price" ? "pill pillOk" : "pill pillPending"}
style={{ border: "none" }}
onClick={() => setParam({ metric: "price" })}
>
</button>
<button
type="button"
className={metric === "index" ? "pill pillOk" : "pill pillPending"}
style={{ border: "none" }}
onClick={() => setParam({ metric: "index" })}
>
</button>
</div>
<div style={{ marginTop: "0.65rem", display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
<label style={{ fontWeight: 600 }}>
<input
type="date"
value={bizDate}
onChange={(e) => setParam({ bizDate: e.target.value })}
style={{ marginLeft: "0.35rem" }}
/>
</label>
{bizDate ? (
<button type="button" onClick={() => setParam({ bizDate: undefined })}>
</button>
) : null}
</div>
</div>
{error ? <p style={{ color: "var(--danger)" }}>{error}</p> : null}
<div className="card">
{rows.length === 0 && !error ? (
<p style={{ margin: 0, color: "var(--muted)" }}></p>
) : null}
{rows.length > 0 ? (
<table className="table">
<thead>
<tr>
<th>#</th>
<th></th>
<th></th>
<th></th>
<th></th>
{type === "change_pct" ? <th>%</th> : null}
</tr>
</thead>
<tbody>
{rows.map((r, idx) => (
<tr key={`${r.varietyId}-${r.bizDate}-${idx}`}>
<td>{idx + 1}</td>
<td>
<Link to={`/query?q=${encodeURIComponent(r.displayName)}&varietyId=${r.varietyId}`}>
{r.displayName}
</Link>
</td>
<td>{r.bizDate}</td>
<td>{r.valueToday == null ? "—" : r.valueToday}</td>
<td>{r.valueYesterday == null ? "—" : r.valueYesterday}</td>
{type === "change_pct" ? (
<td>{r.changePct == null ? "—" : r.changePct}</td>
) : null}
</tr>
))}
</tbody>
</table>
) : null}
</div>
</section>
);
}

139
web/src/pages/TrendPage.tsx Normal file
View File

@ -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<SeriesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const hostRef = useRef<HTMLDivElement>(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 (
<section>
<h1 className="pageTitle"></h1>
<p className="pageLead">
URL <code>varietyId</code>{" "}
<Link to="/query?q=西红柿"></Link>
</p>
</section>
);
}
return (
<section>
<h1 className="pageTitle"></h1>
<p className="pageLead">
30
<code>{metric}</code>{" "}
<Link to={`/trend?varietyId=${varietyId}&metric=index`}></Link> /{" "}
<Link to={`/trend?varietyId=${varietyId}&metric=price`}></Link>
</p>
{error ? <p style={{ color: "var(--danger)" }}>{error}</p> : null}
{!error && data && data.points.length === 0 ? (
<p style={{ color: "var(--muted)" }}></p>
) : null}
<div className="card">
<div ref={hostRef} className="chartBox" />
</div>
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More