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:
parent
1ac183fc0c
commit
e1e9ba1a77
77
deploy.sh
77
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)
|
||||
|
|
|
|||
|
|
@ -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 模式)
|
||||
#=============================================================================
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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() {
|
|||
<Route path="/" element={<ChatPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/chat/:conversationId" element={<ChatPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/bind-phone" element={<BindPhonePage />} />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-secondary-200">
|
||||
<div className="text-xs text-secondary-400 text-center">
|
||||
iConsulting v1.0.0
|
||||
</div>
|
||||
{/* Footer - User Profile */}
|
||||
<div className="p-3 border-t border-secondary-200">
|
||||
<UserProfileMenu />
|
||||
</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 {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -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);
|
||||
|
||||
-- ===========================================
|
||||
-- 结束
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue