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
This commit is contained in:
Sisyphus 2026-06-24 11:07:11 +08:00
commit afac18e843
197 changed files with 28891 additions and 0 deletions

48
.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}

229
AGENTS.md Normal file
View 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
View 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
View 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
View 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
View 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"
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CrawlerConfig" ADD COLUMN "categories" TEXT;
ALTER TABLE "CrawlerConfig" ADD COLUMN "keywords" TEXT;

View File

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

View File

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

View File

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View 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()
}
}

View 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
}
}

View 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 {}

View 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,
}
}
}

View 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 {}

View 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)
}
}

View 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 {}

View 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,
})
}
}

View 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)
}
}

View 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 {}

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