commit 90eb1970e1267421a3be3786fe63069a352e14f9 Author: ZF sun <34314687@qq.com> Date: Thu Nov 27 18:22:26 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1dcef2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f9d0fc0 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..967bbcb --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..85d0081 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..abfb613 --- /dev/null +++ b/package.json @@ -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" +} \ No newline at end of file diff --git a/saas-admin-api.postman_collection.json b/saas-admin-api.postman_collection.json new file mode 100644 index 0000000..d55355a --- /dev/null +++ b/saas-admin-api.postman_collection.json @@ -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" } + ] +} \ No newline at end of file diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..a52e9a6 --- /dev/null +++ b/src/db.ts @@ -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; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..a069c88 --- /dev/null +++ b/src/index.ts @@ -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}`); \ No newline at end of file diff --git a/src/queryEngine.ts b/src/queryEngine.ts new file mode 100644 index 0000000..a82b6c1 --- /dev/null +++ b/src/queryEngine.ts @@ -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()); +} \ No newline at end of file diff --git a/src/routes/admin.ts b/src/routes/admin.ts new file mode 100644 index 0000000..2933cab --- /dev/null +++ b/src/routes/admin.ts @@ -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; \ No newline at end of file diff --git a/src/security.ts b/src/security.ts new file mode 100644 index 0000000..b7ef03c --- /dev/null +++ b/src/security.ts @@ -0,0 +1,83 @@ +// src/security.ts +import { Elysia, Context } from 'elysia'; + +// 脱敏规则配置 +const MASK_RULES: Record 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 = {}; + 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 }); + } +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e87fafb --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} \ No newline at end of file