init: fao project v0.1
This commit is contained in:
commit
b2234bc412
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal 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
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
29
config/.env.example
Normal file
29
config/.env.example
Normal 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 采集写 raw(ingest_raw_event),与 server Flyway 对齐:
|
||||
FAO_MYSQL_HOST=127.0.0.1
|
||||
FAO_MYSQL_PORT=3306
|
||||
FAO_MYSQL_USER=fao
|
||||
FAO_MYSQL_PASSWORD=changeme
|
||||
FAO_MYSQL_DATABASE=fao
|
||||
#
|
||||
# 可选:覆盖默认样例路径(默认 data/samples/demo_vegetable_prices.json)
|
||||
# FAO_SAMPLE_JSON_PATH=
|
||||
#
|
||||
# http_json 适配器(仅用于已授权 JSON 接口)
|
||||
# FAO_HTTP_JSON_URL=
|
||||
# FAO_HTTP_SOURCE_CODE=DEMO
|
||||
# FAO_HTTP_TIMEOUT_SECONDS=30
|
||||
# FAO_HTTP_USER_AGENT=fao-ingest/0.1
|
||||
#
|
||||
# 可选: Redis
|
||||
# FAO_REDIS_URL=redis://127.0.0.1:6379/0
|
||||
0
config/.gitkeep
Normal file
0
config/.gitkeep
Normal file
21
data/samples/demo_vegetable_prices.json
Normal file
21
data/samples/demo_vegetable_prices.json
Normal 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
0
db/migrations/.gitkeep
Normal file
0
deploy/.gitkeep
Normal file
0
deploy/.gitkeep
Normal file
210
docs/产品设计-蔬菜价格指数平台.md
Normal file
210
docs/产品设计-蔬菜价格指数平台.md
Normal file
@ -0,0 +1,210 @@
|
||||
# 蔬菜价格指数平台 — 产品设计文档
|
||||
|
||||
> 版本:1.2
|
||||
> 状态:与《架构设计》对齐的 MVP 基线
|
||||
> 范围:价格查询、指数趋势、价格榜单、历史数据;兼顾普通用户与专业用户
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档目的与边界
|
||||
|
||||
**目的**:为前台信息架构、页面逻辑、交互与迭代节奏提供可执行的产品基线,避免过度设计。
|
||||
|
||||
**边界**:
|
||||
|
||||
- 采集、治理、指算法实现细节以《架构设计》与数据契约为准;本文不重复技术实现,仅约定**产品可见的数据契约**(口径、粒度、更新频率、异常说明)。
|
||||
- 展示层技术栈与架构一致:**Vue 或 React + ECharts**,请求 **Java(Spring Boot)REST API**;契约以 **OpenAPI** 为单一描述时可与研发对齐字段与版本。
|
||||
|
||||
**非目标(MVP 不做)**:复杂自定义 BI 看板、拖拽式报表编排、重社交/Feed、非必要的账号体系(除非合规或商业化明确要求)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 演示数据与正式数据源策略
|
||||
|
||||
目标:**演示阶段快速跑通流程与界面**;**接正式源时仅更换数据供给与治理实现**,不重做前台信息架构与主路径(「演示可快、正式可换」)。
|
||||
|
||||
### 2.1 演示阶段
|
||||
|
||||
- **数据形态**:优先 **CSV / JSON 静态样例**,或「一次性从公开渠道导出的快照」;可由 **Java 提供 `/demo` 类只读接口**(与《架构设计》中「展示层请求 Spring Boot」一致)。页面须**显著标注**「**演示数据,非实时**」。
|
||||
- **字段与粒度**:样例的字段名、时间粒度、单位(如元/公斤)**按正式接入目标 schema 设计后再造数**,避免为演示单独发明一套键名,导致接正式源时前台与契约大面积返工。
|
||||
- **能力取舍**:**查询、趋势、榜单、历史** 四条主路径做全;**导出、多源合并、复杂权限** 可后放。
|
||||
|
||||
### 2.2 接正式源阶段
|
||||
|
||||
- **单一数据契约**:前台只依赖稳定字段(示例):标准**品种 ID**、**市场/区域**、**业务日期**、**价格类型**、**单位**、**数据来源 code**、**数据/配置版本**;新发地、部里信息系统、采购或合同数据等,均仅在 **采集 / 治理层** 映射到同一套结构。
|
||||
- **实现边界(与《架构设计》一致)**:**Python** 仅写 **raw**;**Java** 负责治理、指数与 **REST**;演示数据源将来替换为「读取同结构的 clean / 指标表」时,**前台路由与页面逻辑保持不变**。
|
||||
- **合规与运维**:正式源须落实 **授权或条款**(开放 API、采购数据协议、是否允许抓取等);界面与文档固定展示 **数据来源、更新时间、免责声明**;多源并存时禁止在无前缀说明的情况下 **静默混源**。
|
||||
- **上线门禁**:生产环境 **长期自动化** 拉取须满足《[数据源上线清单](./数据源上线清单.md)》中的授权、SLA、观测告警与降级话术。
|
||||
|
||||
### 2.3 来源标识约定(评审落地)
|
||||
|
||||
- 演示数据:`source_code = DEMO`(或等价字段,以后端字典为准)。
|
||||
- 正式源:按接入顺序增加可枚举 code(示例:`XFD`、`MOA_…`,具体以后端维表为准)。
|
||||
- **API 与 UI 均须透传** `source_code`(及必要时的 `config_version`),便于对账、审计与用户理解,**避免混源时说不清口径**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户画像
|
||||
|
||||
| 角色 | 典型目标 | 关键行为 | 主要痛点 |
|
||||
|------|----------|----------|----------|
|
||||
| **普通用户** | 判断「某菜今天贵不贵」、大致涨跌 | 搜索品种、扫榜单、看短期趋势 | 术语多、不知看哪个指标、要求快、易懂 |
|
||||
| **专业用户** | 区域/时段对比、报告引用、假设验证 | 多条件筛选、历史区间、导出与可复现链接 | 口径不透明、缺元数据与版本、批量诉求 |
|
||||
| **运营/内容(内部)** | 榜单规则、公告、异常说明与前台一致 | 管理端配置与审核(能力随管理端分期开放) | 前后口径不一致、难追溯 |
|
||||
|
||||
**合规与信任(产品侧必显式满足)**:
|
||||
|
||||
- 默认**最小化个人信息**;无必要不登录。
|
||||
- 前台固定提供:**指标定义、单位、数据来源说明、更新频率、免责声明**;指数与算法变更建议带 **config_version** 或等价说明(与后端域模型一致)。
|
||||
- 若存在地域/市场级敏感粒度,展示与导出遵循组织**脱敏与权限**策略(与架构中的鉴权/审计一致)。
|
||||
|
||||
---
|
||||
|
||||
## 4. 产品架构(信息架构)
|
||||
|
||||
### 4.1 前台模块与核心业务映射
|
||||
|
||||
| 业务 | 用户价值 | 前台模块 |
|
||||
|------|----------|----------|
|
||||
| 蔬菜价格查询 | 单点现价/均价与指数快照 | **查询**(搜索 + 结果详情) |
|
||||
| 指数趋势 | 时间维度变化与对比 | **趋势**(默认折线图,ECharts) |
|
||||
| 价格榜单 | 横向比较涨跌与价位 | **榜单**(Tab:涨幅 / 跌幅 / 均价等,与数据就绪度对齐) |
|
||||
| 历史数据 | 长区间查阅与摘录 | **历史**(表格为主,图为辅) |
|
||||
|
||||
### 4.2 横切能力
|
||||
|
||||
- **口径与帮助**:图表/榜单旁统一「i」入口 → 指标定义、基期、滞后、异常值处理原则(文案与数据版本绑定)。
|
||||
- **深度链接**:筛选条件写入 **URL Query**(便于专业用户书签与协作);首屏性能由后端分页/汇总与可选缓存保障(见架构)。
|
||||
- **多端**:同一套 IA;移动端遵循「一屏一任务」:查询 → 结果 → 下钻趋势/榜单。
|
||||
|
||||
### 4.3 与管理端的关系(分期)
|
||||
|
||||
- MVP 前台可依赖 **Java 侧已落库的指标与维表** 只读展示;榜单规则、别名映射等以**后端当前生效配置**为准。
|
||||
- 运营配置界面随 **管理端** 分期上线;产品需约定「配置变更 → 前台展示延迟与版本提示」行为。
|
||||
|
||||
---
|
||||
|
||||
## 5. 页面逻辑与用户路径
|
||||
|
||||
### 5.1 首页
|
||||
|
||||
- **入口**:搜索框(品种联想)、热门品种快捷入口、今日概览(少数字卡片,避免信息堆叠)。
|
||||
- **出口**:进入查询结果、榜单默认 Tab、或「数据说明/口径」页。
|
||||
|
||||
### 5.2 查询(品种详情骨架)
|
||||
|
||||
1. 用户输入 → **联想列表**(标准品种名 + 别名命中规则由维表决定)。
|
||||
2. **结果页**:现价/均价/指数(以 API 实际字段为准)、最近更新时点、**看趋势**、**在榜单中的位置**(若榜单 API 支持锚点)。
|
||||
3. 子模块:**趋势**、**历史** 与详情共用同一品种上下文(路由或 Query 保持一致)。
|
||||
|
||||
### 5.3 趋势
|
||||
|
||||
- 默认展示 **近 30 天**(或与后端默认聚合粒度一致的可配置区间)。
|
||||
- **MVP**:单品种为主;**P1**:多品种对比、同比/环比(数据与公式就绪后开放,未就绪时灰显并链到口径说明)。
|
||||
- 图表类型默认 **折线**;大数据量时以后端降采样或聚合区间为准,前端避免一次渲染过量点。
|
||||
|
||||
### 5.4 榜单
|
||||
|
||||
- **Tab**:建议 MVP 含「涨幅榜」「均价榜」;「跌幅榜」与数据源同步上线。
|
||||
- **筛选器**:与查询共用维度(如区域、品类),**Sticky** 在列表顶部(桌面端);移动端收折为抽屉。
|
||||
- 行点击 → 进入与查询共用的**品种详情**。
|
||||
|
||||
### 5.5 历史数据
|
||||
|
||||
- **时间范围选择** → 表格 + 分页(或虚拟滚动,以后端分页契约为准)。
|
||||
- **图/表切换**:MVP 可二选一优先「表」;图用 ECharts 轻量折线即可。
|
||||
- **导出(P1)**:CSV 等能力依赖 Java API 与权限设计;MVP 可用「复制链接」替代部分场景。
|
||||
|
||||
### 5.6 异常与空态(统一文案策略)
|
||||
|
||||
区分并分别引导:**无数据**、**维护/延迟**、**筛选过窄**;避免笼统「加载失败」。必要时链到状态页或公告(若 API 提供)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 交互与视觉
|
||||
|
||||
### 6.1 布局原则
|
||||
|
||||
- **上**:筛选与控制;**中**:主视觉(图或表);**下**:口径、数据来源、免责声明。
|
||||
- 避免三栏复杂仪表盘;**一屏一主任务**。
|
||||
|
||||
### 6.2 图表与色彩(ECharts)
|
||||
|
||||
- **涨跌语义色**全站统一(建议色盲友好调色板);图例可开关系列。
|
||||
- **坐标轴**:单位与时间格式可读;专业用户需支持 **tooltip 精确值** 与 **缩放/拖拽(P1)** 按性能评估开启。
|
||||
|
||||
### 6.3 性能与感知
|
||||
|
||||
- 首屏 **骨架屏**;列表与图表请求失败可重试;长时间查询显式进度或预估(若后端支持)。
|
||||
|
||||
### 6.4 无障碍(在成本可控内)
|
||||
|
||||
- 关键趋势附 **数据摘要** 或表格式兜底;对比度符合规范;筛选控件尽量支持键盘操作(分期)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 功能优先级
|
||||
|
||||
### 7.1 MVP(第一期必交付)
|
||||
|
||||
1. 品种搜索 + 联想 + 详情页(价格/指数快照 + 更新时点)。
|
||||
2. 单品种趋势(默认区间 + ECharts 折线)。
|
||||
3. 榜单至少一类核心榜(涨幅或均价)+ 基础筛选。
|
||||
4. 历史数据表格 + 分页。
|
||||
5. 静态或半静态的 **数据说明 / 口径 / 免责声明**(可独立路由页 + 全局页脚入口)。
|
||||
|
||||
### 7.2 P1
|
||||
|
||||
- 多品种对比、同比环比;更多榜单 Tab。
|
||||
- 地域/市场维度扩展(与维表与 API 同步)。
|
||||
- CSV 导出、带参 URL 全量覆盖筛选态。
|
||||
- 简单反馈入口(非登录)。
|
||||
|
||||
### 7.3 刻意延后
|
||||
|
||||
- 自定义拖拽看板、复杂告警订阅、重运营活动页(除非有明确业务订单)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 迭代规划(建议节奏)
|
||||
|
||||
| 阶段 | 目标 | 产品交付物 |
|
||||
|------|------|------------|
|
||||
| 迭代 0 | 契约对齐 | 与研发确认 OpenAPI 首版字段:品种、时间粒度、指数 code、版本号、分页约定、**`source_code` 与演示/正式切换策略** |
|
||||
| 迭代 1 | 前台闭环 MVP | 查询、趋势、榜单、历史、口径页、空/异常态;**演示数据标注与来源透传** |
|
||||
| 迭代 2 | 专业增强 | 对比、导出、URL 状态、(如需)登录与权限可见范围 |
|
||||
| 持续 | 信任与运维体验 | 数据延迟说明自动化、口径版本 Release Note、监控状态对用户可见(只读) |
|
||||
|
||||
---
|
||||
|
||||
## 9. 成功指标(轻量)
|
||||
|
||||
| 指标 | 说明 |
|
||||
|------|------|
|
||||
| 搜索成功率 | 有结果点击 / 搜索发起 |
|
||||
| 详情二次行为率 | 从详情进入趋势或历史的比例 |
|
||||
| 榜单点击率 | 反映横向比较需求是否被满足 |
|
||||
| 口径页跳出率 | 过高则提示主界面指标仍不清晰 |
|
||||
| 导出次数(P1) | 专业渗透度 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 与研发的协作清单(产品需在评审前带齐)
|
||||
|
||||
- [ ] 默认时间粒度(日/周)与各页面默认区间。
|
||||
- [ ] 指数列表:`index_code` 与用户可见中文名映射表。
|
||||
- [ ] 地域/市场层级是否 MVP 开放及枚举来源。
|
||||
- [ ] 榜单排序定义(涨幅计算公式引用版本号)。
|
||||
- [ ] 延迟容忍:API `updated_at` 与前台展示策略。
|
||||
- [ ] 错误码与用户文案映射表(含维护态)。
|
||||
- [ ] **`source_code` 枚举**与「演示 / 正式」环境或开关策略;多源时混排规则与 UI 展示。
|
||||
|
||||
---
|
||||
|
||||
## 11. 修订记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| 1.0 | 2026-04-23 | 初稿,与《架构设计》v1.1 对齐 |
|
||||
| 1.1 | 2026-04-23 | 新增「演示数据与正式数据源策略」;协作清单与迭代 0/1 补充 `source_code` 与来源透传 |
|
||||
| 1.2 | 2026-04-23 | 与《数据源上线清单》互链;正式源持续采集门禁引用 |
|
||||
140
docs/产品设计师智能体提示词(蔬菜价格指数平台专属).md
Normal file
140
docs/产品设计师智能体提示词(蔬菜价格指数平台专属).md
Normal 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.0(MVP版):实现核心功能,完成PC端基础展示,保障操作流畅、数据准确;
|
||||
|
||||
- V2.0(进阶版):完善查询、历史数据、多维度对比功能,优化视觉与交互,适配移动端;
|
||||
|
||||
- V3.0(优化版):新增用户分层、数据导出、异常提醒,拓展专业分析功能;
|
||||
|
||||
- 扩展方向:地区价格对比、蔬菜品类细分指数、行情预测、B端定制化功能。
|
||||
|
||||
### 8. 附加输出
|
||||
|
||||
主动提供辅助设计内容,助力产品落地:
|
||||
|
||||
- 用户操作流程图(文字版);
|
||||
|
||||
- 核心页面的信息架构表;
|
||||
|
||||
- 功能需求清单(含优先级、验收标准);
|
||||
|
||||
- 与技术、架构师的协作要点(明确产品需求与技术实现的衔接)。
|
||||
|
||||
## 回答风格
|
||||
|
||||
- 专业、简洁、落地,多用标题+列表,拒绝冗余废话,避免空泛的设计理论;
|
||||
|
||||
- 兼顾新手友好与专业性,设计方案无需复杂技术支撑,同时预留扩展空间;
|
||||
|
||||
- 若需补充某部分细节(如交互细节、视觉规范细则、需求清单),可直接响应指令补充。
|
||||
|
||||
# 二、精简短版提示词(适合AI平台字符限制,轻量化适配)
|
||||
|
||||
你是资深产品设计师,专注数据可视化平台设计,核心负责蔬菜价格指数平台产品设计。需围绕「蔬菜价格查询、指数趋势、价格榜单、历史数据」核心业务,聚焦普通用户与专业用户需求,输出结构化设计方案(用户画像→产品架构→页面逻辑→交互视觉→优先级→迭代规划)。优先轻量化MVP落地,贴合Python+ECharts技术栈,兼顾操作便捷性与数据直观性,自动考虑用户痛点、多端适配、合规性,回答简洁专业、可落地,拒绝过度设计。
|
||||
|
||||
# 三、超短压缩版(适合快速塞入AI助手,极简适配)
|
||||
|
||||
资深产品设计师,专注蔬菜价格指数平台,输出结构化设计:用户需求→产品架构→页面交互→视觉规范→优先级迭代,轻量化落地,贴合技术栈,兼顾普通/专业用户,简洁可落地,自动规避用户痛点。
|
||||
> (注:文档部分内容可能由 AI 生成)
|
||||
80
docs/定时任务配置示例.md
Normal file
80
docs/定时任务配置示例.md
Normal 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` 最多处理条数;代码中还会限制在 **1~200**。
|
||||
- **`source-codes`**:非空时,定时任务与未带 `sourceCodes` 的 HTTP 治理请求都只处理列出的 `source_code`。
|
||||
|
||||
### 1.2 方式 B — Spring Profile(推荐与默认配置分离)
|
||||
|
||||
1. 将 `server/src/main/resources/application-scheduled.yml.example` **复制为** `application-scheduled.yml`(后者可加入 `.gitignore` 若含环境专有值)。
|
||||
2. 启动时增加:**`--spring.profiles.active=scheduled`**(或与 `default` 并列:`default,scheduled`)。
|
||||
|
||||
仅 profile 文件存在、未激活时,**不会**打开定时任务。
|
||||
|
||||
### 1.3 方式 C — 环境变量(容器 / systemd)
|
||||
|
||||
与 `fao.governance.scheduled.*` 对应的环境变量示例(Spring Boot 宽松绑定,以实际运行镜像为准):
|
||||
|
||||
| 配置项 | 环境变量示例 |
|
||||
|--------|----------------|
|
||||
| `scheduled.enabled` | `FAO_GOVERNANCE_SCHEDULED_ENABLED=true` |
|
||||
| `scheduled.initial-delay-ms` | `FAO_GOVERNANCE_SCHEDULED_INITIALDELAYMS=120000` |
|
||||
| `scheduled.fixed-delay-ms` | `FAO_GOVERNANCE_SCHEDULED_FIXEDDELAYMS=300000` |
|
||||
| `scheduled.batch-limit` | `FAO_GOVERNANCE_SCHEDULED_BATCHLIMIT=50` |
|
||||
|
||||
---
|
||||
|
||||
## 2. 与采集频率的配合(建议)
|
||||
|
||||
| 环节 | 典型节奏(示例) | 说明 |
|
||||
|------|------------------|------|
|
||||
| **Python 写 raw** | 每 15~60 分钟(视源站授权) | 外网抓取;**仓库内无内置调度**,靠 Cron / K8s CronJob。 |
|
||||
| **Java 消费 raw** | `fixed-delay` 5~15 分钟 | 应 **不显著快于** 单次治理耗时,避免空转;可略快于采集,以便积压尽快消化。 |
|
||||
|
||||
若 raw 积压大,可临时提高 `batch-limit` 或缩短 `fixed-delay-ms`,并观察 DB 与 CPU。
|
||||
|
||||
---
|
||||
|
||||
## 3. Cron 示例(采集)
|
||||
|
||||
见仓库 **`scripts/cron/fao-ingest.crontab.example`**。安装示例:
|
||||
|
||||
```bash
|
||||
# 编辑后安装到当前用户 crontab(路径、适配器、.env 务必按环境修改)
|
||||
crontab scripts/cron/fao-ingest.crontab.example
|
||||
```
|
||||
|
||||
生产更推荐 **K8s CronJob** 或统一调度平台,思路相同:定时执行 `scripts/run_ingest.sh --adapter <name>`,并注入 `FAO_MYSQL_*` / `FAO_HTTP_JSON_URL` 等环境变量。
|
||||
|
||||
---
|
||||
|
||||
## 4. 修订记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| 1.0 | 2026-04-23 | 初稿:application 片段、Profile 样例、Cron 引用 |
|
||||
116
docs/数据源上线清单.md
Normal file
116
docs/数据源上线清单.md
Normal 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.1~2.6 合规项已归档
|
||||
- [ ] 第 3 节 SLA 已写入契约或附件
|
||||
- [ ] `source_code` 已录入维表并与前台展示方案一致
|
||||
- [ ] 5.1~5.5 技术项已实现或已排期
|
||||
- [ ] 第 6 节告警已接入值班渠道
|
||||
- [ ] 第 7 节降级策略与话术已产品/法务确认
|
||||
|
||||
**签字角色建议**:产品负责人、研发负责人、运维/ SRE 或等价角色、法务或授权审批人(视组织要求)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 修订记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|------|------|------|
|
||||
| 1.0 | 2026-04-23 | 初稿:上线持续采集门禁与运维基线 |
|
||||
225
docs/架构设计.md
Normal file
225
docs/架构设计.md
Normal 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)
|
||||
│
|
||||
▼
|
||||
指数计算引擎 ←── 基期/权重/算法版本配置
|
||||
│
|
||||
▼
|
||||
指标结果表 + 维表
|
||||
│
|
||||
▼
|
||||
对外/管理 API(Java)/ ECharts
|
||||
```
|
||||
|
||||
- **源适配器(Python)**:一源一包;**仅** 输出统一 raw 契约;选择器、登录、限频不混入 Java 代码。
|
||||
- **治理/指数/服务(Java)**:消费 raw 表,**写** clean 与 `index_series` 等,对外提供 REST 与**运营配置能力**。
|
||||
- **指数字典与算法版本**:`index_code`、`config_version` 等字段在 **Java 域模型与 Flyway** 中一以贯之。
|
||||
- **计算幂等**:由 Java 批任务保证,按「业务日期 + 指数版本」可重算。
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据流(端到端)
|
||||
|
||||
1. 调度触发各源任务,拉取列表/详情。
|
||||
2. 写入 **raw**(可含 `LONGTEXT`/`JSON` 与关键检索列),记录 HTTP 状态与内容指纹。
|
||||
3. **去重(Java 治理)**:DB 唯一约束在 Flyway 中统一定义;Redis 去重与 Java/爬虫约定 key 前缀。
|
||||
4. **标准化(Java)**:单位、市场、品种别名 → 维表由 **管理端/导入** 维护。
|
||||
5. **异常与质量(Java)**:写 clean 与 `quality_flag`。
|
||||
6. **指数批算(Java)**:读清洗 + 当版本配置 → 写 `index_series`。
|
||||
7. **API(Java)**:读指标与维表;**管理端** 对规则、维表、重算任务 **发令**;缓存键与索引策略在 Java 侧实现。
|
||||
|
||||
---
|
||||
|
||||
## 6. MySQL 实践要点
|
||||
|
||||
- 版本与字符集:MySQL **8.0+**,`utf8mb4`,InnoDB。
|
||||
- 半结构化:JSON 存扩展字段,**高筛选字段尽量落普通列+索引**;8.0 可配合生成列建索引。
|
||||
- 大表:按**交易日期/入库日期** RANGE 分区,便于归档与冷数据迁出。
|
||||
- 多 Worker 队列表:使用 `SELECT … FOR UPDATE SKIP LOCKED`(8.0)实现任务抢占。
|
||||
- 备份:定期全量/增量,raw 与指标层策略与 RPO 对齐业务要求。
|
||||
|
||||
与 PostgreSQL 相比:若未来有极重 JSON 分析或强 GIS 需求,可再评估从库/分析库;当前以结构化为主的指数平台,**单 MySQL 可覆盖 MVP 至中期**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 反爬、质量与合规
|
||||
|
||||
| 主题 | 建议 |
|
||||
|------|------|
|
||||
| 反爬 | 频控、退避、UA/协议合法范围内轮换;**优先**对接官方或开放 API。 |
|
||||
| 去重 | DB 唯一约束 + 指纹/哈希;同日多价按产品规则(如中位、最新)在治理层定稿。 |
|
||||
| 异常 | 硬规则(负值、超阈)→ 统计(分位/IQR)→ 业务产季/地域规则。 |
|
||||
| 指算法 | 固定文档:公式、基期=100、缺权/缺价处理、品种替代;多版本可并存。 |
|
||||
| 合规 | robots/条款与用途评估;**最小化**存个人类信息;对公示指数附方法论与免责声明。 |
|
||||
|
||||
**生产环境长期自动化采集**:除上表原则外,须满足《[数据源上线清单](./数据源上线清单.md)》中的 **授权归档、SLA、观测告警、降级与对外话术** 等上线门禁;与《产品设计-蔬菜价格指数平台》中 `source_code`、来源透传约定一致。
|
||||
|
||||
---
|
||||
|
||||
## 8. 主要风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|------|------|
|
||||
| 源站改版 | 监控解析失败率;适配器独立;**原始 HTML/快照** 可选入对象存储 |
|
||||
| 数据损坏/误删 | raw 追加策略、备份、指数可重算 |
|
||||
| 单库压力 | 分区、读写分离(从库只读 API)、热数据 Redis |
|
||||
| 指争议与口径变更 | 配置版本化、**可重放**历史计算、对账报表 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 分阶段迭代
|
||||
|
||||
| 阶段 | 内容 | 验收参考 |
|
||||
|------|------|----------|
|
||||
| 阶段 1(MVP) | 1~2 个稳定源、Python 写 raw、Java 最小治理 + 1 套指数、**Spring Boot** + ECharts | 日任务成功率、指口径可说明、API P95 |
|
||||
| 阶段 2 | 多源、Java 管理维表/规则、多指数、Redis、监控、指数重算入口 | 维表覆盖率、重算耗时、跨语言可观测 |
|
||||
| 阶段 3 | 消息驱动、抓取/计算**水平扩展**、复杂地理层级、SLA | 扩展与容灾指标 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 扩展预留
|
||||
|
||||
- 表设计早期包含:`source_id`、`config_version`、**时间维度** 便于分区。
|
||||
- **表结构以 Java 侧迁移为权威**;若 Python 需 Pydantic 模型,**由 DDL/OpenAPI/手动同步**,避免两真相。
|
||||
- Java **API 无状态**,配置与密钥走 Spring Profile / 环境变量/中心配置。
|
||||
- Python 爬虫**无状态**、可水平扩展,仅依赖 DSN 与 `PYTHONPATH`/`pip install -e .`。
|
||||
|
||||
---
|
||||
|
||||
## 11. 文档维护
|
||||
|
||||
- 表结构、指数口径与**对外方法论文档**应与本文件同步更新(可拆子文档:`docs/指数字典与口径.md` 等)。
|
||||
- **生产采集门禁**与运维基线见《[数据源上线清单](./数据源上线清单.md)》;变更授权或 SLA 时同步评审本文件第 7 节是否需修订。
|
||||
- 重大架构变更时递增本文「版本」并记录变更摘要。
|
||||
|
||||
**版本摘要(1.2)**:增补对《数据源上线清单》的引用,明确上线环境持续采集的文档门禁。
|
||||
|
||||
---
|
||||
|
||||
## 12. 项目目录结构(与实现对齐)
|
||||
|
||||
**双栈单仓**(**Python 仅爬取**,**Java 为后台主工程**):
|
||||
|
||||
```text
|
||||
fao/
|
||||
├── docs/ # 架构、口径、运维说明
|
||||
├── server/ # **Java 主工程**(Spring Boot 3 + Maven;`pom.xml` 在根下)
|
||||
│ # `mvn -f server/pom.xml spring-boot:run`;迁移:`src/main/resources/db/migration/`
|
||||
├── src/
|
||||
│ └── fao/ # **仅** Python 采集包(pyproject 可 `pip install -e .`)
|
||||
│ ├── common/ # 爬虫子进程:配置、日志
|
||||
│ ├── db/ # 仅向 raw/队列写入所需的最小数据访问
|
||||
│ └── ingest/
|
||||
│ └── adapters/ # 各数据源一模块
|
||||
├── scripts/ # 爬虫/批抓入口(`python -m` 等),**不含**指计算
|
||||
├── config/ # 爬虫用 `.env.example` 等
|
||||
├── db/
|
||||
│ └── migrations/ # **可选**:与 `server` 中 Flyway **二选一为权威**;建议以 `server/.../db/migration` 为准后此处留空或删
|
||||
├── tests/
|
||||
│ ├── unit/ # 建议拆:server 内 surefire + Python pytest 各管各
|
||||
│ └── integration/
|
||||
├── web/ # 前端,对接 **Java API**
|
||||
├── deploy/ # 含 `docker-compose`:服务 `app`(Java) + `crawl`(Python) 等
|
||||
├── pyproject.toml # 仅 fao 爬虫包
|
||||
├── setup.py
|
||||
└── .gitignore
|
||||
```
|
||||
|
||||
| 路径 | 放什么、不放什么 |
|
||||
|------|------------------|
|
||||
| `server/` | 全部 **HTTP、安全、域模型、批治理、指计算、迁移**;**不** 写爬虫。 |
|
||||
| `src/fao/ingest` | 抓取、限频、**仅 raw/队列** 模型与写入;**禁止** 指算法与维表管理。 |
|
||||
| `src/fao/db` | 爬虫用 DAO;表结构**消费** Java Flyway 产出。 |
|
||||
| `scripts` | 只调度 **Python 爬取**;**指数重算/治理** 入口放在 **Java** 或企业调度调 Java 接口。 |
|
||||
| `config` | 以爬虫侧为主;Java 用 `server` 内 `application-*.yml`。 |
|
||||
| `web` | 管理端/大屏,BFF 为 **Java**。 |
|
||||
|
||||
**可选**:`e2e/` 联调;`ops/` 运维脚本。前端可拆独立仓库,**`server` 为后台边界**。
|
||||
|
||||
---
|
||||
|
||||
## 13. 语言边界与数据所有权
|
||||
|
||||
| 项目 | 约定 |
|
||||
|------|------|
|
||||
| **表结构** | **Java 工程迁移脚本** 为唯一权威;Python 升级前 **拉取最新** DDL 或从文档生成模型。 |
|
||||
| **raw 写入** | 仅 **Python**(或经审查的 ETL)写到约定列;**不** 写 clean/指数字段,除非团队明确开例外并文档化。 |
|
||||
| **clean / 指标** | 仅 **Java**(及经同一鉴权的运维脚本)。 |
|
||||
| **同一 Redis** | key 加前缀:`crawl:` / `app:` 等。 |
|
||||
| **重算指数** | 管理端/定时器调 **Java** 内部服务或 `POST /internal/...`(**内网 + 鉴权**)。 |
|
||||
| **故障** | 爬虫挂 → raw 空;**Java 报表仍可展示**历史指数据;**治理/批算**在 Java 侧可告警「无新 raw」。 |
|
||||
|
||||
---
|
||||
|
||||
*本文档为蔬菜价格指数平台的技术架构说明,实现细节以各模块设计与数据库 DDL 为准。*
|
||||
28
pyproject.toml
Normal file
28
pyproject.toml
Normal 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
0
scripts/.gitkeep
Normal file
15
scripts/cron/fao-ingest.crontab.example
Normal file
15
scripts/cron/fao-ingest.crontab.example
Normal file
@ -0,0 +1,15 @@
|
||||
# 示例 crontab:FAO Python 采集(仅写 raw)。安装前请全文替换路径、适配器与日志路径。
|
||||
# 安装:crontab /path/to/fao/scripts/cron/fao-ingest.crontab.example
|
||||
#
|
||||
# 依赖:已配置 MySQL(FAO_MYSQL_*),http_json 适配器还需 FAO_HTTP_JSON_URL 等(见 config/.env.example)。
|
||||
|
||||
SHELL=/bin/bash
|
||||
PATH=/usr/local/bin:/usr/bin:/bin
|
||||
# GNU cron 支持时,建议固定为上海时区,避免与业务「交易日」错位
|
||||
CRON_TZ=Asia/Shanghai
|
||||
|
||||
# 每 30 分钟执行一次 http_json 适配器(按授权与频控改为 15m / 60m 等)
|
||||
*/30 * * * * cd /ABS/PATH/TO/fao && [ -f config/.env ] && set -a && . ./config/.env && set +a && ./scripts/run_ingest.sh --adapter http_json >> /var/log/fao/ingest.log 2>&1
|
||||
|
||||
# 备用:仅本地/演示用 demo_json(不访问外网)
|
||||
# 15 * * * * cd /ABS/PATH/TO/fao && [ -f config/.env ] && set -a && . ./config/.env && set +a && ./scripts/run_ingest.sh --adapter demo_json >> /var/log/fao/ingest-demo.log 2>&1
|
||||
6
scripts/run_ingest.sh
Normal file
6
scripts/run_ingest.sh
Normal 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
2
server/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/target
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
80
server/pom.xml
Normal file
80
server/pom.xml
Normal 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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
package com.fao.platform.api;
|
||||
|
||||
import com.fao.platform.api.dto.ConsumeRawRequest;
|
||||
import com.fao.platform.api.dto.ConsumeRawResponse;
|
||||
import com.fao.platform.governance.GovernanceProperties;
|
||||
import com.fao.platform.governance.RawGovernanceService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import java.util.List;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@Tag(name = "治理(内部)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/internal/governance")
|
||||
public class GovernanceController {
|
||||
|
||||
private final GovernanceProperties governanceProperties;
|
||||
private final RawGovernanceService rawGovernanceService;
|
||||
|
||||
public GovernanceController(
|
||||
GovernanceProperties governanceProperties, RawGovernanceService rawGovernanceService) {
|
||||
this.governanceProperties = governanceProperties;
|
||||
this.rawGovernanceService = rawGovernanceService;
|
||||
}
|
||||
|
||||
@Operation(
|
||||
summary = "消费 ingest_raw_event(price_quote_batch)写入 daily_market",
|
||||
description = "可选 body: limit、sourceCodes(仅处理指定 source_code;不传则用配置 fao.governance.source-codes)")
|
||||
@PostMapping("/consume-raw")
|
||||
public ConsumeRawResponse consumeRaw(
|
||||
@RequestHeader("X-Fao-Governance-Token") String token,
|
||||
@RequestBody(required = false) ConsumeRawRequest body) {
|
||||
governanceProperties.assertToken(token);
|
||||
int limit = body != null && body.limit() != null ? body.limit() : 20;
|
||||
List<String> sourceCodes = body != null ? body.sourceCodes() : null;
|
||||
return rawGovernanceService.consume(limit, sourceCodes);
|
||||
}
|
||||
}
|
||||
22
server/src/main/java/com/fao/platform/api/MarketMetric.java
Normal file
22
server/src/main/java/com/fao/platform/api/MarketMetric.java
Normal 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");
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
24
server/src/main/java/com/fao/platform/api/RankingType.java
Normal file
24
server/src/main/java/com/fao/platform/api/RankingType.java
Normal 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");
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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()));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.fao.platform.api.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ConsumeRawRequest(Integer limit, List<String> sourceCodes) {}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.fao.platform.api.dto;
|
||||
|
||||
public record ConsumeRawResponse(
|
||||
int batchSize,
|
||||
int eventsDone,
|
||||
int eventsFailed,
|
||||
int quotesUpserted) {}
|
||||
@ -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) {}
|
||||
@ -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) {}
|
||||
@ -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) {}
|
||||
@ -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) {}
|
||||
@ -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) {}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.fao.platform.api.dto;
|
||||
|
||||
public record VarietyDetailDto(
|
||||
long id,
|
||||
String code,
|
||||
String displayName,
|
||||
String aliases,
|
||||
LatestQuoteDto latest) {}
|
||||
@ -0,0 +1,3 @@
|
||||
package com.fao.platform.api.dto;
|
||||
|
||||
public record VarietySummaryDto(long id, String code, String displayName, String aliases) {}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
118
server/src/main/java/com/fao/platform/domain/DailyMarket.java
Normal file
118
server/src/main/java/com/fao/platform/domain/DailyMarket.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
157
server/src/main/java/com/fao/platform/domain/IngestRawEvent.java
Normal file
157
server/src/main/java/com/fao/platform/domain/IngestRawEvent.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.fao.platform.domain;
|
||||
|
||||
public enum IngestionStatus {
|
||||
PENDING,
|
||||
DONE,
|
||||
FAILED
|
||||
}
|
||||
87
server/src/main/java/com/fao/platform/domain/Variety.java
Normal file
87
server/src/main/java/com/fao/platform/domain/Variety.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
}
|
||||
24
server/src/main/resources/application-scheduled.yml.example
Normal file
24
server/src/main/resources/application-scheduled.yml.example
Normal file
@ -0,0 +1,24 @@
|
||||
# 复制为 application-scheduled.yml 后启用 Profile:scheduled
|
||||
# 启动示例:mvn -f server/pom.xml spring-boot:run -Dspring-boot.run.profiles=scheduled
|
||||
# 或:java -jar app.jar --spring.profiles.active=scheduled
|
||||
#
|
||||
# 请勿在未设置 FAO_GOVERNANCE_TOKEN 的生产镜像中开启治理 HTTP;定时任务仅消费 raw,仍建议配置 token 以备手工触发。
|
||||
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: scheduled
|
||||
|
||||
fao:
|
||||
governance:
|
||||
token: ${FAO_GOVERNANCE_TOKEN:}
|
||||
# 生产建议填写白名单,例如: ["XFD"]
|
||||
source-codes: []
|
||||
scheduled:
|
||||
enabled: true
|
||||
# 应用启动后首次执行的等待时间(毫秒)
|
||||
initial-delay-ms: 120000
|
||||
# 上一轮 consume 结束后再等待多久执行下一轮(毫秒);默认示例 5 分钟
|
||||
fixed-delay-ms: 300000
|
||||
# 每轮最多处理的 raw 条数(服务端再限制在 1~200)
|
||||
batch-limit: 50
|
||||
38
server/src/main/resources/application.yml
Normal file
38
server/src/main/resources/application.yml
Normal 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
|
||||
3
server/src/main/resources/db/migration/V1__baseline.sql
Normal file
3
server/src/main/resources/db/migration/V1__baseline.sql
Normal file
@ -0,0 +1,3 @@
|
||||
-- 基线脚本:表结构在后续 V2、V3… 中追加(如 raw、维表、index_series)。
|
||||
-- 占位语句便于 Flyway 在空库上成功执行一次迁移。
|
||||
SET NAMES utf8mb4;
|
||||
28
server/src/main/resources/db/migration/V2__mvp_core.sql
Normal file
28
server/src/main/resources/db/migration/V2__mvp_core.sql
Normal 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);
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -0,0 +1,15 @@
|
||||
package com.fao.platform;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class FaoServerApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
// 不连 MySQL:test profile 下使用 H2,Flyway 关闭,由 hibernate create-drop
|
||||
}
|
||||
}
|
||||
101
server/src/test/java/com/fao/platform/api/CatalogApiIT.java
Normal file
101
server/src/test/java/com/fao/platform/api/CatalogApiIT.java
Normal 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()));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
17
server/src/test/resources/application-test.yml
Normal file
17
server/src/test/resources/application-test.yml
Normal 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
5
setup.py
Normal 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
3
src/fao/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""蔬菜价格指数平台 — 仅含采集(ingest)。治理、指数、API 在 Java `server/` 工程。"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
1
src/fao/common/__init__.py
Normal file
1
src/fao/common/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# 公共:配置、日志、时区;与 Java 服务共享环境变量/配置键约定。
|
||||
46
src/fao/common/settings.py
Normal file
46
src/fao/common/settings.py
Normal 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
1
src/fao/db/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# 爬虫侧数据访问:以 raw/队列表写入为主,表结构以 Java 工程迁移为准。
|
||||
35
src/fao/db/mysql.py
Normal file
35
src/fao/db/mysql.py
Normal 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
62
src/fao/db/raw.py
Normal 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())
|
||||
1
src/fao/ingest/__init__.py
Normal file
1
src/fao/ingest/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# 采集层:调度入口、向 raw 落库;各源在 adapters/。业务治理与指数在 Java 侧。
|
||||
1
src/fao/ingest/adapters/__init__.py
Normal file
1
src/fao/ingest/adapters/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# 各数据源一个子模块,禁止在包外硬编码源站选择器。
|
||||
12
src/fao/ingest/adapters/base.py
Normal file
12
src/fao/ingest/adapters/base.py
Normal 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 行。"""
|
||||
53
src/fao/ingest/adapters/demo_json.py
Normal file
53
src/fao/ingest/adapters/demo_json.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""
|
||||
演示适配器:读取仓库内静态 JSON(产品 2.1:CSV/JSON 样例优先)。
|
||||
|
||||
正式环境持续拉取须满足《docs/数据源上线清单.md》:授权、频控、可观测等。
|
||||
禁止将未授权网页抓取作为生产唯一依赖。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fao.common.settings import repo_root
|
||||
from fao.ingest.canonical import fingerprint_for_payload
|
||||
from fao.ingest.contracts import RawFetchResult, build_price_quote_batch
|
||||
|
||||
|
||||
class DemoJsonAdapter:
|
||||
name = "demo_json"
|
||||
|
||||
def __init__(self, sample_path: Path | None = None) -> None:
|
||||
env_path = os.environ.get("FAO_SAMPLE_JSON_PATH")
|
||||
if env_path:
|
||||
self._path = Path(env_path).expanduser()
|
||||
else:
|
||||
self._path = sample_path or (repo_root() / "data" / "samples" / "demo_vegetable_prices.json")
|
||||
|
||||
def run(self) -> RawFetchResult:
|
||||
text = self._path.read_text(encoding="utf-8")
|
||||
outer = json.loads(text)
|
||||
items = outer.get("items")
|
||||
if not isinstance(items, list) or not items:
|
||||
raise ValueError("样例文件缺少非空 items 数组")
|
||||
|
||||
source_code = "DEMO"
|
||||
payload = build_price_quote_batch(
|
||||
source_code=source_code,
|
||||
items=items,
|
||||
schema_version=int(outer.get("schema_version", 1)),
|
||||
notes="static_sample",
|
||||
)
|
||||
fp = fingerprint_for_payload(payload)
|
||||
excerpt = text if len(text) <= 16_384 else text[:16_384]
|
||||
return RawFetchResult(
|
||||
source_code=source_code,
|
||||
adapter_name=self.name,
|
||||
content_fingerprint=fp,
|
||||
payload=payload,
|
||||
request_url=f"file://{self._path.resolve()}",
|
||||
http_status=None,
|
||||
raw_excerpt=excerpt,
|
||||
)
|
||||
92
src/fao/ingest/adapters/http_json.py
Normal file
92
src/fao/ingest/adapters/http_json.py
Normal 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,
|
||||
)
|
||||
18
src/fao/ingest/canonical.py
Normal file
18
src/fao/ingest/canonical.py
Normal 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
57
src/fao/ingest/cli.py
Normal 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 行 raw(ingest_raw_event)")
|
||||
else:
|
||||
print("[fao-ingest] 跳过:相同 source_code + fingerprint 已存在(幂等)")
|
||||
return 0
|
||||
|
||||
|
||||
def app() -> None:
|
||||
"""setuptools console_scripts 入口。"""
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
37
src/fao/ingest/contracts.py
Normal file
37
src/fao/ingest/contracts.py
Normal 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
29
src/fao/ingest/runner.py
Normal 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,
|
||||
)
|
||||
0
tests/integration/.gitkeep
Normal file
0
tests/integration/.gitkeep
Normal file
0
tests/unit/.gitkeep
Normal file
0
tests/unit/.gitkeep
Normal file
12
tests/unit/test_canonical.py
Normal file
12
tests/unit/test_canonical.py
Normal 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)
|
||||
17
tests/unit/test_demo_adapter.py
Normal file
17
tests/unit/test_demo_adapter.py
Normal 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
2
web/.env.example
Normal 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
1
web/.env.production
Normal file
@ -0,0 +1 @@
|
||||
VITE_API_BASE=https://fao.s-good.com
|
||||
4
web/.gitignore
vendored
Normal file
4
web/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.local
|
||||
.DS_Store
|
||||
0
web/.gitkeep
Normal file
0
web/.gitkeep
Normal file
12
web/index.html
Normal file
12
web/index.html
Normal 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
1797
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
web/package.json
Normal file
24
web/package.json
Normal 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
35
web/src/App.tsx
Normal 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
110
web/src/api/catalog.ts
Normal 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
39
web/src/api/client.ts
Normal 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
42
web/src/index.css
Normal 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;
|
||||
}
|
||||
73
web/src/layout/AppLayout.module.css
Normal file
73
web/src/layout/AppLayout.module.css
Normal 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);
|
||||
}
|
||||
42
web/src/layout/AppLayout.tsx
Normal file
42
web/src/layout/AppLayout.tsx
Normal 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
13
web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
30
web/src/pages/AboutPage.tsx
Normal file
30
web/src/pages/AboutPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
web/src/pages/HistoryPage.tsx
Normal file
139
web/src/pages/HistoryPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
web/src/pages/HomePage.tsx
Normal file
91
web/src/pages/HomePage.tsx
Normal 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
216
web/src/pages/QueryPage.tsx
Normal 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
169
web/src/pages/RankPage.tsx
Normal 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
139
web/src/pages/TrendPage.tsx
Normal 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
Loading…
Reference in New Issue
Block a user