feat(multi-tenant): add super admin module and user profile pages

- Add SuperAdmin module for tenant management (CRUD, suspend/activate)
- Add tenant management REST API (/super-admin/tenants/*)
- Add user profile menu in ChatSidebar with dropdown
- Add ProfilePage and BindPhonePage for user account management
- Update init-db.sql with tenant_id columns for all 16 tables
- Add database seed script (scripts/seed.ts) with ts-node
- Integrate db:seed into deploy.sh rebuild command

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-26 06:41:11 -08:00
parent 1ac183fc0c
commit e1e9ba1a77
16 changed files with 1565 additions and 21 deletions

View File

@ -395,6 +395,46 @@ start_infrastructure() {
log_success "基础设施启动完成" 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() { init_database() {
log_step "检查数据库初始化状态..." 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) 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 if [ "$table_exists" = "t" ]; then
log_success "数据库已初始化,跳过" log_success "数据库已初始化"
# 即使数据库已初始化,也运行 seed 确保种子数据存在
run_db_seed
return 0 return 0
fi fi
@ -430,6 +472,9 @@ init_database() {
else else
log_warning "pgvector 扩展未能启用,向量搜索功能可能受限" log_warning "pgvector 扩展未能启用,向量搜索功能可能受限"
fi fi
# 运行种子脚本确保数据完整
run_db_seed
else else
log_error "数据库初始化失败,请检查 init-db.sql" log_error "数据库初始化失败,请检查 init-db.sql"
return 1 return 1
@ -610,21 +655,23 @@ do_rebuild() {
local target=${1:-all} local target=${1:-all}
local no_cache="" local no_cache=""
local cache_msg="" local cache_msg=""
local run_seed=false
# 检查是否有 --no-cache 参数 # 检查是否有 --no-cache 或 --seed 参数
for arg in "$@"; do for arg in "$@"; do
if [ "$arg" = "--no-cache" ]; then if [ "$arg" = "--no-cache" ]; then
no_cache="--no-cache" no_cache="--no-cache"
cache_msg=" (--no-cache)" cache_msg=" (--no-cache)"
fi fi
if [ "$arg" = "--seed" ]; then
run_seed=true
fi
done done
if [ "$target" = "all" ] || [ "$target" = "--no-cache" ]; then if [ "$target" = "all" ] || [ "$target" = "--no-cache" ] || [ "$target" = "--seed" ]; then
# 如果第一个参数是 --no-cache,则 target 是 all # 如果第一个参数是 --no-cache 或 --seed,则 target 是 all
if [ "$target" = "--no-cache" ]; then if [ "$target" = "--no-cache" ] || [ "$target" = "--seed" ]; then
target="all" target="all"
no_cache="--no-cache"
cache_msg=" (--no-cache)"
fi fi
log_step "重新构建所有服务镜像${cache_msg}..." log_step "重新构建所有服务镜像${cache_msg}..."
@ -633,6 +680,12 @@ do_rebuild() {
# 重启所有后端服务 # 重启所有后端服务
log_step "重启所有后端服务..." log_step "重启所有后端服务..."
$DOCKER_COMPOSE up -d user-service payment-service knowledge-service conversation-service evolution-service file-service $DOCKER_COMPOSE up -d user-service payment-service knowledge-service conversation-service evolution-service file-service
# 等待服务就绪
sleep 5
# 运行种子脚本
run_db_seed
else else
# 获取服务名 # 获取服务名
local service_name="" local service_name=""
@ -654,6 +707,12 @@ do_rebuild() {
# 重启服务使用新镜像 # 重启服务使用新镜像
log_step "重启 $target 服务..." log_step "重启 $target 服务..."
$DOCKER_COMPOSE up -d "$service_name" $DOCKER_COMPOSE up -d "$service_name"
# 如果重建的是 evolution 服务,也运行 seed (因为管理员在这个服务)
if [ "$target" = "evolution" ] || [ "$run_seed" = true ]; then
sleep 3
run_db_seed
fi
fi fi
log_success "镜像重新构建并重启完成" log_success "镜像重新构建并重启完成"
@ -1551,8 +1610,8 @@ do_db() {
init_database init_database
;; ;;
seed) seed)
log_step "初始化种子数据..." log_step "初始化种子数据 (使用 ts-node)..."
# 添加种子数据脚本 run_db_seed
log_success "种子数据初始化完成" log_success "种子数据初始化完成"
;; ;;
backup) backup)

View File

@ -117,6 +117,32 @@ services:
networks: networks:
- iconsulting-network - 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 模式) # Kong API 网关 (DB-less 模式)
#============================================================================= #=============================================================================

View File

@ -5,7 +5,8 @@
"private": true, "private": true,
"workspaces": [ "workspaces": [
"packages/*", "packages/*",
"packages/services/*" "packages/services/*",
"scripts"
], ],
"scripts": { "scripts": {
"dev": "turbo run dev", "dev": "turbo run dev",
@ -14,6 +15,7 @@
"test": "turbo run test", "test": "turbo run test",
"clean": "turbo run clean && rm -rf node_modules", "clean": "turbo run clean && rm -rf node_modules",
"db:migrate": "turbo run db:migrate", "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:dev": "docker-compose -f infrastructure/docker/docker-compose.dev.yml up -d",
"docker:down": "docker-compose -f infrastructure/docker/docker-compose.dev.yml down" "docker:down": "docker-compose -f infrastructure/docker/docker-compose.dev.yml down"
}, },

View File

@ -13,6 +13,7 @@ import { AdminModule } from './admin/admin.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { AnalyticsModule } from './analytics/analytics.module'; import { AnalyticsModule } from './analytics/analytics.module';
import { TenantModule } from './infrastructure/tenant/tenant.module'; import { TenantModule } from './infrastructure/tenant/tenant.module';
import { SuperAdminModule } from './super-admin/super-admin.module';
@Module({ @Module({
imports: [ imports: [
@ -64,6 +65,9 @@ import { TenantModule } from './infrastructure/tenant/tenant.module';
EvolutionModule, EvolutionModule,
AdminModule, AdminModule,
AnalyticsModule, AnalyticsModule,
// 超级管理员模块 (租户管理)
SuperAdminModule,
], ],
}) })
export class AppModule implements NestModule { export class AppModule implements NestModule {

View File

@ -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;
}
}

View File

@ -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<string, any>;
}
export interface UpdateTenantDto {
name?: string;
plan?: 'FREE' | 'STANDARD' | 'ENTERPRISE';
maxUsers?: number;
maxConversationsPerMonth?: number;
maxStorageMb?: number;
billingEmail?: string;
billingName?: string;
billingPhone?: string;
config?: Record<string, any>;
}
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<TenantORM>,
@InjectRepository(AdminORM)
private readonly adminRepo: Repository<AdminORM>,
) {}
/**
*
*/
async verifySuperAdmin(adminId: string): Promise<AdminORM> {
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<TenantORM> {
const tenant = await this.tenantRepo.findOne({ where: { id: tenantId } });
if (!tenant) {
throw new NotFoundException('租户不存在');
}
return tenant;
}
/**
*
*/
async createTenant(dto: CreateTenantDto): Promise<TenantORM> {
// 检查 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<TenantORM> {
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<TenantORM> {
const tenant = await this.getTenant(tenantId);
tenant.status = TenantStatus.SUSPENDED;
tenant.suspendedAt = new Date();
return this.tenantRepo.save(tenant);
}
/**
*
*/
async activateTenant(tenantId: string): Promise<TenantORM> {
const tenant = await this.getTenant(tenantId);
tenant.status = TenantStatus.ACTIVE;
tenant.suspendedAt = null;
return this.tenantRepo.save(tenant);
}
/**
*
*/
async archiveTenant(tenantId: string): Promise<TenantORM> {
const tenant = await this.getTenant(tenantId);
tenant.status = TenantStatus.ARCHIVED;
return this.tenantRepo.save(tenant);
}
/**
*
*/
async listTenantAdmins(tenantId: string): Promise<AdminORM[]> {
return this.adminRepo.find({
where: { tenantId },
select: ['id', 'username', 'name', 'email', 'phone', 'role', 'isActive', 'lastLoginAt', 'createdAt'],
});
}
/**
*
*/
async createTenantAdmin(tenantId: string, dto: CreateTenantAdminDto): Promise<AdminORM> {
// 验证租户存在
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),
};
}
}

View File

@ -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 {}

View File

@ -1,6 +1,8 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Toaster } from '@/shared/components/Toaster'; import { Toaster } from '@/shared/components/Toaster';
import ChatPage from '@/features/chat/presentation/pages/ChatPage'; 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() { function App() {
return ( return (
@ -10,6 +12,8 @@ function App() {
<Route path="/" element={<ChatPage />} /> <Route path="/" element={<ChatPage />} />
<Route path="/chat" element={<ChatPage />} /> <Route path="/chat" element={<ChatPage />} />
<Route path="/chat/:conversationId" element={<ChatPage />} /> <Route path="/chat/:conversationId" element={<ChatPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/bind-phone" element={<BindPhonePage />} />
</Routes> </Routes>
<Toaster /> <Toaster />
</div> </div>

View File

@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; 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 { clsx } from 'clsx';
import { useChatStore } from '../stores/chatStore'; import { useChatStore } from '../stores/chatStore';
import { useChat } from '../hooks/useChat'; import { useChat } from '../hooks/useChat';
@ -81,16 +81,101 @@ export function ChatSidebar() {
)} )}
</div> </div>
{/* Footer */} {/* Footer - User Profile */}
<div className="p-4 border-t border-secondary-200"> <div className="p-3 border-t border-secondary-200">
<div className="text-xs text-secondary-400 text-center"> <UserProfileMenu />
iConsulting v1.0.0
</div>
</div> </div>
</div> </div>
); );
} }
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 (
<div className="relative">
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-secondary-50 transition-colors text-left"
>
<div className="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-primary-600" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-secondary-700 truncate">
{isRegistered ? '已注册用户' : '访客用户'}
</p>
<p className="text-xs text-secondary-400 truncate">
{userId ? `ID: ${userId.slice(0, 8)}...` : '未登录'}
</p>
</div>
<Settings className="w-4 h-4 text-secondary-400" />
</button>
{/* Dropdown menu */}
{menuOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
/>
{/* Menu */}
<div className="absolute bottom-full left-0 right-0 mb-1 bg-white rounded-lg shadow-lg border border-secondary-200 overflow-hidden z-20">
<button
onClick={handleProfileClick}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-secondary-50 transition-colors text-left"
>
<User className="w-4 h-4 text-secondary-500" />
<span className="text-sm text-secondary-700"></span>
</button>
{!isRegistered && (
<button
onClick={handleBindPhone}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-secondary-50 transition-colors text-left"
>
<Phone className="w-4 h-4 text-secondary-500" />
<span className="text-sm text-secondary-700"></span>
</button>
)}
<div className="border-t border-secondary-100" />
<button
onClick={handleLogout}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-red-50 transition-colors text-left"
>
<LogOut className="w-4 h-4 text-red-500" />
<span className="text-sm text-red-600">退</span>
</button>
</div>
</>
)}
</div>
);
}
interface ConversationItemProps { interface ConversationItemProps {
id: string; id: string;
title: string; title: string;

View File

@ -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 (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-white flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Check className="w-8 h-8 text-green-600" />
</div>
<h2 className="text-xl font-semibold text-secondary-900 mb-2"></h2>
<p className="text-secondary-500">...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-white">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-md border-b border-secondary-200">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-secondary-600" />
</button>
<h1 className="text-lg font-semibold text-secondary-900"></h1>
</div>
</header>
{/* Content */}
<main className="max-w-md mx-auto px-4 py-8">
<div className="bg-white rounded-2xl shadow-sm border border-secondary-200 p-6">
{/* Icon */}
<div className="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-6">
<Phone className="w-8 h-8 text-primary-600" />
</div>
<h2 className="text-xl font-semibold text-secondary-900 text-center mb-2">
</h2>
<p className="text-sm text-secondary-500 text-center mb-6">
</p>
{/* Error */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-600 text-sm">
{error}
</div>
)}
{/* Phone Input */}
<div className="mb-4">
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" />
<input
type="tel"
value={phone}
onChange={(e) => 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}
/>
</div>
<button
onClick={sendCode}
disabled={loading || countdown > 0 || phone.length < 11}
className="px-4 py-2.5 bg-primary-100 text-primary-700 rounded-lg hover:bg-primary-200 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{countdown > 0 ? `${countdown}s` : '获取验证码'}
</button>
</div>
</div>
{/* Code Input */}
{codeSent && (
<div className="mb-6">
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
</label>
<div className="relative">
<Shield className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-secondary-400" />
<input
type="text"
value={code}
onChange={(e) => 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"
/>
</div>
</div>
)}
{/* Submit Button */}
<button
onClick={bindPhone}
disabled={loading || !codeSent || code.length < 4}
className="w-full py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
>
{loading ? '处理中...' : '确认绑定'}
</button>
</div>
{/* Back */}
<button
onClick={() => navigate('/chat')}
className="w-full mt-4 py-3 text-secondary-500 hover:text-secondary-700 transition-colors"
>
</button>
</main>
</div>
);
}

View File

@ -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<UserProfile | null>(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 (
<div className="min-h-screen flex items-center justify-center">
<div className="w-8 h-8 border-4 border-primary-200 border-t-primary-600 rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-white">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/80 backdrop-blur-md border-b border-secondary-200">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="p-2 hover:bg-secondary-100 rounded-lg transition-colors"
>
<ArrowLeft className="w-5 h-5 text-secondary-600" />
</button>
<h1 className="text-lg font-semibold text-secondary-900"></h1>
</div>
</header>
{/* Content */}
<main className="max-w-2xl mx-auto px-4 py-6">
{/* Avatar Section */}
<div className="bg-white rounded-2xl shadow-sm border border-secondary-200 p-6 mb-4">
<div className="flex items-center gap-4">
<div className="w-20 h-20 bg-primary-100 rounded-full flex items-center justify-center">
<User className="w-10 h-10 text-primary-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-secondary-900">
{profile?.nickname || '访客用户'}
</h2>
<p className="text-sm text-secondary-500">
{profile?.type === 'REGISTERED' ? '已注册用户' : '匿名访客'}
</p>
</div>
</div>
</div>
{/* Profile Info */}
<div className="bg-white rounded-2xl shadow-sm border border-secondary-200 p-6 mb-4">
<h3 className="text-sm font-medium text-secondary-500 mb-4"></h3>
{/* Nickname */}
<div className="mb-4">
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
</label>
<div className="flex gap-2">
<input
type="text"
value={nickname}
onChange={(e) => 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"
/>
<button
onClick={handleSave}
disabled={saving || !nickname.trim()}
className="px-4 py-2.5 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{saved ? (
<>
<Check className="w-4 h-4" />
<span></span>
</>
) : (
<>
<Save className="w-4 h-4" />
<span>{saving ? '保存中...' : '保存'}</span>
</>
)}
</button>
</div>
</div>
{/* Phone */}
<div className="mb-4">
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
</label>
<div className="flex items-center gap-3 px-4 py-2.5 bg-secondary-50 rounded-lg">
<Phone className="w-4 h-4 text-secondary-400" />
{profile?.phone ? (
<span className="text-secondary-900">{profile.phone}</span>
) : (
<button
onClick={() => navigate('/bind-phone')}
className="text-primary-600 hover:text-primary-700"
>
</button>
)}
</div>
</div>
{/* User ID */}
<div className="mb-4">
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
ID
</label>
<div className="flex items-center gap-3 px-4 py-2.5 bg-secondary-50 rounded-lg">
<Mail className="w-4 h-4 text-secondary-400" />
<span className="text-secondary-600 font-mono text-sm">
{profile?.id || userId}
</span>
</div>
</div>
{/* Created At */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1.5">
</label>
<div className="px-4 py-2.5 bg-secondary-50 rounded-lg text-secondary-600">
{profile?.createdAt ? formatDate(profile.createdAt) : '未知'}
</div>
</div>
</div>
{/* Back Button */}
<button
onClick={() => navigate('/chat')}
className="w-full py-3 bg-white border border-secondary-200 rounded-xl text-secondary-700 hover:bg-secondary-50 transition-colors"
>
</button>
</main>
</div>
);
}

View File

@ -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 "$@"

View File

@ -7,12 +7,70 @@
CREATE EXTENSION IF NOT EXISTS vector; -- pgvector: 向量存储和相似度搜索 CREATE EXTENSION IF NOT EXISTS vector; -- pgvector: 向量存储和相似度搜索
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- UUID生成函数 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) -- 用户表 (users)
-- 存储系统用户信息,支持匿名用户和注册用户 -- 存储系统用户信息,支持匿名用户和注册用户
-- =========================================== -- ===========================================
CREATE TABLE users ( CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 用户类型: ANONYMOUS(匿名访客), REGISTERED(已注册用户) -- 用户类型: ANONYMOUS(匿名访客), REGISTERED(已注册用户)
type VARCHAR(20) NOT NULL DEFAULT 'ANONYMOUS' type VARCHAR(20) NOT NULL DEFAULT 'ANONYMOUS'
CHECK (type IN ('ANONYMOUS', 'REGISTERED')), 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_type ON users(type);
CREATE INDEX idx_users_source_channel ON users(source_channel); CREATE INDEX idx_users_source_channel ON users(source_channel);
CREATE INDEX idx_users_created_at ON users(created_at); 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) -- 对话表 (conversations)
@ -60,6 +121,8 @@ CREATE INDEX idx_users_created_at ON users(created_at);
-- =========================================== -- ===========================================
CREATE TABLE conversations ( CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 所属用户ID可为空支持匿名用户如果关联注册用户则有外键 -- 所属用户ID可为空支持匿名用户如果关联注册用户则有外键
-- 注意不使用外键约束允许匿名访客使用user_id为随机UUID或null -- 注意不使用外键约束允许匿名访客使用user_id为随机UUID或null
user_id UUID, 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_consulting_stage ON conversations(consulting_stage);
CREATE INDEX idx_conversations_conversion_path ON conversations(conversion_path); 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_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) -- 消息表 (messages)
@ -129,6 +196,8 @@ CREATE INDEX idx_conversations_collected_info ON conversations USING GIN(collect
-- =========================================== -- ===========================================
CREATE TABLE messages ( CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 所属对话ID -- 所属对话ID
conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE,
-- 消息角色: user(用户), assistant(AI助手), system(系统) -- 消息角色: 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_conversation_id ON messages(conversation_id);
CREATE INDEX idx_messages_role ON messages(role); CREATE INDEX idx_messages_role ON messages(role);
CREATE INDEX idx_messages_created_at ON messages(created_at); CREATE INDEX idx_messages_created_at ON messages(created_at);
CREATE INDEX idx_messages_tenant ON messages(tenant_id);
-- =========================================== -- ===========================================
-- 订单表 (orders) -- 订单表 (orders)
@ -162,6 +232,8 @@ CREATE INDEX idx_messages_created_at ON messages(created_at);
-- =========================================== -- ===========================================
CREATE TABLE orders ( CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 订单号用于展示格式ORD + 年月日 + 序号) -- 订单号用于展示格式ORD + 年月日 + 序号)
order_no VARCHAR(50) UNIQUE, order_no VARCHAR(50) UNIQUE,
-- 所属用户ID -- 所属用户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_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_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) -- 支付表 (payments)
@ -234,6 +309,8 @@ CREATE INDEX idx_orders_user_status ON orders(user_id, status);
-- =========================================== -- ===========================================
CREATE TABLE payments ( CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 关联的订单ID -- 关联的订单ID
order_id UUID REFERENCES orders(id) ON DELETE CASCADE, 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); CREATE INDEX idx_payments_order_status ON payments(order_id, status);
-- 唯一索引:确保 transaction_id 唯一性(幂等性保证,仅非空值) -- 唯一索引:确保 transaction_id 唯一性(幂等性保证,仅非空值)
CREATE UNIQUE INDEX idx_payments_transaction_id_unique ON payments(transaction_id) WHERE transaction_id IS NOT NULL; 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) -- 分类账/财务流水表 (ledger_entries)
@ -351,6 +430,8 @@ CREATE INDEX idx_ledger_entries_business_type ON ledger_entries(business_type);
-- =========================================== -- ===========================================
CREATE TABLE daily_statistics ( CREATE TABLE daily_statistics (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 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, stat_date DATE NOT NULL,
-- 统计维度: OVERALL(总体), CHANNEL(渠道), CATEGORY(移民类别) -- 统计维度: OVERALL(总体), CHANNEL(渠道), CATEGORY(移民类别)
@ -411,8 +492,8 @@ CREATE TABLE daily_statistics (
-- 更新时间 -- 更新时间
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), 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 '日统计表 - 预聚合的每日统计数据,支持多维度分析'; 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_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_dimension ON daily_statistics(dimension, dimension_value);
CREATE INDEX idx_daily_statistics_tenant ON daily_statistics(tenant_id);
-- =========================================== -- ===========================================
-- 月度财务报表 (monthly_financial_reports) -- 月度财务报表 (monthly_financial_reports)
@ -429,8 +511,10 @@ CREATE INDEX idx_daily_statistics_dimension ON daily_statistics(dimension, dimen
-- =========================================== -- ===========================================
CREATE TABLE monthly_financial_reports ( CREATE TABLE monthly_financial_reports (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 报表月份格式YYYY-MM -- 报表月份格式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 ( CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 操作者ID用户或管理员 -- 操作者ID用户或管理员
actor_id UUID, actor_id UUID,
-- 操作者类型: USER, ADMIN, SYSTEM -- 操作者类型: 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 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_created_at_brin ON audit_logs USING BRIN(created_at);
-- 租户索引
CREATE INDEX idx_audit_logs_tenant ON audit_logs(tenant_id);
-- =========================================== -- ===========================================
-- 发件箱表 (outbox) -- 发件箱表 (outbox)
@ -830,6 +918,8 @@ CREATE INDEX idx_evolution_logs_created_at ON evolution_logs(created_at DESC);
-- =========================================== -- ===========================================
CREATE TABLE verification_codes ( CREATE TABLE verification_codes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 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, 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_phone ON verification_codes(phone);
CREATE INDEX idx_verification_codes_expires_at ON verification_codes(expires_at); 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_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) -- 服务定价表 (service_pricing)
@ -1219,12 +1312,18 @@ ORDER BY total_users DESC;
COMMENT ON VIEW v_channel_statistics IS '渠道统计视图 - 按来源渠道汇总用户和收入数据'; 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) -- 知识文章表 (knowledge_articles)
-- 存储移民相关的知识内容支持RAG检索 -- 存储移民相关的知识内容支持RAG检索
-- =========================================== -- ===========================================
CREATE TABLE knowledge_articles ( CREATE TABLE knowledge_articles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 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, title VARCHAR(500) NOT NULL,
-- 文章内容纯文本或Markdown -- 文章内容纯文本或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_published ON knowledge_articles(is_published);
CREATE INDEX idx_knowledge_articles_quality ON knowledge_articles(quality_score DESC); 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_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) -- 知识块表 (knowledge_chunks)
@ -1279,6 +1380,8 @@ CREATE INDEX idx_knowledge_articles_embedding ON knowledge_articles USING ivffla
-- =========================================== -- ===========================================
CREATE TABLE knowledge_chunks ( CREATE TABLE knowledge_chunks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 所属文章ID -- 所属文章ID
article_id UUID NOT NULL REFERENCES knowledge_articles(id) ON DELETE CASCADE, 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_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_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) -- 用户记忆表 (user_memories)
@ -1311,6 +1416,8 @@ CREATE INDEX idx_knowledge_chunks_embedding ON knowledge_chunks USING ivfflat (e
-- =========================================== -- ===========================================
CREATE TABLE user_memories ( CREATE TABLE user_memories (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 用户ID支持匿名用户不使用外键约束 -- 用户ID支持匿名用户不使用外键约束
user_id UUID NOT NULL, 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_type ON user_memories(memory_type);
CREATE INDEX idx_user_memories_importance ON user_memories(user_id, importance DESC); 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_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) -- 系统经验表 (system_experiences)
@ -1368,6 +1477,8 @@ CREATE INDEX idx_user_memories_embedding ON user_memories USING ivfflat (embeddi
-- =========================================== -- ===========================================
CREATE TABLE system_experiences ( CREATE TABLE system_experiences (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 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 experience_type VARCHAR(30) NOT NULL
CHECK (experience_type IN ( 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_active ON system_experiences(is_active);
CREATE INDEX idx_system_experiences_confidence ON system_experiences(confidence DESC); 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_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) -- 管理员表 (admins)
@ -1433,6 +1546,10 @@ CREATE INDEX idx_system_experiences_embedding ON system_experiences USING ivffla
-- =========================================== -- ===========================================
CREATE TABLE admins ( CREATE TABLE admins (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), 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, 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_username ON admins(username);
CREATE INDEX idx_admins_role ON admins(role); CREATE INDEX idx_admins_role ON admins(role);
CREATE INDEX idx_admins_active ON admins(is_active); 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实际生产环境需要修改 -- 插入默认超级管理员(密码: admin123实际生产环境需要修改
-- bcrypt hash for 'admin123' generated with cost 10 -- bcrypt hash for 'admin123' generated with cost 10
INSERT INTO admins (username, password_hash, name, role, permissions) VALUES INSERT INTO admins (tenant_id, is_super_admin, username, password_hash, name, role, permissions) VALUES
('admin', '$2b$10$79R2HdSS0Yez9lG5eSdVMutBQu4ew6fb2qzHNeAhu5p70JmDoYsde', '系统管理员', 'SUPER_ADMIN', '["*"]'); ('00000000-0000-0000-0000-000000000001', TRUE, 'admin', '$2b$10$79R2HdSS0Yez9lG5eSdVMutBQu4ew6fb2qzHNeAhu5p70JmDoYsde', '系统管理员', 'SUPER_ADMIN', '["*"]');
-- =========================================== -- ===========================================
-- 文件表 (files) -- 文件表 (files)
@ -1481,6 +1601,8 @@ INSERT INTO admins (username, password_hash, name, role, permissions) VALUES
-- =========================================== -- ===========================================
CREATE TABLE files ( CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 所属用户ID -- 所属用户ID
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- 关联的对话ID可选 -- 关联的对话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_user_created ON files(user_id, created_at DESC);
CREATE INDEX idx_files_status ON files(status); CREATE INDEX idx_files_status ON files(status);
CREATE INDEX idx_files_type ON files(type); 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(); 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 ( CREATE TABLE token_usages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- 租户ID
tenant_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000001',
-- 所属用户ID可为空支持匿名用户 -- 所属用户ID可为空支持匿名用户
user_id UUID, user_id UUID,
-- 所属对话ID -- 所属对话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); CREATE INDEX idx_token_usages_intent ON token_usages(intent_type);
-- BRIN索引用于时间范围查询优化 -- BRIN索引用于时间范围查询优化
CREATE INDEX idx_token_usages_created_brin ON token_usages USING BRIN(created_at); 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);
-- =========================================== -- ===========================================
-- 结束 -- 结束

20
scripts/package.json Normal file
View File

@ -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"
}
}

241
scripts/seed.ts Normal file
View File

@ -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();

17
scripts/tsconfig.json Normal file
View File

@ -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"]
}