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
+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