platform-assistant/AGENTS.md
Sisyphus 68f6fc5c16 feat(ui): 前端 UI 组件化重构 + 发布表单增强
【新组件】
- ErrorBoundary: 全局错误边界,渲染崩溃时显示重试/刷新按钮
- ui/Toast: 全局 Toast 通知系统 (success/error/warning/info),Context Provider 模式
- ui/Modal: 统一弹窗 (ESC关闭 + loading遮罩 + 自定义footer)
- ui/Skeleton: 骨架屏 (SkeletonTable / SkeletonCard)
- ui/Lightbox: 图片放大灯箱

【页面迁移】
- InquiryPage: useToast + Modal + SkeletonTable 替换手写代码,新增 pubForm
  发布前预填联系人/电话/地址 + 发布时传 body 给后端
- LeadDetailPage: 迁移到 Toast + Modal
- ProductPage: 迁移到 Toast + Modal + SkeletonTable
- AdminPage: 迁移到 useToast
- ComparisonPage: 迁移到 Toast + SkeletonTable
- ImagePage: 迁移到 Toast + SkeletonCard

【基础设施】
- globals.css: 新增 fadeIn/slideUp 动画
- page.tsx: SPA 根接入 ToastProvider + ErrorBoundary
- tailwind.config.ts: 新增 animate keyframes

【后端】
- integration.service.ts: MCP session 管理 + charset 修复
- lead.controller.ts/lead.service.ts: 发布端点增强 + 物料提取服务注入
2026-07-16 15:46:37 +08:00

493 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# AGENTS.md
语言:中文。所有对话、注释、文档、提交信息均使用中文。
## 🤝 协作流程标准(避免理解不一致造成返工)
**所有具体问题的修改,必须按这个流程执行:**
```
1. 用户描述需求(业务场景、看到的问题、截图、日志)
↓
2. 协作者反问 7 个技术维度(场景/触发/重要程度/数据/边界/样式/部署)
↓
3. 对齐范围:用户确认 + 协作者列技术方案
↓
4. 实施 + 验证
↓
5. 用户看效果,反馈或继续下一项
```
### 协作者必须问的 7 个技术维度
| 维度 | 问什么 |
|------|--------|
| **场景** | 哪个页面/哪个模块?用户路径是什么? |
| **触发条件** | 什么时候出现?手动还是自动?频率? |
| **重要程度** | 阻塞核心流程 / 体验优化 / 锦上添花? |
| **数据** | 影响多少数据?要不要数据迁移? |
| **边界** | 空数据/异常情况怎么处理? |
| **样式** | 改动大吗?要不要出截图对比? |
| **生产部署** | 改完要不要立即部署?还是攒一起? |
### 原则
- **不擅自开干**:未对齐范围前不做实现
- **不预设方案**:用户描述 ≠ 协作者的解决方案,先问清楚
- **不省略验证**:改完必须跑端到端测试,不只是"代码看起来对"
- **不静默改大动作**:涉及数据库结构、生产配置、依赖升级等要明确告知
### 例外(不需要问的简单场景)
- 拼写错误、明显笔误
- 单文件 typo 修复
- 用户明确说"按你判断做"
- 紧急 bug 修复(事后补问)
## 项目结构
```
/
├── apps/
│ ├── web/ # Next.js 14 App Router + Tailwind CSS 3
│ └── api/ # NestJS 10 + Prisma ORM + SQLite
├── packages/
│ └── shared/ # 共享 TS 类型(编译型,无运行时依赖)
└── scripts/
├── parse_products.py # 解析 Excel/CSV → JSON
├── tender-fetcher/ # 招标公告抓取脚本
│ ├── fetch-v1.2.js # 中电建
│ ├── fetch-xkgroup.py # 新矿集团
│ ├── fetch-cnncecp.py # 中核集团
│ └── lead_evaluation_report.py
└── direv-publish/ # direv 平台自动发布脚本(10 个 .py)
```
## 开发命令
```bash
npm run setup # 首次:npm install + db:init
npm run dev # 同时启动前端(3001)和后端(4000)
npm run dev:api # 仅后端(nest start --watch,用 dist/ 编译产物)
npm run dev:web # 仅前端
npm run db:init # prisma generate → migrate → seed
npm run db:reset # 删 db 文件后 db:init
npm run build # nest build + next build
npm run lint # eslint + next lint
npm run typecheck # tsc --noEmit 所有包(无 root tsconfig,各包独立)
npm run dev:daemon # nohup 后台启动(PID 写入 .dev-api.pid / .dev-web.pid)
npm run dev:stop # kill PID 文件中记录的进程
```
## 端口速查 (本地开发)
| 端口 | 服务 | 启动方式 |
|---|---|---|
| **3001** | 平台助手 web (Next.js) | `npm run dev:web` 或 `npm run dev` |
| **4000** | 平台助手 API (NestJS) | `npm run dev:api` 或 `npm run dev` |
| **3002** | devbox MCP server (Node.js 版 `product_network_mcp`) | `ssh devbox` + PM2, **不在 Mac 本地** |
| **8095** | devbox MCP server (Java 版 `industry-mcp-server` Docker) | `ssh devbox` + `docker ps` 看 `industry-mcp-server`, **不在 Mac 本地** |
注意:**3002 / 8095 都是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。
打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002/8095)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用。
> ⚠️ 2026-07-15 实测: 平台助手本地 `next dev` 偶尔会 fallback 到 3000 端口(被 `admin_new` Vite 项目抢 3001 时),以实际启动后终端打印的端口为准。`AdminPage.tsx:1000` 默认填的 endpoint 仍是 `http://100.111.84.16:3002/mcp`(Node 版)。
AdminPage.tsx / schema.prisma 里出现的 `100.111.84.16:3002/mcp` 是 MCP server 地址 (填到 ConfigTab 的"endpoint" 字段), 不是 web 入口。
## 后端陷阱
- **`nest start --watch` 用 `dist/` 编译产物**:Prisma schema 变更后必须 `npm run build -w apps/api` 重建 dist,再重启。单纯 `prisma generate` 不够
- **端口 4000 残留进程**:`dev:stop` 只杀 PID 文件中记录的进程。如果有多余进程占端口,需手动 `lsof -ti:4000 | xargs kill -9`
- **Prisma 变更流程**:改 schema → `npm run db:init`(含 generate + migrate + seed)
- SQLite:`apps/api/prisma/dev.db`;种子数据(seed.ts):15 电缆型号 × 5 供应商 = 75 Product + 管理用户;`crawlerConfig` 保留 3 条(中电建 + 新矿集团 + 中核集团,中核默认 paused)
- `CrawlerService`(新增):`bid_powerchina` 类型 → `execSync('node fetch-v1.2.js')`;`bid_xkgroup` 类型 → `execSync('python3 fetch-xkgroup.py')`。脚本路径硬编码为 `scripts/tender-fetcher/`,结果从 `~/Desktop/招标公告/<date>/downloaded.json` 读取,upsert 到 Lead 表
- Lead 表 `noticeId` 从 `summary` 中提取时曾因 `substr` 字符/字节偏移混淆导致截断错误(`+8` 应为 `+6`)。提取公告 ID 统一用 `substr(summary, instr(summary, '公告ID:') + 6, 32)`
- Lead 表 `@@unique([source, noticeId])`:去重键,`CrawlerService.run()` 用 `upsert` 写入,重复则更新 summary/detailUrl
- Lead 表 `detailUrl`:独立字段,前端显示为可点击链接。脚本保存 `id` 后重构 URL(中电建用 `r.id` 而非 `systemId`;新矿需补 `&userId=guest&accountId=2066845847806639104` 认证参数)
- 后端无测试框架
## 前端陷阱
- **SPA 无路由**:`page.tsx` 用 `useState<Page>` 切换 14 个组件(含 LoginPage + Sidebar + Providers)。导航通过 `onNavigate` prop 传递 `page` 值,参数通过 props
- **API base**:`src/lib/apiBase.ts` 暴露 `API_BASE`,构建期由 `NEXT_PUBLIC_API_URL` 注入。本地开发不设环境变量时回退到 `http://localhost:4000`,生产构建为 `/api`(Nginx 反代到 4000)
- JWT 存 localStorage key `auth`,AuthProvider 启动时验证
- 3000 端口被占用,`next dev` 自动 fallback 到 3001
- 路径别名 `@/` → `src/`
- Prettier: noSemi, singleQuote, trailingComma: all, printWidth: 100
- Tailwind 设计 Token:`secondary(#0051d5)`, `border-subtle(#E2E8F0)`, `surface-container` 系
## 前端数据对接状态
| 页面 | 状态 |
|------|------|
| ComparisonPage | ✅ GET `api/comparison`,POST upload/suggestion |
| ImagePage | ✅ GET `api/images`,POST generate/parse(MiniMax 生图) |
| ProductPage | ✅ GET/POST/PATCH/DELETE `api/products` + POST upload-image |
| OverviewPage | ✅ Promise.all([GET `api/leads/stats`, GET `api/leads`]) |
| InquiryPage | ✅ GET `api/leads`,POST `api/leads/{id}/publish`(含发布前确认弹窗) |
| LeadDetailPage | ✅ GET `api/leads/{id}`,POST to-rfq/to-collab/publish/follow-up |
| RuleCenterPage | 含"采集器配置"Tab:GET/POST/PUT `api/crawlers`,POST run/pause/resume |
| ReportCenterPage | 纯 mock |
| SystemMonitorPage | 纯 mock |
| AdminPage | 已实现,5 个子区块(tenants/users/stats/configs/plans),configs 含 MCP 对接配置 |
| LoginPage | POST `api/auth/login` |
## 演示账号
`admin` / `admin123`(username + password,**不是 email**;AGENTS.md 旧版写的 `admin@platform.com` 不对,后端 LoginDto 用 username 字段)
## 数据库模型
User, Lead(含 AI 评分/状态流转/跟进日志), Rule/RuleVersion, Report, AuditLog, CrawlerConfig(含 keywords/categories + cron schedule), Inquiry, Product(含 supplier/imageUrl), GeneratedImage, **ProductCategory**(品类字典,AI 关键词反哺用)
## 品类方案 C(关键词优先 + AI 反哺,2026-07-01)
### 数据模型
`ProductCategory` 表:
- `name`:品类名(2-6 中文字,唯一)
- `keywords`:JSON 数组,关键字列表
- `isAIGenerated`:AI 自动创建标记(人工 review 后可改 false)
### 分类逻辑
```
1. 采集器入库 / 手动回填时,对每条线索标题做:
关键词匹配(ProductCategory.keywords contains 标题)→ 命中直接写
↓ 未命中
MiniMax API 分类 → 写品类 + 反写关键词到 ProductCategory
2. 同类标题下次直接走关键词,不再调 AI
```
### 关键代码路径
| 文件 | 说明 |
|------|------|
| `apps/api/prisma/schema.prisma` | `ProductCategory` model |
| `apps/api/src/product-category/` | CRUD + 关键词匹配 service |
| `apps/api/src/product-category/category-inference.service.ts` | AI 推断 + 反写关键词 |
| `apps/api/src/crawler/crawler.service.ts` | 采集入库用 `batchInfer()` |
| `apps/api/src/crawler/notes-extractor.service.ts` | PDF/HTML 提取要点也走 AI |
| `apps/web/src/components/InquiryPage.tsx` | 品类筛选项动态从 `/api/categories` 加载 |
### LLM 配置(DeepSeek → MiniMax 迁移)
- endpoint:`https://api.minimaxi.com/v1/chat/completions`
- model:`MiniMax-M2.7`(env `MINIMAX_MODEL` 可覆盖)
- env:`MINIMAX_API_KEY` 写入 `apps/api/.env`(生产已配)。本地开发 `.env` 也需要配,否则物料提取/category-inference 走兜底
- **注意**:M2.7 是推理模型,max_tokens 要 ≥500 才够(否则全被 `<think>` 吃掉)。物料提取正文用 2000,标题提取用 800
- 生产 `.env` 同时保留 `MINIMAX_API_KEY` 和 `MINIMAX_MODEL` 两个变量
### 历史回填策略(重要:AI 不可全信)
2026-07-01 用 MiniMax 回填了 507 条历史空品类线索,结果:
- ✅ 命中:693/761(91%)已分配品类
- ⚠️ AI 自动新建 ~25 个细分类,需要人工 review 合并/删除
- ❌ 68 条空品类线索(重要数据手动指定,非重要忽略)
**决策(用户)**:停止继续调 AI 回填剩余数据。原因:
- 单条 API 推理模型耗时大(500 tokens 给思考留空间)
- AI 输出容易混入 `<think>`、`让我分析`、`**加粗**` 等解释文字
- 关键词匹配已能覆盖新采集数据,老数据人工 review 更可靠
### 后续流程
- **新采集数据**:自动走关键词匹配(15 个初始品类 + 已入库的 ~25 个细分类)
- **AdminPage → 品类管理**:用户手动 review AI 新建的细分类(合并到已有大类、删除不合理的)
- **空品类历史线索**:用户从详情面板手动指定,或忽略(过期数据)
- **新增采集场景需要新关键词**:编辑 `ProductCategory.keywords` 字段(AdminPage 或直接 SQL)
### 数据维护脚本
| 脚本 | 用途 |
|------|------|
| `scripts/backfill_category.js` | 批量回填空品类(已停止使用,仅供参考) |
| `scripts/clean_category.js` | 清理 AI 输出脏值(`<think>`、`**加粗**`、` - 描述`、解释行) |
| `scripts/deploy.sh` | 部署时自动同步上述脚本到生产 |
## 采集器关键事实
- 调度基于 `@nestjs/schedule` + `SchedulerRegistry`,`onModuleInit` 时从 DB 加载 `active` 配置注册 cron
- pause/resume/update 联动调度器的注册与注销
- `CrawlerConfig` 的 `schedule` 字段存储 cron 表达式,前端可选:每4小时、每6小时、每天早8点、每天晚7点、手动
- 运行采集器有即时反馈 Modal(请求完自动更新状态,也可"后台运行"关闭弹窗)
- 脚本路径相对于运行时工作目录,实际为 `scripts/tender-fetcher/`
- **通用化新增采集器**:PowerChina 保持特殊处理(JSON 格式不同)。其他网站只需:
1. 在 `scripts/tender-fetcher/` 放脚本(如 `fetch-xxx.py`),输出 `~/desktop/招标公告/<date>/xxx_downloaded.json`
2. 前端"采集器配置"新建记录,`type` 填 `bid_xxx`,`sourceName` 填来源显示名,`scriptPath` 填脚本文件名
3. 无需改代码,`runGenericScript()` 自动推断脚本和 JSON 文件路径
- `CrawlerConfig` 新增 `sourceName`(线索来源显示名)和 `scriptPath`(脚本文件名,可选,默认从 type 推断)
## 生产部署(腾讯云 172.81.241.109 / direv.cloud)
> ⚠️ 注意:服务器 IP 以 `scripts/deploy.sh` 里的 `SSH_HOST` 为准(当前 172.81.241.109)。
> 历史曾用 124.222.182.242,已失效(SSH 22 端口超时)。如遇 IP 变更,改 deploy.sh 即可。
- **一键部署**:`bash scripts/deploy.sh`(build + rsync dist/.next/源码 + pm2 restart,密码内置于脚本)
- **SSH**:`sshpass -p 'Jubianshuzi@2026' ssh ubuntu@172.81.241.109`(需本地装 sshpass)
- **路径**:`/opt/pingtai/`(代码) + `/data/pingtai/`(持久化数据,dev.db / uploads / library / 招标公告)
- **进程**:`pm2` 托管 `pingtai-api`(NestJS 4000)+ `pingtai-web`(Next.js 3001),systemd 自启(`/etc/systemd/system/pm2-ubuntu.service`)
- **反代**:Nginx 80/443 → 3001;`/api/*` → 4000;`/uploads/*` → `/data/pingtai/uploads/` 静态直出
- **HTTPS**:Let's Encrypt(certbot),自动续期 timer 已开
- **本地重部署流程**:
1. `npm run build` 重新编译 api/web
2. `rsync` 同步 dist/.next + 源码到 `/opt/pingtai/`
3. `pm2 restart pingtai-api pingtai-web`(注意 `pm2 restart` 默认不更新环境变量,要 `--update-env`)
4. Prisma 变更后必须先 `npx prisma migrate deploy && npx prisma generate`(在 `apps/api/` 下)
- ⚠️ **Web 构建注意**:本地 build 的 `.next/` 若直接 rsync 到生产,可能因 `node_modules` 版本不一致导致 chunk 加载失败。遇此情况,在生产服务器上执行 `cd /opt/pingtai/apps/web && npx next build` 重建。
- **常见操作**:
- `pm2 list` / `pm2 logs` / `pm2 restart pingtai-api`
- `tail -f /var/log/pingtai/api.out-1.log`
- `sqlite3 /data/pingtai/dev.db` 直接查 DB
- 备份:`tar czf pingtai-backup-$(date +%Y%m%d).tgz /data/pingtai/`
- **历史包袱**:服务器上旧的 5月22日 Postgres/Redis 部署已备份到 `/home/ubuntu/platform-assistant.bak-*`,旧 nginx conf 在 `/etc/nginx/conf.d/platform.conf.bak-*`,如需恢复可回退
- **采集器脚本**:`scripts/tender-fetcher/` 写出的 `~/desktop/招标公告/` 通过 symlink `~/desktop -> /data/pingtai` 落到持久化卷;symlink 必须指向 `/data/pingtai`(不是 `/data/pingtai/招标公告`),否则会双层嵌套
## CODE MAP
| Symbol | Type | Location | Role |
|--------|------|----------|------|
| `AppModule` | module | apps/api/src/app.module.ts | 根模块,导入 13 个子模块 |
| `bootstrap()` | function | apps/api/src/main.ts | NestJS 应用启动入口 |
| `page.tsx` | page | apps/web/src/app/page.tsx | SPA 根,useState 切换 14 页面 |
| `AuthProvider` | context | apps/web/src/components/AuthProvider.tsx | JWT 认证上下文 |
| `CrawlerService.run()` | method | apps/api/src/crawler/crawler.service.ts | 采集器入口,execSync 调度脚本 |
| `ImageService` | class | apps/api/src/image/image.service.ts | 三阶段生图(图库→Bing→AI) |
| `runGenericScript()` | method | apps/api/src/crawler/crawler.service.ts | 通用化采集器调度 |
| `MaterialExtractorService` | class | apps/api/src/lead/material-extractor.service.ts | **W37 新增** 发布时实时提取物料清单 (items),三级降级链 |
| `search_image.py` | script | scripts/search_image.py | Bing 搜索 top N 候选 |
| `process_image.py` | script | scripts/process_image.py | 去水印+裁剪+白底合成 |
## product_network B2B 平台对接(2026-06-16 新增,MCP 主通道)
### 对接架构
```
平台助手 NestJS (4000)
↓ IntegrationService.callTool()
↓ MCP 协议 (Streamable HTTP, POST 方法)
↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key)
renren-mcp-server
├─ Node.js 版 (devbox:3002, product_network_mcp)
└─ Java 版 (devbox:8095, industry-mcp-server Docker 容器)
↓ 直调 Java 业务方法
↓
product_network 7 个 Java 服务
```
注: MCP server 端口 **3002(Node)和 8095(Java Docker)都是 devbox 开发机上的端口**, 不是平台助手本地端口 (3001/4000)。两套 MCP server 后端数据库不同, appId/appSecret 互不通用, 申请时确认走哪一套。
### MCP 端点要求(重要!)
平台助手消费 MCP 时, 端点必须满足:
- **协议**: Streamable HTTP (`@modelcontextprotocol/sdk` 的 `StreamableHTTPClientTransport`)
- **方法**: **POST** (不是 GET). 如果 MCP server 实现要求 GET, 需要改 server 而不是 client
- **认证头**: 多租户 APIKey → `x-app-id` + `x-app-secret`, 由 devbox B2B 运营后台"集成管理 → 应用列表"申请
- **路径示例**: `http://host:port/mcp` (注意末尾 `/mcp`)
前端 "MCP 对接配置 → 新增对接" 弹窗的 MCP 地址字段下方有提示文案提醒这些要求。
### 设计原则
- **MCP 主通道**:17 个 tool(询价 5 + 商品 4 + 比价 4 + 推理 4)
- **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销)
- **APIKey 认证**:Header `x-app-id` + `x-app-secret`(多租户, 替代旧的 `x-api-key`), AES-256-GCM 加密存储(密钥 `ENCRYPT_KEY` in `.env`,32 bytes hex)
- **公司账号代理**:每客户 1 个 `sys_user` (user_type=99)
### 关键代码路径
| 文件 | 说明 |
|------|------|
| `apps/api/prisma/schema.prisma` | `Integration` 表(MCP 连接配置) |
| `apps/api/src/integration/integration.service.ts` | MCP client 管理(connect/listTools/callTool)+ **session 管理 + charset 修复** |
| `apps/api/src/integration/integration.controller.ts` | REST API(CRUD + test + connect) |
| `apps/api/src/integration/integration.module.ts` | NestJS 模块 |
| `apps/api/src/lead/lead.service.ts` | `publishToDirev()` 调 MCP `publish_rfq` tool + **可选 overrides 参数** |
| `apps/api/src/lead/lead.controller.ts` | `POST /api/leads/:id/publish` 新增可选 body |
| `apps/api/src/lead/material-extractor.service.ts` | **W37 新增** 物料清单提取(发布时实时调 MiniMax) |
| `apps/api/src/lead/lead.module.ts` | 注册 `MaterialExtractorService` |
| `apps/web/src/components/InquiryPage.tsx` | **发布前确认弹窗**(对接文档 §5.2) |
| `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | MCP 对接配置(嵌入系统配置页面)+ MCP 协议提示文案 |
### 使用流程
1. 前端「运营管理 → 系统配置」页面顶部"MCP 对接配置"区域
2. 点击「新增对接」,填入:名称、MCP Server 地址、APIKey、客户编码
3. 点击「连接」验证 MCP server 可达并获取工具列表
4. 发布询价时,自动通过 MCP 调用 `publish_rfq` tool
### MCP Client 实现
```typescript
// integration.service.ts 核心逻辑
const client = new Client({ name: 'platform-assistant', version: '1.0.0' })
const transport = new StreamableHTTPClientTransport(
new URL(endpoint),
{
requestInit: {
headers: { 'x-app-id': appId, 'x-app-secret': appSecret },
},
// 自定义 fetch: 自动管理 mcp-session-id (MCP Streamable HTTP 协议要求)
fetch: this.buildSessionAwareFetch(id),
}
)
await client.connect(transport)
const tools = await client.listTools()
await client.callTool({ name: 'publish_rfq', arguments: { title, ... } })
```
### MCP Session 管理(2026-07-15 实现)
`integration.service.ts` 的 `buildSessionAwareFetch(integrationId)` 方法实现三层 session 管理:
1. **提取**:自定义 fetch wrapper 从 initialize 响应头 `mcp-session-id` 提取并缓存到 `mcpSessionIds: Map<integrationId, sessionId>`
2. **注入**:后续所有请求自动在 header 中带 `mcp-session-id`
3. **清理**:disconnect / 重连时自动清空旧 session
双保险设计(SDK 1.27.0 内置 session 管理 + 自定义 wrapper fetch 兜底):
| 路径 | 谁管理 session |
|---|---|
| connect() initialization | wrapper fetch 从响应头提取 |
| listTools() / callTool() | wrapper fetch 注入 `mcp-session-id` header |
| executeTool() 自动重连 | `clearSession(id)` 后再调用 `connect()` |
| disconnect() | 调用 `clearSession(id)` |
| 进程关闭 (onModuleDestroy) | `mcpSessionIds.clear()` |
### Content-Type charset 修复(2026-07-15)
SDK 默认设 `content-type: application/json` 但不带 `charset=utf-8`。Java Jackson 解析器缺 charset 时可能不以 UTF-8 解码,导致多字节中文的字节计数与 Content-Length 不匹配,报 JSON 解析错误。
**修复**:在 `buildSessionAwareFetch` wrapper 中检测 Content-Type,自动补上 `charset=utf-8`:
```typescript
const ct = headers.get('content-type')
if (ct && !ct.includes('charset') && ct.includes('application/json')) {
headers.set('content-type', 'application/json; charset=utf-8')
}
```
### API 端点
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/integrations` | 列表 |
| POST | `/api/integrations` | 创建 |
| PUT | `/api/integrations/:id` | 更新 |
| DELETE | `/api/integrations/:id` | 删除 |
| POST | `/api/integrations/:id/connect` | 连接 MCP |
| POST | `/api/integrations/:id/disconnect` | 断开 |
| POST | `/api/integrations/test` | 测试连接 |
| POST | `/api/integrations/:id/call-tool` | 调用工具 |
### 注意事项
- MCP server 地址格式:`http://host:port/mcp`(Streamable HTTP transport)
- APIKey 通过 Header `x-api-key` 传递
- 客户编码通过 Header `x-customer-code` 传递(可选)
- **APIKey 加密**:AES-256-GCM,存 DB 为 base64 密文(iv12+tag16+ciphertext),读 API 时自动解密。旧明文数据读取时 fallback 不加密(兼容)
- 连接状态存储在数据库,服务重启后需重新连接
- 发布失败降级为本地模式(生成假 inquiryNo)
### publish_rfq 字段映射(对接文档 4.1+4.2 节)
`lead.service.ts` → `publishToDirev(id, overrides?)` 组装 `publishArgs` 后调 `executeTool('publish_rfq', args)`。
`overrides` 可选参数:`{ contactPhone?, contactName?, deliveryAddress? }`,由前端弹窗传入。
| publishArgs 字段 | lead 来源 / 默认值 | 文档要求 | 类型 | 备注 |
|---|---|---|---|---|
| title | `lead.title` | 询价标题 | string | 限 50 字,超长报错 |
| items | MaterialExtractor 实时提取 | 物料清单行 | array | 可选,最多 20 行 |
| deliveryAddress | `overrides?.deliveryAddress` → `lead.regionDetail` → `lead.region` | 收货地址 | string | 弹窗可编辑覆盖 |
| deliveryDate | `deadline + 30 天` | 期望交货日期 | string YYYY-MM-DD | — |
| validDays | `7` | 报价有效期 | integer | **W37 新增** |
| contactName | `overrides?.contactName \|\| lead.contact \|\| '待补充'` | 联系人 | string | 弹窗可编辑 |
| contactPhone | `overrides?.contactPhone \|\| lead.phone \|\| '待补充'` | 联系手机 | string | **必填,弹窗补全** |
| inquiryParty | 硬编码 `郑州巨变网络科技有限公司` | 询价主体 | string | 企业配置 |
| freightFlag | `1` | 运费承担 | integer | 1=含运费(曾传字符串"含运费"报错,已修复) |
| procurementType | `1` | 采购类型 | integer | 1=公开采购(曾传字符串"定量采购"报错,已修复) |
| quotationDemand | `1` | 报价要求 | integer | **W37 新增**,1=需要报价 |
| invoiceType | `2` | 开票要求 | integer | 2=增值税专票(曾传字符串报错,已修复) |
| paymentMethod | **不传** | 付款方式 | — | 后端用企业默认配置,传了"23"会报"企业字典找不到" |
| userId / enterpriseId | `Integration.companyUserId` | 企业身份 | string | MCP connect 时获取 |
### 发布前确认弹窗(对接文档 §5.2,2026-07-16 实现)
`InquiryPage.tsx` 中实现,用户勾选线索后点"一键发布"弹出:
**弹窗布局**(三个区域):
| 区域 | 数据来源 | 可编辑 |
|---|---|---|
| 询价信息 | 线索标题/品类/数量/单位 | ❌ 只读 |
| 收货信息 | 联系人、联系手机、收货地址 | ✅ 用户可编辑 |
| 业务默认值 | 企业硬编码字段值清单 | ❌ 只读 |
**编辑字段**:
| 字段 | 类型 | 验证 | 为空时 |
|---|---|---|---|
| contactName | 文本输入框 | 无 | 传 `'待补充'` |
| contactPhone | 电话输入框 | 标红提示"⚠ 必填" | 传 `'待补充'` |
| deliveryAddress | 文本输入框 | 标红提示"⚠ 必填" | 传线索地区数据 |
**数据流**:
```
前端弹窗 → DO_Publish() → POST /api/leads/:id/publish { contactName, contactPhone, deliveryAddress }
→ lead.service.ts publishToDirev(id, overrides)
→ 覆盖组装 publishArgs
→ executeTool('publish_rfq', publishArgs)
→ MCP server
→ 产业平台询价广场
```
### publish 端点变化(2026-07-16)
`lead.controller.ts`:
| 端点 | 方法 | Body | 改动 |
|---|---|---|---|
| `POST /api/leads/:id/publish` | POST | `{ contactPhone?, contactName?, deliveryAddress? }` | 新增可选 body 参数 |
| `POST /api/leads/batch-publish` | POST | `{ ids: string[] }` | 未改(循环调 publish) |
### 当前已知卡点(对接文档 §8 + 2026-07-16 实测)
| 卡点 | 状态 | 说明 |
|---|---|---|
| `code:10021 "未获取到用户信息"` | ⏳ 产业平台修复中 | MCP server 用户上下文问题,创建 APIKey 时已关联 `companyUserId`,后端应直接使用 |
| `paymentMethod` 传 `"23"` 报"企业字典找不到" | ✅ 已规避 | 已修改:不传 paymentMethod,后端用默认值 |
| JSON 截断 / Content-Length 不匹配 | ✅ 已修复 | 加 `charset=utf-8` 后解决 |
| 字段值类型不匹配(字符串 vs 整数) | ✅ 已修复 | `freightFlag`, `procurementType`, `invoiceType` 改整数,`paymentMethod` 不传 |
| `tb_inquiry.title` varchar(50) 超长 | ✅ 已加前置校验 | 发布前 `publishToDirev()` 检查 title 长度,超 50 字提前报错 |
### 数据质量现状(2026-07-16 生产数据统计)
815 条线索中,以下字段**全军覆没**(采集器只抓了列表页元数据):
| 字段 | 填写率 | 说明 |
|---|---|---|
| deadline | 702/815 (86%) | 有 |
| region | 696/815 (85%) | 有 |
| regionDetail | 542/815 (66%) | 有 |
| contact | 0/815 (0%) | **全缺** — 采集器未提取 |
| phone | 0/815 (0%) | **全缺** |
| quantity | 0/815 (0%) | **全缺** |
| unit | 0/815 (0%) | **全缺** |
| notes | 0/815 (0%) | **全缺** |
**原因**:三个采集脚本(中电建/fetch-v1.2.js、新矿/fetch-xkgroup.py、中核/fetch-cnncecp.py)都只解析列表页,不访问详情页正文提取联系人/电话/数量/单位。这些字段需要新增"详情页内容提取"步骤,或依赖发布前弹窗让用户手动补全。
**当前应对**:发布前弹窗让用户手动填写 contactPhone 和 deliveryAddress。如需自动化提取,需在 crawler.service.ts 入库后增加类似 `notes-extractor` 的 AI 提取步骤(待实现)。
## ANTI-PATTERNS
- 前端不加路由库,保持 SPA useState 模式
- 不提交 secrets 到 git(JWT_SECRET 已在 .env.production,但 .gitignore 未排除)
- 不删除旧采集器 state 文件(中电建用 `systemId_招采公告` 作 key)
- 不依赖 Postgres/Redis(docker-compose.yml 是历史遗留,实际只用 SQLite)
- 不在后端代码中硬编码脚本路径以外的文件路径