From a7add8ff9093480434246d16ee75f340651d4fd6 Mon Sep 17 00:00:00 2001 From: hailin Date: Fri, 9 Jan 2026 00:01:12 -0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20iConsulting=20=E9=A6=99?= =?UTF-8?q?=E6=B8=AF=E7=A7=BB=E6=B0=91=E5=92=A8=E8=AF=A2=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目架构: - Monorepo (pnpm + Turborepo) - 后端: NestJS 微服务 + Claude Agent SDK - 前端: React + Vite + Ant Design 包含服务: - conversation-service: 对话服务 (Claude AI) - user-service: 用户认证服务 - payment-service: 支付服务 (支付宝/微信/Stripe) - knowledge-service: 知识库服务 (RAG + Neo4j) - evolution-service: 自我进化服务 - web-client: 用户前端 - admin-client: 管理后台 基础设施: - PostgreSQL + Redis + Neo4j - Kong API Gateway - Nginx 反向代理 - Docker Compose 部署配置 Co-Authored-By: Claude Opus 4.5 --- .claude/settings.local.json | 13 + .dockerignore | 72 + .env.example | 151 + .gitignore | 97 + DEVELOPMENT_GUIDE.md | 1322 +++ README.md | 176 + deploy.sh | 903 ++ docker-compose.yml | 319 + iconsulting部署架构.jpg | Bin 0 -> 158361 bytes infrastructure/docker/docker-compose.dev.yml | 134 + .../docker/services/postgres/init.sql | 1450 +++ nginx/conf.d/default.conf | 130 + nginx/nginx.conf | 51 + nginx/ssl/.gitkeep | 2 + package.json | 32 + packages/admin-client/index.html | 13 + packages/admin-client/package.json | 40 + packages/admin-client/postcss.config.js | 6 + packages/admin-client/src/App.tsx | 37 + .../auth/presentation/pages/LoginPage.tsx | 82 + .../presentation/pages/DashboardPage.tsx | 298 + .../presentation/pages/ExperiencePage.tsx | 393 + .../presentation/pages/KnowledgePage.tsx | 404 + packages/admin-client/src/index.css | 13 + packages/admin-client/src/main.tsx | 36 + .../src/shared/components/MainLayout.tsx | 128 + .../src/shared/components/ProtectedRoute.tsx | 36 + .../admin-client/src/shared/hooks/useAuth.ts | 105 + packages/admin-client/src/shared/utils/api.ts | 37 + packages/admin-client/src/vite-env.d.ts | 1 + packages/admin-client/tailwind.config.js | 14 + packages/admin-client/tsconfig.json | 25 + packages/admin-client/tsconfig.node.json | 10 + packages/admin-client/vite.config.ts | 19 + .../services/conversation-service/Dockerfile | 45 + .../conversation-service/nest-cli.json | 8 + .../conversation-service/package.json | 48 + .../conversation-service/src/app.module.ts | 37 + .../conversation/conversation.controller.ts | 142 + .../src/conversation/conversation.gateway.ts | 149 + .../src/conversation/conversation.module.ts | 15 + .../src/conversation/conversation.service.ts | 198 + .../domain/entities/conversation.entity.ts | 55 + .../src/domain/entities/message.entity.ts | 59 + .../claude/claude-agent.service.ts | 231 + .../infrastructure/claude/claude.module.ts | 12 + .../claude/prompts/system-prompt.ts | 94 + .../claude/tools/immigration-tools.service.ts | 331 + .../services/conversation-service/src/main.ts | 34 + .../conversation-service/tsconfig.json | 18 + .../services/evolution-service/Dockerfile | 45 + .../services/evolution-service/nest-cli.json | 8 + .../services/evolution-service/package.json | 48 + .../src/admin/admin.controller.ts | 298 + .../src/admin/admin.module.ts | 15 + .../src/admin/admin.service.ts | 344 + .../evolution-service/src/app.module.ts | 37 + .../src/evolution/evolution.controller.ts | 62 + .../src/evolution/evolution.module.ts | 25 + .../src/evolution/evolution.service.ts | 321 + .../claude/experience-extractor.service.ts | 292 + .../database/entities/admin.orm.ts | 52 + .../database/entities/conversation.orm.ts | 61 + .../database/entities/message.orm.ts | 33 + .../entities/system-experience.orm.ts | 61 + .../services/evolution-service/src/main.ts | 40 + .../services/evolution-service/tsconfig.json | 17 + .../services/knowledge-service/Dockerfile | 45 + .../services/knowledge-service/nest-cli.json | 8 + .../services/knowledge-service/package.json | 47 + .../knowledge-service/src/app.module.ts | 37 + .../application/services/chunking.service.ts | 311 + .../src/application/services/rag.service.ts | 279 + .../entities/knowledge-article.entity.ts | 193 + .../domain/entities/knowledge-chunk.entity.ts | 97 + .../entities/system-experience.entity.ts | 209 + .../src/domain/entities/user-memory.entity.ts | 126 + .../knowledge.repository.interface.ts | 72 + .../memory.repository.interface.ts | 95 + .../database/neo4j/neo4j.service.ts | 422 + .../entities/knowledge-article.orm.ts | 67 + .../postgres/entities/knowledge-chunk.orm.ts | 38 + .../entities/system-experience.orm.ts | 65 + .../postgres/entities/user-memory.orm.ts | 52 + .../postgres/knowledge-postgres.repository.ts | 321 + .../postgres/memory-postgres.repository.ts | 426 + .../embedding/embedding.service.ts | 153 + .../src/knowledge/knowledge.controller.ts | 267 + .../src/knowledge/knowledge.module.ts | 53 + .../src/knowledge/knowledge.service.ts | 336 + .../services/knowledge-service/src/main.ts | 39 + .../src/memory/memory.controller.ts | 286 + .../src/memory/memory.module.ts | 41 + .../src/memory/memory.service.ts | 323 + .../services/knowledge-service/tsconfig.json | 17 + packages/services/payment-service/Dockerfile | 45 + .../services/payment-service/nest-cli.json | 8 + .../services/payment-service/package.json | 39 + .../payment-service/src/app.module.ts | 33 + .../src/domain/entities/order.entity.ts | 84 + .../src/domain/entities/payment.entity.ts | 84 + packages/services/payment-service/src/main.ts | 31 + .../src/order/order.controller.ts | 100 + .../payment-service/src/order/order.module.ts | 13 + .../src/order/order.service.ts | 119 + .../src/payment/adapters/alipay.adapter.ts | 87 + .../src/payment/adapters/stripe.adapter.ts | 88 + .../payment/adapters/wechat-pay.adapter.ts | 80 + .../src/payment/payment.controller.ts | 109 + .../src/payment/payment.module.ts | 25 + .../src/payment/payment.service.ts | 197 + .../services/payment-service/tsconfig.json | 18 + packages/services/user-service/Dockerfile | 64 + packages/services/user-service/package.json | 45 + .../services/user-service/src/app.module.ts | 46 + .../user-service/src/auth/auth.controller.ts | 107 + .../user-service/src/auth/auth.module.ts | 17 + .../user-service/src/auth/auth.service.ts | 176 + .../src/domain/entities/user.entity.ts | 46 + .../entities/verification-code.entity.ts | 27 + packages/services/user-service/src/main.ts | 31 + .../user-service/src/user/user.controller.ts | 68 + .../user-service/src/user/user.module.ts | 13 + .../user-service/src/user/user.service.ts | 98 + packages/services/user-service/tsconfig.json | 18 + packages/shared/package.json | 35 + .../shared/src/constants/api.constants.ts | 93 + .../src/constants/error-codes.constants.ts | 125 + .../src/constants/immigration.constants.ts | 80 + packages/shared/src/constants/index.ts | 8 + packages/shared/src/index.ts | 8 + packages/shared/src/types/common.types.ts | 50 + .../shared/src/types/conversation.types.ts | 121 + .../shared/src/types/immigration.types.ts | 203 + packages/shared/src/types/index.ts | 17 + packages/shared/src/types/knowledge.types.ts | 215 + packages/shared/src/types/payment.types.ts | 116 + packages/shared/src/types/user.types.ts | 79 + packages/shared/src/utils/format.utils.ts | 150 + packages/shared/src/utils/index.ts | 3 + packages/shared/src/utils/validation.utils.ts | 85 + packages/shared/tsconfig.json | 9 + packages/web-client/index.html | 14 + packages/web-client/package.json | 40 + packages/web-client/postcss.config.js | 6 + packages/web-client/src/app/App.tsx | 20 + .../presentation/components/ChatSidebar.tsx | 137 + .../presentation/components/ChatWindow.tsx | 126 + .../presentation/components/InputArea.tsx | 72 + .../presentation/components/MessageBubble.tsx | 102 + .../components/TypingIndicator.tsx | 21 + .../chat/presentation/hooks/useChat.ts | 162 + .../chat/presentation/pages/ChatPage.tsx | 49 + .../chat/presentation/stores/chatStore.ts | 109 + packages/web-client/src/main.tsx | 22 + .../src/shared/components/Toaster.tsx | 105 + .../src/shared/hooks/useAnonymousAuth.ts | 112 + packages/web-client/src/styles/globals.css | 96 + packages/web-client/tailwind.config.js | 47 + packages/web-client/tsconfig.json | 25 + packages/web-client/tsconfig.node.json | 11 + packages/web-client/vite.config.ts | 25 + pnpm-lock.yaml | 9864 +++++++++++++++++ pnpm-workspace.yaml | 3 + scripts/init-db.sql | 271 + scripts/setup-kong.sh | 138 + scripts/setup-network.sh | 125 + tsconfig.base.json | 30 + turbo.json | 27 + 香港移民类别.jpg | Bin 0 -> 244258 bytes 170 files changed, 29281 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEVELOPMENT_GUIDE.md create mode 100644 README.md create mode 100644 deploy.sh create mode 100644 docker-compose.yml create mode 100644 iconsulting部署架构.jpg create mode 100644 infrastructure/docker/docker-compose.dev.yml create mode 100644 infrastructure/docker/services/postgres/init.sql create mode 100644 nginx/conf.d/default.conf create mode 100644 nginx/nginx.conf create mode 100644 nginx/ssl/.gitkeep create mode 100644 package.json create mode 100644 packages/admin-client/index.html create mode 100644 packages/admin-client/package.json create mode 100644 packages/admin-client/postcss.config.js create mode 100644 packages/admin-client/src/App.tsx create mode 100644 packages/admin-client/src/features/auth/presentation/pages/LoginPage.tsx create mode 100644 packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx create mode 100644 packages/admin-client/src/features/experience/presentation/pages/ExperiencePage.tsx create mode 100644 packages/admin-client/src/features/knowledge/presentation/pages/KnowledgePage.tsx create mode 100644 packages/admin-client/src/index.css create mode 100644 packages/admin-client/src/main.tsx create mode 100644 packages/admin-client/src/shared/components/MainLayout.tsx create mode 100644 packages/admin-client/src/shared/components/ProtectedRoute.tsx create mode 100644 packages/admin-client/src/shared/hooks/useAuth.ts create mode 100644 packages/admin-client/src/shared/utils/api.ts create mode 100644 packages/admin-client/src/vite-env.d.ts create mode 100644 packages/admin-client/tailwind.config.js create mode 100644 packages/admin-client/tsconfig.json create mode 100644 packages/admin-client/tsconfig.node.json create mode 100644 packages/admin-client/vite.config.ts create mode 100644 packages/services/conversation-service/Dockerfile create mode 100644 packages/services/conversation-service/nest-cli.json create mode 100644 packages/services/conversation-service/package.json create mode 100644 packages/services/conversation-service/src/app.module.ts create mode 100644 packages/services/conversation-service/src/conversation/conversation.controller.ts create mode 100644 packages/services/conversation-service/src/conversation/conversation.gateway.ts create mode 100644 packages/services/conversation-service/src/conversation/conversation.module.ts create mode 100644 packages/services/conversation-service/src/conversation/conversation.service.ts create mode 100644 packages/services/conversation-service/src/domain/entities/conversation.entity.ts create mode 100644 packages/services/conversation-service/src/domain/entities/message.entity.ts create mode 100644 packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts create mode 100644 packages/services/conversation-service/src/infrastructure/claude/claude.module.ts create mode 100644 packages/services/conversation-service/src/infrastructure/claude/prompts/system-prompt.ts create mode 100644 packages/services/conversation-service/src/infrastructure/claude/tools/immigration-tools.service.ts create mode 100644 packages/services/conversation-service/src/main.ts create mode 100644 packages/services/conversation-service/tsconfig.json create mode 100644 packages/services/evolution-service/Dockerfile create mode 100644 packages/services/evolution-service/nest-cli.json create mode 100644 packages/services/evolution-service/package.json create mode 100644 packages/services/evolution-service/src/admin/admin.controller.ts create mode 100644 packages/services/evolution-service/src/admin/admin.module.ts create mode 100644 packages/services/evolution-service/src/admin/admin.service.ts create mode 100644 packages/services/evolution-service/src/app.module.ts create mode 100644 packages/services/evolution-service/src/evolution/evolution.controller.ts create mode 100644 packages/services/evolution-service/src/evolution/evolution.module.ts create mode 100644 packages/services/evolution-service/src/evolution/evolution.service.ts create mode 100644 packages/services/evolution-service/src/infrastructure/claude/experience-extractor.service.ts create mode 100644 packages/services/evolution-service/src/infrastructure/database/entities/admin.orm.ts create mode 100644 packages/services/evolution-service/src/infrastructure/database/entities/conversation.orm.ts create mode 100644 packages/services/evolution-service/src/infrastructure/database/entities/message.orm.ts create mode 100644 packages/services/evolution-service/src/infrastructure/database/entities/system-experience.orm.ts create mode 100644 packages/services/evolution-service/src/main.ts create mode 100644 packages/services/evolution-service/tsconfig.json create mode 100644 packages/services/knowledge-service/Dockerfile create mode 100644 packages/services/knowledge-service/nest-cli.json create mode 100644 packages/services/knowledge-service/package.json create mode 100644 packages/services/knowledge-service/src/app.module.ts create mode 100644 packages/services/knowledge-service/src/application/services/chunking.service.ts create mode 100644 packages/services/knowledge-service/src/application/services/rag.service.ts create mode 100644 packages/services/knowledge-service/src/domain/entities/knowledge-article.entity.ts create mode 100644 packages/services/knowledge-service/src/domain/entities/knowledge-chunk.entity.ts create mode 100644 packages/services/knowledge-service/src/domain/entities/system-experience.entity.ts create mode 100644 packages/services/knowledge-service/src/domain/entities/user-memory.entity.ts create mode 100644 packages/services/knowledge-service/src/domain/repositories/knowledge.repository.interface.ts create mode 100644 packages/services/knowledge-service/src/domain/repositories/memory.repository.interface.ts create mode 100644 packages/services/knowledge-service/src/infrastructure/database/neo4j/neo4j.service.ts create mode 100644 packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-article.orm.ts create mode 100644 packages/services/knowledge-service/src/infrastructure/database/postgres/entities/knowledge-chunk.orm.ts create mode 100644 packages/services/knowledge-service/src/infrastructure/database/postgres/entities/system-experience.orm.ts create mode 100644 packages/services/knowledge-service/src/infrastructure/database/postgres/entities/user-memory.orm.ts create mode 100644 packages/services/knowledge-service/src/infrastructure/database/postgres/knowledge-postgres.repository.ts create mode 100644 packages/services/knowledge-service/src/infrastructure/database/postgres/memory-postgres.repository.ts create mode 100644 packages/services/knowledge-service/src/infrastructure/embedding/embedding.service.ts create mode 100644 packages/services/knowledge-service/src/knowledge/knowledge.controller.ts create mode 100644 packages/services/knowledge-service/src/knowledge/knowledge.module.ts create mode 100644 packages/services/knowledge-service/src/knowledge/knowledge.service.ts create mode 100644 packages/services/knowledge-service/src/main.ts create mode 100644 packages/services/knowledge-service/src/memory/memory.controller.ts create mode 100644 packages/services/knowledge-service/src/memory/memory.module.ts create mode 100644 packages/services/knowledge-service/src/memory/memory.service.ts create mode 100644 packages/services/knowledge-service/tsconfig.json create mode 100644 packages/services/payment-service/Dockerfile create mode 100644 packages/services/payment-service/nest-cli.json create mode 100644 packages/services/payment-service/package.json create mode 100644 packages/services/payment-service/src/app.module.ts create mode 100644 packages/services/payment-service/src/domain/entities/order.entity.ts create mode 100644 packages/services/payment-service/src/domain/entities/payment.entity.ts create mode 100644 packages/services/payment-service/src/main.ts create mode 100644 packages/services/payment-service/src/order/order.controller.ts create mode 100644 packages/services/payment-service/src/order/order.module.ts create mode 100644 packages/services/payment-service/src/order/order.service.ts create mode 100644 packages/services/payment-service/src/payment/adapters/alipay.adapter.ts create mode 100644 packages/services/payment-service/src/payment/adapters/stripe.adapter.ts create mode 100644 packages/services/payment-service/src/payment/adapters/wechat-pay.adapter.ts create mode 100644 packages/services/payment-service/src/payment/payment.controller.ts create mode 100644 packages/services/payment-service/src/payment/payment.module.ts create mode 100644 packages/services/payment-service/src/payment/payment.service.ts create mode 100644 packages/services/payment-service/tsconfig.json create mode 100644 packages/services/user-service/Dockerfile create mode 100644 packages/services/user-service/package.json create mode 100644 packages/services/user-service/src/app.module.ts create mode 100644 packages/services/user-service/src/auth/auth.controller.ts create mode 100644 packages/services/user-service/src/auth/auth.module.ts create mode 100644 packages/services/user-service/src/auth/auth.service.ts create mode 100644 packages/services/user-service/src/domain/entities/user.entity.ts create mode 100644 packages/services/user-service/src/domain/entities/verification-code.entity.ts create mode 100644 packages/services/user-service/src/main.ts create mode 100644 packages/services/user-service/src/user/user.controller.ts create mode 100644 packages/services/user-service/src/user/user.module.ts create mode 100644 packages/services/user-service/src/user/user.service.ts create mode 100644 packages/services/user-service/tsconfig.json create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/constants/api.constants.ts create mode 100644 packages/shared/src/constants/error-codes.constants.ts create mode 100644 packages/shared/src/constants/immigration.constants.ts create mode 100644 packages/shared/src/constants/index.ts create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/types/common.types.ts create mode 100644 packages/shared/src/types/conversation.types.ts create mode 100644 packages/shared/src/types/immigration.types.ts create mode 100644 packages/shared/src/types/index.ts create mode 100644 packages/shared/src/types/knowledge.types.ts create mode 100644 packages/shared/src/types/payment.types.ts create mode 100644 packages/shared/src/types/user.types.ts create mode 100644 packages/shared/src/utils/format.utils.ts create mode 100644 packages/shared/src/utils/index.ts create mode 100644 packages/shared/src/utils/validation.utils.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/web-client/index.html create mode 100644 packages/web-client/package.json create mode 100644 packages/web-client/postcss.config.js create mode 100644 packages/web-client/src/app/App.tsx create mode 100644 packages/web-client/src/features/chat/presentation/components/ChatSidebar.tsx create mode 100644 packages/web-client/src/features/chat/presentation/components/ChatWindow.tsx create mode 100644 packages/web-client/src/features/chat/presentation/components/InputArea.tsx create mode 100644 packages/web-client/src/features/chat/presentation/components/MessageBubble.tsx create mode 100644 packages/web-client/src/features/chat/presentation/components/TypingIndicator.tsx create mode 100644 packages/web-client/src/features/chat/presentation/hooks/useChat.ts create mode 100644 packages/web-client/src/features/chat/presentation/pages/ChatPage.tsx create mode 100644 packages/web-client/src/features/chat/presentation/stores/chatStore.ts create mode 100644 packages/web-client/src/main.tsx create mode 100644 packages/web-client/src/shared/components/Toaster.tsx create mode 100644 packages/web-client/src/shared/hooks/useAnonymousAuth.ts create mode 100644 packages/web-client/src/styles/globals.css create mode 100644 packages/web-client/tailwind.config.js create mode 100644 packages/web-client/tsconfig.json create mode 100644 packages/web-client/tsconfig.node.json create mode 100644 packages/web-client/vite.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/init-db.sql create mode 100644 scripts/setup-kong.sh create mode 100644 scripts/setup-network.sh create mode 100644 tsconfig.base.json create mode 100644 turbo.json create mode 100644 香港移民类别.jpg diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..13d5074 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(pnpm install:*)", + "Bash(npm install:*)", + "Bash(npx tsc:*)", + "Bash(pnpm add:*)", + "Bash(git init:*)", + "Bash(git checkout:*)", + "Bash(git add:*)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6ef0c67 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,72 @@ +# =========================================== +# Docker 忽略文件 +# =========================================== + +# 依赖 +node_modules +**/node_modules + +# 构建产物 +dist +**/dist +.next +.nuxt + +# 日志 +logs +*.log +npm-debug.log* +pnpm-debug.log* + +# 环境配置 +.env +.env.* +!.env.example + +# IDE +.idea +.vscode +*.swp +*.swo + +# 系统文件 +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# 测试 +coverage +.nyc_output +test +tests +**/*.test.ts +**/*.spec.ts + +# 文档 +docs +*.md +!README.md + +# 临时文件 +tmp +temp +.tmp +.temp + +# 备份 +backups +*.bak + +# 证书 (敏感) +*.pem +*.key +*.crt +nginx/ssl/* + +# 其他 +.turbo +.cache +.parcel-cache diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..006850c --- /dev/null +++ b/.env.example @@ -0,0 +1,151 @@ +# =========================================== +# iConsulting - Environment Configuration +# =========================================== +# 使用方法: 复制此文件为 .env 并填入实际值 +# cp .env.example .env +# =========================================== + +# Application +NODE_ENV=production +APP_NAME=iConsulting + +# =========================================== +# 服务器网络配置 +# =========================================== +# 对外服务 IP (用户访问) +SERVER_PUBLIC_IP=14.215.128.96 +# Claude API 出口 IP +CLAUDE_API_OUTBOUND_IP=154.84.135.121 +# Claude API 服务器 +CLAUDE_API_SERVER=67.223.119.33 + +# =========================================== +# Anthropic Claude API +# =========================================== +ANTHROPIC_API_KEY=sk-ant-api03-xxx +ANTHROPIC_BASE_URL=https://api.anthropic.com + +# =========================================== +# OpenAI API (用于 Embedding) +# =========================================== +OPENAI_API_KEY=sk-xxx + +# =========================================== +# PostgreSQL Database +# =========================================== +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=your_secure_password +POSTGRES_DB=iconsulting +DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + +# =========================================== +# Neo4j Graph Database +# =========================================== +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=your_secure_password + +# =========================================== +# Redis Cache +# =========================================== +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=your_redis_password +REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT} + +# =========================================== +# Payment - Alipay +# =========================================== +ALIPAY_APP_ID=your_app_id +ALIPAY_PRIVATE_KEY=your_private_key +ALIPAY_PUBLIC_KEY=alipay_public_key +ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do +ALIPAY_NOTIFY_URL=https://your-domain.com/api/v1/payments/alipay/notify + +# =========================================== +# Payment - WeChat Pay +# =========================================== +WECHAT_APP_ID=your_app_id +WECHAT_MCH_ID=your_merchant_id +WECHAT_API_KEY=your_api_key +WECHAT_CERT_PATH=/path/to/wechat/cert +WECHAT_NOTIFY_URL=https://your-domain.com/api/v1/payments/wechat/notify + +# =========================================== +# Payment - Stripe (Credit Card) +# =========================================== +STRIPE_SECRET_KEY=sk_test_xxx +STRIPE_PUBLISHABLE_KEY=pk_test_xxx +STRIPE_WEBHOOK_SECRET=whsec_xxx + +# =========================================== +# Payment Callback URLs +# =========================================== +PAYMENT_CALLBACK_BASE_URL=https://your-domain.com +PAYMENT_SUCCESS_REDIRECT_URL=https://your-domain.com/payment/success +PAYMENT_CANCEL_REDIRECT_URL=https://your-domain.com/payment/cancel + +# =========================================== +# JWT Authentication +# =========================================== +JWT_SECRET=your_super_secret_jwt_key_change_in_production +JWT_EXPIRES_IN=7d +JWT_REFRESH_EXPIRES_IN=30d + +# =========================================== +# SMS Service (for phone verification) +# =========================================== +SMS_PROVIDER=aliyun +ALIYUN_SMS_ACCESS_KEY_ID=your_access_key_id +ALIYUN_SMS_ACCESS_KEY_SECRET=your_access_key_secret +ALIYUN_SMS_SIGN_NAME=iConsulting +ALIYUN_SMS_TEMPLATE_CODE=SMS_xxx + +# =========================================== +# Service Ports +# =========================================== +USER_SERVICE_PORT=3001 +PAYMENT_SERVICE_PORT=3002 +KNOWLEDGE_SERVICE_PORT=3003 +CONVERSATION_SERVICE_PORT=3004 +EVOLUTION_SERVICE_PORT=3005 + +# =========================================== +# 服务间通信 URL +# =========================================== +USER_SERVICE_URL=http://localhost:3001 +PAYMENT_SERVICE_URL=http://localhost:3002 +KNOWLEDGE_SERVICE_URL=http://localhost:3003 +CONVERSATION_SERVICE_URL=http://localhost:3004 +EVOLUTION_SERVICE_URL=http://localhost:3005 + +# =========================================== +# Kong API Gateway +# =========================================== +KONG_PROXY_PORT=8000 +KONG_ADMIN_PORT=8001 + +# =========================================== +# Frontend URLs +# =========================================== +WEB_CLIENT_URL=http://localhost +ADMIN_CLIENT_URL=http://localhost/admin + +# =========================================== +# CORS +# =========================================== +CORS_ORIGINS=http://localhost,http://14.215.128.96 + +# =========================================== +# Rate Limiting +# =========================================== +RATE_LIMIT_TTL=60 +RATE_LIMIT_MAX=100 + +# =========================================== +# Logging +# =========================================== +LOG_LEVEL=info +LOG_FORMAT=json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99c4da2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,97 @@ +# =========================================== +# iConsulting Git 忽略配置 +# =========================================== + +# 依赖目录 +node_modules/ +**/node_modules/ +.pnpm-store/ + +# 构建产物 +dist/ +**/dist/ +build/ +.next/ +.nuxt/ +.output/ +out/ + +# 环境变量 (包含敏感信息) +.env +.env.local +.env.*.local +!.env.example + +# 日志文件 +logs/ +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* + +# 运行时数据 +pids/ +*.pid +*.seed +*.pid.lock + +# IDE 和编辑器 +.idea/ +.vscode/ +*.swp +*.swo +*.sublime-workspace +*.sublime-project + +# 系统文件 +.DS_Store +Thumbs.db +desktop.ini + +# 测试覆盖率 +coverage/ +.nyc_output/ +*.lcov + +# 缓存 +.cache/ +.parcel-cache/ +.turbo/ +.eslintcache +.stylelintcache +*.tsbuildinfo + +# 临时文件 +tmp/ +temp/ +.tmp/ +.temp/ +*.tmp + +# 备份文件 +backups/ +*.bak +*~ + +# SSL 证书 (敏感) +*.pem +*.key +*.crt +*.p12 +nginx/ssl/* +!nginx/ssl/.gitkeep + +# 数据库文件 +*.sqlite +*.db + +# Docker +docker-compose.override.yml + +# PM2 +.pm2/ + +# 打包文件 +*.tar.gz +*.zip diff --git a/DEVELOPMENT_GUIDE.md b/DEVELOPMENT_GUIDE.md new file mode 100644 index 0000000..e060070 --- /dev/null +++ b/DEVELOPMENT_GUIDE.md @@ -0,0 +1,1322 @@ +# iConsulting - 香港移民在线咨询系统开发指导 + +## 项目概述 + +iConsulting 是一个基于 Claude Agent SDK 的智能在线客服系统,专注于提供香港移民咨询服务。系统具备自我进化能力,能够从用户对话中学习经验,并根据管理员意图持续优化服务。 + +### 核心特性 + +- **智能咨询**: 基于 Claude Agent SDK 的自然语言对话 +- **付费评估**: 移民资格评估服务,支持多种支付方式 +- **知识增强**: RAG + 知识图谱,提供准确专业的回答 +- **自我进化**: 从对话中学习,根据管理员指令调整 +- **长期记忆**: 基于时间线的知识图谱记录 + +--- + +## 技术架构 + +### 整体架构图 + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 客户端层 (Client Layer) │ +├─────────────────────────────────────────────────────────────────────┤ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ PC Web │ │ H5 Mobile │ │ Admin Dashboard │ │ +│ │ (React+TS) │ │ (React+TS) │ │ (React+TS) │ │ +│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ API 网关层 (API Gateway) │ +├─────────────────────────────────────────────────────────────────────┤ +│ - 路由分发 - 认证授权 - 限流熔断 - 日志追踪 │ +│ - WebSocket 代理 - 请求聚合 │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 微服务层 (Microservices) │ +├─────────────────────────────────────────────────────────────────────┤ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ User │ │ Conversation│ │ Knowledge │ │ Payment │ │ +│ │ Service │ │ Service │ │ Service │ │ Service │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ - 匿名用户 │ │ - Claude │ │ - RAG引擎 │ │ - 支付宝 │ │ +│ │ - 手机验证 │ │ Agent │ │ - 向量检索 │ │ - 微信支付 │ │ +│ │ - 会话管理 │ │ - 对话管理 │ │ - 知识图谱 │ │ - 信用卡 │ │ +│ │ - 用户画像 │ │ - 流式输出 │ │ - 长期记忆 │ │ - 订单管理 │ │ +│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ ┌────────────┐ ┌────────────┐ │ +│ │ Admin │ │ Evolution │ │ +│ │ Service │ │ Service │ │ +│ │ │ │ │ │ +│ │ - 后台管理 │ │ - 经验提取 │ │ +│ │ - 角色权限 │ │ - 模式识别 │ │ +│ │ - 系统配置 │ │ - 自我调整 │ │ +│ │ - 数据统计 │ │ - 管理员交互│ │ +│ └────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ 基础设施层 (Infrastructure) │ +├─────────────────────────────────────────────────────────────────────┤ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ +│ │ PostgreSQL │ │ Neo4j │ │ Redis │ │ Kafka │ │ +│ │ │ │ │ │ │ │ │ │ +│ │ - 用户数据 │ │ - 知识图谱 │ │ - 会话缓存 │ │ - 事件驱动 │ │ +│ │ - 订单数据 │ │ - 长期记忆 │ │ - 分布式锁 │ │ - Outbox │ │ +│ │ - 配置数据 │ │ - 关系网络 │ │ - 限流计数 │ │ - 消息队列 │ │ +│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │ +│ │ +│ ┌────────────┐ ┌────────────┐ │ +│ │ Anthropic │ │ 向量数据库 │ │ +│ │ Claude API │ │ (pgvector) │ │ +│ └────────────┘ └────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 技术栈详情 + +| 层次 | 技术选型 | 说明 | +|------|---------|------| +| 前端 | React 18 + TypeScript | Clean Architecture | +| 前端UI | TailwindCSS + Radix UI | 响应式设计 | +| 前端状态 | Zustand + React Query | 轻量高效 | +| 后端框架 | NestJS | DDD + Hexagonal Architecture | +| API协议 | REST + WebSocket | 实时对话支持 | +| 主数据库 | PostgreSQL 15 | 结构化数据存储 | +| 向量数据库 | pgvector | RAG 向量检索 | +| 图数据库 | Neo4j | 知识图谱 + 长期记忆 | +| 缓存 | Redis | 会话 + 缓存 | +| 消息队列 | Kafka | 事件驱动 + Outbox模式 | +| AI引擎 | Claude Agent SDK | 对话 + 推理 | + +--- + +## 项目目录结构 + +``` +iconsulting/ +├── docs/ # 项目文档 +│ ├── architecture/ # 架构设计文档 +│ ├── api/ # API 文档 +│ └── deployment/ # 部署文档 +│ +├── packages/ # Monorepo 包管理 +│ ├── shared/ # 共享代码 +│ │ ├── types/ # TypeScript 类型定义 +│ │ ├── utils/ # 工具函数 +│ │ └── constants/ # 常量定义 +│ │ +│ ├── web-client/ # 用户端 Web 应用 (PC/H5) +│ │ ├── src/ +│ │ │ ├── app/ # 应用层 +│ │ │ │ ├── providers/ # Context Providers +│ │ │ │ ├── routes/ # 路由配置 +│ │ │ │ └── App.tsx +│ │ │ ├── features/ # 功能模块 (Clean Architecture) +│ │ │ │ ├── chat/ # 聊天功能 +│ │ │ │ │ ├── domain/ # 领域层 +│ │ │ │ │ ├── data/ # 数据层 +│ │ │ │ │ └── presentation/# 表现层 +│ │ │ │ ├── payment/ # 支付功能 +│ │ │ │ └── user/ # 用户功能 +│ │ │ ├── shared/ # 共享组件 +│ │ │ │ ├── components/ # UI 组件 +│ │ │ │ ├── hooks/ # 自定义 Hooks +│ │ │ │ └── utils/ # 工具函数 +│ │ │ └── main.tsx +│ │ ├── package.json +│ │ └── vite.config.ts +│ │ +│ ├── admin-client/ # 管理端 Web 应用 +│ │ ├── src/ +│ │ │ ├── app/ +│ │ │ ├── features/ +│ │ │ │ ├── dashboard/ # 仪表盘 +│ │ │ │ ├── knowledge/ # 知识库管理 +│ │ │ │ ├── evolution/ # 进化管理 +│ │ │ │ ├── users/ # 用户管理 +│ │ │ │ ├── orders/ # 订单管理 +│ │ │ │ └── settings/ # 系统设置 +│ │ │ └── shared/ +│ │ └── package.json +│ │ +│ └── services/ # 后端微服务 +│ ├── api-gateway/ # API 网关 +│ │ ├── src/ +│ │ │ ├── main.ts +│ │ │ ├── gateway.module.ts +│ │ │ └── middleware/ +│ │ └── package.json +│ │ +│ ├── user-service/ # 用户服务 +│ │ ├── src/ +│ │ │ ├── domain/ # 领域层 +│ │ │ │ ├── entities/ +│ │ │ │ ├── value-objects/ +│ │ │ │ ├── repositories/ +│ │ │ │ └── services/ +│ │ │ ├── application/ # 应用层 +│ │ │ │ ├── commands/ +│ │ │ │ ├── queries/ +│ │ │ │ └── services/ +│ │ │ ├── infrastructure/ # 基础设施层 +│ │ │ │ ├── persistence/ +│ │ │ │ ├── messaging/ +│ │ │ │ └── external/ +│ │ │ └── interfaces/ # 接口层 +│ │ │ ├── http/ +│ │ │ └── grpc/ +│ │ └── package.json +│ │ +│ ├── conversation-service/ # 对话服务 (核心) +│ │ ├── src/ +│ │ │ ├── domain/ +│ │ │ │ ├── entities/ +│ │ │ │ │ ├── conversation.entity.ts +│ │ │ │ │ ├── message.entity.ts +│ │ │ │ │ └── session.entity.ts +│ │ │ │ ├── value-objects/ +│ │ │ │ ├── repositories/ +│ │ │ │ └── services/ +│ │ │ │ └── claude-agent.service.ts +│ │ │ ├── application/ +│ │ │ │ ├── commands/ +│ │ │ │ │ ├── send-message.command.ts +│ │ │ │ │ └── start-conversation.command.ts +│ │ │ │ └── services/ +│ │ │ │ └── conversation.service.ts +│ │ │ ├── infrastructure/ +│ │ │ │ ├── claude/ # Claude Agent SDK 集成 +│ │ │ │ │ ├── claude-client.ts +│ │ │ │ │ ├── tools/ # Agent Tools +│ │ │ │ │ └── prompts/ +│ │ │ │ └── persistence/ +│ │ │ └── interfaces/ +│ │ │ ├── http/ +│ │ │ └── websocket/ +│ │ └── package.json +│ │ +│ ├── knowledge-service/ # 知识服务 +│ │ ├── src/ +│ │ │ ├── domain/ +│ │ │ │ ├── entities/ +│ │ │ │ │ ├── document.entity.ts +│ │ │ │ │ ├── knowledge-node.entity.ts +│ │ │ │ │ └── memory.entity.ts +│ │ │ │ └── services/ +│ │ │ │ ├── rag.service.ts +│ │ │ │ └── graph.service.ts +│ │ │ ├── application/ +│ │ │ ├── infrastructure/ +│ │ │ │ ├── vector-store/ # pgvector +│ │ │ │ ├── graph-db/ # Neo4j +│ │ │ │ └── embedding/ # 向量化 +│ │ │ └── interfaces/ +│ │ └── package.json +│ │ +│ ├── payment-service/ # 支付服务 +│ │ ├── src/ +│ │ │ ├── domain/ +│ │ │ │ ├── entities/ +│ │ │ │ │ ├── order.entity.ts +│ │ │ │ │ └── payment.entity.ts +│ │ │ │ └── services/ +│ │ │ ├── application/ +│ │ │ ├── infrastructure/ +│ │ │ │ ├── alipay/ # 支付宝 +│ │ │ │ ├── wechat-pay/ # 微信支付 +│ │ │ │ └── stripe/ # 信用卡(Stripe) +│ │ │ └── interfaces/ +│ │ └── package.json +│ │ +│ ├── admin-service/ # 管理服务 +│ │ ├── src/ +│ │ │ ├── domain/ +│ │ │ │ ├── entities/ +│ │ │ │ │ ├── admin-user.entity.ts +│ │ │ │ │ ├── role.entity.ts +│ │ │ │ │ └── system-config.entity.ts +│ │ │ │ └── services/ +│ │ │ ├── application/ +│ │ │ ├── infrastructure/ +│ │ │ └── interfaces/ +│ │ └── package.json +│ │ +│ └── evolution-service/ # 进化服务 (核心) +│ ├── src/ +│ │ ├── domain/ +│ │ │ ├── entities/ +│ │ │ │ ├── experience.entity.ts +│ │ │ │ ├── pattern.entity.ts +│ │ │ │ └── evolution-log.entity.ts +│ │ │ └── services/ +│ │ │ ├── experience-extractor.service.ts +│ │ │ ├── pattern-recognizer.service.ts +│ │ │ └── evolution-engine.service.ts +│ │ ├── application/ +│ │ │ ├── commands/ +│ │ │ │ ├── extract-experience.command.ts +│ │ │ │ └── apply-evolution.command.ts +│ │ │ └── services/ +│ │ ├── infrastructure/ +│ │ │ ├── analyzers/ # 分析器 +│ │ │ └── adapters/ # 适配器 +│ │ └── interfaces/ +│ └── package.json +│ +├── infrastructure/ # 基础设施配置 +│ ├── docker/ +│ │ ├── docker-compose.yml +│ │ ├── docker-compose.dev.yml +│ │ └── services/ +│ │ ├── postgres/ +│ │ ├── neo4j/ +│ │ ├── redis/ +│ │ └── kafka/ +│ ├── k8s/ # Kubernetes 配置 (可选) +│ └── scripts/ # 部署脚本 +│ +├── package.json # Root package.json +├── pnpm-workspace.yaml # pnpm workspace 配置 +├── turbo.json # Turborepo 配置 +├── tsconfig.base.json # TypeScript 基础配置 +├── .env.example # 环境变量示例 +└── README.md # 项目说明 +``` + +--- + +## 核心模块设计 + +### 1. 对话服务 (Conversation Service) + +#### Claude Agent SDK 集成 + +```typescript +// conversation-service/src/infrastructure/claude/claude-client.ts + +import Anthropic from '@anthropic-ai/sdk'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ClaudeAgentClient { + private client: Anthropic; + + constructor(private configService: ConfigService) { + this.client = new Anthropic({ + apiKey: this.configService.get('ANTHROPIC_API_KEY'), + }); + } + + async createConversation( + systemPrompt: string, + tools: Tool[], + ): Promise { + // 创建带有工具的对话会话 + } + + async sendMessage( + sessionId: string, + message: string, + context: ConversationContext, + ): AsyncGenerator { + // 流式发送消息并获取响应 + } +} +``` + +#### 对话工具定义 + +系统需要定义以下 Agent Tools: + +1. **知识检索工具** - 从 RAG 系统检索相关知识 +2. **评估工具** - 收集用户信息进行移民评估 +3. **支付工具** - 生成支付二维码 +4. **记忆工具** - 读写长期记忆 + +```typescript +// conversation-service/src/infrastructure/claude/tools/index.ts + +export const immigrationTools = [ + { + name: 'search_knowledge', + description: '搜索香港移民相关知识库', + input_schema: { + type: 'object', + properties: { + query: { type: 'string', description: '搜索查询' }, + category: { + type: 'string', + enum: ['优才计划', '专才计划', '留学IANG', '高才通', '投资移民', '科技人才'], + description: '移民类别' + } + }, + required: ['query'] + } + }, + { + name: 'start_assessment', + description: '开始移民资格评估(付费服务)', + input_schema: { + type: 'object', + properties: { + category: { type: 'string', description: '评估的移民类别' }, + user_info: { type: 'object', description: '用户基本信息' } + }, + required: ['category'] + } + }, + { + name: 'generate_payment', + description: '生成支付二维码', + input_schema: { + type: 'object', + properties: { + amount: { type: 'number', description: '支付金额' }, + service_type: { type: 'string', description: '服务类型' }, + payment_method: { + type: 'string', + enum: ['alipay', 'wechat', 'credit_card'], + description: '支付方式' + } + }, + required: ['amount', 'service_type', 'payment_method'] + } + }, + { + name: 'check_off_topic', + description: '检查问题是否与移民相关', + input_schema: { + type: 'object', + properties: { + question: { type: 'string', description: '用户问题' } + }, + required: ['question'] + } + } +]; +``` + +#### 系统提示词设计 + +```typescript +// conversation-service/src/infrastructure/claude/prompts/system-prompt.ts + +export const buildSystemPrompt = (config: SystemConfig): string => ` +你是 iConsulting 的香港移民咨询顾问,专门为用户提供香港各类移民政策的咨询服务。 + +## 身份定位 +${config.identity || '专业、友善、耐心的移民顾问'} + +## 服务范围 +你只回答与香港移民相关的问题,包括以下6大类别: + +### 1. 优才计划 (QMAS - Quality Migrant Admission Scheme) +- **目的**: 吸纳优秀人才来港定居 +- **审核标准**: 年龄、教育经历、工作经验、语言能力、年收入证明、良好品格、是否持有公司等 +- **人才类型**: 行业翘楚、精英人士 +- **名额**: 暂无名额限制 +- **申请要点**: 年龄、教育经历、工作经验、年收入证明、良好品格,是否符合等方面的要求(共12项),满足其中6项为基本门槛,择优批准 + +### 2. 专才计划 (GEP - General Employment Policy) +- **目的**: 吸引专业人才来港就业 +- **审核标准**: 雇佣企业的行业背景、行业内的地位、政策扶持力度、所属行业经济地位 +- **人才类型**: 专业人士(≥200万年薪,属于人才清单) +- **名额**: 无配额限制(不限行业) +- **申请要点**: 必须有雇主担保、要保持工作连续性(被裁3个月内)、工作和居住在非粤港澳、申请前至少有一年半社保记录、提供所在公司配套宣传图 + +### 3. 留学IANG (Immigration Arrangements for Non-local Graduates) +- **目的**: 吸引优秀学生人才来港续读 +- **审核标准**: 有无完成学业、是否有雇主担保 +- **人才类型**: 成绩优异、拥有国际化视野的学生 +- **名额**: 扩展到本港大学大湾区校区毕业生,为期2年、1年后续签 +- **申请要点**: 必须有雇主担保、要保持工作连续性 + +### 4. 高才通计划 (TTPS - Top Talent Pass Scheme) +- **目的**: 吸引高端人才来港 +- **审核标准**: + - A类: 年薪≥250万港币 + - B类: 毕业于世界前100名大学,5年内有3年以上工作经验 + - C类: 毕业于世界前100名大学,5年内有少于3年工作经验(年度限额10,000名) +- **人才类型**: 顶尖人才 +- **名额**: 无限期计划(推出后制一进行检讨) + +### 5. 投资移民 (新资本投资者入境计划) +- **目的**: 吸引投资者 +- **审核标准**: 投资不少于3,000万港币(或等值外币)净资产于其指定对实益拥有者的许可投资 +- **人才类型**: 投资者 +- **名额**: 无名额限制 +- **申请要点**: 资产审查新政3月1日起,申请人只须证明在提出申请六个月前已期限内持有该资产(1,000万元额度可获考虑) + +### 6. 科技人才入境计划 (TechTAS) +- **目的**: 吸引科技人才来港就业 +- **审核标准**: 申请人主要从事先进通讯技术、人工智能、生物科技、网络安全、数据分析、数码娱乐、金融科技、绿色科技、集成电路设计、物联网、材料科学、微电子、量子科技、机械人技术等研究工作;申请薪酬应不低于香港特区该业务的市场薪酬水平 +- **人才类型**: 科技人才 +- **名额**: 需用公司高新创新科技营配出有效配额 +- **申请要点**: 由符合创新科技署发出的获配证出有效配额 + +## 行为准则 +1. 如果用户询问与移民无关的问题,礼貌地引导回移民话题 +2. 对于复杂的评估需求,建议使用付费评估服务 +3. 提供信息时注明信息来源和更新时间 +4. 不做任何法律承诺或申请成功率的保证 + +## 付费服务 +当用户需要个性化移民评估时,介绍付费评估服务: +- 说明服务内容和价值 +- 确认用户意愿后生成支付码 + +## 对话风格 +${config.conversationStyle || '专业但不生硬,用简洁明了的语言解答'} + +## 已积累的经验 +${config.accumulatedExperience || '暂无'} + +## 管理员特别指示 +${config.adminInstructions || '暂无'} +`; +``` + +### 2. 知识服务 (Knowledge Service) + +#### RAG 架构 + +```typescript +// knowledge-service/src/domain/services/rag.service.ts + +@Injectable() +export class RAGService { + constructor( + private vectorStore: VectorStoreRepository, + private embeddingService: EmbeddingService, + private graphService: GraphService, + ) {} + + async search(query: string, options: SearchOptions): Promise { + // 1. 向量相似度搜索 + const embedding = await this.embeddingService.embed(query); + const vectorResults = await this.vectorStore.search(embedding, options.limit); + + // 2. 图谱增强(可选) + if (options.useGraph) { + const graphContext = await this.graphService.getRelatedNodes( + vectorResults.map(r => r.nodeId) + ); + return this.mergeResults(vectorResults, graphContext); + } + + return vectorResults; + } + + async addDocument(doc: Document): Promise { + // 1. 文档分块 + const chunks = await this.chunkDocument(doc); + + // 2. 向量化并存储 + for (const chunk of chunks) { + const embedding = await this.embeddingService.embed(chunk.content); + await this.vectorStore.insert({ + content: chunk.content, + embedding, + metadata: chunk.metadata, + }); + } + + // 3. 更新知识图谱 + await this.graphService.addDocumentNodes(doc, chunks); + } +} +``` + +#### 知识图谱 (Neo4j) + +```typescript +// knowledge-service/src/infrastructure/graph-db/neo4j.repository.ts + +@Injectable() +export class Neo4jRepository { + constructor(private neo4jService: Neo4jService) {} + + // 添加用户记忆节点 + async addMemory(memory: Memory): Promise { + await this.neo4jService.write(` + MERGE (u:User {id: $userId}) + CREATE (m:Memory { + id: $memoryId, + content: $content, + timestamp: datetime($timestamp), + type: $type + }) + CREATE (u)-[:HAS_MEMORY {timestamp: datetime($timestamp)}]->(m) + `, { + userId: memory.userId, + memoryId: memory.id, + content: memory.content, + timestamp: memory.timestamp.toISOString(), + type: memory.type, + }); + } + + // 获取用户时间线记忆 + async getUserTimeline(userId: string, limit: number = 50): Promise { + const result = await this.neo4jService.read(` + MATCH (u:User {id: $userId})-[r:HAS_MEMORY]->(m:Memory) + RETURN m + ORDER BY m.timestamp DESC + LIMIT $limit + `, { userId, limit }); + + return result.records.map(r => this.mapToMemory(r.get('m'))); + } + + // 知识关联查询 + async getRelatedKnowledge(topic: string): Promise { + const result = await this.neo4jService.read(` + MATCH (n:Knowledge)-[r:RELATED_TO*1..2]-(related) + WHERE n.topic CONTAINS $topic + RETURN n, related, r + `, { topic }); + + return this.mapToKnowledgeNodes(result); + } +} +``` + +### 3. 进化服务 (Evolution Service) + +这是系统的核心创新点,实现自我学习和进化。 + +#### 经验提取器 + +```typescript +// evolution-service/src/domain/services/experience-extractor.service.ts + +@Injectable() +export class ExperienceExtractorService { + constructor( + private claudeClient: ClaudeAgentClient, + private experienceRepo: ExperienceRepository, + ) {} + + // 从对话中提取经验 + async extractFromConversation(conversation: Conversation): Promise { + const prompt = ` + 分析以下对话,提取有价值的经验: + 1. 用户常见问题模式 + 2. 有效的回答策略 + 3. 用户满意度信号 + 4. 需要改进的地方 + + 对话内容: + ${JSON.stringify(conversation.messages)} + + 请以JSON格式返回经验列表。 + `; + + const response = await this.claudeClient.analyze(prompt); + return this.parseExperiences(response); + } + + // 定期批量分析 + async batchAnalyze(): Promise { + const conversations = await this.getRecentConversations(); + + const prompt = ` + 分析以下 ${conversations.length} 个对话,总结: + 1. 高频问题 TOP 10 + 2. 用户最关心的移民类别 + 3. 转化率较高的话术 + 4. 常见的用户困惑点 + 5. 建议的系统改进方向 + + 请生成详细的分析报告。 + `; + + return await this.claudeClient.analyze(prompt); + } +} +``` + +#### 进化引擎 + +```typescript +// evolution-service/src/domain/services/evolution-engine.service.ts + +@Injectable() +export class EvolutionEngineService { + constructor( + private experienceRepo: ExperienceRepository, + private configService: SystemConfigService, + private claudeClient: ClaudeAgentClient, + ) {} + + // 与管理员对话,理解意图 + async interactWithAdmin( + adminMessage: string, + context: AdminContext, + ): Promise { + const experiences = await this.experienceRepo.getRecentExperiences(); + const currentConfig = await this.configService.getCurrentConfig(); + + const prompt = ` + 你是 iConsulting 系统的进化引擎。 + + ## 当前系统配置 + ${JSON.stringify(currentConfig)} + + ## 近期积累的经验 + ${JSON.stringify(experiences)} + + ## 管理员指令 + ${adminMessage} + + ## 任务 + 1. 理解管理员的意图 + 2. 基于积累的经验,提出具体的调整方案 + 3. 生成新的配置或行为规则 + + 请返回: + - 对管理员意图的理解 + - 建议的调整方案 + - 需要确认的事项(如有) + `; + + return await this.claudeClient.analyze(prompt); + } + + // 应用进化 + async applyEvolution(evolution: Evolution): Promise { + // 更新系统提示词 + if (evolution.promptChanges) { + await this.configService.updatePrompt(evolution.promptChanges); + } + + // 更新知识库 + if (evolution.knowledgeChanges) { + await this.knowledgeService.applyChanges(evolution.knowledgeChanges); + } + + // 更新业务规则 + if (evolution.ruleChanges) { + await this.configService.updateRules(evolution.ruleChanges); + } + + // 记录进化日志 + await this.evolutionLogRepo.save({ + timestamp: new Date(), + changes: evolution, + triggeredBy: evolution.adminId, + }); + } +} +``` + +### 4. 支付服务 (Payment Service) + +```typescript +// payment-service/src/infrastructure/alipay/alipay.adapter.ts + +@Injectable() +export class AlipayAdapter implements PaymentGateway { + constructor(private configService: ConfigService) { + this.alipay = new AlipaySdk({ + appId: this.configService.get('ALIPAY_APP_ID'), + privateKey: this.configService.get('ALIPAY_PRIVATE_KEY'), + // ... + }); + } + + async createPayment(order: Order): Promise { + const result = await this.alipay.exec('alipay.trade.precreate', { + bizContent: { + out_trade_no: order.id, + total_amount: order.amount.toString(), + subject: order.subject, + } + }); + + return { + qrCode: result.qrCode, + orderId: order.id, + expireAt: new Date(Date.now() + 30 * 60 * 1000), // 30分钟有效 + }; + } + + async handleCallback(payload: AlipayCallback): Promise { + // 验签并处理回调 + } +} +``` + +### 5. 用户服务 (User Service) + +#### 匿名用户处理 + +```typescript +// user-service/src/domain/entities/user.entity.ts + +export class User extends AggregateRoot { + id: UserId; + type: UserType; // ANONYMOUS | REGISTERED + fingerprint?: string; + phone?: string; + createdAt: Date; + lastActiveAt: Date; + + static createAnonymous(fingerprint: string): User { + return new User({ + id: UserId.generate(), + type: UserType.ANONYMOUS, + fingerprint, + createdAt: new Date(), + lastActiveAt: new Date(), + }); + } + + upgradeToRegistered(phone: string): void { + this.phone = phone; + this.type = UserType.REGISTERED; + this.addDomainEvent(new UserUpgradedEvent(this.id, phone)); + } +} +``` + +--- + +## 数据模型设计 + +### PostgreSQL 表结构 + +```sql +-- 用户表 +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(20) NOT NULL DEFAULT 'ANONYMOUS', + fingerprint VARCHAR(255), + phone VARCHAR(20), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + last_active_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 对话表 +CREATE TABLE conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + ended_at TIMESTAMP WITH TIME ZONE +); + +-- 消息表 +CREATE TABLE messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id UUID REFERENCES conversations(id), + role VARCHAR(20) NOT NULL, -- user, assistant, system + content TEXT NOT NULL, + metadata JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 订单表 +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + service_type VARCHAR(50) NOT NULL, + amount DECIMAL(10, 2) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + payment_method VARCHAR(20), + paid_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 发件箱表 (Outbox Pattern) +CREATE TABLE outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + aggregate_type VARCHAR(100) NOT NULL, + aggregate_id UUID NOT NULL, + event_type VARCHAR(100) NOT NULL, + payload JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + processed_at TIMESTAMP WITH TIME ZONE +); + +-- 知识文档表 +CREATE TABLE documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + category VARCHAR(50), + metadata JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 向量存储表 (pgvector) +CREATE TABLE document_embeddings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID REFERENCES documents(id), + chunk_index INT NOT NULL, + content TEXT NOT NULL, + embedding vector(1536), + metadata JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 系统配置表 +CREATE TABLE system_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + key VARCHAR(100) UNIQUE NOT NULL, + value JSONB NOT NULL, + updated_by UUID, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 管理员表 +CREATE TABLE admin_users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(100) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 进化日志表 +CREATE TABLE evolution_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + triggered_by UUID REFERENCES admin_users(id), + change_type VARCHAR(50) NOT NULL, + changes JSONB NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); +``` + +### Neo4j 图模型 + +```cypher +// 用户节点 +(:User { + id: string, + type: string, + createdAt: datetime +}) + +// 记忆节点 +(:Memory { + id: string, + content: string, + type: string, // FACT, PREFERENCE, INTERACTION + timestamp: datetime +}) + +// 知识节点 +(:Knowledge { + id: string, + topic: string, + content: string, + category: string, + updatedAt: datetime +}) + +// 经验节点 +(:Experience { + id: string, + pattern: string, + insight: string, + frequency: int, + createdAt: datetime +}) + +// 关系定义 +(User)-[:HAS_MEMORY {timestamp}]->(Memory) +(User)-[:ASKED_ABOUT]->(Knowledge) +(Knowledge)-[:RELATED_TO {weight}]->(Knowledge) +(Experience)-[:DERIVED_FROM]->(Conversation) +(Experience)-[:INFLUENCES]->(SystemConfig) +``` + +--- + +## API 设计 + +### REST API + +```yaml +# 用户端 API +/api/v1: + /auth: + /anonymous: + POST: 创建匿名会话 + /verify-phone: + POST: 发送验证码 + /login: + POST: 手机号登录 + + /conversations: + POST: 创建新对话 + GET: 获取对话列表 + /{id}: + GET: 获取对话详情 + + /messages: + POST: 发送消息 (HTTP) + + /payments: + POST: 创建支付订单 + /{id}/status: + GET: 查询支付状态 + +# 管理端 API +/admin/api/v1: + /auth: + /login: + POST: 管理员登录 + + /knowledge: + GET: 获取知识列表 + POST: 添加知识 + /{id}: + PUT: 更新知识 + DELETE: 删除知识 + /upload: + POST: 上传文档 + + /evolution: + /chat: + POST: 与系统进化引擎对话 + /experiences: + GET: 获取经验总结 + /apply: + POST: 应用进化变更 + + /configs: + GET: 获取配置 + PUT: 更新配置 + + /analytics: + /dashboard: + GET: 获取仪表盘数据 + /conversations: + GET: 对话统计 + /payments: + GET: 支付统计 +``` + +### WebSocket API + +```typescript +// 对话 WebSocket +ws://host/ws/conversation + +// 客户端 -> 服务端 +{ + type: 'message', + conversationId: 'uuid', + content: '用户消息' +} + +// 服务端 -> 客户端 (流式) +{ + type: 'stream_start', + messageId: 'uuid' +} +{ + type: 'stream_chunk', + messageId: 'uuid', + content: '部分内容', + index: 0 +} +{ + type: 'stream_end', + messageId: 'uuid' +} +{ + type: 'tool_call', + tool: 'generate_payment', + result: { qrCode: '...', orderId: '...' } +} +``` + +--- + +## 前端架构 (Clean Architecture) + +### 目录结构详解 + +``` +features/chat/ +├── domain/ # 领域层 +│ ├── entities/ +│ │ ├── Message.ts # 消息实体 +│ │ └── Conversation.ts # 对话实体 +│ ├── repositories/ +│ │ └── IChatRepository.ts # 仓储接口 +│ └── usecases/ +│ ├── SendMessageUseCase.ts +│ └── GetConversationUseCase.ts +│ +├── data/ # 数据层 +│ ├── repositories/ +│ │ └── ChatRepositoryImpl.ts +│ ├── datasources/ +│ │ ├── ChatApiDataSource.ts +│ │ └── ChatWebSocketDataSource.ts +│ └── models/ +│ ├── MessageModel.ts +│ └── ConversationModel.ts +│ +└── presentation/ # 表现层 + ├── components/ + │ ├── ChatWindow.tsx + │ ├── MessageBubble.tsx + │ ├── InputArea.tsx + │ └── PaymentModal.tsx + ├── hooks/ + │ ├── useChat.ts + │ └── useWebSocket.ts + ├── stores/ + │ └── chatStore.ts + └── pages/ + └── ChatPage.tsx +``` + +### 核心组件示例 + +```tsx +// features/chat/presentation/components/ChatWindow.tsx + +import { useChat } from '../hooks/useChat'; +import { MessageBubble } from './MessageBubble'; +import { InputArea } from './InputArea'; + +export const ChatWindow: React.FC = () => { + const { + messages, + isStreaming, + sendMessage, + currentStreamContent + } = useChat(); + + return ( +
+
+ {messages.map((msg) => ( + + ))} + {isStreaming && ( + + )} +
+ +
+ ); +}; +``` + +--- + +## 部署方案 + +### Docker Compose (开发环境) + +```yaml +# infrastructure/docker/docker-compose.dev.yml +version: '3.8' + +services: + postgres: + image: pgvector/pgvector:pg15 + environment: + POSTGRES_USER: iconsulting + POSTGRES_PASSWORD: dev_password + POSTGRES_DB: iconsulting + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + + neo4j: + image: neo4j:5 + environment: + NEO4J_AUTH: neo4j/dev_password + ports: + - "7474:7474" + - "7687:7687" + volumes: + - neo4j_data:/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + + zookeeper: + image: confluentinc/cp-zookeeper:7.4.0 + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ports: + - "2181:2181" + + kafka: + image: confluentinc/cp-kafka:7.4.0 + depends_on: + - zookeeper + ports: + - "9092:9092" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + +volumes: + postgres_data: + neo4j_data: +``` + +--- + +## 开发计划 + +### Phase 1: 基础架构 (核心) +1. 项目初始化和工程配置 +2. 数据库 Schema 和连接 +3. 基础微服务框架搭建 +4. API 网关配置 + +### Phase 2: 核心对话功能 +1. Claude Agent SDK 集成 +2. 对话服务完整实现 +3. WebSocket 实时通信 +4. 基础知识检索 (RAG) + +### Phase 3: 支付和用户 +1. 支付服务对接 +2. 用户认证体系 +3. 订单管理 + +### Phase 4: 知识和记忆 +1. Neo4j 知识图谱 +2. 长期记忆系统 +3. RAG 优化 + +### Phase 5: 进化系统 +1. 经验提取引擎 +2. 管理员交互界面 +3. 自动进化机制 + +### Phase 6: 管理后台 +1. 管理员认证 +2. 知识库管理 +3. 系统配置 +4. 数据统计 + +### Phase 7: 前端完善 +1. 用户端 UI 完善 +2. 管理端 UI 完善 +3. 移动端适配 + +### Phase 8: 测试和优化 +1. 单元测试 +2. 集成测试 +3. 性能优化 +4. 安全审计 + +--- + +## 环境变量配置 + +```env +# .env.example + +# Anthropic +ANTHROPIC_API_KEY=sk-ant-xxx + +# PostgreSQL +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=iconsulting +POSTGRES_PASSWORD=your_password +POSTGRES_DB=iconsulting + +# Neo4j +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=your_password + +# Redis +REDIS_HOST=localhost +REDIS_PORT=6379 + +# Kafka +KAFKA_BROKERS=localhost:9092 + +# 支付宝 +ALIPAY_APP_ID=your_app_id +ALIPAY_PRIVATE_KEY=your_private_key +ALIPAY_PUBLIC_KEY=alipay_public_key + +# 微信支付 +WECHAT_APP_ID=your_app_id +WECHAT_MCH_ID=your_mch_id +WECHAT_API_KEY=your_api_key + +# JWT +JWT_SECRET=your_jwt_secret +JWT_EXPIRES_IN=7d + +# 服务端口 +API_GATEWAY_PORT=3000 +USER_SERVICE_PORT=3001 +CONVERSATION_SERVICE_PORT=3002 +KNOWLEDGE_SERVICE_PORT=3003 +PAYMENT_SERVICE_PORT=3004 +ADMIN_SERVICE_PORT=3005 +EVOLUTION_SERVICE_PORT=3006 +``` + +--- + +## 安全考虑 + +1. **API 安全** + - JWT 认证 + - 请求限流 + - 输入验证 + - SQL 注入防护 + +2. **支付安全** + - 签名验证 + - 回调白名单 + - 金额校验 + +3. **数据安全** + - 敏感数据加密 + - 日志脱敏 + - 定期备份 + +4. **Claude API 安全** + - API Key 安全存储 + - 输入内容过滤 + - 输出内容审核 + +--- + +## 下一步行动 + +1. 确认移民类别列表(等待图片) +2. 创建项目目录结构 +3. 初始化 Monorepo 配置 +4. 搭建基础微服务框架 +5. 集成 Claude Agent SDK + +准备好后请告知,我将开始创建项目结构! diff --git a/README.md b/README.md new file mode 100644 index 0000000..08098c1 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# iConsulting - 香港移民在线咨询系统 + +基于 Claude Agent SDK 的智能在线客服系统,专注于提供香港移民咨询服务。 + +## 功能特性 + +- **智能咨询**: 基于 Claude Agent SDK 的自然语言对话 +- **付费评估**: 移民资格评估服务,支持支付宝/微信/信用卡 +- **知识增强**: RAG + Neo4j 知识图谱 +- **自我进化**: 从对话中学习,根据管理员指令调整 +- **长期记忆**: 基于时间线的知识图谱记录 +- **多端支持**: PC Web / H5 响应式设计 + +## 支持的移民类别 + +1. **优才计划 (QMAS)** - 行业翘楚、精英人士 +2. **专才计划 (GEP)** - 专业人才 +3. **留学IANG** - 非本地毕业生 +4. **高才通 (TTPS)** - 高端人才 +5. **投资移民 (CIES)** - 投资者 +6. **科技人才 (TechTAS)** - 科技领域人才 + +## 技术架构 + +``` +├── 前端 (Clean Architecture) +│ ├── React 18 + TypeScript +│ ├── TailwindCSS + Radix UI +│ └── Zustand + React Query +│ +├── 后端 (DDD + Hexagonal + 微服务) +│ ├── NestJS +│ ├── Claude Agent SDK +│ └── TypeORM +│ +└── 基础设施 + ├── PostgreSQL + pgvector (RAG) + ├── Neo4j (知识图谱) + ├── Redis (缓存) + └── Kafka (消息队列) +``` + +## 项目结构 + +``` +iconsulting/ +├── packages/ +│ ├── shared/ # 共享类型、常量、工具 +│ ├── web-client/ # 用户端 Web 应用 +│ ├── admin-client/ # 管理端 Web 应用 +│ └── services/ # 后端微服务 +│ ├── api-gateway/ # API 网关 +│ ├── user-service/ # 用户服务 +│ ├── conversation-service/ # 对话服务 (核心) +│ ├── knowledge-service/ # 知识服务 +│ ├── payment-service/ # 支付服务 +│ ├── admin-service/ # 管理服务 +│ └── evolution-service/ # 进化服务 +│ +├── infrastructure/ +│ └── docker/ # Docker 配置 +│ +├── DEVELOPMENT_GUIDE.md # 详细开发指导 +└── README.md +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +# 安装 pnpm (如果没有) +npm install -g pnpm + +# 安装项目依赖 +pnpm install +``` + +### 2. 启动基础设施 + +```bash +# 启动 Docker 容器 (PostgreSQL, Neo4j, Redis, Kafka) +pnpm docker:dev +``` + +### 3. 配置环境变量 + +```bash +# 复制环境变量示例文件 +cp .env.example .env + +# 编辑 .env 文件,填入必要的配置 +# 特别是 ANTHROPIC_API_KEY +``` + +### 4. 运行数据库迁移 + +```bash +pnpm db:migrate +``` + +### 5. 启动开发服务器 + +```bash +# 启动所有服务 +pnpm dev +``` + +访问: +- 用户端: http://localhost:5173 +- 管理端: http://localhost:5174 +- API: http://localhost:3000 + +## 环境变量 + +关键配置项: + +```env +# Claude API +ANTHROPIC_API_KEY=sk-ant-xxx + +# 数据库 +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=iconsulting +POSTGRES_PASSWORD=your_password +POSTGRES_DB=iconsulting + +# Neo4j +NEO4J_URI=bolt://localhost:7687 +NEO4J_USER=neo4j +NEO4J_PASSWORD=your_password + +# 支付 (支付宝/微信) +ALIPAY_APP_ID=xxx +WECHAT_APP_ID=xxx +``` + +完整配置请参考 `.env.example` + +## 开发指南 + +详细的开发指导请参考 [DEVELOPMENT_GUIDE.md](./DEVELOPMENT_GUIDE.md) + +## 开发进度 + +### 已完成 + +- [x] 项目架构设计 +- [x] 开发指导文档 +- [x] Monorepo 配置 +- [x] 共享类型定义 +- [x] Docker 基础设施配置 +- [x] 数据库 Schema +- [x] 对话服务 (Claude Agent SDK 集成) +- [x] 用户端前端基础框架 + +### 进行中 + +- [ ] 用户服务 +- [ ] 知识服务 (RAG + Neo4j) +- [ ] 支付服务 +- [ ] 管理服务 (自我进化) +- [ ] 管理后台前端 + +## 贡献指南 + +1. Fork 本仓库 +2. 创建特性分支 (`git checkout -b feature/amazing-feature`) +3. 提交更改 (`git commit -m 'Add some amazing feature'`) +4. 推送到分支 (`git push origin feature/amazing-feature`) +5. 创建 Pull Request + +## 许可证 + +私有项目,保留所有权利。 diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..90810e8 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,903 @@ +#!/bin/bash + +#=============================================================================== +# iConsulting 部署管理脚本 +# +# 用法: ./deploy.sh [service] [options] +# +# 命令: +# build - 编译构建 +# start - 启动服务 +# stop - 停止服务 +# restart - 重启服务 +# status - 查看状态 +# logs - 查看日志 +# clean - 清理构建产物 +# deploy - 完整部署(构建+启动) +# db - 数据库操作 +# help - 显示帮助 +# +# 服务: +# all - 所有服务 +# web-client - 用户前端 +# admin-client - 管理后台前端 +# conversation - 对话服务 +# user - 用户服务 +# payment - 支付服务 +# knowledge - 知识库服务 +# evolution - 进化服务 +# kong - API网关 +# postgres - PostgreSQL数据库 +# redis - Redis缓存 +# neo4j - Neo4j图数据库 +# nginx - Nginx静态服务 +# +#=============================================================================== + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +PURPLE='\033[0;35m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# 项目根目录 +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$PROJECT_ROOT" + +# 配置 +COMPOSE_FILE="docker-compose.yml" +ENV_FILE=".env" + +# 服务端口配置 +declare -A SERVICE_PORTS=( + ["conversation"]=3004 + ["user"]=3001 + ["payment"]=3002 + ["knowledge"]=3003 + ["evolution"]=3005 + ["kong"]=8000 + ["postgres"]=5432 + ["redis"]=6379 + ["neo4j"]=7474 + ["nginx"]=80 +) + +# 服务目录映射 +declare -A SERVICE_DIRS=( + ["conversation"]="packages/services/conversation-service" + ["user"]="packages/services/user-service" + ["payment"]="packages/services/payment-service" + ["knowledge"]="packages/services/knowledge-service" + ["evolution"]="packages/services/evolution-service" + ["web-client"]="packages/web-client" + ["admin-client"]="packages/admin-client" + ["shared"]="packages/shared" +) + +# Docker服务名映射 +declare -A DOCKER_SERVICES=( + ["conversation"]="conversation-service" + ["user"]="user-service" + ["payment"]="payment-service" + ["knowledge"]="knowledge-service" + ["evolution"]="evolution-service" + ["web-client"]="web-client" + ["admin-client"]="admin-client" + ["kong"]="kong" + ["postgres"]="postgres" + ["redis"]="redis" + ["neo4j"]="neo4j" + ["nginx"]="nginx" +) + +#=============================================================================== +# 工具函数 +#=============================================================================== + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_step() { + echo -e "${PURPLE}[STEP]${NC} $1" +} + +# 检查命令是否存在 +check_command() { + if ! command -v "$1" &> /dev/null; then + log_error "$1 未安装,请先安装" + exit 1 + fi +} + +# 检查环境 +check_environment() { + log_step "检查运行环境..." + + check_command "node" + check_command "pnpm" + check_command "docker" + check_command "docker-compose" + + # 检查 Node 版本 + NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1) + if [ "$NODE_VERSION" -lt 18 ]; then + log_error "Node.js 版本需要 >= 18,当前版本: $(node -v)" + exit 1 + fi + + log_success "环境检查通过" +} + +# 加载环境变量 +load_env() { + if [ -f "$ENV_FILE" ]; then + export $(grep -v '^#' "$ENV_FILE" | xargs) + fi +} + +# 等待服务就绪 +wait_for_service() { + local host=$1 + local port=$2 + local service=$3 + local max_attempts=${4:-30} + local attempt=1 + + log_info "等待 $service ($host:$port) 就绪..." + + while [ $attempt -le $max_attempts ]; do + if nc -z "$host" "$port" 2>/dev/null; then + log_success "$service 已就绪" + return 0 + fi + echo -n "." + sleep 2 + attempt=$((attempt + 1)) + done + + echo "" + log_error "$service 启动超时" + return 1 +} + +#=============================================================================== +# 构建函数 +#=============================================================================== + +# 安装依赖 +install_deps() { + log_step "安装项目依赖..." + pnpm install + log_success "依赖安装完成" +} + +# 构建共享包 +build_shared() { + log_step "构建 shared 包..." + cd "$PROJECT_ROOT/${SERVICE_DIRS[shared]}" + pnpm run build + cd "$PROJECT_ROOT" + log_success "shared 构建完成" +} + +# 构建单个后端服务 +build_backend_service() { + local service=$1 + local dir="${SERVICE_DIRS[$service]}" + + if [ -z "$dir" ]; then + log_error "未知服务: $service" + return 1 + fi + + log_step "构建 $service..." + cd "$PROJECT_ROOT/$dir" + + # 清理旧构建 + rm -rf dist + + # TypeScript 编译 + pnpm run build + + cd "$PROJECT_ROOT" + log_success "$service 构建完成" +} + +# 构建单个前端 +build_frontend() { + local service=$1 + local dir="${SERVICE_DIRS[$service]}" + + if [ -z "$dir" ]; then + log_error "未知服务: $service" + return 1 + fi + + log_step "构建 $service..." + cd "$PROJECT_ROOT/$dir" + + # 清理旧构建 + rm -rf dist + + # Vite 构建 + pnpm run build + + cd "$PROJECT_ROOT" + log_success "$service 构建完成" +} + +# 构建所有后端服务 +build_all_backend() { + build_shared + + for service in conversation user payment knowledge evolution; do + build_backend_service "$service" + done +} + +# 构建所有前端 +build_all_frontend() { + for service in web-client admin-client; do + build_frontend "$service" + done +} + +# 构建所有 +build_all() { + log_info "开始构建所有服务..." + install_deps + build_all_backend + build_all_frontend + log_success "所有服务构建完成" +} + +# 构建入口 +do_build() { + local target=${1:-all} + + case $target in + all) + build_all + ;; + shared) + build_shared + ;; + backend) + build_all_backend + ;; + frontend) + build_all_frontend + ;; + web-client|admin-client) + build_frontend "$target" + ;; + conversation|user|payment|knowledge|evolution) + build_backend_service "$target" + ;; + *) + log_error "未知构建目标: $target" + exit 1 + ;; + esac +} + +#=============================================================================== +# Docker 操作函数 +#=============================================================================== + +# 构建 Docker 镜像 +build_docker_images() { + local service=${1:-} + + log_step "构建 Docker 镜像..." + + if [ -n "$service" ] && [ "$service" != "all" ]; then + local docker_service="${DOCKER_SERVICES[$service]}" + if [ -n "$docker_service" ]; then + docker-compose build "$docker_service" + else + log_error "未知服务: $service" + return 1 + fi + else + docker-compose build + fi + + log_success "Docker 镜像构建完成" +} + +# 启动基础设施 +start_infrastructure() { + log_step "启动基础设施服务..." + + docker-compose up -d postgres redis neo4j + + # 等待数据库就绪 + wait_for_service localhost 5432 "PostgreSQL" + wait_for_service localhost 6379 "Redis" + wait_for_service localhost 7474 "Neo4j" + + log_success "基础设施启动完成" +} + +# 启动 Kong 网关 +start_kong() { + log_step "启动 Kong API 网关..." + + docker-compose up -d kong-database + sleep 5 + + # Kong 数据库迁移 + docker-compose run --rm kong kong migrations bootstrap || true + + docker-compose up -d kong + wait_for_service localhost 8000 "Kong" + + log_success "Kong 启动完成" +} + +# 启动后端服务 (非 Docker 模式) +start_backend_service_local() { + local service=$1 + local dir="${SERVICE_DIRS[$service]}" + local port="${SERVICE_PORTS[$service]}" + + if [ -z "$dir" ]; then + log_error "未知服务: $service" + return 1 + fi + + log_step "启动 $service (端口: $port)..." + + cd "$PROJECT_ROOT/$dir" + + # 检查是否已构建 + if [ ! -d "dist" ]; then + log_warning "$service 未构建,先进行构建..." + pnpm run build + fi + + # 使用 PM2 或直接启动 + if command -v pm2 &> /dev/null; then + pm2 start dist/main.js --name "iconsulting-$service" --cwd "$PROJECT_ROOT/$dir" + else + # 后台启动 + nohup node dist/main.js > "$PROJECT_ROOT/logs/$service.log" 2>&1 & + echo $! > "$PROJECT_ROOT/pids/$service.pid" + fi + + cd "$PROJECT_ROOT" + + sleep 2 + wait_for_service localhost "$port" "$service" 15 +} + +# 启动后端服务 (Docker 模式) +start_backend_service_docker() { + local service=$1 + local docker_service="${DOCKER_SERVICES[$service]}" + + log_step "启动 $service (Docker)..." + docker-compose up -d "$docker_service" + + local port="${SERVICE_PORTS[$service]}" + wait_for_service localhost "$port" "$service" +} + +# 启动所有后端服务 +start_all_backend() { + local mode=${1:-docker} + + for service in user payment knowledge conversation evolution; do + if [ "$mode" = "docker" ]; then + start_backend_service_docker "$service" + else + start_backend_service_local "$service" + fi + done +} + +# 启动 Nginx (静态文件服务) +start_nginx() { + log_step "启动 Nginx..." + docker-compose up -d nginx + wait_for_service localhost 80 "Nginx" + log_success "Nginx 启动完成" +} + +# 启动所有服务 +start_all() { + local mode=${1:-docker} + + log_info "开始启动所有服务 (模式: $mode)..." + + # 创建必要目录 + mkdir -p "$PROJECT_ROOT/logs" + mkdir -p "$PROJECT_ROOT/pids" + + start_infrastructure + start_kong + start_all_backend "$mode" + start_nginx + + log_success "所有服务启动完成" + do_status +} + +# 启动入口 +do_start() { + local target=${1:-all} + local mode=${2:-docker} + + load_env + + case $target in + all) + start_all "$mode" + ;; + infra|infrastructure) + start_infrastructure + ;; + kong) + start_kong + ;; + nginx) + start_nginx + ;; + postgres|redis|neo4j) + docker-compose up -d "$target" + ;; + conversation|user|payment|knowledge|evolution) + if [ "$mode" = "docker" ]; then + start_backend_service_docker "$target" + else + start_backend_service_local "$target" + fi + ;; + backend) + start_all_backend "$mode" + ;; + *) + log_error "未知启动目标: $target" + exit 1 + ;; + esac +} + +#=============================================================================== +# 停止函数 +#=============================================================================== + +# 停止单个服务 (本地模式) +stop_service_local() { + local service=$1 + + log_step "停止 $service..." + + if command -v pm2 &> /dev/null; then + pm2 stop "iconsulting-$service" 2>/dev/null || true + pm2 delete "iconsulting-$service" 2>/dev/null || true + else + local pid_file="$PROJECT_ROOT/pids/$service.pid" + if [ -f "$pid_file" ]; then + kill $(cat "$pid_file") 2>/dev/null || true + rm -f "$pid_file" + fi + fi + + log_success "$service 已停止" +} + +# 停止单个服务 (Docker 模式) +stop_service_docker() { + local service=$1 + local docker_service="${DOCKER_SERVICES[$service]}" + + if [ -n "$docker_service" ]; then + log_step "停止 $service..." + docker-compose stop "$docker_service" + log_success "$service 已停止" + fi +} + +# 停止所有服务 +stop_all() { + local mode=${1:-docker} + + log_info "停止所有服务..." + + if [ "$mode" = "docker" ]; then + docker-compose down + else + for service in conversation user payment knowledge evolution; do + stop_service_local "$service" + done + docker-compose down + fi + + log_success "所有服务已停止" +} + +# 停止入口 +do_stop() { + local target=${1:-all} + local mode=${2:-docker} + + case $target in + all) + stop_all "$mode" + ;; + infra|infrastructure) + docker-compose stop postgres redis neo4j + ;; + conversation|user|payment|knowledge|evolution) + if [ "$mode" = "docker" ]; then + stop_service_docker "$target" + else + stop_service_local "$target" + fi + ;; + kong|postgres|redis|neo4j|nginx) + docker-compose stop "$target" + ;; + *) + log_error "未知停止目标: $target" + exit 1 + ;; + esac +} + +#=============================================================================== +# 重启函数 +#=============================================================================== + +do_restart() { + local target=${1:-all} + local mode=${2:-docker} + + log_info "重启 $target..." + do_stop "$target" "$mode" + sleep 2 + do_start "$target" "$mode" +} + +#=============================================================================== +# 状态查看 +#=============================================================================== + +do_status() { + echo "" + echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}" + echo -e "${CYAN} iConsulting 服务状态 ${NC}" + echo -e "${CYAN}═══════════════════════════════════════════════════════════════${NC}" + echo "" + + # Docker 服务状态 + echo -e "${PURPLE}Docker 容器状态:${NC}" + docker-compose ps + echo "" + + # 端口检查 + echo -e "${PURPLE}服务端口检查:${NC}" + printf "%-20s %-10s %-10s\n" "服务" "端口" "状态" + echo "----------------------------------------" + + for service in "${!SERVICE_PORTS[@]}"; do + local port="${SERVICE_PORTS[$service]}" + if nc -z localhost "$port" 2>/dev/null; then + printf "%-20s %-10s ${GREEN}%-10s${NC}\n" "$service" "$port" "运行中" + else + printf "%-20s %-10s ${RED}%-10s${NC}\n" "$service" "$port" "未运行" + fi + done + + echo "" + + # PM2 状态 (如果使用) + if command -v pm2 &> /dev/null; then + echo -e "${PURPLE}PM2 进程状态:${NC}" + pm2 list 2>/dev/null | grep iconsulting || echo "无 PM2 管理的服务" + echo "" + fi +} + +#=============================================================================== +# 日志查看 +#=============================================================================== + +do_logs() { + local service=${1:-all} + local lines=${2:-100} + + if [ "$service" = "all" ]; then + docker-compose logs -f --tail="$lines" + else + local docker_service="${DOCKER_SERVICES[$service]}" + if [ -n "$docker_service" ]; then + docker-compose logs -f --tail="$lines" "$docker_service" + else + # 本地日志 + local log_file="$PROJECT_ROOT/logs/$service.log" + if [ -f "$log_file" ]; then + tail -f -n "$lines" "$log_file" + else + log_error "日志文件不存在: $log_file" + fi + fi + fi +} + +#=============================================================================== +# 清理函数 +#=============================================================================== + +do_clean() { + local target=${1:-build} + + case $target in + build) + log_step "清理构建产物..." + for dir in "${SERVICE_DIRS[@]}"; do + rm -rf "$PROJECT_ROOT/$dir/dist" + done + log_success "构建产物已清理" + ;; + deps) + log_step "清理依赖..." + rm -rf node_modules + for dir in "${SERVICE_DIRS[@]}"; do + rm -rf "$PROJECT_ROOT/$dir/node_modules" + done + log_success "依赖已清理" + ;; + docker) + log_step "清理 Docker 资源..." + docker-compose down -v --rmi local + docker system prune -f + log_success "Docker 资源已清理" + ;; + logs) + log_step "清理日志..." + rm -rf "$PROJECT_ROOT/logs/*" + log_success "日志已清理" + ;; + all) + do_clean build + do_clean deps + do_clean docker + do_clean logs + ;; + *) + log_error "未知清理目标: $target (可选: build, deps, docker, logs, all)" + exit 1 + ;; + esac +} + +#=============================================================================== +# 完整部署 +#=============================================================================== + +do_deploy() { + local mode=${1:-docker} + + log_info "开始完整部署 (模式: $mode)..." + + check_environment + + # 构建 + do_build all + + # 如果是 Docker 模式,构建镜像 + if [ "$mode" = "docker" ]; then + build_docker_images + fi + + # 启动 + do_start all "$mode" + + log_success "部署完成!" + echo "" + echo -e "${CYAN}访问地址:${NC}" + echo " 用户前端: http://localhost" + echo " 管理后台: http://localhost/admin" + echo " API 网关: http://localhost:8000" + echo " Kong 管理: http://localhost:8001" + echo "" +} + +#=============================================================================== +# 数据库操作 +#=============================================================================== + +do_db() { + local action=${1:-status} + + case $action in + migrate) + log_step "执行数据库迁移..." + # 可以添加 TypeORM 迁移命令 + for service in user payment knowledge conversation evolution; do + local dir="${SERVICE_DIRS[$service]}" + cd "$PROJECT_ROOT/$dir" + pnpm run migration:run 2>/dev/null || log_warning "$service 无迁移或迁移失败" + cd "$PROJECT_ROOT" + done + log_success "数据库迁移完成" + ;; + seed) + log_step "初始化种子数据..." + # 添加种子数据脚本 + log_success "种子数据初始化完成" + ;; + backup) + local backup_dir="$PROJECT_ROOT/backups/$(date +%Y%m%d_%H%M%S)" + mkdir -p "$backup_dir" + + log_step "备份数据库..." + docker-compose exec -T postgres pg_dump -U postgres iconsulting > "$backup_dir/postgres.sql" + log_success "数据库备份到: $backup_dir" + ;; + restore) + local backup_file=$2 + if [ -z "$backup_file" ]; then + log_error "请指定备份文件: ./deploy.sh db restore " + exit 1 + fi + + log_step "恢复数据库..." + docker-compose exec -T postgres psql -U postgres iconsulting < "$backup_file" + log_success "数据库恢复完成" + ;; + reset) + log_warning "这将删除所有数据!" + read -p "确认继续? (y/N) " confirm + if [ "$confirm" = "y" ] || [ "$confirm" = "Y" ]; then + docker-compose down -v + docker-compose up -d postgres redis neo4j + wait_for_service localhost 5432 "PostgreSQL" + do_db migrate + log_success "数据库已重置" + fi + ;; + status) + echo -e "${PURPLE}数据库状态:${NC}" + docker-compose exec postgres psql -U postgres -c "SELECT version();" 2>/dev/null || echo "PostgreSQL 未运行" + docker-compose exec redis redis-cli ping 2>/dev/null || echo "Redis 未运行" + curl -s http://localhost:7474 > /dev/null && echo "Neo4j 运行中" || echo "Neo4j 未运行" + ;; + *) + log_error "未知数据库操作: $action (可选: migrate, seed, backup, restore, reset, status)" + exit 1 + ;; + esac +} + +#=============================================================================== +# 帮助信息 +#=============================================================================== + +show_help() { + cat << 'EOF' + +╔═══════════════════════════════════════════════════════════════════════════════╗ +║ iConsulting 部署管理脚本 ║ +╚═══════════════════════════════════════════════════════════════════════════════╝ + +用法: ./deploy.sh [target] [options] + +命令: + build [target] 编译构建 + target: all, shared, backend, frontend, + conversation, user, payment, knowledge, evolution, + web-client, admin-client + + start [target] [mode] 启动服务 + target: all, infra, kong, nginx, backend, + conversation, user, payment, knowledge, evolution, + postgres, redis, neo4j + mode: docker (默认), local + + stop [target] [mode] 停止服务 + (target 同上) + + restart [target] [mode] 重启服务 + (target 同上) + + status 查看所有服务状态 + + logs [service] [lines] 查看日志 + service: 服务名或 all + lines: 显示行数 (默认 100) + + clean [target] 清理 + target: build, deps, docker, logs, all + + deploy [mode] 完整部署 (构建 + 启动) + mode: docker (默认), local + + db 数据库操作 + action: migrate, seed, backup, restore, reset, status + + help 显示此帮助信息 + +示例: + ./deploy.sh deploy # 完整部署 + ./deploy.sh build conversation # 只构建对话服务 + ./deploy.sh start backend local # 本地模式启动所有后端 + ./deploy.sh restart user docker # 重启用户服务 (Docker) + ./deploy.sh logs conversation 200 # 查看对话服务最近200行日志 + ./deploy.sh clean all # 清理所有构建产物和依赖 + ./deploy.sh db backup # 备份数据库 + ./deploy.sh db migrate # 执行数据库迁移 + +EOF +} + +#=============================================================================== +# 主入口 +#=============================================================================== + +main() { + local command=${1:-help} + shift || true + + case $command in + build) + do_build "$@" + ;; + start) + do_start "$@" + ;; + stop) + do_stop "$@" + ;; + restart) + do_restart "$@" + ;; + status) + do_status + ;; + logs) + do_logs "$@" + ;; + clean) + do_clean "$@" + ;; + deploy) + do_deploy "$@" + ;; + db) + do_db "$@" + ;; + help|--help|-h) + show_help + ;; + *) + log_error "未知命令: $command" + show_help + exit 1 + ;; + esac +} + +# 执行主函数 +main "$@" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8994414 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,319 @@ +#=============================================================================== +# iConsulting Docker Compose 配置 +# +# 服务架构: +# - 基础设施: PostgreSQL, Redis, Neo4j +# - API网关: Kong +# - 后端服务: conversation, user, payment, knowledge, evolution +# - 前端服务: nginx (托管 web-client 和 admin-client) +# +# 网络配置: +# - 对外网卡: 14.215.128.96 (用户访问) +# - 出口网卡: 154.84.135.121 (Claude API 调用) +# +#=============================================================================== + +version: '3.8' + +services: + #============================================================================= + # 基础设施服务 + #============================================================================= + + postgres: + image: postgres:15-alpine + container_name: iconsulting-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + POSTGRES_DB: ${POSTGRES_DB:-iconsulting} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - iconsulting-network + + redis: + image: redis:7-alpine + container_name: iconsulting-redis + restart: unless-stopped + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-redis123} + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - iconsulting-network + + neo4j: + image: neo4j:5-community + container_name: iconsulting-neo4j + restart: unless-stopped + environment: + NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-neo4j123} + NEO4J_PLUGINS: '["apoc"]' + NEO4J_dbms_memory_heap_max__size: 1G + ports: + - "7474:7474" # HTTP + - "7687:7687" # Bolt + volumes: + - neo4j_data:/data + - neo4j_logs:/logs + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - iconsulting-network + + #============================================================================= + # Kong API 网关 + #============================================================================= + + kong-database: + image: postgres:15-alpine + container_name: iconsulting-kong-db + restart: unless-stopped + environment: + POSTGRES_USER: kong + POSTGRES_PASSWORD: kong + POSTGRES_DB: kong + volumes: + - kong_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kong"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - iconsulting-network + + kong: + image: kong:3.4-alpine + container_name: iconsulting-kong + restart: unless-stopped + depends_on: + kong-database: + condition: service_healthy + environment: + KONG_DATABASE: postgres + KONG_PG_HOST: kong-database + KONG_PG_USER: kong + KONG_PG_PASSWORD: kong + KONG_PG_DATABASE: kong + KONG_PROXY_ACCESS_LOG: /dev/stdout + KONG_ADMIN_ACCESS_LOG: /dev/stdout + KONG_PROXY_ERROR_LOG: /dev/stderr + KONG_ADMIN_ERROR_LOG: /dev/stderr + KONG_ADMIN_LISTEN: 0.0.0.0:8001 + KONG_PROXY_LISTEN: 0.0.0.0:8000, 0.0.0.0:8443 ssl + ports: + - "8000:8000" # Proxy + - "8443:8443" # Proxy SSL + - "8001:8001" # Admin API + healthcheck: + test: ["CMD", "kong", "health"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - iconsulting-network + + #============================================================================= + # 后端微服务 + #============================================================================= + + user-service: + build: + context: . + dockerfile: packages/services/user-service/Dockerfile + container_name: iconsulting-user + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting} + REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379 + JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key} + JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d} + ports: + - "3001:3001" + networks: + - iconsulting-network + + payment-service: + build: + context: . + dockerfile: packages/services/payment-service/Dockerfile + container_name: iconsulting-payment + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3002 + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting} + REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379 + ALIPAY_APP_ID: ${ALIPAY_APP_ID} + ALIPAY_PRIVATE_KEY: ${ALIPAY_PRIVATE_KEY} + WECHAT_APP_ID: ${WECHAT_APP_ID} + WECHAT_MCH_ID: ${WECHAT_MCH_ID} + WECHAT_API_KEY: ${WECHAT_API_KEY} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + ports: + - "3002:3002" + networks: + - iconsulting-network + + knowledge-service: + build: + context: . + dockerfile: packages/services/knowledge-service/Dockerfile + container_name: iconsulting-knowledge + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + neo4j: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3003 + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting} + REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379 + NEO4J_URI: bolt://neo4j:7687 + NEO4J_USER: ${NEO4J_USER:-neo4j} + NEO4J_PASSWORD: ${NEO4J_PASSWORD:-neo4j123} + OPENAI_API_KEY: ${OPENAI_API_KEY} + ports: + - "3003:3003" + networks: + - iconsulting-network + + conversation-service: + build: + context: . + dockerfile: packages/services/conversation-service/Dockerfile + container_name: iconsulting-conversation + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + knowledge-service: + condition: service_started + environment: + NODE_ENV: production + PORT: 3004 + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting} + REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379 + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} + ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com} + KNOWLEDGE_SERVICE_URL: http://knowledge-service:3003 + # Claude API 出口配置 (如需指定出口IP,在宿主机配置路由) + ports: + - "3004:3004" + networks: + - iconsulting-network + + evolution-service: + build: + context: . + dockerfile: packages/services/evolution-service/Dockerfile + container_name: iconsulting-evolution + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + NODE_ENV: production + PORT: 3005 + DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-iconsulting} + REDIS_URL: redis://:${REDIS_PASSWORD:-redis123}@redis:6379 + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY} + ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com} + ports: + - "3005:3005" + networks: + - iconsulting-network + + #============================================================================= + # 前端 Nginx + #============================================================================= + + nginx: + image: nginx:alpine + container_name: iconsulting-nginx + restart: unless-stopped + depends_on: + - kong + ports: + - "80:80" + - "443:443" + volumes: + - ./packages/web-client/dist:/usr/share/nginx/html/web:ro + - ./packages/admin-client/dist:/usr/share/nginx/html/admin:ro + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/health"] + interval: 10s + timeout: 5s + retries: 3 + networks: + - iconsulting-network + +#=============================================================================== +# 网络配置 +#=============================================================================== + +networks: + iconsulting-network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + +#=============================================================================== +# 数据卷 +#=============================================================================== + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + neo4j_data: + driver: local + neo4j_logs: + driver: local + kong_data: + driver: local diff --git a/iconsulting部署架构.jpg b/iconsulting部署架构.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e20b5a8889baeb89f4ee4792da779bbebdcbbb97 GIT binary patch literal 158361 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 1 AND rating <= 5), + -- 用户反馈内容 + feedback TEXT, + -- 是否转化为付费(用于统计转化率) + has_converted BOOLEAN DEFAULT FALSE, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 结束时间 + ended_at TIMESTAMP WITH TIME ZONE +); + +COMMENT ON TABLE conversations IS '对话表 - 存储用户与AI助手的对话会话'; +COMMENT ON COLUMN conversations.category IS '主要咨询的移民类别,用于分析用户兴趣分布'; +COMMENT ON COLUMN conversations.has_converted IS '是否产生付费转化,用于计算转化率'; +COMMENT ON COLUMN conversations.rating IS '用户对对话的评分,1-5分'; + +CREATE INDEX idx_conversations_user_id ON conversations(user_id); +CREATE INDEX idx_conversations_status ON conversations(status); +CREATE INDEX idx_conversations_category ON conversations(category); +CREATE INDEX idx_conversations_created_at ON conversations(created_at DESC); +CREATE INDEX idx_conversations_has_converted ON conversations(has_converted) WHERE has_converted = TRUE; + +-- =========================================== +-- 消息表 (messages) +-- 存储对话中的每条消息 +-- =========================================== +CREATE TABLE messages ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 所属对话ID + conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, + -- 消息角色: user(用户), assistant(AI助手), system(系统) + role VARCHAR(20) NOT NULL CHECK (role IN ('user', 'assistant', 'system')), + -- 消息类型 + type VARCHAR(30) NOT NULL DEFAULT 'TEXT' + CHECK (type IN ('TEXT', 'TOOL_CALL', 'TOOL_RESULT', 'PAYMENT_REQUEST', 'ASSESSMENT_START', 'ASSESSMENT_RESULT')), + -- 消息内容 + content TEXT NOT NULL, + -- 元数据(工具调用信息、Token使用等) + metadata JSONB, + -- Token使用量 + input_tokens INT, + output_tokens INT, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE messages IS '消息表 - 存储对话中的每条消息'; +COMMENT ON COLUMN messages.role IS '消息角色: user=用户发送, assistant=AI回复, system=系统消息'; +COMMENT ON COLUMN messages.type IS '消息类型,用于区分普通文本、工具调用、支付请求等'; +COMMENT ON COLUMN messages.metadata IS '元数据,存储工具调用参数、结果等扩展信息'; + +CREATE INDEX idx_messages_conversation_id ON messages(conversation_id); +CREATE INDEX idx_messages_role ON messages(role); +CREATE INDEX idx_messages_created_at ON messages(created_at); + +-- =========================================== +-- 订单表 (orders) +-- 存储用户购买的服务订单 +-- =========================================== +CREATE TABLE orders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 订单号(用于展示,格式:ORD + 年月日 + 序号) + order_no VARCHAR(50) UNIQUE, + -- 所属用户ID + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + -- 关联的对话ID(订单从哪个对话产生) + conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL, + -- 服务类型: ASSESSMENT(评估), CONSULTATION(咨询), DOCUMENT_REVIEW(文档审核) + service_type VARCHAR(50) NOT NULL + CHECK (service_type IN ('ASSESSMENT', 'CONSULTATION', 'DOCUMENT_REVIEW')), + -- 服务对应的移民类别 + service_category VARCHAR(50), + -- 订单金额 + amount DECIMAL(10, 2) NOT NULL, + -- 货币类型 + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + -- 订单状态 + status VARCHAR(20) NOT NULL DEFAULT 'CREATED' + CHECK (status IN ('CREATED', 'PENDING_PAYMENT', 'PAID', 'PROCESSING', 'COMPLETED', 'CANCELLED', 'REFUNDED')), + -- 支付方式: ALIPAY, WECHAT, CREDIT_CARD + payment_method VARCHAR(20), + -- 关联的支付ID + payment_id UUID, + -- 优惠券ID + coupon_id UUID, + -- 优惠金额 + discount_amount DECIMAL(10, 2) DEFAULT 0, + -- 实付金额 + paid_amount DECIMAL(10, 2), + -- 支付时间 + paid_at TIMESTAMP WITH TIME ZONE, + -- 完成时间 + completed_at TIMESTAMP WITH TIME ZONE, + -- 取消时间 + cancelled_at TIMESTAMP WITH TIME ZONE, + -- 取消原因 + cancel_reason TEXT, + -- 退款时间 + refunded_at TIMESTAMP WITH TIME ZONE, + -- 退款原因 + refund_reason TEXT, + -- 订单备注 + notes TEXT, + -- 扩展元数据(评估结果等) + metadata JSONB, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE orders IS '订单表 - 存储用户购买的服务订单'; +COMMENT ON COLUMN orders.order_no IS '订单号,用于对外展示,格式:ORD + 年月日 + 序号'; +COMMENT ON COLUMN orders.service_type IS '服务类型: ASSESSMENT=移民评估, CONSULTATION=付费咨询, DOCUMENT_REVIEW=文档审核'; +COMMENT ON COLUMN orders.metadata IS '扩展数据,如评估结果详情等'; + +CREATE INDEX idx_orders_order_no ON orders(order_no); +CREATE INDEX idx_orders_user_id ON orders(user_id); +CREATE INDEX idx_orders_status ON orders(status); +CREATE INDEX idx_orders_service_type ON orders(service_type); +CREATE INDEX idx_orders_created_at ON orders(created_at DESC); +CREATE INDEX idx_orders_paid_at ON orders(paid_at) WHERE paid_at IS NOT NULL; + +-- =========================================== +-- 支付表 (payments) +-- 存储支付交易记录 +-- =========================================== +CREATE TABLE payments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 关联的订单ID + order_id UUID REFERENCES orders(id) ON DELETE CASCADE, + -- 支付方式 + method VARCHAR(20) NOT NULL CHECK (method IN ('ALIPAY', 'WECHAT', 'CREDIT_CARD')), + -- 支付金额 + amount DECIMAL(10, 2) NOT NULL, + -- 货币类型 + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + -- 支付状态 + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'REFUNDED', 'CANCELLED')), + -- 第三方交易号(支付宝/微信/Stripe的交易ID) + transaction_id VARCHAR(255), + -- 支付二维码URL(支付宝/微信) + qr_code_url TEXT, + -- 支付页面URL(Stripe) + payment_url TEXT, + -- 支付过期时间 + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- 实际支付时间 + paid_at TIMESTAMP WITH TIME ZONE, + -- 失败原因 + failed_reason TEXT, + -- 第三方回调原始数据(用于对账和排查问题) + callback_payload JSONB, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE payments IS '支付表 - 存储支付交易记录'; +COMMENT ON COLUMN payments.transaction_id IS '第三方支付平台的交易号,用于对账'; +COMMENT ON COLUMN payments.callback_payload IS '支付回调的原始数据,用于问题排查和对账'; + +CREATE INDEX idx_payments_order_id ON payments(order_id); +CREATE INDEX idx_payments_status ON payments(status); +CREATE INDEX idx_payments_method ON payments(method); +CREATE INDEX idx_payments_transaction_id ON payments(transaction_id); +CREATE INDEX idx_payments_created_at ON payments(created_at); + +-- =========================================== +-- 分类账/财务流水表 (ledger_entries) +-- 记录所有资金流动,支持财务对账和报表 +-- =========================================== +CREATE TABLE ledger_entries ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 流水号(唯一标识) + entry_no VARCHAR(50) UNIQUE NOT NULL, + -- 流水类型 + entry_type VARCHAR(30) NOT NULL + CHECK (entry_type IN ('INCOME', 'REFUND', 'COMMISSION', 'WITHDRAWAL', 'ADJUSTMENT')), + -- 关联订单ID + order_id UUID REFERENCES orders(id) ON DELETE SET NULL, + -- 关联支付ID + payment_id UUID REFERENCES payments(id) ON DELETE SET NULL, + -- 关联用户ID + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + -- 金额(正数为收入,负数为支出) + amount DECIMAL(12, 2) NOT NULL, + -- 货币类型 + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + -- 账户余额(流水后的账户余额快照) + balance_after DECIMAL(12, 2), + -- 交易渠道: ALIPAY, WECHAT, STRIPE, BANK, MANUAL + channel VARCHAR(30), + -- 第三方交易号 + transaction_id VARCHAR(255), + -- 业务类型: ASSESSMENT, CONSULTATION, DOCUMENT_REVIEW + business_type VARCHAR(50), + -- 业务类别: QMAS, GEP, IANG, TTPS, CIES, TECHTAS + business_category VARCHAR(50), + -- 摘要/描述 + description TEXT, + -- 备注 + notes TEXT, + -- 状态: PENDING(待确认), CONFIRMED(已确认), CANCELLED(已取消) + status VARCHAR(20) NOT NULL DEFAULT 'CONFIRMED' + CHECK (status IN ('PENDING', 'CONFIRMED', 'CANCELLED')), + -- 确认时间 + confirmed_at TIMESTAMP WITH TIME ZONE, + -- 确认人(管理员ID) + confirmed_by UUID, + -- 会计期间(格式:YYYY-MM) + accounting_period VARCHAR(7), + -- 创建时间(流水发生时间) + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE ledger_entries IS '分类账/财务流水表 - 记录所有资金流动,支持财务对账和报表生成'; +COMMENT ON COLUMN ledger_entries.entry_no IS '流水号,格式:LE + 年月日 + 序号'; +COMMENT ON COLUMN ledger_entries.entry_type IS '流水类型: INCOME=收入, REFUND=退款, COMMISSION=佣金, WITHDRAWAL=提现, ADJUSTMENT=调整'; +COMMENT ON COLUMN ledger_entries.balance_after IS '该笔流水后的账户余额快照,用于对账'; +COMMENT ON COLUMN ledger_entries.accounting_period IS '会计期间,格式YYYY-MM,用于月度报表'; + +CREATE INDEX idx_ledger_entries_entry_no ON ledger_entries(entry_no); +CREATE INDEX idx_ledger_entries_entry_type ON ledger_entries(entry_type); +CREATE INDEX idx_ledger_entries_order_id ON ledger_entries(order_id); +CREATE INDEX idx_ledger_entries_user_id ON ledger_entries(user_id); +CREATE INDEX idx_ledger_entries_status ON ledger_entries(status); +CREATE INDEX idx_ledger_entries_accounting_period ON ledger_entries(accounting_period); +CREATE INDEX idx_ledger_entries_created_at ON ledger_entries(created_at DESC); +CREATE INDEX idx_ledger_entries_business_type ON ledger_entries(business_type); + +-- =========================================== +-- 日统计表 (daily_statistics) +-- 预聚合的每日统计数据,用于快速报表查询 +-- =========================================== +CREATE TABLE daily_statistics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 统计日期 + stat_date DATE NOT NULL, + -- 统计维度: OVERALL(总体), CHANNEL(渠道), CATEGORY(移民类别) + dimension VARCHAR(30) NOT NULL DEFAULT 'OVERALL', + -- 维度值(如渠道名称、类别代码) + dimension_value VARCHAR(50), + + -- ===== 用户统计 ===== + -- 新增用户数 + new_users INT DEFAULT 0, + -- 新增注册用户数 + new_registered_users INT DEFAULT 0, + -- 活跃用户数 + active_users INT DEFAULT 0, + + -- ===== 对话统计 ===== + -- 新增对话数 + new_conversations INT DEFAULT 0, + -- 总消息数 + total_messages INT DEFAULT 0, + -- 用户消息数 + user_messages INT DEFAULT 0, + -- AI消息数 + assistant_messages INT DEFAULT 0, + -- 平均对话轮次 + avg_conversation_turns DECIMAL(10, 2) DEFAULT 0, + + -- ===== 订单统计 ===== + -- 新增订单数 + new_orders INT DEFAULT 0, + -- 支付成功订单数 + paid_orders INT DEFAULT 0, + -- 订单总金额 + total_order_amount DECIMAL(12, 2) DEFAULT 0, + -- 实收金额 + total_paid_amount DECIMAL(12, 2) DEFAULT 0, + -- 退款订单数 + refunded_orders INT DEFAULT 0, + -- 退款金额 + refund_amount DECIMAL(12, 2) DEFAULT 0, + + -- ===== 转化统计 ===== + -- 咨询转化数(对话转订单) + conversion_count INT DEFAULT 0, + -- 转化率 + conversion_rate DECIMAL(5, 4) DEFAULT 0, + + -- ===== Token消耗统计 ===== + -- 输入Token总数 + total_input_tokens BIGINT DEFAULT 0, + -- 输出Token总数 + total_output_tokens BIGINT DEFAULT 0, + -- 预估API成本(美元) + estimated_api_cost DECIMAL(10, 4) DEFAULT 0, + + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + + -- 唯一约束:同一天同一维度同一维度值只有一条记录 + UNIQUE(stat_date, dimension, dimension_value) +); + +COMMENT ON TABLE daily_statistics IS '日统计表 - 预聚合的每日统计数据,支持多维度分析'; +COMMENT ON COLUMN daily_statistics.dimension IS '统计维度: OVERALL=总体统计, CHANNEL=按渠道, CATEGORY=按移民类别'; +COMMENT ON COLUMN daily_statistics.conversion_rate IS '转化率 = 支付订单数 / 新对话数'; +COMMENT ON COLUMN daily_statistics.estimated_api_cost IS '预估Claude API成本,基于Token消耗计算'; + +CREATE INDEX idx_daily_statistics_stat_date ON daily_statistics(stat_date DESC); +CREATE INDEX idx_daily_statistics_dimension ON daily_statistics(dimension, dimension_value); + +-- =========================================== +-- 月度财务报表 (monthly_financial_reports) +-- 月度汇总的财务数据 +-- =========================================== +CREATE TABLE monthly_financial_reports ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 报表月份(格式:YYYY-MM) + report_month VARCHAR(7) NOT NULL UNIQUE, + + -- ===== 收入统计 ===== + -- 总收入 + total_revenue DECIMAL(12, 2) DEFAULT 0, + -- 评估服务收入 + assessment_revenue DECIMAL(12, 2) DEFAULT 0, + -- 咨询服务收入 + consultation_revenue DECIMAL(12, 2) DEFAULT 0, + -- 其他收入 + other_revenue DECIMAL(12, 2) DEFAULT 0, + + -- ===== 退款统计 ===== + -- 总退款 + total_refunds DECIMAL(12, 2) DEFAULT 0, + -- 净收入(总收入 - 退款) + net_revenue DECIMAL(12, 2) DEFAULT 0, + + -- ===== 成本统计 ===== + -- API成本(Claude) + api_cost DECIMAL(10, 2) DEFAULT 0, + -- 支付手续费 + payment_fees DECIMAL(10, 2) DEFAULT 0, + -- 其他成本 + other_costs DECIMAL(10, 2) DEFAULT 0, + -- 总成本 + total_costs DECIMAL(10, 2) DEFAULT 0, + + -- ===== 利润统计 ===== + -- 毛利润 + gross_profit DECIMAL(12, 2) DEFAULT 0, + -- 毛利率 + gross_margin DECIMAL(5, 4) DEFAULT 0, + + -- ===== 订单统计 ===== + -- 总订单数 + total_orders INT DEFAULT 0, + -- 成功订单数 + successful_orders INT DEFAULT 0, + -- 平均订单金额 + avg_order_amount DECIMAL(10, 2) DEFAULT 0, + + -- ===== 按类别收入明细(JSONB) ===== + revenue_by_category JSONB DEFAULT '{}', + + -- ===== 按渠道收入明细(JSONB) ===== + revenue_by_channel JSONB DEFAULT '{}', + + -- 报表状态: DRAFT(草稿), CONFIRMED(已确认), LOCKED(已锁定) + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT' + CHECK (status IN ('DRAFT', 'CONFIRMED', 'LOCKED')), + -- 确认人 + confirmed_by UUID, + -- 确认时间 + confirmed_at TIMESTAMP WITH TIME ZONE, + -- 备注 + notes TEXT, + + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE monthly_financial_reports IS '月度财务报表 - 月度汇总的财务数据,支持确认和锁定'; +COMMENT ON COLUMN monthly_financial_reports.report_month IS '报表月份,格式YYYY-MM'; +COMMENT ON COLUMN monthly_financial_reports.revenue_by_category IS '按移民类别的收入明细,JSON格式'; +COMMENT ON COLUMN monthly_financial_reports.status IS 'DRAFT=草稿可修改, CONFIRMED=已确认, LOCKED=已锁定不可修改'; + +CREATE INDEX idx_monthly_financial_reports_month ON monthly_financial_reports(report_month DESC); +CREATE INDEX idx_monthly_financial_reports_status ON monthly_financial_reports(status); + +-- =========================================== +-- 审计日志表 (audit_logs) +-- 记录所有重要操作,用于安全审计和问题追踪 +-- =========================================== +CREATE TABLE audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 操作者ID(用户或管理员) + actor_id UUID, + -- 操作者类型: USER, ADMIN, SYSTEM + actor_type VARCHAR(20) NOT NULL CHECK (actor_type IN ('USER', 'ADMIN', 'SYSTEM')), + -- 操作者名称/标识 + actor_name VARCHAR(100), + -- 操作类型 + action VARCHAR(50) NOT NULL, + -- 操作对象类型(表名) + entity_type VARCHAR(50) NOT NULL, + -- 操作对象ID + entity_id UUID, + -- 操作前的数据快照 + old_values JSONB, + -- 操作后的数据快照 + new_values JSONB, + -- 变更的字段列表 + changed_fields TEXT[], + -- 操作描述 + description TEXT, + -- 客户端IP地址 + ip_address INET, + -- 用户代理(浏览器信息) + user_agent TEXT, + -- 请求ID(用于追踪) + request_id VARCHAR(100), + -- 操作结果: SUCCESS, FAILED + result VARCHAR(20) DEFAULT 'SUCCESS' CHECK (result IN ('SUCCESS', 'FAILED')), + -- 错误信息(如果失败) + error_message TEXT, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE audit_logs IS '审计日志表 - 记录所有重要操作,用于安全审计'; +COMMENT ON COLUMN audit_logs.action IS '操作类型,如: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, PAYMENT等'; +COMMENT ON COLUMN audit_logs.old_values IS '操作前的数据快照,用于审计和回滚'; +COMMENT ON COLUMN audit_logs.new_values IS '操作后的数据快照'; + +CREATE INDEX idx_audit_logs_actor_id ON audit_logs(actor_id); +CREATE INDEX idx_audit_logs_actor_type ON audit_logs(actor_type); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_entity_type ON audit_logs(entity_type); +CREATE INDEX idx_audit_logs_entity_id ON audit_logs(entity_id); +CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC); +-- 按时间范围查询优化 +CREATE INDEX idx_audit_logs_created_at_brin ON audit_logs USING BRIN(created_at); + +-- =========================================== +-- 发件箱表 (outbox) +-- 事务发件箱模式,确保消息可靠发送到Kafka +-- =========================================== +CREATE TABLE outbox ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 聚合根类型(如:Order, Payment, User) + aggregate_type VARCHAR(100) NOT NULL, + -- 聚合根ID + aggregate_id UUID NOT NULL, + -- 事件类型(如:OrderCreated, PaymentCompleted) + event_type VARCHAR(100) NOT NULL, + -- 事件负载(JSON格式的事件数据) + payload JSONB NOT NULL, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 处理时间(消息发送到Kafka的时间) + processed_at TIMESTAMP WITH TIME ZONE, + -- 处理次数(用于重试机制) + retry_count INT DEFAULT 0, + -- 最后错误信息 + last_error TEXT +); + +COMMENT ON TABLE outbox IS '发件箱表 - 事务发件箱模式,确保事件消息可靠发送'; +COMMENT ON COLUMN outbox.aggregate_type IS '聚合根类型,对应领域模型'; +COMMENT ON COLUMN outbox.event_type IS '领域事件类型'; + +CREATE INDEX idx_outbox_unprocessed ON outbox(created_at) WHERE processed_at IS NULL; +CREATE INDEX idx_outbox_aggregate ON outbox(aggregate_type, aggregate_id); + +-- =========================================== +-- 知识文档表 (documents) +-- 存储移民知识库文档,用于RAG检索 +-- =========================================== +CREATE TABLE documents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 文档标题 + title VARCHAR(255) NOT NULL, + -- 文档内容(原始文本) + content TEXT NOT NULL, + -- 移民类别: QMAS, GEP, IANG, TTPS, CIES, TECHTAS, GENERAL + category VARCHAR(50), + -- 标签数组 + tags TEXT[], + -- 来源名称 + source VARCHAR(255), + -- 来源URL + source_url VARCHAR(500), + -- 文档元数据 + metadata JSONB, + -- 文档版本 + version INT DEFAULT 1, + -- 是否启用 + is_active BOOLEAN DEFAULT TRUE, + -- 最后验证时间(确认信息仍然有效) + last_verified_at TIMESTAMP WITH TIME ZONE, + -- 验证人 + verified_by UUID, + -- 创建人 + created_by UUID, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE documents IS '知识文档表 - 存储移民知识库文档,用于RAG检索增强'; +COMMENT ON COLUMN documents.category IS '移民类别,GENERAL表示通用知识'; +COMMENT ON COLUMN documents.last_verified_at IS '最后验证时间,确保信息的时效性'; + +CREATE INDEX idx_documents_category ON documents(category); +CREATE INDEX idx_documents_is_active ON documents(is_active); +CREATE INDEX idx_documents_tags ON documents USING GIN(tags); +CREATE INDEX idx_documents_created_at ON documents(created_at); + +-- =========================================== +-- 文档向量嵌入表 (document_embeddings) +-- 存储文档分块的向量嵌入,用于相似度搜索 +-- =========================================== +CREATE TABLE document_embeddings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 所属文档ID + document_id UUID REFERENCES documents(id) ON DELETE CASCADE, + -- 分块序号 + chunk_index INT NOT NULL, + -- 分块内容 + content TEXT NOT NULL, + -- 向量嵌入(1536维,对应text-embedding-3-small) + embedding vector(1536), + -- 分块元数据 + metadata JSONB, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE document_embeddings IS '文档向量嵌入表 - 存储文档分块的向量,用于语义搜索'; +COMMENT ON COLUMN document_embeddings.embedding IS '1536维向量,使用text-embedding-3-small模型生成'; + +CREATE INDEX idx_document_embeddings_document_id ON document_embeddings(document_id); +-- IVFFlat索引,用于快速向量相似度搜索 +CREATE INDEX idx_document_embeddings_embedding ON document_embeddings + USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- =========================================== +-- 系统配置表 (system_configs) +-- 存储系统配置项,支持动态调整 +-- =========================================== +CREATE TABLE system_configs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 配置键(唯一) + key VARCHAR(100) UNIQUE NOT NULL, + -- 配置值(JSON格式) + value JSONB NOT NULL, + -- 配置分组: SYSTEM, PROMPT, PAYMENT, NOTIFICATION, FEATURE + config_group VARCHAR(50) DEFAULT 'SYSTEM', + -- 配置描述 + description TEXT, + -- 是否敏感信息(敏感信息不在日志中显示完整值) + is_sensitive BOOLEAN DEFAULT FALSE, + -- 更新人 + updated_by UUID, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE system_configs IS '系统配置表 - 存储可动态调整的系统配置'; +COMMENT ON COLUMN system_configs.config_group IS '配置分组,便于管理和查询'; +COMMENT ON COLUMN system_configs.is_sensitive IS '敏感配置在日志中脱敏显示'; + +CREATE INDEX idx_system_configs_group ON system_configs(config_group); + +-- =========================================== +-- 管理员用户表 (admin_users) +-- 存储后台管理员信息 +-- =========================================== +CREATE TABLE admin_users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 用户名(唯一) + username VARCHAR(100) UNIQUE NOT NULL, + -- 邮箱 + email VARCHAR(255), + -- 密码哈希(bcrypt) + password_hash VARCHAR(255) NOT NULL, + -- 角色: SUPER_ADMIN, ADMIN, OPERATOR, VIEWER + role VARCHAR(50) NOT NULL CHECK (role IN ('SUPER_ADMIN', 'ADMIN', 'OPERATOR', 'VIEWER')), + -- 姓名 + display_name VARCHAR(100), + -- 手机号 + phone VARCHAR(20), + -- 是否启用 + is_active BOOLEAN DEFAULT TRUE, + -- 最后登录时间 + last_login_at TIMESTAMP WITH TIME ZONE, + -- 最后登录IP + last_login_ip INET, + -- 登录失败次数(用于账户锁定) + failed_login_attempts INT DEFAULT 0, + -- 账户锁定时间 + locked_until TIMESTAMP WITH TIME ZONE, + -- 备注 + notes TEXT, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE admin_users IS '管理员用户表 - 存储后台管理员信息'; +COMMENT ON COLUMN admin_users.role IS 'SUPER_ADMIN=超级管理员, ADMIN=管理员, OPERATOR=操作员, VIEWER=只读用户'; +COMMENT ON COLUMN admin_users.failed_login_attempts IS '连续登录失败次数,超过阈值锁定账户'; + +CREATE INDEX idx_admin_users_username ON admin_users(username); +CREATE INDEX idx_admin_users_email ON admin_users(email); +CREATE INDEX idx_admin_users_role ON admin_users(role); +CREATE INDEX idx_admin_users_is_active ON admin_users(is_active); + +-- =========================================== +-- 经验库表 (experiences) +-- 存储从对话中提取的经验,用于系统自我进化 +-- =========================================== +CREATE TABLE experiences ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 经验类型 + type VARCHAR(50) NOT NULL + CHECK (type IN ('FAQ_PATTERN', 'USER_CONCERN', 'EFFECTIVE_RESPONSE', 'CONVERSION_PATTERN', 'CONFUSION_POINT')), + -- 模式描述 + pattern TEXT NOT NULL, + -- 洞察/经验总结 + insight TEXT NOT NULL, + -- 出现频率 + frequency INT DEFAULT 1, + -- 置信度(0-1) + confidence DECIMAL(3, 2) DEFAULT 0.5 CHECK (confidence >= 0 AND confidence <= 1), + -- 相关移民类别 + category VARCHAR(50), + -- 来源对话ID列表 + source_conversation_ids UUID[], + -- 示例问题 + sample_questions TEXT[], + -- 示例回答 + sample_responses TEXT[], + -- 元数据 + metadata JSONB, + -- 是否已应用 + is_applied BOOLEAN DEFAULT FALSE, + -- 应用时间 + applied_at TIMESTAMP WITH TIME ZONE, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE experiences IS '经验库表 - 存储从对话中提取的经验,支持系统自我进化'; +COMMENT ON COLUMN experiences.type IS '经验类型: FAQ_PATTERN=常见问题模式, USER_CONCERN=用户关注点, EFFECTIVE_RESPONSE=有效回答, CONVERSION_PATTERN=转化模式, CONFUSION_POINT=困惑点'; +COMMENT ON COLUMN experiences.confidence IS '置信度,值越高表示该经验越可靠'; + +CREATE INDEX idx_experiences_type ON experiences(type); +CREATE INDEX idx_experiences_category ON experiences(category); +CREATE INDEX idx_experiences_confidence ON experiences(confidence DESC); +CREATE INDEX idx_experiences_is_applied ON experiences(is_applied); + +-- =========================================== +-- 进化日志表 (evolution_logs) +-- 记录系统配置和行为的变更历史 +-- =========================================== +CREATE TABLE evolution_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 触发人(管理员ID) + triggered_by UUID REFERENCES admin_users(id), + -- 变更类型 + type VARCHAR(50) NOT NULL + CHECK (type IN ('PROMPT_UPDATE', 'KNOWLEDGE_UPDATE', 'RULE_UPDATE', 'BEHAVIOR_UPDATE')), + -- 变更状态 + status VARCHAR(20) NOT NULL DEFAULT 'PROPOSED' + CHECK (status IN ('PROPOSED', 'APPROVED', 'APPLIED', 'ROLLED_BACK', 'REJECTED')), + -- 变更内容 + changes JSONB NOT NULL, + -- 变更原因/说明 + reason TEXT, + -- 关联的经验ID列表 + related_experience_ids UUID[], + -- 审批人 + approved_by UUID REFERENCES admin_users(id), + -- 审批时间 + approved_at TIMESTAMP WITH TIME ZONE, + -- 应用时间 + applied_at TIMESTAMP WITH TIME ZONE, + -- 回滚时间 + rollback_at TIMESTAMP WITH TIME ZONE, + -- 回滚原因 + rollback_reason TEXT, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE evolution_logs IS '进化日志表 - 记录系统配置和行为的变更历史'; +COMMENT ON COLUMN evolution_logs.type IS '变更类型: PROMPT_UPDATE=提示词更新, KNOWLEDGE_UPDATE=知识库更新, RULE_UPDATE=规则更新, BEHAVIOR_UPDATE=行为更新'; +COMMENT ON COLUMN evolution_logs.status IS '变更状态流转: PROPOSED -> APPROVED -> APPLIED, 可回滚为ROLLED_BACK'; + +CREATE INDEX idx_evolution_logs_status ON evolution_logs(status); +CREATE INDEX idx_evolution_logs_type ON evolution_logs(type); +CREATE INDEX idx_evolution_logs_triggered_by ON evolution_logs(triggered_by); +CREATE INDEX idx_evolution_logs_created_at ON evolution_logs(created_at DESC); + +-- =========================================== +-- 验证码表 (verification_codes) +-- 存储手机验证码 +-- =========================================== +CREATE TABLE verification_codes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 手机号 + phone VARCHAR(20) NOT NULL, + -- 验证码 + code VARCHAR(10) NOT NULL, + -- 过期时间 + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + -- 是否已使用 + is_used BOOLEAN DEFAULT FALSE, + -- 使用时间 + used_at TIMESTAMP WITH TIME ZONE, + -- IP地址(用于防刷) + ip_address INET, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE verification_codes IS '验证码表 - 存储手机验证码,支持防刷和过期机制'; + +CREATE INDEX idx_verification_codes_phone ON verification_codes(phone); +CREATE INDEX idx_verification_codes_expires_at ON verification_codes(expires_at); +CREATE INDEX idx_verification_codes_ip ON verification_codes(ip_address); + +-- =========================================== +-- 服务定价表 (service_pricing) +-- 存储各类服务的定价信息 +-- =========================================== +CREATE TABLE service_pricing ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 服务类型 + service_type VARCHAR(50) NOT NULL + CHECK (service_type IN ('ASSESSMENT', 'CONSULTATION', 'DOCUMENT_REVIEW')), + -- 移民类别 + category VARCHAR(50), + -- 价格 + price DECIMAL(10, 2) NOT NULL, + -- 原价(用于显示折扣) + original_price DECIMAL(10, 2), + -- 货币 + currency VARCHAR(10) NOT NULL DEFAULT 'CNY', + -- 服务描述 + description TEXT, + -- 服务详情(JSON格式) + details JSONB, + -- 是否启用 + is_active BOOLEAN DEFAULT TRUE, + -- 生效开始时间 + effective_from TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 生效结束时间 + effective_until TIMESTAMP WITH TIME ZONE, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 唯一约束 + UNIQUE(service_type, category) +); + +COMMENT ON TABLE service_pricing IS '服务定价表 - 存储各类服务的定价信息'; +COMMENT ON COLUMN service_pricing.original_price IS '原价,用于显示折扣效果'; +COMMENT ON COLUMN service_pricing.effective_from IS '价格生效开始时间,支持定时调价'; + +CREATE INDEX idx_service_pricing_service_type ON service_pricing(service_type); +CREATE INDEX idx_service_pricing_is_active ON service_pricing(is_active); + +-- =========================================== +-- 优惠券表 (coupons) +-- 存储优惠券信息 +-- =========================================== +CREATE TABLE coupons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 优惠券码 + code VARCHAR(50) UNIQUE NOT NULL, + -- 优惠券名称 + name VARCHAR(100) NOT NULL, + -- 优惠类型: FIXED(固定金额), PERCENTAGE(百分比) + discount_type VARCHAR(20) NOT NULL CHECK (discount_type IN ('FIXED', 'PERCENTAGE')), + -- 优惠值(固定金额或百分比) + discount_value DECIMAL(10, 2) NOT NULL, + -- 最低消费金额 + min_amount DECIMAL(10, 2) DEFAULT 0, + -- 最高优惠金额(百分比优惠时的上限) + max_discount DECIMAL(10, 2), + -- 适用服务类型(为空表示全部适用) + applicable_services TEXT[], + -- 适用移民类别(为空表示全部适用) + applicable_categories TEXT[], + -- 总发行量 + total_quantity INT, + -- 已使用数量 + used_quantity INT DEFAULT 0, + -- 每人限用次数 + per_user_limit INT DEFAULT 1, + -- 生效时间 + valid_from TIMESTAMP WITH TIME ZONE NOT NULL, + -- 失效时间 + valid_until TIMESTAMP WITH TIME ZONE NOT NULL, + -- 是否启用 + is_active BOOLEAN DEFAULT TRUE, + -- 备注 + notes TEXT, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE coupons IS '优惠券表 - 存储优惠券信息,支持固定金额和百分比折扣'; + +CREATE INDEX idx_coupons_code ON coupons(code); +CREATE INDEX idx_coupons_is_active ON coupons(is_active); +CREATE INDEX idx_coupons_valid_until ON coupons(valid_until); + +-- =========================================== +-- 用户优惠券表 (user_coupons) +-- 存储用户领取/使用的优惠券 +-- =========================================== +CREATE TABLE user_coupons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 用户ID + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + -- 优惠券ID + coupon_id UUID REFERENCES coupons(id) ON DELETE CASCADE, + -- 状态: AVAILABLE(可用), USED(已使用), EXPIRED(已过期) + status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE' + CHECK (status IN ('AVAILABLE', 'USED', 'EXPIRED')), + -- 使用的订单ID + used_order_id UUID REFERENCES orders(id) ON DELETE SET NULL, + -- 使用时间 + used_at TIMESTAMP WITH TIME ZONE, + -- 领取时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE user_coupons IS '用户优惠券表 - 记录用户领取和使用优惠券的情况'; + +CREATE INDEX idx_user_coupons_user_id ON user_coupons(user_id); +CREATE INDEX idx_user_coupons_coupon_id ON user_coupons(coupon_id); +CREATE INDEX idx_user_coupons_status ON user_coupons(status); + +-- =========================================== +-- 插入默认数据 +-- =========================================== + +-- 默认管理员用户 (密码: admin123) +INSERT INTO admin_users (username, email, password_hash, role, display_name) +VALUES ('admin', 'admin@iconsulting.com', '$2b$10$rqKu.wB1E8z9vQzKK0FZMuXz.B7xPc5S7BUTr6G9TkVdEX5C0Q.Wy', 'SUPER_ADMIN', '系统管理员'); + +-- 默认系统配置 +INSERT INTO system_configs (key, value, config_group, description) VALUES +('system_prompt_identity', '"专业、友善、耐心的香港移民顾问"', 'PROMPT', '系统身份定位'), +('system_prompt_style', '"专业但不生硬,用简洁明了的语言解答"', 'PROMPT', '对话风格'), +('system_prompt_rules', '["只回答移民相关问题", "复杂评估建议付费服务", "不做法律承诺"]', 'PROMPT', '系统行为规则'), +('max_free_messages_per_day', '50', 'SYSTEM', '每日免费消息数限制'), +('max_conversation_context_messages', '20', 'SYSTEM', '对话上下文最大消息数'), +('assessment_price_default', '99', 'PAYMENT', '默认评估价格(元)'), +('payment_timeout_minutes', '30', 'PAYMENT', '支付超时时间(分钟)'), +('sms_rate_limit_per_hour', '5', 'SYSTEM', '每小时短信发送限制'), +('enable_anonymous_chat', 'true', 'FEATURE', '是否允许匿名用户聊天'), +('require_phone_for_payment', 'true', 'FEATURE', '支付时是否要求手机验证'); + +-- 默认服务定价 +INSERT INTO service_pricing (service_type, category, price, original_price, currency, description) VALUES +('ASSESSMENT', 'QMAS', 99.00, 199.00, 'CNY', '优才计划移民资格评估'), +('ASSESSMENT', 'GEP', 99.00, 199.00, 'CNY', '专才计划移民资格评估'), +('ASSESSMENT', 'IANG', 79.00, 149.00, 'CNY', '留学IANG移民资格评估'), +('ASSESSMENT', 'TTPS', 99.00, 199.00, 'CNY', '高才通移民资格评估'), +('ASSESSMENT', 'CIES', 199.00, 399.00, 'CNY', '投资移民资格评估'), +('ASSESSMENT', 'TECHTAS', 99.00, 199.00, 'CNY', '科技人才移民资格评估'); + +-- =========================================== +-- 创建函数和触发器 +-- =========================================== + +-- 更新 updated_at 时间戳的通用函数 +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- 为所有需要的表添加更新时间触发器 +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_conversations_updated_at BEFORE UPDATE ON conversations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_payments_updated_at BEFORE UPDATE ON payments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_documents_updated_at BEFORE UPDATE ON documents FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_system_configs_updated_at BEFORE UPDATE ON system_configs FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_admin_users_updated_at BEFORE UPDATE ON admin_users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_experiences_updated_at BEFORE UPDATE ON experiences FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_service_pricing_updated_at BEFORE UPDATE ON service_pricing FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_coupons_updated_at BEFORE UPDATE ON coupons FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_ledger_entries_updated_at BEFORE UPDATE ON ledger_entries FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_daily_statistics_updated_at BEFORE UPDATE ON daily_statistics FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_monthly_financial_reports_updated_at BEFORE UPDATE ON monthly_financial_reports FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- 更新对话消息计数的函数 +CREATE OR REPLACE FUNCTION update_conversation_message_count() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE conversations + SET + message_count = message_count + 1, + user_message_count = user_message_count + CASE WHEN NEW.role = 'user' THEN 1 ELSE 0 END, + assistant_message_count = assistant_message_count + CASE WHEN NEW.role = 'assistant' THEN 1 ELSE 0 END, + total_input_tokens = total_input_tokens + COALESCE(NEW.input_tokens, 0), + total_output_tokens = total_output_tokens + COALESCE(NEW.output_tokens, 0) + WHERE id = NEW.conversation_id; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER increment_conversation_message_count + AFTER INSERT ON messages + FOR EACH ROW + EXECUTE FUNCTION update_conversation_message_count(); + +-- 生成订单号的函数 +CREATE OR REPLACE FUNCTION generate_order_no() +RETURNS TRIGGER AS $$ +DECLARE + seq_val INT; +BEGIN + -- 获取当天的序号 + SELECT COALESCE(MAX(CAST(SUBSTRING(order_no FROM 12) AS INT)), 0) + 1 INTO seq_val + FROM orders + WHERE order_no LIKE 'ORD' || TO_CHAR(NOW(), 'YYYYMMDD') || '%'; + + NEW.order_no := 'ORD' || TO_CHAR(NOW(), 'YYYYMMDD') || LPAD(seq_val::TEXT, 6, '0'); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER generate_order_no_trigger + BEFORE INSERT ON orders + FOR EACH ROW + WHEN (NEW.order_no IS NULL) + EXECUTE FUNCTION generate_order_no(); + +-- 生成分类账流水号的函数 +CREATE OR REPLACE FUNCTION generate_ledger_entry_no() +RETURNS TRIGGER AS $$ +DECLARE + seq_val INT; +BEGIN + SELECT COALESCE(MAX(CAST(SUBSTRING(entry_no FROM 11) AS INT)), 0) + 1 INTO seq_val + FROM ledger_entries + WHERE entry_no LIKE 'LE' || TO_CHAR(NOW(), 'YYYYMMDD') || '%'; + + NEW.entry_no := 'LE' || TO_CHAR(NOW(), 'YYYYMMDD') || LPAD(seq_val::TEXT, 6, '0'); + NEW.accounting_period := TO_CHAR(NOW(), 'YYYY-MM'); + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER generate_ledger_entry_no_trigger + BEFORE INSERT ON ledger_entries + FOR EACH ROW + WHEN (NEW.entry_no IS NULL) + EXECUTE FUNCTION generate_ledger_entry_no(); + +-- 支付成功后自动创建分类账条目的函数 +CREATE OR REPLACE FUNCTION create_ledger_entry_on_payment() +RETURNS TRIGGER AS $$ +BEGIN + -- 仅当支付状态变为COMPLETED时创建收入流水 + IF NEW.status = 'COMPLETED' AND (OLD.status IS NULL OR OLD.status != 'COMPLETED') THEN + INSERT INTO ledger_entries ( + entry_type, + order_id, + payment_id, + user_id, + amount, + currency, + channel, + transaction_id, + description, + status + ) + SELECT + 'INCOME', + NEW.order_id, + NEW.id, + o.user_id, + NEW.amount, + NEW.currency, + NEW.method, + NEW.transaction_id, + '订单支付: ' || o.order_no || ' - ' || o.service_type || COALESCE(' (' || o.service_category || ')', ''), + 'CONFIRMED' + FROM orders o + WHERE o.id = NEW.order_id; + END IF; + + -- 退款时创建退款流水 + IF NEW.status = 'REFUNDED' AND OLD.status != 'REFUNDED' THEN + INSERT INTO ledger_entries ( + entry_type, + order_id, + payment_id, + user_id, + amount, + currency, + channel, + transaction_id, + description, + status + ) + SELECT + 'REFUND', + NEW.order_id, + NEW.id, + o.user_id, + -NEW.amount, -- 退款为负数 + NEW.currency, + NEW.method, + NEW.transaction_id, + '订单退款: ' || o.order_no, + 'CONFIRMED' + FROM orders o + WHERE o.id = NEW.order_id; + END IF; + + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER create_ledger_entry_on_payment_trigger + AFTER UPDATE ON payments + FOR EACH ROW + EXECUTE FUNCTION create_ledger_entry_on_payment(); + +-- =========================================== +-- 创建统计视图 +-- =========================================== + +-- 今日实时统计视图 +CREATE OR REPLACE VIEW v_today_statistics AS +SELECT + COUNT(DISTINCT CASE WHEN u.created_at >= CURRENT_DATE THEN u.id END) AS new_users_today, + COUNT(DISTINCT CASE WHEN u.created_at >= CURRENT_DATE AND u.type = 'REGISTERED' THEN u.id END) AS new_registered_today, + COUNT(DISTINCT CASE WHEN c.created_at >= CURRENT_DATE THEN c.id END) AS new_conversations_today, + COUNT(CASE WHEN m.created_at >= CURRENT_DATE THEN 1 END) AS messages_today, + COUNT(DISTINCT CASE WHEN o.created_at >= CURRENT_DATE THEN o.id END) AS new_orders_today, + COUNT(DISTINCT CASE WHEN o.paid_at >= CURRENT_DATE THEN o.id END) AS paid_orders_today, + COALESCE(SUM(CASE WHEN o.paid_at >= CURRENT_DATE THEN o.paid_amount END), 0) AS revenue_today +FROM users u +FULL OUTER JOIN conversations c ON TRUE +FULL OUTER JOIN messages m ON TRUE +FULL OUTER JOIN orders o ON TRUE; + +COMMENT ON VIEW v_today_statistics IS '今日实时统计视图 - 展示当天的关键指标'; + +-- 移民类别统计视图 +CREATE OR REPLACE VIEW v_category_statistics AS +SELECT + c.category, + COUNT(DISTINCT c.id) AS total_conversations, + COUNT(DISTINCT c.user_id) AS unique_users, + COUNT(DISTINCT CASE WHEN c.has_converted THEN c.id END) AS converted_conversations, + ROUND(COUNT(DISTINCT CASE WHEN c.has_converted THEN c.id END)::NUMERIC / + NULLIF(COUNT(DISTINCT c.id), 0) * 100, 2) AS conversion_rate, + COALESCE(SUM(o.paid_amount), 0) AS total_revenue +FROM conversations c +LEFT JOIN orders o ON c.id = o.conversation_id AND o.status = 'PAID' +WHERE c.category IS NOT NULL +GROUP BY c.category +ORDER BY total_conversations DESC; + +COMMENT ON VIEW v_category_statistics IS '移民类别统计视图 - 按类别汇总对话和转化数据'; + +-- 渠道统计视图 +CREATE OR REPLACE VIEW v_channel_statistics AS +SELECT + u.source_channel, + COUNT(DISTINCT u.id) AS total_users, + COUNT(DISTINCT CASE WHEN u.type = 'REGISTERED' THEN u.id END) AS registered_users, + COUNT(DISTINCT c.id) AS total_conversations, + COALESCE(SUM(o.paid_amount), 0) AS total_revenue, + COUNT(DISTINCT CASE WHEN o.status = 'PAID' THEN o.id END) AS paid_orders +FROM users u +LEFT JOIN conversations c ON u.id = c.user_id +LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'PAID' +GROUP BY u.source_channel +ORDER BY total_users DESC; + +COMMENT ON VIEW v_channel_statistics IS '渠道统计视图 - 按来源渠道汇总用户和收入数据'; + +-- =========================================== +-- 知识文章表 (knowledge_articles) +-- 存储移民相关的知识内容,支持RAG检索 +-- =========================================== +CREATE TABLE knowledge_articles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 文章标题 + title VARCHAR(500) NOT NULL, + -- 文章内容(纯文本或Markdown) + content TEXT NOT NULL, + -- 内容摘要(用于预览) + summary TEXT, + -- 移民类别: QMAS, GEP, IANG, TTPS, CIES, TechTAS, GENERAL + category VARCHAR(50) NOT NULL, + -- 内容标签(JSON数组) + tags TEXT[] DEFAULT '{}', + -- 来源: MANUAL(手动), CRAWL(爬取), EXTRACT(对话提取), IMPORT(批量导入) + source VARCHAR(20) NOT NULL DEFAULT 'MANUAL' + CHECK (source IN ('MANUAL', 'CRAWL', 'EXTRACT', 'IMPORT')), + -- 来源URL + source_url VARCHAR(1000), + -- 内容向量(用于语义搜索) + embedding VECTOR(1536), + -- 是否已发布 + is_published BOOLEAN DEFAULT FALSE, + -- 引用次数(被对话引用) + citation_count INT DEFAULT 0, + -- 点赞数 + helpful_count INT DEFAULT 0, + -- 点踩数 + unhelpful_count INT DEFAULT 0, + -- 质量评分 0-100 + quality_score INT DEFAULT 50 CHECK (quality_score >= 0 AND quality_score <= 100), + -- 创建者ID + created_by UUID, + -- 更新者ID + updated_by UUID, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE knowledge_articles IS '知识文章表 - 存储移民相关知识,支持RAG语义检索'; +COMMENT ON COLUMN knowledge_articles.category IS '移民类别,用于分类筛选'; +COMMENT ON COLUMN knowledge_articles.source IS '内容来源:MANUAL手动添加,CRAWL网页爬取,EXTRACT对话提取,IMPORT批量导入'; +COMMENT ON COLUMN knowledge_articles.embedding IS '文章向量,1536维,用于pgvector语义搜索'; +COMMENT ON COLUMN knowledge_articles.quality_score IS '质量评分,根据引用和反馈自动计算'; + +CREATE INDEX idx_knowledge_articles_category ON knowledge_articles(category); +CREATE INDEX idx_knowledge_articles_published ON knowledge_articles(is_published); +CREATE INDEX idx_knowledge_articles_quality ON knowledge_articles(quality_score DESC); +CREATE INDEX idx_knowledge_articles_embedding ON knowledge_articles USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- =========================================== +-- 知识块表 (knowledge_chunks) +-- 将文章拆分为更小的检索单元,提高RAG精确度 +-- =========================================== +CREATE TABLE knowledge_chunks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 所属文章ID + article_id UUID NOT NULL REFERENCES knowledge_articles(id) ON DELETE CASCADE, + -- 块内容 + content TEXT NOT NULL, + -- 块序号(在文章中的位置) + chunk_index INT NOT NULL, + -- 块类型: TITLE(标题), PARAGRAPH(段落), LIST(列表), TABLE(表格), CODE(代码), FAQ(问答) + chunk_type VARCHAR(20) NOT NULL DEFAULT 'PARAGRAPH' + CHECK (chunk_type IN ('TITLE', 'PARAGRAPH', 'LIST', 'TABLE', 'CODE', 'FAQ')), + -- 内容向量 + embedding VECTOR(1536), + -- 元数据(如章节标题、前后块链接等) + metadata JSONB DEFAULT '{}', + -- Token数量(估算) + token_count INT DEFAULT 0, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE knowledge_chunks IS '知识块表 - 文章分块,用于精确RAG检索'; +COMMENT ON COLUMN knowledge_chunks.chunk_type IS '块类型,用于理解内容结构'; +COMMENT ON COLUMN knowledge_chunks.metadata IS '元数据,包含章节标题、前后块链接等'; + +CREATE INDEX idx_knowledge_chunks_article ON knowledge_chunks(article_id); +CREATE INDEX idx_knowledge_chunks_embedding ON knowledge_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- =========================================== +-- 用户记忆表 (user_memories) +-- 存储用户的长期记忆,用于个性化对话 +-- =========================================== +CREATE TABLE user_memories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 用户ID + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + -- 记忆类型 + memory_type VARCHAR(30) NOT NULL + CHECK (memory_type IN ( + 'PERSONAL_INFO', -- 个人信息 + 'WORK_EXPERIENCE', -- 工作经历 + 'EDUCATION', -- 教育背景 + 'LANGUAGE', -- 语言能力 + 'IMMIGRATION_INTENT', -- 移民意向 + 'PREFERRED_CATEGORY', -- 倾向类别 + 'ASSESSMENT_RESULT', -- 评估结果 + 'QUESTION_ASKED', -- 问过的问题 + 'CONCERN', -- 关注点 + 'PREFERENCE', -- 偏好设置 + 'CUSTOM' -- 自定义 + )), + -- 记忆内容 + content TEXT NOT NULL, + -- 重要性 0-100 + importance INT DEFAULT 50 CHECK (importance >= 0 AND importance <= 100), + -- 来源对话ID + source_conversation_id UUID, + -- 相关移民类别 + related_category VARCHAR(50), + -- 内容向量 + embedding VECTOR(1536), + -- 访问次数 + access_count INT DEFAULT 0, + -- 最后访问时间 + last_accessed_at TIMESTAMP WITH TIME ZONE, + -- 是否已过期 + is_expired BOOLEAN DEFAULT FALSE, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE user_memories IS '用户记忆表 - 存储用户长期记忆,支持个性化对话'; +COMMENT ON COLUMN user_memories.memory_type IS '记忆类型,用于分类管理用户信息'; +COMMENT ON COLUMN user_memories.importance IS '重要性评分,影响检索优先级'; +COMMENT ON COLUMN user_memories.is_expired IS '是否过期,用户情况变化时标记'; + +CREATE INDEX idx_user_memories_user ON user_memories(user_id); +CREATE INDEX idx_user_memories_type ON user_memories(memory_type); +CREATE INDEX idx_user_memories_importance ON user_memories(user_id, importance DESC); +CREATE INDEX idx_user_memories_embedding ON user_memories USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- =========================================== +-- 系统经验表 (system_experiences) +-- 存储系统从对话中学习到的经验,用于自我进化 +-- =========================================== +CREATE TABLE system_experiences ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 经验类型 + experience_type VARCHAR(30) NOT NULL + CHECK (experience_type IN ( + 'COMMON_QUESTION', -- 常见问题 + 'ANSWER_TEMPLATE', -- 回答模板 + 'CLARIFICATION', -- 澄清方式 + 'USER_PATTERN', -- 用户行为模式 + 'CONVERSION_TRIGGER', -- 转化触发点 + 'KNOWLEDGE_GAP', -- 知识缺口 + 'KNOWLEDGE_UPDATE', -- 知识更新 + 'CONVERSATION_SKILL', -- 对话技巧 + 'OBJECTION_HANDLING', -- 异议处理 + 'CUSTOM' -- 自定义 + )), + -- 经验内容 + content TEXT NOT NULL, + -- 置信度 0-100 + confidence INT DEFAULT 50 CHECK (confidence >= 0 AND confidence <= 100), + -- 应用场景描述 + scenario TEXT NOT NULL, + -- 相关移民类别 + related_category VARCHAR(50), + -- 来源对话ID列表 + source_conversation_ids UUID[] DEFAULT '{}', + -- 验证状态: PENDING(待验证), APPROVED(已通过), REJECTED(已拒绝), DEPRECATED(已弃用) + verification_status VARCHAR(20) NOT NULL DEFAULT 'PENDING' + CHECK (verification_status IN ('PENDING', 'APPROVED', 'REJECTED', 'DEPRECATED')), + -- 验证者ID + verified_by UUID, + -- 验证时间 + verified_at TIMESTAMP WITH TIME ZONE, + -- 使用次数 + usage_count INT DEFAULT 0, + -- 正面反馈次数 + positive_count INT DEFAULT 0, + -- 负面反馈次数 + negative_count INT DEFAULT 0, + -- 内容向量 + embedding VECTOR(1536), + -- 是否激活使用 + is_active BOOLEAN DEFAULT FALSE, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE system_experiences IS '系统经验表 - 从对话中学习的经验,支持系统自我进化'; +COMMENT ON COLUMN system_experiences.experience_type IS '经验类型,用于分类应用'; +COMMENT ON COLUMN system_experiences.confidence IS '置信度,根据来源数量和反馈计算'; +COMMENT ON COLUMN system_experiences.verification_status IS '验证状态,需管理员审核后才能激活'; +COMMENT ON COLUMN system_experiences.is_active IS '是否激活,只有通过审核才能在对话中使用'; + +CREATE INDEX idx_system_experiences_type ON system_experiences(experience_type); +CREATE INDEX idx_system_experiences_status ON system_experiences(verification_status); +CREATE INDEX idx_system_experiences_active ON system_experiences(is_active); +CREATE INDEX idx_system_experiences_confidence ON system_experiences(confidence DESC); +CREATE INDEX idx_system_experiences_embedding ON system_experiences USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- =========================================== +-- 管理员表 (admins) +-- 管理后台用户,支持多角色权限 +-- =========================================== +CREATE TABLE admins ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 用户名(登录用) + username VARCHAR(50) NOT NULL UNIQUE, + -- 密码哈希 + password_hash VARCHAR(255) NOT NULL, + -- 姓名 + name VARCHAR(100) NOT NULL, + -- 邮箱 + email VARCHAR(255), + -- 手机号 + phone VARCHAR(20), + -- 角色: SUPER_ADMIN(超级管理员), ADMIN(管理员), OPERATOR(运营), VIEWER(只读) + role VARCHAR(20) NOT NULL DEFAULT 'OPERATOR' + CHECK (role IN ('SUPER_ADMIN', 'ADMIN', 'OPERATOR', 'VIEWER')), + -- 权限列表(细粒度权限控制) + permissions JSONB DEFAULT '[]', + -- 头像URL + avatar VARCHAR(500), + -- 最后登录时间 + last_login_at TIMESTAMP WITH TIME ZONE, + -- 最后登录IP + last_login_ip VARCHAR(50), + -- 是否启用 + is_active BOOLEAN DEFAULT TRUE, + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +COMMENT ON TABLE admins IS '管理员表 - 管理后台用户,支持多角色权限'; +COMMENT ON COLUMN admins.role IS '角色:SUPER_ADMIN超管,ADMIN管理员,OPERATOR运营,VIEWER只读'; +COMMENT ON COLUMN admins.permissions IS '细粒度权限列表,JSON数组格式'; + +CREATE INDEX idx_admins_username ON admins(username); +CREATE INDEX idx_admins_role ON admins(role); +CREATE INDEX idx_admins_active ON admins(is_active); + +-- 插入默认超级管理员(密码: admin123,实际生产环境需要修改) +INSERT INTO admins (username, password_hash, name, role, permissions) VALUES +('admin', '$2b$10$rQNDjKwYXOw8FNrFcD3e0.T8KCqVJLqDQT9gQR2KPnDqPvqK8VpKi', '系统管理员', 'SUPER_ADMIN', '["*"]'); + +-- =========================================== +-- 结束 +-- =========================================== diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf new file mode 100644 index 0000000..ebd9d9a --- /dev/null +++ b/nginx/conf.d/default.conf @@ -0,0 +1,130 @@ +#=============================================================================== +# iConsulting Nginx 配置 +# +# 路由规则: +# / -> web-client (用户前端) +# /admin -> admin-client (管理后台) +# /api/v1/* -> Kong API Gateway +# /ws/* -> WebSocket (conversation-service) +# +#=============================================================================== + +server { + listen 80; + server_name _; + + # 健康检查端点 + location /health { + access_log off; + return 200 'OK'; + add_header Content-Type text/plain; + } + + # 用户前端 (web-client) + location / { + root /usr/share/nginx/html/web; + index index.html; + try_files $uri $uri/ /index.html; + + # 缓存静态资源 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + # 管理后台 (admin-client) + location /admin { + alias /usr/share/nginx/html/admin; + index index.html; + try_files $uri $uri/ /admin/index.html; + + # 缓存静态资源 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + # API 请求代理到 Kong + location /api/ { + proxy_pass http://kong_upstream/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 超时设置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + + # 缓冲设置 + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + } + + # WebSocket 代理 + location /ws/ { + proxy_pass http://websocket_upstream/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket 超时 (保持长连接) + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # Socket.IO 专用路径 + location /socket.io/ { + proxy_pass http://websocket_upstream/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 7d; + proxy_send_timeout 7d; + proxy_read_timeout 7d; + } + + # 禁止访问隐藏文件 + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +} + +# HTTPS 配置 (如有SSL证书,取消注释) +# server { +# listen 443 ssl http2; +# server_name _; +# +# ssl_certificate /etc/nginx/ssl/fullchain.pem; +# ssl_certificate_key /etc/nginx/ssl/privkey.pem; +# ssl_session_timeout 1d; +# ssl_session_cache shared:SSL:50m; +# ssl_session_tickets off; +# +# ssl_protocols TLSv1.2 TLSv1.3; +# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; +# ssl_prefer_server_ciphers off; +# +# # HSTS +# add_header Strict-Transport-Security "max-age=63072000" always; +# +# # 其他配置同上... +# include /etc/nginx/conf.d/locations.conf; +# } diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..ee23ea3 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,51 @@ +user nginx; +worker_processes auto; + +error_log /var/log/nginx/error.log notice; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + + keepalive_timeout 65; + + # Gzip 压缩 + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml; + + # 请求大小限制 + client_max_body_size 50M; + + # 上游服务定义 + upstream kong_upstream { + server kong:8000; + keepalive 32; + } + + upstream websocket_upstream { + server conversation-service:3004; + keepalive 32; + } + + include /etc/nginx/conf.d/*.conf; +} diff --git a/nginx/ssl/.gitkeep b/nginx/ssl/.gitkeep new file mode 100644 index 0000000..1477133 --- /dev/null +++ b/nginx/ssl/.gitkeep @@ -0,0 +1,2 @@ +# 此目录用于存放 SSL 证书 +# 请将 fullchain.pem 和 privkey.pem 放置于此 diff --git a/package.json b/package.json new file mode 100644 index 0000000..71a6ee5 --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "iconsulting", + "version": "1.0.0", + "description": "Hong Kong Immigration Consulting System based on Claude Agent SDK", + "private": true, + "workspaces": [ + "packages/*", + "packages/services/*" + ], + "scripts": { + "dev": "turbo run dev", + "build": "turbo run build", + "lint": "turbo run lint", + "test": "turbo run test", + "clean": "turbo run clean && rm -rf node_modules", + "db:migrate": "turbo run db:migrate", + "docker:dev": "docker-compose -f infrastructure/docker/docker-compose.dev.yml up -d", + "docker:down": "docker-compose -f infrastructure/docker/docker-compose.dev.yml down" + }, + "devDependencies": { + "turbo": "^2.0.0", + "typescript": "^5.3.0", + "@types/node": "^20.10.0", + "prettier": "^3.1.0", + "eslint": "^8.55.0" + }, + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.0.0" + }, + "packageManager": "pnpm@8.15.0" +} diff --git a/packages/admin-client/index.html b/packages/admin-client/index.html new file mode 100644 index 0000000..f0b7317 --- /dev/null +++ b/packages/admin-client/index.html @@ -0,0 +1,13 @@ + + + + + + + iConsulting 管理后台 + + +
+ + + diff --git a/packages/admin-client/package.json b/packages/admin-client/package.json new file mode 100644 index 0000000..9743343 --- /dev/null +++ b/packages/admin-client/package.json @@ -0,0 +1,40 @@ +{ + "name": "@iconsulting/admin-client", + "version": "0.1.0", + "description": "iConsulting 管理后台", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "@tanstack/react-query": "^5.17.0", + "antd": "^5.12.8", + "@ant-design/icons": "^5.2.6", + "axios": "^1.6.5", + "dayjs": "^1.11.10", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.1", + "zustand": "^4.4.7", + "recharts": "^2.10.4" + }, + "devDependencies": { + "@types/react": "^18.2.47", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.18.1", + "@typescript-eslint/parser": "^6.18.1", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.16", + "eslint": "^8.56.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.5", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.0.11" + } +} diff --git a/packages/admin-client/postcss.config.js b/packages/admin-client/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/packages/admin-client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/admin-client/src/App.tsx b/packages/admin-client/src/App.tsx new file mode 100644 index 0000000..0b1d36a --- /dev/null +++ b/packages/admin-client/src/App.tsx @@ -0,0 +1,37 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { MainLayout } from './shared/components/MainLayout'; +import { ProtectedRoute } from './shared/components/ProtectedRoute'; +import { LoginPage } from './features/auth/presentation/pages/LoginPage'; +import { DashboardPage } from './features/dashboard/presentation/pages/DashboardPage'; +import { KnowledgePage } from './features/knowledge/presentation/pages/KnowledgePage'; +import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage'; + +function App() { + return ( + + {/* 登录页 */} + } /> + + {/* 需要认证的路由 */} + + + + } + > + } /> + } /> + } /> + 用户管理(开发中)} /> + 系统设置(开发中)} /> + + + {/* 未匹配路由重定向 */} + } /> + + ); +} + +export default App; diff --git a/packages/admin-client/src/features/auth/presentation/pages/LoginPage.tsx b/packages/admin-client/src/features/auth/presentation/pages/LoginPage.tsx new file mode 100644 index 0000000..be6f949 --- /dev/null +++ b/packages/admin-client/src/features/auth/presentation/pages/LoginPage.tsx @@ -0,0 +1,82 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Form, Input, Button, Card, message } from 'antd'; +import { UserOutlined, LockOutlined } from '@ant-design/icons'; +import { useAuth } from '../../../../shared/hooks/useAuth'; + +interface LoginFormValues { + username: string; + password: string; +} + +export function LoginPage() { + const [loading, setLoading] = useState(false); + const navigate = useNavigate(); + const login = useAuth((state) => state.login); + + const onFinish = async (values: LoginFormValues) => { + setLoading(true); + try { + await login(values.username, values.password); + message.success('登录成功'); + navigate('/'); + } catch (error) { + message.error('用户名或密码错误'); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+

iConsulting

+

管理后台

+
+ +
+ + } + placeholder="用户名" + /> + + + + } + placeholder="密码" + /> + + + + + +
+ +
+ 默认账号: admin / admin123 +
+
+
+ ); +} diff --git a/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx b/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx new file mode 100644 index 0000000..f2bd1db --- /dev/null +++ b/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx @@ -0,0 +1,298 @@ +import { useQuery } from '@tanstack/react-query'; +import { Card, Row, Col, Statistic, Tag, Progress, List, Typography } from 'antd'; +import { + UserOutlined, + MessageOutlined, + DollarOutlined, + RobotOutlined, + CheckCircleOutlined, + ClockCircleOutlined, +} from '@ant-design/icons'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + PieChart, + Pie, + Cell, +} from 'recharts'; +import api from '../../../../shared/utils/api'; + +const { Title, Text } = Typography; + +// Mock数据 - 实际应该从API获取 +const mockTrendData = [ + { date: '01-01', conversations: 120, users: 45 }, + { date: '01-02', conversations: 150, users: 52 }, + { date: '01-03', conversations: 180, users: 68 }, + { date: '01-04', conversations: 145, users: 55 }, + { date: '01-05', conversations: 200, users: 75 }, + { date: '01-06', conversations: 230, users: 88 }, + { date: '01-07', conversations: 210, users: 82 }, +]; + +const mockCategoryData = [ + { name: 'QMAS', value: 35, color: '#1890ff' }, + { name: 'GEP', value: 25, color: '#52c41a' }, + { name: 'IANG', value: 20, color: '#faad14' }, + { name: 'TTPS', value: 10, color: '#722ed1' }, + { name: 'CIES', value: 7, color: '#eb2f96' }, + { name: 'TechTAS', value: 3, color: '#13c2c2' }, +]; + +export function DashboardPage() { + const { data: evolutionStats } = useQuery({ + queryKey: ['evolution-stats'], + queryFn: async () => { + const response = await api.get('/evolution/statistics'); + return response.data.data; + }, + }); + + const { data: healthReport } = useQuery({ + queryKey: ['system-health'], + queryFn: async () => { + const response = await api.get('/evolution/health'); + return response.data.data; + }, + }); + + const getHealthColor = (status: string) => { + switch (status) { + case 'healthy': + return 'success'; + case 'warning': + return 'warning'; + case 'critical': + return 'error'; + default: + return 'default'; + } + }; + + return ( +
+ 仪表盘 + + {/* 核心指标 */} + + + + } + valueStyle={{ color: '#1890ff' }} + /> +
+ 较昨日 +12% +
+
+ + + + } + valueStyle={{ color: '#52c41a' }} + /> +
+ 较昨日 +8% +
+
+ + + + } + suffix="元" + valueStyle={{ color: '#faad14' }} + /> +
+ 较昨日 +15% +
+
+ + + + } + valueStyle={{ color: '#722ed1' }} + /> +
+ 较昨日 -2% +
+
+ +
+ + {/* 趋势图表 */} + + + + + + + + + + + + + + + + + + + + + `${name} ${(percent * 100).toFixed(0)}%` + } + > + {mockCategoryData.map((entry, index) => ( + + ))} + + + + + + + + + {/* 系统状态 */} + + + + 系统健康 + + {healthReport?.overall === 'healthy' + ? '健康' + : healthReport?.overall === 'warning' + ? '警告' + : '异常'} + +
+ } + > + ( + +
+
+ {item.name} + + {item.value} / {item.threshold} + +
+ +
+
+ )} + /> + {healthReport?.recommendations?.length > 0 && ( +
+ 建议: +
    + {healthReport.recommendations.map((rec: string, i: number) => ( +
  • {rec}
  • + ))} +
+
+ )} + + + + + + + } + /> + + + } + valueStyle={{ color: '#52c41a' }} + /> + + + } + valueStyle={{ color: '#faad14' }} + /> + + + } + /> + + +
+ 经验类型分布 +
+ {evolutionStats?.topExperienceTypes?.map( + (item: { type: string; count: number }) => ( + + {item.type}: {item.count} + + ) + )} +
+
+
+ + + + ); +} diff --git a/packages/admin-client/src/features/experience/presentation/pages/ExperiencePage.tsx b/packages/admin-client/src/features/experience/presentation/pages/ExperiencePage.tsx new file mode 100644 index 0000000..9ec84fc --- /dev/null +++ b/packages/admin-client/src/features/experience/presentation/pages/ExperiencePage.tsx @@ -0,0 +1,393 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + Card, + Table, + Button, + Select, + Tag, + Space, + Modal, + message, + Tabs, + Typography, + Statistic, + Row, + Col, +} from 'antd'; +import { + CheckOutlined, + CloseOutlined, + EyeOutlined, + PlayCircleOutlined, +} from '@ant-design/icons'; +import api from '../../../../shared/utils/api'; +import { useAuth } from '../../../../shared/hooks/useAuth'; + +const { Title, Text, Paragraph } = Typography; + +const EXPERIENCE_TYPES = [ + { value: 'COMMON_QUESTION', label: '常见问题' }, + { value: 'ANSWER_TEMPLATE', label: '回答模板' }, + { value: 'CLARIFICATION', label: '澄清方式' }, + { value: 'USER_PATTERN', label: '用户模式' }, + { value: 'CONVERSION_TRIGGER', label: '转化触发' }, + { value: 'KNOWLEDGE_GAP', label: '知识缺口' }, + { value: 'CONVERSATION_SKILL', label: '对话技巧' }, + { value: 'OBJECTION_HANDLING', label: '异议处理' }, +]; + +interface Experience { + id: string; + experienceType: string; + content: string; + scenario: string; + confidence: number; + relatedCategory: string; + sourceConversationIds: string[]; + verificationStatus: string; + usageCount: number; + positiveCount: number; + negativeCount: number; + isActive: boolean; + createdAt: string; +} + +export function ExperiencePage() { + const [activeTab, setActiveTab] = useState('pending'); + const [typeFilter, setTypeFilter] = useState(); + const [selectedExperience, setSelectedExperience] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const queryClient = useQueryClient(); + const admin = useAuth((state) => state.admin); + + const { data: pendingData, isLoading: pendingLoading } = useQuery({ + queryKey: ['pending-experiences', typeFilter], + queryFn: async () => { + const params = new URLSearchParams(); + if (typeFilter) params.append('type', typeFilter); + const response = await api.get(`/memory/experience/pending?${params}`); + return response.data.data; + }, + enabled: activeTab === 'pending', + }); + + const { data: stats } = useQuery({ + queryKey: ['experience-stats'], + queryFn: async () => { + const response = await api.get('/memory/experience/statistics'); + return response.data.data; + }, + }); + + const approveMutation = useMutation({ + mutationFn: (id: string) => + api.post(`/memory/experience/${id}/approve`, { adminId: admin?.id }), + onSuccess: () => { + message.success('经验已批准'); + queryClient.invalidateQueries({ queryKey: ['pending-experiences'] }); + queryClient.invalidateQueries({ queryKey: ['experience-stats'] }); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: (id: string) => + api.post(`/memory/experience/${id}/reject`, { adminId: admin?.id }), + onSuccess: () => { + message.success('经验已拒绝'); + queryClient.invalidateQueries({ queryKey: ['pending-experiences'] }); + queryClient.invalidateQueries({ queryKey: ['experience-stats'] }); + }, + }); + + const runEvolutionMutation = useMutation({ + mutationFn: () => api.post('/evolution/run', { hoursBack: 24, limit: 50 }), + onSuccess: (response) => { + const result = response.data.data; + message.success( + `进化任务完成:分析了${result.conversationsAnalyzed}个对话,提取了${result.experiencesExtracted}条经验` + ); + queryClient.invalidateQueries({ queryKey: ['pending-experiences'] }); + queryClient.invalidateQueries({ queryKey: ['experience-stats'] }); + }, + }); + + const handleView = (exp: Experience) => { + setSelectedExperience(exp); + setIsModalOpen(true); + }; + + const getTypeLabel = (type: string) => { + return EXPERIENCE_TYPES.find((t) => t.value === type)?.label || type; + }; + + const getStatusTag = (status: string) => { + const statusMap: Record = { + PENDING: { color: 'orange', label: '待审核' }, + APPROVED: { color: 'green', label: '已通过' }, + REJECTED: { color: 'red', label: '已拒绝' }, + DEPRECATED: { color: 'default', label: '已弃用' }, + }; + const s = statusMap[status] || { color: 'default', label: status }; + return {s.label}; + }; + + const columns = [ + { + title: '类型', + dataIndex: 'experienceType', + key: 'experienceType', + render: (type: string) => {getTypeLabel(type)}, + }, + { + title: '场景', + dataIndex: 'scenario', + key: 'scenario', + ellipsis: true, + }, + { + title: '内容', + dataIndex: 'content', + key: 'content', + ellipsis: true, + render: (text: string) => ( + + {text} + + ), + }, + { + title: '置信度', + dataIndex: 'confidence', + key: 'confidence', + render: (confidence: number) => ( + = 70 + ? 'text-green-600' + : confidence >= 40 + ? 'text-yellow-600' + : 'text-red-600' + } + > + {confidence}% + + ), + }, + { + title: '来源对话', + dataIndex: 'sourceConversationIds', + key: 'sources', + render: (ids: string[]) => {ids?.length || 0}个, + }, + { + title: '状态', + dataIndex: 'verificationStatus', + key: 'status', + render: getStatusTag, + }, + { + title: '操作', + key: 'action', + render: (_: unknown, record: Experience) => ( + + + + + {/* 统计卡片 */} + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ } + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + style={{ width: 200 }} + /> + + + + + + + + +