chore: CRM-NEW baseline

This commit is contained in:
wangliang
2026-10-23 10:35:28 +08:00
commit aa499f6dbf
111 changed files with 15126 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
# ============================================================================
# crm-new/libredesk 本地/测试环境配置
# 复制为 .env 后填写。scripts/bootstrap.sh 会在缺失时自动生成密钥。
# ============================================================================
# ---- LibreDesk ----
# 稳定版(v2.8.x,无 WhatsApp)
LIBREDESK_IMAGE=libredesk/libredesk:latest
# 当前使用 v2.9 预发布版(含 WhatsApp;稳定版 latest = v2.8.0 无 WhatsApp)
# LIBREDESK_IMAGE=docker.1panel.live/libredesk/libredesk:v2.9.0-rc.10
# 本机无法直连 Docker Hub 时,用镜像源前缀或本地已有镜像覆盖:
# 回滚稳定版:LIBREDESK_IMAGE=docker.1panel.live/libredesk/libredesk:latest
# POSTGRES_IMAGE=pgvector/pgvector:pg18
# REDIS_IMAGE=redis:8.8-alpine
# 首次启动时设置的 System 用户密码
LIBREDESK_SYSTEM_USER_PASSWORD=change-me-please
LIBREDESK_BASE_URL=http://localhost:9000
# 系统用户 API key/secret(登录后 Admin → Teammates → System 生成),
# 仅 scripts/register-tools.js 自动注册 AI tools 时需要。
LIBREDESK_API_KEY=
LIBREDESK_API_SECRET=
# ---- Postgres(本套独立实例,与旧 crm 的 crm-postgres 隔离)----
# compose 默认使用 pgvector/pgvector:pg18(PG18 数据目录挂载 /var/lib/postgresql)
POSTGRES_USER=libredesk
POSTGRES_PASSWORD=change-me-please
POSTGRES_DB=libredesk
# ---- 加密密钥 ----
# 32 位十六进制字符串(openssl rand -hex 16);bootstrap 缺失时自动生成。
ENCRYPTION_KEY=
# ---- Tools 服务 ----
TOOLS_PORT=4010
# LibreDesk 调工具时带的共享密钥(两边必须一致)
TOOLS_SHARED_KEY=change-me-please
# LibreDesk 容器访问宿主机的地址
TOOLS_PUBLIC_BASE_URL=http://host.docker.internal:4010
# 业务 RPC(默认指向测试服 47.76.223.252)
RECOMMEND_URL=jsonrpc://47.76.223.252:24503
ORDER_API_URL=jsonrpc://47.76.223.252:24505
INVENTORY_API_URL=jsonrpc://47.76.223.252:24509
MEMBER_RPC_URL=jsonrpc://47.76.223.252:24506
RPC_TIMEOUT_MS=8000
# 商品搜索无结果时的兜底搜索页
SITE_SEARCH_URL=https://www.yehwang.com/search.html?q=
# 仅本地联调:跳过 verified contact 校验(生产禁止打开)
TOOLS_DEV_BYPASS=false
# ---- Twenty CRM ----
TWENTY_BASE_URL=http://localhost:3000
TWENTY_GRAPHQL_PATH=/graphql
TWENTY_WORKSPACE_ID=
TWENTY_API_KEY=
+164
View File
@@ -0,0 +1,164 @@
# libredesk(crm-new/libredesk)
位于 `/home/dev/code/crm-new/libredesk` 的 **LibreDesk 版 CRM 客服栈**,与旧项目 `/home/dev/code/crm/crm-ai-demo` 完全隔离(独立目录、独立 git、独立 Postgres/Redis、独立端口)。
- **定位**:验证「LibreDesk 作为客服平台 + 内置 AI Assistant + 外部业务工具」能否替代现有 Chatwoot + bridge 架构。
- **当前阶段**:POC 骨架(可启动、工具服务可跑、含单元测试),尚未接入真实 widget 和生产数据。
- **旧系统不受影响**:`crm-ai-demo` 仍在 3000/3001/4000 端口运行。
## 当前状态(2026-10-21)
| 项 | 状态 |
|---|---|
| 目录/配置/脚本脚手架 | ✅ 完成 |
| crm-tools 服务(4 工具 + 鉴权 + 验证门控) | ✅ 单测 18/18 通过,本机 :4010 可运行 |
| config.toml 渲染 / compose 校验 | ✅ 通过 |
| LibreDesk 容器 | ✅ 运行中(**v2.9.0-rc.10**,health 200);Postgres 18 / Redis 健康 |
| 后台 AI/工具/助手配置 | ⏳ 登录 `http://localhost:9000` 后按 `docs/libredesk-setup.md` 执行 |
> 当前运行 **v2.9.0-rc.10**:渠道已包含 `email / livechat / whatsapp`,WhatsApp 表已迁移完成。
> RC 属于预发布版本,功能可用但生产使用前需评估稳定性(回滚方法见「镜像拉取」)。
## 架构
```
访客 ──► LibreDesk Live Chat Widget (:9000)
│
▼
LibreDesk AI Assistant ──HTTP tool calls──► crm-tools (:4010)
(snippets 向量检索 / 内置 OTP 验证) │
├──► 商品推荐 RPC (recommend)
├──► 订单 RPC (trading-service)
├──► 库存/ETA RPC (eta-service)
└──► Twenty CRM GraphQL (商机/备注/任务)
```
关键设计:**业务数据与身份验证不放进 LibreDesk 代码里**,而是通过它的自定义 HTTP 工具回调 `crm-tools`;工具服务二次校验 `X-Libredesk-Contact-Verified`,不信任模型参数里的邮箱。
详见 [docs/architecture.md](docs/architecture.md)。
## 目录
```
crm-new/libredesk/
├── docker-compose.yml # LibreDesk + 独立 Postgres(5433) + Redis(6380)
├── .env.example # 环境变量模板(bootstrap 自动生成 .env)
├── config/
│ └── config.toml.example # LibreDesk 配置模板(渲染成 config.toml,不入库)
├── scripts/
│ ├── bootstrap.sh # 生成密钥 + 渲染配置 + 启动 LibreDesk
│ ├── start-tools.sh # 宿主机启动 tools 服务
│ ├── render-config.js # config.toml 渲染
│ ├── register-tools.js # 把工具注册进 LibreDesk(可选)
│ └── smoke.sh # 冒烟测试
├── services/tools/ # 业务工具服务(Node 20+,零外部依赖)
│ ├── src/
│ │ ├── server.js # HTTP 入口 + 鉴权 + 验证门控
│ │ ├── tools.js # 4 个工具:商品/订单/库存/建商机
│ │ ├── twenty.js # Twenty GraphQL 客户端
│ │ ├── rpc.js # JSONRPC-TCP 客户端(Hyperf 协议)
│ │ ├── config.js / auth.js / errors.js
│ └── test/ # node:test 单元测试
├── docs/
│ ├── architecture.md # 架构与数据流
│ ├── libredesk-setup.md # 后台配置步骤(AI/工具/助手/自动化/Widget)
│ ├── api-mapping.md # Chatwoot → LibreDesk 接口/事件/字段对照
│ └── migration-plan.md # 6 层拆分、方案 A/B/C、代码存废清单
├── deploy/nginx.conf.example
└── uploads/ # LibreDesk 上传目录(运行时)
```
## 快速开始
> 网络提示:本机直连 Docker Hub 会 reset。`.env` 已默认复用本地已有镜像
> (`pgvector/pgvector:pg18`、`redis:8.8-alpine`),LibreDesk 镜像可用时可改
> `LIBREDESK_IMAGE=docker.1panel.live/libredesk/libredesk:latest` 等镜像源。
> 详见文末「镜像拉取」一节。
```bash
cd /home/dev/code/crm-new/libredesk
# 1) 启动 LibreDesk(自动生成 .env / config.toml,首次拉镜像)
bash scripts/bootstrap.sh
# 2) 另开终端,启动工具服务 :4010
bash scripts/start-tools.sh
# 3) 按 docs/libredesk-setup.md 配置 AI Provider / Inbox
# 然后在 Admin → Teammates → System 生成 API key,填入 .env
# 4) 自动注册 4 个 AI 工具(失败也可按文档手工创建)
node scripts/register-tools.js
# 5) 冒烟
bash scripts/smoke.sh
```
| 组件 | 地址 | 说明 |
|---|---|---|
| LibreDesk | http://localhost:9000 | `System` + bootstrap 生成的密码 |
| crm-tools | http://127.0.0.1:4010 | `/healthz`、`/tools/manifest`、`/tools/<name>` |
| Postgres | 127.0.0.1:5433 | 独立实例,库 `libredesk` |
| Redis | 127.0.0.1:6380 | 独立实例 |
## 工具清单
| 工具 | 需要验证 | 作用 |
|---|---|---|
| `search_products` | 否 | 商品关键词搜索(recommend RPC) |
| `search_orders` | **是** | 订单/物流查询,邮箱强制取自已验证身份头 |
| `search_inventory` | 否 | SKU 实时库存 + ETA |
| `create_opportunity` | **是** | Twenty 建商机 + 聊天摘要备注 + 3 天跟进任务,按邮箱去重 |
## 测试
```bash
cd services/tools && npm test
```
覆盖:地址解析、鉴权 fail-closed、验证门控、邮箱作用域/IDOR 防护、商机去重。
## 本阶段边界(未做)
- 未接入真实网站 widget(JWT 签发仍是下一步)
- 未做 Chatwoot → LibreDesk 数据迁移(当前旧库仅 2 条会话,可忽略)
- 未接入观测/评估/飞轮(需要时按 [docs/migration-plan.md](docs/migration-plan.md) 的 L6 保留 bridge)
- WhatsApp 需 v2.9 预发布版(见 setup 文档最后一节)
## 镜像拉取(国内网络)
本机实测:`registry-1.docker.io` 连接被 reset,直连不可用。
| 方案 | 命令 | 备注 |
|---|---|---|
| 1panel 镜像源 | `docker pull docker.1panel.live/libredesk/libredesk:v2.9.0-rc.10` | ✅ 2026-10-21 实测可用(74MB) |
| 稳定版 latest | `docker pull docker.1panel.live/libredesk/libredesk:latest` | ✅ 可用(= v2.8.0,无 WhatsApp) |
| 1ms 镜像源 | `docker pull docker.1ms.run/libredesk/libredesk:latest` | 备用 |
| 自建(不推荐) | 从 `v2.8.0` 源码构建 | 需要 Go + Node + pnpm,耗时较长 |
拉到镜像后改 `.env`:
```ini
LIBREDESK_IMAGE=docker.1panel.live/libredesk/libredesk:v2.9.0-rc.10
# 或本地打 tag 后继续用默认值
# docker tag docker.1panel.live/libredesk/libredesk:v2.9.0-rc.10 libredesk/libredesk:v2.9.0-rc.10
```
### 回滚到稳定版 v2.8.0
```bash
sed -i 's#^LIBREDESK_IMAGE=.*#LIBREDESK_IMAGE=docker.1panel.live/libredesk/libredesk:latest#' .env
docker compose up -d --force-recreate app # 跨版本回退需用备份库,见下
```
## 与旧系统的关系
| | 旧栈 `crm-ai-demo` | 新栈 `crm-new/libredesk` |
|---|---|---|
| 客服平台 | Chatwoot 4.16(Rails) | LibreDesk(Go 单二进制) |
| AI 编排 | bridge 自研 agent loop | LibreDesk AI Assistant(POC 目标) |
| 业务工具 | bridge 内置 | 独立 `services/tools` |
| 数据 | crm-postgres:5432 | 独立 postgres:5433 |
| 端口 | 3000/3001/4000 | 9000/4010 |
两套可以并行跑,方便同一批问题做 A/B 对比。
@@ -0,0 +1,81 @@
name: crm-libredesk
# 一套独立的 LibreDesk 实例:自带 Postgres/Redis,端口与旧 crm 栈隔离。
# - LibreDesk : http://localhost:9000
# - Postgres : 127.0.0.1:5433(旧栈是 5432)
# - Redis : 127.0.0.1:6380(旧栈是 6379)
# Tools 服务跑在宿主机 :4010(见 README),LibreDesk 通过 host.docker.internal 访问。
services:
app:
image: ${LIBREDESK_IMAGE:-libredesk/libredesk:latest}
container_name: libredesk_app
restart: unless-stopped
ports:
- "9000:9000"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
LIBREDESK_SYSTEM_USER_PASSWORD: ${LIBREDESK_SYSTEM_USER_PASSWORD:-}
networks:
- libredesk
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./uploads:/libredesk/uploads:rw
- ./config/config.toml:/libredesk/config.toml:ro
command:
- sh
- -c
- >-
./libredesk --install --idempotent-install --yes --config /libredesk/config.toml &&
./libredesk --upgrade --yes --config /libredesk/config.toml &&
./libredesk --config /libredesk/config.toml
db:
image: ${POSTGRES_IMAGE:-pgvector/pgvector:pg18}
container_name: libredesk_db
restart: unless-stopped
networks:
- libredesk
ports:
# 只绑本机,避免与旧栈 5432 冲突
- "127.0.0.1:5433:5432"
environment:
POSTGRES_USER: ${POSTGRES_USER:-libredesk}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-libredesk}
POSTGRES_DB: ${POSTGRES_DB:-libredesk}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-libredesk} -d ${POSTGRES_DB:-libredesk}"]
interval: 5s
timeout: 5s
retries: 12
volumes:
# PG18 镜像要求挂载 /var/lib/postgresql(数据在子目录 18/docker)
- postgres-data:/var/lib/postgresql
redis:
image: ${REDIS_IMAGE:-redis:7-alpine}
container_name: libredesk_redis
restart: unless-stopped
networks:
- libredesk
ports:
- "127.0.0.1:6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
volumes:
- redis-data:/data
networks:
libredesk:
volumes:
postgres-data:
redis-data:
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# 一键启动本套 LibreDesk(独立 pg/redis,端口与旧 crm 栈隔离)。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [ ! -f .env ]; then
echo "==> 未找到 .env,从 .env.example 生成并填充随机密钥"
cp .env.example .env
node - "$ROOT" <<'NODE'
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const p = path.join(process.argv[2], '.env');
let s = fs.readFileSync(p, 'utf8');
const set = (k, v) => {
const re = new RegExp('^' + k + '=.*$', 'm');
s = re.test(s) ? s.replace(re, `${k}=${v}`) : `${s}\n${k}=${v}`;
};
if (/^ENCRYPTION_KEY=$/m.test(s)) set('ENCRYPTION_KEY', crypto.randomBytes(16).toString('hex'));
if (/^POSTGRES_PASSWORD=change-me-please$/m.test(s)) set('POSTGRES_PASSWORD', crypto.randomBytes(12).toString('hex'));
if (/^TOOLS_SHARED_KEY=change-me-please$/m.test(s)) set('TOOLS_SHARED_KEY', crypto.randomBytes(16).toString('hex'));
const strongPassword = () => `Yehwang${crypto.randomInt(1000, 9999)}!${crypto.randomBytes(4).toString('hex')}`;
if (/^LIBREDESK_SYSTEM_USER_PASSWORD=change-me-please$/m.test(s)) set('LIBREDESK_SYSTEM_USER_PASSWORD', strongPassword());
fs.writeFileSync(p, s, { mode: 0o600 });
NODE
echo " 已生成 .env(含随机密钥,勿提交)"
fi
echo "==> 渲染 config/config.toml"
node scripts/render-config.js
mkdir -p uploads
touch uploads/.gitkeep
echo "==> 启动 LibreDesk(首次会拉取镜像,约 1-3 分钟)"
docker compose up -d
echo "==> 等待 http://localhost:9000/health"
for i in $(seq 1 90); do
if curl -fsS --max-time 2 http://localhost:9000/health >/dev/null 2>&1; then
echo " LibreDesk 已就绪(${i} 次探测)"
break
fi
if [ "$i" -eq 90 ]; then
echo "!! 等待超时,查看日志:docker logs -f libredesk_app" >&2
exit 1
fi
sleep 2
done
# shellcheck disable=SC1091
set -a; . ./.env; set +a
echo
echo "==================== 启动完成 ===================="
echo " 后台地址 : http://localhost:9000"
echo " 登录用户 : System"
echo " 登录密码 : ${LIBREDESK_SYSTEM_USER_PASSWORD}"
echo
echo " 下一步:"
echo " 1. 启动工具服务:bash scripts/start-tools.sh"
echo " 2. 按 docs/libredesk-setup.md 配置 AI Provider / Actions"
echo " 3. 自动注册工具(可选):node scripts/register-tools.js"
echo "================================================="
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
'use strict';
/**
* 把 tools 服务的 manifest 注册到 LibreDesk(POST/PUT /api/v1/ai/tools)。
*
* 前置:
* 1. LibreDesk 已启动
* 2. .env 里配置 LIBREDESK_API_KEY / LIBREDESK_API_SECRET(系统用户或具备 ai:manage 的 agent)
* 3. tools 服务已启动(bash scripts/start-tools.sh)
*
* 权限不足(403/401)时,回退到 Admin → AI → Tools 手工创建(字段见 docs/libredesk-setup.md)。
*/
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
function parseEnv(file) {
const out = {};
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
let v = t.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
out[t.slice(0, eq).trim()] = v;
}
return out;
}
async function main() {
const envPath = path.join(root, '.env');
if (!fs.existsSync(envPath)) throw new Error('.env not found — run scripts/bootstrap.sh first');
const env = parseEnv(envPath);
const base = (env.LIBREDESK_BASE_URL || 'http://localhost:9000').replace(/\/+$/, '');
const apiKey = env.LIBREDESK_API_KEY;
const apiSecret = env.LIBREDESK_API_SECRET;
const toolsPort = env.TOOLS_PORT || '4010';
const toolsKey = env.TOOLS_SHARED_KEY;
if (!apiKey || !apiSecret) throw new Error('LIBREDESK_API_KEY / LIBREDESK_API_SECRET 未配置');
if (!toolsKey) throw new Error('TOOLS_SHARED_KEY 未配置');
const authHeaders = {
Authorization: `token ${apiKey}:${apiSecret}`,
'Content-Type': 'application/json',
};
// 1. 本机拉取 manifest(TOOLS_PUBLIC_BASE_URL 是容器视角,宿主机要用 127.0.0.1)
const manifestRes = await fetch(`http://127.0.0.1:${toolsPort}/tools/manifest`, { headers: { 'X-Tools-Key': toolsKey } });
if (!manifestRes.ok) throw new Error(`tools manifest 不可用(HTTP ${manifestRes.status}),先启动 bash scripts/start-tools.sh`);
const manifest = await manifestRes.json();
// 2. 现有工具
const listRes = await fetch(`${base}/api/v1/ai/tools`, { headers: authHeaders });
const listText = await listRes.text();
if (!listRes.ok) throw new Error(`读取 AI tools 失败(HTTP ${listRes.status}):${listText.slice(0, 300)}`);
const listJson = JSON.parse(listText);
const existing = Array.isArray(listJson) ? listJson : (listJson.data || []);
const byName = new Map(existing.map((t) => [t.name, t]));
// 3. 逐个创建/更新
const results = [];
for (const tool of manifest.tools) {
const body = {
name: tool.name,
description: tool.description,
url: tool.url,
method: 'POST',
auth: { headers: [{ key: 'X-Tools-Key', value: toolsKey }] },
parameters: tool.parameters,
enabled: true,
copilot_enabled: true,
generate_reply_enabled: true,
requires_agent_approval: false,
requires_verification: Boolean(tool.requires_verification),
};
const current = byName.get(tool.name);
const url = current ? `${base}/api/v1/ai/tools/${current.id}` : `${base}/api/v1/ai/tools`;
const res = await fetch(url, { method: current ? 'PUT' : 'POST', headers: authHeaders, body: JSON.stringify(body) });
const text = await res.text();
if (!res.ok) {
results.push({ name: tool.name, ok: false, status: res.status, body: text.slice(0, 300) });
} else {
results.push({ name: tool.name, ok: true, action: current ? 'updated' : 'created' });
}
}
for (const r of results) {
if (r.ok) console.log(` ✓ ${r.name} (${r.action})`);
else console.error(` ✗ ${r.name} → HTTP ${r.status}: ${r.body}`);
}
if (results.some((r) => !r.ok)) {
console.error('\n部分工具注册失败。常见原因:API key 权限不足(需要 ai:manage)、LibreDesk 版本字段不匹配。');
console.error('回退方案:按 docs/libredesk-setup.md 在 Admin → AI → Tools 手工创建。');
process.exitCode = 1;
}
}
main().catch((err) => {
console.error('[register-tools]', err.message);
process.exit(1);
});
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
'use strict';
/**
* 渲染 config/config.toml:读取 .env + 模板,替换密钥占位符。
* 只做字符串替换,不做 TOML 校验(LibreDesk 启动时会报错)。
*/
const fs = require('fs');
const path = require('path');
const root = path.resolve(__dirname, '..');
const envPath = path.join(root, '.env');
const tplPath = path.join(root, 'config', 'config.toml.example');
const outPath = path.join(root, 'config', 'config.toml');
function parseEnv(text) {
const out = {};
for (const line of text.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 0) continue;
const key = t.slice(0, eq).trim();
let value = t.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
if (!fs.existsSync(envPath)) {
console.error('[render-config] .env not found — run scripts/bootstrap.sh first');
process.exit(1);
}
const env = parseEnv(fs.readFileSync(envPath, 'utf8'));
const required = ['ENCRYPTION_KEY', 'POSTGRES_PASSWORD'];
const missing = required.filter((k) => !env[k]);
if (missing.length) {
console.error(`[render-config] missing required env: ${missing.join(', ')}`);
process.exit(1);
}
if (env.ENCRYPTION_KEY.length !== 32) {
console.error('[render-config] ENCRYPTION_KEY must be 32 chars (openssl rand -hex 16)');
process.exit(1);
}
const rendered = fs
.readFileSync(tplPath, 'utf8')
.replaceAll('__ENCRYPTION_KEY__', env.ENCRYPTION_KEY)
.replaceAll('__DB_PASSWORD__', env.POSTGRES_PASSWORD);
fs.writeFileSync(outPath, rendered, { mode: 0o600 });
console.log(`[render-config] wrote ${path.relative(root, outPath)} (mode 600)`);
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# 冒烟测试:验证 LibreDesk / tools 服务 / 工具鉴权与验证门控。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
# shellcheck disable=SC1091
set -a; . ./.env; set +a
TOOLS_PORT="${TOOLS_PORT:-4010}"
tools_base="http://127.0.0.1:${TOOLS_PORT}"
say() { printf '\n== %s ==\n' "$1"; }
status() { curl -s -o /tmp/crm_libredesk_smoke.out -w '%{http_code}' "$@"; }
say "LibreDesk /health"
code=$(status "http://localhost:9000/health" || true)
echo "HTTP ${code} $(head -c 200 /tmp/crm_libredesk_smoke.out 2>/dev/null || true)"
say "tools /healthz"
code=$(status "${tools_base}/healthz" || true)
echo "HTTP ${code} $(head -c 300 /tmp/crm_libredesk_smoke.out 2>/dev/null || true)"
say "tools manifest(鉴权)"
code=$(status -H "X-Tools-Key: ${TOOLS_SHARED_KEY}" "${tools_base}/tools/manifest" || true)
echo "HTTP ${code}"
python3 -c "import json;d=json.load(open('/tmp/crm_libredesk_smoke.out'));print('tools:',[t['name'] for t in d['tools']]);print('base:',d['base_url'])" 2>/dev/null || true
say "search_products(未验证,应 200)"
code=$(status -X POST "${tools_base}/tools/search_products" \
-H "X-Tools-Key: ${TOOLS_SHARED_KEY}" -H 'Content-Type: application/json' \
-d '{"query":"gold hoop earrings"}' || true)
echo "HTTP ${code} $(head -c 400 /tmp/crm_libredesk_smoke.out 2>/dev/null || true)"
say "search_orders(未验证,应 403 verification_required)"
code=$(status -X POST "${tools_base}/tools/search_orders" \
-H "X-Tools-Key: ${TOOLS_SHARED_KEY}" -H 'Content-Type: application/json' \
-d '{"orderNumber":"SO-1"}' || true)
echo "HTTP ${code} $(head -c 300 /tmp/crm_libredesk_smoke.out 2>/dev/null || true)"
say "search_orders(伪造已验证头,仅探测鉴权链路;生产不应依赖客户端头)"
code=$(status -X POST "${tools_base}/tools/search_orders" \
-H "X-Tools-Key: ${TOOLS_SHARED_KEY}" -H 'Content-Type: application/json' \
-H 'X-Libredesk-Contact-Verified: true' -H "X-Libredesk-Contact-Email: ${SMOKE_TEST_EMAIL:-demo@example.com}" \
-d '{"orderNumber":"SO-1"}' || true)
echo "HTTP ${code} $(head -c 300 /tmp/crm_libredesk_smoke.out 2>/dev/null || true)"
rm -f /tmp/crm_libredesk_smoke.out
echo
echo "冒烟测试完成。"
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# 在宿主机启动 tools 服务(LibreDesk 容器通过 host.docker.internal:4010 访问)。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
if [ ! -f .env ]; then
echo "缺少 .env,请先执行 bash scripts/bootstrap.sh" >&2
exit 1
fi
# shellcheck disable=SC1091
set -a; . ./.env; set +a
cd services/tools
if [ ! -d node_modules ]; then
echo "==> 安装依赖(本服务零外部依赖,仅生成 lockfile)"
npm install --package-lock-only --silent >/dev/null 2>&1 || true
fi
echo "==> tools 服务启动于 :${TOOLS_PORT:-4010}"
echo " manifest: http://127.0.0.1:${TOOLS_PORT:-4010}/tools/manifest (需 X-Tools-Key)"
exec node src/server.js