From cd06d049fe7d02e0ff558e3a77b923cde8cd06a8 Mon Sep 17 00:00:00 2001 From: wmq Date: Mon, 27 Jul 2026 23:03:00 +0800 Subject: [PATCH] feat(history): establish Telegram SaaS platform foundation --- .dockerignore | 6 + .env.example | 40 +- .gitignore | 2 + Dockerfile | 12 + README.md | 187 +- docker-compose.yml | 77 + docker/nginx.conf | 34 + package-lock.json | 316 ++- package.json | 10 +- playwright.config.ts | 20 + server/Dockerfile | 8 + server/package-lock.json | 1477 ++++++++++++++ server/package.json | 22 + server/src/ai.js | 48 + server/src/business.js | 76 + server/src/db.js | 97 + server/src/gateway.js | 79 + server/src/index.js | 1754 +++++++++++++++++ server/test/business.test.js | 44 + src/app/router/index.tsx | 11 - src/app/store/auth.ts | 5 - src/components/charts/index.tsx | 4 +- src/components/common/DemoNote.tsx | 18 - src/components/common/LiveDataBadge.tsx | 17 + src/components/inbox/AiReplies.tsx | 46 +- src/components/inbox/Composer.tsx | 88 +- .../inbox/ConversationInsightBar.tsx | 75 + src/components/inbox/ConversationList.tsx | 9 +- src/components/inbox/InsightPanel.tsx | 43 +- src/components/inbox/MessageBubble.tsx | 186 +- src/components/layout/AppLayout.tsx | 2 + src/components/layout/GlobalRealtime.tsx | 61 + src/components/layout/Topbar.tsx | 24 +- src/components/ui/avatar.tsx | 15 +- src/config/constants.ts | 2 +- src/config/env.ts | 8 +- src/config/navigation.ts | 2 - src/config/pricingPlans.ts | 1 - src/config/roles.ts | 2 +- src/i18n/locales/ar.ts | 5 +- src/i18n/locales/en.ts | 3 - src/i18n/locales/zh.ts | 3 - src/mocks/db.ts | 108 - src/mocks/seed/analytics.ts | 208 -- src/mocks/seed/channels.ts | 30 - src/mocks/seed/conversations.ts | 26 - src/mocks/seed/customers.ts | 443 ----- src/mocks/seed/helpers.ts | 45 - src/mocks/seed/knowledge.ts | 45 - src/mocks/seed/messages.ts | 176 -- src/mocks/seed/misc.ts | 109 - src/mocks/seed/opportunities.ts | 30 - src/mocks/seed/risks.ts | 126 -- src/mocks/seed/tasks.ts | 26 - src/mocks/seed/team.ts | 46 - src/pages/analytics/AnalyticsPage.tsx | 8 +- src/pages/auth/LoginPage.tsx | 60 +- src/pages/auth/RegisterPage.tsx | 2 +- src/pages/billing/BillingPage.tsx | 11 +- src/pages/channels/ChannelsPage.tsx | 67 +- src/pages/customers/CustomerDetailPage.tsx | 2 +- src/pages/customers/CustomersPage.tsx | 115 +- src/pages/dashboard/DashboardPage.tsx | 8 +- src/pages/help/HelpPage.tsx | 93 +- src/pages/inbox/InboxPage.tsx | 449 ++++- src/pages/interpret/InterpretPage.tsx | 152 -- src/pages/knowledge/KnowledgePage.tsx | 33 +- src/pages/onboarding/OnboardingPage.tsx | 278 --- src/pages/opportunities/OpportunitiesPage.tsx | 1 - src/pages/settings/SettingsPage.tsx | 120 +- src/pages/translate/TranslatePage.tsx | 22 +- src/services/api/ai.ts | 11 +- src/services/api/auth.ts | 35 +- src/services/api/channels.ts | 16 + src/services/api/client.ts | 15 +- src/services/api/conversations.ts | 30 +- src/services/api/knowledge.ts | 2 +- src/services/api/org.ts | 2 +- src/services/mock/handlers/crm.ts | 331 ---- src/services/mock/handlers/inbox.ts | 320 --- src/services/mock/handlers/misc.ts | 396 ---- src/services/mock/index.ts | 19 - src/services/mock/router.ts | 69 - src/services/mock/translateEngine.ts | 141 -- src/styles/index.css | 24 + src/types/crm.ts | 25 + src/types/messaging.ts | 9 + src/utils/format.ts | 2 +- src/utils/misc.ts | 2 +- src/vite-env.d.ts | 1 - tests/e2e/inbox-ai-live.spec.ts | 61 + tests/e2e/lingxi.spec.ts | 68 + 92 files changed, 5608 insertions(+), 3749 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/nginx.conf create mode 100644 playwright.config.ts create mode 100644 server/Dockerfile create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/src/ai.js create mode 100644 server/src/business.js create mode 100644 server/src/db.js create mode 100644 server/src/gateway.js create mode 100644 server/src/index.js create mode 100644 server/test/business.test.js delete mode 100644 src/components/common/DemoNote.tsx create mode 100644 src/components/common/LiveDataBadge.tsx create mode 100644 src/components/inbox/ConversationInsightBar.tsx create mode 100644 src/components/layout/GlobalRealtime.tsx delete mode 100644 src/mocks/db.ts delete mode 100644 src/mocks/seed/analytics.ts delete mode 100644 src/mocks/seed/channels.ts delete mode 100644 src/mocks/seed/conversations.ts delete mode 100644 src/mocks/seed/customers.ts delete mode 100644 src/mocks/seed/helpers.ts delete mode 100644 src/mocks/seed/knowledge.ts delete mode 100644 src/mocks/seed/messages.ts delete mode 100644 src/mocks/seed/misc.ts delete mode 100644 src/mocks/seed/opportunities.ts delete mode 100644 src/mocks/seed/risks.ts delete mode 100644 src/mocks/seed/tasks.ts delete mode 100644 src/mocks/seed/team.ts delete mode 100644 src/pages/interpret/InterpretPage.tsx delete mode 100644 src/pages/onboarding/OnboardingPage.tsx delete mode 100644 src/services/mock/handlers/crm.ts delete mode 100644 src/services/mock/handlers/inbox.ts delete mode 100644 src/services/mock/handlers/misc.ts delete mode 100644 src/services/mock/index.ts delete mode 100644 src/services/mock/router.ts delete mode 100644 src/services/mock/translateEngine.ts create mode 100644 tests/e2e/inbox-ai-live.spec.ts create mode 100644 tests/e2e/lingxi.spec.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8eab033 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +server/node_modules +dist +.git +.env +*.log diff --git a/.env.example b/.env.example index 1353019..183b9ff 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,35 @@ -# ===== 灵犀跨境 前端环境变量 ===== -# 是否使用内置 Mock 服务:true = 本地模拟数据(默认);false = 请求真实后端 -VITE_USE_MOCK=true +# 必须生成新值,禁止提交真实密钥 +LX_DB_PASSWORD=replace-with-strong-database-password +LX_JWT_SECRET=replace-with-at-least-32-random-characters +LX_WEB_PORT=8088 -# 真实后端 API 基础地址(VITE_USE_MOCK=false 时生效) -# 注意:请通过服务端环境变量管理密钥,前端代码中禁止放置任何真实 API Key -VITE_API_BASE_URL=http://localhost:8080/api +# DeepSeek Anthropic-compatible API +DEEPSEEK_API_KEY=replace-with-rotated-deepseek-key +DEEPSEEK_MODEL=deepseek-v4-flash + +# 已部署的 Telegram Gateway 服务凭据 +TELEGRAM_GATEWAY_URL=http://host.docker.internal:3000/api +TELEGRAM_GATEWAY_TOKEN=replace-with-gateway-jwt +TELEGRAM_GATEWAY_TENANT_ID=replace-with-gateway-tenant-id +TELEGRAM_GATEWAY_SERVICE_EMAIL=replace-with-gateway-service-email +TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-random-service-password +TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id +TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret +PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088 +APP_PUBLIC_URL=http://127.0.0.1:8088 + +# 真实团队邀请邮件 +SMTP_HOST= +SMTP_PORT=587 +SMTP_SECURE=false +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= + +# 真实 Stripe Checkout +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_LITE= +STRIPE_PRICE_STANDARD= +STRIPE_PRICE_PRO= +STRIPE_PRICE_GROWTH= diff --git a/.gitignore b/.gitignore index b42112a..486f128 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ node_modules dist dist-ssr +.env +server/.env *.local .env.local .env.*.local diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..355a349 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS web-build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=web-build /app/dist /usr/share/nginx/html +EXPOSE 80 +HEALTHCHECK --interval=15s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/health || exit 1 diff --git a/README.md b/README.md index 3b46b37..0879fb5 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,53 @@ -# 灵犀跨境 · 跨境 AI 经营服务平台(前端) +# 灵犀跨境 -> 让跨境沟通更简单 — 徐州欧汇科技有限公司 +灵犀跨境是面向跨境业务团队的 Telegram 客户沟通与 AI 经营平台。当前版本只开放 Telegram,所有开放功能均访问真实 API、PostgreSQL、DeepSeek 和 Telegram Gateway,不包含前端 Mock 数据。 -本仓库为「灵犀跨境」SaaS 平台的前端部分,面向中小外贸企业、跨境卖家和产业带企业,将 WhatsApp / Telegram / 邮件 / 独立站表单等渠道的客户咨询统一接入,通过 AI 翻译、意图识别、情感分析、风险预警与客户建档,将聊天记录沉淀为可追踪、可成交的客户资产。 +## 服务组成 -## 技术栈 +- React + TypeScript 前端,由 Nginx 提供静态资源和 API 反向代理。 +- Node.js API,负责独立租户、用户认证、CRM、会话、AI 和网关适配。 +- PostgreSQL,持久化租户隔离的业务数据。 +- 已有 Telegram Gateway,负责真实 Telegram 账号与 TDLib 操作。 +- DeepSeek Anthropic-compatible API,负责翻译、情感、意图、风险、摘要和回复建议。 -- **React 18** + **TypeScript 5** -- **Vite 5** — 开发与构建 -- **Tailwind CSS 3.4** — 原子化样式 -- **shadcn/ui 风格** — 手写轻量企业 UI 组件 -- **React Router 6** — 路由 -- **Zustand 4** — 全局状态(认证/UI/Toast/确认弹窗) -- **TanStack Query 5** — 数据请求与缓存 -- **Recharts 2** — 图表 -- **React Hook Form 7** + **Zod 3** — 表单与校验 -- **Lucide React** — 图标 +## Docker 启动 -## 快速开始 +1. 复制 `.env.example` 为 `.env`。 +2. 生成数据库、JWT、Webhook 随机密钥。 +3. 设置轮换后的 `DEEPSEEK_API_KEY`。 +4. 设置 Telegram Gateway 的租户令牌、租户 ID 和账号 ID。 +5. 启动: -```bash -# 1. 安装依赖 +```powershell +docker compose --env-file .env up -d --build +``` + +默认访问地址:`http://127.0.0.1:8088`。 + +健康检查: + +```powershell +Invoke-RestMethod http://127.0.0.1:8088/health +Invoke-RestMethod http://127.0.0.1:8088/api/dashboard/overview +``` + +业务 API 需要通过注册或登录取得 Bearer Token。浏览器永远不会收到 DeepSeek 或 Telegram Gateway 服务凭据。 + +## 开发检查 + +```powershell npm install +npm run typecheck +npm run build -# 2. 启动开发服务器(默认 Mock 模式) -npm run dev - -# 3. 浏览器打开 http://localhost:5173 +Set-Location server +npm install +node --check src/index.js ``` -登录页点击下方**演示角色一键登录**即可进入系统: -- 企业管理员 / 业务主管 / 外贸业务员 / 客服人员 / 只读成员 +## 安全说明 -也可以使用验证码模式:任意手机号 / 邮箱 + 任意 6 位验证码即可登录。 - -## 构建生产包 - -```bash -npm run build # TypeScript 检查 + Vite 构建 -npm run preview # 预览构建结果 -``` - -## 环境变量与模式切换 - -项目通过 `.env` 文件控制数据来源: - -```env -# 使用内置 Mock 服务(默认,无需后端) -VITE_USE_MOCK=true - -# 真实后端 API 地址(VITE_USE_MOCK=false 时生效) -VITE_API_BASE_URL=http://localhost:8080/api -``` - -- **Mock 模式**(`VITE_USE_MOCK=true`):前端内置完整演示数据集(30 个客户、20 条会话、100+ 条多语种消息、20 个跟进任务、15 条风险预警、30 条企业知识、8 名团队成员、13 个渠道),所有 API 请求在前端通过 Mock 路由处理,无需任何后端服务。 -- **真实模式**(`VITE_USE_MOCK=false`):所有 API 请求转发到 `VITE_API_BASE_URL`,自动携带 Bearer Token。 - -> ⚠️ **安全提示**:请勿在前端代码中存放任何真实 API 密钥。所有密钥应由服务端环境变量管理。 - -## 项目结构 - -``` -src/ -├── app/ -│ ├── router/index.tsx # React Router 路由配置 -│ ├── providers/index.tsx # QueryClient + i18n + Toast + Confirm -│ └── store/ # Zustand stores (auth / ui / toast / confirm) -├── components/ -│ ├── layout/ # AppLayout / Sidebar / Topbar / GlobalSearch -│ ├── common/ # PageHeader / StatCard / ChannelIcon / CountryFlag / StatusBadges / PermissionGate / DemoNote -│ ├── ui/ # UI 基础组件(button / input / select / dialog / drawer / tabs / table / toast / pagination 等) -│ ├── inbox/ # 统一消息专属组件(ConversationList / MessageBubble / Composer / InsightPanel / AiReplies) -│ ├── customer/ # 客户表单弹窗(CustomerFormDialog) -│ ├── crm/ # 商机与任务表单(TaskFormDialog) -│ └── charts/ # 图表封装(TrendChart / DonutChart / FunnelChart / RankBars) -├── pages/ -│ ├── auth/ # 登录 / 注册 -│ ├── onboarding/ # 8 步企业初始化向导 -│ ├── dashboard/ # 今日经营工作台 -│ ├── inbox/ # 统一消息中心(三栏布局 + 移动适配) -│ ├── customers/ # 客户管理(表格/卡片/看板) + 客户详情 -│ ├── opportunities/ # 商机看板 + 跟进任务 + 日历 -│ ├── translate/ # AI 翻译中心 -│ ├── interpret/ # 同声传译(模拟实时双向字幕) -│ ├── risks/ # 风险预警中心 -│ ├── knowledge/ # 企业知识库 + 检索测试 + 使用统计 -│ ├── analytics/ # 数据分析 + 老板看板 -│ ├── channels/ # 渠道中心 + 连接向导 -│ ├── team/ # 团队协作 + 角色权限矩阵 -│ ├── billing/ # 套餐与账单 -│ ├── settings/ # 系统设置(13 组) -│ └── help/ # 帮助与培训 + 交付进度 -├── services/ -│ ├── api/ # 领域 API 服务(auth / dashboard / channels / conversations / ai / customers / crm / knowledge / org / search) -│ └── mock/ # Mock 路由与处理器(按 HTTP 方法 + 路径匹配,支持 :param 参数) -├── mocks/ -│ ├── db.ts # 内存数据库(增删改持久到刷新) -│ └── seed/ # 种子数据(customers / conversations / messages / tasks / opportunities / risks / knowledge / team / channels / analytics / misc) -├── types/ # TypeScript 类型定义 -├── config/ # 全局配置(套餐价格 / 支持语言数 / 角色权限 / 导航 / 业务常量 / 环境变量) -├── i18n/ # 国际化(zh / en / ar + RTL) -├── hooks/ # 自定义 Hooks(usePermission / useDebounce / useMediaQuery) -├── utils/ # 工具函数(cn / format / misc) -└── styles/ # 全局样式与 CSS 变量 -``` - -## 接入真实后端 - -项目中预留了完整的 API 接口层,对应后端路由如下: - -| 领域 | 核心接口 | -|------|---------| -| 认证 | `POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/me` | -| 工作台 | `GET /api/dashboard/overview` | -| 渠道 | `GET /api/channels`, `POST /api/channels/connect`, `POST /api/channels/test`, `PATCH /api/channels/:id`, `DELETE /api/channels/:id` | -| 会话 | `GET /api/conversations`, `GET /api/conversations/:id`, `GET /api/conversations/:id/messages`, `POST /api/conversations/:id/messages`, `PATCH /api/conversations/:id` | -| AI | `POST /api/ai/translate`, `POST /api/ai/analyze`, `POST /api/ai/replies`, `POST /api/ai/summarize`, `POST /api/ai/interpretation`, `POST /api/ai/text-action` | -| 客户 | `GET/POST /api/customers`, `GET/PATCH/DELETE /api/customers/:id`, `POST /api/customers/merge`, `POST /api/customers/import`, `GET /api/customers/export`, `GET /api/customers/duplicates` | -| 商机 | `GET/POST /api/opportunities`, `PATCH /api/opportunities/:id` | -| 任务 | `GET/POST /api/tasks`, `PATCH /api/tasks/:id` | -| 风险 | `GET /api/risks`, `PATCH /api/risks/:id` | -| 知识库 | `GET/POST /api/knowledge`, `PATCH/DELETE /api/knowledge/:id`, `POST /api/knowledge/upload`, `POST /api/knowledge/search-test`, `GET /api/knowledge/stats` | -| 分析 | `GET /api/analytics` | -| 团队 | `GET /api/team`, `POST /api/team/invite`, `PATCH/DELETE /api/team/:id` | -| 账单 | `GET /api/billing`, `POST /api/billing/upgrade` | -| 设置 | `GET/PATCH /api/settings` | -| 通知 | `GET /api/notifications`, `POST /api/notifications/read-all` | -| 搜索 | `GET /api/search?q=` | - -后端可使用 Java / Node.js / Python 等任意技术栈实现。参考 Mock 服务中 `src/services/mock/handlers/` 目录下的处理器即可了解各接口的请求/响应 Shape。 - -## 套餐与价格配置 - -所有套餐价格集中维护在 `src/config/pricingPlans.ts` 中,包含四档套餐(轻助手版 / 标准工作台版 / 专业版 / 增长版/定制版),一处修改全局生效。 - -## 语言支持 - -平台 UI 支持中文 / English / العربية(RTL),RTL 布局通过 `document.documentElement.dir = 'rtl'` 原生实现。 - -翻译引擎支持语言数配置在 `src/config/languages.ts` 的 `supportedLanguageCount` 字段,当前默认值为 96。 - -## 演示数据说明 - -所有图表、经营指标与客户数据均为**模拟演示数据**(数据来源:`src/mocks/seed/` 目录),不代表真实企业经营状况。每个图表卡片均标注「演示数据」提示。 - -客户与消息覆盖以下真实语言场景: -- 🇵🇰 乌尔都语(巴基斯坦 · WhatsApp) -- 🇦🇪 / 🇸🇦 阿拉伯语(阿联酋 / 沙特 · 邮件 / Telegram) -- 🇧🇩 孟加拉语(孟加拉国 · WhatsApp) -- 🇪🇸 西班牙语(西班牙 · 独立站表单 / WhatsApp) -- 🇬🇧 / 🇺🇸 / 🇩🇪 英语(英国 / 美国 / 德国 · 邮件 / Messenger) - -## License - -本项目为徐州欧汇科技有限公司内部产品,未开源授权。仅供演示评估使用。 +- `.env` 已加入 `.gitignore`,严禁提交真实密钥。 +- AI 回复只提供人工确认建议,不会自动向 Telegram 发送。 +- 所有业务资源都通过 JWT 中的租户 ID 隔离。 +- Webhook 必须配置独立随机密钥;生产部署应进一步使用 HTTPS、反向代理和 IP 白名单。 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4ed8bc9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,77 @@ +name: lingxi-crossborder + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${LX_DB_USER:-lingxi} + POSTGRES_PASSWORD: ${LX_DB_PASSWORD:?LX_DB_PASSWORD is required} + POSTGRES_DB: ${LX_DB_NAME:-lingxi_crossborder} + volumes: + - lingxi_pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${LX_DB_USER:-lingxi} -d ${LX_DB_NAME:-lingxi_crossborder}"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + api: + build: + context: ./server + environment: + PORT: 3100 + DATABASE_URL: postgresql://${LX_DB_USER:-lingxi}:${LX_DB_PASSWORD}@postgres:5432/${LX_DB_NAME:-lingxi_crossborder} + JWT_SECRET: ${LX_JWT_SECRET:?LX_JWT_SECRET is required} + DEEPSEEK_BASE_URL: https://api.deepseek.com/anthropic + DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?DEEPSEEK_API_KEY is required} + TELEGRAM_GATEWAY_URL: ${TELEGRAM_GATEWAY_URL:-http://host.docker.internal:3000/api} + TELEGRAM_GATEWAY_TOKEN: ${TELEGRAM_GATEWAY_TOKEN:?TELEGRAM_GATEWAY_TOKEN is required} + TELEGRAM_GATEWAY_TENANT_ID: ${TELEGRAM_GATEWAY_TENANT_ID:?TELEGRAM_GATEWAY_TENANT_ID is required} + TELEGRAM_GATEWAY_SERVICE_EMAIL: ${TELEGRAM_GATEWAY_SERVICE_EMAIL:?TELEGRAM_GATEWAY_SERVICE_EMAIL is required} + TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required} + TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-} + TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?TELEGRAM_WEBHOOK_SECRET is required} + PUBLIC_WEBHOOK_BASE_URL: ${PUBLIC_WEBHOOK_BASE_URL:-http://host.docker.internal:8088} + APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://127.0.0.1:8088} + SMTP_HOST: ${SMTP_HOST:-} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_SECURE: ${SMTP_SECURE:-false} + SMTP_USER: ${SMTP_USER:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_FROM: ${SMTP_FROM:-} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_PRICE_LITE: ${STRIPE_PRICE_LITE:-} + STRIPE_PRICE_STANDARD: ${STRIPE_PRICE_STANDARD:-} + STRIPE_PRICE_PRO: ${STRIPE_PRICE_PRO:-} + STRIPE_PRICE_GROWTH: ${STRIPE_PRICE_GROWTH:-} + extra_hosts: + - "host.docker.internal:host-gateway" + dns: + - 1.1.1.1 + - 8.8.8.8 + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + restart: unless-stopped + + web: + build: + context: . + dockerfile: Dockerfile + ports: + - "${LX_WEB_PORT:-8088}:80" + depends_on: + api: + condition: service_healthy + restart: unless-stopped + +volumes: + lingxi_pgdata: diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..b71d361 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,34 @@ +server { + listen 80; + server_name _; + client_max_body_size 50m; + + location /health { + access_log off; + return 200 'ok'; + add_header Content-Type text/plain; + } + + location /api/ { + proxy_pass http://api:3100; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location /internal/ { + proxy_pass http://api:3100; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Request-Id $request_id; + } + + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } +} diff --git a/package-lock.json b/package-lock.json index fd4e1a9..e6ebbb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,18 +16,20 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.0", - "react-router-dom": "^6.28.0", + "react-router-dom": "^7.18.1", "recharts": "^2.15.0", "tailwind-merge": "^2.5.5", "zod": "^3.24.1", "zustand": "^4.5.5" }, "devDependencies": { + "@playwright/test": "^1.62.0", "@types/node": "^22.10.2", "@types/react": "^18.3.14", "@types/react-dom": "^18.3.2", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "ffmpeg-static": "^5.3.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.16", "typescript": "^5.7.2", @@ -338,6 +340,22 @@ "node": ">=6.9.0" } }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmmirror.com/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -826,13 +844,20 @@ "node": ">= 8" } }, - "node_modules/@remix-run/router": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", - "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", - "license": "MIT", + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, "engines": { - "node": ">=14.0.0" + "node": ">=20" } }, "node_modules/@rolldown/pluginutils": { @@ -1431,6 +1456,19 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1569,6 +1607,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1600,6 +1645,13 @@ ], "license": "CC-BY-4.0" }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -1669,6 +1721,22 @@ "node": ">= 6" } }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "dev": true, + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1676,6 +1744,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -1871,6 +1952,16 @@ "dev": true, "license": "ISC" }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1985,6 +2076,23 @@ "reusify": "^1.0.4" } }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "dev": true, + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2073,6 +2181,44 @@ "node": ">= 0.4" } }, + "node_modules/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -2344,6 +2490,12 @@ "node": ">= 6" } }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", + "dev": true + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2391,6 +2543,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.21", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz", @@ -2554,6 +2753,16 @@ "dev": true, "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -2650,35 +2859,41 @@ } }, "node_modules/react-router": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", - "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", + "version": "7.18.1", + "resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.18.1.tgz", + "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", - "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", + "version": "7.18.1", + "resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.18.1.tgz", + "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.3", - "react-router": "6.30.4" + "react-router": "7.18.1" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/react-smooth": { @@ -2722,6 +2937,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2870,6 +3100,27 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2889,6 +3140,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2899,6 +3156,16 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3080,6 +3347,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index c1ed530..798506a 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,14 @@ "private": true, "version": "1.0.0", "type": "module", - "description": "灵犀跨境 - 跨境AI经营服务平台前端(演示环境,Mock数据)", + "description": "灵犀跨境 - Telegram 跨境 AI 客户经营平台", "scripts": { "dev": "vite", "build": "tsc --noEmit && vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test", + "test:e2e:update": "playwright test --update-snapshots" }, "dependencies": { "@hookform/resolvers": "^3.9.1", @@ -19,18 +21,20 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.54.0", - "react-router-dom": "^6.28.0", + "react-router-dom": "^7.18.1", "recharts": "^2.15.0", "tailwind-merge": "^2.5.5", "zod": "^3.24.1", "zustand": "^4.5.5" }, "devDependencies": { + "@playwright/test": "^1.62.0", "@types/node": "^22.10.2", "@types/react": "^18.3.14", "@types/react-dom": "^18.3.2", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "ffmpeg-static": "^5.3.0", "postcss": "^8.4.49", "tailwindcss": "^3.4.16", "typescript": "^5.7.2", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7709cb1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,20 @@ +import { defineConfig, devices } from '@playwright/test' + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30_000, + expect: { timeout: 10_000 }, + fullyParallel: false, + retries: 0, + reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], + use: { + baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:8088', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [ + { name: 'desktop-chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'mobile-chromium', use: { ...devices['Pixel 7'] } }, + ], + outputDir: 'test-results', +}) diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..3355be6 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +COPY src ./src +ENV NODE_ENV=production +EXPOSE 3100 +CMD ["npm", "start"] diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..6d30ffb --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,1477 @@ +{ + "name": "lingxi-crossborder-api", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lingxi-crossborder-api", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "express": "^5.1.0", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "mammoth": "^1.9.0", + "nodemailer": "^7.0.5", + "pdf-parse": "^1.1.1", + "pg": "^8.13.1", + "ws": "^8.18.3" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmmirror.com/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-ensure": { + "version": "0.0.0", + "resolved": "https://registry.npmmirror.com/node-ensure/-/node-ensure-0.0.0.tgz", + "integrity": "sha512-DRI60hzo2oKN1ma0ckc6nQWlHU69RH6xN0sjQTjMpChPfTYvKZdcQFfdYK2RWbJcKyUizSIy/l8OTGxMAM1QDw==", + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pdf-parse": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/pdf-parse/-/pdf-parse-1.1.4.tgz", + "integrity": "sha512-XRIRcLgk6ZnUbsHsYXExMw+krrPE81hJ6FQPLdBNhhBefqIQKXu/WeTgNBGSwPrfU0v+UCEwn7AoAUOsVKHFvQ==", + "license": "MIT", + "dependencies": { + "node-ensure": "^0.0.0" + }, + "engines": { + "node": ">=6.8.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmmirror.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmmirror.com/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..20bdec3 --- /dev/null +++ b/server/package.json @@ -0,0 +1,22 @@ +{ + "name": "lingxi-crossborder-api", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/index.js", + "test": "node --test test/business.test.js" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "express": "^5.1.0", + "helmet": "^8.1.0", + "jsonwebtoken": "^9.0.2", + "mammoth": "^1.9.0", + "nodemailer": "^7.0.5", + "pdf-parse": "^1.1.1", + "pg": "^8.13.1" + ,"ws": "^8.18.3" + } +} diff --git a/server/src/ai.js b/server/src/ai.js new file mode 100644 index 0000000..f8ec421 --- /dev/null +++ b/server/src/ai.js @@ -0,0 +1,48 @@ +function contentText(payload) { + return payload?.content?.map((part) => part.text || '').join('') || '' +} + +function parseJson(text) { + const cleaned = text.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim() + return JSON.parse(cleaned) +} + +export async function deepseek(system, user, json = false) { + const apiKey = process.env.DEEPSEEK_API_KEY + if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 }) + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000)) + try { + const response = await fetch(`${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`, { + method: 'POST', + signal: controller.signal, + headers: { + 'content-type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash', + max_tokens: Number(process.env.AI_MAX_TOKENS || 4096), + temperature: json ? 0.1 : 0.3, + system, + messages: [{ role: 'user', content: [{ type: 'text', text: user }] }], + }), + }) + const body = await response.json() + if (!response.ok) throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 }) + const text = contentText(body) + return json ? parseJson(text) : text + } finally { + clearTimeout(timeout) + } +} + +export function conversationContext(messages) { + return messages.slice(-80).map((m) => { + const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户' + const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]` + const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : '' + return `${m.createdAt || ''} ${role}: ${content}${translation}` + }).join('\n') +} diff --git a/server/src/business.js b/server/src/business.js new file mode 100644 index 0000000..a0a991e --- /dev/null +++ b/server/src/business.js @@ -0,0 +1,76 @@ +export function telegramAttachment(content = {}) { + const type = content['@type'] + if (type === 'messagePhoto') { + const sizes = Array.isArray(content.photo?.sizes) ? content.photo.sizes : [] + const file = sizes.at(-1)?.photo + return file?.id ? { + telegramFileId: Number(file.id), + attachmentName: `telegram-photo-${file.id}.jpg`, + attachmentMimeType: 'image/jpeg', + attachmentSize: Number(file.size || file.expected_size || 0), + } : {} + } + if (type === 'messageDocument') { + const document = content.document || {} + const file = document.document + return file?.id ? { + telegramFileId: Number(file.id), + attachmentName: document.file_name || `document-${file.id}`, + attachmentMimeType: document.mime_type || 'application/octet-stream', + attachmentSize: Number(file.size || file.expected_size || 0), + } : {} + } + if (type === 'messageVoiceNote') { + const file = content.voice_note?.voice + return file?.id ? { + telegramFileId: Number(file.id), + attachmentName: `voice-${file.id}.ogg`, + attachmentMimeType: content.voice_note?.mime_type || 'audio/ogg', + attachmentSize: Number(file.size || file.expected_size || 0), + duration: Number(content.voice_note?.duration || 0), + } : {} + } + if (type === 'messageAudio') { + const audio = content.audio || {} + const file = audio.audio + return file?.id ? { + telegramFileId: Number(file.id), + attachmentName: audio.file_name || audio.title || `audio-${file.id}`, + attachmentMimeType: audio.mime_type || 'audio/mpeg', + attachmentSize: Number(file.size || file.expected_size || 0), + duration: Number(audio.duration || 0), + } : {} + } + if (type === 'messageSticker') { + const sticker = content.sticker || {} + const file = sticker.sticker + const format = sticker.format?.['@type'] + const extension = format === 'stickerFormatTgs' ? 'tgs' : format === 'stickerFormatWebm' ? 'webm' : 'webp' + return file?.id ? { + telegramFileId: Number(file.id), + attachmentName: `sticker-${file.id}.${extension}`, + attachmentMimeType: extension === 'webp' ? 'image/webp' : extension === 'webm' ? 'video/webm' : 'application/x-tgsticker', + attachmentSize: Number(file.size || file.expected_size || 0), + stickerEmoji: sticker.emoji || '', + } : {} + } + return {} +} + +export const dateKey = (value = Date.now()) => { + const date = new Date(value) + return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10) +} + +export const percent = (numerator, denominator) => + denominator ? Math.round((numerator / denominator) * 1000) / 10 : 0 + +export const countBy = (list, key) => { + const counts = new Map() + for (const item of list) { + const value = typeof key === 'function' ? key(item) : item[key] + if (!value) continue + counts.set(String(value), (counts.get(String(value)) || 0) + 1) + } + return counts +} diff --git a/server/src/db.js b/server/src/db.js new file mode 100644 index 0000000..9a8b60f --- /dev/null +++ b/server/src/db.js @@ -0,0 +1,97 @@ +import pg from 'pg' +import crypto from 'node:crypto' + +const { Pool } = pg +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +export async function migrate() { + await pool.query(` + CREATE TABLE IF NOT EXISTS tenants ( + id uuid PRIMARY KEY, name text NOT NULL, industry text NOT NULL DEFAULT '', + size text NOT NULL DEFAULT '', country text NOT NULL DEFAULT 'CN', + plan text NOT NULL DEFAULT 'pro', created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + name text NOT NULL, account text NOT NULL UNIQUE, password_hash text NOT NULL, + phone text NOT NULL DEFAULT '', role text NOT NULL DEFAULT 'admin', + department text NOT NULL DEFAULT '', title text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT 'active', last_login_at timestamptz, created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS resources ( + tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + kind text NOT NULL, id text NOT NULL, data jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, kind, id) + ); + CREATE INDEX IF NOT EXISTS resources_kind_idx ON resources(tenant_id, kind, updated_at DESC); + CREATE TABLE IF NOT EXISTS telegram_bindings ( + id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + gateway_account_id text, phone text, display_name text, + status text NOT NULL DEFAULT 'unconnected', auth_state text, + created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS audit_logs ( + id uuid PRIMARY KEY, tenant_id uuid, user_id uuid, action text NOT NULL, + target text NOT NULL DEFAULT '', detail jsonb NOT NULL DEFAULT '{}', + ip text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS login_records ( + id uuid PRIMARY KEY, tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE, + user_id uuid REFERENCES users(id) ON DELETE SET NULL, account text NOT NULL DEFAULT '', + device text NOT NULL DEFAULT '', ip text NOT NULL DEFAULT '', + status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS login_records_tenant_idx ON login_records(tenant_id, created_at DESC); + ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version integer NOT NULL DEFAULT 0; + `) +} + +export const uid = () => crypto.randomUUID() + +export async function listResources(tenantId, kind) { + const { rows } = await pool.query( + 'SELECT data FROM resources WHERE tenant_id=$1 AND kind=$2 ORDER BY updated_at DESC', + [tenantId, kind], + ) + return rows.map((row) => row.data) +} + +export async function getResource(tenantId, kind, id) { + const { rows } = await pool.query( + 'SELECT data FROM resources WHERE tenant_id=$1 AND kind=$2 AND id=$3', + [tenantId, kind, id], + ) + return rows[0]?.data ?? null +} + +export async function putResource(tenantId, kind, value) { + const data = { ...value, id: value.id || uid(), updatedAt: new Date().toISOString() } + await pool.query( + `INSERT INTO resources(tenant_id,kind,id,data) VALUES($1,$2,$3,$4) + ON CONFLICT(tenant_id,kind,id) DO UPDATE SET data=$4,updated_at=now()`, + [tenantId, kind, data.id, data], + ) + return data +} + +export async function patchResource(tenantId, kind, id, patch) { + const current = await getResource(tenantId, kind, id) + if (!current) return null + return putResource(tenantId, kind, { ...current, ...patch, id }) +} + +export async function deleteResource(tenantId, kind, id) { + const result = await pool.query( + 'DELETE FROM resources WHERE tenant_id=$1 AND kind=$2 AND id=$3', + [tenantId, kind, id], + ) + return result.rowCount > 0 +} + +export async function audit(req, action, target, detail = {}) { + await pool.query( + 'INSERT INTO audit_logs(id,tenant_id,user_id,action,target,detail,ip) VALUES($1,$2,$3,$4,$5,$6,$7)', + [uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, detail, req.ip], + ) +} diff --git a/server/src/gateway.js b/server/src/gateway.js new file mode 100644 index 0000000..93feb25 --- /dev/null +++ b/server/src/gateway.js @@ -0,0 +1,79 @@ +const base = () => (process.env.TELEGRAM_GATEWAY_URL || 'http://host.docker.internal:3000/api').replace(/\/$/, '') + +let gatewayToken = process.env.TELEGRAM_GATEWAY_TOKEN || '' +let refreshPromise + +function tokenExpiresSoon(token) { + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8')) + return !payload.exp || payload.exp * 1000 < Date.now() + 5 * 60 * 1000 + } catch { + return true + } +} + +async function refreshGatewayToken() { + if (refreshPromise) return refreshPromise + refreshPromise = (async () => { + const email = process.env.TELEGRAM_GATEWAY_SERVICE_EMAIL + const password = process.env.TELEGRAM_GATEWAY_SERVICE_PASSWORD + const tenantId = process.env.TELEGRAM_GATEWAY_TENANT_ID + if (!email || !password || !tenantId) { + throw Object.assign(new Error('Telegram 网关服务账号凭据尚未配置'), { status: 503 }) + } + const response = await fetch(`${base()}/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password, tenantId }), + }) + const raw = await response.text() + let result + try { result = raw ? JSON.parse(raw) : null } catch { result = raw } + const token = result?.data?.token || result?.token + if (!response.ok || !token) { + throw Object.assign(new Error(result?.message || 'Telegram 网关服务账号续签失败'), { status: response.status || 503 }) + } + gatewayToken = token + return token + })().finally(() => { refreshPromise = undefined }) + return refreshPromise +} + +export async function gatewayRequest(path, options = {}) { + const tenantId = process.env.TELEGRAM_GATEWAY_TENANT_ID + if (!tenantId) throw Object.assign(new Error('Telegram 网关租户尚未配置'), { status: 503 }) + if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken() + const send = () => fetch(`${base()}${path}`, { + ...options, + headers: { + ...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }), + authorization: `Bearer ${gatewayToken}`, + 'x-tenant-id': tenantId, + ...(options.headers || {}), + }, + }) + let response = await send() + if (response.status === 401) { + await refreshGatewayToken() + response = await send() + } + const raw = await response.text() + let result + try { result = raw ? JSON.parse(raw) : null } catch { result = raw } + if (!response.ok) { + throw Object.assign(new Error(result?.message || `Telegram 网关请求失败 (${response.status})`), { status: response.status }) + } + return result?.data ?? result +} + +export function postGateway(path, body) { + return gatewayRequest(path, { method: 'POST', body: JSON.stringify(body) }) +} + +export function putGateway(path, body) { + return gatewayRequest(path, { method: 'PUT', body: JSON.stringify(body) }) +} + +export function uploadGateway(path, formData) { + return gatewayRequest(path, { method: 'POST', body: formData }) +} diff --git a/server/src/index.js b/server/src/index.js new file mode 100644 index 0000000..05adb04 --- /dev/null +++ b/server/src/index.js @@ -0,0 +1,1754 @@ +import express from 'express' +import http from 'node:http' +import cors from 'cors' +import helmet from 'helmet' +import jwt from 'jsonwebtoken' +import bcrypt from 'bcryptjs' +import crypto from 'node:crypto' +import { WebSocketServer } from 'ws' +import nodemailer from 'nodemailer' +import mammoth from 'mammoth' +import pdfParse from 'pdf-parse' +import { + audit, deleteResource, getResource, listResources, migrate, patchResource, pool, putResource, uid, +} from './db.js' +import { gatewayRequest, postGateway, putGateway, uploadGateway } from './gateway.js' +import { conversationContext, deepseek } from './ai.js' +import { countBy, dateKey, percent, telegramAttachment } from './business.js' + +const app = express() +const httpServer = http.createServer(app) +const sockets = new WebSocketServer({ server: httpServer, path: '/api/ws' }) +const socketTenants = new Map() +sockets.on('connection', async (socket, request) => { + try { + const url = new URL(request.url, 'http://localhost') + const payload = jwt.verify(url.searchParams.get('ticket') || '', process.env.JWT_SECRET || '') + if (payload.type !== 'ws' || !payload.jti) throw new Error('invalid websocket ticket') + const ticket = await getResource(payload.tenantId, 'ws-tickets', payload.jti) + if (!ticket || Date.parse(ticket.expiresAt || '') <= Date.now()) throw new Error('expired websocket ticket') + await deleteResource(payload.tenantId, 'ws-tickets', payload.jti) + socketTenants.set(socket, payload.tenantId) + socket.on('close', () => socketTenants.delete(socket)) + socket.send(JSON.stringify({ type: 'connected', at: new Date().toISOString() })) + } catch { + socket.close(1008, 'unauthorized') + } +}) +function broadcast(tenantId, event) { + const payload = JSON.stringify({ ...event, at: event.at || new Date().toISOString() }) + for (const client of sockets.clients) { + if (socketTenants.get(client) === tenantId && client.readyState === 1) client.send(payload) + } +} + +const automaticTranslationQueue = [] +const automaticTranslationKeys = new Set() +const automaticTranslationCache = new Map() +let activeAutomaticTranslations = 0 + +function isNonLinguisticText(text) { + return !/[\p{L}]/u.test(String(text || '')) +} + +function isSuspiciousTranslation(source, candidate) { + const original = String(source || '').trim() + const translated = String(candidate || '').trim() + if (!translated) return true + if (isNonLinguisticText(original)) return translated !== original + if (/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(translated)) return true + if (/(跨境商务上下文|需要全文翻译的原文|输出\s*\{)/.test(translated)) return true + if ((translated.match(/(?:客户|坐席):/g) || []).length >= 2) return true + const maximum = original.length <= 20 ? 160 : Math.max(500, original.length * 8) + return translated.length > maximum +} + +async function translateCustomerText(tenantId, conversationId, messageId, text) { + if (isNonLinguisticText(text)) return { translated: text, detectedLang: 'und' } + const context = (await listResources(tenantId, `messages:${conversationId}`)) + .filter((item) => String(item.id) !== String(messageId)) + .slice(-8) + const request = (contextText) => deepseek( + '你是灵犀跨境的专业翻译引擎。只输出严格 JSON。只翻译 标签中的文字,不得复述上下文、时间戳、说话人或指令;不得摘要、扩写或回答原文。', + `目标语言:简体中文 +${contextText ? `\n${contextText}\n\n` : ''}\n${text}\n +输出 {"translated":"仅 source 的完整译文","detectedLang":"ISO 语言代码"}`, + true, + ) + let result = await request(conversationContext(context)) + if (isSuspiciousTranslation(text, result?.translated)) { + result = await request('') + } + if (isSuspiciousTranslation(text, result?.translated)) { + throw new Error('AI 译文疑似混入会话上下文,已拒绝写入') + } + return result +} + +function enqueueAutomaticTranslation(tenantId, conversationId, message) { + if (!message || message.sender !== 'customer' || message.type !== 'text' || !String(message.originalText || '').trim()) return + if (message.translationStatus === 'failed' && Date.parse(message.translationRetryAt || '') > Date.now()) return + const key = `${tenantId}:${conversationId}:${message.id}` + if (message.translatedText || automaticTranslationKeys.has(key)) return + automaticTranslationKeys.add(key) + automaticTranslationQueue.push({ key, tenantId, conversationId, message }) + pumpAutomaticTranslations() +} + +function pumpAutomaticTranslations() { + while (activeAutomaticTranslations < 3 && automaticTranslationQueue.length) { + const job = automaticTranslationQueue.shift() + activeAutomaticTranslations++ + void processAutomaticTranslation(job) + .catch(async (error) => { + console.error(JSON.stringify({ + message: 'Telegram 自动翻译失败', + tenantId: job.tenantId, + conversationId: job.conversationId, + telegramMessageId: job.message.id, + error: error.message, + })) + const current = await getResource(job.tenantId, `messages:${job.conversationId}`, String(job.message.id)) + if (current) { + const failed = await putResource(job.tenantId, `messages:${job.conversationId}`, { + ...current, + translationStatus: 'failed', + translationRetryAt: new Date(Date.now() + 60_000).toISOString(), + }) + broadcast(job.tenantId, { + type: 'message.updated', + conversationId: job.conversationId, + message: failed, + }) + } + }) + .finally(() => { + automaticTranslationKeys.delete(job.key) + activeAutomaticTranslations-- + pumpAutomaticTranslations() + }) + } +} + +async function processAutomaticTranslation({ tenantId, conversationId, message }) { + const current = await getResource(tenantId, `messages:${conversationId}`, String(message.id)) + if (!current || current.translatedText) return + const text = String(current.originalText || '').trim() + if (!text) return + const sharedKey = `${message.id}:${crypto.createHash('sha256').update(text).digest('hex')}` + let translationPromise = automaticTranslationCache.get(sharedKey) + if (!translationPromise) { + translationPromise = translateCustomerText(tenantId, conversationId, message.id, text).catch((error) => { + automaticTranslationCache.delete(sharedKey) + throw error + }) + automaticTranslationCache.set(sharedKey, translationPromise) + setTimeout(() => automaticTranslationCache.delete(sharedKey), 10 * 60 * 1000).unref?.() + } + const result = await translationPromise + if (!result?.translated || isSuspiciousTranslation(text, result.translated)) { + throw new Error('AI 未返回可信的完整译文') + } + const latest = await getResource(tenantId, `messages:${conversationId}`, String(message.id)) + if (!latest) return + const translated = await putResource(tenantId, `messages:${conversationId}`, { + ...latest, + translatedText: String(result.translated), + originalLang: String(result.detectedLang || latest.originalLang || 'auto'), + translationStatus: 'completed', + translatedAt: new Date().toISOString(), + }) + const conversation = await getResource(tenantId, 'conversations', conversationId) + if (String(conversation?.lastMessage?.id || '') === String(translated.id)) { + await patchResource(tenantId, 'conversations', conversationId, { lastMessage: translated }) + } + broadcast(tenantId, { type: 'message.updated', conversationId, message: translated }) +} + +const automaticProfileTimers = new Map() +const automaticProfileCache = new Map() + +function enqueueAutomaticProfile(tenantId, conversationId) { + const key = `${tenantId}:${conversationId}` + const current = automaticProfileTimers.get(key) + if (current) clearTimeout(current) + const timer = setTimeout(() => { + automaticProfileTimers.delete(key) + void analyzeAndPersistConversation(tenantId, conversationId).catch((error) => { + console.error(JSON.stringify({ + message: 'Telegram 客户画像自动分析失败', + tenantId, + conversationId, + error: error.message, + })) + }) + }, 1200) + timer.unref?.() + automaticProfileTimers.set(key, timer) +} + +async function analyzeAndPersistConversation(tenantId, conversationId) { + const conversation = await getResource(tenantId, 'conversations', conversationId) + if (!conversation) throw new Error('待分析会话不存在') + const customer = await getResource(tenantId, 'customers', conversation.customerId) + if (!customer) throw new Error('待分析客户不存在') + const messages = await listResources(tenantId, `messages:${conversationId}`) + const context = conversationContext(messages) + if (!context.trim()) return null + const sharedKey = crypto.createHash('sha256').update(context).digest('hex') + let analysisPromise = automaticProfileCache.get(sharedKey) + if (!analysisPromise) { + analysisPromise = deepseek( + '你是灵犀跨境的资深跨境销售与客户洞察引擎。只输出严格 JSON,不要 Markdown。综合完整会话、上下文变化和明确证据,分析采购角色、需求、预算、时效、异议、成交信号、风险、沟通偏好与下一步策略。没有依据的字段必须为空,不得臆造。', + `${context}\n输出 { + "intentScore":0到100, + "intentLevel":"high|medium-high|medium|low", + "stage":"initial|comparing|sample|custom|closing|fulfillment|aftersales|repurchase", + "sentiment":"positive|neutral-positive|neutral|negative|angry", + "riskLevel":"none|low|medium|high|urgent", + "riskNote":"", + "country":"两位国家代码或空字符串", + "products":[], + "tags":[], + "summary":"中文画像摘要", + "nextActions":[], + "aiProfile":{ + "companyName":"", + "industry":"", + "businessModel":"", + "purchasingRole":"", + "decisionPower":"", + "inquiryQuantity":"", + "targetPrice":"", + "budget":"", + "moqRequirement":"", + "sampleNeed":"", + "customRequirement":"", + "deliveryTime":"", + "urgency":"", + "purchaseTimeline":"", + "logistics":"", + "payment":"", + "aftersales":"", + "keyNeeds":[], + "painPoints":[], + "concerns":[], + "objections":[], + "buyingSignals":[], + "competitorMentions":[], + "communicationStyle":"", + "recommendedTone":"", + "preferredLanguage":"", + "followUpTiming":"", + "dealProbability":0到100, + "churnProbability":0到100, + "confidenceScore":0到100, + "evidence":["最多5条会话证据,简短转述"] + } + }`, + true, + ).catch((error) => { + automaticProfileCache.delete(sharedKey) + throw error + }) + automaticProfileCache.set(sharedKey, analysisPromise) + setTimeout(() => automaticProfileCache.delete(sharedKey), 10 * 60 * 1000).unref?.() + } + const result = await analysisPromise + const intentScore = Math.max(0, Math.min(100, Number(result.intentScore || 0))) + const updatedCustomer = await putResource(tenantId, 'customers', { + ...customer, + company: String(result.aiProfile?.companyName || '').trim() || customer.company, + language: String(result.aiProfile?.preferredLanguage || '').trim() || customer.language, + country: /^[A-Z]{2}$/i.test(String(result.country || '')) ? String(result.country).toUpperCase() : customer.country, + products: Array.from(new Set([...(customer.products || []), ...(result.products || [])])), + tags: Array.from(new Set([...(customer.tags || []), ...(result.tags || [])])), + intentScore, + intentLevel: result.intentLevel || customer.intentLevel || 'low', + stage: result.stage || customer.stage || 'initial', + sentiment: result.sentiment || customer.sentiment || 'neutral', + riskLevel: result.riskLevel || customer.riskLevel || 'none', + aiProfile: { + ...(customer.aiProfile || {}), + ...(result.aiProfile || {}), + summary: String(result.summary || ''), + nextActions: Array.isArray(result.nextActions) ? result.nextActions : [], + }, + aiAnalyzedAt: new Date().toISOString(), + }) + const updatedConversation = await patchResource(tenantId, 'conversations', conversationId, { + intentLevel: updatedCustomer.intentLevel, + sentiment: updatedCustomer.sentiment, + hasRisk: updatedCustomer.riskLevel !== 'none', + summary: updatedCustomer.aiProfile.summary, + aiAnalyzedAt: updatedCustomer.aiAnalyzedAt, + }) + let generatedRisk + let generatedTask + if (['high', 'urgent'].includes(updatedCustomer.riskLevel)) { + generatedRisk = await putResource(tenantId, 'risks', { + id: `ai-risk-${conversationId}`, + type: updatedCustomer.sentiment === 'angry' ? 'complaint' : 'negative-sentiment', + level: updatedCustomer.riskLevel, + customerId: updatedCustomer.id, + customerName: updatedCustomer.name, + conversationId, + reason: String(result.riskNote || result.summary || 'AI 检测到高风险客户'), + aiExplanation: String(result.summary || ''), + suggestion: (result.nextActions || []).join(';') || '请尽快人工跟进', + ownerId: updatedCustomer.ownerId || null, + createdAt: new Date().toISOString(), + status: 'open', + source: 'ai', + }) + generatedTask = await putResource(tenantId, 'tasks', { + id: `ai-risk-task-${conversationId}`, + title: `紧急跟进:${updatedCustomer.name}`, + type: updatedCustomer.stage === 'aftersales' ? 'aftersales' : 'revisit', + priority: 'high', + status: 'pending', + customerId: updatedCustomer.id, + customerName: updatedCustomer.name, + conversationId, + ownerId: updatedCustomer.ownerId || '', + dueAt: new Date(Date.now() + (updatedCustomer.riskLevel === 'urgent' ? 2 : 12) * 60 * 60 * 1000).toISOString(), + note: String(result.riskNote || result.summary || ''), + relatedSummary: String(result.summary || ''), + createdAt: new Date().toISOString(), + source: 'ai-risk', + }) + } + broadcast(tenantId, { + type: 'conversation.insight.updated', + conversationId, + customer: updatedCustomer, + conversation: updatedConversation, + generatedRisk, + generatedTask, + }) + return { + ...result, + intentScore, + customer: updatedCustomer, + conversation: updatedConversation, + } +} +app.set('trust proxy', 1) +app.use(helmet()) +app.use(cors({ origin: process.env.CORS_ORIGIN?.split(',') || true })) +app.use(express.json({ + limit: '10mb', + verify: (req, _res, buffer) => { req.rawBody = buffer }, +})) + +const secret = process.env.JWT_SECRET || '' +if (secret.length < 32) throw new Error('JWT_SECRET 必须至少 32 个字符') +const ok = (res, data, message = 'ok') => res.json({ code: 0, message, data, requestId: res.getHeader('x-request-id') }) +const fail = (status, message) => Object.assign(new Error(message), { status }) +const tokenFor = (user) => jwt.sign({ + userId: user.id, + tenantId: user.tenant_id, + role: user.role, + tokenVersion: Number(user.token_version || 0), + jti: uid(), +}, secret, { expiresIn: '12h' }) + +const loginAttempts = new Map() +function loginAttemptKey(req, account) { + return `${req.ip}:${account}` +} +function assertLoginAllowed(req, account) { + const key = loginAttemptKey(req, account) + const value = loginAttempts.get(key) + if (!value || value.resetAt <= Date.now()) { + loginAttempts.delete(key) + return + } + if (value.count >= 5) { + const seconds = Math.ceil((value.resetAt - Date.now()) / 1000) + throw fail(429, `登录尝试过于频繁,请在 ${seconds} 秒后重试`) + } +} +function recordLoginFailure(req, account) { + const key = loginAttemptKey(req, account) + const current = loginAttempts.get(key) + loginAttempts.set(key, current && current.resetAt > Date.now() + ? { ...current, count: current.count + 1 } + : { count: 1, resetAt: Date.now() + 15 * 60 * 1000 }) +} + +function authorizeRequest(req) { + if (!req.auth || !req.path.startsWith('/api/')) return + const role = req.auth.role + const write = !['GET', 'HEAD', 'OPTIONS'].includes(req.method) + if (!write) { + let readable = ['admin', 'manager', 'sales', 'service', 'viewer'] + if (req.path.startsWith('/api/settings') || req.path.startsWith('/api/api-keys') || req.path.startsWith('/api/billing')) { + readable = ['admin'] + } else if (req.path.startsWith('/api/team')) { + readable = ['admin', 'manager'] + } else if (req.path.startsWith('/api/channels') || req.path.startsWith('/api/webhooks')) { + readable = ['admin', 'manager'] + } else if (req.path.startsWith('/api/analytics')) { + readable = ['admin', 'manager', 'viewer'] + } else if (req.path.startsWith('/api/opportunities')) { + readable = ['admin', 'manager', 'sales'] + } else if (req.path.startsWith('/api/tasks')) { + readable = ['admin', 'manager', 'sales', 'service'] + } else if (req.path.startsWith('/api/risks') || req.path.startsWith('/api/knowledge')) { + readable = ['admin', 'manager', 'service'] + } + if (!readable.includes(role)) throw fail(403, '当前角色无权查看此数据') + return + } + if (['/api/auth/ws-ticket', '/api/auth/change-password', '/api/auth/logout-all'].includes(req.path)) return + let allowed = ['admin', 'manager', 'sales', 'service'] + if (req.path.startsWith('/api/team') || req.path.startsWith('/api/settings') || req.path.startsWith('/api/billing')) { + allowed = ['admin'] + } else if (req.path.startsWith('/api/channels') || req.path.includes('/import') || req.path.includes('/merge')) { + allowed = ['admin', 'manager'] + } else if (req.method === 'DELETE') { + allowed = ['admin', 'manager'] + } else if (req.path.startsWith('/api/opportunities') || req.path.startsWith('/api/tasks')) { + allowed = ['admin', 'manager', 'sales', 'service'] + } else if (req.path.startsWith('/api/risks') || req.path.startsWith('/api/knowledge')) { + allowed = ['admin', 'manager', 'service'] + } + if (!allowed.includes(role)) throw fail(403, '当前角色无权执行此操作') +} + +app.use((req, res, next) => { + const requestId = req.get('x-request-id') || uid() + res.setHeader('x-request-id', requestId) + next() +}) + +const publicPaths = new Set(['/api/auth/login', '/api/auth/register', '/api/billing/stripe-webhook', '/health/live', '/health/ready']) +app.use(async (req, _res, next) => { + if (publicPaths.has(req.path) || req.path.startsWith('/internal/webhooks/telegram/')) return next() + const raw = req.get('authorization')?.replace(/^Bearer\s+/i, '') + try { + const apiKey = req.get('x-api-key') + if (apiKey) { + const hash = crypto.createHash('sha256').update(apiKey).digest('hex') + const { rows } = await pool.query( + `SELECT tenant_id, data FROM resources + WHERE kind='api-keys' AND data->>'hash'=$1 AND COALESCE((data->>'revoked')::boolean,false)=false + LIMIT 1`, + [hash], + ) + if (!rows[0]) throw new Error('invalid api key') + req.auth = { + userId: rows[0].data.createdBy, + tenantId: String(rows[0].tenant_id), + role: rows[0].data.role || 'service', + apiKeyId: rows[0].data.id, + } + } else { + req.auth = jwt.verify(raw || '', secret) + const { rows } = await pool.query( + 'SELECT status,token_version,role FROM users WHERE id=$1 AND tenant_id=$2', + [req.auth.userId, req.auth.tenantId], + ) + if (!rows[0] || rows[0].status !== 'active' || Number(rows[0].token_version || 0) !== Number(req.auth.tokenVersion || 0)) { + throw new Error('revoked token') + } + req.auth.role = rows[0].role + } + authorizeRequest(req) + next() + } catch (error) { + next(error?.status ? error : fail(401, '登录状态已失效,请重新登录')) + } +}) + +async function session(user) { + const { rows } = await pool.query('SELECT * FROM tenants WHERE id=$1', [user.tenant_id]) + const tenant = rows[0] + return { + token: tokenFor(user), + user: { + id: user.id, name: user.name, email: user.account.includes('@') ? user.account : '', + phone: user.phone || (user.account.includes('@') ? '' : user.account), role: user.role, + department: user.department, title: user.title, status: user.status, + lastLoginAt: user.last_login_at || new Date().toISOString(), + }, + tenant: { + id: tenant.id, name: tenant.name, industry: tenant.industry, size: tenant.size, + plan: tenant.plan, planExpireAt: new Date(Date.now() + 365 * 86400000).toISOString(), country: tenant.country, + }, + onboarded: true, + } +} + +app.post('/api/auth/register', async (req, res) => { + const { name, account, password, company, industry = '', teamSize = '' } = req.body + if (!name || !account || !company || String(password || '').length < 8) throw fail(400, '姓名、账号、企业名称必填,密码至少 8 位') + const exists = await pool.query('SELECT 1 FROM users WHERE account=$1', [String(account).toLowerCase()]) + if (exists.rowCount) throw fail(409, '账号已注册') + const tenantId = uid(); const userId = uid(); const now = new Date() + const client = await pool.connect() + try { + await client.query('BEGIN') + await client.query('INSERT INTO tenants(id,name,industry,size) VALUES($1,$2,$3,$4)', [tenantId, company, industry, teamSize]) + const hash = await bcrypt.hash(password, 12) + await client.query( + `INSERT INTO users(id,tenant_id,name,account,password_hash,role,title,status,last_login_at) + VALUES($1,$2,$3,$4,$5,'admin','管理员','active',$6)`, + [userId, tenantId, name, String(account).toLowerCase(), hash, now], + ) + await client.query('COMMIT') + } catch (error) { await client.query('ROLLBACK'); throw error } finally { client.release() } + const { rows } = await pool.query('SELECT * FROM users WHERE id=$1', [userId]) + ok(res, await session(rows[0]), '注册成功') +}) + +app.post('/api/auth/login', async (req, res) => { + const account = String(req.body.account || '').toLowerCase() + assertLoginAllowed(req, account) + const { rows } = await pool.query('SELECT * FROM users WHERE account=$1', [account]) + const user = rows[0] + if (!user || user.status !== 'active' || !(await bcrypt.compare(String(req.body.password || ''), user.password_hash))) { + recordLoginFailure(req, account) + if (user?.tenant_id) { + await pool.query( + 'INSERT INTO login_records(id,tenant_id,user_id,account,device,ip,status) VALUES($1,$2,$3,$4,$5,$6,$7)', + [uid(), user.tenant_id, user.id, account, String(req.get('user-agent') || '').slice(0, 500), req.ip, 'failed'], + ) + } + throw fail(401, '账号或密码错误') + } + loginAttempts.delete(loginAttemptKey(req, account)) + await pool.query('UPDATE users SET last_login_at=now() WHERE id=$1', [user.id]) + await pool.query( + 'INSERT INTO login_records(id,tenant_id,user_id,account,device,ip,status) VALUES($1,$2,$3,$4,$5,$6,$7)', + [uid(), user.tenant_id, user.id, account, String(req.get('user-agent') || '').slice(0, 500), req.ip, 'success'], + ) + ok(res, await session({ ...user, last_login_at: new Date() })) +}) + +app.get('/api/auth/me', async (req, res) => { + const { rows } = await pool.query('SELECT * FROM users WHERE id=$1 AND tenant_id=$2', [req.auth.userId, req.auth.tenantId]) + if (!rows[0]) throw fail(404, '用户不存在') + const value = await session(rows[0]) + ok(res, { user: value.user, tenant: value.tenant }) +}) +app.post('/api/auth/ws-ticket', async (req, res) => { + const id = uid() + const expiresAt = new Date(Date.now() + 60_000).toISOString() + await putResource(req.auth.tenantId, 'ws-tickets', { + id, + userId: req.auth.userId, + expiresAt, + }) + ok(res, { + ticket: jwt.sign({ + userId: req.auth.userId, + tenantId: req.auth.tenantId, + type: 'ws', + jti: id, + }, secret, { expiresIn: '60s' }), + expiresAt, + }) +}) +app.post('/api/auth/change-password', async (req, res) => { + const { rows } = await pool.query( + 'SELECT * FROM users WHERE id=$1 AND tenant_id=$2', + [req.auth.userId, req.auth.tenantId], + ) + const user = rows[0] + if (!user || !(await bcrypt.compare(String(req.body.currentPassword || ''), user.password_hash))) { + throw fail(400, '当前密码不正确') + } + const password = String(req.body.newPassword || '') + if (password.length < 10 || !/[A-Za-z]/.test(password) || !/\d/.test(password)) { + throw fail(400, '新密码至少 10 位,且必须包含字母和数字') + } + await pool.query( + 'UPDATE users SET password_hash=$1,token_version=token_version+1 WHERE id=$2 AND tenant_id=$3', + [await bcrypt.hash(password, 12), req.auth.userId, req.auth.tenantId], + ) + await audit(req, 'auth.password.change', req.auth.userId) + ok(res, { success: true, loginRequired: true }) +}) +app.post('/api/auth/logout-all', async (req, res) => { + await pool.query( + 'UPDATE users SET token_version=token_version+1 WHERE id=$1 AND tenant_id=$2', + [req.auth.userId, req.auth.tenantId], + ) + await audit(req, 'auth.sessions.revoke', req.auth.userId) + ok(res, { success: true }) +}) +app.get('/api/api-keys', async (req, res) => { + if (req.auth.role !== 'admin') throw fail(403, '仅管理员可查看 API Key') + const list = await listResources(req.auth.tenantId, 'api-keys') + ok(res, list.map(({ hash: _hash, ...item }) => item)) +}) +app.post('/api/api-keys', async (req, res) => { + if (req.auth.role !== 'admin') throw fail(403, '仅管理员可创建 API Key') + const plaintext = `lx_${crypto.randomBytes(32).toString('base64url')}` + const value = await putResource(req.auth.tenantId, 'api-keys', { + id: uid(), + name: String(req.body.name || 'API Key').slice(0, 80), + prefix: plaintext.slice(0, 10), + hash: crypto.createHash('sha256').update(plaintext).digest('hex'), + role: req.body.role === 'viewer' ? 'viewer' : 'service', + createdBy: req.auth.userId, + createdAt: new Date().toISOString(), + revoked: false, + }) + await audit(req, 'api-key.create', value.id) + ok(res, { ...value, hash: undefined, key: plaintext }) +}) +app.delete('/api/api-keys/:id', async (req, res) => { + if (req.auth.role !== 'admin') throw fail(403, '仅管理员可撤销 API Key') + const value = await patchResource(req.auth.tenantId, 'api-keys', req.params.id, { + revoked: true, + revokedAt: new Date().toISOString(), + }) + if (!value) throw fail(404, 'API Key 不存在') + await audit(req, 'api-key.revoke', req.params.id) + ok(res, { success: true }) +}) + +const kinds = { + customers: 'customers', opportunities: 'opportunities', tasks: 'tasks', risks: 'risks', + knowledge: 'knowledge', +} +function withDefaults(kind, body) { + if (kind === 'customers') return { + name: '', company: '', country: 'US', channel: 'telegram', email: '', phone: '', + telegram: '', products: [], tags: [], source: 'telegram', stage: 'new', + intentScore: 0, intentLevel: 'low', sentiment: 'neutral', riskLevel: 'none', + valueLevel: 'D', isKey: false, ownerId: null, + aiProfile: { summary: '', keyPoints: [], nextActions: [] }, + ...body, + } + return body +} +for (const [path, kind] of Object.entries(kinds)) { + app.get(`/api/${path}`, async (req, res) => { + let list = await listResources(req.auth.tenantId, kind) + const keyword = String(req.query.keyword || '').toLowerCase() + if (keyword) list = list.filter((x) => JSON.stringify(x).toLowerCase().includes(keyword)) + ok(res, { list, total: list.length }) + }) + app.post(`/api/${path}`, async (req, res) => { + const value = await putResource(req.auth.tenantId, kind, { ...withDefaults(kind, req.body), createdAt: new Date().toISOString() }) + await audit(req, `${kind}.create`, value.id); ok(res, value) + }) + app.patch(`/api/${path}/:id`, async (req, res) => { + const value = await patchResource(req.auth.tenantId, kind, req.params.id, req.body) + if (!value) throw fail(404, '记录不存在') + await audit(req, `${kind}.update`, req.params.id, req.body); ok(res, value) + }) + app.delete(`/api/${path}/:id`, async (req, res) => { + if (!(await deleteResource(req.auth.tenantId, kind, req.params.id))) throw fail(404, '记录不存在') + await audit(req, `${kind}.delete`, req.params.id); ok(res, { success: true }) + }) +} + +app.get('/api/customers/duplicates', async (req, res) => { + const list = await listResources(req.auth.tenantId, 'customers') + const pairs = [] + for (let i = 0; i < list.length; i++) for (let j = i + 1; j < list.length; j++) { + if ((list[i].email && list[i].email === list[j].email) || (list[i].phone && list[i].phone === list[j].phone)) pairs.push({ a: list[i], b: list[j], reasons: ['联系方式重复'] }) + } + ok(res, pairs.map((x, index) => ({ id: `duplicate-${index + 1}`, reason: x.reasons.join('、'), customers: [x.a, x.b] }))) +}) +app.get('/api/customers/export', async (req, res) => { + const list = await listResources(req.auth.tenantId, 'customers') + const escape = (v) => `"${String(v ?? '').replaceAll('"', '""')}"` + ok(res, { csv: ['name,company,email,phone,country', ...list.map((x) => [x.name, x.company, x.email, x.phone, x.country].map(escape).join(','))].join('\n') }) +}) +app.post('/api/customers/import', async (req, res) => { + let imported = 0 + for (const row of req.body.rows || []) { if (row.name) { await putResource(req.auth.tenantId, 'customers', row); imported++ } } + ok(res, { imported, skipped: (req.body.rows || []).length - imported }) +}) +app.post('/api/customers/merge', async (req, res) => { + const keep = await getResource(req.auth.tenantId, 'customers', req.body.keepId) + const merge = await getResource(req.auth.tenantId, 'customers', req.body.mergeId) + if (!keep || !merge) throw fail(404, '客户不存在') + const value = await putResource(req.auth.tenantId, 'customers', { ...merge, ...keep, id: keep.id }) + await deleteResource(req.auth.tenantId, 'customers', merge.id); ok(res, value) +}) +app.get('/api/customers/:id', async (req, res) => { + const customer = await getResource(req.auth.tenantId, 'customers', req.params.id) + if (!customer) throw fail(404, '客户不存在') + const [conversations, opportunities, tasks, risks, files, logs] = await Promise.all([ + listResources(req.auth.tenantId, 'conversations'), + listResources(req.auth.tenantId, 'opportunities'), + listResources(req.auth.tenantId, 'tasks'), + listResources(req.auth.tenantId, 'risks'), + listResources(req.auth.tenantId, `customer-files:${customer.id}`), + pool.query( + `SELECT id,user_id,action,target,detail,created_at + FROM audit_logs + WHERE tenant_id=$1 AND (target=$2 OR detail->>'customerId'=$2) + ORDER BY created_at DESC LIMIT 200`, + [req.auth.tenantId, customer.id], + ), + ]) + ok(res, { + customer, + conversations: conversations.filter((x) => x.customerId === customer.id), + opportunities: opportunities.filter((x) => x.customerId === customer.id), + tasks: tasks.filter((x) => x.customerId === customer.id), + risks: risks.filter((x) => x.customerId === customer.id), + files, + logs: logs.rows.map((row) => ({ + id: row.id, + customerId: customer.id, + actorName: row.user_id ? '团队成员' : '系统', + action: row.action, + detail: typeof row.detail === 'string' ? row.detail : JSON.stringify(row.detail), + createdAt: row.created_at, + })), + }) +}) + +app.get('/api/conversations', async (req, res) => { + let conversations = await listResources(req.auth.tenantId, 'conversations') + const customers = await listResources(req.auth.tenantId, 'customers') + const customerMap = new Map(customers.map((x) => [x.id, x])) + if (req.query.channel) conversations = conversations.filter((x) => x.channel === req.query.channel) + const list = conversations.map((x) => ({ ...x, customer: customerMap.get(x.customerId) })).filter((x) => x.customer) + ok(res, { list, total: list.length }) +}) +app.get('/api/conversations/:id', async (req, res) => { + const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) + if (!conversation) throw fail(404, '会话不存在') + const customer = await getResource(req.auth.tenantId, 'customers', conversation.customerId) + ok(res, { conversation, customer }) +}) +app.get('/api/conversations/:id/messages', async (req, res) => { + const list = await listResources(req.auth.tenantId, `messages:${req.params.id}`) + for (const message of list.slice(0, 50)) { + enqueueAutomaticTranslation(req.auth.tenantId, req.params.id, message) + } + list.sort((a, b) => { + const time = Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '') + if (time) return time + return Number(a.id || 0) - Number(b.id || 0) + }) + ok(res, list) +}) +app.patch('/api/conversations/:id/messages/:messageId', async (req, res) => { + const allowed = {} + if (typeof req.body.isImportant === 'boolean') allowed.isImportant = req.body.isImportant + if (typeof req.body.internalNote === 'string') allowed.internalNote = req.body.internalNote.slice(0, 2000) + if (!Object.keys(allowed).length) throw fail(400, '没有可更新的消息字段') + const value = await patchResource( + req.auth.tenantId, + `messages:${req.params.id}`, + req.params.messageId, + allowed, + ) + if (!value) throw fail(404, '消息不存在') + const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) + if (String(conversation?.lastMessage?.id || '') === String(value.id)) { + await patchResource(req.auth.tenantId, 'conversations', req.params.id, { lastMessage: value }) + } + await audit(req, 'message.update', value.id, allowed) + broadcast(req.auth.tenantId, { + type: 'message.updated', + conversationId: req.params.id, + message: value, + }) + ok(res, value) +}) +app.get('/api/conversations/:id/messages/:messageId/attachment', async (req, res) => { + const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) + if (!conversation) throw fail(404, '会话不存在') + const message = await getResource(req.auth.tenantId, `messages:${req.params.id}`, req.params.messageId) + if (!message?.telegramFileId) throw fail(404, '该消息没有可下载的 Telegram 文件') + const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) + if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') + const file = await gatewayRequest( + `/tg-accounts/${channel.gatewayAccountId}/files/tdlib/${Number(message.telegramFileId)}/base64`, + ) + const mimeType = message.attachmentMimeType || file?.mimeType || 'application/octet-stream' + ok(res, { + dataUrl: `data:${mimeType};base64,${file.base64}`, + mimeType, + name: message.attachmentName || `telegram-file-${message.telegramFileId}`, + }) +}) +app.post('/api/conversations/:id/read', async (req, res) => { + const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) + if (!conversation) throw fail(404, '会话不存在') + const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) + if (!channel?.gatewayAccountId || !conversation.telegramChatId) throw fail(409, '会话未关联可用 Telegram 渠道') + const messages = await listResources(req.auth.tenantId, `messages:${req.params.id}`) + const requested = Array.isArray(req.body.messageIds) ? req.body.messageIds.map(String) : [] + const messageIds = (requested.length + ? requested + : messages.filter((message) => message.sender === 'customer').map((message) => String(message.id))) + .filter((id) => /^\d+$/.test(id)) + .slice(-100) + .map(Number) + if (messageIds.length) { + await postGateway(`/tg-accounts/${channel.gatewayAccountId}/messages/read`, { + chatId: Number(conversation.telegramChatId), + messageIds, + forceRead: true, + }) + } + const updated = await patchResource(req.auth.tenantId, 'conversations', req.params.id, { + unreadCount: 0, + lastReadAt: new Date().toISOString(), + lastReadMessageId: messageIds.length ? String(messageIds.at(-1)) : conversation.lastReadMessageId, + }) + broadcast(req.auth.tenantId, { + type: 'conversation.read', + conversationId: req.params.id, + conversation: updated, + }) + ok(res, updated) +}) +app.patch('/api/conversations/:id', async (req, res) => { + const patch = { ...req.body } + if (patch.markRead) patch.unreadCount = 0 + delete patch.markRead + const value = await patchResource(req.auth.tenantId, 'conversations', req.params.id, patch) + if (!value) throw fail(404, '会话不存在') + ok(res, value) +}) +app.post('/api/conversations/:id/messages', async (req, res) => { + const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) + if (!conversation) throw fail(404, '会话不存在') + const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) + if (!channel?.gatewayAccountId || !conversation.telegramChatId) throw fail(409, '会话未关联可用 Telegram 渠道') + const originalText = String(req.body.text || '').trim() + if (!originalText) throw fail(400, '消息内容不能为空') + let telegramText = originalText + let originalLang = 'auto' + if (req.body.autoTranslate !== false && /[\u3400-\u9fff]/u.test(originalText)) { + const context = await listResources(req.auth.tenantId, `messages:${conversation.id}`) + const translation = await deepseek( + '你是灵犀跨境的中文到英文商务全文翻译引擎。只输出严格 JSON,不要 Markdown。必须完整翻译,不得摘要、删减、扩写或遗漏;保留数字、金额、型号、链接、换行与专有名词;英文应自然、专业且可直接发给海外客户。', + `会话上下文:\n${conversationContext(context)}\n需要发送的中文原文:\n${originalText}\n输出 {"translated":"完整英文译文","detectedLang":"zh-CN"}`, + true, + ) + if (!translation?.translated) throw fail(502, 'AI 未返回英文全文译文,消息未发送') + telegramText = String(translation.translated).trim() + originalLang = String(translation.detectedLang || 'zh-CN') + } + const result = await postGateway(`/tg-accounts/${channel.gatewayAccountId}/messages/send-direct`, { + chatId: conversation.telegramChatId, text: telegramText, replyToMessageId: req.body.replyToId, + idempotencyKey: req.get('idempotency-key') || uid(), + }) + const resultMessageId = String(result?.id || result?.messageId || uid()) + if (telegramText !== originalText) { + await putResource(req.auth.tenantId, 'outgoing-message-meta', { + id: resultMessageId, + conversationId: conversation.id, + originalText, + translatedText: telegramText, + originalLang, + targetLang: 'en', + createdAt: new Date().toISOString(), + }) + } + const message = await putResource(req.auth.tenantId, `messages:${conversation.id}`, { + id: resultMessageId, conversationId: conversation.id, + sender: 'agent', type: req.body.type || 'text', originalText, + translatedText: telegramText !== originalText ? telegramText : undefined, + originalLang, + status: result?.deliveryState === 'sent' ? 'sent' : 'sending', + replyToId: req.body.replyToId ? String(req.body.replyToId) : undefined, + replyPreview: req.body.replyPreview ? String(req.body.replyPreview).slice(0, 240) : undefined, + createdAt: new Date().toISOString(), + }) + await patchResource(req.auth.tenantId, 'conversations', conversation.id, { lastMessage: message, status: 'replied' }) + broadcast(req.auth.tenantId, { type: 'message.updated', conversationId: conversation.id, message }) + ok(res, message) +}) + +app.post('/api/conversations/:id/media', async (req, res) => { + const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) + if (!conversation) throw fail(404, '会话不存在') + const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) + if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') + const { name, mimeType, base64, kind = 'document' } = req.body + const bytes = Buffer.from(String(base64 || ''), 'base64') + if (!name || !bytes.length) throw fail(400, '请选择有效文件') + if (bytes.length > 20 * 1024 * 1024) throw fail(413, '单个文件不能超过 20MB') + const form = new FormData() + form.append('file', new Blob([bytes], { type: mimeType || 'application/octet-stream' }), name) + form.append('chatId', String(conversation.telegramChatId)) + form.append('kind', kind) + const result = await uploadGateway(`/tg-accounts/${channel.gatewayAccountId}/files/upload`, form) + await audit(req, 'telegram.media.send', conversation.id, { name, kind, size: bytes.length }) + ok(res, result) +}) + +app.post('/api/conversations/:id/album', async (req, res) => { + const conversation = await getResource(req.auth.tenantId, 'conversations', req.params.id) + if (!conversation) throw fail(404, '会话不存在') + const channel = await getResource(req.auth.tenantId, 'channels', conversation.channelId) + if (!channel?.gatewayAccountId) throw fail(409, '会话未关联 Telegram 账号') + const files = Array.isArray(req.body.files) ? req.body.files : [] + if (files.length < 2 || files.length > 10) throw fail(400, '相册必须包含 2 到 10 个图片或视频') + const form = new FormData() + let totalSize = 0 + for (const item of files) { + const bytes = Buffer.from(String(item.base64 || ''), 'base64') + if (!item.name || !bytes.length) throw fail(400, '相册包含无效文件') + totalSize += bytes.length + if (bytes.length > 20 * 1024 * 1024 || totalSize > 100 * 1024 * 1024) throw fail(413, '相册文件过大') + form.append('files', new Blob([bytes], { type: item.mimeType || 'image/jpeg' }), item.name) + } + form.append('chatId', String(conversation.telegramChatId)) + const result = await uploadGateway(`/tg-accounts/${channel.gatewayAccountId}/files/upload-album`, form) + await audit(req, 'telegram.album.send', conversation.id, { count: files.length, size: totalSize }) + ok(res, result) +}) + +app.get('/api/channels', async (req, res) => { + const telegram = await listResources(req.auth.tenantId, 'channels') + ok(res, telegram.length ? telegram : [{ + id: 'telegram-available', type: 'telegram', name: 'Telegram', status: 'unconnected', + todayMessages: 0, autoReply: false, + }]) +}) +const telegramWebhookEvents = [ + 'message.received', 'message.pending', 'message.sent', 'message.failed', + 'message.edited', 'message.deleted', 'update.new_chat', +] +async function ensureChannelWebhook(tenantId, channel) { + const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${tenantId}/${channel.id}` + if (channel.webhookId) { + await putGateway(`/tg-accounts/${channel.gatewayAccountId}/webhooks/${channel.webhookId}`, { + url: webhookUrl, + events: telegramWebhookEvents, + isActive: true, + secret: process.env.TELEGRAM_WEBHOOK_SECRET, + }) + return patchResource(tenantId, 'channels', channel.id, { + errorMessage: undefined, + webhookSyncedAt: new Date().toISOString(), + }) + } + const webhook = await postGateway(`/tg-accounts/${channel.gatewayAccountId}/webhooks`, { + url: webhookUrl, + events: telegramWebhookEvents, + secret: process.env.TELEGRAM_WEBHOOK_SECRET, + maxRetries: 8, + }) + return patchResource(tenantId, 'channels', channel.id, { webhookId: webhook?.id, errorMessage: undefined }) +} +app.post('/api/channels/connect', async (req, res) => { + if (req.body.type !== 'telegram') throw fail(400, '当前版本仅支持 Telegram') + const accountId = req.body.gatewayAccountId || process.env.TELEGRAM_GATEWAY_ACCOUNT_ID + if (!accountId) throw fail(400, '缺少 Telegram 网关账号 ID') + const state = await postGateway(`/tg-accounts/${accountId}/auth/state`, {}) + let channel = await putResource(req.auth.tenantId, 'channels', { + type: 'telegram', name: 'Telegram', + status: (state?.status === 'ready' || state?.state === 'ready') && state?.connectionState === 'ready' + ? 'connected' + : 'disconnected', + account: req.body.account || '', gatewayAccountId: accountId, lastSyncAt: new Date().toISOString(), + todayMessages: 0, autoReply: false, + }) + const webhookUrl = `${(process.env.PUBLIC_WEBHOOK_BASE_URL || 'http://host.docker.internal:8088').replace(/\/$/, '')}/internal/webhooks/telegram/${req.auth.tenantId}/${channel.id}` + try { + const webhook = await postGateway(`/tg-accounts/${accountId}/webhooks`, { + url: webhookUrl, + events: telegramWebhookEvents, + secret: process.env.TELEGRAM_WEBHOOK_SECRET, + maxRetries: 8, + }) + channel = await patchResource(req.auth.tenantId, 'channels', channel.id, { webhookId: webhook?.id }) + } catch (error) { + channel = await patchResource(req.auth.tenantId, 'channels', channel.id, { errorMessage: `Webhook 注册失败:${error.message}` }) + } + ok(res, channel) +}) +app.post('/api/channels/telegram/start', async (req, res) => { + const phone = String(req.body.phone || '').trim() + if (!/^\+\d{7,15}$/.test(phone)) throw fail(400, '请输入国际格式手机号') + const account = await postGateway('/tg-accounts', { phoneNumber: phone, firstName: 'Lingxi' }) + await postGateway(`/tg-accounts/${account.id}/start`, {}) + const channel = await putResource(req.auth.tenantId, 'channels', { + type: 'telegram', name: 'Telegram', status: 'disconnected', account: phone, + gatewayAccountId: account.id, authState: 'waiting_phone', todayMessages: 0, autoReply: false, + }) + for (let attempt = 0; attempt < 20; attempt++) { + const authState = await postGateway(`/tg-accounts/${account.id}/auth/state`, {}) + if (authState?.status === 'waiting_phone') break + await new Promise((resolve) => setTimeout(resolve, 250)) + } + const authState = await postGateway(`/tg-accounts/${account.id}/auth/phone`, { phoneNumber: phone }) + ok(res, await patchResource(req.auth.tenantId, 'channels', channel.id, { authState: authState?.status || 'waiting_code' })) +}) +app.post('/api/channels/telegram/:id/phone', async (req, res) => { + const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) + if (!ch) throw fail(404, '渠道不存在') + ok(res, await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/phone`, { phoneNumber: req.body.phone })) +}) +app.post('/api/channels/telegram/:id/code', async (req, res) => { + const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) + if (!ch) throw fail(404, '渠道不存在') + const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/code`, { code: req.body.code }) + const channel = await patchResource(req.auth.tenantId, 'channels', ch.id, { + authState: authState.status, + status: authState.status === 'ready' && authState.connectionState === 'ready' ? 'connected' : 'disconnected', + }) + const readyChannel = authState.status === 'ready' ? await ensureChannelWebhook(req.auth.tenantId, channel) : channel + ok(res, { ...authState, channel: readyChannel }) +}) +app.post('/api/channels/telegram/:id/password', async (req, res) => { + const ch = await getResource(req.auth.tenantId, 'channels', req.params.id) + if (!ch) throw fail(404, '渠道不存在') + const authState = await postGateway(`/tg-accounts/${ch.gatewayAccountId}/auth/password`, { password: req.body.password }) + const channel = await patchResource(req.auth.tenantId, 'channels', ch.id, { + authState: authState.status, + status: authState.status === 'ready' && authState.connectionState === 'ready' ? 'connected' : 'disconnected', + }) + ok(res, { ...authState, channel: await ensureChannelWebhook(req.auth.tenantId, channel) }) +}) + +function telegramPhotoId(photo) { + const value = photo?.small?.id ?? photo?.big?.id + const number = Number(value) + return Number.isFinite(number) && number > 0 ? number : undefined +} + +async function resolveTelegramIdentity(channel, chatId, fallback = {}, existing = {}) { + const identity = await gatewayRequest( + `/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/identity/profile`, + ).catch(() => null) + const photo = identity?.photo || fallback.photo + const photoFileId = telegramPhotoId(photo) + let avatarUrl = existing.avatarUrl + if (photoFileId && photoFileId !== Number(existing.telegramPhotoFileId || 0)) { + const image = await gatewayRequest( + `/tg-accounts/${channel.gatewayAccountId}/files/tdlib/${photoFileId}/base64`, + ).catch(() => null) + if (image?.base64) avatarUrl = `data:${image.mimeType || 'image/jpeg'};base64,${image.base64}` + } + return { + name: String(identity?.title || fallback.title || existing.name || `Telegram ${chatId}`).trim(), + telegramUsername: String(identity?.username || existing.telegramUsername || '').replace(/^@/, ''), + telegramUserId: identity?.userId ? String(identity.userId) : existing.telegramUserId, + phone: String(identity?.phone || existing.phone || ''), + avatarUrl, + telegramPhotoFileId: photoFileId || existing.telegramPhotoFileId, + } +} + +async function syncTelegramTenant(tenantId, channel) { + const gateway = await gatewayRequest(`/tg-accounts/${channel.gatewayAccountId}/chats/sync`) + const chats = await gatewayRequest(`/tg-accounts/${channel.gatewayAccountId}/chats?limit=100`) + let messagesSynced = 0 + for (const chat of chats || []) { + const chatId = String(chat.tgChatId) + const customerId = `tg-${channel.gatewayAccountId}-${chatId}` + const existingCustomer = await getResource(tenantId, 'customers', customerId) + const identity = await resolveTelegramIdentity(channel, chatId, chat, existingCustomer || {}) + const customer = await putResource(tenantId, 'customers', withDefaults('customers', { + ...(existingCustomer || {}), + ...identity, + id: customerId, + telegram: chatId, + source: 'telegram', + })) + const conversationId = `${channel.id}:${chatId}` + const existingConversation = await getResource(tenantId, 'conversations', conversationId) + const recent = await gatewayRequest( + `/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/history?limit=100`, + ).catch(() => ({ messages: [] })) + const historyMessages = [...(recent?.messages || [])] + let historyComplete = Boolean(existingConversation?.historyComplete) || historyMessages.length < 100 + let cursor = existingConversation?.historyComplete + ? undefined + : existingConversation?.historyOldestMessageId || historyMessages.at(-1)?.id + let pages = 1 + while (!historyComplete && cursor && pages < 50) { + const page = await gatewayRequest( + `/tg-accounts/${channel.gatewayAccountId}/chats/${chatId}/history?limit=100&fromMessageId=${encodeURIComponent(String(cursor))}`, + ).catch(() => ({ messages: [] })) + const rows = page?.messages || [] + const known = new Set(historyMessages.map((message) => String(message.id))) + const newRows = rows.filter((message) => !known.has(String(message.id))) + historyMessages.push(...newRows) + pages++ + if (rows.length < 100 || !newRows.length) { + historyComplete = true + break + } + cursor = rows.at(-1)?.id + } + const history = { messages: historyMessages } + let lastMessage + for (const raw of [...(history?.messages || [])].reverse()) { + const content = raw.content || {} + const typeMap = { messagePhoto: 'image', messageDocument: 'file', messageVoiceNote: 'audio', messageAudio: 'audio', messageSticker: 'file' } + lastMessage = await putResource(tenantId, `messages:${conversationId}`, { + id: String(raw.id), conversationId, sender: raw.is_outgoing ? 'agent' : 'customer', + type: typeMap[content['@type']] || 'text', + originalText: content?.text?.text || content?.caption?.text || '', + originalLang: 'auto', status: raw.is_outgoing ? 'sent' : 'delivered', + createdAt: new Date(Number(raw.date || Date.now() / 1000) * 1000).toISOString(), + telegramContent: content, + ...telegramAttachment(content), + }) + messagesSynced++ + } + await putResource(tenantId, 'conversations', { + ...(existingConversation || {}), + id: conversationId, customerId: customer.id, channel: 'telegram', channelId: channel.id, + telegramChatId: chatId, unreadCount: Number(chat.unreadCount || 0), + status: Number(chat.unreadCount || 0) ? 'pending' : 'replied', assigneeId: null, + intentLevel: 'low', sentiment: 'neutral', hasRisk: false, summary: '', + lastMessage: lastMessage || (chat.lastMessage ? { + id: String(chat.lastMessageId || `${chatId}-last`), conversationId, sender: 'customer', + type: 'text', originalText: chat.lastMessage, originalLang: 'auto', status: 'delivered', + createdAt: chat.lastMessageDate || new Date().toISOString(), + } : undefined), + historyComplete, + historyOldestMessageId: historyMessages.at(-1)?.id + ? String(historyMessages.at(-1).id) + : existingConversation?.historyOldestMessageId, + historySyncedAt: new Date().toISOString(), + createdAt: existingConversation?.createdAt || new Date().toISOString(), + }) + } + await patchResource(tenantId, 'channels', channel.id, { lastSyncAt: new Date().toISOString() }) + broadcast(tenantId, { type: 'sync.completed', channelId: channel.id, chats: chats?.length || 0, messages: messagesSynced }) + return { gateway, chats: chats?.length || 0, messages: messagesSynced } +} + +app.post('/api/channels/:id/sync', async (req, res) => { + const channel = await getResource(req.auth.tenantId, 'channels', req.params.id) + if (!channel) throw fail(404, '渠道不存在') + ok(res, await syncTelegramTenant(req.auth.tenantId, channel)) +}) + +app.post('/api/channels/test', async (req, res) => { + const start = Date.now() + const ch = await getResource(req.auth.tenantId, 'channels', req.body.id) + if (!ch) throw fail(404, '渠道不存在') + await gatewayRequest(`/tg-accounts/${ch.gatewayAccountId}`) + ok(res, { success: true, latencyMs: Date.now() - start, message: 'Telegram 网关连接正常' }) +}) +app.get('/api/channels/:id/logs', async (req, res) => ok(res, await listResources(req.auth.tenantId, `channel-logs:${req.params.id}`))) +app.patch('/api/channels/:id', async (req, res) => { + const value = await patchResource(req.auth.tenantId, 'channels', req.params.id, req.body) + if (!value) throw fail(404, '渠道不存在'); ok(res, value) +}) +app.delete('/api/channels/:id', async (req, res) => { + if (!(await deleteResource(req.auth.tenantId, 'channels', req.params.id))) throw fail(404, '渠道不存在') + ok(res, { success: true }) +}) + +async function messagesFor(req) { + if (!req.body.conversationId) return [] + return listResources(req.auth.tenantId, `messages:${req.body.conversationId}`) +} +app.post('/api/ai/translate', async (req, res) => { + const messages = await messagesFor(req) + const result = await deepseek( + '你是灵犀跨境的专业跨境贸易翻译。只输出严格 JSON,不要 Markdown。', + `目标语言:${req.body.targetLang}\n场景:${req.body.scenario || '跨境商务'}\n上下文:\n${conversationContext(messages)}\n待翻译:\n${req.body.text}\n输出 {"translated":"...","detectedLang":"ISO语言代码","terms":[{"source":"...","target":"..."}]}`, + true, + ) + ok(res, result) +}) +app.post('/api/ai/analyze', async (req, res) => { + const result = await analyzeAndPersistConversation(req.auth.tenantId, req.body.conversationId) + if (!result) throw fail(400, '会话没有可分析的消息') + await putResource(req.auth.tenantId, 'ai-analysis', { ...result, conversationId: req.body.conversationId }) + ok(res, result) +}) +app.post('/api/ai/replies', async (req, res) => { + const messages = await messagesFor(req) + const result = await deepseek( + '你是跨境销售助理。只输出严格 JSON 数组,生成三条可人工确认的英文回复建议。content 必须是可直接发送给海外客户的完整英文;reason、expectedEffect 和 cultureTip 使用中文。', + `${conversationContext(messages)}\n输出数组元素结构 {"id":"唯一值","style":"stable|active|brief","content":"英文回复","targetLang":"en","reason":"中文理由","knowledgeRefs":[],"expectedEffect":"中文说明","cultureTip":"中文提示"}`, + true, + ) + ok(res, result) +}) +app.post('/api/ai/adoptions', async (req, res) => { + const value = await putResource(req.auth.tenantId, 'ai-adoptions', { + id: uid(), + conversationId: String(req.body.conversationId || ''), + suggestionId: String(req.body.suggestionId || ''), + contentHash: crypto.createHash('sha256').update(String(req.body.content || '')).digest('hex'), + adoptedBy: req.auth.userId, + createdAt: new Date().toISOString(), + }) + ok(res, value) +}) +app.post('/api/ai/summarize', async (req, res) => { + const messages = await messagesFor(req) + ok(res, { summary: await deepseek('用中文简洁总结跨境商务会话。', conversationContext(messages)) }) +}) +app.post('/api/ai/text-action', async (req, res) => { + ok(res, { result: await deepseek('你是跨境商务写作助手,只返回处理后的文本。', `操作:${req.body.action}\n语气:${req.body.tone || ''}\n目标语言:${req.body.targetLang || ''}\n文本:${req.body.text}`) }) +}) +const emptyKpi = () => ({ value: 0, delta: 0 }) +async function businessDataset(tenantId) { + const [customers, conversations, tasks, risks, opportunities, adoptions, usersResult] = await Promise.all([ + listResources(tenantId, 'customers'), + listResources(tenantId, 'conversations'), + listResources(tenantId, 'tasks'), + listResources(tenantId, 'risks'), + listResources(tenantId, 'opportunities'), + listResources(tenantId, 'ai-adoptions'), + pool.query('SELECT id,name FROM users WHERE tenant_id=$1', [tenantId]), + ]) + const messageGroups = await Promise.all(conversations.map(async (conversation) => ({ + conversation, + messages: (await listResources(tenantId, `messages:${conversation.id}`)) + .sort((a, b) => Date.parse(a.createdAt || '') - Date.parse(b.createdAt || '')), + }))) + const responseTimes = [] + const repliesByOwner = new Map() + for (const group of messageGroups) { + for (let index = 0; index < group.messages.length; index++) { + const message = group.messages[index] + if (message.sender !== 'customer') continue + const reply = group.messages.slice(index + 1).find((item) => item.sender === 'agent') + if (!reply) continue + const minutes = Math.max(0, (Date.parse(reply.createdAt) - Date.parse(message.createdAt)) / 60000) + if (Number.isFinite(minutes) && minutes < 7 * 24 * 60) { + responseTimes.push(minutes) + const owner = group.conversation.assigneeId + if (owner) { + const row = repliesByOwner.get(owner) || { total: 0, count: 0 } + row.total += minutes + row.count++ + repliesByOwner.set(owner, row) + } + } + } + } + return { + customers, conversations, tasks, risks, opportunities, adoptions, + users: usersResult.rows, + messageGroups, + messages: messageGroups.flatMap((group) => group.messages), + responseTimes, + repliesByOwner, + } +} + +app.get('/api/dashboard/overview', async (req, res) => { + const data = await businessDataset(req.auth.tenantId) + const { customers, conversations, tasks, risks, opportunities, messages, responseTimes, users, repliesByOwner, adoptions } = data + const unread = conversations.reduce((n, x) => n + Number(x.unreadCount || 0), 0) + const today = dateKey() + const won = opportunities.filter((item) => item.stage === 'won').length + const outgoing = messages.filter((message) => message.sender === 'agent').length + const days = Array.from({ length: 7 }, (_, index) => dateKey(Date.now() - (6 - index) * 86400000)) + const sources = countBy(customers, (customer) => customer.sourcePath || customer.source || 'telegram') + const stages = countBy(customers, 'stage') + const teamNames = new Map(users.map((user) => [user.id, user.name])) + ok(res, { + stats: { + newInquiriesToday: { value: conversations.filter((x) => String(x.createdAt || '').slice(0, 10) === today).length, delta: 0 }, + pendingReply: { value: unread, delta: 0 }, highIntentCustomers: { value: customers.filter((x) => x.intentScore >= 75).length, delta: 0 }, + todayFollowUps: { value: tasks.filter((x) => x.status !== 'done').length, delta: 0 }, openRisks: { value: risks.filter((x) => x.status !== 'resolved').length, delta: 0 }, + avgFirstResponseMin: { value: responseTimes.length ? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length) : 0, delta: 0, unit: '分钟' }, + monthConversionRate: { value: percent(won, opportunities.length), delta: 0, isPercent: true }, + aiAdoptionRate: { value: percent(adoptions.length, Math.max(outgoing, 1)), delta: 0, isPercent: true }, + }, + todos: tasks.filter((x) => x.status !== 'done').slice(0, 7), + highValueCustomers: customers.sort((a, b) => (b.intentScore || 0) - (a.intentScore || 0)).slice(0, 6), + channelOverview: [{ channel: 'telegram', todayMessages: messages.filter((message) => dateKey(message.createdAt) === today).length, pending: conversations.filter((x) => x.status === 'pending').length, unread }], + inquiryTrend: days.map((day) => ({ + date: day.slice(5), + inquiries: conversations.filter((item) => dateKey(item.createdAt) === day).length, + customers: customers.filter((item) => dateKey(item.createdAt || item.updatedAt) === day).length, + })), + sourceDistribution: [...sources].map(([name, value]) => ({ name, value })), + funnel: [...stages].map(([stage, count]) => ({ stage, label: stage, count })), + teamReplyRank: [...repliesByOwner].map(([memberId, row]) => ({ + memberId, + name: teamNames.get(memberId) || '未命名成员', + avgResponseMin: Math.round(row.total / row.count), + replies: row.count, + })).sort((a, b) => a.avgResponseMin - b.avgResponseMin), + recentRisks: risks.slice(0, 5), updatedAt: new Date().toISOString(), + }) +}) + +app.get('/api/team', async (req, res) => { + const [{ rows }, customers, tasks] = await Promise.all([ + pool.query('SELECT * FROM users WHERE tenant_id=$1 ORDER BY created_at', [req.auth.tenantId]), + listResources(req.auth.tenantId, 'customers'), + listResources(req.auth.tenantId, 'tasks'), + ]) + ok(res, rows.map((x) => ({ + id: x.id, + name: x.name, + email: x.account, + phone: x.phone, + department: x.department, + role: x.role, + online: Date.parse(x.last_login_at || '') > Date.now() - 15 * 60 * 1000, + customerCount: customers.filter((customer) => customer.ownerId === x.id).length, + todoCount: tasks.filter((task) => task.ownerId === x.id && task.status !== 'done').length, + lastLoginAt: x.last_login_at, + status: x.status, + joinedAt: x.created_at, + }))) +}) +app.get('/api/team/operation-logs', async (req, res) => { + const { rows } = await pool.query('SELECT * FROM audit_logs WHERE tenant_id=$1 ORDER BY created_at DESC LIMIT 200', [req.auth.tenantId]) + ok(res, rows.map((x) => ({ id: x.id, actorId: x.user_id, actorName: '', action: x.action, target: x.target, detail: JSON.stringify(x.detail), ip: x.ip, createdAt: x.created_at }))) +}) +app.get('/api/team/login-records', async (req, res) => { + const { rows } = await pool.query( + `SELECT lr.*,u.name AS member_name + FROM login_records lr LEFT JOIN users u ON u.id=lr.user_id + WHERE lr.tenant_id=$1 ORDER BY lr.created_at DESC LIMIT 200`, + [req.auth.tenantId], + ) + ok(res, rows.map((row) => ({ + id: row.id, + memberId: row.user_id, + memberName: row.member_name || row.account, + device: row.device || 'Unknown', + ip: row.ip, + location: row.ip || 'Unknown', + status: row.status, + createdAt: row.created_at, + }))) +}) +app.post('/api/team/invite', async (req, res) => { + if (req.auth.role !== 'admin') throw fail(403, '仅管理员可邀请成员') + const email = String(req.body.email || '').trim().toLowerCase() + if (!email.includes('@')) throw fail(400, '请输入有效邮箱') + if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASSWORD) throw fail(503, 'SMTP 邮件服务尚未配置') + const temporaryPassword = `Lx!${crypto.randomBytes(12).toString('base64url')}` + const userId = uid() + const exists = await pool.query('SELECT 1 FROM users WHERE account=$1', [email]) + if (exists.rowCount) throw fail(409, '该邮箱已加入团队') + await pool.query( + `INSERT INTO users(id,tenant_id,name,account,password_hash,phone,role,department,title,status) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,'pending')`, + [userId, req.auth.tenantId, req.body.name || email.split('@')[0], email, await bcrypt.hash(temporaryPassword, 12), + req.body.phone || '', req.body.role || 'sales', req.body.department || '', req.body.title || '成员'], + ) + const transport = nodemailer.createTransport({ + host: process.env.SMTP_HOST, port: Number(process.env.SMTP_PORT || 587), + secure: String(process.env.SMTP_SECURE || 'false') === 'true', + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD }, + }) + try { + await transport.verify() + await transport.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, to: email, + subject: '邀请加入灵犀跨境', + text: `你已被邀请加入灵犀跨境。登录地址:${process.env.APP_PUBLIC_URL || 'http://127.0.0.1:8088'},临时密码:${temporaryPassword}。首次登录后请立即修改密码。`, + }) + await pool.query("UPDATE users SET status='active' WHERE id=$1", [userId]) + } catch (error) { + await pool.query('DELETE FROM users WHERE id=$1', [userId]) + throw fail(502, `邀请邮件发送失败:${error.message}`) + } + const { rows } = await pool.query('SELECT * FROM users WHERE id=$1', [userId]) + await audit(req, 'team.invite', userId, { email }) + ok(res, rows[0]) +}) +app.patch('/api/team/:id', async (req, res) => { + if (req.auth.role !== 'admin') throw fail(403, '仅管理员可修改成员') + const allowed = ['name', 'phone', 'department', 'role', 'status']; const values = []; const sets = [] + for (const key of allowed) if (key in req.body) { values.push(req.body[key]); sets.push(`${key}=$${values.length}`) } + values.push(req.params.id, req.auth.tenantId) + const { rows } = await pool.query(`UPDATE users SET ${sets.join(',')} WHERE id=$${values.length - 1} AND tenant_id=$${values.length} RETURNING *`, values) + if (!rows[0]) throw fail(404, '成员不存在'); ok(res, rows[0]) +}) +app.delete('/api/team/:id', (_req, _res, next) => next(fail(501, '成员删除改为禁用,避免审计数据丢失'))) + +const defaultSettings = { + company: { name: '灵犀跨境', industry: '', size: '', contact: '', email: '', intro: '', products: [], regions: [] }, + ai: { replyLanguage: 'zh-CN', replyTone: 'professional', autoGenerate: true, requireConfirm: true, sentimentAnalysis: true, riskAlert: true, cultureCheck: true, allowHistoryRef: true }, + notification: { newInquiry: true, highIntent: true, riskAlert: true, taskDue: true, taskOverdue: true, channelOffline: true, planExpire: true, byEmail: false, bySms: false }, + translate: { industry: '', formality: 'professional', targetCountry: '', localization: true, keepBrand: true, keepModel: true, autoUnit: true, cultureReminder: true, religionReminder: true }, + language: 'zh-CN', timezone: 'Asia/Hong_Kong', sessionRetentionMonths: 12, brandColor: '#2563eb', onboarding: {}, +} +app.get('/api/settings', async (req, res) => ok(res, (await getResource(req.auth.tenantId, 'singleton', 'settings')) || defaultSettings)) +app.patch('/api/settings', async (req, res) => { + const current = (await getResource(req.auth.tenantId, 'singleton', 'settings')) || defaultSettings + const value = await putResource(req.auth.tenantId, 'singleton', { + ...current, + ...req.body, + company: { ...current.company, ...(req.body.company || {}) }, + ai: { ...current.ai, ...(req.body.ai || {}) }, + notification: { ...current.notification, ...(req.body.notification || {}) }, + locale: { ...current.locale, ...(req.body.locale || {}) }, + id: 'settings', + }) + await audit(req, 'settings.update', 'settings', Object.keys(req.body || {})) + ok(res, value) +}) +app.get('/api/notifications', async (req, res) => ok(res, await listResources(req.auth.tenantId, 'notifications'))) +app.post('/api/notifications/read-all', async (req, res) => { + for (const n of await listResources(req.auth.tenantId, 'notifications')) await putResource(req.auth.tenantId, 'notifications', { ...n, read: true }) + ok(res, { success: true }) +}) +app.get('/api/knowledge/packs', (_req, res) => ok(res, [])) +app.get('/api/knowledge/stats', async (req, res) => { + const list = await listResources(req.auth.tenantId, 'knowledge') + ok(res, { totalCitations: list.reduce((n, x) => n + Number(x.citedCount || 0), 0), aiReplyCitationRate: 0, topUsed: [], lowQuality: [], outdated: [] }) +}) +app.post('/api/knowledge/upload', async (req, res) => { + const files = Array.isArray(req.body.files) ? req.body.files : [] + const items = [] + for (const file of files) { + const buffer = Buffer.from(String(file.base64 || ''), 'base64') + if (!buffer.length || buffer.length > 20 * 1024 * 1024) throw fail(400, `文件 ${file.name || ''} 无内容或超过 20MB`) + const extension = String(file.name || '').split('.').pop()?.toLowerCase() + let content = '' + if (['txt', 'md', 'csv', 'json', 'html'].includes(extension)) content = buffer.toString('utf8') + else if (extension === 'docx') content = (await mammoth.extractRawText({ buffer })).value + else if (extension === 'pdf') content = (await pdfParse(buffer)).text + else throw fail(415, `暂不支持 ${extension || '未知'} 文件`) + items.push(await putResource(req.auth.tenantId, 'knowledge', { + title: String(file.name).replace(/\.[^.]+$/, ''), category: 'product', + content: content.trim(), tags: ['文件导入'], enabled: true, version: 1, + updatedBy: req.auth.userId, source: 'file', fileName: file.name, fileType: extension, + status: 'ready', citedCount: 0, languages: ['auto'], quality: 'normal', + createdAt: new Date().toISOString(), + })) + } + await audit(req, 'knowledge.upload', '', { count: items.length }) + ok(res, { items }) +}) +app.post('/api/knowledge/search-test', async (req, res) => { + const list = await listResources(req.auth.tenantId, 'knowledge') + const words = String(req.body.question || '').toLowerCase().split(/\s+/) + const hits = list.filter((x) => words.some((w) => JSON.stringify(x).toLowerCase().includes(w))).slice(0, 5).map((item) => ({ item, score: 1, snippet: String(item.content || '').slice(0, 120) })) + ok(res, { answer: hits.length ? await deepseek('仅根据给出的企业知识回答,不得编造。', `问题:${req.body.question}\n知识:${JSON.stringify(hits)}`) : '知识库中没有匹配内容。', hits }) +}) +app.get('/api/billing', async (req, res) => { + const [{ rows }, orders, invoices] = await Promise.all([ + pool.query('SELECT plan FROM tenants WHERE id=$1', [req.auth.tenantId]), + listResources(req.auth.tenantId, 'billing-orders'), + listResources(req.auth.tenantId, 'billing-invoices'), + ]) + const planKey = rows[0]?.plan || 'lite' + ok(res, { + info: { planKey, planName: planKey, expireAt: '', autoRenew: false, usage: { + members: { used: 0, total: 100, unit: '人' }, messages: { used: 0, total: 100000, unit: '条' }, + translations: { used: 0, total: 100000, unit: '次' }, knowledgeStorage: { used: 0, total: 10240, unit: 'MB' }, + } }, + orders, invoices, + }) +}) +app.post('/api/billing/upgrade', async (req, res) => { + const priceMap = { + lite: process.env.STRIPE_PRICE_LITE, + standard: process.env.STRIPE_PRICE_STANDARD, + pro: process.env.STRIPE_PRICE_PRO, + growth: process.env.STRIPE_PRICE_GROWTH, + } + const price = priceMap[req.body.planKey] + if (!process.env.STRIPE_SECRET_KEY || !price) throw fail(503, 'Stripe 商户密钥或套餐 Price ID 尚未配置') + const form = new URLSearchParams({ + mode: 'subscription', 'line_items[0][price]': price, 'line_items[0][quantity]': '1', + success_url: `${process.env.APP_PUBLIC_URL || 'http://127.0.0.1:8088'}/billing?payment=success&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.APP_PUBLIC_URL || 'http://127.0.0.1:8088'}/billing?payment=cancelled`, + 'metadata[tenantId]': req.auth.tenantId, 'metadata[planKey]': req.body.planKey, + }) + const response = await fetch('https://api.stripe.com/v1/checkout/sessions', { + method: 'POST', + headers: { authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`, 'content-type': 'application/x-www-form-urlencoded' }, + body: form, + }) + const checkout = await response.json() + if (!response.ok) throw fail(502, checkout?.error?.message || '创建支付会话失败') + await putResource(req.auth.tenantId, 'billing-orders', { + id: checkout.id, planKey: req.body.planKey, status: 'pending', + checkoutUrl: checkout.url, createdAt: new Date().toISOString(), + }) + ok(res, { checkoutUrl: checkout.url, sessionId: checkout.id }) +}) + +app.post('/api/billing/stripe-webhook', async (req, res) => { + const signingSecret = process.env.STRIPE_WEBHOOK_SECRET + const signature = req.get('stripe-signature') || '' + if (!signingSecret || !signature) throw fail(401, 'Stripe Webhook 签名配置缺失') + const parts = Object.fromEntries(signature.split(',').map((part) => part.split('=', 2))) + const timestamp = Number(parts.t) + if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) throw fail(401, 'Stripe Webhook 已过期') + const expected = crypto.createHmac('sha256', signingSecret) + .update(`${timestamp}.${req.rawBody.toString('utf8')}`).digest('hex') + const provided = String(parts.v1 || '') + if (provided.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) { + throw fail(401, 'Stripe Webhook 签名无效') + } + const event = req.body + if (event.type === 'checkout.session.completed') { + const session = event.data?.object || {} + const tenantId = session.metadata?.tenantId + const planKey = session.metadata?.planKey + if (tenantId && planKey) { + await pool.query('UPDATE tenants SET plan=$1 WHERE id=$2', [planKey, tenantId]) + await putResource(tenantId, 'billing-orders', { + id: session.id, planKey, status: 'paid', amountTotal: session.amount_total, + currency: session.currency, customerId: session.customer, subscriptionId: session.subscription, + paidAt: new Date().toISOString(), + }) + await putResource(tenantId, 'billing-invoices', { + id: session.payment_intent || session.id, orderId: session.id, planKey, + amount: session.amount_total, currency: session.currency, status: 'paid', + createdAt: new Date().toISOString(), + }) + broadcast(tenantId, { type: 'billing.updated', planKey, status: 'paid' }) + } + } + ok(res, { received: true }) +}) +app.get('/api/analytics', async (req, res) => { + const data = await businessDataset(req.auth.tenantId) + const { customers, conversations, tasks, risks, opportunities, messages, responseTimes, users, repliesByOwner, adoptions } = data + const days = Array.from({ length: 30 }, (_, index) => dateKey(Date.now() - (29 - index) * 86400000)) + const won = opportunities.filter((item) => item.stage === 'won') + const doneTasks = tasks.filter((item) => item.status === 'done') + const handledRisks = risks.filter((item) => ['resolved', 'ignored'].includes(item.status)) + const translated = messages.filter((message) => message.translatedText).length + const productCounts = new Map() + for (const customer of customers) for (const product of customer.products || []) { + productCounts.set(product, (productCounts.get(product) || 0) + 1) + } + const countryCounts = countBy(customers, 'country') + const stageCounts = countBy(customers, 'stage') + const sourceCounts = countBy(customers, (customer) => customer.sourcePath || customer.source || 'telegram') + const userNames = new Map(users.map((user) => [user.id, user.name])) + const ownerCustomers = countBy(customers, 'ownerId') + const ownerWon = countBy(won, 'ownerId') + const overdue = tasks.filter((task) => task.status !== 'done' && Date.parse(task.dueAt || '') < Date.now()) + const now = Date.now() + ok(res, { + kpis: { + newCustomers: { value: customers.filter((item) => Date.parse(item.createdAt || item.updatedAt || '') >= now - 30 * 86400000).length, delta: 0 }, + validInquiries: { value: conversations.length, delta: 0 }, + highIntent: { value: customers.filter((item) => Number(item.intentScore || 0) >= 75).length, delta: 0 }, + conversionRate: { value: percent(won.length, opportunities.length), delta: 0, isPercent: true }, + avgResponseMin: { value: responseTimes.length ? Math.round(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length) : 0, delta: 0, unit: '分钟' }, + aiTranslateUsage: { value: translated, delta: 0 }, + aiAdoptionRate: { value: percent(adoptions.length, messages.filter((item) => item.sender === 'agent').length), delta: 0, isPercent: true }, + taskCompletionRate: { value: percent(doneTasks.length, tasks.length), delta: 0, isPercent: true }, + riskHandleRate: { value: percent(handledRisks.length, risks.length), delta: 0, isPercent: true }, + repurchaseRate: { value: percent(customers.filter((item) => item.stage === 'repurchase').length, customers.length), delta: 0, isPercent: true }, + }, + customerGrowth: days.map((day) => ({ date: day.slice(5), count: customers.filter((item) => dateKey(item.createdAt || item.updatedAt) === day).length })), + channelInquiries: [{ channel: 'telegram', name: 'Telegram', count: conversations.filter((item) => item.channel === 'telegram').length }], + countryDistribution: [...countryCounts].map(([country, count]) => ({ country, name: country, count })), + sourcePaths: [...sourceCounts].map(([path, count]) => ({ path, count })), + funnel: [...stageCounts].map(([stage, count]) => ({ stage, label: stage, count })), + intentDistribution: [ + { range: '0-24', count: customers.filter((item) => Number(item.intentScore || 0) < 25).length }, + { range: '25-49', count: customers.filter((item) => Number(item.intentScore || 0) >= 25 && Number(item.intentScore || 0) < 50).length }, + { range: '50-74', count: customers.filter((item) => Number(item.intentScore || 0) >= 50 && Number(item.intentScore || 0) < 75).length }, + { range: '75-100', count: customers.filter((item) => Number(item.intentScore || 0) >= 75).length }, + ], + sentimentTrend: days.map((day) => { + const rows = customers.filter((item) => dateKey(item.aiAnalyzedAt || item.updatedAt || item.createdAt) === day) + return { + date: day.slice(5), + positive: rows.filter((item) => ['positive', 'neutral-positive'].includes(item.sentiment)).length, + neutral: rows.filter((item) => item.sentiment === 'neutral').length, + negative: rows.filter((item) => ['negative', 'angry'].includes(item.sentiment)).length, + } + }), + riskTrend: days.map((day) => ({ date: day.slice(5), count: risks.filter((item) => dateKey(item.createdAt) === day).length })), + productRank: [...productCounts].map(([product, inquiries]) => ({ product, inquiries })).sort((a, b) => b.inquiries - a.inquiries).slice(0, 10), + teamReplyRank: [...repliesByOwner].map(([id, row]) => ({ name: userNames.get(id) || '未命名成员', avgResponseMin: Math.round(row.total / row.count) })).sort((a, b) => a.avgResponseMin - b.avgResponseMin), + teamConversionRank: users.map((user) => ({ name: user.name, rate: percent(ownerWon.get(user.id) || 0, ownerCustomers.get(user.id) || 0) })).sort((a, b) => b.rate - a.rate), + taskCompletion: users.map((user) => { + const rows = tasks.filter((item) => item.ownerId === user.id) + return { name: user.name, done: rows.filter((item) => item.status === 'done').length, total: rows.length } + }), + churnReasons: [...countBy(risks, 'type')].map(([reason, count]) => ({ reason, count })), + industryDistribution: [...countBy(customers, (item) => item.aiProfile?.industry || '未知')].map(([industry, count]) => ({ industry, count })), + bossBoard: { + urgentHighValue: customers.filter((item) => Number(item.intentScore || 0) >= 75 && ['high', 'urgent'].includes(item.riskLevel)).slice(0, 8).map((item) => ({ id: item.id, name: item.name, country: item.country, reason: item.aiProfile?.summary || '高意向且存在风险', intentScore: item.intentScore })), + overdueMembers: users.map((user) => ({ id: user.id, name: user.name, overdueCount: overdue.filter((task) => task.ownerId === user.id).length })).filter((item) => item.overdueCount > 0), + bestChannel: { channel: 'telegram', name: 'Telegram', conversion: percent(won.length, opportunities.length) }, + fastestCountries: [...countryCounts].map(([country, count]) => ({ country, name: country, growth: percent(count, Math.max(customers.length, 1)) / 100 })).sort((a, b) => b.growth - a.growth).slice(0, 6), + topProducts: [...productCounts].map(([product, inquiries]) => ({ product, inquiries })).sort((a, b) => b.inquiries - a.inquiries).slice(0, 6), + churningHighIntent: customers.filter((item) => Number(item.intentScore || 0) >= 75 && now - Date.parse(item.lastContactAt || item.updatedAt || '') > 7 * 86400000).slice(0, 8).map((item) => ({ id: item.id, name: item.name, country: item.country, daysSilent: Math.floor((now - Date.parse(item.lastContactAt || item.updatedAt)) / 86400000), intentScore: item.intentScore })), + }, + }) +}) +app.get('/api/search', async (req, res) => { + const q = String(req.query.q || '').toLowerCase() + const result = {} + for (const kind of ['customers', 'conversations', 'knowledge', 'tasks']) result[kind] = (await listResources(req.auth.tenantId, kind)).filter((x) => JSON.stringify(x).toLowerCase().includes(q)).slice(0, 5).map((x) => ({ id: x.id, title: x.name || x.title || x.summary || '', sub: x.company || x.content || '' })) + ok(res, result) +}) + +app.post('/internal/webhooks/telegram/:tenantId/:channelId', async (req, res) => { + const expected = process.env.TELEGRAM_WEBHOOK_SECRET + const received = String(req.get('x-webhook-signature') || '').replace(/^sha256=/, '') + const calculated = crypto.createHmac('sha256', expected || '').update(req.rawBody || Buffer.from(JSON.stringify(req.body))).digest('hex') + if (!expected || received.length !== calculated.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(calculated))) throw fail(401, 'Webhook 签名无效') + const { tenantId, channelId } = req.params + const channel = await getResource(tenantId, 'channels', channelId) + if (!channel) throw fail(404, 'Webhook 渠道映射不存在') + const eventId = String(req.body.id || req.body.updateId || crypto.createHash('sha256').update(req.rawBody || Buffer.from(JSON.stringify(req.body))).digest('hex')) + if (await getResource(tenantId, 'webhook-events', eventId)) return ok(res, { accepted: true, duplicate: true }) + await putResource(tenantId, 'webhook-events', { id: eventId, payload: req.body, receivedAt: new Date().toISOString() }) + const telegramMessage = req.body?.data?.message + if (['message.received', 'message.pending', 'message.sent', 'message.failed'].includes(req.body.event) && telegramMessage) { + const chatId = String(telegramMessage.chat_id) + const customerId = `tg-${channel.gatewayAccountId}-${chatId}` + let customer = await getResource(tenantId, 'customers', customerId) + const identity = await resolveTelegramIdentity(channel, chatId, {}, customer || {}) + customer = await putResource(tenantId, 'customers', withDefaults('customers', { + ...(customer || {}), + ...identity, + id: customerId, + telegram: chatId, + source: 'telegram', + })) + const conversationId = `${channelId}:${chatId}` + const outgoing = Boolean(telegramMessage.is_outgoing) + const content = telegramMessage.content || {} + const originalText = content?.text?.text || content?.caption?.text || '' + const typeMap = { + messagePhoto: 'image', + messageDocument: 'file', + messageVoiceNote: 'audio', + messageAudio: 'audio', + messageSticker: 'file', + } + const messageStatus = !outgoing + ? 'delivered' + : req.body.event === 'message.failed' + ? 'failed' + : req.body.event === 'message.sent' + ? 'sent' + : 'sending' + const oldMessageId = String(req.body?.data?.old_message_id || '') + if (oldMessageId && oldMessageId !== String(telegramMessage.id)) { + await deleteResource(tenantId, `messages:${conversationId}`, oldMessageId) + } + const outgoingMeta = outgoing + ? await getResource(tenantId, 'outgoing-message-meta', oldMessageId || String(telegramMessage.id)) + : null + const message = await putResource(tenantId, `messages:${conversationId}`, { + id: String(telegramMessage.id), conversationId, sender: outgoing ? 'agent' : 'customer', + senderName: outgoing ? '我' : customer.name, + type: typeMap[content['@type']] || 'text', + originalText: outgoingMeta?.originalText || originalText, + translatedText: outgoingMeta?.translatedText, + originalLang: outgoingMeta?.originalLang || 'auto', + status: messageStatus, + translationStatus: !outgoing && content['@type'] === 'messageText' && originalText ? 'pending' : undefined, + createdAt: new Date(Number(telegramMessage.date || Date.now() / 1000) * 1000).toISOString(), + ...telegramAttachment(content), + }) + const previous = await getResource(tenantId, 'conversations', conversationId) + await putResource(tenantId, 'conversations', { + id: conversationId, customerId: customer.id, channel: 'telegram', channelId, + telegramChatId: chatId, unreadCount: outgoing ? Number(previous?.unreadCount || 0) : Number(previous?.unreadCount || 0) + 1, + status: outgoing ? 'replied' : 'pending', assigneeId: previous?.assigneeId || null, + intentLevel: previous?.intentLevel || 'low', sentiment: previous?.sentiment || 'neutral', + hasRisk: previous?.hasRisk || false, summary: previous?.summary || '', + lastMessage: message, createdAt: previous?.createdAt || new Date().toISOString(), + }) + broadcast(tenantId, { + type: 'message.created', + conversationId, + message, + customer, + oldMessageId: oldMessageId || undefined, + }) + if (!outgoing && req.body.event === 'message.received') { + await putResource(tenantId, 'notifications', { + id: `telegram-message-${channelId}-${telegramMessage.id}`, + type: 'inquiry', + title: `${customer.name} 发来新消息`, + description: originalText || (typeMap[content['@type']] === 'image' + ? '[图片]' + : typeMap[content['@type']] === 'audio' + ? '[语音]' + : typeMap[content['@type']] === 'file' + ? '[文件]' + : '[新消息]'), + link: `/inbox?c=${encodeURIComponent(conversationId)}`, + read: false, + createdAt: new Date().toISOString(), + }) + } + if (outgoingMeta && req.body.event === 'message.sent') { + await deleteResource(tenantId, 'outgoing-message-meta', outgoingMeta.id) + } + enqueueAutomaticTranslation(tenantId, conversationId, message) + if (!outgoing && req.body.event === 'message.received') { + enqueueAutomaticProfile(tenantId, conversationId) + } + } + ok(res, { accepted: true }) +}) + +app.get('/health/live', (_req, res) => res.json({ status: 'ok', service: 'lingxi-crossborder-api' })) +app.get('/health/ready', async (_req, res) => { + await pool.query('SELECT 1'); res.json({ status: 'ready', database: 'ok' }) +}) + +app.use((error, req, res, _next) => { + const status = Number(error.status || 500) + if (status >= 500) console.error(JSON.stringify({ requestId: res.getHeader('x-request-id'), message: error.message, stack: error.stack })) + res.status(status).json({ code: status, message: error.message || '服务器内部错误', data: null, requestId: res.getHeader('x-request-id') }) +}) + +async function repairCorruptedTranslations() { + const { rows } = await pool.query( + `SELECT tenant_id, kind, data + FROM resources + WHERE kind LIKE 'messages:%' + AND data ? 'translatedText'`, + ) + let repaired = 0 + for (const row of rows) { + const message = row.data + if (!isSuspiciousTranslation(message.originalText, message.translatedText)) continue + const conversationId = String(message.conversationId || row.kind.slice('messages:'.length)) + const { + translatedText: _translatedText, + translatedAt: _translatedAt, + translationRetryAt: _translationRetryAt, + ...clean + } = message + const numericOnly = isNonLinguisticText(clean.originalText) + const fixed = await putResource(String(row.tenant_id), row.kind, { + ...clean, + translatedText: numericOnly ? String(clean.originalText || '').trim() : undefined, + originalLang: numericOnly ? 'und' : clean.originalLang, + translationStatus: numericOnly ? 'completed' : 'pending', + translatedAt: numericOnly ? new Date().toISOString() : undefined, + }) + const conversation = await getResource(String(row.tenant_id), 'conversations', conversationId) + if (String(conversation?.lastMessage?.id || '') === String(fixed.id)) { + await patchResource(String(row.tenant_id), 'conversations', conversationId, { lastMessage: fixed }) + } + if (!numericOnly) enqueueAutomaticTranslation(String(row.tenant_id), conversationId, fixed) + repaired++ + } + if (repaired) console.log(`已修复 ${repaired} 条受污染的历史译文`) +} + +await migrate() +await repairCorruptedTranslations() +const existingTelegramChannels = await pool.query( + `SELECT tenant_id, data FROM resources + WHERE kind='channels' AND data->>'type'='telegram' AND data ? 'gatewayAccountId'`, +) +for (const row of existingTelegramChannels.rows) { + await ensureChannelWebhook(String(row.tenant_id), row.data).catch(async (error) => { + await patchResource(String(row.tenant_id), 'channels', row.data.id, { + errorMessage: `Webhook 同步失败:${error.message}`, + }) + }) +} +httpServer.listen(Number(process.env.PORT || 3100), '0.0.0.0', () => console.log(`灵犀跨境 API listening on ${process.env.PORT || 3100}`)) diff --git a/server/test/business.test.js b/server/test/business.test.js new file mode 100644 index 0000000..a6ec0d8 --- /dev/null +++ b/server/test/business.test.js @@ -0,0 +1,44 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js' + +test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => { + assert.deepEqual( + telegramAttachment({ + '@type': 'messagePhoto', + photo: { sizes: [{ photo: { id: 11, size: 2048 } }] }, + }), + { + telegramFileId: 11, + attachmentName: 'telegram-photo-11.jpg', + attachmentMimeType: 'image/jpeg', + attachmentSize: 2048, + }, + ) + + assert.equal(telegramAttachment({ + '@type': 'messageDocument', + document: { file_name: 'quote.pdf', mime_type: 'application/pdf', document: { id: 12, size: 4096 } }, + }).attachmentMimeType, 'application/pdf') + + assert.equal(telegramAttachment({ + '@type': 'messageVoiceNote', + voice_note: { duration: 8, voice: { id: 13, expected_size: 1024 } }, + }).duration, 8) + + assert.equal(telegramAttachment({ + '@type': 'messageSticker', + sticker: { emoji: '👍', format: { '@type': 'stickerFormatWebp' }, sticker: { id: 14, size: 512 } }, + }).attachmentMimeType, 'image/webp') +}) + +test('业务指标工具处理百分比、分组和无效日期', () => { + assert.equal(percent(3, 8), 37.5) + assert.equal(percent(1, 0), 0) + assert.equal(dateKey('invalid'), '') + assert.equal(dateKey('2026-07-27T12:30:00Z'), '2026-07-27') + assert.deepEqual( + Object.fromEntries(countBy([{ stage: 'new' }, { stage: 'new' }, { stage: 'won' }], 'stage')), + { new: 2, won: 1 }, + ) +}) diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index b31964c..272fd5b 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -5,14 +5,12 @@ import type { ReactNode } from 'react' import { LoginPage } from '@/pages/auth/LoginPage' import { RegisterPage } from '@/pages/auth/RegisterPage' -import { OnboardingPage } from '@/pages/onboarding/OnboardingPage' import { DashboardPage } from '@/pages/dashboard/DashboardPage' import { InboxPage } from '@/pages/inbox/InboxPage' import { CustomersPage } from '@/pages/customers/CustomersPage' import { CustomerDetailPage } from '@/pages/customers/CustomerDetailPage' import { OpportunitiesPage } from '@/pages/opportunities/OpportunitiesPage' import { TranslatePage } from '@/pages/translate/TranslatePage' -import { InterpretPage } from '@/pages/interpret/InterpretPage' import { RisksPage } from '@/pages/risks/RisksPage' import { KnowledgePage } from '@/pages/knowledge/KnowledgePage' import { AnalyticsPage } from '@/pages/analytics/AnalyticsPage' @@ -38,14 +36,6 @@ function GuestOnly({ children }: { children: ReactNode }) { const router = createBrowserRouter([ { path: '/login', element: }, { path: '/register', element: }, - { - path: '/onboarding', - element: ( - - - - ), - }, { path: '/', element: ( @@ -61,7 +51,6 @@ const router = createBrowserRouter([ { path: 'customers/:id', element: }, { path: 'opportunities', element: }, { path: 'translate', element: }, - { path: 'interpret', element: }, { path: 'risks', element: }, { path: 'knowledge', element: }, { path: 'analytics', element: }, diff --git a/src/app/store/auth.ts b/src/app/store/auth.ts index e096a05..e25f75d 100644 --- a/src/app/store/auth.ts +++ b/src/app/store/auth.ts @@ -2,7 +2,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' import type { AuthSession, Tenant, User } from '@/types' import type { RoleKey } from '@/config/roles' -import { ROLE_META } from '@/config/roles' import { setTokenGetter } from '@/services/api/client' interface AuthState { @@ -12,8 +11,6 @@ interface AuthState { onboarded: boolean setSession: (session: AuthSession) => void logout: () => void - /** 演示用:切换当前用户角色以预览不同权限 */ - switchRole: (role: RoleKey) => void setOnboarded: (v: boolean) => void } @@ -27,8 +24,6 @@ export const useAuthStore = create()( setSession: (session) => set({ token: session.token, user: session.user, tenant: session.tenant, onboarded: session.onboarded }), logout: () => set({ token: null, user: null, tenant: null }), - switchRole: (role) => - set((s) => (s.user ? { user: { ...s.user, role, title: ROLE_META[role].label } } : s)), setOnboarded: (v) => set({ onboarded: v }), }), { name: 'lx-auth' }, diff --git a/src/components/charts/index.tsx b/src/components/charts/index.tsx index f700af7..28cef13 100644 --- a/src/components/charts/index.tsx +++ b/src/components/charts/index.tsx @@ -15,7 +15,7 @@ import { YAxis, } from 'recharts' import { Card, CardHeader } from '@/components/ui/card' -import { DemoNote } from '@/components/common/DemoNote' +import { LiveDataBadge } from '@/components/common/LiveDataBadge' import { cn } from '@/utils/cn' export const CHART_COLORS = ['#247b71', '#32998c', '#52b3a5', '#83cfc2', '#f59e0b', '#3b82f6', '#8b5cf6', '#94a3b8'] @@ -43,7 +43,7 @@ export function ChartCard({

{title}

{description &&

{description}

} - {action ?? } + {action ?? }
diff --git a/src/components/common/DemoNote.tsx b/src/components/common/DemoNote.tsx deleted file mode 100644 index 6a00b13..0000000 --- a/src/components/common/DemoNote.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Info } from 'lucide-react' -import { cn } from '@/utils/cn' - -/** “演示数据”标注 */ -export function DemoNote({ className, text = '演示数据' }: { className?: string; text?: string }) { - return ( - - - {text} - - ) -} diff --git a/src/components/common/LiveDataBadge.tsx b/src/components/common/LiveDataBadge.tsx new file mode 100644 index 0000000..7e007c5 --- /dev/null +++ b/src/components/common/LiveDataBadge.tsx @@ -0,0 +1,17 @@ +import { Radio } from 'lucide-react' +import { cn } from '@/utils/cn' + +export function LiveDataBadge({ className, text = '实时数据' }: { className?: string; text?: string }) { + return ( + + + {text} + + ) +} diff --git a/src/components/inbox/AiReplies.tsx b/src/components/inbox/AiReplies.tsx index 9778500..9173c00 100644 --- a/src/components/inbox/AiReplies.tsx +++ b/src/components/inbox/AiReplies.tsx @@ -1,6 +1,6 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { useMutation } from '@tanstack/react-query' -import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen } from 'lucide-react' +import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, X } from 'lucide-react' import { generateReplies } from '@/services/api/ai' import type { AiReplySuggestion } from '@/types/ai' import { Button } from '@/components/ui/button' @@ -19,12 +19,15 @@ export function AiReplies({ conversationId, onAdopt, onEdit, + onClose, }: { conversationId: string onAdopt: (content: string) => void onEdit: (content: string) => void + onClose: () => void }) { const [items, setItems] = useState(null) + useEffect(() => setItems(null), [conversationId]) const mutation = useMutation({ mutationFn: () => generateReplies(conversationId), @@ -42,21 +45,26 @@ export function AiReplies({ AI 智能回复 - +
+ + +
{mutation.isPending && ( @@ -76,7 +84,7 @@ export function AiReplies({ {items && items.length > 0 && (
{items.map((s) => { - const style = STYLE_LABELS[s.style] + const style = STYLE_LABELS[s.style] || STYLE_LABELS.stable return (
@@ -89,7 +97,7 @@ export function AiReplies({

{s.content}

- {s.knowledgeRefs.slice(0, 2).map((ref) => ( + {(s.knowledgeRefs || []).slice(0, 2).map((ref) => ( {ref} diff --git a/src/components/inbox/Composer.tsx b/src/components/inbox/Composer.tsx index f5447e5..7b5a046 100644 --- a/src/components/inbox/Composer.tsx +++ b/src/components/inbox/Composer.tsx @@ -10,6 +10,8 @@ import { Shrink, Expand, ChevronDown, + Reply, + X, } from 'lucide-react' import { Segmented } from '@/components/ui/tabs' import { Button } from '@/components/ui/button' @@ -39,27 +41,52 @@ const TONES = [ export function Composer({ onSend, + onMedia, + value, + onChange, disabled, + replyingTo, + onCancelReply, }: { - onSend: (text: string, translatedText?: string) => Promise + onSend: (text: string, autoTranslate?: boolean) => Promise + onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise + value?: string + onChange?: (text: string) => void disabled?: boolean + replyingTo?: { id: string; text: string } | null + onCancelReply?: () => void }) { - const [text, setText] = useState('') + const [internalText, setInternalText] = useState('') const [sending, setSending] = useState(false) - const [mode, setMode] = useState<'text' | 'preview'>('text') - const [previewText, setPreviewText] = useState('') - const [confirmBeforeSend, setConfirmBeforeSend] = useState(true) + const [autoTranslate, setAutoTranslate] = useState(true) const [aiLoading, setAiLoading] = useState(false) + const text = value ?? internalText + const setText = onChange ?? setInternalText const textarea = useRef(null) + const documentInput = useRef(null) + const imageInput = useRef(null) + const audioInput = useRef(null) + const stickerInput = useRef(null) - const send = async () => { - const body = confirmBeforeSend ? previewText || text : text - if (!body.trim()) return + const uploadFiles = async (files: FileList | null, kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => { + if (!files?.length || !onMedia) return setSending(true) try { - await onSend(body, previewText || undefined) + await onMedia(Array.from(files), kind) + toast.success(`${files.length} 个文件已发送到 Telegram`) + } catch { + toast.error('媒体发送失败') + } finally { + setSending(false) + } + } + + const send = async () => { + if (!text.trim()) return + setSending(true) + try { + await onSend(text, autoTranslate) setText('') - setPreviewText('') } finally { setSending(false) } @@ -73,7 +100,7 @@ export function Composer({ } const insert = (txt: string) => { - setText((prev) => (prev ? `${prev}\n${txt}` : txt)) + setText(text ? `${text}\n${txt}` : txt) textarea.current?.focus() } @@ -108,7 +135,7 @@ export function Composer({ + + uploadFiles(e.target.files, 'document')} /> + uploadFiles(e.target.files, 'photo')} /> + uploadFiles(e.target.files, 'voice')} /> + uploadFiles(e.target.files, 'sticker')} /> {/* AI 工具 */} {[ @@ -164,13 +203,22 @@ export function Composer({
{toolbar}
+ {replyingTo && ( +
+ +
+

引用回复

+

{replyingTo.text}

+
+ +
+ )}