This commit is contained in:
2025-11-27 18:22:26 +08:00
commit 90eb1970e1
12 changed files with 767 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
.env

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# Dockerfile
FROM oven/bun:1 AS base
WORKDIR /app
# 安装依赖(缓存层)
COPY package.json bun.lockb* ./
RUN bun install --production
# 复制源码
COPY . .
# 构建Bun 支持直接运行 TS无需编译
# 如果你希望预编译,可加 RUN bun build ...
# 创建非 root 用户(安全最佳实践)
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001 -G nodejs
USER nextjs
EXPOSE 8080
# 启动命令
CMD ["bun", "run", "src/index.ts"]

87
bun.lock Normal file
View File

@@ -0,0 +1,87 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "saas-admin-api",
"dependencies": {
"dotenv": "^16.4.5",
"elysia": "^1.0.0",
"mysql2": "^3.10.0",
},
"devDependencies": {
"@types/node": "^20.14.0",
"bun-types": "^1.1.0",
},
},
},
"packages": {
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "https://registry.npmmirror.com/@borewit/text-codec/-/text-codec-0.1.1.tgz", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "https://registry.npmmirror.com/@sinclair/typebox/-/typebox-0.34.41.tgz", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "https://registry.npmmirror.com/@tokenizer/inflate/-/inflate-0.4.1.tgz", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "https://registry.npmmirror.com/@tokenizer/token/-/token-0.3.0.tgz", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/node": ["@types/node@20.19.25", "https://registry.npmmirror.com/@types/node/-/node-20.19.25.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ=="],
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
"bun-types": ["bun-types@1.3.3", "https://registry.npmmirror.com/bun-types/-/bun-types-1.3.3.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"cookie": ["cookie@1.1.1", "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"denque": ["denque@2.1.0", "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"dotenv": ["dotenv@16.6.1", "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"elysia": ["elysia@1.4.16", "https://registry.npmmirror.com/elysia/-/elysia-1.4.16.tgz", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.3", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-KZtKN160/bdWVKg2hEgyoNXY8jRRquc+m6PboyisaLZL891I+Ufb7Ja6lDAD7vMQur8sLEWIcidZOzj5lWw9UA=="],
"exact-mirror": ["exact-mirror@0.2.3", "https://registry.npmmirror.com/exact-mirror/-/exact-mirror-0.2.3.tgz", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-aLdARfO0W0ntufjDyytUJQMbNXoB9g+BbA8KcgIq4XOOTYRw48yUGON/Pr64iDrYNZKcKvKbqE0MPW56FF2BXA=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "https://registry.npmmirror.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"file-type": ["file-type@21.1.1", "https://registry.npmmirror.com/file-type/-/file-type-21.1.1.tgz", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="],
"generate-function": ["generate-function@2.3.1", "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz", { "dependencies": { "is-property": "^1.0.2" } }, "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ=="],
"iconv-lite": ["iconv-lite@0.7.0", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.0.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
"ieee754": ["ieee754@1.2.1", "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"is-property": ["is-property@1.0.2", "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
"long": ["long@5.3.2", "https://registry.npmmirror.com/long/-/long-5.3.2.tgz", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
"lru-cache": ["lru-cache@7.18.3", "https://registry.npmmirror.com/lru-cache/-/lru-cache-7.18.3.tgz", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"lru.min": ["lru.min@1.1.3", "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.3.tgz", {}, "sha512-Lkk/vx6ak3rYkRR0Nhu4lFUT2VDnQSxBe8Hbl7f36358p6ow8Bnvr8lrLt98H8J1aGxfhbX4Fs5tYg2+FTwr5Q=="],
"memoirist": ["memoirist@0.4.0", "https://registry.npmmirror.com/memoirist/-/memoirist-0.4.0.tgz", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mysql2": ["mysql2@3.15.3", "https://registry.npmmirror.com/mysql2/-/mysql2-3.15.3.tgz", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="],
"named-placeholders": ["named-placeholders@1.1.3", "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.3.tgz", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="],
"openapi-types": ["openapi-types@12.1.3", "https://registry.npmmirror.com/openapi-types/-/openapi-types-12.1.3.tgz", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"seq-queue": ["seq-queue@0.0.5", "https://registry.npmmirror.com/seq-queue/-/seq-queue-0.0.5.tgz", {}, "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="],
"sqlstring": ["sqlstring@2.3.3", "https://registry.npmmirror.com/sqlstring/-/sqlstring-2.3.3.tgz", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
"strtok3": ["strtok3@10.3.4", "https://registry.npmmirror.com/strtok3/-/strtok3-10.3.4.tgz", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"token-types": ["token-types@6.1.1", "https://registry.npmmirror.com/token-types/-/token-types-6.1.1.tgz", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "https://registry.npmmirror.com/uint8array-extras/-/uint8array-extras-1.5.0.tgz", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
}
}

45
docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
# docker-compose.yml
version: '3.8'
services:
# mysql:
# image: mysql:8.0
# container_name: saas-mysql
# restart: always
# environment:
# MYSQL_ROOT_PASSWORD: root_secure_password
# MYSQL_DATABASE: lucky_sass
# MYSQL_USER: saas_admin
# MYSQL_PASSWORD: saas_secure_password
# ports:
# - "3306:3306"
# volumes:
# - ./init_v2.0.sql:/docker-entrypoint-initdb.d/init.sql:ro
# - mysql_data:/var/lib/mysql
# command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
saas-admin-api:
build: .
container_name: saas-admin-api
restart: always
ports:
- "8080:8080"
environment:
DB_HOST: mysql
DB_PORT: 3306
DB_USER: saas_admin
DB_PASSWORD: saas_secure_password
DB_NAME: lucky_sass
ADMIN_API_KEY: sk-admin-xxxxxxxxxxxxxxxx
PORT: 8080
depends_on:
# - mysql
# 等待 MySQL 就绪(可选:使用 wait-for-it.sh 更健壮)
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mysql_data:

27
package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "saas-admin-api",
"version": "1.0.0",
"description": "SaaS 平台数据查询 API供 Dify Agent 调用,支持自然语言转结构化查询",
"main": "src/index.ts",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"build": "echo \"Bun runs TypeScript directly — no build needed.\"",
"test": "echo \"Add tests with @types/bun or Jest later\""
},
"dependencies": {
"elysia": "^1.0.0",
"mysql2": "^3.10.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/node": "^20.14.0",
"bun-types": "^1.1.0"
},
"engines": {
"bun": ">=1.0.0"
},
"keywords": ["bun", "elysia", "mysql", "saas", "dify", "natural-language-query"],
"author": "Your Name",
"license": "MIT"
}

View File

@@ -0,0 +1,102 @@
{
"info": {
"name": "SaaS Admin API - query_platform_data",
"_postman_id": "unique-id-here",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
"item": [
{
"name": "商家 GMV昨天",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer sk-admin-xxxxxxxxxxxxxxxx" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"question\": \"商家 TechStore 昨天的 GMV 是多少?\"\n}"
},
"url": "{{base_url}}/admin/query"
}
},
{
"name": "订单状态",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer sk-admin-xxxxxxxxxxxxxxxx" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"question\": \"订单 L20251127001 的状态是什么?\"\n}"
},
"url": "{{base_url}}/admin/query"
}
},
{
"name": "用户信息",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer sk-admin-xxxxxxxxxxxxxxxx" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"question\": \"用户 12345 的信息?\"\n}"
},
"url": "{{base_url}}/admin/query"
}
},
{
"name": "商品销量",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer sk-admin-xxxxxxxxxxxxxxxx" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"question\": \"商品 iPhone 16 卖了多少件?\"\n}"
},
"url": "{{base_url}}/admin/query"
}
},
{
"name": "未发货订单",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer sk-admin-xxxxxxxxxxxxxxxx" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"question\": \"列出所有未发货订单\"\n}"
},
"url": "{{base_url}}/admin/query"
}
},
{
"name": "系统公告",
"request": {
"method": "POST",
"header": [
{ "key": "Authorization", "value": "Bearer sk-admin-xxxxxxxxxxxxxxxx" },
{ "key": "Content-Type", "value": "application/json" }
],
"body": {
"mode": "raw",
"raw": "{\n \"question\": \"最近的系统公告有哪些?\"\n}"
},
"url": "{{base_url}}/admin/query"
}
}
],
"variable": [
{ "key": "base_url", "value": "http://localhost:8080" }
]
}

22
src/db.ts Normal file
View File

@@ -0,0 +1,22 @@
// src/db.ts
import mysql from 'mysql2/promise';
import { config } from 'dotenv';
config();
// 创建连接池(推荐用于生产)
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'saas_db',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
timezone: '+08:00', // 根据你的时区调整
});
console.log('✅ Connected to MySQL');
export default pool;

12
src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
// src/index.ts
import { Elysia } from 'elysia';
import adminRoutes from './routes/admin';
import { config } from 'dotenv';
config();
const app = new Elysia()
.use(adminRoutes)
.get('/', () => 'SaaS Admin API - Ready for Dify Agent')
.listen(process.env.PORT || 8080);
console.log(`🦊 Elysia running on http://localhost:${app.server?.port}`);

273
src/queryEngine.ts Normal file
View File

@@ -0,0 +1,273 @@
// src/queryEngine.ts
import pool from './db';
type QueryContext = {
current_tenant_id?: string;
operator_role?: string;
};
export async function executeNaturalLanguageQuery(
question: string,
context: QueryContext = {}
) {
const q = question.toLowerCase().trim();
// 工具函数:安全转义 LIKE
const escapeLike = (str: string) => str.replace(/[%_]/g, '\\$&');
// 场景 1: 商家 GMV支持时间范围
if ((q.includes('gmv') || q.includes('销售额')) && q.includes('商家')) {
const nameMatch = q.match(/商家\s*["']?([a-zA-Z0-9\u4e00-\u9fa5]+)["']?/);
const merchantName = nameMatch ? nameMatch[1] : null;
if (!merchantName) throw new Error('请指定商家名称');
let timeFilter = '';
let params: any[] = [`%${escapeLike(merchantName)}%`];
if (q.includes('昨天')) {
timeFilter = 'AND DATE(FROM_UNIXTIME(o.create_time)) = CURDATE() - INTERVAL 1 DAY';
} else if (q.includes('上周')) {
timeFilter = 'AND YEARWEEK(FROM_UNIXTIME(o.create_time), 1) = YEARWEEK(CURDATE() - INTERVAL 1 WEEK, 1)';
} else if (q.includes('上月')) {
timeFilter = 'AND DATE_FORMAT(FROM_UNIXTIME(o.create_time), "%Y-%m") = DATE_FORMAT(CURDATE() - INTERVAL 1 MONTH, "%Y-%m")';
}
const sql = `
SELECT
m.merch_name AS merchant,
COALESCE(SUM(o.order_money), 0) AS gmv,
COUNT(o.order_id) AS order_count
FROM lucky_merch m
LEFT JOIN lucky_order o ON m.merch_id = o.merch_id
WHERE m.merch_name LIKE ? ${timeFilter}
GROUP BY m.merch_id, m.merch_name
`;
const [rows] = await pool.execute(sql, params);
return (rows as any[])[0] || null;
}
// 场景 2: 订单状态
if (q.includes('订单') && (q.includes('状态') || q.includes('详情'))) {
const idMatch = q.match(/订单\s*["']?([a-zA-Z0-9]+)["']?/);
const orderId = idMatch ? idMatch[1] : null;
if (!orderId) throw new Error('请提供订单编号或ID');
const sql = `
SELECT
order_no,
order_status_name AS status,
pay_status,
delivery_status,
order_money,
name AS buyer_name,
mobile,
create_time
FROM lucky_order
WHERE order_no = ? OR order_id = ?
`;
const [rows] = await pool.execute(sql, [orderId, isNaN(Number(orderId)) ? null : Number(orderId)]);
return (rows as any[])[0] || null;
}
// 场景 3: 用户信息
if (q.includes('用户') && (q.includes('信息') || q.includes('详情') || q.includes('资料'))) {
const idMatch = q.match(/用户\s*["']?(\d+)["']?/);
const userId = idMatch && idMatch[1] !== undefined ? parseInt(idMatch[1]) : null;
if (!userId) throw new Error('请提供用户ID');
const sql = `
SELECT member_id, nickname, mobile, email
FROM lucky_member
WHERE member_id = ?
`;
const [rows] = await pool.execute(sql, [userId]);
return (rows as any[])[0] || null;
}
// 场景 4: 商品销量
if (q.includes('商品') && q.includes('销量')) {
const nameMatch = q.match(/商品\s*["']?([a-zA-Z0-9\u4e00-\u9fa5\s]+)["']?/);
const goodsName = nameMatch && nameMatch[1] ? nameMatch[1].trim() : null;
if (!goodsName) throw new Error('请指定商品名称');
const sql = `
SELECT
g.goods_name,
SUM(og.num) AS total_sold
FROM lucky_goods g
JOIN lucky_order_goods og ON g.goods_id = og.goods_id
WHERE g.goods_name LIKE ?
GROUP BY g.goods_id, g.goods_name
`;
const [rows] = await pool.execute(sql, [`%${escapeLike(goodsName)}%`]);
return Array.isArray(rows) && rows.length > 0 ? rows[0] : null;
}
// 场景 5: 退款率(按商家)
if (q.includes('退款率') && q.includes('商家')) {
const nameMatch = q.match(/商家\s*["']?([a-zA-Z0-9\u4e00-\u9fa5]+)["']?/);
const merchantName = nameMatch ? nameMatch[1] : null;
if (!merchantName) throw new Error('请指定商家名称');
const sql = `
SELECT
m.merch_name,
COUNT(o.order_id) AS total_orders,
SUM(CASE WHEN o.refund_status > 0 THEN 1 ELSE 0 END) AS refunded_orders,
ROUND(
SUM(CASE WHEN o.refund_status > 0 THEN 1 ELSE 0 END) * 100.0 / COUNT(o.order_id),
2
) AS refund_rate_percent
FROM lucky_merch m
JOIN lucky_order o ON m.merch_id = o.merch_id
WHERE m.merch_name LIKE ?
GROUP BY m.merch_id, m.merch_name
`;
const [rows] = await pool.execute(sql, [`%${escapeLike(merchantName)}%`]);
return (rows as any)[0] || null;
}
// 场景 6: 未发货订单
if (q.includes('未发货') && q.includes('订单')) {
const sql = `
SELECT
order_no,
name AS buyer,
order_money,
create_time
FROM lucky_order
WHERE delivery_status = 0 -- 假设 0=未发货
ORDER BY create_time DESC
LIMIT 20
`;
const [rows] = await pool.execute(sql);
return rows;
}
// 场景 7: 正常营业商户
if (q.includes('商户') && (q.includes('列表') || q.includes('所有'))) {
const sql = `
SELECT
merch_id,
merch_name,
status,
balance,
create_time
FROM lucky_merch
WHERE status = 1 -- 1=正常
ORDER BY sort ASC
`;
const [rows] = await pool.execute(sql);
return rows;
}
// 场景 8: 商户提现记录
if (q.includes('提现') && q.includes('商户')) {
const nameMatch = q.match(/商户\s*["']?([a-zA-Z0-9\u4e00-\u9fa5]+)["']?/);
const merchantName = nameMatch ? nameMatch[1] : null;
if (!merchantName) throw new Error('请指定商户名称');
const sql = `
SELECT
withdraw_no,
apply_money,
money AS arrived_amount,
status_name,
apply_time
FROM lucky_merch_withdraw w
JOIN lucky_merch m ON w.merch_id = m.merch_id
WHERE m.merch_name LIKE ?
ORDER BY apply_time DESC
LIMIT 10
`;
const [rows] = await pool.execute(sql, [`%${escapeLike(merchantName)}%`]);
return rows;
}
// 场景 9: 用户充值记录
if (q.includes('充值') && q.includes('用户')) {
const idMatch = q.match(/用户\s*["']?(\d+)["']?/);
const userId = idMatch && idMatch[1] ? parseInt(idMatch[1]) : null;
if (!userId) throw new Error('请提供用户ID');
const sql = `
SELECT
order_no,
price AS amount,
status,
create_time,
pay_time
FROM lucky_recharge_card_order
WHERE member_id = ?
ORDER BY create_time DESC
`;
const [rows] = await pool.execute(sql, [userId]);
return rows;
}
// 场景 10: 邀请奖励
if (q.includes('邀请') && q.includes('奖励')) {
const idMatch = q.match(/用户\s*["']?(\d+)["']?/);
const userId = idMatch && idMatch[1] ? parseInt(idMatch[1]) : null;
if (!userId) throw new Error('请提供用户ID');
const sql = `
SELECT
recommend_name AS activity,
point,
balance,
create_time
FROM lucky_member_recommend_award
WHERE member_id = ?
ORDER BY create_time DESC
`;
const [rows] = await pool.execute(sql, [userId]);
return rows;
}
// 场景 11: 系统公告
if (q.includes('公告') || q.includes('通知')) {
const sql = `
SELECT
title,
content,
create_time,
is_top
FROM lucky_notice
ORDER BY is_top DESC, create_time DESC
LIMIT 5
`;
const [rows] = await pool.execute(sql);
return rows;
}
// 场景 12: 店铺笔记
if (q.includes('笔记') || q.includes('文章')) {
const titleMatch = q.match(/笔记\s*["']?([a-zA-Z0-9\u4e00-\u9fa5\s]+)["']?/);
const noteTitle = titleMatch && titleMatch[1] ? titleMatch[1].trim() : null;
let sql = `
SELECT note_title, note_abstract, release_time, read_num
FROM lucky_notes
WHERE status = 1 -- 已发布
`;
let params: any[] = [];
if (noteTitle) {
sql += ' AND note_title LIKE ?';
params.push(`%${escapeLike(noteTitle)}%`);
}
sql += ' ORDER BY release_time DESC LIMIT 5';
const [rows] = await pool.execute(sql, params);
return rows;
}
// 默认兜底
throw new Error(`
无法理解该问题。支持的查询包括:
- 商家 GMV如“TechStore 昨天的 GMV”
- 订单状态(如“订单 L20251127001 状态”)
- 用户/商品/提现/充值/公告等信息
`.trim());
}

46
src/routes/admin.ts Normal file
View File

@@ -0,0 +1,46 @@
// src/routes/admin.ts
import { Elysia } from 'elysia';
import { verifyApiKey, sanitizeResult } from '../security';
import { executeNaturalLanguageQuery } from '../queryEngine';
const adminRoutes = new Elysia({ prefix: '/admin' })
.use(verifyApiKey)
.post('/query', async ({ body }) => {
if (!body || typeof body !== 'object') {
return new Response(JSON.stringify({ error: "Invalid request body" }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const { question, context } = body as { question: string; context?: object };
if (typeof question !== 'string' || !question.trim()) {
return new Response(JSON.stringify({ error: "Missing or invalid 'question'" }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
try {
const startTime = performance.now();
const result = await executeNaturalLanguageQuery(question, context || {});
const sanitized = sanitizeResult(result);
const endTime = performance.now();
return {
result: sanitized,
metadata: {
executed_query_type: 'natural_language_fallback',
data_source: 'direct_sql',
processing_time_ms: Math.round(endTime - startTime),
},
};
} catch (error: any) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
});
export default adminRoutes;

83
src/security.ts Normal file
View File

@@ -0,0 +1,83 @@
// src/security.ts
import { Elysia, Context } from 'elysia';
// 脱敏规则配置
const MASK_RULES: Record<string, (value: string) => string> = {
// 手机号138****1234
mobile: (v) => v.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
phone: (v) => v.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2'),
// 邮箱ab***@example.com
email: (v) => v.replace(/(.{2}).+(@.*)/, '$1***$2'),
// 身份证110***********1234
id_card: (v) => v.length > 8
? v.substring(0, 3) + '*'.repeat(v.length - 7) + v.substring(v.length - 4)
: v,
// 银行卡6222 **** **** 1234
bank_card: (v) => v.replace(/(\d{4})\d+(\d{4})/, '$1 **** **** $2'),
// 用户名:如果长度>4则中间用*
nickname: (v) => v.length > 4
? v.substring(0, 2) + '*'.repeat(v.length - 4) + v.substring(v.length - 2)
: v,
// 默认规则
default: (v) => v.length > 6
? v.substring(0, 2) + '*'.repeat(v.length - 4) + v.substring(v.length - 2)
: v
};
// 判断是否为敏感字段(支持模糊匹配)
const isSensitiveField = (key: string): boolean => {
const sensitiveKeys = ['mobile', 'phone', 'email', 'id_card', 'bank_card', 'nickname', 'realname', 'address'];
return sensitiveKeys.some(term => key.toLowerCase().includes(term));
};
// 获取脱敏函数
const getMaskFn = (key: string): ((v: string) => string) => {
for (const [term, fn] of Object.entries(MASK_RULES)) {
if (key.toLowerCase().includes(term)) {
return fn;
}
}
return MASK_RULES.default || ((v: string) => v);
};
// 递归脱敏
export const sanitizeResult = (data: any): any => {
if (Array.isArray(data)) {
return data.map(sanitizeResult);
}
if (typeof data === 'object' && data !== null) {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(data)) {
if (value == null) {
result[key] = value;
} else if (isSensitiveField(key)) {
result[key] = typeof value === 'string' ? getMaskFn(key)(value) : value;
} else if (typeof value === 'object') {
result[key] = sanitizeResult(value);
} else {
result[key] = value;
}
}
return result;
}
return data;
};
// API Key 验证(修复:使用 Elysia 插件类型)
export const verifyApiKey = (app: Elysia) => app.onBeforeHandle((ctx) => {
const authHeader = ctx.request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return new Response('Unauthorized', { status: 401 });
}
const token = authHeader.substring(7);
if (token !== process.env.ADMIN_API_KEY) {
return new Response('Forbidden', { status: 403 });
}
});

45
tsconfig.json Normal file
View File

@@ -0,0 +1,45 @@
{
"compilerOptions": {
/* */
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
/* */
"exactOptionalPropertyTypes": false,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"noUncheckedIndexedAccess": true,
/* */
"outDir": "./dist",
"rootDir": "./src",
"sourceMap": true,
/* Bun & ESM */
"lib": ["ES2022", "DOM"],
"types": ["bun-types", "@types/node"],
/* */
// "baseUrl": "./src",
// "paths": {
// "@/*": ["./*"]
// }
},
"include": [
"src/**/*",
"*.ts"
],
"exclude": [
"node_modules",
"dist",
"coverage"
]
}