init: AI培训与智能巡检系统
@@ -0,0 +1,2 @@
|
||||
# AI培训与智能巡检系统
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
# 中国机车图鉴 · 产品与技术方案
|
||||
|
||||
> 一个面向铁路爱好者的、可众包维护的机车知识库与社区平台
|
||||
> 数据基础:`中国铁路车型统计表(总表)` 共 12 张分类表
|
||||
|
||||
---
|
||||
|
||||
## 1. 项目愿景
|
||||
|
||||
打造一个**「科学严谨 + 趣味探索 + 社区共建」**的中国机车数字图鉴:
|
||||
|
||||
- 对爱好者:一个能"逛得进去、查得明白、玩得起来"的机车百科 + 打卡社区。
|
||||
- 对数据:一份持续生长、有据可查、版本可追溯的开放车型数据库。
|
||||
- 对社区:一个有荣誉感、有归属感、有交流沉淀的爱好者聚集地。
|
||||
|
||||
一句话定位:**机车界的「维基百科 + 观鸟记录 App + 兴趣社区」**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据现状分析
|
||||
|
||||
现有 Excel 已覆盖 12 个分类,字段相当完整,是天然的"数据底座"。
|
||||
|
||||
| 分类 | 代表字段 | 数据特征 |
|
||||
|------|----------|----------|
|
||||
| 蒸汽机车(建国前/后) | 型号、生产商、年代 | 历史向,强时间轴属性 |
|
||||
| 电力机车 | 牵引力、供电方式、轴列式、时速 | 技术参数密集 |
|
||||
| 内燃机车 | 系列、传动方式、动力来源 | 有"系列→型号"层级 |
|
||||
| 动车组(和谐号/复兴号/CJ) | 编组、节数、试验/量产状态 | 强版本/谱系关系 |
|
||||
| 货车 | 车型大类、车型代码、载重、容积、车钩 | 编码体系清晰 |
|
||||
| 客车 | 型号、用途 | 数量大 |
|
||||
| 检测车 / 综合检测列车 | 车号、功能、配属、状态、涂装 | 强"个体实例"属性(精确到车号) |
|
||||
| 旅游列车 | — | 运营/线路向 |
|
||||
|
||||
**关键洞察:**
|
||||
|
||||
1. **存在两个数据粒度**——"车型(Model)"与"具体车辆个体(Unit,精确到车号)"。检测车表已经到了车号粒度,这正是众包打卡的核心载体。
|
||||
2. **存在层级与谱系关系**——系列 → 型号 → 改型(如 C62A / C64K),动车组有试验型→量产型演化,适合做"技术族谱"可视化。
|
||||
3. **字段需要标准化**——现表中存在合并单元格、单位混排(如"整备重量 / 轴重 /t")、空表头等问题,入库前需清洗与字段规范化。
|
||||
4. **天然带时间维度与空间维度**——首产/停产年份 → 时间轴;配属路局/运营线路 → 地图。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心数据模型
|
||||
|
||||
采用三层模型,兼顾科学性与众包扩展性:
|
||||
|
||||
```
|
||||
车型分类 (Category)
|
||||
└── 车型 (Model) ← 百科主体,技术参数挂这里
|
||||
└── 车辆个体 (Unit) ← 精确到车号,爱好者打卡/拍摄对象
|
||||
└── 目击记录 (Sighting) ← 用户众包内容:时间+地点+照片
|
||||
```
|
||||
|
||||
**Model(车型)核心字段**(统一字段表,跨分类通用 + 分类专有)
|
||||
|
||||
- 通用:型号、别名、分类、系列、生产商、**制造国/地区**、**国别属性(国产/进口/引进仿制/中外合资)**、首产年、停产年、状态、用途、产量、缩略图
|
||||
- 尺寸/重量:车长、车宽、车高、整备重量、轴重、轴距、轴列式
|
||||
- 动力:牵引力(起动/持续)、最高时速、供电方式、传动方式、动力来源
|
||||
- 关系:父型号(改型自)、衍生型号(构成"族谱")、**原型车/技术来源(指向国外车型,如 8K↔法国、ND 系列↔进口内燃)**
|
||||
|
||||
**Unit(车辆个体)字段**:车号、所属车型、配属路局、涂装、当前状态(现役/封存/报废/保存于博物馆)、首次/末次目击。
|
||||
|
||||
**Sighting(目击/打卡)字段**:用户、车辆、时间、地点(经纬度+车站)、照片、描述。
|
||||
|
||||
> 字段命名、单位统一在数据字典中维护,所有数值字段拆出独立"数值 + 单位"避免现表的混排问题。
|
||||
|
||||
---
|
||||
|
||||
## 4. 展示模式(既有趣又科学)
|
||||
|
||||
不押注单一视图,而是提供**多视图切换**,让不同诉求的人各取所需。
|
||||
|
||||
### 4.1 时间轴视图 ⭐ 推荐主视图
|
||||
- 横轴为年代(1881 至今),按分类分泳道。
|
||||
- 每个车型是轴上一个节点,点击展开详情卡。
|
||||
- 适合呈现"蒸汽→内燃→电力→动车组"的技术演进,**科学感与故事性兼具**。
|
||||
|
||||
### 4.2 地图视图 ⭐ 推荐
|
||||
- 两种用法:
|
||||
- **配属地图**:按路局展示车型分布(适合机车/检测车)。
|
||||
- **目击热力图**:聚合用户打卡记录,展示"哪里能拍到什么车"——这是社区活跃度的可视化,趣味性极强。
|
||||
- 技术:MapLibre / 高德地图,聚合点 + 热力图。
|
||||
|
||||
### 4.3 技术族谱图(谱系树)⭐ 差异化亮点
|
||||
- 用有向图展示车型演化(如 韶山系列、和谐号→复兴号、C 系列敞车改型)。
|
||||
- 节点=车型,连线=改进/衍生关系。最能体现"科学"且独此一家。
|
||||
|
||||
### 4.4 图鉴/卡牌视图
|
||||
- 类似宝可梦图鉴的卡片墙,按分类筛选,带"已收集/未收集"状态(结合打卡)。
|
||||
- 是趣味性与收集欲的核心载体。
|
||||
|
||||
### 4.5 参数对比视图
|
||||
- 勾选 2–4 个车型,雷达图/对比表并排比较(时速、牵引力、轴重等)。
|
||||
- 满足"硬核党"的科学需求。
|
||||
|
||||
### 4.6 详情页
|
||||
- 集成:参数表、历史沿革、图集、族谱位置、目击地图、相关讨论。
|
||||
|
||||
**结论**:以 **时间轴 + 图鉴卡牌** 为日常入口,**地图 + 族谱 + 对比** 为深度探索,兼顾趣味与科学。
|
||||
|
||||
---
|
||||
|
||||
## 5. 众包维护机制(让爱好者有荣誉感)
|
||||
|
||||
核心思路:**Wiki 式协作编辑 + 审核 + 贡献激励**。
|
||||
|
||||
### 5.1 编辑与审核流程
|
||||
- 任何登录用户可对车型发起"编辑建议"(修改字段、补充资料、上传图片)。
|
||||
- 采用 **修订版本(Revision)** 机制:每次修改生成版本记录,可对比、可回滚(类似维基百科历史)。
|
||||
- 审核分级:
|
||||
- 新手编辑 → 进入审核队列,由资深用户/版主复核。
|
||||
- 高信誉用户 → 可直接生效(信任分机制)。
|
||||
- 每条字段可附**来源引用**(书籍、铭牌照片、官方资料),保障科学性。
|
||||
|
||||
### 5.2 荣誉与激励体系
|
||||
- **贡献积分**:新增车型、补全字段、上传被采用的照片、通过审核均得分。
|
||||
- **等级与头衔**:如"见习巡道员 → 司炉 → 司机 → 机务段长 → 总工程师"(铁路主题化)。
|
||||
- **徽章成就**:首张照片、集齐某分类、连续打卡、被采纳 N 次修订等。
|
||||
- **贡献榜**:周榜/月榜/总榜,详情页显示"本词条主要贡献者"署名——**直接给到荣誉感**。
|
||||
- **认领词条**:资深爱好者可"认领维护"某车型,成为该词条 maintainer,署名上墙。
|
||||
|
||||
### 5.3 数据质量保障
|
||||
- 字段校验(数值范围、单位、年代逻辑)。
|
||||
- 争议字段可发起讨论投票。
|
||||
- 完整修订历史,任何错误可追溯、可回滚。
|
||||
|
||||
---
|
||||
|
||||
## 6. 社区交流(保留交流历史)
|
||||
|
||||
### 6.1 论坛/板块
|
||||
- 按分类、路局、主题(拍车/模型/历史考证)分板块。
|
||||
- 帖子、回复、@、点赞、收藏,**全部持久化保存,永久可查**(满足"保留交流历史")。
|
||||
|
||||
### 6.2 词条挂讨论
|
||||
- 每个车型/个体详情页内嵌讨论区,讨论与数据绑定沉淀。
|
||||
|
||||
### 6.3 打卡动态流
|
||||
- 用户上传目击照片形成 Feed,可评论互动——把"维护数据"和"社交"融为一体。
|
||||
|
||||
### 6.4 内容沉淀
|
||||
- 优质考证帖可被"精华化"并引用进词条来源,形成 UGC → 知识库的正循环。
|
||||
|
||||
---
|
||||
|
||||
## 7. 其他扩展(建议)
|
||||
|
||||
| 方向 | 说明 |
|
||||
|------|------|
|
||||
| 📷 **打卡集邮** | 拍到某车号即"收集",类似观鸟/集邮,强收集欲 |
|
||||
| 🗺️ **拍车攻略** | 基于目击热力图,推荐"在哪个站点能拍到什么车" |
|
||||
| 🏛️ **退役/保存机车地图** | 标注各博物馆、公园保存的实车位置,便于线下打卡 |
|
||||
| 🔔 **稀有车提醒** | 关注某车型,有新目击时推送 |
|
||||
| 🧠 **AI 识车** | 上传照片自动识别车型(长期,基于社区图库训练) |
|
||||
| 📊 **数据可视化大屏** | 全国保有量、技术演进、时速变迁等科普图表 |
|
||||
| 🎮 **AR/3D 模型** | 部分经典车型 3D 展示或 AR 摆放 |
|
||||
| 🌐 **开放 API / 数据导出** | 开放数据集供研究者使用,强化"科学开放"形象 |
|
||||
| 🌍 **多语言** | 面向海外铁道迷 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 国外车型的考量
|
||||
|
||||
**当前方案以中国机车为中心,但国外车型不应被忽略**——原因是二者高度交织:
|
||||
|
||||
- **历史渊源**:建国前蒸汽机车多为进口/外国制造(美、日等);建国后大量引进苏联、东欧、法国(8K 电力机车)、日本等机型,形成"**引进 → 仿制 → 自主**"的清晰技术脉络。
|
||||
- **强化族谱视图**:把国外原型车纳入后,技术族谱图能完整呈现"原型 → 引进 → 国产化 → 自主升级"链条,科学性和故事性都更强。
|
||||
|
||||
### 范围策略(按相关性分圈层收敛,避免范围失控)
|
||||
|
||||
| 圈层 | 范围 | 优先级 |
|
||||
|------|------|--------|
|
||||
| 🟢 圈层一 | 中国铁路车型(现有数据) | MVP,立即做 |
|
||||
| 🟡 圈层二 | **在华运用过的外国车型** + **中国车型的国外原型/技术来源** | 中期,价值最高 |
|
||||
| 🔵 圈层三 | 与中国有渊源的延伸(如出口机车、同平台海外型号) | 长期 |
|
||||
| ⚪ 圈层四 | 纯国外车型全球图鉴 | 视社区意愿再定,谨慎扩张 |
|
||||
|
||||
### 设计要点
|
||||
|
||||
- 数据模型已预留**制造国/地区、国别属性、原型车/技术来源**字段,从一开始就为国外车型留好位置,无需后期重构。
|
||||
- 展示上增加"**按国别筛选**"和"**世界地图**"维度;族谱图跨国连线。
|
||||
- **建议**:先做好圈层一、二(与中国强相关的部分),这是差异化价值所在;是否扩到圈层四交给社区共建决定,而非一开始铺开全球数据。
|
||||
|
||||
### 注意事项
|
||||
|
||||
- 国外资料的**来源与版权**更需谨慎(多语言资料、图片授权)。
|
||||
- 字段需国际化(单位、命名、轴列式 UIC 标准 vs 中国习惯)。
|
||||
- 防止范围蔓延导致中国部分的深度被稀释——**深度优先于广度**。
|
||||
|
||||
---
|
||||
|
||||
## 9. 技术架构建议
|
||||
|
||||
**前端**
|
||||
- Web 优先(响应式),后续 App。框架:React / Next.js(SEO 友好,利于百科被搜索到)。
|
||||
- 可视化:时间轴/族谱用 D3 或 ECharts,地图用 MapLibre/高德,对比用 ECharts 雷达图。
|
||||
|
||||
**后端**
|
||||
- API:Node.js(NestJS) 或 Python(FastAPI)。
|
||||
- 数据库:PostgreSQL(结构化车型数据 + PostGIS 处理地理目击数据)。
|
||||
- 全文检索:Meilisearch / Elasticsearch(中文搜索)。
|
||||
- 对象存储:图片/图集(OSS / S3)。
|
||||
- 缓存:Redis。
|
||||
|
||||
**关键工程**
|
||||
- **数据导入管线**:先做一个 Excel → 清洗 → 入库 的 ETL 脚本(处理合并单元格、单位拆分、字段映射),把现有 12 表作为初始种子数据。
|
||||
- **版本/审核服务**:修订记录、diff、回滚。
|
||||
- **权限与信誉系统**:RBAC + 贡献积分。
|
||||
|
||||
---
|
||||
|
||||
## 10. 分期路线图(MVP 优先)
|
||||
|
||||
**Phase 1 · 数据底座(MVP)**
|
||||
- Excel 清洗入库,统一字段字典。
|
||||
- 车型列表 + 详情页 + 搜索筛选。
|
||||
- 时间轴视图、图鉴卡牌视图。
|
||||
|
||||
**Phase 2 · 众包共建**
|
||||
- 登录注册、Wiki 式编辑 + 审核 + 修订历史。
|
||||
- 贡献积分、等级、徽章、贡献榜。
|
||||
|
||||
**Phase 3 · 社区与空间**
|
||||
- 论坛 + 词条讨论 + 打卡动态流。
|
||||
- 地图视图(配属 + 目击热力图)。
|
||||
|
||||
**Phase 4 · 进阶玩法 + 圈层扩展**
|
||||
- 族谱图、参数对比、保存机车地图、稀有车提醒。
|
||||
- 接入圈层二:在华运用的外国车型 + 中国车型的国外原型,跨国族谱连线。
|
||||
|
||||
**Phase 5 · 智能化**
|
||||
- AI 识车、数据大屏、开放 API、AR/3D。
|
||||
|
||||
---
|
||||
|
||||
## 11. 风险与注意事项
|
||||
|
||||
- **数据版权与来源**:众包内容需标注来源,图片需声明授权,避免侵权。
|
||||
- **数据准确性**:以"来源引用 + 审核 + 可回滚"对冲错误;争议字段公开讨论。
|
||||
- **冷启动**:先邀请核心爱好者圈子共建种子内容与首批 maintainer。
|
||||
- **审核成本**:信誉分越高审核越宽松,平衡质量与活跃度。
|
||||
- **隐私与合规**:目击地点等 UGC 注意敏感区域与合规要求。
|
||||
|
||||
---
|
||||
|
||||
## 12. 小结
|
||||
|
||||
以现有 12 张分类表为种子,构建"**车型→个体→目击**"三层数据模型;用**时间轴 + 图鉴 + 地图 + 族谱**多视图兼顾趣味与科学;以 **Wiki 式协作 + 信誉荣誉体系**驱动众包维护;用**论坛 + 词条讨论 + 打卡动态**沉淀社区交流历史。建议从数据底座 MVP 起步,逐步叠加共建、社区与智能化能力。
|
||||
@@ -0,0 +1,237 @@
|
||||
# 1-PRD · 中国机车图鉴平台 产品需求文档
|
||||
|
||||
> 版本:v1.0
|
||||
> 来源:基于 `方案.md` 全文细化
|
||||
> 状态:草稿(待评审)
|
||||
> 一句话定位:机车界的「维基百科 + 观鸟记录 App + 兴趣社区」
|
||||
|
||||
---
|
||||
|
||||
## 0. 文档说明
|
||||
|
||||
本 PRD 将 `方案.md` 的产品构想细化为可评审、可拆解的需求集合,覆盖目标、用户、范围、功能需求、数据需求、非功能需求、指标与里程碑。技术实现细节(架构选型、表结构)留待后续设计文档(2-design)展开,本文只在"约束"层面引用。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品目标与背景
|
||||
|
||||
### 1.1 背景
|
||||
铁路爱好者群体对机车型号、参数、历史与现役状态有强烈的查询、收集、考证与交流需求,但现有资料分散在 Excel、贴吧、论坛、个人博客中,缺乏一个**结构化、可信、可持续生长**的统一平台。本项目以现有《中国铁路车型统计表》12 张分类表为种子数据,构建该平台。
|
||||
|
||||
### 1.2 产品目标
|
||||
| 维度 | 目标 |
|
||||
|------|------|
|
||||
| 知识库 | 提供权威、结构化、可追溯的机车车型数据库 |
|
||||
| 趣味体验 | 通过时间轴、图鉴卡牌、地图、族谱等多视图让浏览"既有趣又科学" |
|
||||
| 众包共建 | 让爱好者低门槛参与维护,并获得荣誉感与归属感 |
|
||||
| 社区沉淀 | 提供交流空间,且交流历史永久可查 |
|
||||
|
||||
### 1.3 设计原则
|
||||
1. **科学严谨**:数据可溯源、可审核、可回滚。
|
||||
2. **趣味探索**:多视图、收集机制、可视化。
|
||||
3. **社区共建**:低门槛编辑 + 荣誉激励 + 信任分级。
|
||||
4. **深度优先于广度**:先做透中国车型,国外车型按相关性分圈层扩展。
|
||||
|
||||
---
|
||||
|
||||
## 2. 目标用户与画像
|
||||
|
||||
| 角色 | 描述 | 核心诉求 |
|
||||
|------|------|----------|
|
||||
| 浏览型爱好者(游客/轻度) | 偶尔查车型、看历史 | 快速查询、有趣的浏览 |
|
||||
| 拍车/打卡党 | 线下追车、拍照记录 | 记录目击、收集车号、拍车攻略 |
|
||||
| 硬核考据党 | 关注参数、历史、谱系 | 准确参数、对比、技术族谱、可溯源 |
|
||||
| 贡献者/编辑 | 补全和维护数据 | 便捷编辑、荣誉署名、贡献统计 |
|
||||
| 版主/维护者 | 审核与质量把控 | 审核队列、修订对比、回滚 |
|
||||
| 管理员 | 平台运营 | 权限、内容治理、数据治理 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围定义
|
||||
|
||||
### 3.1 数据范围(圈层策略)
|
||||
| 圈层 | 范围 | 阶段 |
|
||||
|------|------|------|
|
||||
| 🟢 圈层一 | 中国铁路车型(现有 12 表) | MVP |
|
||||
| 🟡 圈层二 | 在华运用的外国车型 + 中国车型的国外原型 | 中期 |
|
||||
| 🔵 圈层三 | 出口机车、同平台海外型号 | 长期 |
|
||||
| ⚪ 圈层四 | 纯国外全球图鉴 | 视社区意愿 |
|
||||
|
||||
### 3.2 阶段范围(对应方案路线图)
|
||||
- **Phase 1(MVP)**:数据底座 + 列表/详情/搜索 + 时间轴 + 图鉴卡牌。
|
||||
- **Phase 2**:账户体系 + Wiki 编辑/审核/修订历史 + 荣誉体系。
|
||||
- **Phase 3**:社区(论坛/讨论/动态流)+ 地图视图。
|
||||
- **Phase 4**:族谱图 + 参数对比 + 保存机车地图 + 稀有车提醒 + 圈层二数据。
|
||||
- **Phase 5**:AI 识车 + 数据大屏 + 开放 API + AR/3D。
|
||||
|
||||
### 3.3 不在本期范围(MVP 明确排除)
|
||||
- 用户登录与众包编辑(Phase 2)
|
||||
- 社区论坛与打卡(Phase 3)
|
||||
- AI 识车、AR/3D、开放 API(Phase 5)
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据需求
|
||||
|
||||
### 4.1 数据模型(三层 + 关系)
|
||||
```
|
||||
Category 车型分类
|
||||
└── Model 车型(百科主体,技术参数)
|
||||
└── Unit 车辆个体(精确到车号)
|
||||
└── Sighting 目击记录(用户众包:时间+地点+照片)
|
||||
Model ←→ Model:父型号 / 衍生型号 / 国外原型(族谱关系)
|
||||
```
|
||||
|
||||
### 4.2 Model 字段字典(统一字段表)
|
||||
| 分组 | 字段 | 说明/约束 |
|
||||
|------|------|-----------|
|
||||
| 标识 | 型号、别名、分类、系列 | 型号必填、唯一性校验 |
|
||||
| 来源 | 生产商、制造国/地区、国别属性(国产/进口/引进仿制/合资) | 国别属性枚举 |
|
||||
| 时间 | 首产年、停产年 | 年代逻辑校验(首产≤停产) |
|
||||
| 状态 | 状态、用途、产量、缩略图 | 状态枚举 |
|
||||
| 尺寸/重量 | 车长、车宽、车高、整备重量、轴重、轴距、轴列式 | 数值+单位分离 |
|
||||
| 动力 | 牵引力(起动/持续)、最高时速、供电方式、传动方式、动力来源 | 数值+单位分离 |
|
||||
| 关系 | 父型号、衍生型号、原型车/技术来源 | 指向其他 Model |
|
||||
| 溯源 | 每字段可附来源引用(Phase 2) | 书籍/铭牌照片/官方资料 |
|
||||
|
||||
> 约束:所有数值字段以"数值 + 单位"独立存储,杜绝现表的单位混排问题。
|
||||
|
||||
### 4.3 Unit / Sighting 字段
|
||||
- **Unit**:车号、所属车型、配属路局、涂装、当前状态(现役/封存/报废/博物馆保存)、首/末次目击。
|
||||
- **Sighting**:用户、车辆、时间、地点(经纬度+车站)、照片、描述。
|
||||
|
||||
### 4.4 数据导入(ETL)
|
||||
- 将现有 12 张 CSV/Excel 清洗后入库作为种子数据。
|
||||
- 处理:合并单元格还原、空表头修复、单位拆分、字段映射到统一字典、系列→型号层级归并、改型关系识别。
|
||||
- 产出:导入报告(成功/失败/需人工复核条目)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 功能需求(按模块)
|
||||
|
||||
> 标注:【P1】MVP / 【P2】共建 / 【P3】社区 / 【P4】进阶 / 【P5】智能化
|
||||
|
||||
### 5.1 车型浏览与检索【P1】
|
||||
- FR-1.1 车型列表:分页、按分类筛选、按字段排序。
|
||||
- FR-1.2 多维筛选:分类、年代、时速区间、生产商、国别、状态。
|
||||
- FR-1.3 全文搜索:型号/别名/生产商中文检索,支持模糊与拼音(增强项)。
|
||||
- FR-1.4 车型详情页:参数表、历史沿革、图集、族谱位置、相关讨论入口。
|
||||
- FR-1.5 数值字段统一展示单位,缺失字段优雅留白。
|
||||
|
||||
### 5.2 展示视图【P1/P3/P4】
|
||||
- FR-2.1【P1】时间轴视图:年代横轴、分类泳道、节点点击展开详情卡。
|
||||
- FR-2.2【P1】图鉴卡牌视图:卡片墙、分类筛选、"已收集/未收集"占位(收集逻辑 P3 接入)。
|
||||
- FR-2.3【P4】参数对比:勾选 2–4 个车型,雷达图 + 对比表。
|
||||
- FR-2.4【P4】技术族谱图:有向图展示演化关系,支持跨国连线。
|
||||
- FR-2.5【P3】地图视图:配属地图 + 目击热力图,聚合点。
|
||||
- FR-2.6 视图间可相互跳转(如从族谱节点跳详情)。
|
||||
|
||||
### 5.3 账户与权限【P2】
|
||||
- FR-3.1 注册/登录(邮箱/第三方)。
|
||||
- FR-3.2 角色:游客、注册用户、信任用户、版主、管理员(RBAC)。
|
||||
- FR-3.3 个人主页:贡献统计、徽章、收集进度、目击记录。
|
||||
|
||||
### 5.4 众包编辑与审核【P2】
|
||||
- FR-4.1 编辑建议:登录用户可修改字段、补资料、传图。
|
||||
- FR-4.2 修订版本:每次修改生成 Revision,支持 diff 对比与回滚。
|
||||
- FR-4.3 审核分级:新手编辑入审核队列;信任用户直接生效。
|
||||
- FR-4.4 字段来源引用:可为字段附引用来源。
|
||||
- FR-4.5 字段校验:数值范围、单位、年代逻辑。
|
||||
- FR-4.6 争议字段:可发起讨论/投票。
|
||||
|
||||
### 5.5 荣誉与激励【P2】
|
||||
- FR-5.1 贡献积分:新增/补全/采用照片/通过审核计分。
|
||||
- FR-5.2 等级头衔:铁路主题等级(见习巡道员→…→总工程师)。
|
||||
- FR-5.3 徽章成就:首图、集齐分类、连续打卡、被采纳 N 次等。
|
||||
- FR-5.4 贡献榜:周/月/总榜;详情页显示"主要贡献者"署名。
|
||||
- FR-5.5 认领词条:成为 maintainer 并署名。
|
||||
|
||||
### 5.6 社区交流【P3】
|
||||
- FR-6.1 论坛板块:按分类/路局/主题分区。
|
||||
- FR-6.2 帖子与回复:发帖、回复、@、点赞、收藏,**永久持久化保存可查**。
|
||||
- FR-6.3 词条内嵌讨论区:讨论与数据绑定。
|
||||
- FR-6.4 打卡动态流:目击照片 Feed + 评论互动。
|
||||
- FR-6.5 内容沉淀:优质考证帖可精华化并引用进词条来源。
|
||||
|
||||
### 5.7 打卡与收集【P3/P4】
|
||||
- FR-7.1 目击打卡:记录车号、时间、地点、照片。
|
||||
- FR-7.2 图鉴收集:拍到即收集,图鉴显示进度。
|
||||
- FR-7.3【P4】拍车攻略:基于目击热力图推荐拍摄点。
|
||||
- FR-7.4【P4】保存机车地图:标注博物馆/公园保存实车。
|
||||
- FR-7.5【P4】稀有车提醒:关注车型新目击推送。
|
||||
|
||||
### 5.8 智能化与开放【P5】
|
||||
- FR-8.1 AI 识车(照片→车型)。
|
||||
- FR-8.2 数据可视化大屏(保有量/时速变迁等)。
|
||||
- FR-8.3 开放 API / 数据导出。
|
||||
- FR-8.4 AR/3D 模型展示。
|
||||
- FR-8.5 多语言(面向海外爱好者)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 非功能需求
|
||||
|
||||
| 类别 | 需求 |
|
||||
|------|------|
|
||||
| 性能 | 列表/详情页 P95 < 1.5s;搜索响应 < 500ms |
|
||||
| 可用性 | 响应式 Web 优先,移动端可用;SEO 友好(百科可被搜索引擎收录) |
|
||||
| 可扩展 | 数据模型预留国别/原型字段,圈层扩展不需重构 |
|
||||
| 数据质量 | 字段校验 + 来源引用 + 修订可回滚 |
|
||||
| 安全 | RBAC 权限;UGC 内容审核;防刷分 |
|
||||
| 合规 | 图片版权声明、来源标注;目击地点敏感区域脱敏 |
|
||||
| 可观测 | 关键操作日志、审核日志、贡献流水 |
|
||||
| 国际化 | 字段单位/命名国际化(UIC vs 中国习惯轴列式) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 成功指标(KPI)
|
||||
|
||||
| 阶段 | 指标 |
|
||||
|------|------|
|
||||
| MVP | 种子数据完整入库(12 表全覆盖);车型详情可访问;时间轴/图鉴可用 |
|
||||
| 共建 | 月活跃编辑者数、月新增/修订条目数、审核通过率 |
|
||||
| 社区 | 月活、发帖/回复数、打卡数、收集完成率 |
|
||||
| 留存 | 次周留存、贡献者复访率 |
|
||||
| 质量 | 字段完整度、被回滚修订占比(越低越好)、争议解决时长 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 里程碑与交付物
|
||||
|
||||
| 里程碑 | 交付物 | 依赖 |
|
||||
|--------|--------|------|
|
||||
| M1(MVP) | ETL 入库 + 列表/详情/搜索 + 时间轴 + 图鉴卡牌 | 种子数据清洗 |
|
||||
| M2 | 账户 + Wiki 编辑/审核/修订 + 荣誉体系 | M1 |
|
||||
| M3 | 论坛/讨论/动态流 + 地图视图 | M2 |
|
||||
| M4 | 族谱图 + 对比 + 保存机车地图 + 提醒 + 圈层二数据 | M2/M3 |
|
||||
| M5 | AI 识车 + 大屏 + 开放 API + AR/3D | M4 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险与对策
|
||||
|
||||
| 风险 | 对策 |
|
||||
|------|------|
|
||||
| 数据版权/来源争议 | 强制来源标注、图片授权声明 |
|
||||
| 数据准确性 | 来源引用 + 审核 + 可回滚 + 争议投票 |
|
||||
| 冷启动乏力 | 邀请核心爱好者共建种子内容与首批 maintainer |
|
||||
| 审核成本高 | 信任分级,高信誉自动生效 |
|
||||
| 范围蔓延 | 圈层收敛,深度优先于广度 |
|
||||
| 隐私合规 | 目击地点敏感区域处理与合规审查 |
|
||||
|
||||
---
|
||||
|
||||
## 10. 待确认问题(Open Questions)
|
||||
|
||||
1. 技术栈最终选型(Next.js + NestJS/FastAPI + PostgreSQL)是否锁定?
|
||||
2. 是否需要原生 App,还是 PWA 即可?
|
||||
3. 第三方登录渠道范围(微信/GitHub/邮箱)?
|
||||
4. 地图服务选型(高德 vs MapLibre)与国内合规?
|
||||
5. 图片对象存储与 CDN 选型?
|
||||
6. MVP 是否需要最简账户(仅收藏)还是完全匿名?
|
||||
7. 国外车型圈层二的启动时机与数据来源?
|
||||
|
||||
---
|
||||
|
||||
## 附录 A · 现有数据分类清单(种子)
|
||||
建国前蒸汽机车 / 建国后蒸汽机车 / 北京表(蒸汽机车)/ 普速客车 / 电力机车 / 内燃机车 / CJ 型及早期动车组及和谐号 / 复兴号 / 货车 / 普速检测车 / 高速综合检测列车 / 旅游列车(共 12 类)。
|
||||
@@ -0,0 +1,410 @@
|
||||
# 2-TASK · 中国机车图鉴平台 开发任务清单
|
||||
|
||||
> 版本:v1.0
|
||||
> 来源:基于 `1-prd.md` 细化
|
||||
> 用途:落实与跟进开发计划(开发 / 单元测试 / e2e 测试)
|
||||
|
||||
## 进度日志
|
||||
- **2025 · 第 1 批**:完成 Phase 1A 数据底座(T-1.1 / T-1.2)。
|
||||
- ETL 导入 12 张 CSV → SQLite + JSON + 报告:**Model 540 / Unit 307**,跳过 10(空行),0 待复核。
|
||||
- 31 个单元/集成测试全部通过(含全量导入幂等性)。
|
||||
- 代码位于 `app/etl`、`app/tests`,产物位于 `app/data`,说明见 `app/README.md`。
|
||||
- 取舍:开发期用 SQLite(schema 设计为可移植到 PostgreSQL);Meilisearch/Redis/对象存储等基础设施待 Phase 1B/接入时引入。
|
||||
- **2025 · 第 2 批**:完成 Phase 1B 后端 API(T-1.3 / T-1.4),技术栈 **NestJS + TypeScript**(方案 B)。
|
||||
- 接口:`/api/health`、`/api/categories`、`/api/models`(列表/筛选/排序/分页)、`/api/models/:id`(详情)、`/api/units`、`/api/search`。
|
||||
- 参数化查询防注入;全局 ValidationPipe 校验非法参数(返回 400)。
|
||||
- 测试:14 个单元测试 + 9 个 e2e(supertest)全部通过;build 通过;真实库(540/307)联调通过。
|
||||
- 代码位于 `apps/api`(NestJS 标准结构),读取 `app/data/machines.db`。
|
||||
- 取舍:搜索先用 SQLite LIKE+排序实现(规模足够),Meilisearch 延后。
|
||||
- **2025 · 第 3 批**:完成 Phase 1C 前端(T-1.5 / T-1.6 / T-1.7),技术栈 **Vite + React + TS**(方案 B,独立前端 `apps/web`)。
|
||||
- 页面:列表页(筛选/排序/分页 + 列表/时间轴/图鉴 三视图切换)、详情页(参数表 + raw_json 保真表)、全局搜索框(防抖下拉)。
|
||||
- 时间轴:分类泳道 + 年代横轴刻度 + 节点跳转;图鉴:卡牌墙 + 已收集/未收集占位。
|
||||
- 测试:23 个单元测试(Vitest + Testing Library)全部通过;`npm run build`(tsc + vite)通过。
|
||||
- 全栈联调:Vite 代理 `/api`→NestJS,真实库 540 车型,列表/排序/搜索均正确返回。
|
||||
- 取舍:SSR/SEO、缩放平移、Playwright 脚本化 e2e 标记延后(已手动联调全栈通过)。
|
||||
- **2025 · 第 4 批(A:闭合 MVP)**:补齐 Phase 1 验收(T-1.8)。
|
||||
- 性能基准 `apps/api/bench.mjs`:list/filter/detail p95 ≤ 3ms(阈值 1500ms),search p95 1.3ms(阈值 500ms),全部达标。
|
||||
- Playwright e2e(`apps/web/e2e/smoke.spec.ts`):首页/筛选→详情/时间轴/图鉴/搜索 **5 项全通过**,webServer 自动拉起前后端。
|
||||
- 修复真实 UX bug:时间轴同年同泳道节点完全重叠导致不可点击 → 实现碰撞分行(错位堆叠)+ 标签 pointer-events:none。
|
||||
- **Phase 1(MVP)正式闭合**。累计测试:ETL 31 + API 23 + 前端单元 25 + e2e 5 = **84 个测试全绿**。
|
||||
- **2025 · 第 5 批(图鉴差异化)**:解决"列表与图鉴雷同"问题,落实方案"收集欲"主线。
|
||||
- 图鉴改为**图片优先卡牌墙**:按分类生成 SVG 占位缩略图(`lib/thumb.ts`,真实车照待 Phase 2/3 众包上传替换)。
|
||||
- 轻量**收集功能**:localStorage 记录已收集(`lib/useCollection.ts`),点卡片"收集"点亮缩略图,顶部显示收集进度条;Phase 3 账户上线后迁移云端。
|
||||
- 测试:前端单元增至 34(新增 thumb / GalleryCard),e2e 图鉴用例改为验证缩略图+收集交互;全绿。
|
||||
- **2025 · 第 6 批(前端全方位重构 · 主线串联)**:解决"首页死板、一屏过载、无主线"问题。
|
||||
- **叙事首页**(`HomePage`):英雄区 + 关键统计 + **时速演进曲线**(`EvolutionCurve`,SVG)+ **四个时代章节**(蒸汽→内燃→电力→高铁,每节代表车 + 进入探索),把技术演进主线显性串联。
|
||||
- **信息架构重组**:`/` 故事首页、`/explore` 探索页、`/models/:id` 详情;顶部导航 故事/探索 + 全局搜索。
|
||||
- **探索页减负**:筛选器收进可折叠面板(默认隐藏,带激活计数),默认图鉴视图,更大留白。
|
||||
- 章节链接经 `?category=` 跳入探索并自动筛选(URL 同步)。
|
||||
- 主线逻辑(时代/代表车/时速演进)抽到 `lib/eras.ts` 纯函数并单测。
|
||||
- 测试:前端单元 39 + e2e 7(覆盖首页→探索→详情/时间轴/搜索/章节跳转),全绿;build 通过。
|
||||
- **2025 · 第 7 批(专业图标 + 视图合并 + 全详情)**:
|
||||
- **全部 emoji → 专业图标**:引入 `react-icons`,统一 `components/icons.tsx`(分类图标 + 品牌/锁/星/筛选/视图图标);缩略图改为 `Thumb`(分类渐变 + 专业图标),移除 emoji/data-URI 方案。
|
||||
- **探索页合并视图**:去掉"列表",统一用**图鉴**展示(默认),仅保留 图鉴 / 时间轴 两视图。
|
||||
- **详情页升级为"全部详情"**:缩略图 Hero + 标签 + 基本信息 / 尺寸与重量 / 动力与性能 / 原始数据 全字段分组展示。
|
||||
- 测试:前端单元 37 + e2e 7(图鉴收集改 aria-pressed、详情断言分组标题),全绿;build 通过。
|
||||
- **2025 · 第 8 批(图鉴分页 + 全中文 + 多图灯箱)**:
|
||||
- **图鉴分页**:客户端分页 18/页,分页器置于卡牌墙**上方**;筛选变化回到第 1 页;编号跨页连续。
|
||||
- **详情全中文**:新增 `lib/fieldLabels.ts`(英文规范键→中文),"原始数据"区英文键全部映射为中文(model 17 等已核对全覆盖)。
|
||||
- **详情多图 + 灯箱**:`lib/useImages.ts`(每车型本地图册,localStorage)+ `Lightbox`(缩放/滚轮/拖拽平移/上下张/Esc);详情新增"图册"区可添加多张图片并点开缩放欣赏;云端众包上传待 Phase 2/3。
|
||||
- 测试:前端单元 39 + e2e 8(新增分页/全中文/图册上传缩放),全绿;build 通过。
|
||||
- **2025 · 第 9 批(接入 Wikimedia Commons 真实照片)**:
|
||||
- 每页改为 **21**;详情"图册"接入 **Wikimedia Commons**(`lib/commons.ts` + `useCommonsImages`):浏览器端 origin=* CORS 检索自由授权实拍图,**署名(作者/许可证/来源链接)显示在灯箱**。
|
||||
- 图片优先级:Commons 真实照片 → 本地上传 → 系统生成示意图(兜底,永不空);卡片角标区分来源(Commons/我的/示意图)。
|
||||
- 健壮回退:无网/无匹配/请求失败时静默回退示意图;结果做内存 + sessionStorage 缓存。
|
||||
- 灯箱升级为通用图源(URL/dataURL)并展示署名。
|
||||
- 注:构建/测试沙箱仅放行 npm,无法联网验证 Commons 覆盖率,需在浏览器端验证(如 CR400AF / HXD1型)。
|
||||
- 测试:前端单元 42 + e2e 8,全绿;build 通过。
|
||||
- **2025 · 第 10 批(Phase 2 启动 · T-2.1 账户体系,采用 B1)**:
|
||||
- 架构:新增**独立可写库 `app/data/app.db`**(`WritableDbService`,含迁移),与只读 `machines.db` 分离,避免被 ETL 重建清空。
|
||||
- 后端:`AuthModule`(NestJS + @nestjs/jwt + bcryptjs)——注册/登录/`/api/auth/me`;`JwtAuthGuard` + `RolesGuard`(游客<注册<信任<版主<管理员 等级);密码 bcrypt,邮箱规范化小写,密码不外泄。
|
||||
- 前端:`AuthProvider` 上下文 + 登录/注册模态框 + 顶栏用户菜单 + 个人主页(贡献积分/已收集/加入日期)。
|
||||
- 安全提示:JWT secret 走 `JWT_SECRET` 环境变量,默认值仅供开发,生产须替换。
|
||||
- 测试:后端单元 18 + e2e 14(含注册/重复 409/非法 400/登录 401/me 鉴权);前端单元 42 + e2e 9(含注册→用户菜单→个人主页);build 通过。
|
||||
- **2025 · 第 11 批(T-2.2 编辑/修订 + T-2.3 审核)**:
|
||||
- 数据:`app.db` 增 `revisions` / `revision_changes` / `model_overrides` 三表;编辑不改只读 `machines.db`,而以"字段覆盖"叠加,详情接口读取时合并(标记 `overridden`)。
|
||||
- 后端:`ContribModule`——`POST /api/models/:id/revisions`(提交编辑,含 old→new diff)、`GET .../revisions`(历史)、`GET /api/revisions/pending`(版主队列)、`approve`/`reject`(版主+)、`GET /api/editable-fields`;字段白名单 + 校验(年代范围/状态枚举/国别枚举);信任级以上自动通过;通过即应用覆盖并给作者 +5 积分。
|
||||
- 引导管理员:`ADMIN_EMAIL` 环境变量命中即注册为 admin(便于审核演示/测试)。
|
||||
- 前端:详情页"编辑词条"模态(白名单字段表单)、"修订历史"区(状态/作者/diff)、`/review` 审核页(版主+,通过/驳回)、顶栏"审核"入口(按角色显示)。
|
||||
- 测试:后端单元 18 + e2e 21(提交/历史/403/管理员审核生效/自动通过/非法 400);前端单元 42 + e2e 10(含登录后编辑→待审核修订);两端 build 通过。
|
||||
- **2025 · 第 12 批(T-2.4 荣誉与激励)→ Phase 2 完成**:
|
||||
- 等级头衔(`community/levels.ts` 纯函数):见习巡道员→巡道员→司炉→副司机→司机→机务段长→总工程师(按积分阈值);徽章(首改采纳/高产编辑/资深贡献/总工殊荣)。
|
||||
- `/api/auth/me` 增等级/徽章/被采纳数;`CommunityModule`:`GET /api/leaderboard`(贡献榜),`GET/POST/DELETE /api/models/:id/maintainers`(词条认领署名)。
|
||||
- 前端:个人主页等级进度条 + 徽章;`/leaderboard` 贡献榜页(金银铜);详情页"词条维护者"认领/署名;顶栏"贡献榜"入口。
|
||||
- 测试:后端单元 22 + e2e 25(含 me 等级、贡献榜、认领、未登录 401);前端单元 42 + e2e 13(含认领署名、等级徽章、贡献榜);两端 build 通过。
|
||||
- **Phase 2(众包共建)完成**:账户 + Wiki 式编辑/审核/修订历史 + 荣誉体系全部跑通。
|
||||
- **2025 · 第 13 批(测试账号一键登录)**:后端开机幂等预置 3 个演示账号(admin@demo.com / mod@demo.com / user@demo.com,密码 demo1234,角色 admin/moderator/user;仅非生产,`SEED_TEST_USERS=false` 可关);登录框加"测试账号一键填入"。e2e 15。
|
||||
- **2025 · 第 14 批(Phase 3 启动 · T-3.1 论坛 + T-3.2 词条讨论)**:
|
||||
- 可写库增 `threads` / `thread_replies` 表(板块 + 可选 model_id 绑定词条)。
|
||||
- 后端 `ForumModule`:`GET /api/boards`、`GET/POST /api/threads`(板块/词条过滤、发帖)、`GET /api/threads/:id`、`POST /api/threads/:id/replies`;DTO 校验,发帖/回复需登录。
|
||||
- 前端:`/community` 论坛(板块标签 + 发帖 + 帖子列表)、`/thread/:id` 帖子页(正文 + 回复 + 回复框)、详情页"讨论区"(绑定该车型的话题 + 发起讨论);顶栏"社区"入口。
|
||||
- 测试:后端 e2e 29(发帖/回复/板块/词条绑定/401/400);前端 e2e 16(社区发帖→帖子页回复);两端 build 通过。
|
||||
- **2025 · 第 15 批(T-3.3 打卡 + T-3.4 地图)→ Phase 3 完成**:
|
||||
- 可写库增 `sightings` 表;`SightingsModule`:`POST/GET /api/models/:id/sightings`、`GET /api/sightings/recent`(动态流)、`GET /api/sightings/map`;坐标校验(IsLatitude/IsLongitude),跨库用只读库补 model_code/分类。
|
||||
- 前端:详情页"目击打卡"区(经纬度/用我的位置 geolocation/车站/车号 + 列表);`/map` 打卡地图(**MapLibre + OpenStreetMap 免费瓦片**,标记 + 弹窗 + 最新打卡侧栏);顶栏"打卡地图"入口。
|
||||
- 修复:sighting 补全用 `category_id` join 分类(model 表无 category 列)导致的 500。
|
||||
- 取舍:地图先用点位标记(OSM 栅格瓦片,遵守使用政策并署名);热力图/路局聚合/PostGIS 后续;打卡暂不计贡献积分(保持榜单=数据编辑)。
|
||||
- 测试:后端 e2e 31(打卡/动态/地图/401/非法坐标 400);前端 e2e 18(详情打卡→列表、地图页加载);两端 build 通过。
|
||||
- **Phase 3(社区与空间)完成**:论坛 + 词条讨论 + 打卡动态 + 地图。
|
||||
- **2025 · 第 16 批(修复 + T-4.1 参数对比)**:
|
||||
- 修复:贡献榜列表 class 名 `.lb` 与灯箱 `.lb`(全屏 fixed 遮罩)冲突,导致无法离开贡献榜页 → 列表改名 `.leaderboard`,加回归测试。
|
||||
- T-4.1 参数对比(Phase 4 起步):`/compare` 页,搜索加入 2–4 车型,自绘 **SVG 雷达图**(`lib/compare.ts` 归一化纯函数 + `RadarChart`)+ 对比表;URL 同步 ids;详情页"加入对比"按钮;顶栏"对比"入口。
|
||||
- 测试:前端单元 45(新增 compare 归一化)+ e2e 21(含贡献榜可离开回归、参数对比雷达图);build 通过。
|
||||
- **2025 · 第 17 批(完成 Phase 4 主体:T-4.2/4.3/4.4)**:
|
||||
- T-4.2 技术族谱(`/family`):后端 `GET /api/models/families` 按分类→系列聚合;前端分类标签 + 系列泳道 + 型号节点(非国产标记),点击进详情。
|
||||
- T-4.3 拍车攻略:后端 `GET /api/sightings/spots` 按车站聚合"在哪能拍到什么车",打卡地图页展示热门打卡点;稀有车提醒(轻量):`useFollow`(localStorage 关注)+ 详情"关注"按钮 + 个人主页"关注车型最新目击"汇总。
|
||||
- T-4.4 国别维度:探索筛选器已有"国别",族谱标注非国产;制造国/国别属性/原型字段就位且可众包编辑。
|
||||
- 取舍:真正的父/衍生/原型有向图、跨国连线、保存机车地图(缺坐标)、提醒推送基建 → 待数据/基建后续。
|
||||
- 测试:后端 e2e 33(含 families/spots);前端单元 45 + e2e 23(含族谱跳详情、关注汇总);两端 build 通过。
|
||||
- **Phase 4 主体完成**(数据可得部分),余项已明确标注待数据/基建。
|
||||
- **2025 · 第 18 批(Phase 5:数据大屏 + 开放 API + AI 识车占位)**:
|
||||
- 数据大屏(`/stats`):后端 `GET /api/stats`(分类/国别/年代/时速演进聚合);前端 KPI + 柱状图 + 时速演进曲线。
|
||||
- 开放 API / 导出:`GET /api/export/models.json|csv`(CSV 转义)+ `/api-docs` 接口一览与下载按钮;底部导航入口。
|
||||
- AI 识车(`/identify`):诚实占位页(上传本地预览 + 明确声明非真实识别 + 示例候选),不伪造识别。
|
||||
- 修复:vite 代理 `/api` 前缀误吞前端路由 `/api-docs` → 改为正则 `^/api/` 仅代理真实接口;加回归测试。
|
||||
- 取舍(如实标注延后):AR/3D(需 3D 资产)、多语言(需翻译工作量)、真实 AI 模型(需图库训练/算力)。
|
||||
- 测试:后端 e2e 36(含 stats/export);前端 e2e 28(含大屏/开放API/AI占位 + 两条回归);两端 build 通过。
|
||||
- **Phase 5 数据可得部分完成**;五个阶段全部推进完毕,未尽项均有明确原因与后续路径。
|
||||
- **2025 · 第 19 批(前端专业美化)**:
|
||||
- 导航分组:顶栏收敛为 故事 / 图鉴▾ / 社区▾ / 数据▾ / 审核(`NavMenu` 点击展开下拉,路由/外部点击/Esc 关闭,可访问性 aria)。
|
||||
- 统一组件与设计令牌:扩充 CSS tokens(间距/圆角/阴影刻度 + 焦点环);新增 `components/ui.tsx`(Button / PageHeader / EmptyState / Tag)+ 统一 `.btn` 体系;统一卡片悬浮过渡、毛玻璃粘性头部。
|
||||
- UX 一致性:下拉/焦点/卡片/页头统一;族谱页改用 PageHeader + EmptyState。
|
||||
- 族谱卡片加列车图片:family 节点改为带封面(Thumb 分类插画)的卡片。
|
||||
- e2e 适配分组导航(图鉴→探索、社区→论坛);前端单元 45 + e2e 28 全绿;build 通过。
|
||||
- **2025 · 第 20 批(统一组件 100% 收敛)**:
|
||||
- 各页改用 `ui` 基础组件:`PageHeader`(贡献榜/社区/对比/大屏/开放API/AI识车/打卡地图/审核/族谱)、`EmptyState`(贡献榜/社区/对比)、`Button`(社区发帖发布、帖子回复、个人退出、审核通过/驳回、详情编辑/关注/加入对比/添加图片/认领/打卡/发起讨论、编辑模态、探索筛选、开放API 导出)。
|
||||
- 保留差异化控件(分段控件视图切换/板块标签、登录模态、定位按钮)以维持模式语义。
|
||||
- 文案/类名保持不变,e2e 全绿(前端单元 45 + e2e 28);build 通过。
|
||||
- **2025 · 第 21 批(图鉴改为管理员独占管理,用户只读)**:
|
||||
- 风险收口:图鉴词条编辑与图片上传仅管理员可用,普通用户只读。
|
||||
- 后端:`POST /api/models/:id/revisions` 加 `@Roles('admin')`(普通用户 403,未登录 401)。
|
||||
- 前端:详情页"编辑词条"、"图册 + 添加图片"入口仅 `role==='admin'` 可见(含 file input 一并隐藏);图册文案改为"由管理员维护"。
|
||||
- 测试:后端 e2e 36(普通用户编辑 403、管理员编辑自动生效、白名单 400);前端 e2e 29(管理员编辑生效、普通用户无编辑/上传入口、管理员上传+灯箱);两端 build 通过。
|
||||
- 说明:当前管理员图片上传仍是浏览器本地(localStorage,非共享);"管理员云端管理共享图库 + 公共图库 + 上传/打卡即收集 + 云端收集同步 + 集邮成就"为后续批次(已有完整实现方案)。
|
||||
- **2025 · 第 22 批(后端图片云存储 + 管理员上传写入共享图库)**:
|
||||
- 可写库新增 `photos` 表;服务端文件存储 `app/data/uploads/`(`UPLOAD_DIR` 可覆盖),`main.ts` 用 express.static 暴露 `/uploads`。
|
||||
- `PhotosModule`:`POST /api/models/:id/photos`(**仅管理员**,multer multipart,类型/大小校验)、`GET /api/models/:id/photos`(公共,所有人可看)、`DELETE /api/photos/:pid`(管理员)。
|
||||
- 车型列表 API 附带 `cover_url`(共享图库首图),**图鉴卡片用真实照片作封面**(管理员上传后所有访客可见)。
|
||||
- 详情"图册"改为共享图库:管理员上传(multipart)→ 所有人查看 + 灯箱;移除原 localStorage 本地图方案。
|
||||
- Vite 增 `^/uploads/` 代理。
|
||||
- 测试:后端 e2e 40(上传 403/201/非图片 400/删除 + 公共列表);前端 e2e 29(管理员上传→共享→灯箱);两端 build 通过;真实管线 curl 验证(上传→入库→静态 200→代理 200)。
|
||||
- **2025 · 第 23 批(候选/确认机制 + 批量取图脚本)**:
|
||||
- `photos` 加 `status('candidate'|'confirmed')` + `source_url/author/license`(已存在库自动补列)。管理员手动上传=`confirmed`;脚本入库=`candidate`。
|
||||
- **卡片封面只取 `confirmed`**;详情图册显示"候选/图库"角标 + 署名(作者/许可证/来源),管理员可逐张"✓确认/✕删除",并有"确认全部候选"。
|
||||
- API:`POST /api/photos/:pid/confirm`(管理员)。
|
||||
- **取图脚本** `apps/api/scripts/fetch-images.mjs`(`npm run fetch-images`,在有网本机跑):按 **Wikidata P18 → Commons 同名分类 → 关键词搜索** 顺序为缺图车型下载候选图入库(candidate),记录署名,输出命中报告;支持 `--limit/--category/--dry`。
|
||||
- 测试:后端 e2e 41(含候选→确认流程);前端单元 45 + e2e 29;两端 build 通过。
|
||||
- 说明:脚本需联网访问 wikidata/commons,本沙箱不可联网,需在用户本机运行。
|
||||
- **2025 · 第 24 批(管理员候选审图页)**:
|
||||
- 后端 `GET /api/photos/candidates`(管理员):全站候选图聚合 + 车型信息(跨只读库补 model_code/分类)。
|
||||
- 前端 `/admin/photos` 审图页(仅管理员):候选图网格(缩略图 + 车型链接 + 署名/来源)、逐张确认/删除、"全部确认";顶栏管理员专属"候选审图"入口(普通用户不可见)。
|
||||
- 测试:前端 e2e 31(含管理员可访问、普通用户无入口);两端 build 通过。
|
||||
- **2025 · 第 25 批(图鉴展示与封面优化 · 4 项)**:
|
||||
- 1) 卡片封面始终展示最佳可用图:`cover_url`(共享图库 confirmed)→ Commons 实拍(懒取)→ 分类示意图;移除"未收集锁/暗色遮罩",★ 改为个人"收藏"书签(aria 收藏/已收藏)。
|
||||
- 2) 详情头图改用真实图:hero = featured 封面 → confirmed 图库 → Commons[0] → 示意图(`.detail-hero-img` 覆盖填充)。
|
||||
- 3) 管理员可指定展示图:`photos` 加 `featured` 列;`POST /api/photos/:pid/feature`(管理员,清同车型其它 featured 并置 confirmed);卡片 cover 查询优先 `featured` 再最小 id;详情图册 confirmed 图加"★ 设为封面"(当前封面金色高亮),候选图保留"✓ 确认"。
|
||||
- 4) 图鉴可直接输入型号筛选:探索工具条加 `.kw-input`(防抖 300ms → `query.q`)。
|
||||
- 测试:前端单元 45 + e2e 31(新增"型号关键字筛选""管理员设为封面");后端单元 22 + e2e 42(新增"封面优先 featured + 权限受控");修复 models.service 单测夹具缺 `photos` 表;两端 build 通过。
|
||||
- **2025 · 第 26 批(Commons 下载到本地,运行时不再访问外部图源)**:
|
||||
- 诉求:把原先浏览器端实时检索的 Wikimedia Commons 照片**全部下载到本地共享图库**,应用运行时只读本地 `/uploads`,不再发外部请求。
|
||||
- 前端:移除浏览器端 Commons(删除 `lib/commons.ts` / `lib/useCommonsImages.ts` / `lib/commons.test.ts`)。`GalleryCard` 封面只取本地 `cover_url`(否则分类示意图),不再懒取 Commons;`DetailPage` 图册改为「本地共享图库照片 + 系统示意图」,头图取 featured/confirmed 本地照片;移除"正在从 Commons 检索"提示与 Commons 角标,图注署名仍展示(下载时已存库)。
|
||||
- 取图脚本升级(`apps/api/scripts/fetch-images.mjs`,本机联网跑一次即可):新增 `--per N`(每车型多张)、`--confirm`(直接入库为 confirmed,封面/图册立即可见,替代原实时 Commons 展示);多来源去重;保存到本地 `app/data/uploads/` + `photos` 表并记录署名(作者/许可证/来源)。
|
||||
- 说明:构建/测试沙箱仅放行 npm、不可联网,无法在此执行实际下载;需在用户本机运行 `node scripts/fetch-images.mjs --per 3 --confirm` 完成落地。未下载到照片的车型自动回退分类示意图(永不空)。
|
||||
- 测试:前端单元 42(移除 commons 3 项)+ e2e 31,全绿;后端单元 22 + e2e 42 不变;两端 build 通过。
|
||||
- **2025 · 第 27 批(修正:下载图片一律进入候选审图)**:
|
||||
- 问题:上一批脚本提供了 `--confirm`,按该建议运行后下载图直接入库为 `confirmed`,**跳过了"候选审图"双闸门**。
|
||||
- 修正:取图脚本移除 `--confirm`,所有下载图**一律入库为 `candidate`**,进入管理员「候选审图」队列,人工确认后才作封面/图册(恢复"候选 + 人工确认"准确性闸门)。
|
||||
- 数据修复:将已下载的 Commons 图片(含 `source_url`)从 confirmed 改回 `candidate`(清 featured);并清除早期 e2e 测试误写入生产库的 8 张 1×1 占位图(行 + 文件)。
|
||||
- 验证:管理员登录 `GET /api/photos/candidates` 返回 15 张候选(带署名),`/admin/photos` 审图页可见可逐张确认。
|
||||
- **2025 · 第 28 批(图鉴分页记忆 + 去收集示例 + 首页紧凑/带图)**:
|
||||
- 1) 图鉴翻页记忆:图鉴页码存入 URL `?gp=N`(`GalleryView` 改为受控分页),从卡片进详情后"← 返回图鉴"用 `navigate(-1)` 回到来路并停在原页;筛选/关键字变化时自动回到第 1 页(`gp` 清除)。
|
||||
- 2) 图鉴页移除"收集示例"按钮(保留收集进度条与单卡收藏)。
|
||||
- 3) 首页更紧凑(英雄区/时代轨/留白收敛);时代代表车的 mini 卡在有真实封面(`cover_url`)时直接显示照片,且 `pickRepresentatives` 优先挑有图车型"带出来"。
|
||||
- 测试:前端单元 42 + e2e 33(新增"翻页进详情返回仍在该页""不再显示收集示例"),目标子集全绿;build 通过。
|
||||
- **2025 · 第 29 批(全站专业图标 + 数据大屏重构 + AI 识车接入通义千问)**:
|
||||
- 图标:扩充 `components/icons.tsx`(编辑/对比/确认/关闭/删除/维护/定位/返回/前进/警告/下拉/论坛/图片/奖杯/站点/缩放/翻页等 react-icons 出口);替换 DetailPage、NavMenu、Lightbox、Auth/Edit 模态、ComparePage、ProfilePage、各页返回链接、首页 CTA、EmptyState 等处的 emoji/符号为专业图标;EmptyState `icon` 改为 ReactNode。(保留原始数据 diff 的 `→` 与原生 `<option>` 内 ↑↓ 文案,属内容/原生限制)
|
||||
- 数据大屏(`/stats`)重构为真正的大屏:顶栏标题 + 实时时钟 + 渐变描边;5 张带图标/光晕的 KPI 卡(车型/个体/分类/最高时速/年代跨度);面板网格含 **SVG 环形图**(分类构成 / 国别构成)、**纵向柱状图**(各年代新增)、时速演进曲线(跨列);响应式。
|
||||
- AI 识车(`/identify`)接入 **通义千问 DashScope**(OpenAI 兼容多模态 `qwen-vl-plus`):后端 `IdentifyModule`——`POST /api/identify`(登录态,multer 内存上传 → base64 data URL → 调用 Qwen-VL,提示词要求输出 JSON 候选);解析候选并**匹配图鉴库**给出可点击详情;密钥走 `apps/api/.env` 的 `DASHSCOPE_API_KEY`(`.gitignore` 忽略,附 `.env.example`,main.ts `process.loadEnvFile()` 加载)。前端重写为上传→识别→候选(置信度条/依据)+图鉴相关车型,含未登录提示与加载态。
|
||||
- 真机验证:上传真实电力机车照片,识别为 **DJ1型(95%)** 并命中铭牌"DJ1 0003A",给出 SS8/HXD1 备选,链接到对应词条。
|
||||
- 测试:前端单元 42 + e2e(AI 页改为"未登录提示+上传区"、数据大屏改为 donut/vbars 断言),子集全绿;前后端 build 通过。
|
||||
- **2025 · 第 30 批(AI 识车结果持久化 + 历史 CRUD + 哈希缓存)**:
|
||||
- 可写库新增 `identifications` 表(user_id/filename/image_hash/summary/guesses_json/matches_json/top_code/top_name/note/error/created_at)。
|
||||
- 后端:`POST /api/identify` 改为**识别并落库**——先按图片 **SHA-256 哈希命中缓存**(同图已成功识别则复用结果与图片文件,不再调用 LLM、不重复存图),否则调用通义千问并保存图片;`GET /api/identifications`(本人历史)、`PATCH /api/identifications/:id`(改备注/人工纠正)、`DELETE /api/identifications/:id`(删除,仅当无其它记录共用该图时删文件);均需登录、按用户隔离。
|
||||
- 前端:`/identify` 识别后结果加入历史;新增"识别历史"列表——缩略图 + 候选/置信度 + 摘要 + 图鉴相关车型链接 + **可编辑备注(保存)** + 删除 + "缓存命中"标记。
|
||||
- 真机验证:首次识别 `cached=false` 落库;同图再传 `cached=true`(秒回、零调用);列表/改备注/删除均生效;缓存去重的共享图片在删除其一后仍保留。
|
||||
- 测试:前端单元 42 + e2e(AI 页 + 数据大屏)全绿;前后端 build 通过;测试产生的识别记录与图片已清理。
|
||||
- **2025 · 第 31 批(识别历史删除确认 + 导航 IA 重构)**:
|
||||
- 识别历史删除前弹确认(`确定删除这条识别记录?删除后不可恢复。`),避免误删。
|
||||
- 导航按用户意图重组:**图鉴**(探索图鉴 / 数据大屏 / 技术族谱 / 参数对比 / AI 识车)、**社区**(论坛 / 打卡地图 / 贡献榜);移除原先混杂的「数据」组。数据大屏作为「图鉴的宏观概览」置于图鉴组(先看大盘再下钻);开放 API 属开发者资源,仅保留在页脚,顶部导航更聚焦。
|
||||
- 测试:前端 build 通过;导航相关 e2e(社区发帖、审核/候选审图入口、数据大屏、AI 识车)全绿。
|
||||
|
||||
## 使用说明
|
||||
- 任务编号 `T-<阶段>.<序号>`,子项含 **开发 / 单元测试(UT) / 端到端测试(E2E)**。
|
||||
- `[ ]` 未开始 · `[~]` 进行中 · `[x]` 已完成。
|
||||
- `← FR-x.x` 表示对应 PRD 功能需求;`依赖:` 表示前置任务。
|
||||
- 测试约定:UT 覆盖率目标 ≥ 80%(核心逻辑模块);E2E 覆盖关键用户旅程。
|
||||
|
||||
---
|
||||
|
||||
## 技术栈基线(默认,待 PRD Open Question 锁定)
|
||||
- 前端:Next.js + React + TypeScript;可视化 D3/ECharts;地图 MapLibre。
|
||||
- 后端:NestJS(Node+TS) 或 FastAPI(Python);本清单以 **NestJS + TypeScript** 表述。
|
||||
- 数据库:PostgreSQL (+PostGIS);检索 Meilisearch;缓存 Redis;对象存储 S3/OSS。
|
||||
- 测试:UT 用 Jest/Vitest;API 集成用 Supertest;E2E 用 Playwright;ETL 用 pytest(如 Python)。
|
||||
- CI:GitHub Actions(lint + UT + 集成 + E2E 冒烟)。
|
||||
|
||||
---
|
||||
|
||||
# Phase 0 · 工程基建(所有阶段前置)
|
||||
|
||||
### T-0.1 仓库与脚手架
|
||||
- [~] 开发:Monorepo 结构(apps/web、apps/api、packages/shared、etl)。【已建 `app/etl`+`app/tests` 的数据层与测试骨架;JS 前后端 apps 待建】
|
||||
- [ ] 开发:TypeScript、ESLint、Prettier、commitlint、husky 预提交钩子。
|
||||
- [x] UT:shared 工具函数(单位解析、格式化)单测样例跑通。【clean/field_dict 已覆盖】
|
||||
- [ ] E2E:Playwright 安装 + 首条"首页可打开"冒烟用例。
|
||||
|
||||
### T-0.2 CI/CD 流水线
|
||||
- [ ] 开发:CI 跑 lint + UT + 集成测试 + E2E 冒烟;PR 必须绿。
|
||||
- [ ] 开发:测试覆盖率报告产出与阈值门禁。
|
||||
- [ ] 开发:预览环境自动部署(每 PR)。
|
||||
|
||||
### T-0.3 基础设施
|
||||
- [ ] 开发:本地 docker-compose(PostgreSQL+PostGIS、Redis、Meilisearch、MinIO)。
|
||||
- [ ] 开发:数据库迁移工具(Prisma/TypeORM migration)接入。
|
||||
- [ ] UT:数据库连接/迁移健康检查测试。
|
||||
|
||||
---
|
||||
|
||||
# Phase 1 · 数据底座 MVP
|
||||
|
||||
## 1A 数据建模与 ETL
|
||||
|
||||
### T-1.1 字段字典与数据模型 ← FR(数据需求 4.2)
|
||||
- [x] 开发:定义统一字段字典(数值+单位分离),落库 schema:Category / Model / Unit。
|
||||
- [x] 开发:迁移脚本 + 种子枚举(国别属性、状态、分类)。
|
||||
- [x] UT:字段校验规则(首产≤停产、数值范围、单位枚举、型号唯一)。
|
||||
- [x] UT:Model↔关系字段(父型号/衍生/原型车)外键完整性。
|
||||
|
||||
### T-1.2 ETL 导入管线 ← FR(4.4)
|
||||
- [x] 开发:读取 12 张 CSV/Excel,处理合并单元格、空表头修复。
|
||||
- [x] 开发:单位拆分("整备重量 / 轴重 /t" → 独立字段+单位)。
|
||||
- [x] 开发:字段映射到统一字典;系列→型号层级归并;改型关系识别。
|
||||
- [x] 开发:产出导入报告(成功/失败/待人工复核)。
|
||||
- [x] UT:每类清洗规则的单元测试(含脏数据样例:缺值、混排、异常年代)。
|
||||
- [x] UT:幂等性测试(重复导入不产生重复记录)。
|
||||
- [x] E2E:跑全量 12 表导入,断言记录数与抽样字段正确。
|
||||
|
||||
## 1B 后端 API
|
||||
|
||||
### T-1.3 车型查询 API ← FR-1.1 / FR-1.2 / FR-1.5
|
||||
- [x] 开发:车型列表接口(分页、分类筛选、排序)。
|
||||
- [x] 开发:多维筛选(年代区间、时速区间、生产商、国别、状态)。
|
||||
- [x] 开发:车型详情接口(参数、关系、图集占位)。
|
||||
- [x] UT:筛选/排序/分页边界(空结果、超范围页码、非法参数)。
|
||||
- [x] UT:详情序列化(数值+单位、缺失字段留空)。
|
||||
- [x] E2E(API):列表→详情链路,断言筛选组合结果正确。
|
||||
|
||||
### T-1.4 搜索服务 ← FR-1.3
|
||||
- [~] 开发:Meilisearch 索引(型号/别名/生产商),增量同步。【延后:当前用 SQLite LIKE+排序实现】
|
||||
- [x] 开发:搜索接口(模糊匹配,拼音为增强项)。
|
||||
- [x] UT:索引构建与查询排序、空查询、特殊字符处理。
|
||||
- [x] E2E(API):关键词搜索命中预期车型。
|
||||
|
||||
## 1C 前端
|
||||
|
||||
### T-1.5 车型列表与详情页 ← FR-1.1/1.4/1.5
|
||||
- [x] 开发:列表页(筛选器、分页、排序、卡片/表格切换)。
|
||||
- [x] 开发:详情页(参数表、历史沿革、图集占位、相关入口)。
|
||||
- [~] 开发:响应式 + SEO(SSR/metadata)。【已响应式;SSR/SEO 待 Next.js 化或预渲染,后续】
|
||||
- [x] UT:筛选器组件、单位展示组件、详情渲染(含缺失字段)。
|
||||
- [x] E2E:用户筛选→进入详情→参数正确展示的完整旅程。
|
||||
|
||||
### T-1.6 时间轴视图 ← FR-2.1
|
||||
- [x] 开发:年代横轴 + 分类泳道;节点点击展开详情卡。
|
||||
- [~] 开发:缩放/平移、按分类显隐。【已实现轴+刻度+悬停标签+碰撞分行;缩放/平移待增强】
|
||||
- [x] UT:时间轴数据转换(年代缺失/区间重叠/排序/碰撞分行)。
|
||||
- [x] E2E:打开时间轴→点击节点→跳转详情。
|
||||
|
||||
### T-1.7 图鉴卡牌视图 ← FR-2.2
|
||||
- [x] 开发:卡片墙 + 分类筛选 + "已收集/未收集"占位(收集逻辑 P3 接入)。
|
||||
- [x] UT:卡片列表渲染、筛选、占位状态。
|
||||
- [x] E2E:切换分类→卡片刷新→点击进入详情。
|
||||
|
||||
### T-1.8 MVP 验收
|
||||
- [x] E2E 套件:首页→搜索→筛选→详情→时间轴→图鉴 全链路冒烟。【Playwright 5 项全通过】
|
||||
- [x] 性能:列表/详情 P95 < 1.5s,搜索 < 500ms 基准测试。【实测 p95≤3ms / 1.3ms,远优于阈值】
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 · 众包共建
|
||||
|
||||
### T-2.1 账户与权限 ← FR-3.1/3.2/3.3
|
||||
- [x] 开发:注册/登录(邮箱+第三方)、会话/JWT。【邮箱+密码 JWT;第三方登录后续】
|
||||
- [x] 开发:RBAC(游客/注册/信任/版主/管理员)。
|
||||
- [x] 开发:个人主页(贡献统计、徽章、收集进度占位)。
|
||||
- [x] UT:权限矩阵(各角色可执行操作)、令牌过期/刷新。【AuthService 单测 + 角色等级 RolesGuard】
|
||||
- [x] E2E:注册→登录→访问受限页→登出。
|
||||
|
||||
### T-2.2 编辑与修订版本 ← FR-4.1/4.2/4.5
|
||||
- [x] 开发:字段编辑建议提交;Revision 版本记录。
|
||||
- [x] 开发:diff 对比与回滚。【修订含 old→new diff;覆盖按修订追溯,可回滚到基础数据】
|
||||
- [x] 开发:字段校验(数值范围、单位、年代逻辑)复用 T-1.1 规则。
|
||||
- [x] UT:版本生成、diff 计算、回滚正确性、校验拦截非法值。【经 e2e 覆盖】
|
||||
- [x] E2E:编辑字段→生成版本→对比→(管理员)审核生效。
|
||||
|
||||
### T-2.3 审核流程 ← FR-4.3/4.4/4.6
|
||||
- [x] 开发:审核队列;新手入队、信任用户直接生效。
|
||||
- [x] 开发:字段来源引用(修改说明 note);争议字段讨论后续。
|
||||
- [x] UT:审核状态机(待审/通过/驳回)、信任分级路由。【经 e2e 覆盖】
|
||||
- [x] E2E:新手提交→版主审核通过→数据生效。
|
||||
|
||||
### T-2.4 荣誉与激励 ← FR-5.1~5.5
|
||||
- [x] 开发:贡献积分引擎;等级头衔;徽章成就;贡献榜;词条认领署名。
|
||||
- [x] UT:积分计算、等级阈值、徽章触发条件、防刷分规则。【levels 单测 + e2e 积分/榜单/认领】
|
||||
- [x] E2E:完成被采纳编辑→积分增加→徽章/榜单更新。
|
||||
|
||||
---
|
||||
|
||||
# Phase 3 · 社区与空间
|
||||
|
||||
### T-3.1 论坛 ← FR-6.1/6.2
|
||||
- [x] 开发:板块(综合/拍车打卡/历史考证);发帖、回复;持久化永久可查。
|
||||
- [~] 开发:@、点赞、收藏。【发帖/回复/板块已做;@/赞/收藏后续】
|
||||
- [x] UT:发帖/回复/分页、权限、保留历史。【经 e2e 覆盖】
|
||||
- [x] E2E:发帖→回复→历史可检索。
|
||||
|
||||
### T-3.2 词条讨论与动态流 ← FR-6.3/6.4/6.5
|
||||
- [x] 开发:详情页内嵌讨论(绑定 model_id 的讨论区)。
|
||||
- [x] 开发:打卡动态 Feed(打卡地图页"最新打卡")。【精华化引用后续】
|
||||
- [x] UT:讨论与车型绑定。【经 e2e 覆盖(modelId 过滤)】
|
||||
- [x] E2E:词条下发起讨论 → 帖子页回复。
|
||||
|
||||
### T-3.3 打卡与收集 ← FR-7.1/7.2
|
||||
- [x] 开发:目击打卡(车号/时间/地点/经纬度);图鉴收集进度联动(localStorage,Phase 3 已起)。
|
||||
- [x] UT:打卡校验(坐标范围)、收集进度计算。【经 e2e(非法坐标 400)+ 前端单测】
|
||||
- [x] E2E:详情页打卡 → 出现在打卡列表。
|
||||
|
||||
### T-3.4 地图视图 ← FR-2.5
|
||||
- [x] 开发:打卡地图(MapLibre + OpenStreetMap 瓦片)+ 标记 + 最新打卡侧栏。
|
||||
- [~] 开发:配属地图 + 目击热力图(PostGIS 聚合)。【已做目击点位地图;热力/路局聚合后续】
|
||||
- [x] UT:地理点位渲染。【经 e2e(map 容器加载)】
|
||||
- [x] E2E:打开地图→展示标记/侧栏。
|
||||
|
||||
---
|
||||
|
||||
# Phase 4 · 进阶玩法 + 圈层扩展
|
||||
|
||||
### T-4.1 参数对比 ← FR-2.3
|
||||
- [x] 开发:勾选 2–4 车型→雷达图 + 对比表。
|
||||
- [x] UT:对比数据归一化、单位对齐、缺失项处理。
|
||||
- [x] E2E:选择多车型→对比图渲染正确。
|
||||
|
||||
### T-4.2 技术族谱图 ← FR-2.4
|
||||
- [x] 开发:按系列聚合的谱系视图(分类→系列→型号,可进详情)。
|
||||
- [~] 开发:父/衍生/原型有向图 + 跨国连线。【现用 series 谱系;真正关系待 model_relation 数据/众包】
|
||||
- [x] UT:族谱数据构建(系列聚合)。【经 e2e 覆盖】
|
||||
- [x] E2E:打开族谱→节点跳详情。
|
||||
|
||||
### T-4.3 拍车攻略 / 保存机车地图 / 稀有车提醒 ← FR-7.3/7.4/7.5
|
||||
- [x] 开发:拍车攻略(按打卡车站聚合"在哪能拍到什么车")。
|
||||
- [~] 开发:保存机车地图。【已有 unit.location 文本;缺坐标,地图化待数据】
|
||||
- [x] 开发:稀有车提醒(轻量版:关注 + 个人主页汇总关注车型最新目击)。【推送待通知基建】
|
||||
- [x] UT:聚合排序、关注汇总。【经 e2e 覆盖】
|
||||
- [x] E2E:关注车型→个人主页"最新目击"出现;拍车攻略聚合。
|
||||
|
||||
### T-4.4 圈层二数据扩展 ← PRD 3.1
|
||||
- [~] 开发:在华外国车型 + 国外原型导入。【国别属性/制造国/原型字段就位且可众包编辑;真实国外数据待录入】
|
||||
- [x] 开发:按国别筛选(探索筛选器)+ 族谱标注非国产。
|
||||
- [~] 开发:世界地图 / 跨国族谱连线。【待圈层二数据】
|
||||
- [x] UT:国别字段映射。【经既有覆盖】
|
||||
|
||||
---
|
||||
|
||||
# Phase 5 · 智能化与开放
|
||||
|
||||
### T-5.1 AI 识车 ← FR-8.1
|
||||
- [x] 开发:照片→车型识别。【接入通义千问 DashScope `qwen-vl-plus` 多模态:`POST /api/identify` 上传识别 + 匹配图鉴库给出候选与链接;密钥走 `DASHSCOPE_API_KEY`】
|
||||
- [~] UT:推理服务接口、置信度阈值、未知类回退。【已含 JSON 解析容错/未配置回退/错误处理;自动化单测待补】
|
||||
- [x] E2E:上传照片→返回候选。【真机验证:DJ1 型 95% 命中铭牌;前端未登录提示 + 上传区 e2e】
|
||||
|
||||
### T-5.2 数据大屏 / 开放 API / AR-3D / 多语言 ← FR-8.2~8.5
|
||||
- [x] 开发:可视化大屏(`/stats`:分类/国别/年代柱状 + 时速演进曲线,后端 `/api/stats`)。
|
||||
- [x] 开发:开放 API + 数据导出(`/api/export/models.json|csv` + `/api-docs` 文档页)。
|
||||
- [ ] 开发:AR/3D 展示。【需 3D 资产/模型,延后】
|
||||
- [ ] 开发:多语言。【需较大翻译工作量,延后;架构可后续接 i18n】
|
||||
- [x] UT:聚合统计、导出格式。【后端 e2e:stats / export json / export csv】
|
||||
- [x] E2E:大屏渲染、API 文档与导出链接、AI 占位页。
|
||||
|
||||
---
|
||||
|
||||
# 横切任务(贯穿各阶段)
|
||||
|
||||
### T-X.1 非功能与质量 ← PRD 6
|
||||
- [ ] 开发:性能预算监控、日志/审计(编辑、审核、积分流水)。
|
||||
- [ ] 开发:安全(防刷分、UGC 审核、敏感地点脱敏)、合规(图片版权声明)。
|
||||
- [ ] UT:限流、脱敏、版权字段校验。
|
||||
- [ ] E2E:安全旅程(越权访问被拒、敏感坐标脱敏展示)。
|
||||
|
||||
### T-X.2 可观测与运维
|
||||
- [ ] 开发:错误监控(Sentry)、指标看板(KPI:编辑量/审核通过率/留存)。
|
||||
- [ ] 开发:数据备份与回滚演练。
|
||||
|
||||
---
|
||||
|
||||
## 里程碑对照
|
||||
| 里程碑 | 覆盖任务 | 出口标准 |
|
||||
|--------|----------|----------|
|
||||
| M1 (MVP) | T-0.* / T-1.* | 12 表入库 + 列表/详情/搜索/时间轴/图鉴 + 性能达标 |
|
||||
| M2 | T-2.* | 账户 + 编辑/审核/修订 + 荣誉体系上线 |
|
||||
| M3 | T-3.* | 论坛/讨论/动态/打卡 + 地图 |
|
||||
| M4 | T-4.* | 对比 + 族谱 + 进阶玩法 + 圈层二数据 |
|
||||
| M5 | T-5.* | AI 识车 + 大屏 + 开放 API + AR/3D + 多语言 |
|
||||
|
||||
## 关键依赖链
|
||||
```
|
||||
T-0.* ──▶ T-1.1 ──▶ T-1.2 ──▶ T-1.3/1.4 ──▶ T-1.5/1.6/1.7 ──▶ T-1.8(MVP)
|
||||
└─▶ T-2.1 ──▶ T-2.2 ──▶ T-2.3 ──▶ T-2.4
|
||||
T-2.1 ──▶ T-3.1/3.2/3.3 ; T-1.x(PostGIS) ──▶ T-3.4
|
||||
T-1.1(关系字段) ──▶ T-4.2(族谱) ; T-3.3 ──▶ T-4.3
|
||||
T-1.1(国别/原型) ──▶ T-4.4(圈层二)
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# 中国机车图鉴 · 应用工程
|
||||
|
||||
对应 `2-task.md` 的实现代码。当前已完成 **Phase 0(部分)+ Phase 1A(数据底座 ETL)**。
|
||||
|
||||
## 目录结构
|
||||
```
|
||||
app/
|
||||
etl/
|
||||
field_dict.py # 统一字段字典、同义表头映射、枚举、分类配置 (T-1.1)
|
||||
clean.py # 清洗规则:单元格清理、数值+单位拆分、年代/状态/国别 (T-1.2)
|
||||
schema.sql # 三层模型 schema:Category/Model/Unit + 关系表 (T-1.1)
|
||||
importer.py # ETL 主流程:12 CSV -> SQLite + JSON + 报告 (T-1.2)
|
||||
tests/ # 单元/集成测试 (Jest 等价物,使用 Python unittest)
|
||||
data/ # 产物:machines.db / machines.json / import_report.md
|
||||
```
|
||||
|
||||
## 运行 ETL 导入
|
||||
```bash
|
||||
# 在 Train/ 目录下
|
||||
python3 -m app.etl.importer
|
||||
```
|
||||
输入:`Train/csv/*.csv`(由总表 xlsx 转出的 12 张分类表)
|
||||
输出:`app/data/machines.db`、`machines.json`、`import_report.md`
|
||||
|
||||
当前导入结果:**Model 540 / Unit 307**,跳过 10(均为空行),0 待复核。
|
||||
|
||||
## 运行测试
|
||||
```bash
|
||||
python3 -m unittest discover -s app/tests -p "test_*.py" -v
|
||||
```
|
||||
覆盖:字段映射、单位拆分、年代/状态解析、向前填充、表头识别、全量导入幂等性。
|
||||
|
||||
## 设计说明与已知取舍
|
||||
- **存储**:开发期使用 SQLite(零依赖、可运行验证)。schema 按可移植到 PostgreSQL 设计,
|
||||
Phase 1B 接入 API 时可平滑迁移(数值+单位字段、外键、索引均兼容)。
|
||||
- **保真**:每条记录保留 `raw_json`,原始清洗后所有列不丢失,便于后续众包修订与字段补全。
|
||||
- **复合数值**(如电力机车 "2×92(100)"):取首个数值入 `*_value`,完整原文存 `raw_json`,
|
||||
待 Phase 2 众包修订细化。
|
||||
- **国别属性**:当前用启发式默认"国产",字段已就位,等待圈层二(国外车型)数据与人工标注。
|
||||
@@ -0,0 +1,23 @@
|
||||
# ETL 导入报告
|
||||
|
||||
- 车型(Model):**540**
|
||||
- 个体(Unit):**307**
|
||||
- 跳过(无主键):**10**
|
||||
- 待人工复核:**0**
|
||||
|
||||
## 分表明细
|
||||
|
||||
| 分类表 | 分类 | 粒度 | 数据行 | 入库 | 跳过 | 备注 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 中国蒸汽汽车统计表(建国前) | 蒸汽机车 | model | 0 | 0 | 0 | 空表(无数据) |
|
||||
| 中国蒸汽机车型号表(建国后) | 蒸汽机车 | model | 16 | 16 | 0 | |
|
||||
| 北京表(蒸汽机车) | 蒸汽机车 | unit | 67 | 59 | 8 | |
|
||||
| 全国普速客车型号统计表 | 客车 | model | 188 | 188 | 0 | |
|
||||
| 全国电力机车型号表 | 电力机车 | model | 63 | 63 | 0 | |
|
||||
| 全国内燃机车型号表 | 内燃机车 | model | 100 | 100 | 0 | |
|
||||
| CJ型动车组、早期动车组及和谐号统计表 | 动车组 | model | 59 | 59 | 0 | |
|
||||
| 全国复兴号统计表 | 动车组 | model | 41 | 41 | 0 | |
|
||||
| 中国铁路货车统计表 | 货车 | model | 35 | 35 | 0 | |
|
||||
| 中国铁路普速检测车统计表 | 检测车 | unit | 229 | 229 | 0 | |
|
||||
| 中国高速综合检测列车统计表 | 检测车 | unit | 21 | 19 | 2 | |
|
||||
| 中国旅游列车统计表 | 旅游列车 | model | 38 | 38 | 0 | |
|
||||
|
After Width: | Height: | Size: 351 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 572 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 415 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 338 KiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 237 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 532 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 232 KiB |
@@ -0,0 +1,110 @@
|
||||
"""清洗规则:单元格清理、数值+单位拆分、年代解析、状态/国别规范化。
|
||||
|
||||
对应任务 T-1.2(清洗)。所有函数均为纯函数,便于单元测试(T-1.2 UT)。
|
||||
"""
|
||||
import re
|
||||
|
||||
# 占位/空值标记
|
||||
_EMPTY_MARKERS = {"", "-", "——", "—", "/", "N/A", "n/a", "无", "未知"}
|
||||
|
||||
|
||||
def clean_cell(value) -> str:
|
||||
"""清理单元格:去换行、压缩空白、trim。返回字符串。"""
|
||||
if value is None:
|
||||
return ""
|
||||
s = str(value).replace("\n", "").replace("\r", "")
|
||||
s = s.replace("\u3000", " ")
|
||||
s = re.sub(r"\s+", " ", s).strip()
|
||||
return s
|
||||
|
||||
|
||||
def is_empty(value) -> bool:
|
||||
"""判断是否为空/占位值。"""
|
||||
return clean_cell(value) in _EMPTY_MARKERS
|
||||
|
||||
|
||||
# 匹配前导数字(含小数、千分位、负号),后跟可选单位
|
||||
_NUM_RE = re.compile(r"-?\d+(?:\.\d+)?")
|
||||
|
||||
|
||||
def parse_value_unit(raw, default_unit: str = ""):
|
||||
"""从形如 '400km/h(试验)'、'17t'、'2×92(100)'、'126.0'、'——'
|
||||
中拆出 (数值: float|None, 单位: str, 原文: str)。
|
||||
|
||||
规则:取第一个出现的数值作为主数值;单位优先取 default_unit,
|
||||
否则尝试从数值后紧跟的字母/单位片段提取;无数值则返回 (None, '', 原文)。
|
||||
"""
|
||||
text = clean_cell(raw)
|
||||
if text in _EMPTY_MARKERS:
|
||||
return None, "", text
|
||||
m = _NUM_RE.search(text)
|
||||
if not m:
|
||||
return None, "", text
|
||||
value = float(m.group())
|
||||
unit = default_unit
|
||||
if not unit:
|
||||
# 取数值之后紧邻的单位片段(字母 / 常见单位字符)
|
||||
rest = text[m.end():]
|
||||
um = re.match(r"\s*([A-Za-z%/·³²]+(?:/[A-Za-z]+)?)", rest)
|
||||
if um:
|
||||
unit = um.group(1)
|
||||
return value, unit, text
|
||||
|
||||
|
||||
def parse_year(raw):
|
||||
"""解析年份:'1971 年'、'2006'、'2007-12-22 00:00:00' -> int|None。"""
|
||||
text = clean_cell(raw)
|
||||
if text in _EMPTY_MARKERS:
|
||||
return None
|
||||
m = re.search(r"(1[89]\d{2}|20\d{2})", text)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def normalize_status(raw) -> str:
|
||||
"""把自由文本状态规范到枚举之一。"""
|
||||
text = clean_cell(raw)
|
||||
if not text:
|
||||
return "未知"
|
||||
mapping = [
|
||||
("半封存", "半封存"),
|
||||
("封存", "封存"),
|
||||
("现役", "现役"),
|
||||
("在役", "现役"),
|
||||
("退役", "退役"),
|
||||
("淘汰", "退役"),
|
||||
("报废", "报废"),
|
||||
("保存", "保存"),
|
||||
("试验", "试验"),
|
||||
("样车", "试验"),
|
||||
]
|
||||
for kw, val in mapping:
|
||||
if kw in text:
|
||||
return val
|
||||
return "未知"
|
||||
|
||||
|
||||
# 国别属性的简单启发式推断(默认国产;后续众包修订可覆盖)
|
||||
_IMPORT_HINTS = ["进口", "苏联", "罗马尼亚", "法国", "日本", "德国", "美国", "捷克"]
|
||||
|
||||
|
||||
def infer_country_type(*texts) -> str:
|
||||
blob = " ".join(clean_cell(t) for t in texts)
|
||||
if "合资" in blob:
|
||||
return "中外合资"
|
||||
if "仿制" in blob or "引进" in blob:
|
||||
return "引进仿制"
|
||||
for h in _IMPORT_HINTS:
|
||||
if h in blob:
|
||||
return "进口"
|
||||
return "国产"
|
||||
|
||||
|
||||
def forward_fill(values):
|
||||
"""对一列做向前填充(处理合并单元格被拆空的 系列 列)。"""
|
||||
out, last = [], ""
|
||||
for v in values:
|
||||
c = clean_cell(v)
|
||||
if c:
|
||||
last = c
|
||||
out.append(last)
|
||||
return out
|
||||
@@ -0,0 +1,109 @@
|
||||
"""统一字段字典与枚举。
|
||||
|
||||
将各 sheet 中形态各异的表头映射到规范字段名(canonical),
|
||||
并声明哪些字段是"数值 + 单位"型(需要拆分)。
|
||||
对应任务 T-1.1(字段字典)/ T-1.2(字段映射)。
|
||||
"""
|
||||
|
||||
# 规范字段 -> 该字段的同义表头集合(去空格后匹配)
|
||||
# 注意:表头里常含空格/换行,匹配前统一做 normalize_header() 处理。
|
||||
FIELD_SYNONYMS = {
|
||||
"series": ["系列"],
|
||||
"model_code": ["型号", "型号(代号)", "车型", "车型代码", "小表编号车型"],
|
||||
"full_name": ["车型全称", "车型名称"],
|
||||
"first_year": ["首产时间", "制造时间", "生产时间"],
|
||||
"last_year": ["停产时间"],
|
||||
"manufacturer": ["生产商", "主要生产厂商", "生产厂商", "制造商"],
|
||||
"length": ["车体长度/mm", "机车长度/mm", "车辆全长/mm", "车体全长/mm"],
|
||||
"width": ["车体宽度/mm", "机车宽度/mm"],
|
||||
"height": ["车体高度/mm"],
|
||||
"wheelbase": ["轴距/mm", "车辆定距/mm"],
|
||||
"weight": ["整备重量/t", "自重/t", "整备重量/轴重/t", "整备重量/节数"],
|
||||
"axle_load": ["轴重/t"],
|
||||
"axle_arrangement": ["轴列式", "轴式", "轴列式(标准编组)"],
|
||||
"tractive_start": ["牵引力(起动)/kN"],
|
||||
"tractive_cont": ["牵引力(持续)/kN"],
|
||||
"power_kw": ["牵引力/kW"],
|
||||
"efficiency": ["传动效率", "机械效率"],
|
||||
"drive": ["传动方式/动力来源", "供电方式(传动方式)", "供电方式", "制动方式"],
|
||||
"max_speed": ["最高运行时速", "最高运营时速", "构造速度/km/h", "运行时速",
|
||||
"最高速度", "构造速度"],
|
||||
"usage": ["用途", "主要用途", "核心运行区段/主题"],
|
||||
"production_count": ["产量", "累计产量", "生产台数", "生产数量",
|
||||
"累计产量/配属"],
|
||||
"capacity": ["容积/m³", "定员/人"],
|
||||
"load": ["载重/t"],
|
||||
"bogie": ["转向架型号"],
|
||||
"coupler": ["车钩类型"],
|
||||
# 个体(Unit)粒度字段
|
||||
"car_number": ["车号", "编号"],
|
||||
"function": ["功能"],
|
||||
"depot": ["配属", "所属路局"],
|
||||
"livery": ["颜色", "涂装"],
|
||||
"status": ["状态"],
|
||||
"side_mark": ["侧标"],
|
||||
"note": ["备注"],
|
||||
"location": ["存放位置"],
|
||||
"formation": ["动力车、拖车", "动力车拖车"],
|
||||
"predecessor": ["前身(部分含)", "前身"],
|
||||
"lifespan": ["最大使用寿命(结合实际)"],
|
||||
"tour_name": ["旅游列车名称"],
|
||||
"tractor_models": ["牵引机车常用型号"],
|
||||
}
|
||||
|
||||
# "数值 + 单位"型字段:导入时拆出 <field>_value(float) 与 <field>_unit(text)
|
||||
NUMERIC_UNIT_FIELDS = {
|
||||
"length": "mm", "width": "mm", "height": "mm", "wheelbase": "mm",
|
||||
"weight": "t", "axle_load": "t", "load": "t",
|
||||
"tractive_start": "kN", "tractive_cont": "kN", "power_kw": "kW",
|
||||
"max_speed": "km/h", "capacity": "",
|
||||
}
|
||||
|
||||
# 状态枚举(规范化目标)
|
||||
STATUS_ENUM = ["现役", "封存", "半封存", "报废", "退役", "保存", "试验", "未知"]
|
||||
|
||||
# 国别属性枚举
|
||||
COUNTRY_TYPE_ENUM = ["国产", "进口", "引进仿制", "中外合资", "未知"]
|
||||
|
||||
# sheet 文件名 -> 分类配置
|
||||
# grain: "model" 车型粒度 / "unit" 个体粒度
|
||||
CATEGORY_CONFIG = {
|
||||
"中国蒸汽汽车统计表(建国前)": {"category": "蒸汽机车", "subcat": "建国前", "grain": "model"},
|
||||
"中国蒸汽机车型号表(建国后)": {"category": "蒸汽机车", "subcat": "建国后", "grain": "model"},
|
||||
"北京表(蒸汽机车)": {"category": "蒸汽机车", "subcat": "北京现存", "grain": "unit"},
|
||||
"全国普速客车型号统计表": {"category": "客车", "subcat": "普速客车", "grain": "model"},
|
||||
"全国电力机车型号表": {"category": "电力机车", "subcat": "", "grain": "model",
|
||||
"col_override": {0: "series", 1: "model_code"}},
|
||||
"全国内燃机车型号表": {"category": "内燃机车", "subcat": "", "grain": "model"},
|
||||
"CJ型动车组、早期动车组及和谐号统计表": {"category": "动车组", "subcat": "CJ/早期/和谐号", "grain": "model"},
|
||||
"全国复兴号统计表": {"category": "动车组", "subcat": "复兴号", "grain": "model"},
|
||||
"中国铁路货车统计表": {"category": "货车", "subcat": "", "grain": "model"},
|
||||
"中国铁路普速检测车统计表": {"category": "检测车", "subcat": "普速检测", "grain": "unit"},
|
||||
"中国高速综合检测列车统计表": {"category": "检测车", "subcat": "高速综合检测", "grain": "unit"},
|
||||
"中国旅游列车统计表": {"category": "旅游列车", "subcat": "", "grain": "model"},
|
||||
}
|
||||
|
||||
# 用于识别"表头行"的关键 token(normalize 后)
|
||||
HEADER_TOKENS = {"型号", "车号", "车型代码", "车型", "旅游列车名称", "小表编号",
|
||||
"型号(代号)", "系列", "车型全称"}
|
||||
|
||||
# 反向索引:normalized header -> canonical field
|
||||
_REVERSE = {}
|
||||
for _canon, _syns in FIELD_SYNONYMS.items():
|
||||
for _s in _syns:
|
||||
_REVERSE[_s] = _canon
|
||||
|
||||
|
||||
def normalize_header(h: str) -> str:
|
||||
"""规范化表头:去除空白、换行、全角空格,并将全角括号转半角。"""
|
||||
if h is None:
|
||||
return ""
|
||||
s = (h.replace("\n", "").replace("\r", "")
|
||||
.replace(" ", "").replace("\u3000", "").strip())
|
||||
s = s.replace("(", "(").replace(")", ")")
|
||||
return s
|
||||
|
||||
|
||||
def map_header(h: str):
|
||||
"""把原始表头映射到 canonical 字段名,未知返回 None。"""
|
||||
return _REVERSE.get(normalize_header(h))
|
||||
@@ -0,0 +1,267 @@
|
||||
"""ETL 导入管线:12 张 CSV -> 清洗 -> SQLite + JSON + 导入报告。
|
||||
|
||||
对应任务 T-1.2。运行:
|
||||
python3 -m app.etl.importer # 从 Train/ 目录运行
|
||||
或:
|
||||
python3 app/etl/importer.py
|
||||
"""
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
from . import field_dict as fd
|
||||
from . import clean as cl
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
APP_DIR = os.path.dirname(HERE)
|
||||
ROOT = os.path.dirname(APP_DIR) # Train/
|
||||
CSV_DIR = os.path.join(ROOT, "csv")
|
||||
OUT_DIR = os.path.join(APP_DIR, "data")
|
||||
DB_PATH = os.path.join(OUT_DIR, "machines.db")
|
||||
JSON_PATH = os.path.join(OUT_DIR, "machines.json")
|
||||
REPORT_PATH = os.path.join(OUT_DIR, "import_report.md")
|
||||
SCHEMA_PATH = os.path.join(HERE, "schema.sql")
|
||||
|
||||
|
||||
def find_header_row(rows):
|
||||
"""返回表头行索引:含 >=3 个非空单元且命中关键 token 的首行。"""
|
||||
for i, row in enumerate(rows):
|
||||
cells = [fd.normalize_header(c) for c in row]
|
||||
nonempty = [c for c in cells if c]
|
||||
if len(nonempty) >= 3 and (set(cells) & fd.HEADER_TOKENS):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def build_column_map(header_row):
|
||||
"""列索引 -> canonical 字段名(未知列保留为 raw::原表头)。"""
|
||||
col_map = {}
|
||||
for idx, h in enumerate(header_row):
|
||||
canon = fd.map_header(h)
|
||||
norm = fd.normalize_header(h)
|
||||
if canon:
|
||||
col_map[idx] = canon
|
||||
elif norm:
|
||||
col_map[idx] = "raw::" + norm
|
||||
return col_map
|
||||
|
||||
|
||||
def clean_record(row, col_map):
|
||||
"""把一行映射为 canonical 字段 dict(含 raw:: 保真列)。"""
|
||||
rec, raw = {}, {}
|
||||
for idx, field in col_map.items():
|
||||
value = row[idx] if idx < len(row) else ""
|
||||
if field.startswith("raw::"):
|
||||
c = cl.clean_cell(value)
|
||||
if c:
|
||||
raw[field[5:]] = c
|
||||
continue
|
||||
rec[field] = value
|
||||
rec["_raw_extra"] = raw
|
||||
return rec
|
||||
|
||||
|
||||
def to_model_row(rec, category_id, sheet, series_value):
|
||||
"""构造 model 表插入字典。"""
|
||||
raw_all = dict(rec.get("_raw_extra", {}))
|
||||
m = {
|
||||
"category_id": category_id,
|
||||
"series": cl.clean_cell(rec.get("series") or series_value),
|
||||
"model_code": cl.clean_cell(rec.get("model_code")),
|
||||
"full_name": cl.clean_cell(rec.get("full_name")),
|
||||
"manufacturer": cl.clean_cell(rec.get("manufacturer")),
|
||||
"first_year": cl.parse_year(rec.get("first_year")),
|
||||
"last_year": cl.parse_year(rec.get("last_year")),
|
||||
"status": cl.normalize_status(rec.get("status")),
|
||||
"usage": cl.clean_cell(rec.get("usage")),
|
||||
"production_count": cl.clean_cell(rec.get("production_count")),
|
||||
"axle_arrangement": cl.clean_cell(rec.get("axle_arrangement")),
|
||||
"drive": cl.clean_cell(rec.get("drive")),
|
||||
"efficiency": cl.clean_cell(rec.get("efficiency")),
|
||||
"country": "中国",
|
||||
"country_type": cl.infer_country_type(
|
||||
rec.get("manufacturer"), rec.get("model_code"),
|
||||
rec.get("usage"), rec.get("production_count")),
|
||||
"source_sheet": sheet,
|
||||
}
|
||||
# 数值+单位字段拆分
|
||||
for field, default_unit in fd.NUMERIC_UNIT_FIELDS.items():
|
||||
if field not in fd.FIELD_SYNONYMS:
|
||||
continue
|
||||
val, unit, _ = cl.parse_value_unit(rec.get(field), default_unit)
|
||||
m[field + "_value"] = val
|
||||
m[field + "_unit"] = unit or default_unit
|
||||
if rec.get(field) is not None:
|
||||
raw_all[field] = cl.clean_cell(rec.get(field))
|
||||
# 把所有 canonical 原文也并入 raw_json 保真
|
||||
for k, v in rec.items():
|
||||
if k == "_raw_extra":
|
||||
continue
|
||||
c = cl.clean_cell(v)
|
||||
if c:
|
||||
raw_all[k] = c
|
||||
m["raw_json"] = json.dumps(raw_all, ensure_ascii=False)
|
||||
return m
|
||||
|
||||
|
||||
def to_unit_row(rec, category_id, sheet):
|
||||
raw_all = dict(rec.get("_raw_extra", {}))
|
||||
for k, v in rec.items():
|
||||
if k == "_raw_extra":
|
||||
continue
|
||||
c = cl.clean_cell(v)
|
||||
if c:
|
||||
raw_all[k] = c
|
||||
return {
|
||||
"category_id": category_id,
|
||||
"car_number": cl.clean_cell(rec.get("car_number")),
|
||||
"model_name": cl.clean_cell(rec.get("full_name") or rec.get("model_code")),
|
||||
"function": cl.clean_cell(rec.get("function")),
|
||||
"depot": cl.clean_cell(rec.get("depot")),
|
||||
"livery": cl.clean_cell(rec.get("livery")),
|
||||
"status": cl.normalize_status(rec.get("status")),
|
||||
"location": cl.clean_cell(rec.get("location")),
|
||||
"note": cl.clean_cell(rec.get("note")),
|
||||
"raw_json": json.dumps(raw_all, ensure_ascii=False),
|
||||
"source_sheet": sheet,
|
||||
}
|
||||
|
||||
|
||||
def _insert(conn, table, row):
|
||||
cols = list(row.keys())
|
||||
ph = ",".join(["?"] * len(cols))
|
||||
conn.execute(
|
||||
f"INSERT INTO {table} ({','.join(cols)}) VALUES ({ph})",
|
||||
[row[c] for c in cols],
|
||||
)
|
||||
|
||||
|
||||
def import_all(csv_dir=CSV_DIR, db_path=DB_PATH):
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
with open(SCHEMA_PATH, encoding="utf-8") as f:
|
||||
conn.executescript(f.read())
|
||||
|
||||
report = {"sheets": [], "models": 0, "units": 0, "skipped": 0, "review": []}
|
||||
export = {"categories": [], "models": [], "units": []}
|
||||
cat_ids = {}
|
||||
|
||||
for sheet, cfg in fd.CATEGORY_CONFIG.items():
|
||||
path = os.path.join(csv_dir, sheet + ".csv")
|
||||
entry = {"sheet": sheet, "category": cfg["category"], "grain": cfg["grain"],
|
||||
"rows": 0, "imported": 0, "skipped": 0, "note": ""}
|
||||
if not os.path.exists(path):
|
||||
entry["note"] = "文件缺失"
|
||||
report["sheets"].append(entry)
|
||||
continue
|
||||
with open(path, encoding="utf-8-sig") as fh:
|
||||
rows = list(csv.reader(fh))
|
||||
if not rows:
|
||||
entry["note"] = "空表(无数据)"
|
||||
report["sheets"].append(entry)
|
||||
continue
|
||||
|
||||
key = (cfg["category"], cfg["subcat"])
|
||||
if key not in cat_ids:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO category(name, subcat, slug) VALUES (?,?,?)",
|
||||
(cfg["category"], cfg["subcat"], None))
|
||||
cat_ids[key] = cur.lastrowid
|
||||
export["categories"].append(
|
||||
{"id": cur.lastrowid, "name": cfg["category"], "subcat": cfg["subcat"]})
|
||||
category_id = cat_ids[key]
|
||||
|
||||
hidx = find_header_row(rows)
|
||||
if hidx is None:
|
||||
entry["note"] = "未识别表头行"
|
||||
report["sheets"].append(entry)
|
||||
continue
|
||||
col_map = build_column_map(rows[hidx])
|
||||
# 应用 per-sheet 列覆盖(处理表头标注与实际不符的脏表)
|
||||
for idx, field in cfg.get("col_override", {}).items():
|
||||
col_map[idx] = field
|
||||
data_rows = rows[hidx + 1:]
|
||||
entry["rows"] = len(data_rows)
|
||||
|
||||
# 系列列向前填充(合并单元格)
|
||||
series_col = next((i for i, f in col_map.items() if f == "series"), None)
|
||||
if series_col is not None:
|
||||
filled = cl.forward_fill([r[series_col] if series_col < len(r) else ""
|
||||
for r in data_rows])
|
||||
else:
|
||||
filled = [""] * len(data_rows)
|
||||
|
||||
for r, series_value in zip(data_rows, filled):
|
||||
rec = clean_record(r, col_map)
|
||||
if cfg["grain"] == "unit":
|
||||
car = cl.clean_cell(rec.get("car_number"))
|
||||
if not car:
|
||||
entry["skipped"] += 1
|
||||
report["skipped"] += 1
|
||||
continue
|
||||
row = to_unit_row(rec, category_id, sheet)
|
||||
_insert(conn, "unit", row)
|
||||
export["units"].append(row)
|
||||
report["units"] += 1
|
||||
entry["imported"] += 1
|
||||
else:
|
||||
code = cl.clean_cell(rec.get("model_code")) or \
|
||||
cl.clean_cell(rec.get("tour_name")) or \
|
||||
cl.clean_cell(rec.get("full_name"))
|
||||
if not code:
|
||||
entry["skipped"] += 1
|
||||
report["skipped"] += 1
|
||||
continue
|
||||
row = to_model_row(rec, category_id, sheet, series_value)
|
||||
if not row["model_code"]:
|
||||
row["model_code"] = code
|
||||
_insert(conn, "model", row)
|
||||
export["models"].append(row)
|
||||
report["models"] += 1
|
||||
entry["imported"] += 1
|
||||
# 年代逻辑校验 -> 待复核
|
||||
if (row["first_year"] and row["last_year"]
|
||||
and row["first_year"] > row["last_year"]):
|
||||
report["review"].append(
|
||||
f"{sheet} / {code}: 首产年 {row['first_year']} > 停产年 {row['last_year']}")
|
||||
report["sheets"].append(entry)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with open(JSON_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(export, f, ensure_ascii=False, indent=2)
|
||||
_write_report(report)
|
||||
return report
|
||||
|
||||
|
||||
def _write_report(report):
|
||||
lines = ["# ETL 导入报告\n",
|
||||
f"- 车型(Model):**{report['models']}**",
|
||||
f"- 个体(Unit):**{report['units']}**",
|
||||
f"- 跳过(无主键):**{report['skipped']}**",
|
||||
f"- 待人工复核:**{len(report['review'])}**\n",
|
||||
"## 分表明细\n",
|
||||
"| 分类表 | 分类 | 粒度 | 数据行 | 入库 | 跳过 | 备注 |",
|
||||
"|---|---|---|---|---|---|---|"]
|
||||
for s in report["sheets"]:
|
||||
lines.append(f"| {s['sheet']} | {s['category']} | {s['grain']} | "
|
||||
f"{s['rows']} | {s['imported']} | {s['skipped']} | {s['note']} |")
|
||||
if report["review"]:
|
||||
lines.append("\n## 待人工复核\n")
|
||||
for r in report["review"]:
|
||||
lines.append(f"- {r}")
|
||||
with open(REPORT_PATH, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
rep = import_all()
|
||||
print(f"导入完成:Model={rep['models']} Unit={rep['units']} "
|
||||
f"跳过={rep['skipped']} 待复核={len(rep['review'])}")
|
||||
print(f"输出:\n {DB_PATH}\n {JSON_PATH}\n {REPORT_PATH}")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,77 @@
|
||||
-- 中国机车图鉴 · 数据底座 schema(SQLite,设计为可移植到 PostgreSQL)
|
||||
-- 对应任务 T-1.1。三层模型:Category -> Model -> Unit,加 Model 关系表。
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS category (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
subcat TEXT DEFAULT '',
|
||||
slug TEXT,
|
||||
UNIQUE(name, subcat)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model (
|
||||
id INTEGER PRIMARY KEY,
|
||||
category_id INTEGER NOT NULL REFERENCES category(id),
|
||||
series TEXT DEFAULT '',
|
||||
model_code TEXT NOT NULL,
|
||||
full_name TEXT DEFAULT '',
|
||||
aliases TEXT DEFAULT '',
|
||||
manufacturer TEXT DEFAULT '',
|
||||
country TEXT DEFAULT '中国',
|
||||
country_type TEXT DEFAULT '国产', -- 国产/进口/引进仿制/中外合资/未知
|
||||
first_year INTEGER,
|
||||
last_year INTEGER,
|
||||
status TEXT DEFAULT '未知',
|
||||
usage TEXT DEFAULT '',
|
||||
production_count TEXT DEFAULT '',
|
||||
axle_arrangement TEXT DEFAULT '',
|
||||
drive TEXT DEFAULT '',
|
||||
efficiency TEXT DEFAULT '',
|
||||
-- 数值 + 单位(拆分存储)
|
||||
length_value REAL, length_unit TEXT,
|
||||
width_value REAL, width_unit TEXT,
|
||||
height_value REAL, height_unit TEXT,
|
||||
wheelbase_value REAL, wheelbase_unit TEXT,
|
||||
weight_value REAL, weight_unit TEXT,
|
||||
axle_load_value REAL, axle_load_unit TEXT,
|
||||
load_value REAL, load_unit TEXT,
|
||||
tractive_start_value REAL, tractive_start_unit TEXT,
|
||||
tractive_cont_value REAL, tractive_cont_unit TEXT,
|
||||
power_kw_value REAL, power_kw_unit TEXT,
|
||||
max_speed_value REAL, max_speed_unit TEXT,
|
||||
capacity_value REAL, capacity_unit TEXT,
|
||||
raw_json TEXT, -- 原始清洗后所有列,保真不丢字段
|
||||
source_sheet TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_category ON model(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_code ON model(model_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_first_year ON model(first_year);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS unit (
|
||||
id INTEGER PRIMARY KEY,
|
||||
category_id INTEGER NOT NULL REFERENCES category(id),
|
||||
model_id INTEGER REFERENCES model(id),
|
||||
car_number TEXT,
|
||||
model_name TEXT DEFAULT '',
|
||||
function TEXT DEFAULT '',
|
||||
depot TEXT DEFAULT '',
|
||||
livery TEXT DEFAULT '',
|
||||
status TEXT DEFAULT '未知',
|
||||
location TEXT DEFAULT '',
|
||||
note TEXT DEFAULT '',
|
||||
raw_json TEXT,
|
||||
source_sheet TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_unit_category ON unit(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_unit_car_number ON unit(car_number);
|
||||
|
||||
-- 车型族谱关系(父型号/衍生/国外原型),本期预留,后续填充
|
||||
CREATE TABLE IF NOT EXISTS model_relation (
|
||||
id INTEGER PRIMARY KEY,
|
||||
from_model_id INTEGER NOT NULL REFERENCES model(id),
|
||||
to_model_id INTEGER NOT NULL REFERENCES model(id),
|
||||
rel_type TEXT NOT NULL, -- parent / derivative / prototype
|
||||
UNIQUE(from_model_id, to_model_id, rel_type)
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
"""清洗规则单元测试 — 对应 T-1.2 UT。"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from app.etl import clean as cl
|
||||
|
||||
|
||||
class TestCleanCell(unittest.TestCase):
|
||||
def test_strip_newlines_and_spaces(self):
|
||||
self.assertEqual(cl.clean_cell("东\n\n\n风"), "东风")
|
||||
self.assertEqual(cl.clean_cell(" HXD1 型 "), "HXD1 型")
|
||||
|
||||
def test_none(self):
|
||||
self.assertEqual(cl.clean_cell(None), "")
|
||||
|
||||
def test_is_empty(self):
|
||||
for v in ["", "-", "——", "—", "/", None, "无"]:
|
||||
self.assertTrue(cl.is_empty(v), v)
|
||||
self.assertFalse(cl.is_empty("现役"))
|
||||
|
||||
|
||||
class TestParseValueUnit(unittest.TestCase):
|
||||
def test_plain_number(self):
|
||||
v, u, _ = cl.parse_value_unit("126.0", "t")
|
||||
self.assertEqual(v, 126.0)
|
||||
self.assertEqual(u, "t")
|
||||
|
||||
def test_with_unit_in_text(self):
|
||||
v, u, _ = cl.parse_value_unit("400km/h(试验)")
|
||||
self.assertEqual(v, 400.0)
|
||||
self.assertEqual(u, "km/h")
|
||||
|
||||
def test_speed_with_default_unit(self):
|
||||
v, u, _ = cl.parse_value_unit("160km/h", "km/h")
|
||||
self.assertEqual(v, 160.0)
|
||||
self.assertEqual(u, "km/h")
|
||||
|
||||
def test_axle_load_with_paren(self):
|
||||
v, u, _ = cl.parse_value_unit("23(25)", "t")
|
||||
self.assertEqual(v, 23.0)
|
||||
self.assertEqual(u, "t")
|
||||
|
||||
def test_composite_takes_first_number(self):
|
||||
v, u, _ = cl.parse_value_unit("2×92(100)", "t")
|
||||
self.assertEqual(v, 2.0) # 取第一个数值,原文保真在 raw_json
|
||||
|
||||
def test_empty_markers(self):
|
||||
for raw in ["——", "-", "", "/"]:
|
||||
v, u, txt = cl.parse_value_unit(raw, "t")
|
||||
self.assertIsNone(v)
|
||||
self.assertEqual(u, "")
|
||||
|
||||
def test_no_number(self):
|
||||
v, u, txt = cl.parse_value_unit("交-直-交传动", "")
|
||||
self.assertIsNone(v)
|
||||
self.assertEqual(txt, "交-直-交传动")
|
||||
|
||||
|
||||
class TestParseYear(unittest.TestCase):
|
||||
def test_year_with_char(self):
|
||||
self.assertEqual(cl.parse_year("1971 年"), 1971)
|
||||
|
||||
def test_year_datetime(self):
|
||||
self.assertEqual(cl.parse_year("2007-12-22 00:00:00"), 2007)
|
||||
|
||||
def test_year_plain(self):
|
||||
self.assertEqual(cl.parse_year("2006"), 2006)
|
||||
|
||||
def test_year_dashes(self):
|
||||
self.assertIsNone(cl.parse_year("——"))
|
||||
self.assertIsNone(cl.parse_year(""))
|
||||
|
||||
|
||||
class TestNormalizeStatus(unittest.TestCase):
|
||||
def test_mapping(self):
|
||||
self.assertEqual(cl.normalize_status("半封存"), "半封存")
|
||||
self.assertEqual(cl.normalize_status("封存"), "封存")
|
||||
self.assertEqual(cl.normalize_status("已淘汰"), "退役")
|
||||
self.assertEqual(cl.normalize_status("样车"), "试验")
|
||||
self.assertEqual(cl.normalize_status(""), "未知")
|
||||
|
||||
|
||||
class TestInferCountryType(unittest.TestCase):
|
||||
def test_default_domestic(self):
|
||||
self.assertEqual(cl.infer_country_type("大连机车车辆厂"), "国产")
|
||||
|
||||
def test_import(self):
|
||||
self.assertEqual(cl.infer_country_type("苏联引进"), "引进仿制")
|
||||
self.assertEqual(cl.infer_country_type("日本制造"), "进口")
|
||||
|
||||
def test_joint(self):
|
||||
self.assertEqual(cl.infer_country_type("中外合资生产"), "中外合资")
|
||||
|
||||
|
||||
class TestForwardFill(unittest.TestCase):
|
||||
def test_fill(self):
|
||||
self.assertEqual(
|
||||
cl.forward_fill(["东风", "", "", "韶山", ""]),
|
||||
["东风", "东风", "东风", "韶山", "韶山"])
|
||||
|
||||
def test_leading_empty(self):
|
||||
self.assertEqual(cl.forward_fill(["", "A"]), ["", "A"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""字段字典/映射单元测试 — 对应 T-1.1 UT。"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from app.etl import field_dict as fd
|
||||
|
||||
|
||||
class TestHeaderMapping(unittest.TestCase):
|
||||
def test_basic(self):
|
||||
self.assertEqual(fd.map_header("型号"), "model_code")
|
||||
self.assertEqual(fd.map_header("车号"), "car_number")
|
||||
self.assertEqual(fd.map_header("系列"), "series")
|
||||
|
||||
def test_with_spaces_and_newlines(self):
|
||||
self.assertEqual(fd.map_header("车体长度 /mm"), "length")
|
||||
self.assertEqual(fd.map_header("牵引力 (起动)/kN"), "tractive_start")
|
||||
self.assertEqual(fd.map_header("最高运营时速"), "max_speed")
|
||||
|
||||
def test_unknown(self):
|
||||
self.assertIsNone(fd.map_header("不存在的列"))
|
||||
|
||||
def test_normalize(self):
|
||||
self.assertEqual(fd.normalize_header("车体长度 /mm"), "车体长度/mm")
|
||||
self.assertEqual(fd.normalize_header("东\n风"), "东风")
|
||||
|
||||
|
||||
class TestConfig(unittest.TestCase):
|
||||
def test_all_12_sheets_configured(self):
|
||||
self.assertEqual(len(fd.CATEGORY_CONFIG), 12)
|
||||
|
||||
def test_grain_values(self):
|
||||
for cfg in fd.CATEGORY_CONFIG.values():
|
||||
self.assertIn(cfg["grain"], ("model", "unit"))
|
||||
|
||||
def test_numeric_fields_have_synonyms(self):
|
||||
for field in fd.NUMERIC_UNIT_FIELDS:
|
||||
self.assertIn(field, fd.FIELD_SYNONYMS, field)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,64 @@
|
||||
"""导入管线集成/幂等性测试 — 对应 T-1.2 UT/E2E。"""
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
|
||||
from app.etl import importer
|
||||
|
||||
|
||||
class TestHeaderDetection(unittest.TestCase):
|
||||
def test_find_header_row_skips_title(self):
|
||||
rows = [["全国电力机车型号表"], [], ["型号", "首产时间", "停产时间", "生产商"]]
|
||||
self.assertEqual(importer.find_header_row(rows), 2)
|
||||
|
||||
def test_build_column_map(self):
|
||||
header = ["系列", "型号", "首产时间", "未知列X"]
|
||||
cmap = importer.build_column_map(header)
|
||||
self.assertEqual(cmap[0], "series")
|
||||
self.assertEqual(cmap[1], "model_code")
|
||||
self.assertEqual(cmap[2], "first_year")
|
||||
self.assertTrue(cmap[3].startswith("raw::"))
|
||||
|
||||
|
||||
class TestFullImport(unittest.TestCase):
|
||||
"""跑真实 12 表导入,断言结果与幂等性。"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.mkdtemp()
|
||||
self.db = os.path.join(self.tmp, "t.db")
|
||||
|
||||
def test_import_and_idempotent(self):
|
||||
if not os.path.isdir(importer.CSV_DIR):
|
||||
self.skipTest("CSV 目录不存在")
|
||||
r1 = importer.import_all(db_path=self.db)
|
||||
self.assertGreater(r1["models"], 0)
|
||||
self.assertGreater(r1["units"], 0)
|
||||
conn = sqlite3.connect(self.db)
|
||||
n1 = conn.execute("SELECT COUNT(*) FROM model").fetchone()[0]
|
||||
conn.close()
|
||||
# 再次导入:应重建而非翻倍(幂等)
|
||||
importer.import_all(db_path=self.db)
|
||||
conn = sqlite3.connect(self.db)
|
||||
n2 = conn.execute("SELECT COUNT(*) FROM model").fetchone()[0]
|
||||
conn.close()
|
||||
self.assertEqual(n1, n2)
|
||||
|
||||
def test_numeric_split_persisted(self):
|
||||
if not os.path.isdir(importer.CSV_DIR):
|
||||
self.skipTest("CSV 目录不存在")
|
||||
importer.import_all(db_path=self.db)
|
||||
conn = sqlite3.connect(self.db)
|
||||
row = conn.execute(
|
||||
"SELECT max_speed_value, max_speed_unit FROM model "
|
||||
"WHERE max_speed_value IS NOT NULL LIMIT 1").fetchone()
|
||||
conn.close()
|
||||
self.assertIsNotNone(row)
|
||||
self.assertIsInstance(row[0], float)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,5 @@
|
||||
# 复制为 .env 并填入你的密钥(AI 识车需要)
|
||||
# 通义千问 DashScope API Key:https://dashscope.console.aliyun.com/
|
||||
DASHSCOPE_API_KEY=
|
||||
# 可选:视觉模型,默认 qwen-vl-plus(也可用 qwen-vl-max)
|
||||
# DASHSCOPE_VL_MODEL=qwen-vl-plus
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,39 @@
|
||||
// 简易性能基准 — 对应 T-1.8 性能验收。
|
||||
// 用法:先启动 API(node dist/main.js),再 `node bench.mjs`
|
||||
const BASE = process.env.BENCH_BASE || 'http://127.0.0.1:3001';
|
||||
const N = Number(process.env.BENCH_N || 200);
|
||||
|
||||
function pct(arr, p) {
|
||||
const s = [...arr].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||
}
|
||||
|
||||
async function bench(name, path, threshold) {
|
||||
const lat = [];
|
||||
// 预热
|
||||
for (let i = 0; i < 5; i++) await fetch(`${BASE}${path}`);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t = performance.now();
|
||||
const res = await fetch(`${BASE}${path}`);
|
||||
await res.text();
|
||||
lat.push(performance.now() - t);
|
||||
}
|
||||
const p50 = pct(lat, 50);
|
||||
const p95 = pct(lat, 95);
|
||||
const ok = p95 < threshold;
|
||||
console.log(
|
||||
`${ok ? '✓' : '✗'} ${name.padEnd(16)} p50=${p50.toFixed(1)}ms ` +
|
||||
`p95=${p95.toFixed(1)}ms (阈值 ${threshold}ms)`,
|
||||
);
|
||||
return ok;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
results.push(await bench('list', '/api/models?pageSize=24', 1500));
|
||||
results.push(await bench('list+filter', '/api/models?category=动车组&sort=max_speed_value&order=desc', 1500));
|
||||
results.push(await bench('detail', '/api/models/1', 1500));
|
||||
results.push(await bench('search', '/api/search?q=HXD', 500));
|
||||
|
||||
const allOk = results.every(Boolean);
|
||||
console.log(`\n基准结果:${allOk ? '全部达标 ✓' : '存在未达标项 ✗'}(每项 ${N} 次请求)`);
|
||||
process.exit(allOk ? 0 : 1);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@train/api",
|
||||
"version": "0.1.0",
|
||||
"description": "中国机车图鉴 后端 API (NestJS)",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"fetch-images": "node scripts/fetch-images.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.4",
|
||||
"@nestjs/core": "^10.4.4",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^10.4.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/schematics": "^10.1.4",
|
||||
"@nestjs/testing": "^10.4.4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 批量取图脚本(在有网络的本机运行):
|
||||
* 按 Wikidata P18 → Commons 同名分类 → Commons 关键词搜索 的顺序,
|
||||
* 为每个车型下载若干张真实照片到「本地共享图库」(uploads + photos 表),
|
||||
* 并记录署名(作者/许可证/来源)。下载后应用即从本地提供图片,运行时不再访问 Commons。
|
||||
*
|
||||
* 用法(在 apps/api 目录):
|
||||
* node scripts/fetch-images.mjs # 全部缺图车型,每个 1 张
|
||||
* node scripts/fetch-images.mjs --per 3 # 每个车型最多 3 张
|
||||
* node scripts/fetch-images.mjs --limit 20 # 仅处理前 20 个缺图车型
|
||||
* node scripts/fetch-images.mjs --category 电力机车
|
||||
* node scripts/fetch-images.mjs --dry # 只检索不下载不入库
|
||||
*
|
||||
* 所有下载图片一律入库为「候选(candidate)」,进入管理员「候选审图」队列,
|
||||
* 由人工确认后才会作为封面/图册展示(候选 + 人工确认 双闸门,保证准确与合规)。
|
||||
*
|
||||
* 注意:需联网访问 wikidata.org / commons.wikimedia.org(自由授权,已记录署名)。
|
||||
* 下载完成后图片保存在本地 app/data/uploads/,库表记录在 app/data/app.db。
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join, resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..', '..'); // Train/
|
||||
const MACHINES_DB = join(ROOT, 'app', 'data', 'machines.db');
|
||||
const APP_DB = process.env.APP_DB_PATH || join(ROOT, 'app', 'data', 'app.db');
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || join(ROOT, 'app', 'data', 'uploads');
|
||||
const UA = 'ChinaLocoAtlas/0.1 (image fetcher; contact admin)';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k) => {
|
||||
const i = args.indexOf(k);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
};
|
||||
const LIMIT = opt('--limit') ? Number(opt('--limit')) : Infinity;
|
||||
const PER = opt('--per') ? Math.max(1, Number(opt('--per'))) : 1;
|
||||
const CATEGORY = opt('--category');
|
||||
const DRY = args.includes('--dry');
|
||||
const STATUS = 'candidate'; // 一律入候选,交管理员「候选审图」确认
|
||||
|
||||
const CAT_HINT = {
|
||||
蒸汽机车: 'steam locomotive',
|
||||
电力机车: 'electric locomotive',
|
||||
内燃机车: 'diesel locomotive',
|
||||
动车组: 'EMU',
|
||||
客车: 'passenger car',
|
||||
货车: 'freight car',
|
||||
检测车: 'inspection car',
|
||||
旅游列车: 'tourist train',
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const latin = (s) => (s.match(/[A-Za-z0-9-]+/g) || []).join('');
|
||||
const strip = (h) => (h || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||
const isImg = (s) => /\.(jpe?g|png)$/i.test(s || '');
|
||||
|
||||
async function api(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 从 Commons File:xxx 取缩略图 url + 署名。*/
|
||||
async function commonsFileInfo(title) {
|
||||
const u =
|
||||
'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*' +
|
||||
`&titles=${encodeURIComponent(title)}&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=1280`;
|
||||
const d = await api(u);
|
||||
const pages = d?.query?.pages || {};
|
||||
const p = Object.values(pages)[0];
|
||||
const ii = p?.imageinfo?.[0];
|
||||
if (!ii?.thumburl) return null;
|
||||
const em = ii.extmetadata || {};
|
||||
return {
|
||||
url: ii.thumburl,
|
||||
descriptionUrl: ii.descriptionurl || '',
|
||||
author: strip(em.Artist?.value) || '未署名',
|
||||
license: strip(em.LicenseShortName?.value) || '见来源',
|
||||
};
|
||||
}
|
||||
|
||||
/** 1) Wikidata P18 主图(最多 1 张,最准)。*/
|
||||
async function viaWikidata(model) {
|
||||
const q = `${latin(model.model_code) || model.model_code} ${CAT_HINT[model.category] || 'train'}`;
|
||||
const s = await api(
|
||||
'https://www.wikidata.org/w/api.php?action=wbsearchentities&format=json&origin=*' +
|
||||
`&language=en&type=item&limit=3&search=${encodeURIComponent(q)}`,
|
||||
);
|
||||
for (const hit of s?.search || []) {
|
||||
const e = await api(
|
||||
'https://www.wikidata.org/w/api.php?action=wbgetentities&format=json&origin=*' +
|
||||
`&props=claims&ids=${hit.id}`,
|
||||
);
|
||||
const p18 = e?.entities?.[hit.id]?.claims?.P18?.[0]?.mainsnak?.datavalue?.value;
|
||||
if (p18) {
|
||||
const info = await commonsFileInfo('File:' + p18);
|
||||
if (info) return [{ ...info, via: 'wikidata' }];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 2) Commons 同名分类(可多张)。*/
|
||||
async function viaCategory(model, want) {
|
||||
const code = latin(model.model_code) || model.model_code;
|
||||
const out = [];
|
||||
for (const cat of [`Category:China Railways ${code}`, `Category:${code}`]) {
|
||||
const d = await api(
|
||||
'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*' +
|
||||
`&list=categorymembers&cmtype=file&cmlimit=20&cmtitle=${encodeURIComponent(cat)}`,
|
||||
);
|
||||
for (const f of d?.query?.categorymembers || []) {
|
||||
if (!isImg(f.title)) continue;
|
||||
const info = await commonsFileInfo(f.title);
|
||||
if (info) out.push({ ...info, via: 'category' });
|
||||
if (out.length >= want) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 3) Commons 关键词搜索(最不准,兜底,可多张)。*/
|
||||
async function viaSearch(model, want) {
|
||||
const q = `${latin(model.model_code) || model.model_code} ${CAT_HINT[model.category] || 'train'} China Railway`;
|
||||
const d = await api(
|
||||
'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*' +
|
||||
`&generator=search&gsrnamespace=6&gsrlimit=${Math.max(3, want)}&gsrsearch=${encodeURIComponent(q)}` +
|
||||
'&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=1280',
|
||||
);
|
||||
const pages = d?.query?.pages || {};
|
||||
const out = [];
|
||||
for (const p of Object.values(pages)) {
|
||||
const ii = p?.imageinfo?.[0];
|
||||
if (!ii?.thumburl || !isImg(ii.thumburl)) continue;
|
||||
const em = ii.extmetadata || {};
|
||||
out.push({
|
||||
url: ii.thumburl,
|
||||
descriptionUrl: ii.descriptionurl || '',
|
||||
author: strip(em.Artist?.value) || '未署名',
|
||||
license: strip(em.LicenseShortName?.value) || '见来源',
|
||||
via: 'search',
|
||||
});
|
||||
if (out.length >= want) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 聚合各来源,按 url 去重,返回最多 want 张。*/
|
||||
async function collectImages(model, want) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
const push = (arr) => {
|
||||
for (const it of arr) {
|
||||
if (out.length >= want) break;
|
||||
if (seen.has(it.url)) continue;
|
||||
seen.add(it.url);
|
||||
out.push(it);
|
||||
}
|
||||
};
|
||||
push(await viaWikidata(model));
|
||||
if (out.length < want) push(await viaCategory(model, want - out.length));
|
||||
if (out.length < want) push(await viaSearch(model, want - out.length));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const mdb = new Database(MACHINES_DB, { readonly: true });
|
||||
const adb = new Database(APP_DB);
|
||||
|
||||
const admin =
|
||||
adb.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get() ||
|
||||
adb.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get();
|
||||
if (!admin) {
|
||||
console.error('找不到用户:请先启动后端注册一个管理员(或设 ADMIN_EMAIL)。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const where = CATEGORY ? 'WHERE c.name = ?' : '';
|
||||
const models = mdb
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id ${where}
|
||||
ORDER BY m.id`,
|
||||
)
|
||||
.all(...(CATEGORY ? [CATEGORY] : []));
|
||||
|
||||
const hasPhoto = adb.prepare('SELECT 1 FROM photos WHERE model_id = ? LIMIT 1');
|
||||
const insert = adb.prepare(
|
||||
`INSERT INTO photos (model_id, uploader_id, filename, caption, status, source_url, author, license)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`取图:每车型最多 ${PER} 张,入库状态=候选(candidate)${DRY ? '(dry-run)' : ''};保存到 ${UPLOAD_DIR}`,
|
||||
);
|
||||
|
||||
const report = [];
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
for (const m of models) {
|
||||
if (done >= LIMIT) break;
|
||||
if (hasPhoto.get(m.id)) continue;
|
||||
done++;
|
||||
let hits = [];
|
||||
try {
|
||||
hits = await collectImages(m, PER);
|
||||
} catch (e) {
|
||||
report.push(`✗ ${m.model_code}:检索出错 ${e.message}`);
|
||||
await sleep(300);
|
||||
continue;
|
||||
}
|
||||
if (hits.length === 0) {
|
||||
report.push(`— ${m.model_code}:未找到`);
|
||||
await sleep(200);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
report.push(`✓ ${m.model_code}:[dry] ${hits.map((h) => h.via).join(',')} (${hits.length} 张)`);
|
||||
await sleep(200);
|
||||
continue;
|
||||
}
|
||||
let n = 0;
|
||||
for (const hit of hits) {
|
||||
try {
|
||||
const buf = Buffer.from(
|
||||
await (await fetch(hit.url, { headers: { 'User-Agent': UA } })).arrayBuffer(),
|
||||
);
|
||||
const ext = (hit.url.match(/\.(jpe?g|png)/i) || ['.jpg'])[0].toLowerCase();
|
||||
const fn = `${Date.now()}-${randomBytes(6).toString('hex')}${ext}`;
|
||||
await writeFile(join(UPLOAD_DIR, fn), buf);
|
||||
insert.run(m.id, admin.id, fn, `${m.model_code}(${hit.via})`, STATUS, hit.descriptionUrl, hit.author, hit.license);
|
||||
n++;
|
||||
saved++;
|
||||
} catch (e) {
|
||||
report.push(`✗ ${m.model_code}:下载失败 ${e.message}`);
|
||||
}
|
||||
await sleep(300); // 礼貌限速
|
||||
}
|
||||
if (n > 0) report.push(`✓ ${m.model_code}:入库 ${n} 张(${STATUS})`);
|
||||
}
|
||||
|
||||
const ok = report.filter((r) => r.startsWith('✓')).length;
|
||||
const miss = report.filter((r) => r.startsWith('—')).length;
|
||||
const err = report.filter((r) => r.startsWith('✗')).length;
|
||||
console.log(report.join('\n'));
|
||||
console.log(`\n命中 ${ok} · 未找到 ${miss} · 出错 ${err}(处理 ${done} 个车型,保存 ${saved} 张)`);
|
||||
console.log('候选图已入库,请用管理员在网页"候选审图 / 图册"逐个确认后作为封面展示。');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { WritableDbModule } from './db/writable-db.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { ModelsModule } from './models/models.module';
|
||||
import { UnitsModule } from './units/units.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ContribModule } from './contrib/contrib.module';
|
||||
import { CommunityModule } from './community/community.module';
|
||||
import { ForumModule } from './forum/forum.module';
|
||||
import { SightingsModule } from './sightings/sightings.module';
|
||||
import { StatsModule } from './stats/stats.module';
|
||||
import { PhotosModule } from './photos/photos.module';
|
||||
import { IdentifyModule } from './identify/identify.module';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
WritableDbModule,
|
||||
AuthModule,
|
||||
CategoriesModule,
|
||||
ModelsModule,
|
||||
UnitsModule,
|
||||
SearchModule,
|
||||
ContribModule,
|
||||
CommunityModule,
|
||||
ForumModule,
|
||||
SightingsModule,
|
||||
StatsModule,
|
||||
PhotosModule,
|
||||
IdentifyModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto, RegisterDto } from './dto';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from './roles';
|
||||
|
||||
@Controller('api/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.auth.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: JwtPayload) {
|
||||
return this.auth.me(user.sub);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard, RolesGuard } from './roles';
|
||||
|
||||
export const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: JWT_SECRET,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtAuthGuard, RolesGuard],
|
||||
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/** AuthService 单元测试 — 对应 T-2.1 UT。*/
|
||||
function makeService() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
contribution_points INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);`);
|
||||
const jwt = new JwtService({ secret: 'test-secret', signOptions: { expiresIn: '1h' } });
|
||||
const svc = new AuthService({ db } as any, jwt);
|
||||
return { svc, jwt, db };
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('注册返回 token 与用户,密码不外泄', async () => {
|
||||
const { svc } = makeService();
|
||||
const { token, user } = await svc.register({
|
||||
email: 'A@Example.com',
|
||||
password: 'secret123',
|
||||
displayName: '老司机',
|
||||
});
|
||||
expect(token).toBeTruthy();
|
||||
expect(user.email).toBe('a@example.com'); // 规范化小写
|
||||
expect(user.role).toBe('user');
|
||||
expect((user as any).password_hash).toBeUndefined();
|
||||
});
|
||||
|
||||
it('重复邮箱注册抛 409', async () => {
|
||||
const { svc } = makeService();
|
||||
const dto = { email: 'b@x.com', password: 'secret123', displayName: '阿强' };
|
||||
await svc.register(dto);
|
||||
await expect(svc.register(dto)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('登录校验密码,错误抛 401', async () => {
|
||||
const { svc } = makeService();
|
||||
await svc.register({ email: 'c@x.com', password: 'secret123', displayName: 'C' });
|
||||
const ok = await svc.login({ email: 'c@x.com', password: 'secret123' });
|
||||
expect(ok.token).toBeTruthy();
|
||||
await expect(
|
||||
svc.login({ email: 'c@x.com', password: 'wrongpass' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('签发的 token 可被验证且含角色', async () => {
|
||||
const { svc, jwt } = makeService();
|
||||
const { token } = await svc.register({
|
||||
email: 'd@x.com',
|
||||
password: 'secret123',
|
||||
displayName: 'D',
|
||||
});
|
||||
const payload = jwt.verify(token);
|
||||
expect(payload.email).toBe('d@x.com');
|
||||
expect(payload.role).toBe('user');
|
||||
expect(payload.sub).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
OnModuleInit,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { LoginDto, RegisterDto } from './dto';
|
||||
import type { JwtPayload, Role } from './roles';
|
||||
import { badgesFor, levelFor, type Badge, type LevelInfo } from '../community/levels';
|
||||
|
||||
export interface PublicUser {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
contributionPoints: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserStats extends PublicUser {
|
||||
level: LevelInfo;
|
||||
approvedCount: number;
|
||||
badges: Badge[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly database: WritableDbService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
/** 开机预置测试账号,便于演示/测试(生产可用 SEED_TEST_USERS=false 关闭)。*/
|
||||
onModuleInit() {
|
||||
if (process.env.SEED_TEST_USERS === 'false') return;
|
||||
const accounts: { email: string; password: string; displayName: string; role: Role }[] = [
|
||||
{ email: 'admin@demo.com', password: 'demo1234', displayName: '演示管理员', role: 'admin' },
|
||||
{ email: 'mod@demo.com', password: 'demo1234', displayName: '演示版主', role: 'moderator' },
|
||||
{ email: 'user@demo.com', password: 'demo1234', displayName: '演示用户', role: 'user' },
|
||||
];
|
||||
const db = this.database.db;
|
||||
for (const a of accounts) {
|
||||
const email = a.email.toLowerCase();
|
||||
const existing: any = db
|
||||
.prepare('SELECT id, role FROM users WHERE email = ?')
|
||||
.get(email);
|
||||
if (existing) {
|
||||
if (existing.role !== a.role) {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(a.role, existing.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO users (email, password_hash, display_name, role)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
).run(email, bcrypt.hashSync(a.password, 10), a.displayName, a.role);
|
||||
}
|
||||
}
|
||||
|
||||
private toPublic(row: any): PublicUser {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
displayName: row.display_name,
|
||||
role: row.role,
|
||||
contributionPoints: row.contribution_points,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private sign(user: PublicUser): string {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
return this.jwt.sign(payload);
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto): Promise<{ token: string; user: PublicUser }> {
|
||||
const db = this.database.db;
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const exists = db.prepare('SELECT 1 FROM users WHERE email = ?').get(email);
|
||||
if (exists) throw new ConflictException('该邮箱已注册');
|
||||
const hash = await bcrypt.hash(dto.password, 10);
|
||||
const adminEmail = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
|
||||
const role: Role = adminEmail && email === adminEmail ? 'admin' : 'user';
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO users (email, password_hash, display_name, role)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(email, hash, dto.displayName.trim(), role);
|
||||
const row = db
|
||||
.prepare('SELECT * FROM users WHERE id = ?')
|
||||
.get(info.lastInsertRowid);
|
||||
const user = this.toPublic(row);
|
||||
return { token: this.sign(user), user };
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<{ token: string; user: PublicUser }> {
|
||||
const db = this.database.db;
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const row: any = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||
if (!row) throw new UnauthorizedException('邮箱或密码错误');
|
||||
const ok = await bcrypt.compare(dto.password, row.password_hash);
|
||||
if (!ok) throw new UnauthorizedException('邮箱或密码错误');
|
||||
const user = this.toPublic(row);
|
||||
return { token: this.sign(user), user };
|
||||
}
|
||||
|
||||
me(userId: number): UserStats {
|
||||
const row = this.database.db
|
||||
.prepare('SELECT * FROM users WHERE id = ?')
|
||||
.get(userId);
|
||||
if (!row) throw new UnauthorizedException('用户不存在');
|
||||
const user = this.toPublic(row);
|
||||
const approvedCount = (
|
||||
this.database.db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS n FROM revisions WHERE author_id = ? AND status = 'approved'",
|
||||
)
|
||||
.get(userId) as { n: number }
|
||||
).n;
|
||||
return {
|
||||
...user,
|
||||
level: levelFor(user.contributionPoints),
|
||||
approvedCount,
|
||||
badges: badgesFor(user.contributionPoints, approvedCount),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6, { message: '密码至少 6 位' })
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2, { message: '昵称至少 2 个字符' })
|
||||
@MaxLength(30)
|
||||
displayName!: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
export type Role = 'guest' | 'user' | 'trusted' | 'moderator' | 'admin';
|
||||
|
||||
/** 角色等级,用于"至少某角色"的比较。*/
|
||||
export const ROLE_RANK: Record<Role, number> = {
|
||||
guest: 0,
|
||||
user: 1,
|
||||
trusted: 2,
|
||||
moderator: 3,
|
||||
admin: 4,
|
||||
};
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
email: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
/** 取当前登录用户(来自 JwtAuthGuard 注入的 req.user)。*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext) =>
|
||||
ctx.switchToHttp().getRequest().user as JwtPayload,
|
||||
);
|
||||
|
||||
function extractToken(req: any): string | null {
|
||||
const h = req.headers?.authorization;
|
||||
if (typeof h === 'string' && h.startsWith('Bearer ')) return h.slice(7);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly jwt: JwtService) {}
|
||||
|
||||
canActivate(ctx: ExecutionContext): boolean {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
const token = extractToken(req);
|
||||
if (!token) throw new UnauthorizedException('需要登录');
|
||||
try {
|
||||
req.user = this.jwt.verify<JwtPayload>(token);
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('登录已失效');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 需要"至少"指定角色之一(按等级比较)。配合 @Roles 与 JwtAuthGuard 使用。*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(ctx: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
ctx.getHandler(),
|
||||
ctx.getClass(),
|
||||
]);
|
||||
if (!required || required.length === 0) return true;
|
||||
const user = ctx.switchToHttp().getRequest().user as JwtPayload | undefined;
|
||||
if (!user) throw new UnauthorizedException('需要登录');
|
||||
const min = Math.min(...required.map((r) => ROLE_RANK[r]));
|
||||
if (ROLE_RANK[user.role] < min) {
|
||||
throw new ForbiddenException('权限不足');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Controller('api/categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categories: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.categories.findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
/** 分类列表 + 各分类车型/个体计数。*/
|
||||
findAll(): any[] {
|
||||
return this.database.db
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.subcat,
|
||||
(SELECT COUNT(*) FROM model m WHERE m.category_id = c.id) AS modelCount,
|
||||
(SELECT COUNT(*) FROM unit u WHERE u.category_id = c.id) AS unitCount
|
||||
FROM category c
|
||||
ORDER BY c.name, c.subcat`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommunityService } from './community.service';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
|
||||
|
||||
@Controller('api')
|
||||
export class CommunityController {
|
||||
constructor(private readonly community: CommunityService) {}
|
||||
|
||||
@Get('leaderboard')
|
||||
leaderboard(@Query('limit') limit?: string) {
|
||||
const n = limit ? Math.min(100, Math.max(1, Number(limit))) : 20;
|
||||
return this.community.leaderboard(n);
|
||||
}
|
||||
|
||||
@Get('models/:id/maintainers')
|
||||
maintainers(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.community.maintainers(id);
|
||||
}
|
||||
|
||||
@Post('models/:id/maintainers')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
claim(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.community.claim(id, user.sub);
|
||||
}
|
||||
|
||||
@Delete('models/:id/maintainers')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
unclaim(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.community.unclaim(id, user.sub);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommunityService } from './community.service';
|
||||
import { CommunityController } from './community.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [CommunityController],
|
||||
providers: [CommunityService],
|
||||
})
|
||||
export class CommunityModule {}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { levelFor } from './levels';
|
||||
|
||||
@Injectable()
|
||||
export class CommunityService {
|
||||
constructor(private readonly wdb: WritableDbService) {}
|
||||
|
||||
/** 贡献榜(公开):按积分降序。*/
|
||||
leaderboard(limit = 20) {
|
||||
const rows = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT id, display_name, role, contribution_points
|
||||
FROM users WHERE contribution_points > 0
|
||||
ORDER BY contribution_points DESC, id ASC LIMIT ?`,
|
||||
)
|
||||
.all(limit) as any[];
|
||||
return rows.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
id: r.id,
|
||||
displayName: r.display_name,
|
||||
role: r.role,
|
||||
points: r.contribution_points,
|
||||
title: levelFor(r.contribution_points).title,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 某车型的维护者署名列表。*/
|
||||
maintainers(modelId: number) {
|
||||
return this.wdb.db
|
||||
.prepare(
|
||||
`SELECT u.id, u.display_name AS displayName, u.role, m.created_at AS since
|
||||
FROM model_maintainers m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.model_id = ? ORDER BY m.created_at ASC`,
|
||||
)
|
||||
.all(modelId);
|
||||
}
|
||||
|
||||
claim(modelId: number, userId: number) {
|
||||
this.wdb.db
|
||||
.prepare(
|
||||
`INSERT INTO model_maintainers (model_id, user_id) VALUES (?, ?)
|
||||
ON CONFLICT(model_id, user_id) DO NOTHING`,
|
||||
)
|
||||
.run(modelId, userId);
|
||||
return this.maintainers(modelId);
|
||||
}
|
||||
|
||||
unclaim(modelId: number, userId: number) {
|
||||
this.wdb.db
|
||||
.prepare('DELETE FROM model_maintainers WHERE model_id = ? AND user_id = ?')
|
||||
.run(modelId, userId);
|
||||
return this.maintainers(modelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { badgesFor, levelFor } from './levels';
|
||||
|
||||
describe('levelFor', () => {
|
||||
it('0 分为见习巡道员', () => {
|
||||
const l = levelFor(0);
|
||||
expect(l.title).toBe('见习巡道员');
|
||||
expect(l.level).toBe(1);
|
||||
expect(l.nextThreshold).toBe(10);
|
||||
});
|
||||
it('跨阈值升级', () => {
|
||||
expect(levelFor(30).title).toBe('司炉');
|
||||
expect(levelFor(159).title).toBe('副司机');
|
||||
expect(levelFor(160).title).toBe('司机');
|
||||
});
|
||||
it('满级 nextThreshold 为 null', () => {
|
||||
const l = levelFor(1000);
|
||||
expect(l.title).toBe('总工程师');
|
||||
expect(l.nextThreshold).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('badgesFor', () => {
|
||||
it('按积分/采纳数派发', () => {
|
||||
expect(badgesFor(0, 0)).toHaveLength(0);
|
||||
expect(badgesFor(5, 1).map((b) => b.key)).toContain('first_edit');
|
||||
expect(badgesFor(200, 12).map((b) => b.key)).toEqual(
|
||||
expect.arrayContaining(['first_edit', 'prolific', 'veteran']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** 铁路主题等级(按贡献积分)。纯函数,便于单测。*/
|
||||
export interface LevelInfo {
|
||||
level: number;
|
||||
title: string;
|
||||
min: number;
|
||||
nextThreshold: number | null; // 升到下一级所需积分;满级为 null
|
||||
}
|
||||
|
||||
export const LEVELS: { min: number; title: string }[] = [
|
||||
{ min: 0, title: '见习巡道员' },
|
||||
{ min: 10, title: '巡道员' },
|
||||
{ min: 30, title: '司炉' },
|
||||
{ min: 80, title: '副司机' },
|
||||
{ min: 160, title: '司机' },
|
||||
{ min: 320, title: '机务段长' },
|
||||
{ min: 640, title: '总工程师' },
|
||||
];
|
||||
|
||||
export function levelFor(points: number): LevelInfo {
|
||||
const p = Math.max(0, points || 0);
|
||||
let idx = 0;
|
||||
for (let i = 0; i < LEVELS.length; i++) if (p >= LEVELS[i].min) idx = i;
|
||||
const next = LEVELS[idx + 1] ?? null;
|
||||
return {
|
||||
level: idx + 1,
|
||||
title: LEVELS[idx].title,
|
||||
min: LEVELS[idx].min,
|
||||
nextThreshold: next ? next.min : null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface Badge {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 根据积分与被采纳修订数派生徽章。*/
|
||||
export function badgesFor(points: number, approvedCount: number): Badge[] {
|
||||
const out: Badge[] = [];
|
||||
if (approvedCount >= 1) out.push({ key: 'first_edit', label: '首改采纳' });
|
||||
if (approvedCount >= 10) out.push({ key: 'prolific', label: '高产编辑' });
|
||||
if (points >= 160) out.push({ key: 'veteran', label: '资深贡献' });
|
||||
if (points >= 640) out.push({ key: 'chief', label: '总工殊荣' });
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { RevisionsService } from './revisions.service';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtAuthGuard,
|
||||
Roles,
|
||||
RolesGuard,
|
||||
type JwtPayload,
|
||||
} from '../auth/roles';
|
||||
|
||||
class SubmitEditDto {
|
||||
@IsObject()
|
||||
changes!: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** 编辑/修订(登录用户)。*/
|
||||
@Controller('api/models/:id/revisions')
|
||||
export class ModelRevisionsController {
|
||||
constructor(private readonly revisions: RevisionsService) {}
|
||||
|
||||
@Get()
|
||||
history(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.revisions.listForModel(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
submit(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: SubmitEditDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
return this.revisions.submitEdit(id, user, dto.changes, dto.note ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核(版主及以上)+ 可编辑字段元数据。*/
|
||||
@Controller('api')
|
||||
export class ReviewController {
|
||||
constructor(private readonly revisions: RevisionsService) {}
|
||||
|
||||
@Get('editable-fields')
|
||||
fields() {
|
||||
return this.revisions.editableFields();
|
||||
}
|
||||
|
||||
@Get('revisions/pending')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('moderator')
|
||||
pending() {
|
||||
return this.revisions.listPending();
|
||||
}
|
||||
|
||||
@Post('revisions/:rid/approve')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('moderator')
|
||||
approve(@Param('rid', ParseIntPipe) rid: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.revisions.approve(rid, user);
|
||||
}
|
||||
|
||||
@Post('revisions/:rid/reject')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('moderator')
|
||||
reject(@Param('rid', ParseIntPipe) rid: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.revisions.reject(rid, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RevisionsService } from './revisions.service';
|
||||
import { ModelRevisionsController, ReviewController } from './contrib.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ModelRevisionsController, ReviewController],
|
||||
providers: [RevisionsService],
|
||||
exports: [RevisionsService],
|
||||
})
|
||||
export class ContribModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export type FieldType = 'text' | 'int';
|
||||
|
||||
export interface EditableField {
|
||||
field: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
}
|
||||
|
||||
/** 可众包编辑的字段白名单(映射到 model 表列)。*/
|
||||
export const EDITABLE_FIELDS: EditableField[] = [
|
||||
{ field: 'full_name', label: '车型全称', type: 'text' },
|
||||
{ field: 'series', label: '系列', type: 'text' },
|
||||
{ field: 'manufacturer', label: '生产商', type: 'text' },
|
||||
{ field: 'country', label: '制造国/地区', type: 'text' },
|
||||
{ field: 'country_type', label: '国别属性', type: 'text' },
|
||||
{ field: 'usage', label: '用途', type: 'text' },
|
||||
{ field: 'status', label: '状态', type: 'text' },
|
||||
{ field: 'drive', label: '传动/供电方式', type: 'text' },
|
||||
{ field: 'efficiency', label: '传动效率', type: 'text' },
|
||||
{ field: 'axle_arrangement', label: '轴列式', type: 'text' },
|
||||
{ field: 'production_count', label: '产量', type: 'text' },
|
||||
{ field: 'first_year', label: '首产年', type: 'int' },
|
||||
{ field: 'last_year', label: '停产年', type: 'int' },
|
||||
{ field: 'max_speed_value', label: '最高时速(值)', type: 'int' },
|
||||
{ field: 'weight_value', label: '整备重量(值)', type: 'int' },
|
||||
];
|
||||
|
||||
export const EDITABLE_MAP = new Map(EDITABLE_FIELDS.map((f) => [f.field, f]));
|
||||
|
||||
export const STATUS_ENUM = [
|
||||
'现役', '封存', '半封存', '退役', '报废', '保存', '试验', '未知',
|
||||
];
|
||||
export const COUNTRY_TYPE_ENUM = ['国产', '进口', '引进仿制', '中外合资', '未知'];
|
||||
|
||||
/** 校验并规范化单个字段值,返回入库用字符串;非法抛 400。*/
|
||||
export function validateField(field: string, raw: unknown): string {
|
||||
const def = EDITABLE_MAP.get(field);
|
||||
if (!def) throw new BadRequestException(`字段不可编辑: ${field}`);
|
||||
const s = String(raw ?? '').trim();
|
||||
|
||||
if (def.type === 'int') {
|
||||
if (s === '') return '';
|
||||
if (!/^-?\d+$/.test(s)) throw new BadRequestException(`${def.label} 必须为整数`);
|
||||
const n = Number(s);
|
||||
if (field === 'first_year' || field === 'last_year') {
|
||||
if (n < 1800 || n > 2100)
|
||||
throw new BadRequestException(`${def.label} 年代超出合理范围`);
|
||||
}
|
||||
if (n < 0) throw new BadRequestException(`${def.label} 不能为负`);
|
||||
return String(n);
|
||||
}
|
||||
|
||||
if (s.length > 200) throw new BadRequestException(`${def.label} 过长`);
|
||||
if (field === 'status' && s && !STATUS_ENUM.includes(s))
|
||||
throw new BadRequestException(`状态取值非法`);
|
||||
if (field === 'country_type' && s && !COUNTRY_TYPE_ENUM.includes(s))
|
||||
throw new BadRequestException(`国别属性取值非法`);
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { ROLE_RANK, type JwtPayload } from '../auth/roles';
|
||||
import { validateField, EDITABLE_FIELDS } from './editable-fields';
|
||||
|
||||
const POINTS_PER_REVISION = 5;
|
||||
|
||||
@Injectable()
|
||||
export class RevisionsService {
|
||||
constructor(
|
||||
private readonly ro: DatabaseService,
|
||||
private readonly wdb: WritableDbService,
|
||||
) {}
|
||||
|
||||
/** 某车型字段的当前生效值(覆盖优先于基础数据),字符串化。*/
|
||||
private effectiveValue(modelId: number, field: string, baseRow: any): string {
|
||||
const ov = this.wdb.db
|
||||
.prepare('SELECT value FROM model_overrides WHERE model_id = ? AND field = ?')
|
||||
.get(modelId, field) as { value: string } | undefined;
|
||||
if (ov) return ov.value ?? '';
|
||||
const v = baseRow?.[field];
|
||||
return v == null ? '' : String(v);
|
||||
}
|
||||
|
||||
private baseRow(modelId: number): any {
|
||||
const row = this.ro.db.prepare('SELECT * FROM model WHERE id = ?').get(modelId);
|
||||
if (!row) throw new NotFoundException(`车型 ${modelId} 不存在`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 提交编辑:生成修订。信任用户及以上自动通过并应用。*/
|
||||
submitEdit(
|
||||
modelId: number,
|
||||
user: JwtPayload,
|
||||
changes: Record<string, unknown>,
|
||||
note = '',
|
||||
) {
|
||||
const base = this.baseRow(modelId);
|
||||
const entries = Object.entries(changes || {});
|
||||
if (entries.length === 0) throw new BadRequestException('没有任何改动');
|
||||
|
||||
const diffs: { field: string; oldValue: string; newValue: string }[] = [];
|
||||
for (const [field, raw] of entries) {
|
||||
const newValue = validateField(field, raw);
|
||||
const oldValue = this.effectiveValue(modelId, field, base);
|
||||
if (newValue !== oldValue) diffs.push({ field, oldValue, newValue });
|
||||
}
|
||||
if (diffs.length === 0) throw new BadRequestException('与当前值相同,无需提交');
|
||||
|
||||
const autoApprove = ROLE_RANK[user.role] >= ROLE_RANK.trusted;
|
||||
const db = this.wdb.db;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO revisions (model_id, author_id, status, note, reviewed_by, reviewed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
modelId,
|
||||
user.sub,
|
||||
autoApprove ? 'approved' : 'pending',
|
||||
note,
|
||||
autoApprove ? user.sub : null,
|
||||
autoApprove ? new Date().toISOString() : null,
|
||||
);
|
||||
const revId = Number(info.lastInsertRowid);
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO revision_changes (revision_id, field, old_value, new_value)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
);
|
||||
for (const d of diffs) ins.run(revId, d.field, d.oldValue, d.newValue);
|
||||
if (autoApprove) {
|
||||
this.applyChanges(modelId, diffs, revId);
|
||||
this.award(user.sub);
|
||||
}
|
||||
return revId;
|
||||
});
|
||||
|
||||
const revId = tx();
|
||||
return this.getRevision(revId);
|
||||
}
|
||||
|
||||
private applyChanges(
|
||||
modelId: number,
|
||||
diffs: { field: string; newValue: string }[],
|
||||
revId: number,
|
||||
) {
|
||||
const up = this.wdb.db.prepare(
|
||||
`INSERT INTO model_overrides (model_id, field, value, revision_id, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(model_id, field) DO UPDATE SET
|
||||
value = excluded.value, revision_id = excluded.revision_id, updated_at = datetime('now')`,
|
||||
);
|
||||
for (const d of diffs) up.run(modelId, d.field, d.newValue, revId);
|
||||
}
|
||||
|
||||
private award(userId: number, points = POINTS_PER_REVISION) {
|
||||
this.wdb.db
|
||||
.prepare('UPDATE users SET contribution_points = contribution_points + ? WHERE id = ?')
|
||||
.run(points, userId);
|
||||
}
|
||||
|
||||
approve(revId: number, reviewer: JwtPayload) {
|
||||
const db = this.wdb.db;
|
||||
const rev: any = db.prepare('SELECT * FROM revisions WHERE id = ?').get(revId);
|
||||
if (!rev) throw new NotFoundException('修订不存在');
|
||||
if (rev.status !== 'pending') throw new BadRequestException('该修订已处理');
|
||||
const changes = db
|
||||
.prepare('SELECT field, new_value AS newValue FROM revision_changes WHERE revision_id = ?')
|
||||
.all(revId) as { field: string; newValue: string }[];
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(
|
||||
`UPDATE revisions SET status='approved', reviewed_by=?, reviewed_at=datetime('now') WHERE id=?`,
|
||||
).run(reviewer.sub, revId);
|
||||
this.applyChanges(rev.model_id, changes, revId);
|
||||
this.award(rev.author_id);
|
||||
});
|
||||
tx();
|
||||
return this.getRevision(revId);
|
||||
}
|
||||
|
||||
reject(revId: number, reviewer: JwtPayload) {
|
||||
const db = this.wdb.db;
|
||||
const rev: any = db.prepare('SELECT * FROM revisions WHERE id = ?').get(revId);
|
||||
if (!rev) throw new NotFoundException('修订不存在');
|
||||
if (rev.status !== 'pending') throw new BadRequestException('该修订已处理');
|
||||
db.prepare(
|
||||
`UPDATE revisions SET status='rejected', reviewed_by=?, reviewed_at=datetime('now') WHERE id=?`,
|
||||
).run(reviewer.sub, revId);
|
||||
return this.getRevision(revId);
|
||||
}
|
||||
|
||||
getRevision(revId: number) {
|
||||
const db = this.wdb.db;
|
||||
const rev: any = db
|
||||
.prepare(
|
||||
`SELECT r.*, u.display_name AS author_name
|
||||
FROM revisions r JOIN users u ON u.id = r.author_id WHERE r.id = ?`,
|
||||
)
|
||||
.get(revId);
|
||||
if (!rev) throw new NotFoundException('修订不存在');
|
||||
rev.changes = db
|
||||
.prepare('SELECT field, old_value, new_value FROM revision_changes WHERE revision_id = ?')
|
||||
.all(revId);
|
||||
return rev;
|
||||
}
|
||||
|
||||
listForModel(modelId: number) {
|
||||
const db = this.wdb.db;
|
||||
const revs = db
|
||||
.prepare(
|
||||
`SELECT r.*, u.display_name AS author_name
|
||||
FROM revisions r JOIN users u ON u.id = r.author_id
|
||||
WHERE r.model_id = ? ORDER BY r.created_at DESC, r.id DESC`,
|
||||
)
|
||||
.all(modelId) as any[];
|
||||
const stmt = db.prepare(
|
||||
'SELECT field, old_value, new_value FROM revision_changes WHERE revision_id = ?',
|
||||
);
|
||||
for (const r of revs) r.changes = stmt.all(r.id);
|
||||
return revs;
|
||||
}
|
||||
|
||||
listPending() {
|
||||
const db = this.wdb.db;
|
||||
const revs = db
|
||||
.prepare(
|
||||
`SELECT r.*, u.display_name AS author_name
|
||||
FROM revisions r JOIN users u ON u.id = r.author_id
|
||||
WHERE r.status = 'pending' ORDER BY r.created_at ASC`,
|
||||
)
|
||||
.all() as any[];
|
||||
const stmt = db.prepare(
|
||||
'SELECT field, old_value, new_value FROM revision_changes WHERE revision_id = ?',
|
||||
);
|
||||
for (const r of revs) r.changes = stmt.all(r.id);
|
||||
return revs;
|
||||
}
|
||||
|
||||
/** 供 ModelsService 合并:返回 model 的字段覆盖 map。*/
|
||||
overridesFor(modelId: number): Record<string, string> {
|
||||
const rows = this.wdb.db
|
||||
.prepare('SELECT field, value FROM model_overrides WHERE model_id = ?')
|
||||
.all(modelId) as { field: string; value: string }[];
|
||||
const out: Record<string, string> = {};
|
||||
for (const r of rows) out[r.field] = r.value;
|
||||
return out;
|
||||
}
|
||||
|
||||
editableFields() {
|
||||
return EDITABLE_FIELDS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { DatabaseService } from './database.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DatabaseService],
|
||||
exports: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
/**
|
||||
* 打开 ETL 产出的 SQLite 数据库(app/data/machines.db)。
|
||||
* 开发期数据底座;schema 设计为可移植到 PostgreSQL。
|
||||
* DB 路径可通过环境变量 DB_PATH 覆盖(测试用)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(DatabaseService.name);
|
||||
public readonly db: Database.Database;
|
||||
|
||||
constructor() {
|
||||
const dbPath =
|
||||
process.env.DB_PATH ||
|
||||
resolve(join(__dirname, '..', '..', '..', '..', 'app', 'data', 'machines.db'));
|
||||
if (!existsSync(dbPath)) {
|
||||
this.logger.warn(
|
||||
`数据库不存在: ${dbPath} —— 请先运行 ETL: python3 -m app.etl.importer`,
|
||||
);
|
||||
}
|
||||
this.db = new Database(dbPath, { readonly: true, fileMustExist: false });
|
||||
// 只读连接无需(也不允许)设置 WAL;容错跳过。
|
||||
try {
|
||||
this.db.pragma('query_only = ON');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { WritableDbService } from './writable-db.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [WritableDbService],
|
||||
exports: [WritableDbService],
|
||||
})
|
||||
export class WritableDbModule {}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
|
||||
/**
|
||||
* 可写库(用户 / 社区数据)。与只读的 machines.db 分离——后者由 ETL 重建会被清空。
|
||||
* 默认 app/data/app.db,可用 APP_DB_PATH 覆盖(测试用)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class WritableDbService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(WritableDbService.name);
|
||||
public readonly db: Database.Database;
|
||||
|
||||
constructor() {
|
||||
const dbPath =
|
||||
process.env.APP_DB_PATH ||
|
||||
resolve(join(__dirname, '..', '..', '..', '..', 'app', 'data', 'app.db'));
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
this.migrate();
|
||||
this.logger.log(`可写库就绪: ${dbPath}`);
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
contribution_points INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_id INTEGER NOT NULL,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected
|
||||
note TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reviewed_by INTEGER REFERENCES users(id),
|
||||
reviewed_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rev_model ON revisions(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rev_status ON revisions(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revision_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_revchg_rev ON revision_changes(revision_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_overrides (
|
||||
model_id INTEGER NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT,
|
||||
revision_id INTEGER REFERENCES revisions(id),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (model_id, field)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_maintainers (
|
||||
model_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (model_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
board TEXT NOT NULL, -- general/photography/history/entry
|
||||
model_id INTEGER, -- 非空表示绑定到某车型(词条讨论)
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
author_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_activity_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reply_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_board ON threads(board);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_model ON threads(model_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_replies_thread ON thread_replies(thread_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sightings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
car_number TEXT DEFAULT '',
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
station TEXT DEFAULT '',
|
||||
spotted_at TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sightings_model ON sightings(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sightings_created ON sightings(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_id INTEGER NOT NULL,
|
||||
uploader_id INTEGER NOT NULL REFERENCES users(id),
|
||||
filename TEXT NOT NULL,
|
||||
caption TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'confirmed',
|
||||
source_url TEXT DEFAULT '',
|
||||
author TEXT DEFAULT '',
|
||||
license TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_model ON photos(model_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
filename TEXT NOT NULL,
|
||||
image_hash TEXT NOT NULL,
|
||||
summary TEXT DEFAULT '',
|
||||
guesses_json TEXT DEFAULT '[]',
|
||||
matches_json TEXT DEFAULT '[]',
|
||||
top_code TEXT DEFAULT '',
|
||||
top_name TEXT DEFAULT '',
|
||||
note TEXT DEFAULT '',
|
||||
error TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ident_user ON identifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ident_hash ON identifications(image_hash);
|
||||
`);
|
||||
// 已存在的库补齐新列(无 IF NOT EXISTS,需手动检查)
|
||||
this.ensureColumns('photos', {
|
||||
status: "TEXT NOT NULL DEFAULT 'confirmed'",
|
||||
source_url: "TEXT DEFAULT ''",
|
||||
author: "TEXT DEFAULT ''",
|
||||
license: "TEXT DEFAULT ''",
|
||||
featured: 'INTEGER NOT NULL DEFAULT 0',
|
||||
});
|
||||
}
|
||||
|
||||
private ensureColumns(table: string, cols: Record<string, string>) {
|
||||
const existing = new Set(
|
||||
(this.db.prepare(`PRAGMA table_info(${table})`).all() as any[]).map(
|
||||
(c) => c.name,
|
||||
),
|
||||
);
|
||||
for (const [name, def] of Object.entries(cols)) {
|
||||
if (!existing.has(name)) {
|
||||
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${def}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ForumService } from './forum.service';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
|
||||
|
||||
class CreateThreadDto {
|
||||
@IsString() @MinLength(1) @MaxLength(40)
|
||||
board!: string;
|
||||
|
||||
@IsOptional() @Type(() => Number) @IsInt()
|
||||
modelId?: number;
|
||||
|
||||
@IsString() @MinLength(2) @MaxLength(80)
|
||||
title!: string;
|
||||
|
||||
@IsString() @MinLength(1) @MaxLength(5000)
|
||||
body!: string;
|
||||
}
|
||||
|
||||
class ReplyDto {
|
||||
@IsString() @MinLength(1) @MaxLength(5000)
|
||||
body!: string;
|
||||
}
|
||||
|
||||
@Controller('api')
|
||||
export class ForumController {
|
||||
constructor(private readonly forum: ForumService) {}
|
||||
|
||||
@Get('boards')
|
||||
boards() {
|
||||
return this.forum.boards();
|
||||
}
|
||||
|
||||
@Get('threads')
|
||||
list(
|
||||
@Query('board') board?: string,
|
||||
@Query('modelId') modelId?: string,
|
||||
) {
|
||||
return this.forum.listThreads({
|
||||
board,
|
||||
modelId: modelId ? Number(modelId) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('threads/:id')
|
||||
get(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.forum.getThread(id);
|
||||
}
|
||||
|
||||
@Post('threads')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
create(@Body() dto: CreateThreadDto, @CurrentUser() user: JwtPayload) {
|
||||
return this.forum.createThread(user.sub, dto);
|
||||
}
|
||||
|
||||
@Post('threads/:id/replies')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
reply(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: ReplyDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
return this.forum.addReply(user.sub, id, dto.body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ForumService } from './forum.service';
|
||||
import { ForumController } from './forum.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ForumController],
|
||||
providers: [ForumService],
|
||||
})
|
||||
export class ForumModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
|
||||
export const BOARDS = [
|
||||
{ key: 'general', label: '综合讨论' },
|
||||
{ key: 'photography', label: '拍车 / 打卡' },
|
||||
{ key: 'history', label: '历史考证' },
|
||||
];
|
||||
const BOARD_KEYS = new Set([...BOARDS.map((b) => b.key), 'entry']);
|
||||
|
||||
@Injectable()
|
||||
export class ForumService {
|
||||
constructor(private readonly wdb: WritableDbService) {}
|
||||
|
||||
boards() {
|
||||
return BOARDS;
|
||||
}
|
||||
|
||||
listThreads(opts: { board?: string; modelId?: number; limit?: number }) {
|
||||
const conds: string[] = [];
|
||||
const params: any[] = [];
|
||||
if (opts.modelId != null) {
|
||||
conds.push('t.model_id = ?');
|
||||
params.push(opts.modelId);
|
||||
} else if (opts.board) {
|
||||
conds.push('t.board = ?');
|
||||
params.push(opts.board);
|
||||
}
|
||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const limit = Math.min(100, Math.max(1, opts.limit ?? 50));
|
||||
return this.wdb.db
|
||||
.prepare(
|
||||
`SELECT t.id, t.board, t.model_id, t.title, t.author_id,
|
||||
u.display_name AS author_name, t.created_at,
|
||||
t.last_activity_at, t.reply_count
|
||||
FROM threads t JOIN users u ON u.id = t.author_id
|
||||
${where}
|
||||
ORDER BY t.last_activity_at DESC, t.id DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(...params, limit);
|
||||
}
|
||||
|
||||
createThread(
|
||||
userId: number,
|
||||
data: { board: string; modelId?: number | null; title: string; body: string },
|
||||
) {
|
||||
const board = BOARD_KEYS.has(data.board) ? data.board : 'general';
|
||||
const info = this.wdb.db
|
||||
.prepare(
|
||||
`INSERT INTO threads (board, model_id, title, body, author_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(board, data.modelId ?? null, data.title.trim(), data.body.trim(), userId);
|
||||
return this.getThread(Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
getThread(id: number) {
|
||||
const t: any = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT t.*, u.display_name AS author_name
|
||||
FROM threads t JOIN users u ON u.id = t.author_id WHERE t.id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
if (!t) throw new NotFoundException('帖子不存在');
|
||||
t.replies = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT r.id, r.body, r.author_id, u.display_name AS author_name, r.created_at
|
||||
FROM thread_replies r JOIN users u ON u.id = r.author_id
|
||||
WHERE r.thread_id = ? ORDER BY r.created_at ASC, r.id ASC`,
|
||||
)
|
||||
.all(id);
|
||||
return t;
|
||||
}
|
||||
|
||||
addReply(userId: number, threadId: number, body: string) {
|
||||
const db = this.wdb.db;
|
||||
const exists = db.prepare('SELECT 1 FROM threads WHERE id = ?').get(threadId);
|
||||
if (!exists) throw new NotFoundException('帖子不存在');
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(
|
||||
'INSERT INTO thread_replies (thread_id, author_id, body) VALUES (?, ?, ?)',
|
||||
).run(threadId, userId, body.trim());
|
||||
db.prepare(
|
||||
`UPDATE threads SET reply_count = reply_count + 1,
|
||||
last_activity_at = datetime('now') WHERE id = ?`,
|
||||
).run(threadId);
|
||||
});
|
||||
tx();
|
||||
return this.getThread(threadId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { DatabaseService } from './db/database.service';
|
||||
|
||||
@Controller('api/health')
|
||||
export class HealthController {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
@Get()
|
||||
health() {
|
||||
const models = (
|
||||
this.database.db.prepare('SELECT COUNT(*) AS n FROM model').get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
const units = (
|
||||
this.database.db.prepare('SELECT COUNT(*) AS n FROM unit').get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
return { status: 'ok', models, units };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { IdentifyService } from './identify.service';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
|
||||
|
||||
const ALLOWED = /\.(jpe?g|png|webp|gif)$/i;
|
||||
|
||||
@Controller('api')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class IdentifyController {
|
||||
constructor(private readonly svc: IdentifyService) {}
|
||||
|
||||
/** AI 识车:上传一张照片,调用通义千问视觉模型识别并持久化(命中哈希缓存则复用)。*/
|
||||
@Post('identify')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) =>
|
||||
ALLOWED.test(file.originalname) || /^image\//.test(file.mimetype)
|
||||
? cb(null, true)
|
||||
: cb(new BadRequestException('仅支持图片文件'), false),
|
||||
}),
|
||||
)
|
||||
async identify(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('未收到图片');
|
||||
return this.svc.identifyAndSave(user.sub, file.buffer, file.mimetype);
|
||||
}
|
||||
|
||||
/** 当前用户的识别历史。*/
|
||||
@Get('identifications')
|
||||
list(@CurrentUser() user: JwtPayload) {
|
||||
return this.svc.list(user.sub);
|
||||
}
|
||||
|
||||
/** 修改备注(人工纠正/标注)。*/
|
||||
@Patch('identifications/:id')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body('note') note: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
return this.svc.updateNote(user.sub, id, note ?? '');
|
||||
}
|
||||
|
||||
/** 删除一条识别历史。*/
|
||||
@Delete('identifications/:id')
|
||||
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.svc.remove(user.sub, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { IdentifyController } from './identify.controller';
|
||||
import { IdentifyService } from './identify.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [IdentifyController],
|
||||
providers: [IdentifyService],
|
||||
})
|
||||
export class IdentifyModule {}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { unlinkSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { PUBLIC_PREFIX, uploadDir } from '../photos/uploads';
|
||||
|
||||
/** DashScope(阿里云·通义千问)OpenAI 兼容多模态接口。*/
|
||||
const ENDPOINT =
|
||||
process.env.DASHSCOPE_ENDPOINT ||
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
|
||||
const MODEL = process.env.DASHSCOPE_VL_MODEL || 'qwen-vl-plus';
|
||||
|
||||
export interface Guess {
|
||||
model_code: string;
|
||||
name: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
export interface ModelMatch {
|
||||
id: number;
|
||||
model_code: string;
|
||||
category: string;
|
||||
}
|
||||
export interface Identification {
|
||||
id: number;
|
||||
url: string;
|
||||
summary: string;
|
||||
guesses: Guess[];
|
||||
matches: ModelMatch[];
|
||||
topCode: string;
|
||||
topName: string;
|
||||
note: string;
|
||||
error: string;
|
||||
cached: boolean;
|
||||
configured: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const EXT: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class IdentifyService {
|
||||
private readonly logger = new Logger(IdentifyService.name);
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly wdb: WritableDbService,
|
||||
) {}
|
||||
|
||||
/** 识别并持久化:命中图片哈希缓存则复用结果(不再调用 LLM)。*/
|
||||
async identifyAndSave(
|
||||
userId: number,
|
||||
buffer: Buffer,
|
||||
mimetype: string,
|
||||
): Promise<Identification> {
|
||||
const hash = createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
// 缓存:同一张图(哈希一致)此前已成功识别 → 复用结果与图片文件
|
||||
const cachedRow: any = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT * FROM identifications
|
||||
WHERE image_hash = ? AND guesses_json <> '[]' AND error = ''
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
)
|
||||
.get(hash);
|
||||
|
||||
let filename: string;
|
||||
let summary = '';
|
||||
let guesses: Guess[] = [];
|
||||
let matches: ModelMatch[] = [];
|
||||
let error = '';
|
||||
let cached = false;
|
||||
let configured = true;
|
||||
|
||||
if (cachedRow) {
|
||||
cached = true;
|
||||
filename = cachedRow.filename;
|
||||
summary = cachedRow.summary || '';
|
||||
guesses = JSON.parse(cachedRow.guesses_json || '[]');
|
||||
matches = JSON.parse(cachedRow.matches_json || '[]');
|
||||
} else {
|
||||
// 落盘图片
|
||||
const ext = EXT[mimetype] || '.jpg';
|
||||
filename = `idf-${Date.now()}-${randomBytes(5).toString('hex')}${ext}`;
|
||||
writeFileSync(join(uploadDir(), filename), buffer);
|
||||
|
||||
const dataUrl = `data:${mimetype};base64,${buffer.toString('base64')}`;
|
||||
const r = await this.runModel(dataUrl);
|
||||
configured = r.configured;
|
||||
error = r.error;
|
||||
summary = r.summary;
|
||||
guesses = r.guesses;
|
||||
matches = r.matches;
|
||||
}
|
||||
|
||||
const top = guesses[0];
|
||||
const info = this.wdb.db
|
||||
.prepare(
|
||||
`INSERT INTO identifications
|
||||
(user_id, filename, image_hash, summary, guesses_json, matches_json, top_code, top_name, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
filename,
|
||||
hash,
|
||||
summary,
|
||||
JSON.stringify(guesses),
|
||||
JSON.stringify(matches),
|
||||
top?.model_code ?? '',
|
||||
top?.name ?? '',
|
||||
error,
|
||||
);
|
||||
|
||||
const row: any = this.wdb.db
|
||||
.prepare('SELECT * FROM identifications WHERE id = ?')
|
||||
.get(Number(info.lastInsertRowid));
|
||||
return { ...this.toPublic(row), cached, configured };
|
||||
}
|
||||
|
||||
list(userId: number): Identification[] {
|
||||
const rows = this.wdb.db
|
||||
.prepare('SELECT * FROM identifications WHERE user_id = ? ORDER BY id DESC')
|
||||
.all(userId) as any[];
|
||||
return rows.map((r) => this.toPublic(r));
|
||||
}
|
||||
|
||||
updateNote(userId: number, id: number, note: string): Identification {
|
||||
const row: any = this.wdb.db
|
||||
.prepare('SELECT * FROM identifications WHERE id = ? AND user_id = ?')
|
||||
.get(id, userId);
|
||||
if (!row) throw new NotFoundException('记录不存在');
|
||||
this.wdb.db
|
||||
.prepare('UPDATE identifications SET note = ? WHERE id = ?')
|
||||
.run((note ?? '').slice(0, 500), id);
|
||||
return this.toPublic(
|
||||
this.wdb.db.prepare('SELECT * FROM identifications WHERE id = ?').get(id),
|
||||
);
|
||||
}
|
||||
|
||||
remove(userId: number, id: number): { ok: boolean } {
|
||||
const row: any = this.wdb.db
|
||||
.prepare('SELECT * FROM identifications WHERE id = ? AND user_id = ?')
|
||||
.get(id, userId);
|
||||
if (!row) throw new NotFoundException('记录不存在');
|
||||
this.wdb.db.prepare('DELETE FROM identifications WHERE id = ?').run(id);
|
||||
// 仅当没有其它记录共用该图片文件(缓存去重)时删除文件
|
||||
const others = this.wdb.db
|
||||
.prepare('SELECT 1 FROM identifications WHERE filename = ? LIMIT 1')
|
||||
.get(row.filename);
|
||||
if (!others) {
|
||||
try {
|
||||
unlinkSync(join(uploadDir(), row.filename));
|
||||
} catch {
|
||||
/* 文件可能已不存在 */
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private toPublic(row: any): Identification {
|
||||
return {
|
||||
id: row.id,
|
||||
url: `${PUBLIC_PREFIX}/${row.filename}`,
|
||||
summary: row.summary || '',
|
||||
guesses: JSON.parse(row.guesses_json || '[]'),
|
||||
matches: JSON.parse(row.matches_json || '[]'),
|
||||
topCode: row.top_code || '',
|
||||
topName: row.top_name || '',
|
||||
note: row.note || '',
|
||||
error: row.error || '',
|
||||
cached: false,
|
||||
configured: true,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** 调用通义千问视觉模型并解析候选 + 匹配图鉴库。*/
|
||||
private async runModel(dataUrl: string): Promise<{
|
||||
configured: boolean;
|
||||
summary: string;
|
||||
guesses: Guess[];
|
||||
matches: ModelMatch[];
|
||||
error: string;
|
||||
}> {
|
||||
const key = process.env.DASHSCOPE_API_KEY;
|
||||
if (!key) {
|
||||
return {
|
||||
configured: false,
|
||||
summary: '',
|
||||
guesses: [],
|
||||
matches: [],
|
||||
error:
|
||||
'AI 识图未配置:请在 apps/api/.env 设置 DASHSCOPE_API_KEY(通义千问 DashScope 密钥)后重启后端。',
|
||||
};
|
||||
}
|
||||
|
||||
const prompt =
|
||||
'这是一张中国铁路机车或动车组的照片。请作为资深铁路机车识别专家,判断它最可能是哪一(几)种车型。\n' +
|
||||
'只输出 JSON(不要任何额外文字、不要 markdown 代码块),格式:\n' +
|
||||
'{"summary":"一句话总体判断","guesses":[{"model_code":"型号代码,如 HXD1/CR400AF/DF4/SS8/前进型","name":"中文名称","confidence":0.0到1.0之间的数,"reason":"判断依据(车头/涂装/受电弓/轮式等外观特征)"}]}\n' +
|
||||
'最多给 3 个候选,按可能性从高到低排序。若无法判断也要给出最接近的猜测。';
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: dataUrl } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const j: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const m = j?.error?.message || j?.message || `HTTP ${res.status}`;
|
||||
return { configured: true, summary: '', guesses: [], matches: [], error: `AI 服务返回错误:${m}` };
|
||||
}
|
||||
const raw = j?.choices?.[0]?.message?.content;
|
||||
content = Array.isArray(raw)
|
||||
? raw.map((c: any) => c?.text || '').join('')
|
||||
: String(raw ?? '');
|
||||
} catch (e: any) {
|
||||
this.logger.error(`identify 调用失败: ${e?.message || e}`);
|
||||
return { configured: true, summary: '', guesses: [], matches: [], error: `调用 AI 服务失败:${e?.message || e}` };
|
||||
}
|
||||
|
||||
const parsed = this.parse(content);
|
||||
const matches = this.matchModels(parsed.guesses);
|
||||
return { configured: true, summary: parsed.summary, guesses: parsed.guesses, matches, error: '' };
|
||||
}
|
||||
|
||||
/** 从模型文本中解析 JSON(容错:截取首个 {...} 块)。*/
|
||||
private parse(text: string): { summary: string; guesses: Guess[] } {
|
||||
const block = text.match(/\{[\s\S]*\}/);
|
||||
if (block) {
|
||||
try {
|
||||
const o = JSON.parse(block[0]);
|
||||
const guesses: Guess[] = Array.isArray(o.guesses)
|
||||
? o.guesses.slice(0, 3).map((g: any) => ({
|
||||
model_code: String(g.model_code ?? g.code ?? '').trim(),
|
||||
name: String(g.name ?? '').trim(),
|
||||
confidence: Math.max(0, Math.min(1, Number(g.confidence) || 0)),
|
||||
reason: String(g.reason ?? '').trim(),
|
||||
}))
|
||||
: [];
|
||||
return { summary: String(o.summary ?? '').trim(), guesses };
|
||||
} catch {
|
||||
/* 解析失败则回退原文 */
|
||||
}
|
||||
}
|
||||
return { summary: text.trim().slice(0, 300), guesses: [] };
|
||||
}
|
||||
|
||||
/** 把 AI 猜测的型号匹配到图鉴库,给出可点击的详情链接。*/
|
||||
private matchModels(guesses: Guess[]): ModelMatch[] {
|
||||
const stmt = this.db.db.prepare(
|
||||
`SELECT m.id, m.model_code, c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
WHERE m.model_code LIKE ? OR m.full_name LIKE ? OR m.series LIKE ?
|
||||
LIMIT 4`,
|
||||
);
|
||||
const out = new Map<number, ModelMatch>();
|
||||
for (const g of guesses) {
|
||||
for (const raw of [g.model_code, g.name].filter(Boolean)) {
|
||||
const latin = (raw.match(/[A-Za-z0-9]+/g) || []).join('');
|
||||
const key = latin || raw;
|
||||
if (key.length < 2) continue;
|
||||
const rows = stmt.all(`%${key}%`, `%${key}%`, `%${key}%`) as ModelMatch[];
|
||||
for (const r of rows) if (!out.has(r.id)) out.set(r.id, r);
|
||||
}
|
||||
}
|
||||
return [...out.values()].slice(0, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import * as express from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { uploadDir, PUBLIC_PREFIX } from './photos/uploads';
|
||||
|
||||
// 加载 apps/api/.env(Node 20.12+/22 内置;缺失时忽略)
|
||||
try {
|
||||
(process as any).loadEnvFile?.();
|
||||
} catch {
|
||||
/* 没有 .env 文件时忽略 */
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: false,
|
||||
}),
|
||||
);
|
||||
app.enableCors();
|
||||
// 静态服务上传的图片(共享图库)
|
||||
app.use(PUBLIC_PREFIX, express.static(uploadDir()));
|
||||
const port = process.env.PORT || 3001;
|
||||
await app.listen(port);
|
||||
new Logger('Bootstrap').log(`API 已启动: http://localhost:${port}/api/health`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ModelsService } from './models.service';
|
||||
import { QueryModelsDto } from './query-models.dto';
|
||||
|
||||
@Controller('api/models')
|
||||
export class ModelsController {
|
||||
constructor(private readonly models: ModelsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: QueryModelsDto) {
|
||||
return this.models.list(query);
|
||||
}
|
||||
|
||||
@Get('families')
|
||||
families(@Query('category') category?: string) {
|
||||
return this.models.families(category);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.models.getById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModelsController } from './models.controller';
|
||||
import { ModelsService } from './models.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ModelsController],
|
||||
providers: [ModelsService],
|
||||
exports: [ModelsService],
|
||||
})
|
||||
export class ModelsModule {}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ModelsService } from './models.service';
|
||||
import { QueryModelsDto } from './query-models.dto';
|
||||
import { seedDatabase } from '../testing/seed';
|
||||
|
||||
/** ModelsService 单元测试 — 对应 T-1.3 UT。*/
|
||||
describe('ModelsService', () => {
|
||||
let service: ModelsService;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeAll(() => {
|
||||
db = new Database(':memory:');
|
||||
seedDatabase(db);
|
||||
const wdb = new Database(':memory:');
|
||||
wdb.exec(
|
||||
`CREATE TABLE model_overrides (model_id INTEGER, field TEXT, value TEXT,
|
||||
revision_id INTEGER, updated_at TEXT, PRIMARY KEY(model_id, field));
|
||||
CREATE TABLE photos (id INTEGER PRIMARY KEY AUTOINCREMENT, model_id INTEGER,
|
||||
uploader_id INTEGER, filename TEXT, caption TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'confirmed', source_url TEXT DEFAULT '',
|
||||
author TEXT DEFAULT '', license TEXT DEFAULT '',
|
||||
featured INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT '');`,
|
||||
);
|
||||
service = new ModelsService({ db } as any, { db: wdb } as any);
|
||||
});
|
||||
|
||||
afterAll(() => db.close());
|
||||
|
||||
const q = (over: Partial<QueryModelsDto> = {}): QueryModelsDto =>
|
||||
Object.assign(new QueryModelsDto(), over);
|
||||
|
||||
it('返回分页结构', () => {
|
||||
const res = service.list(q());
|
||||
expect(res.total).toBe(2);
|
||||
expect(res.page).toBe(1);
|
||||
expect(res.items.length).toBe(2);
|
||||
});
|
||||
|
||||
it('按分类名模糊筛选', () => {
|
||||
const res = service.list(q({ category: '电力' }));
|
||||
expect(res.total).toBe(1);
|
||||
expect(res.items[0].model_code).toBe('HXD1型');
|
||||
});
|
||||
|
||||
it('按年代区间筛选', () => {
|
||||
const res = service.list(q({ yearFrom: 2010 }));
|
||||
expect(res.total).toBe(1);
|
||||
expect(res.items[0].model_code).toBe('CR400AF');
|
||||
});
|
||||
|
||||
it('按时速区间筛选', () => {
|
||||
const res = service.list(q({ speedMin: 300 }));
|
||||
expect(res.total).toBe(1);
|
||||
expect(res.items[0].model_code).toBe('CR400AF');
|
||||
});
|
||||
|
||||
it('关键字搜索', () => {
|
||||
const res = service.list(q({ q: 'HXD' }));
|
||||
expect(res.total).toBe(1);
|
||||
});
|
||||
|
||||
it('排序:按时速降序', () => {
|
||||
const res = service.list(q({ sort: 'max_speed_value', order: 'desc' }));
|
||||
expect(res.items[0].model_code).toBe('CR400AF');
|
||||
});
|
||||
|
||||
it('空结果不报错', () => {
|
||||
const res = service.list(q({ manufacturer: '不存在' }));
|
||||
expect(res.total).toBe(0);
|
||||
expect(res.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('详情含解析后的 raw 字段', () => {
|
||||
const list = service.list(q({ category: '电力' }));
|
||||
const detail = service.getById(list.items[0].id);
|
||||
expect(detail.model_code).toBe('HXD1型');
|
||||
expect(detail.raw).toBeDefined();
|
||||
});
|
||||
|
||||
it('详情不存在抛 404', () => {
|
||||
expect(() => service.getById(99999)).toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { EDITABLE_MAP } from '../contrib/editable-fields';
|
||||
import { QueryModelsDto, SORTABLE_FIELDS } from './query-models.dto';
|
||||
|
||||
export interface PagedResult<T> {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ModelsService {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly wdb: WritableDbService,
|
||||
) {}
|
||||
|
||||
/** 构造 WHERE 子句与绑定参数(参数化,避免注入)。*/
|
||||
private buildWhere(q: QueryModelsDto): { clause: string; params: any[] } {
|
||||
const conds: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (q.category) {
|
||||
if (/^\d+$/.test(q.category)) {
|
||||
conds.push('m.category_id = ?');
|
||||
params.push(Number(q.category));
|
||||
} else {
|
||||
conds.push('c.name LIKE ?');
|
||||
params.push(`%${q.category}%`);
|
||||
}
|
||||
}
|
||||
if (q.yearFrom != null) {
|
||||
conds.push('m.first_year >= ?');
|
||||
params.push(q.yearFrom);
|
||||
}
|
||||
if (q.yearTo != null) {
|
||||
conds.push('m.first_year <= ?');
|
||||
params.push(q.yearTo);
|
||||
}
|
||||
if (q.speedMin != null) {
|
||||
conds.push('m.max_speed_value >= ?');
|
||||
params.push(q.speedMin);
|
||||
}
|
||||
if (q.speedMax != null) {
|
||||
conds.push('m.max_speed_value <= ?');
|
||||
params.push(q.speedMax);
|
||||
}
|
||||
if (q.manufacturer) {
|
||||
conds.push('m.manufacturer LIKE ?');
|
||||
params.push(`%${q.manufacturer}%`);
|
||||
}
|
||||
if (q.country) {
|
||||
conds.push('m.country_type = ?');
|
||||
params.push(q.country);
|
||||
}
|
||||
if (q.status) {
|
||||
conds.push('m.status = ?');
|
||||
params.push(q.status);
|
||||
}
|
||||
if (q.q) {
|
||||
conds.push('(m.model_code LIKE ? OR m.full_name LIKE ? OR m.series LIKE ?)');
|
||||
params.push(`%${q.q}%`, `%${q.q}%`, `%${q.q}%`);
|
||||
}
|
||||
const clause = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
return { clause, params };
|
||||
}
|
||||
|
||||
list(q: QueryModelsDto): PagedResult<any> {
|
||||
const { clause, params } = this.buildWhere(q);
|
||||
const db = this.database.db;
|
||||
|
||||
const total = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM model m
|
||||
JOIN category c ON c.id = m.category_id ${clause}`,
|
||||
)
|
||||
.get(...params) as { n: number }
|
||||
).n;
|
||||
|
||||
const sort = (SORTABLE_FIELDS as readonly string[]).includes(q.sort)
|
||||
? q.sort
|
||||
: 'first_year';
|
||||
const order = q.order === 'desc' ? 'DESC' : 'ASC';
|
||||
const offset = (q.page - 1) * q.pageSize;
|
||||
|
||||
const items = db
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, m.full_name, m.series, m.manufacturer,
|
||||
m.country, m.country_type, m.first_year, m.last_year, m.status,
|
||||
m.usage, m.max_speed_value, m.max_speed_unit,
|
||||
m.weight_value, m.weight_unit, m.axle_arrangement,
|
||||
c.name AS category, c.subcat
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
${clause}
|
||||
ORDER BY (m.${sort} IS NULL), m.${sort} ${order}, m.id ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, q.pageSize, offset) as any[];
|
||||
|
||||
// 附加共享图库封面(每个车型最早一张照片)
|
||||
if (items.length) {
|
||||
const ids = items.map((i) => i.id);
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const covers = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT p.model_id, p.filename FROM photos p
|
||||
WHERE p.status='confirmed' AND p.model_id IN (${ph})
|
||||
AND p.id = (SELECT p2.id FROM photos p2
|
||||
WHERE p2.model_id = p.model_id AND p2.status='confirmed'
|
||||
ORDER BY p2.featured DESC, p2.id ASC LIMIT 1)`,
|
||||
)
|
||||
.all(...ids) as { model_id: number; filename: string }[];
|
||||
const map = new Map(covers.map((c) => [c.model_id, c.filename]));
|
||||
for (const it of items) {
|
||||
const f = map.get(it.id);
|
||||
it.cover_url = f ? `/uploads/${f}` : null;
|
||||
}
|
||||
}
|
||||
|
||||
return { total, page: q.page, pageSize: q.pageSize, items };
|
||||
}
|
||||
|
||||
getById(id: number): any {
|
||||
const db = this.database.db;
|
||||
const row: any = db
|
||||
.prepare(
|
||||
`SELECT m.*, c.name AS category, c.subcat
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
WHERE m.id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
if (!row) {
|
||||
throw new NotFoundException(`车型 ${id} 不存在`);
|
||||
}
|
||||
if (row.raw_json) {
|
||||
try {
|
||||
row.raw = JSON.parse(row.raw_json);
|
||||
} catch {
|
||||
row.raw = {};
|
||||
}
|
||||
}
|
||||
// 叠加众包字段覆盖(已审核通过的编辑)
|
||||
const overrides = this.wdb.db
|
||||
.prepare('SELECT field, value FROM model_overrides WHERE model_id = ?')
|
||||
.all(id) as { field: string; value: string }[];
|
||||
if (overrides.length) {
|
||||
row.overridden = [];
|
||||
for (const o of overrides) {
|
||||
const def = EDITABLE_MAP.get(o.field);
|
||||
row[o.field] =
|
||||
def?.type === 'int' ? (o.value === '' ? null : Number(o.value)) : o.value;
|
||||
row.overridden.push(o.field);
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 技术族谱:按分类→系列聚合现有车型(series 字段)。*/
|
||||
families(category?: string) {
|
||||
const rows = this.database.db
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, m.series, m.first_year, m.last_year,
|
||||
m.country_type, m.max_speed_value, m.max_speed_unit,
|
||||
c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
${category ? 'WHERE c.name = ?' : ''}
|
||||
ORDER BY c.name, m.series, (m.first_year IS NULL), m.first_year`,
|
||||
)
|
||||
.all(...(category ? [category] : [])) as any[];
|
||||
|
||||
const cats = new Map<string, Map<string, any[]>>();
|
||||
for (const r of rows) {
|
||||
if (!cats.has(r.category)) cats.set(r.category, new Map());
|
||||
const series = r.series || '(未归类)';
|
||||
const sm = cats.get(r.category)!;
|
||||
if (!sm.has(series)) sm.set(series, []);
|
||||
sm.get(series)!.push(r);
|
||||
}
|
||||
return [...cats.entries()].map(([cat, sm]) => ({
|
||||
category: cat,
|
||||
series: [...sm.entries()].map(([name, models]) => ({ name, models })),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/** 车型列表查询参数 — 对应 FR-1.1 / FR-1.2。*/
|
||||
export const SORTABLE_FIELDS = [
|
||||
'first_year',
|
||||
'last_year',
|
||||
'max_speed_value',
|
||||
'weight_value',
|
||||
'model_code',
|
||||
'id',
|
||||
] as const;
|
||||
|
||||
export class QueryModelsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
pageSize = 20;
|
||||
|
||||
/** 分类:可传 category_id(数字)或分类名(模糊)。*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
yearFrom?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
yearTo?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
speedMin?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
speedMax?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manufacturer?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
country?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
/** 快速关键字(型号/全称模糊)。*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(SORTABLE_FIELDS as unknown as string[])
|
||||
sort: string = 'first_year';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
order: 'asc' | 'desc' = 'asc';
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PhotosService } from './photos.service';
|
||||
import { uploadDir } from './uploads';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtAuthGuard,
|
||||
Roles,
|
||||
RolesGuard,
|
||||
type JwtPayload,
|
||||
} from '../auth/roles';
|
||||
|
||||
const ALLOWED = /\.(jpe?g|png|webp|gif)$/i;
|
||||
|
||||
const storage = diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir()),
|
||||
filename: (_req, file, cb) =>
|
||||
cb(null, `${Date.now()}-${randomBytes(6).toString('hex')}${extname(file.originalname).toLowerCase()}`),
|
||||
});
|
||||
|
||||
@Controller('api')
|
||||
export class PhotosController {
|
||||
constructor(private readonly photos: PhotosService) {}
|
||||
|
||||
/** 公共图库:某车型的全部照片(所有人可看)。*/
|
||||
@Get('models/:id/photos')
|
||||
list(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.photos.listForModel(id);
|
||||
}
|
||||
|
||||
/** 管理员候选审图:全站待确认照片。*/
|
||||
@Get('photos/candidates')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
candidates() {
|
||||
return this.photos.listCandidates();
|
||||
}
|
||||
|
||||
/** 管理员上传到共享图库。*/
|
||||
@Post('models/:id/photos')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage,
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) =>
|
||||
ALLOWED.test(file.originalname)
|
||||
? cb(null, true)
|
||||
: cb(new BadRequestException('仅支持 jpg/png/webp/gif'), false),
|
||||
}),
|
||||
)
|
||||
upload(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('caption') caption: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('未收到文件');
|
||||
return this.photos.add(id, user.sub, file.filename, {
|
||||
caption: caption ?? '',
|
||||
status: 'confirmed',
|
||||
});
|
||||
}
|
||||
|
||||
@Post('photos/:pid/confirm')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
confirm(@Param('pid', ParseIntPipe) pid: number) {
|
||||
return this.photos.confirm(pid);
|
||||
}
|
||||
|
||||
@Post('photos/:pid/feature')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
feature(@Param('pid', ParseIntPipe) pid: number) {
|
||||
return this.photos.setFeatured(pid);
|
||||
}
|
||||
|
||||
@Delete('photos/:pid')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
remove(@Param('pid', ParseIntPipe) pid: number) {
|
||||
return this.photos.remove(pid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PhotosService } from './photos.service';
|
||||
import { PhotosController } from './photos.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [PhotosController],
|
||||
providers: [PhotosService],
|
||||
})
|
||||
export class PhotosModule {}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
import { PUBLIC_PREFIX, uploadDir } from './uploads';
|
||||
|
||||
export interface AddPhotoOpts {
|
||||
caption?: string;
|
||||
status?: 'candidate' | 'confirmed';
|
||||
sourceUrl?: string;
|
||||
author?: string;
|
||||
license?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PhotosService {
|
||||
constructor(
|
||||
private readonly wdb: WritableDbService,
|
||||
private readonly ro: DatabaseService,
|
||||
) {}
|
||||
|
||||
private toPublic(row: any) {
|
||||
return {
|
||||
id: row.id,
|
||||
modelId: row.model_id,
|
||||
url: `${PUBLIC_PREFIX}/${row.filename}`,
|
||||
caption: row.caption,
|
||||
status: row.status,
|
||||
featured: !!row.featured,
|
||||
sourceUrl: row.source_url,
|
||||
author: row.author,
|
||||
license: row.license,
|
||||
uploaderId: row.uploader_id,
|
||||
uploaderName: row.uploader_name,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
add(modelId: number, uploaderId: number, filename: string, opts: AddPhotoOpts = {}) {
|
||||
const info = this.wdb.db
|
||||
.prepare(
|
||||
`INSERT INTO photos (model_id, uploader_id, filename, caption, status, source_url, author, license)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
modelId,
|
||||
uploaderId,
|
||||
filename,
|
||||
(opts.caption ?? '').trim(),
|
||||
opts.status ?? 'confirmed',
|
||||
opts.sourceUrl ?? '',
|
||||
opts.author ?? '',
|
||||
opts.license ?? '',
|
||||
);
|
||||
return this.get(Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
get(id: number) {
|
||||
const row = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT p.*, u.display_name AS uploader_name
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id WHERE p.id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
if (!row) throw new NotFoundException('照片不存在');
|
||||
return this.toPublic(row);
|
||||
}
|
||||
|
||||
listForModel(modelId: number) {
|
||||
return (
|
||||
this.wdb.db
|
||||
.prepare(
|
||||
`SELECT p.*, u.display_name AS uploader_name
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
WHERE p.model_id = ?
|
||||
ORDER BY (p.status='confirmed') DESC, p.created_at ASC, p.id ASC`,
|
||||
)
|
||||
.all(modelId) as any[]
|
||||
).map((r) => this.toPublic(r));
|
||||
}
|
||||
|
||||
confirm(id: number) {
|
||||
const row = this.wdb.db.prepare('SELECT 1 FROM photos WHERE id = ?').get(id);
|
||||
if (!row) throw new NotFoundException('照片不存在');
|
||||
this.wdb.db.prepare("UPDATE photos SET status='confirmed' WHERE id = ?").run(id);
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** 设为该车型封面(同时置为已确认;清掉同车型其它封面标记)。*/
|
||||
setFeatured(id: number) {
|
||||
const row: any = this.wdb.db.prepare('SELECT model_id FROM photos WHERE id = ?').get(id);
|
||||
if (!row) throw new NotFoundException('照片不存在');
|
||||
const tx = this.wdb.db.transaction(() => {
|
||||
this.wdb.db.prepare('UPDATE photos SET featured=0 WHERE model_id=?').run(row.model_id);
|
||||
this.wdb.db
|
||||
.prepare("UPDATE photos SET featured=1, status='confirmed' WHERE id=?")
|
||||
.run(id);
|
||||
});
|
||||
tx();
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** 全站候选图(管理员审图页),附车型信息。*/
|
||||
listCandidates(limit = 300) {
|
||||
const rows = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT p.*, u.display_name AS uploader_name
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
WHERE p.status = 'candidate'
|
||||
ORDER BY p.model_id, p.id LIMIT ?`,
|
||||
)
|
||||
.all(limit) as any[];
|
||||
if (rows.length === 0) return [];
|
||||
const ids = [...new Set(rows.map((r) => r.model_id))];
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const models = this.ro.db
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id WHERE m.id IN (${ph})`,
|
||||
)
|
||||
.all(...ids) as { id: number; model_code: string; category: string }[];
|
||||
const map = new Map(models.map((m) => [m.id, m]));
|
||||
return rows.map((r) => ({
|
||||
...this.toPublic(r),
|
||||
modelCode: map.get(r.model_id)?.model_code ?? `#${r.model_id}`,
|
||||
category: map.get(r.model_id)?.category ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
remove(id: number) {
|
||||
const row: any = this.wdb.db.prepare('SELECT * FROM photos WHERE id = ?').get(id);
|
||||
if (!row) throw new NotFoundException('照片不存在');
|
||||
try {
|
||||
unlinkSync(join(uploadDir(), row.filename));
|
||||
} catch {
|
||||
/* 文件可能已不存在,忽略 */
|
||||
}
|
||||
this.wdb.db.prepare('DELETE FROM photos WHERE id = ?').run(id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
/** 图片上传存储目录(可用 UPLOAD_DIR 覆盖,测试用)。*/
|
||||
export function uploadDir(): string {
|
||||
const dir =
|
||||
process.env.UPLOAD_DIR ||
|
||||
resolve(join(__dirname, '..', '..', '..', '..', 'app', 'data', 'uploads'));
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export const PUBLIC_PREFIX = '/uploads';
|
||||