feat(F002): W26 + W29.x + W30 + W31 平台助手全部改动首次 commit
- W26: NestJS API + Next.js Web + Prisma + SQLite scaffold - W26: Lead 模型 + Crawler + 询价发布助手 (线索池 + 一键发布) - W29.x: MCP 集成 (Integration 模型 + connectMcp + publish_rfq 5 工具端到端跑通) - W30: 3 字段补充 (notes / quotationType / quotationDemand) - W31 P1: 自动填 enterpriseId / userId (Prisma companyUserId 字段 + get_app_identity tool + lead.service.ts publishArgs) - W31: detail 详情页 + admin UI 集成管理 (OpenAppListView + AuditLogListView) 关联: ~/Documents/02-平台开发/产品开发/F002-W31-交接文档.md GIT-AUDIT-REPORT-2026-06-24.md
48
.gitignore
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local data
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
prisma/dev.db
|
||||||
|
prisma/prisma/dev.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Dev processes
|
||||||
|
.dev-api.pid
|
||||||
|
.dev-web.pid
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Backup files (防误提交)
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
|
*.orig
|
||||||
|
*.rej
|
||||||
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
229
AGENTS.md
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
语言:中文。所有对话、注释、文档、提交信息均使用中文。
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── 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) | `ssh devbox` + PM2, **不在 Mac 本地** |
|
||||||
|
|
||||||
|
注意:**3002 是 devbox 上的 MCP server 端口, 不是平台助手 web 端口**。
|
||||||
|
打开浏览器访问平台助手用 `http://localhost:3001/` (不是 3002)。
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## 采集器关键事实
|
||||||
|
|
||||||
|
- 调度基于 `@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 推断)
|
||||||
|
|
||||||
|
## 生产部署(腾讯云 124.222.182.242 / direv.cloud)
|
||||||
|
|
||||||
|
- **路径**:`/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/` 下)
|
||||||
|
- **常见操作**:
|
||||||
|
- `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 | 通用化采集器调度 |
|
||||||
|
| `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)
|
||||||
|
↓ x-app-id + x-app-secret (多租户 APIKey, 替代旧的 x-api-key)
|
||||||
|
renren-mcp-server (Node.js, devbox:3002)
|
||||||
|
↓ 直调 Java 业务方法
|
||||||
|
↓
|
||||||
|
product_network 7 个 Java 服务
|
||||||
|
```
|
||||||
|
|
||||||
|
注: MCP server 端口 3002 是 **devbox 开发机** 上的端口, 不是平台助手本地端口 (3001/4000)。
|
||||||
|
|
||||||
|
### 设计原则
|
||||||
|
|
||||||
|
- **MCP 主通道**:17 个 tool(询价 5 + 商品 4 + 比价 4 + 推理 4)
|
||||||
|
- **REST 兼容层**:5 个端点(健康/兑换/订阅/订单/撤销)
|
||||||
|
- **APIKey 认证**:Header `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) |
|
||||||
|
| `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 |
|
||||||
|
| `apps/web/src/components/AdminPage.tsx` → `ConfigTab` | 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-api-key': apiKey } } }
|
||||||
|
)
|
||||||
|
await client.connect(transport)
|
||||||
|
const tools = await client.listTools()
|
||||||
|
await client.callTool({ name: 'publish_rfq', arguments: { title, ... } })
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
|
## ANTI-PATTERNS
|
||||||
|
|
||||||
|
- 前端不加路由库,保持 SPA useState 模式
|
||||||
|
- 不提交 secrets 到 git(JWT_SECRET 已在 .env.production,但 .gitignore 未排除)
|
||||||
|
- 不删除旧采集器 state 文件(中电建用 `systemId_招采公告` 作 key)
|
||||||
|
- 不依赖 Postgres/Redis(docker-compose.yml 是历史遗留,实际只用 SQLite)
|
||||||
|
- 不在后端代码中硬编码脚本路径以外的文件路径
|
||||||
10
apps/api/.env.production
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
DATABASE_URL="file:/data/pingtai/dev.db"
|
||||||
|
PORT=4000
|
||||||
|
NODE_ENV=production
|
||||||
|
JWT_SECRET=pingtai-prod-jwt-secret-9f8e7d6c5b4a3210-change-me
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
|
CORS_ORIGIN=https://direv.cloud,http://direv.cloud
|
||||||
|
UPLOAD_DIR=/data/pingtai/uploads
|
||||||
|
LIBRARY_DIR=/data/pingtai/library
|
||||||
|
MINIMAX_API_KEY=
|
||||||
19
apps/api/.eslintrc.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
project: 'tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: { node: true, jest: true },
|
||||||
|
ignorePatterns: ['.eslintrc.js'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
}
|
||||||
55
apps/api/AGENTS.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# apps/api — NestJS 10 后端
|
||||||
|
|
||||||
|
**生成:** 2026-06-05
|
||||||
|
**分支:** main
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
NestJS 10 后端,Prisma ORM + SQLite,提供 RESTful API。端口 4000。JWT 认证。
|
||||||
|
|
||||||
|
## STRUCTURE
|
||||||
|
|
||||||
|
```
|
||||||
|
api/
|
||||||
|
├── src/
|
||||||
|
│ ├── admin/ # 管理后台(租户/用户/配置/计划)
|
||||||
|
│ ├── auth/ # JWT + Passport 认证
|
||||||
|
│ ├── audit-log/ # 审计日志
|
||||||
|
│ ├── comparison/ # 比价模块
|
||||||
|
│ ├── crawler/ # 采集器调度 + execSync 调用脚本
|
||||||
|
│ ├── image/ # 三阶段生图(图库→Bing→AI)
|
||||||
|
│ ├── inquiry/ # 询价
|
||||||
|
│ ├── lead/ # 线索(核心,评分/状态流转/跟进)
|
||||||
|
│ ├── product/ # 商品
|
||||||
|
│ ├── reports/ # 报表
|
||||||
|
│ ├── rules/ # 规则中心
|
||||||
|
│ ├── users/ # 用户管理
|
||||||
|
│ ├── prisma/ # Prisma 服务封装
|
||||||
|
│ ├── common/ # 公共装饰器/过滤器
|
||||||
|
│ ├── app.module.ts # 根模块(导入所有子模块)
|
||||||
|
│ └── main.ts # 启动入口
|
||||||
|
└── prisma/
|
||||||
|
└── schema.prisma # 数据模型(~15 个模型)
|
||||||
|
```
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
|
||||||
|
| 任务 | 位置 | Notes |
|
||||||
|
|------|------|-------|
|
||||||
|
| 新增 API 端点 | `src/xxx/xxx.controller.ts` | ValidationPipe 全局自动转换 |
|
||||||
|
| 修改数据库模型 | `prisma/schema.prisma` | 改后必须 `npm run db:init` |
|
||||||
|
| 采集器调度 | `src/crawler/` | execSync 调用 scripts/tender-fetcher/ |
|
||||||
|
| 图片生成 | `src/image/image.service.ts` | 三阶段流水线 |
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
|
||||||
|
- Prisma 变更三步:generate → migrate → seed(`db:init` 一站式)
|
||||||
|
- 采集器脚本路径:`scripts/tender-fetcher/`
|
||||||
|
- 无测试框架
|
||||||
|
- 端口 4000,CORS 白名单 localhost:3000/3001 + direv.cloud
|
||||||
|
- 静态文件 `/uploads` → `public/uploads`
|
||||||
|
|
||||||
|
## ANTI-PATTERNS
|
||||||
|
|
||||||
|
- 不要在代码中硬编码脚本路径以外的文件路径
|
||||||
|
- 采集器 state 文件不要手动删除(中电建用 `systemId_招采公告` 作 key)
|
||||||
49
apps/api/package.json
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"name": "@pa/api",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nest start --watch",
|
||||||
|
"build": "nest build",
|
||||||
|
"lint": "eslint \"{src,test}/**/*.ts\"",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "ts-node prisma/seed.ts",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"clean": "rm -rf dist .turbo"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/common": "^10.3.0",
|
||||||
|
"@nestjs/core": "^10.3.0",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^10.3.0",
|
||||||
|
"@nestjs/schedule": "^6.1.3",
|
||||||
|
"@prisma/client": "^5.10.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"rxjs": "^7.8.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^10.3.0",
|
||||||
|
"@nestjs/schematics": "^10.1.0",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||||
|
"@typescript-eslint/parser": "^7.0.0",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"prettier": "^3.2.0",
|
||||||
|
"prisma": "^5.10.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/api/prisma/migrations/20260522142447_init/migration.sql
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Inquiry" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"budget" TEXT,
|
||||||
|
"deadline" TEXT,
|
||||||
|
"buyer" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Product" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"spec" TEXT NOT NULL,
|
||||||
|
"unit" TEXT NOT NULL,
|
||||||
|
"price" REAL NOT NULL,
|
||||||
|
"quantity" INTEGER NOT NULL,
|
||||||
|
"imageUrl" TEXT,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "GeneratedImage" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"productName" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'generating',
|
||||||
|
"style" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "brand" TEXT;
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "minOrder" INTEGER;
|
||||||
|
ALTER TABLE "Product" ADD COLUMN "supplier" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Lead" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"level" TEXT NOT NULL DEFAULT 'C',
|
||||||
|
"status" TEXT NOT NULL DEFAULT '待复核',
|
||||||
|
"star" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"amount" TEXT NOT NULL DEFAULT '待确认',
|
||||||
|
"category" TEXT,
|
||||||
|
"organization" TEXT,
|
||||||
|
"region" TEXT,
|
||||||
|
"source" TEXT,
|
||||||
|
"contact" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"email" TEXT,
|
||||||
|
"deadline" TEXT,
|
||||||
|
"summary" TEXT,
|
||||||
|
"reason" TEXT,
|
||||||
|
"analysisLogic" TEXT,
|
||||||
|
"aiScore" REAL,
|
||||||
|
"scoringDetails" TEXT,
|
||||||
|
"publish" TEXT NOT NULL DEFAULT '未上架',
|
||||||
|
"publishedAt" DATETIME,
|
||||||
|
"inquiryNo" TEXT,
|
||||||
|
"direvUrl" TEXT,
|
||||||
|
"assignedTo" TEXT,
|
||||||
|
"followLogs" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Rule" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"config" TEXT NOT NULL,
|
||||||
|
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"userId" TEXT NOT NULL DEFAULT 'default',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RuleVersion" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"ruleId" TEXT NOT NULL,
|
||||||
|
"version" INTEGER NOT NULL,
|
||||||
|
"config" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Report" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'generating',
|
||||||
|
"config" TEXT,
|
||||||
|
"channels" TEXT,
|
||||||
|
"fileUrl" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "AuditLog" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"detail" TEXT NOT NULL,
|
||||||
|
"operator" TEXT NOT NULL,
|
||||||
|
"ip" TEXT,
|
||||||
|
"userId" TEXT NOT NULL DEFAULT 'default',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "CrawlerConfig" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"targetUrl" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"type" TEXT NOT NULL DEFAULT 'bid',
|
||||||
|
"schedule" TEXT,
|
||||||
|
"lastRunAt" DATETIME,
|
||||||
|
"totalRuns" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"successRuns" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"failedRuns" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Inquiry" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"budget" TEXT,
|
||||||
|
"deadline" TEXT,
|
||||||
|
"buyer" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"level" TEXT NOT NULL DEFAULT 'C',
|
||||||
|
"star" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"region" TEXT,
|
||||||
|
"contact" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"aiScore" REAL,
|
||||||
|
"assignedTo" TEXT,
|
||||||
|
"inquiryNo" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Inquiry" ("budget", "buyer", "category", "createdAt", "deadline", "id", "source", "status", "title", "updatedAt", "url") SELECT "budget", "buyer", "category", "createdAt", "deadline", "id", "source", "status", "title", "updatedAt", "url" FROM "Inquiry";
|
||||||
|
DROP TABLE "Inquiry";
|
||||||
|
ALTER TABLE "new_Inquiry" RENAME TO "Inquiry";
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'user',
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Tenant" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"companyName" TEXT NOT NULL,
|
||||||
|
"contactName" TEXT,
|
||||||
|
"contactEmail" TEXT,
|
||||||
|
"plan" TEXT NOT NULL DEFAULT 'basic',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'active',
|
||||||
|
"validFrom" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"validUntil" DATETIME NOT NULL,
|
||||||
|
"platformType" TEXT,
|
||||||
|
"platformUrl" TEXT,
|
||||||
|
"platformApiKey" TEXT,
|
||||||
|
"features" TEXT,
|
||||||
|
"usageQuotas" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'user',
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"tenantId" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("createdAt", "email", "id", "isActive", "name", "password", "phone", "role", "updatedAt") SELECT "createdAt", "email", "id", "isActive", "name", "password", "phone", "role", "updatedAt" FROM "User";
|
||||||
|
DROP TABLE "User";
|
||||||
|
ALTER TABLE "new_User" RENAME TO "User";
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "SystemConfig" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"group" TEXT NOT NULL DEFAULT 'general',
|
||||||
|
"description" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT,
|
||||||
|
"username" TEXT,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"phone" TEXT,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'user',
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"tenantId" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "User_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_User" ("createdAt", "email", "id", "isActive", "name", "password", "phone", "role", "tenantId", "updatedAt") SELECT "createdAt", "email", "id", "isActive", "name", "password", "phone", "role", "tenantId", "updatedAt" FROM "User";
|
||||||
|
DROP TABLE "User";
|
||||||
|
ALTER TABLE "new_User" RENAME TO "User";
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CrawlerConfig" ADD COLUMN "categories" TEXT;
|
||||||
|
ALTER TABLE "CrawlerConfig" ADD COLUMN "keywords" TEXT;
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CrawlerConfig" ADD COLUMN "sourceName" TEXT;
|
||||||
|
ALTER TABLE "CrawlerConfig" ADD COLUMN "scriptPath" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ImageLibrary" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"productName" TEXT NOT NULL,
|
||||||
|
"keyword" TEXT,
|
||||||
|
"filePath" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL DEFAULT 'baidu',
|
||||||
|
"style" TEXT,
|
||||||
|
"ratio" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Lead" ADD COLUMN "noticeId" TEXT;
|
||||||
|
ALTER TABLE "Lead" ADD COLUMN "detailUrl" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Lead_source_noticeId_key" ON "Lead"("source", "noticeId");
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "GeneratedImage" ADD COLUMN "sourceHash" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Integration" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL DEFAULT 'mcp',
|
||||||
|
"endpoint" TEXT NOT NULL,
|
||||||
|
"appId" TEXT,
|
||||||
|
"apiKey" TEXT,
|
||||||
|
"customerCode" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'inactive',
|
||||||
|
"tools" TEXT,
|
||||||
|
"lastConnected" DATETIME,
|
||||||
|
"error" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
3
apps/api/prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (i.e. Git)
|
||||||
|
provider = "sqlite"
|
||||||
229
apps/api/prisma/schema.prisma
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Lead {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
noticeId String?
|
||||||
|
detailUrl String?
|
||||||
|
level String @default("C")
|
||||||
|
status String @default("待复核")
|
||||||
|
star Int @default(0)
|
||||||
|
amount String @default("待确认")
|
||||||
|
category String?
|
||||||
|
organization String?
|
||||||
|
region String?
|
||||||
|
regionDetail String? // 详细收货地址(区别于 region 省份/区域)
|
||||||
|
source String?
|
||||||
|
contact String?
|
||||||
|
phone String?
|
||||||
|
email String?
|
||||||
|
deadline String?
|
||||||
|
quantity Int? // 询价数量
|
||||||
|
unit String? // 单位(件/吨/米/台等)
|
||||||
|
attachments String? // JSON 数组字符串(附件 URL 列表)
|
||||||
|
summary String?
|
||||||
|
reason String?
|
||||||
|
analysisLogic String?
|
||||||
|
aiScore Float?
|
||||||
|
scoringDetails String?
|
||||||
|
publish String @default("未发布")
|
||||||
|
publishTime String? // 原始公告的发布时间(从抓取源读取)
|
||||||
|
publishedAt DateTime?
|
||||||
|
inquiryNo String?
|
||||||
|
direvUrl String?
|
||||||
|
assignedTo String?
|
||||||
|
followLogs String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([source, noticeId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Rule {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
type String
|
||||||
|
config String
|
||||||
|
enabled Boolean @default(true)
|
||||||
|
userId String @default("default")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model RuleVersion {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
ruleId String
|
||||||
|
version Int
|
||||||
|
config String
|
||||||
|
userId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Report {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
type String
|
||||||
|
status String @default("generating")
|
||||||
|
config String?
|
||||||
|
channels String?
|
||||||
|
fileUrl String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model AuditLog {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
action String
|
||||||
|
detail String
|
||||||
|
operator String
|
||||||
|
ip String?
|
||||||
|
userId String @default("default")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model CrawlerConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
targetUrl String
|
||||||
|
status String @default("active")
|
||||||
|
type String @default("bid")
|
||||||
|
sourceName String?
|
||||||
|
scriptPath String?
|
||||||
|
schedule String?
|
||||||
|
keywords String?
|
||||||
|
categories String?
|
||||||
|
lastRunAt DateTime?
|
||||||
|
totalRuns Int @default(0)
|
||||||
|
successRuns Int @default(0)
|
||||||
|
failedRuns Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Inquiry {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
source String
|
||||||
|
url String
|
||||||
|
budget String?
|
||||||
|
deadline String?
|
||||||
|
buyer String
|
||||||
|
category String
|
||||||
|
status String @default("pending")
|
||||||
|
level String @default("C")
|
||||||
|
star Int @default(0)
|
||||||
|
region String?
|
||||||
|
contact String?
|
||||||
|
phone String?
|
||||||
|
aiScore Float?
|
||||||
|
assignedTo String?
|
||||||
|
inquiryNo String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
spec String
|
||||||
|
unit String
|
||||||
|
price Float
|
||||||
|
quantity Int
|
||||||
|
imageUrl String?
|
||||||
|
category String
|
||||||
|
status String @default("draft")
|
||||||
|
brand String?
|
||||||
|
supplier String?
|
||||||
|
minOrder Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tenant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
companyName String
|
||||||
|
contactName String?
|
||||||
|
contactEmail String?
|
||||||
|
plan String @default("basic")
|
||||||
|
status String @default("active")
|
||||||
|
validFrom DateTime @default(now())
|
||||||
|
validUntil DateTime
|
||||||
|
platformType String?
|
||||||
|
platformUrl String?
|
||||||
|
platformApiKey String?
|
||||||
|
features String?
|
||||||
|
usageQuotas String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
users User[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String? @unique
|
||||||
|
username String? @unique
|
||||||
|
password String
|
||||||
|
name String?
|
||||||
|
phone String?
|
||||||
|
role String @default("user")
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
tenantId String?
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model GeneratedImage {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
productName String
|
||||||
|
url String
|
||||||
|
status String @default("generating")
|
||||||
|
style String?
|
||||||
|
sourceHash String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model ImageLibrary {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
productName String
|
||||||
|
keyword String?
|
||||||
|
filePath String
|
||||||
|
source String @default("baidu")
|
||||||
|
style String?
|
||||||
|
ratio String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model SystemConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
key String @unique
|
||||||
|
value String
|
||||||
|
group String @default("general")
|
||||||
|
description String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Integration {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String // 对接名称,如 "B2B 询价平台"
|
||||||
|
type String @default("mcp") // "mcp" | "http" | "custom"
|
||||||
|
endpoint String // MCP server 地址 (devbox 开发机), 如 "http://100.111.84.16:3002/mcp"
|
||||||
|
appId String? // B2B 运营后台"集成管理"申请到的 appId (公开 ID, 非敏感)
|
||||||
|
apiKey String? // appSecret (加密存储) - 对应 B2B 运营后台 app_secret
|
||||||
|
customerCode String? // 客户编码 (冗余, 实际由 MCP 验证后从 X-Customer-Code 头获取)
|
||||||
|
status String @default("inactive") // "active" | "inactive" | "error"
|
||||||
|
tools String? // JSON: 可用工具列表
|
||||||
|
lastConnected DateTime? // 最后成功连接时间
|
||||||
|
error String? // 最近错误信息
|
||||||
|
companyUserId String? // W31: 公司账号 ID (从 B2B 后端 open_api_app 表来, 用于一键发布时自动填 enterpriseId/userId; 用 String 存避免 JS Number 精度丢失)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
269
apps/api/prisma/seed.ts
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client'
|
||||||
|
import * as bcrypt from 'bcrypt'
|
||||||
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const hashed = await bcrypt.hash('admin123', 10)
|
||||||
|
|
||||||
|
// ---- 租户 ----
|
||||||
|
const demoCompany = await prisma.tenant.upsert({
|
||||||
|
where: { id: 'demo-tenant-001' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: 'demo-tenant-001',
|
||||||
|
companyName: '示例科技公司', contactName: '张三', contactEmail: 'contact@example.com',
|
||||||
|
plan: 'enterprise', status: 'active',
|
||||||
|
validFrom: new Date('2026-01-01'), validUntil: new Date('2027-01-01'),
|
||||||
|
platformType: 'jubian', platformUrl: 'https://jubian.example.com',
|
||||||
|
features: JSON.stringify({ inquiry: true, imageGen: true, product: true, comparison: true }),
|
||||||
|
usageQuotas: JSON.stringify({ monthlyLeads: 10000, monthlyImages: 500, monthlyProducts: 5000, monthlyComparisons: 200, maxUsers: 50 }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const farEast = await prisma.tenant.upsert({
|
||||||
|
where: { id: 'demo-tenant-002' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: 'demo-tenant-002',
|
||||||
|
companyName: '远东电缆有限公司', contactName: '李四', contactEmail: 'li@fareast-cable.com',
|
||||||
|
plan: 'pro', status: 'active',
|
||||||
|
validFrom: new Date('2026-03-01'), validUntil: new Date('2026-05-31'),
|
||||||
|
platformType: 'custom', platformUrl: 'https://erp.fareast-cable.com',
|
||||||
|
features: JSON.stringify({ inquiry: true, imageGen: false, product: true, comparison: true }),
|
||||||
|
usageQuotas: JSON.stringify({ monthlyLeads: 5000, monthlyImages: 0, monthlyProducts: 2000, monthlyComparisons: 100, maxUsers: 20 }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const shangshang = await prisma.tenant.upsert({
|
||||||
|
where: { id: 'demo-tenant-003' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: 'demo-tenant-003',
|
||||||
|
companyName: '上上电缆集团', contactName: '王五', contactEmail: 'wang@shangshang.com',
|
||||||
|
plan: 'basic', status: 'active',
|
||||||
|
validFrom: new Date('2026-04-01'), validUntil: new Date('2027-04-01'),
|
||||||
|
features: JSON.stringify({ inquiry: true, imageGen: false, product: true, comparison: false }),
|
||||||
|
usageQuotas: JSON.stringify({ monthlyLeads: 1000, monthlyImages: 0, monthlyProducts: 500, monthlyComparisons: 0, maxUsers: 5 }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const minxing = await prisma.tenant.upsert({
|
||||||
|
where: { id: 'demo-tenant-004' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
id: 'demo-tenant-004',
|
||||||
|
companyName: '民兴电缆科技', contactName: '赵六', contactEmail: 'zhao@minxing.com',
|
||||||
|
plan: 'basic', status: 'expired',
|
||||||
|
validFrom: new Date('2025-01-01'), validUntil: new Date('2026-01-01'),
|
||||||
|
features: JSON.stringify({ inquiry: true, imageGen: false, product: false, comparison: false }),
|
||||||
|
usageQuotas: JSON.stringify({ monthlyLeads: 500, monthlyImages: 0, monthlyProducts: 0, monthlyComparisons: 0, maxUsers: 3 }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 用户 ----
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@platform.com' },
|
||||||
|
update: {},
|
||||||
|
create: { email: 'admin@platform.com', username: 'admin', password: hashed, name: '管理员', role: 'admin', tenantId: demoCompany.id },
|
||||||
|
})
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'user@example.com' },
|
||||||
|
update: {},
|
||||||
|
create: { email: 'user@example.com', username: 'user', password: hashed, name: '测试用户', role: 'user', tenantId: demoCompany.id },
|
||||||
|
})
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'li@fareast-cable.com' },
|
||||||
|
update: {},
|
||||||
|
create: { email: 'li@fareast-cable.com', username: 'li.fareast', password: hashed, name: '李四', role: 'user', tenantId: farEast.id },
|
||||||
|
})
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'wang@shangshang.com' },
|
||||||
|
update: {},
|
||||||
|
create: { email: 'wang@shangshang.com', username: 'wang', password: hashed, name: '王五', role: 'user', tenantId: shangshang.id },
|
||||||
|
})
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'zhao@minxing.com' },
|
||||||
|
update: {},
|
||||||
|
create: { email: 'zhao@minxing.com', username: 'zhao', password: hashed, name: '赵六', role: 'user', tenantId: minxing.id },
|
||||||
|
})
|
||||||
|
await prisma.rule.createMany({
|
||||||
|
data: [
|
||||||
|
{ name: '综合评分权重', type: 'scoring', config: JSON.stringify({ weights: [{ field: '预估金额', weight: 30 }, { field: '匹配度', weight: 25 }, { field: '甲方级别', weight: 20 }, { field: '时效性', weight: 15 }, { field: '历史成交', weight: 10 }] }), enabled: true },
|
||||||
|
{ name: '防水材料关键词库', type: 'keyword', config: JSON.stringify({ keywords: ['防水卷材', '防水涂料', '聚氨酯', 'SBS改性沥青', '高分子防水'], category: '防水材料', priority: 8 }), enabled: true },
|
||||||
|
{ name: '电力设备关键词库', type: 'keyword', config: JSON.stringify({ keywords: ['变压器', '断路器', '电缆', '开关柜', 'GIS'], category: '电力设备', priority: 9 }), enabled: true },
|
||||||
|
{ name: '钢材关键词库', type: 'keyword', config: JSON.stringify({ keywords: ['钢板', '钢管', '型钢', '螺纹钢', 'H型钢'], category: '钢材', priority: 7 }), enabled: true },
|
||||||
|
{ name: '甲方分级标准', type: 'client_level', config: JSON.stringify({ levels: [{ name: 'S级', minAmount: 1000000, minCooperations: 3 }, { name: 'A级', minAmount: 500000, minCooperations: 1 }, { name: 'B级', minAmount: 100000 }, { name: 'C级', minAmount: 10000 }, { name: 'D级' }] }), enabled: true },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.report.createMany({
|
||||||
|
data: [
|
||||||
|
{ title: '5月第3周线索数据报告', type: '线索', status: '已完成', config: JSON.stringify({ dateRange: '2024-05-13~2024-05-19', sources: ['中国采购与招标网', '中国电建阳光采购网'], categories: ['电力设备', '钢材', 'IT设备'] }), channels: JSON.stringify(['飞书', '邮箱']) },
|
||||||
|
{ title: '5月询价发布统计', type: '询价', status: '已完成', config: JSON.stringify({ dateRange: '2024-05-01~2024-05-20' }) },
|
||||||
|
{ title: '本周商品上架报告', type: '商品', status: '生成中', config: JSON.stringify({ dateRange: '2024-05-20~2024-05-22' }) },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.auditLog.createMany({
|
||||||
|
data: [
|
||||||
|
{ action: '登录', detail: '用户 admin 登录系统', operator: 'admin', ip: '192.168.1.100' },
|
||||||
|
{ action: '修改规则', detail: '修改了评分权重配置', operator: 'admin', ip: '192.168.1.100' },
|
||||||
|
{ action: '导出报告', detail: '导出了5月第3周线索数据报告', operator: 'admin', ip: '192.168.1.100' },
|
||||||
|
{ action: '删除线索', detail: '删除了2条无效线索', operator: 'admin', ip: '192.168.1.100' },
|
||||||
|
{ action: '批量上架', detail: '批量上架了5个商品', operator: 'admin', ip: '192.168.1.100' },
|
||||||
|
{ action: '修改规则', detail: '新增了钢材品类关键词', operator: 'admin', ip: '192.168.1.100' },
|
||||||
|
{ action: '导出报告', detail: '导出了5月询价发布统计', operator: 'admin', ip: '192.168.1.100' },
|
||||||
|
{ action: '登录', detail: '用户 admin 登录系统', operator: 'admin', ip: '192.168.1.100' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.crawlerConfig.deleteMany()
|
||||||
|
await prisma.crawlerConfig.createMany({
|
||||||
|
data: [
|
||||||
|
{ name: '中国电建阳光采购网', targetUrl: 'https://bid.powerchina.cn', type: 'bid_powerchina', sourceName: '中电建', status: 'active', schedule: '0 8 * * 1-5', totalRuns: 286, successRuns: 275, failedRuns: 11, keywords: '["电缆","防火门","配电箱","钢材"]' },
|
||||||
|
{ name: '新矿集团供应链平台', targetUrl: 'https://xkgroup.kdcloud.com', type: 'bid_xkgroup', sourceName: '新矿集团', scriptPath: 'fetch-xkgroup.py', status: 'active', schedule: '5 8 * * 1-5', totalRuns: 142, successRuns: 138, failedRuns: 4, keywords: '["电缆","钢材","阀门","管材"]' },
|
||||||
|
{ name: '中核集团电子采购平台', targetUrl: 'https://www.cnncecp.com', type: 'bid_cnncecp', sourceName: '中核集团', scriptPath: 'fetch-cnncecp.py', status: 'paused', totalRuns: 0, successRuns: 0, failedRuns: 0, keywords: '["电缆","防火门","钢材","阀门"]' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.inquiry.createMany({
|
||||||
|
data: [
|
||||||
|
{ title: '2024年电力电缆框架采购项目', source: '中国电建阳光采购网', url: 'https://example.com/inquiry/001', budget: '500万元', deadline: '2024-12-31', buyer: '中国电力建设集团', category: '电力设备', status: 'pending', level: 'S', star: 5, region: '北京' },
|
||||||
|
{ title: '钢结构桥梁用钢板采购招标', source: '中国中铁采购电子商务平台', url: 'https://example.com/inquiry/002', budget: '1200万元', deadline: '2024-11-15', buyer: '中铁大桥局', category: '钢材', status: 'published', level: 'A', star: 4, region: '湖北武汉' },
|
||||||
|
{ title: '数据中心服务器及网络设备采购', source: '中国移动采购与招标网', url: 'https://example.com/inquiry/003', budget: '800万元', deadline: '2024-10-20', buyer: '中国移动通信集团', category: 'IT设备', status: 'pending', level: 'S', star: 5, region: '北京' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const cableSKUs = [
|
||||||
|
{ name: 'YJV-0.6/1kV 3×4 电力电缆', spec: '0.6/1kV 3×4mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'YJV-0.6/1kV 3×6 电力电缆', spec: '0.6/1kV 3×6mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'YJV-0.6/1kV 3×10 电力电缆', spec: '0.6/1kV 3×10mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'YJV22-8.7/15kV 3×70 电力电缆', spec: '8.7/15kV 3×70mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'YJV22-8.7/15kV 3×95 电力电缆', spec: '8.7/15kV 3×95mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'YJV22-8.7/15kV 3×120 电力电缆', spec: '8.7/15kV 3×120mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'YJV22-8.7/15kV 3×150 电力电缆', spec: '8.7/15kV 3×150mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'YJV22-8.7/15kV 3×185 电力电缆', spec: '8.7/15kV 3×185mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'YJV22-8.7/15kV 3×240 电力电缆', spec: '8.7/15kV 3×240mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'YJV22-8.7/15kV 3×300 电力电缆', spec: '8.7/15kV 3×300mm²', unit: '米', category: '电力电缆' },
|
||||||
|
{ name: 'BV-450/750V 2.5mm² 家装电线', spec: '450/750V 2.5mm²', unit: '米', category: '家装电线' },
|
||||||
|
{ name: 'BV-450/750V 4mm² 家装电线', spec: '450/750V 4mm²', unit: '米', category: '家装电线' },
|
||||||
|
{ name: 'BV-450/750V 6mm² 家装电线', spec: '450/750V 6mm²', unit: '米', category: '家装电线' },
|
||||||
|
{ name: 'KVVP-0.45/0.75kV 14×1.5 控制电缆', spec: '0.45/0.75kV 14×1.5mm²', unit: '米', category: '控制电缆' },
|
||||||
|
{ name: 'RVVP-300/300V 4×0.75 屏蔽软电缆', spec: '300/300V 4×0.75mm²', unit: '米', category: '控制电缆' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const suppliers = [
|
||||||
|
{ name: '远东电缆', brand: '远东电缆' },
|
||||||
|
{ name: '上上电缆', brand: '上上电缆' },
|
||||||
|
{ name: '民兴电缆', brand: '民兴电缆' },
|
||||||
|
{ name: '金杯电缆', brand: '金杯电缆' },
|
||||||
|
{ name: '金龙羽', brand: '金龙羽' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 价格矩阵:每行是一个商品,每列是对应供应商的价格
|
||||||
|
// 设计逻辑:每个供应商在不同商品上有价格优势,使得 2/3/4 家组合的最优方案不同
|
||||||
|
// 远东: 商品0-2最便宜 | 上上: 商品3-5最便宜 | 民兴: 商品6-8最便宜 | 金杯: 商品9-11最便宜 | 金龙羽: 商品12-14最便宜
|
||||||
|
const priceMatrix: number[][] = [
|
||||||
|
// SKU 远东 上上 民兴 金杯 金龙羽
|
||||||
|
/* YJV 3×4 电力电缆 */ [12.00, 12.80, 13.50, 13.20, 14.00],
|
||||||
|
/* YJV 3×6 电力电缆 */ [18.00, 19.20, 20.10, 19.50, 21.00],
|
||||||
|
/* YJV 3×10 电力电缆 */ [27.50, 29.00, 30.50, 29.80, 32.00],
|
||||||
|
/* YJV22 3×70 电力电缆 */ [190.00, 182.00, 195.00, 188.00, 200.00],
|
||||||
|
/* YJV22 3×95 电力电缆 */ [250.00, 238.00, 260.00, 248.00, 265.00],
|
||||||
|
/* YJV22 3×120 电力电缆 */[320.00, 305.00, 330.00, 315.00, 340.00],
|
||||||
|
/* YJV22 3×150 电力电缆 */[410.00, 395.00, 385.00, 400.00, 420.00],
|
||||||
|
/* YJV22 3×185 电力电缆 */[500.00, 480.00, 465.00, 475.00, 510.00],
|
||||||
|
/* YJV22 3×240 电力电缆 */[650.00, 630.00, 615.00, 625.00, 670.00],
|
||||||
|
/* YJV22 3×300 电力电缆 */[810.00, 790.00, 780.00, 770.00, 830.00],
|
||||||
|
/* BV 2.5mm² 家装电线 */ [1.85, 1.80, 1.75, 1.68, 1.90],
|
||||||
|
/* BV 4mm² 家装电线 */ [2.70, 2.60, 2.55, 2.45, 2.80],
|
||||||
|
/* BV 6mm² 家装电线 */ [4.30, 4.15, 4.10, 3.95, 4.50],
|
||||||
|
/* KVVP 控制电缆 */ [40.00, 38.50, 37.00, 36.00, 42.00],
|
||||||
|
/* RVVP 屏蔽软电缆 */ [23.00, 22.00, 21.50, 21.00, 24.00],
|
||||||
|
]
|
||||||
|
|
||||||
|
// 每个商品的数量
|
||||||
|
const quantities = [3000, 2500, 2000, 1500, 1200, 1000, 800, 600, 500, 400, 5000, 4000, 3000, 800, 600]
|
||||||
|
|
||||||
|
const productData: any[] = []
|
||||||
|
for (let si = 0; si < suppliers.length; si++) {
|
||||||
|
const s = suppliers[si]
|
||||||
|
for (let pi = 0; pi < cableSKUs.length; pi++) {
|
||||||
|
productData.push({
|
||||||
|
name: cableSKUs[pi].name,
|
||||||
|
spec: cableSKUs[pi].spec,
|
||||||
|
unit: cableSKUs[pi].unit,
|
||||||
|
category: cableSKUs[pi].category,
|
||||||
|
price: priceMatrix[pi][si],
|
||||||
|
quantity: quantities[pi],
|
||||||
|
status: 'draft',
|
||||||
|
brand: s.brand,
|
||||||
|
supplier: s.name,
|
||||||
|
minOrder: 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.product.deleteMany()
|
||||||
|
await prisma.product.createMany({ data: productData })
|
||||||
|
|
||||||
|
// Default system configs
|
||||||
|
const defaultConfigs = [
|
||||||
|
{ key: 'llm_provider', value: 'minimax', group: 'ai', description: '大模型供应商(minimax / openai)' },
|
||||||
|
{ key: 'llm_model', value: 'abab-6.5s-chat', group: 'ai', description: '对话模型名称' },
|
||||||
|
{ key: 'image_provider', value: 'minimax', group: 'ai', description: '图片生成供应商(minimax)' },
|
||||||
|
{ key: 'mmx_group_id', value: 'mg-20260301-abc123', group: 'ai', description: 'MiniMax 分组 ID' },
|
||||||
|
{ key: 'mmx_api_key', value: 'sk-mmx-xxxxxxxxxxxxxxxx', group: 'ai', description: 'MiniMax API 密钥' },
|
||||||
|
{ key: 'jubian_api_url', value: 'https://api.jubian.com/v1', group: 'platform', description: '巨变平台 API 地址' },
|
||||||
|
{ key: 'jubian_api_key', value: 'jb-sk-xxxxxxxxxxxxxxxx', group: 'platform', description: '巨变平台 API 密钥' },
|
||||||
|
{ key: 'custom_api_url', value: '', group: 'platform', description: '自建平台 API 地址' },
|
||||||
|
{ key: 'custom_api_key', value: '', group: 'platform', description: '自建平台 API 密钥' },
|
||||||
|
{ key: 'upload_max_size', value: '20', group: 'system', description: '上传文件大小限制(MB)' },
|
||||||
|
{ key: 'session_timeout', value: '120', group: 'system', description: '会话超时时间(分钟)' },
|
||||||
|
{ key: 'maintenance_mode', value: 'false', group: 'system', description: '系统维护模式(true/false)' },
|
||||||
|
]
|
||||||
|
for (const cfg of defaultConfigs) {
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { key: cfg.key },
|
||||||
|
update: {},
|
||||||
|
create: cfg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default plan config
|
||||||
|
await prisma.systemConfig.upsert({
|
||||||
|
where: { key: 'plan_config' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
key: 'plan_config',
|
||||||
|
value: JSON.stringify({
|
||||||
|
basic: {
|
||||||
|
label: '基础版', price: 3000, description: '小型团队起步',
|
||||||
|
features: { inquiry: true, imageGen: false, product: true, comparison: false },
|
||||||
|
quotas: { monthlyLeads: 100, monthlyImages: 0, monthlyProducts: 100, monthlyComparisons: 0, maxUsers: 5 },
|
||||||
|
platformCount: 1, supportLevel: '社区支持',
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
label: '专业版', price: 6000, description: '中型企业',
|
||||||
|
features: { inquiry: true, imageGen: true, product: true, comparison: true },
|
||||||
|
quotas: { monthlyLeads: 500, monthlyImages: 50, monthlyProducts: 500, monthlyComparisons: 100, maxUsers: 20 },
|
||||||
|
platformCount: 3, supportLevel: '标准支持',
|
||||||
|
},
|
||||||
|
enterprise: {
|
||||||
|
label: '旗舰版', price: 9000, description: '大型企业/多租户',
|
||||||
|
features: { inquiry: true, imageGen: true, product: true, comparison: true },
|
||||||
|
quotas: { monthlyLeads: 99999, monthlyImages: 99999, monthlyProducts: 99999, monthlyComparisons: 99999, maxUsers: 99999 },
|
||||||
|
platformCount: 999, supportLevel: '优先技术支持',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
group: 'system',
|
||||||
|
description: '订阅方案配置(JSON)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Seed data created')
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => console.error(e))
|
||||||
|
.finally(() => prisma.$disconnect())
|
||||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 88 KiB |
BIN
apps/api/public/uploads/library/安全防坠链条_1782112839566.jpg
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
apps/api/public/uploads/library/开关箱_1782112687162.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/api/public/uploads/library/彩条布_1782112637957.jpg
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
apps/api/public/uploads/library/接地针_1782112826904.jpg
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
apps/api/public/uploads/library/电力电缆_1780015856016.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/api/public/uploads/library/盖土网_1782112623323.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/api/public/uploads/library/线缆支架_1782112752321.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/api/public/uploads/library/螺纹钢_1780021781427.jpg
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
apps/api/public/uploads/library/螺纹钢_1780469942307.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
apps/api/public/uploads/library/配电箱_1780470336247.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/api/public/uploads/library/配电箱_1780911303106.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
apps/api/public/uploads/library/钢材_1780027888402.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
apps/api/public/uploads/library/钢材_1780301816602.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/api/public/uploads/library/钢材_1780303172953.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
apps/api/public/uploads/library/阀门_1780022802575.jpg
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
apps/api/public/uploads/library/阀门_1780303259334.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
apps/api/public/uploads/library/阀门_1780361226104.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
apps/api/public/uploads/library/阀门_1780361234877.jpg
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
apps/api/public/uploads/library/防水涂料_1780023753944.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
apps/api/public/uploads/library/防水涂料_1780299916714.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
apps/api/public/uploads/library/防水涂料_1780303134297.jpg
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
apps/api/public/uploads/library/防火门_1780021074049.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
apps/api/public/uploads/library/防火门_1780322405325.jpg
Normal file
|
After Width: | Height: | Size: 53 KiB |
BIN
apps/api/public/uploads/library/防火门_1780469738173.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
apps/api/public/uploads/library/防火门_1780469792215.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
apps/api/public/uploads/product_1780041241207_e7bwwz.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
apps/api/public/uploads/安全防坠链条_1779693726664_001.jpg
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/api/public/uploads/安全防坠链条_1779693745272_001.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/api/public/uploads/安全防坠链条_1779780150671_001.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/api/public/uploads/安全防坠链条_1779780168617_001.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/api/public/uploads/安全防坠链条_1779958038198_001.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/api/public/uploads/安全防坠链条_1779958057126_001.jpg
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
apps/api/public/uploads/开关箱_1779693674142_001.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
apps/api/public/uploads/开关箱_1779779910611_001.jpg
Normal file
|
After Width: | Height: | Size: 191 KiB |
BIN
apps/api/public/uploads/开关箱_1779780096575_001.jpg
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
apps/api/public/uploads/开关箱_1779957977814_001.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
apps/api/public/uploads/开关箱_1782112733419_001.jpg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
apps/api/public/uploads/彩条布_1779693621007_001.jpg
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
apps/api/public/uploads/彩条布_1779779856620_001.jpg
Normal file
|
After Width: | Height: | Size: 474 KiB |
BIN
apps/api/public/uploads/彩条布_1779780041212_001.jpg
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
apps/api/public/uploads/彩条布_1779957918022_001.jpg
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
apps/api/public/uploads/彩条布_1782112667969_001.jpg
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
apps/api/public/uploads/接地针_1779693708336_001.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/api/public/uploads/接地针_1779780132449_001.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/api/public/uploads/接地针_1779958018209_001.jpg
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
apps/api/public/uploads/消防防汛专用沙袋_1779693762535_001.jpg
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
apps/api/public/uploads/消防防汛专用沙袋_1779958076379_001.jpg
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
apps/api/public/uploads/盖土网_1779693538234_001.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
apps/api/public/uploads/盖土网_1779693602958_001.jpg
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
apps/api/public/uploads/盖土网_1779779837708_001.jpg
Normal file
|
After Width: | Height: | Size: 346 KiB |
BIN
apps/api/public/uploads/盖土网_1779780024276_001.jpg
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
apps/api/public/uploads/盖土网_1779957897377_001.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
apps/api/public/uploads/线缆支架_1779693690954_001.jpg
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
apps/api/public/uploads/线缆支架_1779779926892_001.jpg
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
apps/api/public/uploads/线缆支架_1779780114438_001.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
apps/api/public/uploads/线缆支架_1779957998316_001.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
apps/api/public/uploads/线缆支架_1782112806436_001.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
apps/api/public/uploads/螺纹钢_1780469964942_001.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
apps/api/public/uploads/配电箱_1779693639318_001.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
apps/api/public/uploads/配电箱_1779693656256_001.jpg
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
apps/api/public/uploads/配电箱_1779779875011_001.jpg
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
apps/api/public/uploads/配电箱_1779779893211_001.jpg
Normal file
|
After Width: | Height: | Size: 214 KiB |
BIN
apps/api/public/uploads/配电箱_1779780059559_001.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
apps/api/public/uploads/配电箱_1779780078914_001.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
apps/api/public/uploads/配电箱_1779957937988_001.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
apps/api/public/uploads/配电箱_1779957957819_001.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
apps/api/public/uploads/钢材_1780027918411_001.jpg
Normal file
|
After Width: | Height: | Size: 203 KiB |
95
apps/api/src/admin/admin.controller.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { Controller, Get, Post, Patch, Put, Delete, Body, Param, UseGuards } from '@nestjs/common'
|
||||||
|
import { AuthGuard } from '@nestjs/passport'
|
||||||
|
import { AdminService } from './admin.service'
|
||||||
|
import { AdminGuard } from './admin.guard'
|
||||||
|
|
||||||
|
@UseGuards(AuthGuard('jwt'), AdminGuard)
|
||||||
|
@Controller('api/admin')
|
||||||
|
export class AdminController {
|
||||||
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
|
// ---- Tenants ----
|
||||||
|
|
||||||
|
@Get('tenants')
|
||||||
|
findAllTenants() {
|
||||||
|
return this.adminService.findAllTenants()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tenants/:id')
|
||||||
|
findOneTenant(@Param('id') id: string) {
|
||||||
|
return this.adminService.findOneTenant(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('tenants')
|
||||||
|
createTenant(@Body() body: any) {
|
||||||
|
return this.adminService.createTenant(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('tenants/:id')
|
||||||
|
updateTenant(@Param('id') id: string, @Body() body: any) {
|
||||||
|
return this.adminService.updateTenant(id, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('tenants/:id')
|
||||||
|
deleteTenant(@Param('id') id: string) {
|
||||||
|
return this.adminService.deleteTenant(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Users ----
|
||||||
|
|
||||||
|
@Get('users')
|
||||||
|
findAllUsers() {
|
||||||
|
return this.adminService.findAllUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users')
|
||||||
|
createUser(@Body('username') username: string, @Body('password') password: string, @Body('name') name: string, @Body('tenantId') tenantId: string) {
|
||||||
|
return this.adminService.createUser({ username, password, name, tenantId })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:id/reset-password')
|
||||||
|
resetPassword(@Param('id') id: string, @Body('password') password: string) {
|
||||||
|
return this.adminService.resetUserPassword(id, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('users/:id/toggle-active')
|
||||||
|
toggleActive(@Param('id') id: string) {
|
||||||
|
return this.adminService.toggleUserActive(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Plan Config ----
|
||||||
|
|
||||||
|
@Get('plan-config')
|
||||||
|
getPlanConfig() {
|
||||||
|
return this.adminService.getPlanConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('plan-config')
|
||||||
|
updatePlanConfig(@Body() body: Record<string, any>) {
|
||||||
|
return this.adminService.updatePlanConfig(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- System Config ----
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
findAllConfigs() {
|
||||||
|
return this.adminService.findAllConfigs()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('configs')
|
||||||
|
upsertConfig(@Body('key') key: string, @Body('value') value: string, @Body('group') group: string, @Body('description') description?: string) {
|
||||||
|
return this.adminService.upsertConfig(key, value, group, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('configs/:id')
|
||||||
|
deleteConfig(@Param('id') id: string) {
|
||||||
|
return this.adminService.deleteConfig(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Stats ----
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
getStats() {
|
||||||
|
return this.adminService.getStats()
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/admin/admin.guard.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminGuard implements CanActivate {
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest()
|
||||||
|
const user = request.user
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
throw new ForbiddenException('仅管理员可操作')
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
10
apps/api/src/admin/admin.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { AdminController } from './admin.controller'
|
||||||
|
import { AdminService } from './admin.service'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AdminController],
|
||||||
|
providers: [AdminService],
|
||||||
|
exports: [AdminService],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
267
apps/api/src/admin/admin.service.ts
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
import * as bcrypt from 'bcrypt'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// ---- Tenant CRUD ----
|
||||||
|
|
||||||
|
async findAllTenants() {
|
||||||
|
const tenants = await this.prisma.tenant.findMany({ orderBy: { createdAt: 'desc' } })
|
||||||
|
return tenants.map((t) => ({
|
||||||
|
...t,
|
||||||
|
_userCount: 0,
|
||||||
|
_daysUntilExpiry: Math.ceil((new Date(t.validUntil).getTime() - Date.now()) / 86400000),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOneTenant(id: string) {
|
||||||
|
const tenant = await this.prisma.tenant.findUnique({ where: { id }, include: { users: true } })
|
||||||
|
if (!tenant) throw new NotFoundException('租户不存在')
|
||||||
|
return tenant
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTenant(data: {
|
||||||
|
companyName: string
|
||||||
|
contactName?: string
|
||||||
|
contactEmail?: string
|
||||||
|
plan: string
|
||||||
|
status: string
|
||||||
|
validFrom?: string
|
||||||
|
validUntil: string
|
||||||
|
platformType?: string
|
||||||
|
platformUrl?: string
|
||||||
|
platformApiKey?: string
|
||||||
|
features?: string
|
||||||
|
usageQuotas?: string
|
||||||
|
adminUsername?: string
|
||||||
|
adminPassword?: string
|
||||||
|
adminName?: string
|
||||||
|
}) {
|
||||||
|
const { adminUsername, adminPassword, adminName, ...tenantData } = data
|
||||||
|
|
||||||
|
const tenant = await this.prisma.tenant.create({
|
||||||
|
data: {
|
||||||
|
companyName: tenantData.companyName,
|
||||||
|
contactName: tenantData.contactName,
|
||||||
|
contactEmail: tenantData.contactEmail,
|
||||||
|
plan: tenantData.plan,
|
||||||
|
status: tenantData.status,
|
||||||
|
validFrom: tenantData.validFrom ? new Date(tenantData.validFrom) : new Date(),
|
||||||
|
validUntil: new Date(tenantData.validUntil),
|
||||||
|
platformType: tenantData.platformType,
|
||||||
|
platformUrl: tenantData.platformUrl,
|
||||||
|
platformApiKey: tenantData.platformApiKey,
|
||||||
|
features: tenantData.features || JSON.stringify({ inquiry: true, imageGen: true, product: true, comparison: true }),
|
||||||
|
usageQuotas: tenantData.usageQuotas || JSON.stringify({ monthlyLeads: 100, monthlyImages: 50, monthlyProducts: 100, monthlyComparisons: 50, maxUsers: 5 }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (adminUsername && adminPassword) {
|
||||||
|
const existing = await this.prisma.user.findFirst({
|
||||||
|
where: { OR: [{ username: adminUsername }, { email: `${adminUsername}@system.local` }] },
|
||||||
|
})
|
||||||
|
if (existing) throw new ConflictException('管理员用户名已存在')
|
||||||
|
const hashed = await bcrypt.hash(adminPassword, 10)
|
||||||
|
await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: adminUsername,
|
||||||
|
email: `${adminUsername}@system.local`,
|
||||||
|
password: hashed,
|
||||||
|
name: adminName || `${tenantData.companyName}管理员`,
|
||||||
|
role: 'user',
|
||||||
|
tenantId: tenant.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tenant
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTenant(id: string, data: any) {
|
||||||
|
const existing = await this.prisma.tenant.findUnique({ where: { id } })
|
||||||
|
if (!existing) throw new NotFoundException('租户不存在')
|
||||||
|
|
||||||
|
const updateData: any = { ...data }
|
||||||
|
if (data.validFrom) updateData.validFrom = new Date(data.validFrom)
|
||||||
|
if (data.validUntil) updateData.validUntil = new Date(data.validUntil)
|
||||||
|
|
||||||
|
return this.prisma.tenant.update({ where: { id }, data: updateData })
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTenant(id: string) {
|
||||||
|
const existing = await this.prisma.tenant.findUnique({ where: { id } })
|
||||||
|
if (!existing) throw new NotFoundException('租户不存在')
|
||||||
|
|
||||||
|
await this.prisma.user.updateMany({ where: { tenantId: id }, data: { tenantId: null } })
|
||||||
|
return this.prisma.tenant.delete({ where: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- User Management ----
|
||||||
|
|
||||||
|
async findAllUsers() {
|
||||||
|
return this.prisma.user.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: { tenant: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetUserPassword(userId: string, newPassword: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
if (!user) throw new NotFoundException('用户不存在')
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(newPassword, 10)
|
||||||
|
return this.prisma.user.update({ where: { id: userId }, data: { password: hashed } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleUserActive(userId: string) {
|
||||||
|
const user = await this.prisma.user.findUnique({ where: { id: userId } })
|
||||||
|
if (!user) throw new NotFoundException('用户不存在')
|
||||||
|
|
||||||
|
return this.prisma.user.update({ where: { id: userId }, data: { isActive: !user.isActive } })
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: { username: string; password: string; name?: string; tenantId?: string; role?: string }) {
|
||||||
|
const existing = await this.prisma.user.findFirst({
|
||||||
|
where: { OR: [{ username: data.username }, { email: `${data.username}@system.local` }] },
|
||||||
|
})
|
||||||
|
if (existing) throw new ConflictException('用户名已被使用')
|
||||||
|
const hashed = await bcrypt.hash(data.password, 10)
|
||||||
|
const user = await this.prisma.user.create({
|
||||||
|
data: {
|
||||||
|
username: data.username,
|
||||||
|
email: `${data.username}@system.local`,
|
||||||
|
password: hashed,
|
||||||
|
name: data.name,
|
||||||
|
role: data.role || 'user',
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { password, ...result } = user
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Plan Config ----
|
||||||
|
|
||||||
|
async getPlanConfig(): Promise<Record<string, any>> {
|
||||||
|
const row = await this.prisma.systemConfig.findUnique({ where: { key: 'plan_config' } })
|
||||||
|
if (!row) {
|
||||||
|
return this.defaultPlanConfig()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.value)
|
||||||
|
} catch {
|
||||||
|
return this.defaultPlanConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private defaultPlanConfig() {
|
||||||
|
return {
|
||||||
|
basic: {
|
||||||
|
label: '基础版', price: 3000, description: '小型团队起步',
|
||||||
|
features: { inquiry: true, imageGen: false, product: true, comparison: false },
|
||||||
|
quotas: { monthlyLeads: 100, monthlyImages: 0, monthlyProducts: 100, monthlyComparisons: 0, maxUsers: 5 },
|
||||||
|
platformCount: 1, supportLevel: '社区支持',
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
label: '专业版', price: 6000, description: '中型企业',
|
||||||
|
features: { inquiry: true, imageGen: true, product: true, comparison: true },
|
||||||
|
quotas: { monthlyLeads: 500, monthlyImages: 50, monthlyProducts: 500, monthlyComparisons: 100, maxUsers: 20 },
|
||||||
|
platformCount: 3, supportLevel: '标准支持',
|
||||||
|
},
|
||||||
|
enterprise: {
|
||||||
|
label: '旗舰版', price: 9000, description: '大型企业/多租户',
|
||||||
|
features: { inquiry: true, imageGen: true, product: true, comparison: true },
|
||||||
|
quotas: { monthlyLeads: 99999, monthlyImages: 99999, monthlyProducts: 99999, monthlyComparisons: 99999, maxUsers: 99999 },
|
||||||
|
platformCount: 999, supportLevel: '优先技术支持',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePlanConfig(config: Record<string, any>) {
|
||||||
|
await this.prisma.systemConfig.upsert({
|
||||||
|
where: { key: 'plan_config' },
|
||||||
|
update: { value: JSON.stringify(config), group: 'system', description: '订阅方案配置(JSON)' },
|
||||||
|
create: { key: 'plan_config', value: JSON.stringify(config), group: 'system', description: '订阅方案配置(JSON)' },
|
||||||
|
})
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- System Config ----
|
||||||
|
|
||||||
|
async findAllConfigs() {
|
||||||
|
return this.prisma.systemConfig.findMany({ orderBy: [{ group: 'asc' }, { key: 'asc' }] })
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertConfig(key: string, value: string, group: string, description?: string) {
|
||||||
|
return this.prisma.systemConfig.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value, group, description },
|
||||||
|
create: { key, value, group, description },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteConfig(id: string) {
|
||||||
|
return this.prisma.systemConfig.delete({ where: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Platform Stats ----
|
||||||
|
|
||||||
|
async getStats() {
|
||||||
|
const [tenantCount, userCount, globalProductCount, leadCount, imageCount, inquiryCount] =
|
||||||
|
await Promise.all([
|
||||||
|
this.prisma.tenant.count(),
|
||||||
|
this.prisma.user.count(),
|
||||||
|
this.prisma.product.count(),
|
||||||
|
this.prisma.lead.count(),
|
||||||
|
this.prisma.generatedImage.count(),
|
||||||
|
this.prisma.inquiry.count(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const tenants = await this.prisma.tenant.findMany({
|
||||||
|
include: { users: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const allProducts = await this.prisma.product.findMany()
|
||||||
|
|
||||||
|
const perTenant = tenants.map((t) => {
|
||||||
|
const quotas = (() => {
|
||||||
|
try { return JSON.parse(t.usageQuotas || '{}') } catch { return {} }
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Match products to tenant by supplier name overlap
|
||||||
|
const short = t.companyName.replace(/有限公司|集团|股份|科技/g, '')
|
||||||
|
const tenantProducts = allProducts.filter((p) => p.supplier && short.includes(p.supplier))
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
companyName: t.companyName,
|
||||||
|
plan: t.plan,
|
||||||
|
status: t.status,
|
||||||
|
validUntil: t.validUntil,
|
||||||
|
userCount: t.users.length,
|
||||||
|
quotas: {
|
||||||
|
monthlyLeads: quotas.monthlyLeads ?? 0,
|
||||||
|
monthlyImages: quotas.monthlyImages ?? 0,
|
||||||
|
monthlyProducts: quotas.monthlyProducts ?? 0,
|
||||||
|
monthlyComparisons: quotas.monthlyComparisons ?? 0,
|
||||||
|
maxUsers: quotas.maxUsers ?? 0,
|
||||||
|
},
|
||||||
|
usage: {
|
||||||
|
leads: 0,
|
||||||
|
images: 0,
|
||||||
|
products: tenantProducts.length,
|
||||||
|
comparisons: 0,
|
||||||
|
users: t.users.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
overview: { tenantCount, userCount, productCount: globalProductCount, leadCount, imageCount, inquiryCount },
|
||||||
|
perTenant,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
apps/api/src/app.module.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { PrismaModule } from './prisma/prisma.module'
|
||||||
|
import { InquiryModule } from './inquiry/inquiry.module'
|
||||||
|
import { ImageModule } from './image/image.module'
|
||||||
|
import { ProductModule } from './product/product.module'
|
||||||
|
import { ComparisonModule } from './comparison/comparison.module'
|
||||||
|
import { LeadModule } from './lead/lead.module'
|
||||||
|
import { RulesModule } from './rules/rules.module'
|
||||||
|
import { ReportsModule } from './reports/reports.module'
|
||||||
|
import { CrawlerModule } from './crawler/crawler.module'
|
||||||
|
import { AuditLogModule } from './audit-log/audit-log.module'
|
||||||
|
import { AuthModule } from './auth/auth.module'
|
||||||
|
import { UsersModule } from './users/users.module'
|
||||||
|
import { AdminModule } from './admin/admin.module'
|
||||||
|
import { IntegrationModule } from './integration/integration.module'
|
||||||
|
import { HealthController } from './common/health.controller'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
UsersModule,
|
||||||
|
AdminModule,
|
||||||
|
InquiryModule,
|
||||||
|
ImageModule,
|
||||||
|
ProductModule,
|
||||||
|
ComparisonModule,
|
||||||
|
LeadModule,
|
||||||
|
RulesModule,
|
||||||
|
ReportsModule,
|
||||||
|
CrawlerModule,
|
||||||
|
AuditLogModule,
|
||||||
|
IntegrationModule,
|
||||||
|
],
|
||||||
|
controllers: [HealthController],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
12
apps/api/src/audit-log/audit-log.controller.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common'
|
||||||
|
import { AuditLogService } from './audit-log.service'
|
||||||
|
|
||||||
|
@Controller('api/audit-logs')
|
||||||
|
export class AuditLogController {
|
||||||
|
constructor(private readonly auditLogService: AuditLogService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query('limit') limit?: string) {
|
||||||
|
return this.auditLogService.findAll(limit ? parseInt(limit, 10) : undefined)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
apps/api/src/audit-log/audit-log.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module'
|
||||||
|
import { AuditLogService } from './audit-log.service'
|
||||||
|
import { AuditLogController } from './audit-log.controller'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
controllers: [AuditLogController],
|
||||||
|
providers: [AuditLogService],
|
||||||
|
})
|
||||||
|
export class AuditLogModule {}
|
||||||
14
apps/api/src/audit-log/audit-log.service.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable } from '@nestjs/common'
|
||||||
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuditLogService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(limit?: number) {
|
||||||
|
return this.prisma.auditLog.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: limit || 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/api/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Controller, Post, Get, Body, UseGuards, Req } from '@nestjs/common'
|
||||||
|
import { AuthGuard } from '@nestjs/passport'
|
||||||
|
import { AuthService } from './auth.service'
|
||||||
|
|
||||||
|
@Controller('api/auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
@Post('register')
|
||||||
|
register(@Body('email') email: string, @Body('password') password: string, @Body('name') name?: string) {
|
||||||
|
return this.authService.register(email, password, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('login')
|
||||||
|
login(@Body('username') username: string, @Body('password') password: string) {
|
||||||
|
return this.authService.login(username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('profile')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
profile(@Req() req: any) {
|
||||||
|
return this.authService.profile(req.user.userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/api/src/auth/auth.module.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common'
|
||||||
|
import { JwtModule } from '@nestjs/jwt'
|
||||||
|
import { PassportModule } from '@nestjs/passport'
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module'
|
||||||
|
import { AuthService } from './auth.service'
|
||||||
|
import { AuthController } from './auth.controller'
|
||||||
|
import { JwtStrategy } from './jwt.strategy'
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
|
JwtModule.register({
|
||||||
|
secret: process.env.JWT_SECRET || 'platform-assistant-secret-key-2026',
|
||||||
|
signOptions: { expiresIn: '7d' },
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService, JwtStrategy],
|
||||||
|
exports: [AuthService],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||