diff --git a/deploy.sh b/deploy.sh index 21dc3c8..7dbc9bc 100755 --- a/deploy.sh +++ b/deploy.sh @@ -395,6 +395,46 @@ start_infrastructure() { log_success "基础设施启动完成" } +# 运行数据库种子脚本 (使用 Docker 容器中的 ts-node) +run_db_seed() { + log_step "运行数据库种子脚本..." + + # 确保 builder 镜像存在 + build_builder_image + + # 获取数据库连接信息 + local db_host="host.docker.internal" + local db_port="${POSTGRES_PORT:-5432}" + local db_user="${POSTGRES_USER:-postgres}" + local db_password="${POSTGRES_PASSWORD:-postgres}" + local db_name="${POSTGRES_DB:-iconsulting}" + + # 如果在 Linux 上,使用 docker network inspect 获取 postgres 容器 IP + if [ "$(uname)" = "Linux" ]; then + db_host=$($DOCKER_COMPOSE exec -T postgres hostname -i 2>/dev/null | tr -d '[:space:]' || echo "172.20.0.2") + fi + + # 在容器中运行 seed 脚本 + docker run --rm \ + -v "$PROJECT_ROOT:/app" \ + -v "iconsulting-pnpm-store:/root/.local/share/pnpm/store" \ + --network "${PROJECT_NAME:-iconsulting}_iconsulting-network" \ + -e "DB_HOST=postgres" \ + -e "DB_PORT=$db_port" \ + -e "DB_USER=$db_user" \ + -e "DB_PASSWORD=$db_password" \ + -e "DB_NAME=$db_name" \ + -w /app/scripts \ + "$BUILDER_IMAGE" \ + sh -c "npm install --silent 2>/dev/null && npx ts-node seed.ts" + + if [ $? -eq 0 ]; then + log_success "种子数据初始化完成" + else + log_warning "种子脚本执行失败,可能数据已存在" + fi +} + # 初始化数据库 (自动检测并执行) init_database() { log_step "检查数据库初始化状态..." @@ -407,7 +447,9 @@ init_database() { table_exists=$($DOCKER_COMPOSE exec -T postgres psql -U postgres -d iconsulting -tAc "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'admins');" 2>/dev/null) if [ "$table_exists" = "t" ]; then - log_success "数据库已初始化,跳过" + log_success "数据库已初始化" + # 即使数据库已初始化,也运行 seed 确保种子数据存在 + run_db_seed return 0 fi @@ -430,6 +472,9 @@ init_database() { else log_warning "pgvector 扩展未能启用,向量搜索功能可能受限" fi + + # 运行种子脚本确保数据完整 + run_db_seed else log_error "数据库初始化失败,请检查 init-db.sql" return 1 @@ -610,21 +655,23 @@ do_rebuild() { local target=${1:-all} local no_cache="" local cache_msg="" + local run_seed=false - # 检查是否有 --no-cache 参数 + # 检查是否有 --no-cache 或 --seed 参数 for arg in "$@"; do if [ "$arg" = "--no-cache" ]; then no_cache="--no-cache" cache_msg=" (--no-cache)" fi + if [ "$arg" = "--seed" ]; then + run_seed=true + fi done - if [ "$target" = "all" ] || [ "$target" = "--no-cache" ]; then - # 如果第一个参数是 --no-cache,则 target 是 all - if [ "$target" = "--no-cache" ]; then + if [ "$target" = "all" ] || [ "$target" = "--no-cache" ] || [ "$target" = "--seed" ]; then + # 如果第一个参数是 --no-cache 或 --seed,则 target 是 all + if [ "$target" = "--no-cache" ] || [ "$target" = "--seed" ]; then target="all" - no_cache="--no-cache" - cache_msg=" (--no-cache)" fi log_step "重新构建所有服务镜像${cache_msg}..." @@ -633,6 +680,12 @@ do_rebuild() { # 重启所有后端服务 log_step "重启所有后端服务..." $DOCKER_COMPOSE up -d user-service payment-service knowledge-service conversation-service evolution-service file-service + + # 等待服务就绪 + sleep 5 + + # 运行种子脚本 + run_db_seed else # 获取服务名 local service_name="" @@ -654,6 +707,12 @@ do_rebuild() { # 重启服务使用新镜像 log_step "重启 $target 服务..." $DOCKER_COMPOSE up -d "$service_name" + + # 如果重建的是 evolution 服务,也运行 seed (因为管理员在这个服务) + if [ "$target" = "evolution" ] || [ "$run_seed" = true ]; then + sleep 3 + run_db_seed + fi fi log_success "镜像重新构建并重启完成" @@ -1551,8 +1610,8 @@ do_db() { init_database ;; seed) - log_step "初始化种子数据..." - # 添加种子数据脚本 + log_step "初始化种子数据 (使用 ts-node)..." + run_db_seed log_success "种子数据初始化完成" ;; backup) diff --git a/docker-compose.yml b/docker-compose.yml index 546cf1d..b197166 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,6 +117,32 @@ services: networks: - iconsulting-network + db-seed: + image: node:20-alpine + container_name: iconsulting-db-seed + depends_on: + postgres: + condition: service_healthy + working_dir: /app/scripts + environment: + DB_HOST: postgres + DB_PORT: 5432 + DB_USER: ${POSTGRES_USER:-postgres} + DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + DB_NAME: ${POSTGRES_DB:-iconsulting} + volumes: + - ./scripts:/app/scripts:ro + command: > + sh -c " + npm install -g ts-node typescript && + npm install && + npx ts-node seed.ts + " + profiles: + - seed + networks: + - iconsulting-network + #============================================================================= # Kong API 网关 (DB-less 模式) #============================================================================= diff --git a/package.json b/package.json index 71a6ee5..5cc945f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "private": true, "workspaces": [ "packages/*", - "packages/services/*" + "packages/services/*", + "scripts" ], "scripts": { "dev": "turbo run dev", @@ -14,6 +15,7 @@ "test": "turbo run test", "clean": "turbo run clean && rm -rf node_modules", "db:migrate": "turbo run db:migrate", + "db:seed": "cd scripts && pnpm seed", "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" }, diff --git a/packages/services/evolution-service/src/app.module.ts b/packages/services/evolution-service/src/app.module.ts index c15162e..b97328d 100644 --- a/packages/services/evolution-service/src/app.module.ts +++ b/packages/services/evolution-service/src/app.module.ts @@ -13,6 +13,7 @@ import { AdminModule } from './admin/admin.module'; import { HealthModule } from './health/health.module'; import { AnalyticsModule } from './analytics/analytics.module'; import { TenantModule } from './infrastructure/tenant/tenant.module'; +import { SuperAdminModule } from './super-admin/super-admin.module'; @Module({ imports: [ @@ -64,6 +65,9 @@ import { TenantModule } from './infrastructure/tenant/tenant.module'; EvolutionModule, AdminModule, AnalyticsModule, + + // 超级管理员模块 (租户管理) + SuperAdminModule, ], }) export class AppModule implements NestModule { diff --git a/packages/services/evolution-service/src/super-admin/adapters/inbound/tenant.controller.ts b/packages/services/evolution-service/src/super-admin/adapters/inbound/tenant.controller.ts new file mode 100644 index 0000000..ba9b18a --- /dev/null +++ b/packages/services/evolution-service/src/super-admin/adapters/inbound/tenant.controller.ts @@ -0,0 +1,198 @@ +import { + Controller, + Get, + Post, + Put, + Body, + Param, + Query, + Headers, + HttpCode, + HttpStatus, + UnauthorizedException, +} from '@nestjs/common'; +import { + TenantManagementService, + CreateTenantDto, + UpdateTenantDto, + CreateTenantAdminDto, +} from '../../application/services/tenant-management.service'; + +/** + * 超级管理员 - 租户管理 API + * + * 所有接口需要在 Header 中传递: + * - x-admin-id: 超级管理员ID + * + * 路由前缀: /super-admin/tenants + */ +@Controller('super-admin/tenants') +export class TenantController { + constructor(private readonly tenantService: TenantManagementService) {} + + /** + * 验证超级管理员权限 + */ + private async checkSuperAdmin(adminId: string | undefined) { + if (!adminId) { + throw new UnauthorizedException('需要提供管理员ID (x-admin-id header)'); + } + return this.tenantService.verifySuperAdmin(adminId); + } + + /** + * 获取全局统计 + * GET /super-admin/tenants/stats + */ + @Get('stats') + async getGlobalStats(@Headers('x-admin-id') adminId: string) { + await this.checkSuperAdmin(adminId); + return this.tenantService.getGlobalStats(); + } + + /** + * 获取租户列表 + * GET /super-admin/tenants + */ + @Get() + async listTenants( + @Headers('x-admin-id') adminId: string, + @Query('status') status?: string, + @Query('plan') plan?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + await this.checkSuperAdmin(adminId); + return this.tenantService.listTenants({ + status, + plan, + page: page ? parseInt(page) : 1, + limit: limit ? parseInt(limit) : 20, + }); + } + + /** + * 获取租户详情 + * GET /super-admin/tenants/:id + */ + @Get(':id') + async getTenant( + @Headers('x-admin-id') adminId: string, + @Param('id') tenantId: string, + ) { + await this.checkSuperAdmin(adminId); + return this.tenantService.getTenant(tenantId); + } + + /** + * 获取租户统计 + * GET /super-admin/tenants/:id/stats + */ + @Get(':id/stats') + async getTenantStats( + @Headers('x-admin-id') adminId: string, + @Param('id') tenantId: string, + ) { + await this.checkSuperAdmin(adminId); + return this.tenantService.getTenantStats(tenantId); + } + + /** + * 创建租户 + * POST /super-admin/tenants + */ + @Post() + @HttpCode(HttpStatus.CREATED) + async createTenant( + @Headers('x-admin-id') adminId: string, + @Body() dto: CreateTenantDto, + ) { + await this.checkSuperAdmin(adminId); + return this.tenantService.createTenant(dto); + } + + /** + * 更新租户 + * PUT /super-admin/tenants/:id + */ + @Put(':id') + async updateTenant( + @Headers('x-admin-id') adminId: string, + @Param('id') tenantId: string, + @Body() dto: UpdateTenantDto, + ) { + await this.checkSuperAdmin(adminId); + return this.tenantService.updateTenant(tenantId, dto); + } + + /** + * 暂停租户 + * POST /super-admin/tenants/:id/suspend + */ + @Post(':id/suspend') + async suspendTenant( + @Headers('x-admin-id') adminId: string, + @Param('id') tenantId: string, + @Body('reason') reason?: string, + ) { + await this.checkSuperAdmin(adminId); + return this.tenantService.suspendTenant(tenantId, reason); + } + + /** + * 激活租户 + * POST /super-admin/tenants/:id/activate + */ + @Post(':id/activate') + async activateTenant( + @Headers('x-admin-id') adminId: string, + @Param('id') tenantId: string, + ) { + await this.checkSuperAdmin(adminId); + return this.tenantService.activateTenant(tenantId); + } + + /** + * 归档租户 + * POST /super-admin/tenants/:id/archive + */ + @Post(':id/archive') + async archiveTenant( + @Headers('x-admin-id') adminId: string, + @Param('id') tenantId: string, + ) { + await this.checkSuperAdmin(adminId); + return this.tenantService.archiveTenant(tenantId); + } + + /** + * 获取租户管理员列表 + * GET /super-admin/tenants/:id/admins + */ + @Get(':id/admins') + async listTenantAdmins( + @Headers('x-admin-id') adminId: string, + @Param('id') tenantId: string, + ) { + await this.checkSuperAdmin(adminId); + return this.tenantService.listTenantAdmins(tenantId); + } + + /** + * 为租户创建管理员 + * POST /super-admin/tenants/:id/admins + */ + @Post(':id/admins') + @HttpCode(HttpStatus.CREATED) + async createTenantAdmin( + @Headers('x-admin-id') adminId: string, + @Param('id') tenantId: string, + @Body() dto: CreateTenantAdminDto, + ) { + await this.checkSuperAdmin(adminId); + const admin = await this.tenantService.createTenantAdmin(tenantId, dto); + // 不返回密码哈希 + const { passwordHash, ...result } = admin; + return result; + } +} diff --git a/packages/services/evolution-service/src/super-admin/application/services/tenant-management.service.ts b/packages/services/evolution-service/src/super-admin/application/services/tenant-management.service.ts new file mode 100644 index 0000000..ef2029c --- /dev/null +++ b/packages/services/evolution-service/src/super-admin/application/services/tenant-management.service.ts @@ -0,0 +1,276 @@ +import { Injectable, ForbiddenException, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + TenantORM, + TenantStatus, + TenantPlan, + TenantConfigData, +} from '../../../infrastructure/database/postgres/entities/tenant.orm'; +import { AdminORM } from '../../../infrastructure/database/postgres/entities/admin.orm'; + +export interface CreateTenantDto { + name: string; + slug: string; + plan?: 'FREE' | 'STANDARD' | 'ENTERPRISE'; + maxUsers?: number; + maxConversationsPerMonth?: number; + maxStorageMb?: number; + billingEmail?: string; + billingName?: string; + billingPhone?: string; + config?: Record; +} + +export interface UpdateTenantDto { + name?: string; + plan?: 'FREE' | 'STANDARD' | 'ENTERPRISE'; + maxUsers?: number; + maxConversationsPerMonth?: number; + maxStorageMb?: number; + billingEmail?: string; + billingName?: string; + billingPhone?: string; + config?: Record; +} + +export interface CreateTenantAdminDto { + username: string; + password: string; + name: string; + email?: string; + phone?: string; + role?: 'ADMIN' | 'OPERATOR' | 'VIEWER'; +} + +@Injectable() +export class TenantManagementService { + constructor( + @InjectRepository(TenantORM) + private readonly tenantRepo: Repository, + @InjectRepository(AdminORM) + private readonly adminRepo: Repository, + ) {} + + /** + * 验证是否为超级管理员 + */ + async verifySuperAdmin(adminId: string): Promise { + const admin = await this.adminRepo.findOne({ where: { id: adminId } }); + if (!admin) { + throw new NotFoundException('管理员不存在'); + } + if (!admin.isSuperAdmin) { + throw new ForbiddenException('需要超级管理员权限'); + } + return admin; + } + + /** + * 获取所有租户列表 + */ + async listTenants(params?: { + status?: string; + plan?: string; + page?: number; + limit?: number; + }): Promise<{ data: TenantORM[]; total: number }> { + const { status, plan, page = 1, limit = 20 } = params || {}; + + const query = this.tenantRepo.createQueryBuilder('tenant'); + + if (status) { + query.andWhere('tenant.status = :status', { status }); + } + if (plan) { + query.andWhere('tenant.plan = :plan', { plan }); + } + + const total = await query.getCount(); + const data = await query + .orderBy('tenant.createdAt', 'DESC') + .skip((page - 1) * limit) + .take(limit) + .getMany(); + + return { data, total }; + } + + /** + * 获取租户详情 + */ + async getTenant(tenantId: string): Promise { + const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } }); + if (!tenant) { + throw new NotFoundException('租户不存在'); + } + return tenant; + } + + /** + * 创建租户 + */ + async createTenant(dto: CreateTenantDto): Promise { + // 检查 slug 是否已存在 + const existing = await this.tenantRepo.findOne({ where: { slug: dto.slug } }); + if (existing) { + throw new ForbiddenException(`租户标识 ${dto.slug} 已存在`); + } + + const tenant = this.tenantRepo.create({ + name: dto.name, + slug: dto.slug, + status: TenantStatus.ACTIVE, + plan: (dto.plan as TenantPlan) || TenantPlan.STANDARD, + maxUsers: dto.maxUsers || 100, + maxConversationsPerMonth: dto.maxConversationsPerMonth || 10000, + maxStorageMb: dto.maxStorageMb || 5120, + billingEmail: dto.billingEmail || null, + billingName: dto.billingName || null, + billingPhone: dto.billingPhone || null, + config: (dto.config || {}) as TenantConfigData, + }); + + return this.tenantRepo.save(tenant); + } + + /** + * 更新租户 + */ + async updateTenant(tenantId: string, dto: UpdateTenantDto): Promise { + const tenant = await this.getTenant(tenantId); + + if (dto.name !== undefined) tenant.name = dto.name; + if (dto.plan !== undefined) tenant.plan = dto.plan as TenantPlan; + if (dto.maxUsers !== undefined) tenant.maxUsers = dto.maxUsers; + if (dto.maxConversationsPerMonth !== undefined) tenant.maxConversationsPerMonth = dto.maxConversationsPerMonth; + if (dto.maxStorageMb !== undefined) tenant.maxStorageMb = dto.maxStorageMb; + if (dto.billingEmail !== undefined) tenant.billingEmail = dto.billingEmail || null; + if (dto.billingName !== undefined) tenant.billingName = dto.billingName || null; + if (dto.billingPhone !== undefined) tenant.billingPhone = dto.billingPhone || null; + if (dto.config !== undefined) tenant.config = { ...tenant.config, ...dto.config } as TenantConfigData; + + return this.tenantRepo.save(tenant); + } + + /** + * 暂停租户 + */ + async suspendTenant(tenantId: string, _reason?: string): Promise { + const tenant = await this.getTenant(tenantId); + + tenant.status = TenantStatus.SUSPENDED; + tenant.suspendedAt = new Date(); + + return this.tenantRepo.save(tenant); + } + + /** + * 激活租户 + */ + async activateTenant(tenantId: string): Promise { + const tenant = await this.getTenant(tenantId); + + tenant.status = TenantStatus.ACTIVE; + tenant.suspendedAt = null; + + return this.tenantRepo.save(tenant); + } + + /** + * 归档租户 + */ + async archiveTenant(tenantId: string): Promise { + const tenant = await this.getTenant(tenantId); + tenant.status = TenantStatus.ARCHIVED; + return this.tenantRepo.save(tenant); + } + + /** + * 获取租户的管理员列表 + */ + async listTenantAdmins(tenantId: string): Promise { + return this.adminRepo.find({ + where: { tenantId }, + select: ['id', 'username', 'name', 'email', 'phone', 'role', 'isActive', 'lastLoginAt', 'createdAt'], + }); + } + + /** + * 为租户创建管理员 + */ + async createTenantAdmin(tenantId: string, dto: CreateTenantAdminDto): Promise { + // 验证租户存在 + await this.getTenant(tenantId); + + // 检查用户名是否已存在 + const existing = await this.adminRepo.findOne({ where: { username: dto.username } }); + if (existing) { + throw new ForbiddenException(`用户名 ${dto.username} 已存在`); + } + + // 加密密码 + const bcrypt = await import('bcrypt'); + const crypto = await import('crypto'); + const passwordHash = await bcrypt.hash(dto.password, 10); + + const admin = this.adminRepo.create({ + id: crypto.randomUUID(), + tenantId, + isSuperAdmin: false, + username: dto.username, + passwordHash, + name: dto.name, + email: dto.email, + phone: dto.phone, + role: dto.role || 'OPERATOR', + permissions: [], + isActive: true, + }); + + return this.adminRepo.save(admin); + } + + /** + * 获取租户统计摘要 + */ + async getTenantStats(tenantId: string): Promise<{ + userCount: number; + conversationCount: number; + storageUsed: number; + adminCount: number; + }> { + const tenant = await this.getTenant(tenantId); + const adminCount = await this.adminRepo.count({ where: { tenantId } }); + + return { + userCount: tenant.currentUserCount, + conversationCount: tenant.currentConversationCount, + storageUsed: tenant.currentStorageBytes, + adminCount, + }; + } + + /** + * 获取所有租户的汇总统计 + */ + async getGlobalStats(): Promise<{ + totalTenants: number; + activeTenants: number; + suspendedTenants: number; + totalUsers: number; + totalConversations: number; + totalStorage: number; + }> { + const tenants = await this.tenantRepo.find(); + + return { + totalTenants: tenants.length, + activeTenants: tenants.filter(t => t.status === TenantStatus.ACTIVE).length, + suspendedTenants: tenants.filter(t => t.status === TenantStatus.SUSPENDED).length, + totalUsers: tenants.reduce((sum, t) => sum + t.currentUserCount, 0), + totalConversations: tenants.reduce((sum, t) => sum + t.currentConversationCount, 0), + totalStorage: tenants.reduce((sum, t) => sum + Number(t.currentStorageBytes), 0), + }; + } +} diff --git a/packages/services/evolution-service/src/super-admin/super-admin.module.ts b/packages/services/evolution-service/src/super-admin/super-admin.module.ts new file mode 100644 index 0000000..8f8e621 --- /dev/null +++ b/packages/services/evolution-service/src/super-admin/super-admin.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { TenantORM } from '../infrastructure/database/postgres/entities/tenant.orm'; +import { AdminORM } from '../infrastructure/database/postgres/entities/admin.orm'; +import { TenantManagementService } from './application/services/tenant-management.service'; +import { TenantController } from './adapters/inbound/tenant.controller'; + +/** + * 超级管理员模块 + * + * 提供租户管理功能,仅超级管理员可访问: + * - 租户 CRUD + * - 租户暂停/激活/归档 + * - 租户管理员管理 + * - 跨租户统计 + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([TenantORM, AdminORM]), + ], + controllers: [TenantController], + providers: [TenantManagementService], + exports: [TenantManagementService], +}) +export class SuperAdminModule {} diff --git a/packages/web-client/src/app/App.tsx b/packages/web-client/src/app/App.tsx index b689888..a44b5ef 100644 --- a/packages/web-client/src/app/App.tsx +++ b/packages/web-client/src/app/App.tsx @@ -1,6 +1,8 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Toaster } from '@/shared/components/Toaster'; import ChatPage from '@/features/chat/presentation/pages/ChatPage'; +import ProfilePage from '@/features/user/presentation/pages/ProfilePage'; +import BindPhonePage from '@/features/user/presentation/pages/BindPhonePage'; function App() { return ( @@ -10,6 +12,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/packages/web-client/src/features/chat/presentation/components/ChatSidebar.tsx b/packages/web-client/src/features/chat/presentation/components/ChatSidebar.tsx index 3041ab4..a8a34db 100644 --- a/packages/web-client/src/features/chat/presentation/components/ChatSidebar.tsx +++ b/packages/web-client/src/features/chat/presentation/components/ChatSidebar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Plus, MessageSquare, Trash2, ChevronLeft } from 'lucide-react'; +import { Plus, MessageSquare, Trash2, ChevronLeft, User, Settings, LogOut, Phone } from 'lucide-react'; import { clsx } from 'clsx'; import { useChatStore } from '../stores/chatStore'; import { useChat } from '../hooks/useChat'; @@ -81,16 +81,101 @@ export function ChatSidebar() { )} - {/* Footer */} -
-
- iConsulting v1.0.0 -
+ {/* Footer - User Profile */} +
+
); } +function UserProfileMenu() { + const navigate = useNavigate(); + const { userId } = useChatStore(); + const [menuOpen, setMenuOpen] = useState(false); + + const handleProfileClick = () => { + setMenuOpen(false); + navigate('/profile'); + }; + + const handleBindPhone = () => { + setMenuOpen(false); + navigate('/bind-phone'); + }; + + const handleLogout = () => { + setMenuOpen(false); + // 清除本地存储的用户信息 + localStorage.removeItem('userId'); + // 刷新页面以重新生成匿名用户 + window.location.href = '/chat'; + }; + + // 检查用户是否已绑定手机(通过本地存储或其他方式) + const isRegistered = localStorage.getItem('userPhone') !== null; + + return ( +
+ + + {/* Dropdown menu */} + {menuOpen && ( + <> + {/* Backdrop */} +
setMenuOpen(false)} + /> + {/* Menu */} +
+ + {!isRegistered && ( + + )} +
+ +
+ + )} +
+ ); +} + interface ConversationItemProps { id: string; title: string; diff --git a/packages/web-client/src/features/user/presentation/pages/BindPhonePage.tsx b/packages/web-client/src/features/user/presentation/pages/BindPhonePage.tsx new file mode 100644 index 0000000..7dfdbf4 --- /dev/null +++ b/packages/web-client/src/features/user/presentation/pages/BindPhonePage.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, Phone, Shield, Check } from 'lucide-react'; +import { useChatStore } from '@/features/chat/presentation/stores/chatStore'; + +export default function BindPhonePage() { + const navigate = useNavigate(); + const { userId } = useChatStore(); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [codeSent, setCodeSent] = useState(false); + const [countdown, setCountdown] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const sendCode = async () => { + if (!phone || phone.length < 11) { + setError('请输入正确的手机号'); + return; + } + + setLoading(true); + setError(''); + + try { + const response = await fetch('/api/v1/users/send-code', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-user-id': userId!, + }, + body: JSON.stringify({ phone }), + }); + const data = await response.json(); + + if (data.success) { + setCodeSent(true); + // 开始倒计时 + setCountdown(60); + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + } else { + setError(data.message || '发送失败,请稍后重试'); + } + } catch (err) { + setError('网络错误,请稍后重试'); + } finally { + setLoading(false); + } + }; + + const bindPhone = async () => { + if (!code || code.length < 4) { + setError('请输入验证码'); + return; + } + + setLoading(true); + setError(''); + + try { + const response = await fetch('/api/v1/users/bind-phone', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-user-id': userId!, + }, + body: JSON.stringify({ phone, code }), + }); + const data = await response.json(); + + if (data.success) { + setSuccess(true); + // 保存手机号到本地存储 + localStorage.setItem('userPhone', phone); + // 2秒后跳转 + setTimeout(() => { + navigate('/profile'); + }, 2000); + } else { + setError(data.message || '绑定失败,请检查验证码'); + } + } catch (err) { + setError('网络错误,请稍后重试'); + } finally { + setLoading(false); + } + }; + + if (success) { + return ( +
+
+
+ +
+

绑定成功

+

正在跳转...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

绑定手机

+
+
+ + {/* Content */} +
+
+ {/* Icon */} +
+ +
+ +

+ 绑定手机号 +

+

+ 绑定手机号后可保存对话记录,跨设备同步数据 +

+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Phone Input */} +
+ +
+
+ + setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))} + placeholder="请输入手机号" + className="w-full pl-10 pr-4 py-2.5 border border-secondary-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500" + disabled={codeSent && countdown > 0} + /> +
+ +
+
+ + {/* Code Input */} + {codeSent && ( +
+ +
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="请输入验证码" + className="w-full pl-10 pr-4 py-2.5 border border-secondary-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500" + /> +
+
+ )} + + {/* Submit Button */} + +
+ + {/* Back */} + +
+
+ ); +} diff --git a/packages/web-client/src/features/user/presentation/pages/ProfilePage.tsx b/packages/web-client/src/features/user/presentation/pages/ProfilePage.tsx new file mode 100644 index 0000000..124438b --- /dev/null +++ b/packages/web-client/src/features/user/presentation/pages/ProfilePage.tsx @@ -0,0 +1,217 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowLeft, User, Phone, Mail, Save, Check } from 'lucide-react'; +import { useChatStore } from '@/features/chat/presentation/stores/chatStore'; + +interface UserProfile { + id: string; + type: 'ANONYMOUS' | 'REGISTERED'; + phone?: string; + nickname?: string; + avatar?: string; + sourceChannel?: string; + createdAt: string; +} + +export default function ProfilePage() { + const navigate = useNavigate(); + const { userId } = useChatStore(); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [nickname, setNickname] = useState(''); + const [saved, setSaved] = useState(false); + + useEffect(() => { + if (userId) { + loadProfile(); + } + }, [userId]); + + const loadProfile = async () => { + try { + const response = await fetch(`/api/v1/users/${userId}`, { + headers: { + 'x-user-id': userId!, + }, + }); + const data = await response.json(); + if (data.success && data.data) { + setProfile(data.data); + setNickname(data.data.nickname || ''); + } + } catch (error) { + console.error('Failed to load profile:', error); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (!userId || !nickname.trim()) return; + + setSaving(true); + try { + const response = await fetch(`/api/v1/users/${userId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-user-id': userId, + }, + body: JSON.stringify({ nickname: nickname.trim() }), + }); + const data = await response.json(); + if (data.success) { + setProfile(data.data); + setSaved(true); + setTimeout(() => setSaved(false), 2000); + } + } catch (error) { + console.error('Failed to save profile:', error); + } finally { + setSaving(false); + } + }; + + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

个人资料

+
+
+ + {/* Content */} +
+ {/* Avatar Section */} +
+
+
+ +
+
+

+ {profile?.nickname || '访客用户'} +

+

+ {profile?.type === 'REGISTERED' ? '已注册用户' : '匿名访客'} +

+
+
+
+ + {/* Profile Info */} +
+

基本信息

+ + {/* Nickname */} +
+ +
+ setNickname(e.target.value)} + placeholder="设置你的昵称" + className="flex-1 px-4 py-2.5 border border-secondary-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500" + /> + +
+
+ + {/* Phone */} +
+ +
+ + {profile?.phone ? ( + {profile.phone} + ) : ( + + )} +
+
+ + {/* User ID */} +
+ +
+ + + {profile?.id || userId} + +
+
+ + {/* Created At */} +
+ +
+ {profile?.createdAt ? formatDate(profile.createdAt) : '未知'} +
+
+
+ + {/* Back Button */} + +
+
+ ); +} diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh new file mode 100644 index 0000000..d38b37a --- /dev/null +++ b/scripts/docker-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh +set -e + +echo "==========================================" +echo "iConsulting Database Initialization" +echo "==========================================" + +# Wait for database to be ready +echo "Waiting for database..." +until pg_isready -h ${DB_HOST:-postgres} -p ${DB_PORT:-5432} -U ${DB_USER:-postgres} 2>/dev/null; do + echo "Database is unavailable - sleeping" + sleep 2 +done +echo "Database is ready!" + +# Run seed script +echo "Running seed script..." +cd /app/scripts +npx ts-node seed.ts + +echo "==========================================" +echo "Database initialization completed!" +echo "==========================================" + +# Execute the main command +exec "$@" diff --git a/scripts/init-db.sql b/scripts/init-db.sql index 2705c60..984f4dc 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -7,12 +7,70 @@ CREATE EXTENSION IF NOT EXISTS vector; -- pgvector: 向量存储和相似度搜索 CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID生成函数 +-- =========================================== +-- 租户表 (tenants) +-- 多租户架构核心表,存储租户配置和限额 +-- =========================================== +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + -- 租户名称 + name VARCHAR(100) NOT NULL UNIQUE, + -- 租户标识符(URL友好) + slug VARCHAR(50) NOT NULL UNIQUE, + -- 租户状态: ACTIVE(激活), SUSPENDED(暂停), ARCHIVED(归档) + status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'SUSPENDED', 'ARCHIVED')), + -- 订阅计划: FREE, STANDARD, ENTERPRISE + plan VARCHAR(30) NOT NULL DEFAULT 'STANDARD' + CHECK (plan IN ('FREE', 'STANDARD', 'ENTERPRISE')), + -- 最大用户数限制 + max_users INT NOT NULL DEFAULT 100, + -- 每月最大对话数限制 + max_conversations_per_month INT NOT NULL DEFAULT 10000, + -- 最大存储容量(MB) + max_storage_mb INT NOT NULL DEFAULT 5120, + -- 当前用户数 + current_user_count INT NOT NULL DEFAULT 0, + -- 当前对话数 + current_conversation_count INT NOT NULL DEFAULT 0, + -- 当前存储使用(字节) + current_storage_bytes BIGINT NOT NULL DEFAULT 0, + -- 租户配置(JSON) + config JSONB NOT NULL DEFAULT '{}', + -- 账单邮箱 + billing_email VARCHAR(255), + -- 账单联系人姓名 + billing_name VARCHAR(100), + -- 账单联系电话 + billing_phone VARCHAR(20), + -- 创建时间 + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 更新时间 + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + -- 暂停时间 + suspended_at TIMESTAMP WITH TIME ZONE +); + +COMMENT ON TABLE tenants IS '租户表 - 多租户架构核心表,存储租户配置和限额'; +COMMENT ON COLUMN tenants.status IS '租户状态: ACTIVE=激活, SUSPENDED=暂停, ARCHIVED=归档'; +COMMENT ON COLUMN tenants.plan IS '订阅计划,影响功能和限额'; +COMMENT ON COLUMN tenants.config IS '租户配置,包含品牌、AI参数、功能开关等'; + +CREATE INDEX idx_tenants_slug ON tenants(slug); +CREATE INDEX idx_tenants_status ON tenants(status); + +-- 默认租户 (迁移现有数据使用) +INSERT INTO tenants (id, name, slug, status, plan) +VALUES ('00000000-0000-0000-0000-000000000001', 'Default Tenant', 'default', 'ACTIVE', 'ENTERPRISE'); + -- =========================================== -- 用户表 (users) -- 存储系统用户信息,支持匿名用户和注册用户 -- =========================================== CREATE TABLE users ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 用户类型: ANONYMOUS(匿名访客), REGISTERED(已注册用户) type VARCHAR(20) NOT NULL DEFAULT 'ANONYMOUS' CHECK (type IN ('ANONYMOUS', 'REGISTERED')), @@ -53,6 +111,9 @@ CREATE INDEX idx_users_phone ON users(phone); CREATE INDEX idx_users_type ON users(type); CREATE INDEX idx_users_source_channel ON users(source_channel); CREATE INDEX idx_users_created_at ON users(created_at); +CREATE INDEX idx_users_tenant ON users(tenant_id); +CREATE INDEX idx_users_tenant_phone ON users(tenant_id, phone) WHERE phone IS NOT NULL; +CREATE INDEX idx_users_tenant_fingerprint ON users(tenant_id, fingerprint) WHERE fingerprint IS NOT NULL; -- =========================================== -- 对话表 (conversations) @@ -60,6 +121,8 @@ CREATE INDEX idx_users_created_at ON users(created_at); -- =========================================== CREATE TABLE conversations ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 所属用户ID(可为空,支持匿名用户;如果关联注册用户则有外键) -- 注意:不使用外键约束,允许匿名访客使用(user_id为随机UUID或null) user_id UUID, @@ -122,6 +185,10 @@ CREATE INDEX idx_conversations_has_converted ON conversations(has_converted) WHE CREATE INDEX idx_conversations_consulting_stage ON conversations(consulting_stage); CREATE INDEX idx_conversations_conversion_path ON conversations(conversion_path); CREATE INDEX idx_conversations_collected_info ON conversations USING GIN(collected_info); +-- 租户索引 +CREATE INDEX idx_conversations_tenant ON conversations(tenant_id); +CREATE INDEX idx_conversations_tenant_user ON conversations(tenant_id, user_id); +CREATE INDEX idx_conversations_tenant_status ON conversations(tenant_id, status); -- =========================================== -- 消息表 (messages) @@ -129,6 +196,8 @@ CREATE INDEX idx_conversations_collected_info ON conversations USING GIN(collect -- =========================================== CREATE TABLE messages ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 所属对话ID conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, -- 消息角色: user(用户), assistant(AI助手), system(系统) @@ -155,6 +224,7 @@ 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); +CREATE INDEX idx_messages_tenant ON messages(tenant_id); -- =========================================== -- 订单表 (orders) @@ -162,6 +232,8 @@ CREATE INDEX idx_messages_created_at ON messages(created_at); -- =========================================== CREATE TABLE orders ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 订单号(用于展示,格式:ORD + 年月日 + 序号) order_no VARCHAR(50) UNIQUE, -- 所属用户ID @@ -227,6 +299,9 @@ 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; -- 复合索引:用于按用户查询特定状态订单 CREATE INDEX idx_orders_user_status ON orders(user_id, status); +-- 租户索引 +CREATE INDEX idx_orders_tenant ON orders(tenant_id); +CREATE INDEX idx_orders_tenant_user ON orders(tenant_id, user_id); -- =========================================== -- 支付表 (payments) @@ -234,6 +309,8 @@ CREATE INDEX idx_orders_user_status ON orders(user_id, status); -- =========================================== CREATE TABLE payments ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 关联的订单ID order_id UUID REFERENCES orders(id) ON DELETE CASCADE, -- 支付方式 @@ -279,6 +356,8 @@ CREATE INDEX idx_payments_created_at ON payments(created_at); CREATE INDEX idx_payments_order_status ON payments(order_id, status); -- 唯一索引:确保 transaction_id 唯一性(幂等性保证,仅非空值) CREATE UNIQUE INDEX idx_payments_transaction_id_unique ON payments(transaction_id) WHERE transaction_id IS NOT NULL; +-- 租户索引 +CREATE INDEX idx_payments_tenant ON payments(tenant_id); -- =========================================== -- 分类账/财务流水表 (ledger_entries) @@ -351,6 +430,8 @@ CREATE INDEX idx_ledger_entries_business_type ON ledger_entries(business_type); -- =========================================== CREATE TABLE daily_statistics ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 统计日期 stat_date DATE NOT NULL, -- 统计维度: OVERALL(总体), CHANNEL(渠道), CATEGORY(移民类别) @@ -411,8 +492,8 @@ CREATE TABLE daily_statistics ( -- 更新时间 updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), - -- 唯一约束:同一天同一维度同一维度值只有一条记录 - UNIQUE(stat_date, dimension, dimension_value) + -- 唯一约束:同一租户同一天同一维度同一维度值只有一条记录 + UNIQUE(tenant_id, stat_date, dimension, dimension_value) ); COMMENT ON TABLE daily_statistics IS '日统计表 - 预聚合的每日统计数据,支持多维度分析'; @@ -422,6 +503,7 @@ COMMENT ON COLUMN daily_statistics.estimated_api_cost IS '预估Claude API成本 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); +CREATE INDEX idx_daily_statistics_tenant ON daily_statistics(tenant_id); -- =========================================== -- 月度财务报表 (monthly_financial_reports) @@ -429,8 +511,10 @@ CREATE INDEX idx_daily_statistics_dimension ON daily_statistics(dimension, dimen -- =========================================== CREATE TABLE monthly_financial_reports ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 报表月份(格式:YYYY-MM) - report_month VARCHAR(7) NOT NULL UNIQUE, + report_month VARCHAR(7) NOT NULL, -- ===== 收入统计 ===== -- 总收入 @@ -508,6 +592,8 @@ CREATE INDEX idx_monthly_financial_reports_status ON monthly_financial_reports(s -- =========================================== CREATE TABLE audit_logs ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 操作者ID(用户或管理员) actor_id UUID, -- 操作者类型: USER, ADMIN, SYSTEM @@ -555,6 +641,8 @@ 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); +-- 租户索引 +CREATE INDEX idx_audit_logs_tenant ON audit_logs(tenant_id); -- =========================================== -- 发件箱表 (outbox) @@ -830,6 +918,8 @@ CREATE INDEX idx_evolution_logs_created_at ON evolution_logs(created_at DESC); -- =========================================== CREATE TABLE verification_codes ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 手机号 phone VARCHAR(20) NOT NULL, -- 验证码 @@ -851,6 +941,9 @@ 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); +-- 租户索引 +CREATE INDEX idx_verification_codes_tenant ON verification_codes(tenant_id); +CREATE INDEX idx_verification_codes_tenant_phone ON verification_codes(tenant_id, phone); -- =========================================== -- 服务定价表 (service_pricing) @@ -1219,12 +1312,18 @@ ORDER BY total_users DESC; COMMENT ON VIEW v_channel_statistics IS '渠道统计视图 - 按来源渠道汇总用户和收入数据'; +-- 添加 monthly_financial_reports 的租户唯一约束和索引 +ALTER TABLE monthly_financial_reports ADD CONSTRAINT monthly_financial_reports_tenant_month_unique UNIQUE(tenant_id, report_month); +CREATE INDEX idx_monthly_financial_reports_tenant ON monthly_financial_reports(tenant_id); + -- =========================================== -- 知识文章表 (knowledge_articles) -- 存储移民相关的知识内容,支持RAG检索 -- =========================================== CREATE TABLE knowledge_articles ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 文章标题 title VARCHAR(500) NOT NULL, -- 文章内容(纯文本或Markdown) @@ -1272,6 +1371,8 @@ 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); +-- 租户索引 +CREATE INDEX idx_knowledge_articles_tenant ON knowledge_articles(tenant_id); -- =========================================== -- 知识块表 (knowledge_chunks) @@ -1279,6 +1380,8 @@ CREATE INDEX idx_knowledge_articles_embedding ON knowledge_articles USING ivffla -- =========================================== CREATE TABLE knowledge_chunks ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 所属文章ID article_id UUID NOT NULL REFERENCES knowledge_articles(id) ON DELETE CASCADE, -- 块内容 @@ -1304,6 +1407,8 @@ 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); +-- 租户索引 +CREATE INDEX idx_knowledge_chunks_tenant ON knowledge_chunks(tenant_id); -- =========================================== -- 用户记忆表 (user_memories) @@ -1311,6 +1416,8 @@ CREATE INDEX idx_knowledge_chunks_embedding ON knowledge_chunks USING ivfflat (e -- =========================================== CREATE TABLE user_memories ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 用户ID(支持匿名用户,不使用外键约束) user_id UUID NOT NULL, -- 记忆类型 @@ -1361,6 +1468,8 @@ 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); +-- 租户索引 +CREATE INDEX idx_user_memories_tenant ON user_memories(tenant_id); -- =========================================== -- 系统经验表 (system_experiences) @@ -1368,6 +1477,8 @@ CREATE INDEX idx_user_memories_embedding ON user_memories USING ivfflat (embeddi -- =========================================== CREATE TABLE system_experiences ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 经验类型 experience_type VARCHAR(30) NOT NULL CHECK (experience_type IN ( @@ -1426,6 +1537,8 @@ CREATE INDEX idx_system_experiences_status ON system_experiences(verification_st 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); +-- 租户索引 +CREATE INDEX idx_system_experiences_tenant ON system_experiences(tenant_id); -- =========================================== -- 管理员表 (admins) @@ -1433,6 +1546,10 @@ CREATE INDEX idx_system_experiences_embedding ON system_experiences USING ivffla -- =========================================== CREATE TABLE admins ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID(超级管理员可为空) + tenant_id UUID, + -- 是否为超级管理员 + is_super_admin BOOLEAN NOT NULL DEFAULT FALSE, -- 用户名(登录用) username VARCHAR(50) NOT NULL UNIQUE, -- 密码哈希 @@ -1469,11 +1586,14 @@ 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); +-- 租户索引 +CREATE INDEX idx_admins_tenant ON admins(tenant_id); +CREATE INDEX idx_admins_username_tenant ON admins(tenant_id, username); -- 插入默认超级管理员(密码: admin123,实际生产环境需要修改) -- bcrypt hash for 'admin123' generated with cost 10 -INSERT INTO admins (username, password_hash, name, role, permissions) VALUES -('admin', '$2b$10$79R2HdSS0Yez9lG5eSdVMutBQu4ew6fb2qzHNeAhu5p70JmDoYsde', '系统管理员', 'SUPER_ADMIN', '["*"]'); +INSERT INTO admins (tenant_id, is_super_admin, username, password_hash, name, role, permissions) VALUES +('00000000-0000-0000-0000-000000000001', TRUE, 'admin', '$2b$10$79R2HdSS0Yez9lG5eSdVMutBQu4ew6fb2qzHNeAhu5p70JmDoYsde', '系统管理员', 'SUPER_ADMIN', '["*"]'); -- =========================================== -- 文件表 (files) @@ -1481,6 +1601,8 @@ INSERT INTO admins (username, password_hash, name, role, permissions) VALUES -- =========================================== CREATE TABLE files ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 所属用户ID user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- 关联的对话ID(可选) @@ -1526,6 +1648,9 @@ CREATE INDEX idx_files_conversation_id ON files(conversation_id); CREATE INDEX idx_files_user_created ON files(user_id, created_at DESC); CREATE INDEX idx_files_status ON files(status); CREATE INDEX idx_files_type ON files(type); +-- 租户索引 +CREATE INDEX idx_files_tenant ON files(tenant_id); +CREATE INDEX idx_files_tenant_user ON files(tenant_id, user_id); CREATE TRIGGER update_files_updated_at BEFORE UPDATE ON files FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); @@ -1535,6 +1660,8 @@ CREATE TRIGGER update_files_updated_at BEFORE UPDATE ON files FOR EACH ROW EXECU -- =========================================== CREATE TABLE token_usages ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + -- 租户ID + tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001', -- 所属用户ID(可为空,支持匿名用户) user_id UUID, -- 所属对话ID @@ -1580,6 +1707,8 @@ CREATE INDEX idx_token_usages_model ON token_usages(model); CREATE INDEX idx_token_usages_intent ON token_usages(intent_type); -- BRIN索引,用于时间范围查询优化 CREATE INDEX idx_token_usages_created_brin ON token_usages USING BRIN(created_at); +-- 租户索引 +CREATE INDEX idx_token_usages_tenant ON token_usages(tenant_id); -- =========================================== -- 结束 diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..aaac8da --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,20 @@ +{ + "name": "@iconsulting/scripts", + "version": "1.0.0", + "private": true, + "scripts": { + "seed": "ts-node seed.ts", + "seed:prod": "node dist/seed.js" + }, + "dependencies": { + "pg": "^8.11.3", + "bcrypt": "^5.1.1" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/node": "^20.10.6", + "@types/pg": "^8.10.9", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..dd1bd74 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,241 @@ +/** + * 数据库种子脚本 + * + * 用法: + * npx ts-node scripts/seed.ts + * + * 或在 docker-compose 中启动时执行 + * + * 环境变量: + * DATABASE_URL: 数据库连接字符串 + * DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME: 单独配置 + */ + +import { Client } from 'pg'; +import * as bcrypt from 'bcrypt'; + +// 默认租户 ID +const DEFAULT_TENANT_ID = '00000000-0000-0000-0000-000000000001'; + +// 数据库连接配置 +function getDbConfig() { + if (process.env.DATABASE_URL) { + return { connectionString: process.env.DATABASE_URL }; + } + return { + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + user: process.env.DB_USER || 'postgres', + password: process.env.DB_PASSWORD || 'postgres', + database: process.env.DB_NAME || 'iconsulting', + }; +} + +async function seed() { + const client = new Client(getDbConfig()); + + try { + await client.connect(); + console.log('Connected to database'); + + // 1. 检查并创建默认租户 + await seedTenant(client); + + // 2. 检查并创建默认管理员 + await seedAdmin(client); + + // 3. 检查并创建默认系统配置 + await seedSystemConfigs(client); + + // 4. 检查并创建默认服务定价 + await seedServicePricing(client); + + console.log('Seed completed successfully!'); + } catch (error) { + console.error('Seed failed:', error); + process.exit(1); + } finally { + await client.end(); + } +} + +async function seedTenant(client: Client) { + console.log('Checking tenants table...'); + + // 检查 tenants 表是否存在 + const tableExists = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'tenants' + ); + `); + + if (!tableExists.rows[0].exists) { + console.log(' tenants table does not exist, skipping'); + return; + } + + // 检查默认租户是否存在 + const tenantExists = await client.query( + 'SELECT id FROM tenants WHERE id = $1', + [DEFAULT_TENANT_ID] + ); + + if (tenantExists.rows.length === 0) { + console.log(' Creating default tenant...'); + await client.query(` + INSERT INTO tenants (id, name, slug, status, plan) + VALUES ($1, 'Default Tenant', 'default', 'ACTIVE', 'ENTERPRISE') + `, [DEFAULT_TENANT_ID]); + console.log(' Default tenant created'); + } else { + console.log(' Default tenant already exists'); + } +} + +async function seedAdmin(client: Client) { + console.log('Checking admins table...'); + + // 检查 admins 表是否存在 + const tableExists = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'admins' + ); + `); + + if (!tableExists.rows[0].exists) { + console.log(' admins table does not exist, skipping'); + return; + } + + // 检查默认管理员是否存在 + const adminExists = await client.query( + "SELECT id FROM admins WHERE username = 'admin'" + ); + + if (adminExists.rows.length === 0) { + console.log(' Creating default admin...'); + // bcrypt hash for 'admin123' with cost 10 + const passwordHash = await bcrypt.hash('admin123', 10); + + // 检查表结构是否包含 tenant_id 和 is_super_admin + const hasNewColumns = await client.query(` + SELECT column_name FROM information_schema.columns + WHERE table_name = 'admins' + AND column_name IN ('tenant_id', 'is_super_admin') + `); + + if (hasNewColumns.rows.length >= 2) { + // 新表结构 + await client.query(` + INSERT INTO admins (tenant_id, is_super_admin, username, password_hash, name, role, permissions) + VALUES ($1, TRUE, 'admin', $2, '系统管理员', 'SUPER_ADMIN', '["*"]') + `, [DEFAULT_TENANT_ID, passwordHash]); + } else { + // 旧表结构 + await client.query(` + INSERT INTO admins (username, password_hash, name, role, permissions) + VALUES ('admin', $1, '系统管理员', 'SUPER_ADMIN', '["*"]') + `, [passwordHash]); + } + console.log(' Default admin created (username: admin, password: admin123)'); + } else { + console.log(' Default admin already exists'); + } +} + +async function seedSystemConfigs(client: Client) { + console.log('Checking system_configs table...'); + + // 检查 system_configs 表是否存在 + const tableExists = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'system_configs' + ); + `); + + if (!tableExists.rows[0].exists) { + console.log(' system_configs table does not exist, skipping'); + return; + } + + const configs = [ + { key: 'system_prompt_identity', value: '"专业、友善、耐心的香港移民顾问"', group: 'PROMPT', desc: '系统身份定位' }, + { key: 'system_prompt_style', value: '"专业但不生硬,用简洁明了的语言解答"', group: 'PROMPT', desc: '对话风格' }, + { key: 'system_prompt_rules', value: '["只回答移民相关问题", "复杂评估建议付费服务", "不做法律承诺"]', group: 'PROMPT', desc: '系统行为规则' }, + { key: 'max_free_messages_per_day', value: '50', group: 'SYSTEM', desc: '每日免费消息数限制' }, + { key: 'max_conversation_context_messages', value: '20', group: 'SYSTEM', desc: '对话上下文最大消息数' }, + { key: 'assessment_price_default', value: '99', group: 'PAYMENT', desc: '默认评估价格(元)' }, + { key: 'payment_timeout_minutes', value: '30', group: 'PAYMENT', desc: '支付超时时间(分钟)' }, + { key: 'sms_rate_limit_per_hour', value: '5', group: 'SYSTEM', desc: '每小时短信发送限制' }, + { key: 'enable_anonymous_chat', value: 'true', group: 'FEATURE', desc: '是否允许匿名用户聊天' }, + { key: 'require_phone_for_payment', value: 'true', group: 'FEATURE', desc: '支付时是否要求手机验证' }, + ]; + + for (const config of configs) { + const exists = await client.query( + 'SELECT key FROM system_configs WHERE key = $1', + [config.key] + ); + + if (exists.rows.length === 0) { + await client.query(` + INSERT INTO system_configs (key, value, config_group, description) + VALUES ($1, $2, $3, $4) + `, [config.key, config.value, config.group, config.desc]); + console.log(` Created config: ${config.key}`); + } + } + console.log(' System configs seeded'); +} + +async function seedServicePricing(client: Client) { + console.log('Checking service_pricing table...'); + + // 检查 service_pricing 表是否存在 + const tableExists = await client.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'service_pricing' + ); + `); + + if (!tableExists.rows[0].exists) { + console.log(' service_pricing table does not exist, skipping'); + return; + } + + const pricing = [ + { type: 'ASSESSMENT', category: 'QMAS', price: 99, originalPrice: 199, desc: '优才计划移民资格评估' }, + { type: 'ASSESSMENT', category: 'GEP', price: 99, originalPrice: 199, desc: '专才计划移民资格评估' }, + { type: 'ASSESSMENT', category: 'IANG', price: 79, originalPrice: 149, desc: '留学IANG移民资格评估' }, + { type: 'ASSESSMENT', category: 'TTPS', price: 99, originalPrice: 199, desc: '高才通移民资格评估' }, + { type: 'ASSESSMENT', category: 'CIES', price: 199, originalPrice: 399, desc: '投资移民资格评估' }, + { type: 'ASSESSMENT', category: 'TECHTAS', price: 99, originalPrice: 199, desc: '科技人才移民资格评估' }, + ]; + + for (const p of pricing) { + const exists = await client.query( + 'SELECT id FROM service_pricing WHERE service_type = $1 AND category = $2', + [p.type, p.category] + ); + + if (exists.rows.length === 0) { + await client.query(` + INSERT INTO service_pricing (service_type, category, price, original_price, currency, description) + VALUES ($1, $2, $3, $4, 'CNY', $5) + `, [p.type, p.category, p.price, p.originalPrice, p.desc]); + console.log(` Created pricing: ${p.type} - ${p.category}`); + } + } + console.log(' Service pricing seeded'); +} + +// 运行种子脚本 +seed(); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..644599b --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false + }, + "include": ["*.ts"], + "exclude": ["node_modules", "dist"] +}