feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling

- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等
- Tailwind v4 + @theme inline,移除 tailwind.config.js
- React 19 use() + Suspense 流式渲染,首屏骨架秒出
- 三级错误边界:Route → Section → Widget 层层兜底
- 错误上报:useErrorReport → sendBeacon → /api/log mock 端点
- 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围
- 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99%
- notify 统一 Toast 封装,禁止业务直接 import sonner
- PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套)

验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
SpecialX
2026-07-17 16:10:05 +08:00
parent f7e52b5b7f
commit 9cedf0c437
140 changed files with 10872 additions and 3192 deletions

137
scripts/config-seed.sql Normal file
View File

@@ -0,0 +1,137 @@
-- ============================================================
-- config-service 种子数据v2.1 M8
-- 初始化 28 个内置插件 + 5 种 Layout 模板 + teacher 角色默认映射
-- 使用docker exec -i edu-mysql mysql -uedu -pchangeme next_edu_cloud < scripts/config-seed.sql
-- ============================================================
-- ===== 1. Layout 模板5 种预置) =====
INSERT INTO `config_layout_templates` (`layout_id`, `display_name`, `description`, `available_slots`, `layout_schema`, `is_active`) VALUES
('classic', '经典三栏', 'TopBar + SideNav + Main', JSON_ARRAY('top','side','main'), JSON_OBJECT('grid', JSON_OBJECT('rows',1,'cols',1,'areas',JSON_ARRAY(JSON_ARRAY('main')))), TRUE),
('focus', '专注模式', '仅 Main 全屏', JSON_ARRAY('main'), JSON_OBJECT('grid', JSON_OBJECT('rows',1,'cols',1,'areas',JSON_ARRAY(JSON_ARRAY('main')))), TRUE),
('split', '左右分栏', 'Side + Main', JSON_ARRAY('side','main'), JSON_OBJECT('grid', JSON_OBJECT('rows',1,'cols',2,'areas',JSON_ARRAY(JSON_ARRAY('side','main')))), TRUE),
('triple', '三栏布局', 'Side + Main + Right', JSON_ARRAY('side','main','right'), JSON_OBJECT('grid', JSON_OBJECT('rows',1,'cols',3,'areas',JSON_ARRAY(JSON_ARRAY('side','main','right')))), TRUE),
('canvas', '自由画布', '可拖拽自由布局', JSON_ARRAY('main'), JSON_OBJECT('grid', JSON_OBJECT('rows',1,'cols',1,'areas',JSON_ARRAY(JSON_ARRAY('main')))), TRUE)
ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`);
-- ===== 2. 插件注册表28 个内置插件) =====
INSERT INTO `config_plugin_registry` (`plugin_id`, `category`, `version`, `display_name`, `description`, `required_roles`, `default_slot`, `default_size`, `default_props`, `is_builtin`, `is_active`) VALUES
-- universal7
('grades-widget', 'universal', '1.0.0', '成绩查询', '查看学生考试成绩', JSON_ARRAY(), 'main', JSON_OBJECT('w',6,'h',4), JSON_OBJECT(), TRUE, TRUE),
('homework-widget', 'universal', '1.0.0', '作业列表', '查看作业与提交状态', JSON_ARRAY(), 'main', JSON_OBJECT('w',6,'h',4), JSON_OBJECT(), TRUE, TRUE),
('schedule-widget', 'universal', '1.0.0', '课表', '查看本周课表', JSON_ARRAY(), 'main', JSON_OBJECT('w',6,'h',4), JSON_OBJECT(), TRUE, TRUE),
('attendance-widget', 'universal', '1.0.0', '考勤', '出勤率统计', JSON_ARRAY(), 'main', JSON_OBJECT('w',4,'h',3), JSON_OBJECT(), TRUE, TRUE),
('exams-widget', 'universal', '1.0.0', '考试安排', '即将到来的考试', JSON_ARRAY(), 'main', JSON_OBJECT('w',6,'h',3), JSON_OBJECT(), TRUE, TRUE),
('notifications-widget', 'universal', '1.0.0', '通知', '系统通知列表', JSON_ARRAY(), 'main', JSON_OBJECT('w',4,'h',4), JSON_OBJECT(), TRUE, TRUE),
('announcements-widget', 'universal', '1.0.0', '公告', '学校公告', JSON_ARRAY(), 'main', JSON_OBJECT('w',6,'h',3), JSON_OBJECT(), TRUE, TRUE),
-- sidebar4
('class-selector', 'sidebar', '1.0.0', '班级选择器', '切换当前班级', JSON_ARRAY(), 'side', JSON_OBJECT('w',2,'h',1), JSON_OBJECT(), TRUE, TRUE),
('child-selector', 'sidebar', '1.0.0', '孩子选择器', '家长切换孩子', JSON_ARRAY('parent'), 'side', JSON_OBJECT('w',2,'h',1), JSON_OBJECT(), TRUE, TRUE),
('term-switcher', 'sidebar', '1.0.0', '学期切换', '切换学期', JSON_ARRAY(), 'side', JSON_OBJECT('w',2,'h',1), JSON_OBJECT(), TRUE, TRUE),
('quick-actions', 'sidebar', '1.0.0', '快捷操作', '常用操作入口', JSON_ARRAY(), 'side', JSON_OBJECT('w',2,'h',2), JSON_OBJECT(), TRUE, TRUE),
-- topbar4
('notification-bell', 'topbar', '1.0.0', '通知铃铛', '顶部通知提醒', JSON_ARRAY(), 'top', JSON_OBJECT('w',1,'h',1), JSON_OBJECT(), TRUE, TRUE),
('user-menu', 'topbar', '1.0.0', '用户菜单', '用户头像与下拉菜单', JSON_ARRAY(), 'top', JSON_OBJECT('w',1,'h',1), JSON_OBJECT(), TRUE, TRUE),
('global-search', 'topbar', '1.0.0', '全局搜索', '搜索功能', JSON_ARRAY(), 'top', JSON_OBJECT('w',2,'h',1), JSON_OBJECT(), TRUE, TRUE),
('locale-switcher', 'topbar', '1.0.0', '语言切换', '中英文切换', JSON_ARRAY(), 'top', JSON_OBJECT('w',1,'h',1), JSON_OBJECT(), TRUE, TRUE),
-- teacher4
('lesson-plan-editor', 'teacher', '1.0.0', '教案编辑器', '编辑教案', JSON_ARRAY('teacher'), 'main', JSON_OBJECT('w',8,'h',6), JSON_OBJECT(), TRUE, TRUE),
('question-bank', 'teacher', '1.0.0', '题库管理', '题目管理', JSON_ARRAY('teacher'), 'main', JSON_OBJECT('w',8,'h',6), JSON_OBJECT(), TRUE, TRUE),
('textbook-manager', 'teacher', '1.0.0', '教材管理', '教材版本管理', JSON_ARRAY('teacher'), 'main', JSON_OBJECT('w',6,'h',4), JSON_OBJECT(), TRUE, TRUE),
('scheduling-rules', 'teacher', '1.0.0', '排课规则', '排课规则配置', JSON_ARRAY('teacher'), 'main', JSON_OBJECT('w',6,'h',4), JSON_OBJECT(), TRUE, TRUE),
-- student4
('error-book', 'student', '1.0.0', '错题本', '错题汇总', JSON_ARRAY('student'), 'main', JSON_OBJECT('w',6,'h',4), JSON_OBJECT(), TRUE, TRUE),
('learning-path', 'student', '1.0.0', '学习路径', '个性化学习路径', JSON_ARRAY('student'), 'main', JSON_OBJECT('w',4,'h',4), JSON_OBJECT(), TRUE, TRUE),
('elective-selector', 'student', '1.0.0', '选课', '选修课选择', JSON_ARRAY('student'), 'main', JSON_OBJECT('w',6,'h',4), JSON_OBJECT(), TRUE, TRUE),
('ai-tutor', 'student', '1.0.0', 'AI 辅导', 'AI 智能辅导', JSON_ARRAY('student'), 'main', JSON_OBJECT('w',4,'h',4), JSON_OBJECT(), TRUE, TRUE),
-- parent2
('child-overview', 'parent', '1.0.0', '孩子概览', '孩子学习概览', JSON_ARRAY('parent'), 'main', JSON_OBJECT('w',6,'h',4), JSON_OBJECT(), TRUE, TRUE),
('leave-approval', 'parent', '1.0.0', '请假审批', '审批孩子请假', JSON_ARRAY('parent'), 'main', JSON_OBJECT('w',6,'h',3), JSON_OBJECT(), TRUE, TRUE),
-- admin3
('user-management', 'admin', '1.0.0', '用户管理', '用户账号管理', JSON_ARRAY('admin'), 'main', JSON_OBJECT('w',8,'h',6), JSON_OBJECT(), TRUE, TRUE),
('rbac-manager', 'admin', '1.0.0', '权限管理', '角色权限配置', JSON_ARRAY('admin'), 'main', JSON_OBJECT('w',8,'h',6), JSON_OBJECT(), TRUE, TRUE),
('plugin-manager', 'admin', '1.0.0', '插件管理', '插件注册与配置', JSON_ARRAY('admin'), 'main', JSON_OBJECT('w',8,'h',6), JSON_OBJECT(), TRUE, TRUE)
ON DUPLICATE KEY UPDATE `display_name` = VALUES(`display_name`);
-- ===== 3. teacher 角色默认 Layout =====
INSERT INTO `config_role_layout_default` (`role`, `layout_id`, `slot_overrides`) VALUES
('teacher', 'classic', NULL),
('student', 'classic', NULL),
('parent', 'classic', NULL),
('admin', 'classic', NULL)
ON DUPLICATE KEY UPDATE `layout_id` = VALUES(`layout_id`);
-- ===== 4. teacher 角色插件映射(启用 topbar + sidebar + 部分 main =====
INSERT INTO `config_role_plugin_mapping` (`role`, `plugin_id`, `slot`, `sort_order`, `is_enabled`, `widget_props`) VALUES
-- topbar
('teacher', 'notification-bell', 'top', 1, TRUE, NULL),
('teacher', 'global-search', 'top', 2, TRUE, NULL),
('teacher', 'locale-switcher', 'top', 3, TRUE, NULL),
('teacher', 'user-menu', 'top', 4, TRUE, NULL),
-- sidebar
('teacher', 'class-selector', 'side', 1, TRUE, NULL),
('teacher', 'term-switcher', 'side', 2, TRUE, NULL),
('teacher', 'quick-actions', 'side', 3, TRUE, NULL),
-- main universal
('teacher', 'schedule-widget', 'main', 1, TRUE, NULL),
('teacher', 'grades-widget', 'main', 2, TRUE, NULL),
('teacher', 'homework-widget', 'main', 3, TRUE, NULL),
('teacher', 'exams-widget', 'main', 4, TRUE, NULL),
('teacher', 'announcements-widget', 'main', 5, TRUE, NULL),
('teacher', 'attendance-widget', 'main', 6, TRUE, NULL),
('teacher', 'notifications-widget', 'main', 7, TRUE, NULL),
-- main teacher-specific
('teacher', 'lesson-plan-editor', 'main', 10, TRUE, NULL),
('teacher', 'question-bank', 'main', 11, TRUE, NULL),
('teacher', 'textbook-manager', 'main', 12, TRUE, NULL),
('teacher', 'scheduling-rules', 'main', 13, TRUE, NULL)
ON DUPLICATE KEY UPDATE `is_enabled` = VALUES(`is_enabled`);
-- ===== 5. student 角色插件映射 =====
INSERT INTO `config_role_plugin_mapping` (`role`, `plugin_id`, `slot`, `sort_order`, `is_enabled`, `widget_props`) VALUES
('student', 'notification-bell', 'top', 1, TRUE, NULL),
('student', 'locale-switcher', 'top', 2, TRUE, NULL),
('student', 'user-menu', 'top', 3, TRUE, NULL),
('student', 'term-switcher', 'side', 1, TRUE, NULL),
('student', 'quick-actions', 'side', 2, TRUE, NULL),
('student', 'schedule-widget', 'main', 1, TRUE, NULL),
('student', 'grades-widget', 'main', 2, TRUE, NULL),
('student', 'homework-widget', 'main', 3, TRUE, NULL),
('student', 'exams-widget', 'main', 4, TRUE, NULL),
('student', 'announcements-widget', 'main', 5, TRUE, NULL),
('student', 'notifications-widget', 'main', 6, TRUE, NULL),
('student', 'error-book', 'main', 10, TRUE, NULL),
('student', 'learning-path', 'main', 11, TRUE, NULL),
('student', 'elective-selector', 'main', 12, TRUE, NULL),
('student', 'ai-tutor', 'main', 13, TRUE, NULL)
ON DUPLICATE KEY UPDATE `is_enabled` = VALUES(`is_enabled`);
-- ===== 6. parent 角色插件映射 =====
INSERT INTO `config_role_plugin_mapping` (`role`, `plugin_id`, `slot`, `sort_order`, `is_enabled`, `widget_props`) VALUES
('parent', 'notification-bell', 'top', 1, TRUE, NULL),
('parent', 'locale-switcher', 'top', 2, TRUE, NULL),
('parent', 'user-menu', 'top', 3, TRUE, NULL),
('parent', 'child-selector', 'side', 1, TRUE, NULL),
('parent', 'term-switcher', 'side', 2, TRUE, NULL),
('parent', 'grades-widget', 'main', 1, TRUE, NULL),
('parent', 'attendance-widget', 'main', 2, TRUE, NULL),
('parent', 'announcements-widget', 'main', 3, TRUE, NULL),
('parent', 'notifications-widget', 'main', 4, TRUE, NULL),
('parent', 'child-overview', 'main', 10, TRUE, NULL),
('parent', 'leave-approval', 'main', 11, TRUE, NULL)
ON DUPLICATE KEY UPDATE `is_enabled` = VALUES(`is_enabled`);
-- ===== 7. admin 角色插件映射 =====
INSERT INTO `config_role_plugin_mapping` (`role`, `plugin_id`, `slot`, `sort_order`, `is_enabled`, `widget_props`) VALUES
('admin', 'notification-bell', 'top', 1, TRUE, NULL),
('admin', 'global-search', 'top', 2, TRUE, NULL),
('admin', 'locale-switcher', 'top', 3, TRUE, NULL),
('admin', 'user-menu', 'top', 4, TRUE, NULL),
('admin', 'class-selector', 'side', 1, TRUE, NULL),
('admin', 'term-switcher', 'side', 2, TRUE, NULL),
('admin', 'quick-actions', 'side', 3, TRUE, NULL),
('admin', 'announcements-widget', 'main', 1, TRUE, NULL),
('admin', 'notifications-widget', 'main', 2, TRUE, NULL),
('admin', 'user-management', 'main', 10, TRUE, NULL),
('admin', 'rbac-manager', 'main', 11, TRUE, NULL),
('admin', 'plugin-manager', 'main', 12, TRUE, NULL)
ON DUPLICATE KEY UPDATE `is_enabled` = VALUES(`is_enabled`);

54
scripts/dev-service.ps1 Normal file
View File

@@ -0,0 +1,54 @@
<#
.SYNOPSIS
加载 .env 并启动单个服务(内部辅助脚本)
.DESCRIPTION
从项目根 .env 加载环境变量到当前进程,然后启动指定服务。
用法powershell -File scripts\dev-service.ps1 <package-name>
例: powershell -File scripts\dev-service.ps1 @edu/iam-service
#>
param(
[Parameter(Mandatory=$true)]
[string]$Filter
)
$ProjectRoot = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $ProjectRoot ".env"
if (Test-Path $envFile) {
$envLines = Get-Content $envFile
foreach ($line in $envLines) {
$line = $line.Trim()
if (-not $line -or $line.StartsWith("#") -or -not $line.Contains("=")) { continue }
$idx = $line.IndexOf("=")
$key = $line.Substring(0, $idx).Trim()
$val = $line.Substring($idx + 1).Trim()
if ($val.StartsWith('"') -and $val.EndsWith('"')) { $val = $val.Substring(1, $val.Length - 2) }
if ($val.StartsWith("'") -and $val.EndsWith("'")) { $val = $val.Substring(1, $val.Length - 2) }
[Environment]::SetEnvironmentVariable($key, $val, "Process")
}
Write-Host "[dev-service] Loaded .env" -ForegroundColor Green
}
# 补充 dev 模式默认值
if (-not $env:DEV_MODE) { $env:DEV_MODE = "true" }
if (-not $env:OTEL_EXPORTER_OTLP_ENDPOINT) { $env:OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318" }
if (-not $env:NEO4J_URI) { $env:NEO4J_URI = "bolt://localhost:7687" }
if (-not $env:NEO4J_USER) { $env:NEO4J_USER = "neo4j" }
if (-not $env:CLICKHOUSE_URL) { $env:CLICKHOUSE_URL = "http://localhost:8123" }
if (-not $env:ES_URL) { $env:ES_URL = "http://localhost:9200" }
if (-not $env:ROUTER_AUTH_SECRET) { $env:ROUTER_AUTH_SECRET = "dev-router-secret" }
if (-not $env:CLASSES_SERVICE_URL) { $env:CLASSES_SERVICE_URL = "http://localhost:3001" }
if (-not $env:IAM_SERVICE_URL) { $env:IAM_SERVICE_URL = "http://localhost:3002" }
if (-not $env:APOLLO_ROUTER_URL) { $env:APOLLO_ROUTER_URL = "http://localhost:3000" }
if (-not $env:CORE_EDU_SERVICE_URL) { $env:CORE_EDU_SERVICE_URL = "http://localhost:3004" }
if (-not $env:CONTENT_SERVICE_URL) { $env:CONTENT_SERVICE_URL = "http://localhost:3005" }
if (-not $env:DATA_ANA_SERVICE_URL) { $env:DATA_ANA_SERVICE_URL = "http://localhost:3006" }
if (-not $env:MSG_SERVICE_URL) { $env:MSG_SERVICE_URL = "http://localhost:3007" }
if (-not $env:AI_SERVICE_URL) { $env:AI_SERVICE_URL = "http://localhost:3008" }
if (-not $env:CORS_ORIGINS) { $env:CORS_ORIGINS = "http://localhost:4000,http://localhost:4010" }
if (-not $env:JWKS_URL) { $env:JWKS_URL = "http://localhost:3002/v1/iam/.well-known/jwks.json" }
if (-not $env:PUSH_INTERNAL_TOKEN) { $env:PUSH_INTERNAL_TOKEN = "edu-internal-token" }
Write-Host "[dev-service] Starting $Filter with DEV_MODE=$($env:DEV_MODE)" -ForegroundColor Cyan
Set-Location $ProjectRoot
pnpm --filter $Filter dev

View File

@@ -1,8 +1,12 @@
<#
.SYNOPSIS
Edu health check script
Edu v2.1 健康检查脚本
.DESCRIPTION
Checks health of all infrastructure containers + application services + observability endpoints
检查范围:
1. Docker 基础设施容器
2. 应用服务 HTTP 端点
3. 可观测性端点
4. CDC 管道状态
.EXAMPLE
.\scripts\health-check.ps1
#>
@@ -10,25 +14,28 @@
$ErrorActionPreference = "Continue"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Edu Health Check" -ForegroundColor Cyan
Write-Host " Edu v2.1 Health Check" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# ===== 1. Infrastructure containers =====
Write-Host "[1/3] Infrastructure Containers" -ForegroundColor Yellow
# ===== 1. 基础设施容器 =====
Write-Host "[1/4] Infrastructure Containers" -ForegroundColor Yellow
$infraContainers = @(
@{Name="MySQL"; Container="edu-mysql"},
@{Name="Redis"; Container="edu-redis"},
@{Name="Kafka"; Container="edu-kafka"},
@{Name="Zookeeper"; Container="edu-zookeeper"},
@{Name="ClickHouse"; Container="edu-clickhouse"},
@{Name="Debezium"; Container="edu-debezium"},
@{Name="Neo4j"; Container="edu-neo4j"},
@{Name="Elasticsearch"; Container="edu-es"},
@{Name="Jaeger"; Container="edu-jaeger"},
@{Name="Prometheus"; Container="edu-prometheus"},
@{Name="Grafana"; Container="edu-grafana"}
@{Name="MySQL"; Container="edu-mysql"},
@{Name="Redis"; Container="edu-redis"},
@{Name="Kafka"; Container="edu-kafka"},
@{Name="Zookeeper"; Container="edu-zookeeper"},
@{Name="ClickHouse"; Container="edu-clickhouse"},
@{Name="Debezium"; Container="edu-debezium"},
@{Name="Neo4j"; Container="edu-neo4j"},
@{Name="Elasticsearch"; Container="edu-es"},
@{Name="Apollo Router"; Container="edu-apollo-router"},
@{Name="Jaeger"; Container="edu-jaeger"},
@{Name="Prometheus"; Container="edu-prometheus"},
@{Name="Grafana"; Container="edu-grafana"},
@{Name="Loki"; Container="edu-loki"},
@{Name="Alertmanager"; Container="edu-alertmanager"}
)
$infraOk = 0
@@ -36,64 +43,68 @@ $infraFail = 0
foreach ($svc in $infraContainers) {
$running = docker inspect -f '{{.State.Running}}' $svc.Container 2>$null
if ($running -ne "true") {
Write-Host " [FAIL] $($svc.Name) not running" -ForegroundColor Red
$infraFail++
Write-Host " [SKIP] $($svc.Name) (not running, may be optional)" -ForegroundColor Gray
continue
}
# 条件模板Health 不存在时返回空字符串,不报错
$health = docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' $svc.Container 2>$null
if ($health -eq "healthy") {
Write-Host " [OK] $($svc.Name)" -ForegroundColor Green
if ($health -eq "healthy" -or $health -eq "") {
Write-Host " [OK] $($svc.Name)" -ForegroundColor Green
$infraOk++
} elseif ($health -eq "starting") {
Write-Host " [WAIT] $($svc.Name) (starting)" -ForegroundColor Yellow
$infraOk++
} else {
Write-Host " [WARN] $($svc.Name) (running, no healthcheck)" -ForegroundColor Yellow
Write-Host " [WARN] $($svc.Name) (health=$health)" -ForegroundColor Yellow
$infraOk++
}
}
Write-Host ""
# ===== 2. Application services =====
Write-Host "[2/3] Application Services" -ForegroundColor Yellow
# ===== 2. 应用服务 =====
Write-Host "[2/4] Application Services" -ForegroundColor Yellow
$appServices = @(
@{Name="classes"; Url="http://localhost:3001/healthz"},
@{Name="iam"; Url="http://localhost:3002/healthz"},
@{Name="teacher-bff"; Url="http://localhost:3003/healthz"},
@{Name="core-edu"; Url="http://localhost:3004/healthz"},
@{Name="content"; Url="http://localhost:3005/healthz"},
@{Name="data-ana"; Url="http://localhost:3006/healthz"},
@{Name="msg"; Url="http://localhost:3007/healthz"},
@{Name="ai"; Url="http://localhost:3008/healthz"},
@{Name="api-gateway"; Url="http://localhost:8080/healthz"},
@{Name="push-gateway"; Url="http://localhost:8081/healthz"},
@{Name="teacher-portal";Url="http://localhost:3000/"}
@{Name="iam"; Url="http://localhost:3002/healthz"; Critical=$true},
@{Name="config-service"; Url="http://localhost:3011/healthz"; Critical=$true},
@{Name="classes"; Url="http://localhost:3001/healthz"; Critical=$false},
@{Name="core-edu"; Url="http://localhost:3004/healthz"; Critical=$false},
@{Name="content"; Url="http://localhost:3005/healthz"; Critical=$false},
@{Name="data-ana"; Url="http://localhost:3006/healthz"; Critical=$false},
@{Name="msg"; Url="http://localhost:3007/healthz"; Critical=$false},
@{Name="ai"; Url="http://localhost:3008/healthz"; Critical=$false},
@{Name="api-gateway"; Url="http://localhost:8080/healthz"; Critical=$false},
@{Name="push-gateway"; Url="http://localhost:8081/healthz"; Critical=$false},
@{Name="portal-shell"; Url="http://localhost:4010/api/health"; Critical=$true},
@{Name="apollo-router"; Url="http://localhost:8088/health"; Critical=$false}
)
$appOk = 0
$appFail = 0
$failedApps = @()
foreach ($svc in $appServices) {
try {
$null = Invoke-RestMethod -Uri $svc.Url -Method Get -TimeoutSec 3 -ErrorAction Stop
Write-Host " [OK] $($svc.Name)" -ForegroundColor Green
$tag = if ($svc.Critical) { "CRITICAL" } else { "optional" }
Write-Host " [OK] $($svc.Name) ($tag)" -ForegroundColor Green
$appOk++
} catch {
Write-Host " [FAIL] $($svc.Name)" -ForegroundColor Red
$tag = if ($svc.Critical) { "CRITICAL" } else { "optional" }
Write-Host " [FAIL] $($svc.Name) ($tag)" -ForegroundColor Red
$appFail++
$failedApps += $svc.Name
}
}
Write-Host ""
# ===== 3. Observability endpoints =====
Write-Host "[3/3] Observability Endpoints" -ForegroundColor Yellow
# ===== 3. 可观测性端点 =====
Write-Host "[3/4] Observability Endpoints" -ForegroundColor Yellow
$obsEndpoints = @(
@{Name="Prometheus API"; Url="http://localhost:9090/api/v1/query?query=up"},
@{Name="Jaeger API"; Url="http://localhost:16686/api/services"},
@{Name="Debezium Connect"; Url="http://localhost:8083/connectors"},
@{Name="data-ana /metrics";Url="http://localhost:3006/metrics"},
@{Name="iam /metrics"; Url="http://localhost:3002/metrics"}
@{Name="Prometheus API"; Url="http://localhost:9090/api/v1/query?query=up"},
@{Name="Jaeger API"; Url="http://localhost:16686/api/services"},
@{Name="Debezium Connect"; Url="http://localhost:8083/connectors"},
@{Name="Grafana"; Url="http://localhost:3030/api/health"},
@{Name="Loki"; Url="http://localhost:3100/ready"}
)
$obsOk = 0
@@ -101,56 +112,40 @@ $obsFail = 0
foreach ($ep in $obsEndpoints) {
try {
$null = Invoke-RestMethod -Uri $ep.Url -Method Get -TimeoutSec 3 -ErrorAction Stop
Write-Host " [OK] $($ep.Name)" -ForegroundColor Green
Write-Host " [OK] $($ep.Name)" -ForegroundColor Green
$obsOk++
} catch {
Write-Host " [FAIL] $($ep.Name)" -ForegroundColor Red
$obsFail++
}
}
# ===== 4. CDC pipeline status =====
Write-Host ""
Write-Host "[Extra] CDC Pipeline Status" -ForegroundColor Yellow
$connectorStatus = $null
# ===== 4. CDC 管道状态 =====
Write-Host "[4/4] CDC Pipeline Status" -ForegroundColor Yellow
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$connectorStatus = Invoke-RestMethod -Uri "http://localhost:8083/connectors/edu-mysql-source/status" -Method Get -TimeoutSec 3 -ErrorAction Stop
} catch {
Write-Host " [FAIL] Debezium connector not registered or error" -ForegroundColor Red
}
if ($connectorStatus) {
$connectorState = $connectorStatus.connector.state
$tasks = @($connectorStatus.tasks)
if ($tasks.Count -gt 0) {
$taskState = $tasks[0].state
} else {
$taskState = "UNKNOWN"
}
if ($connectorState -eq "RUNNING" -and $taskState -eq "RUNNING") {
$color = "Green"
} else {
$color = "Yellow"
}
Write-Host " Connector: $connectorState / Task: $taskState" -ForegroundColor $color
$taskState = if ($tasks.Count -gt 0) { $tasks[0].state } else { "UNKNOWN" }
$color = if ($connectorState -eq "RUNNING" -and $taskState -eq "RUNNING") { "Green" } else { "Yellow" }
Write-Host " Debezium Connector: $connectorState / Task: $taskState" -ForegroundColor $color
} catch {
Write-Host " [SKIP] Debezium connector not registered" -ForegroundColor Gray
}
$chSql = 'SELECT count(*) FROM edu_analytics.student_dashboard_view'
$chOutput = docker exec edu-clickhouse clickhouse-client --user default --password clickhouse -q $chSql 2>&1
$chCount = "$chOutput".Trim()
if ($chCount -match '^\d+$') {
Write-Host " ClickHouse student_dashboard_view: $chCount records" -ForegroundColor Green
} else {
Write-Host " ClickHouse query failed or empty" -ForegroundColor Yellow
}
# ===== Summary =====
$ErrorActionPreference = $prevEAP
Write-Host ""
# ===== 汇总 =====
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Infra: $infraOk/$($infraContainers.Count) | App: $appOk/$($appServices.Count) | Obs: $obsOk/$($obsEndpoints.Count)" -ForegroundColor Cyan
$totalChecked = $infraContainers.Count + $appServices.Count + $obsEndpoints.Count
$totalOk = $infraOk + $appOk + $obsOk
Write-Host " Infra: $infraOk checked | App: $appOk/$($appServices.Count) | Obs: $obsOk/$($obsEndpoints.Count)" -ForegroundColor Cyan
if ($appFail -gt 0) {
Write-Host " Failed apps: $($failedApps -join ', ')" -ForegroundColor Yellow
}
Write-Host "========================================" -ForegroundColor Cyan
if ($infraFail -gt 0 -or $appFail -gt 0 -or $obsFail -gt 0) {
exit 1
}
if ($appFail -gt 0 -or $obsFail -gt 0) { exit 1 }

View File

@@ -1,103 +1,169 @@
<#
.SYNOPSIS
Edu start all application services script
Edu v2.1 一键启动脚本
.DESCRIPTION
Starts 10 application services + 1 frontend (teacher-portal)
Each service runs in a separate terminal window for log visibility
Automatically injects Python service env vars (CLICKHOUSE/KAFKA/OTEL)
Prerequisite: infrastructure containers (MySQL/Redis/Kafka/ClickHouse etc) must be running
.PARAMETER SkipInfraCheck
Skip infrastructure health check (use when infra is known to be running)
启动顺序:
1. Docker 基础设施MySQL/Redis/Kafka/ClickHouse/Neo4j/ES/observability
2. Apollo RouterDocker 容器GraphQL 联邦网关)
3. 应用服务(本地 pnpm dev / uvicorn / go run
应用服务列表v2.1
- NestJS: iam, config-service, classes, core-edu, content, msg
- Python: data-ana, ai
- Go: api-gateway, push-gateway
- Next.js: portal-shell (前端)
.PARAMETER SkipInfra
跳过 Docker 基础设施启动(已确认 infra 在运行时使用)
.PARAMETER SkipApps
仅启动基础设施,不启动应用服务
.PARAMETER Force
自动杀掉占用端口的进程(不交互确认)
.PARAMETER Profile
Docker Compose profile 层级default | p3 | p4 | p5 | p6默认 p6=全部)
.EXAMPLE
.\scripts\start-all.ps1
.\scripts\start-all.ps1 -SkipInfraCheck
.\scripts\start-all.ps1 # 启动全部
.\scripts\start-all.ps1 -SkipInfra # 仅启动应用服务
.\scripts\start-all.ps1 -SkipApps # 仅启动基础设施
.\scripts\start-all.ps1 -Force # 自动杀端口占用
#>
param(
[switch]$SkipInfraCheck,
[switch]$Force
[switch]$SkipInfra,
[switch]$SkipApps,
[switch]$SkipRouter,
[switch]$Force,
[string]$Profile = "p6"
)
$ErrorActionPreference = "Stop"
$ProjectRoot = Split-Path -Parent $PSScriptRoot
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Edu Start All Services" -ForegroundColor Cyan
Write-Host " Edu v2.1 Start All Services" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# ===== 1. Infrastructure health check =====
if (-not $SkipInfraCheck) {
Write-Host "[1/6] Checking infrastructure health..." -ForegroundColor Yellow
$infraServices = @(
@{Name="MySQL"; Container="edu-mysql"},
@{Name="Redis"; Container="edu-redis"},
@{Name="Kafka"; Container="edu-kafka"},
@{Name="ClickHouse"; Container="edu-clickhouse"},
@{Name="Debezium"; Container="edu-debezium"},
@{Name="Jaeger"; Container="edu-jaeger"}
)
$allHealthy = $true
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
foreach ($svc in $infraServices) {
$running = docker inspect -f '{{.State.Running}}' $svc.Container 2>$null
if ($running -ne "true") {
Write-Host " [FAIL] $($svc.Name) not running" -ForegroundColor Red
$allHealthy = $false
continue
}
# 条件模板Health 不存在时返回空字符串,不报错
$health = docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' $svc.Container 2>$null
if ($health -eq "healthy") {
Write-Host " [OK] $($svc.Name) ($($svc.Container))" -ForegroundColor Green
} else {
Write-Host " [WARN] $($svc.Name) running (no healthcheck)" -ForegroundColor Yellow
}
}
$ErrorActionPreference = $prevEAP
if (-not $allHealthy) {
Write-Host ""
Write-Host "Infrastructure not ready. Start it first:" -ForegroundColor Red
Write-Host " docker compose -f infra/docker-compose.yml --profile p6 --profile observability up -d" -ForegroundColor White
exit 1
}
Write-Host ""
# ===== 0. 加载 .env 文件 =====
Write-Host "[0/7] Loading .env..." -ForegroundColor Yellow
$envFile = Join-Path $ProjectRoot ".env"
if (-not (Test-Path $envFile)) {
Write-Host " [FAIL] .env not found at $envFile" -ForegroundColor Red
exit 1
}
# ===== 2. Environment variables =====
Write-Host "[2/6] Preparing environment variables..." -ForegroundColor Yellow
$env:DEV_MODE = "true"
$env:OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"
$env:DATABASE_URL = "mysql://edu:changeme@localhost:3306/next_edu_cloud"
$env:REDIS_URL = "redis://localhost:6379"
$env:JWT_SECRET = "p1-dev-secret-change-in-production"
$env:KAFKA_BROKERS = "localhost:9092"
$pyEnv = @{
CLICKHOUSE_HOST = "localhost"
CLICKHOUSE_PORT = "8123"
CLICKHOUSE_USER = "default"
CLICKHOUSE_PASSWORD = "clickhouse"
CLICKHOUSE_DATABASE = "edu_analytics"
KAFKA_BROKERS = "localhost:9092"
OTEL_ENDPOINT = "http://localhost:4318"
DEV_MODE = "true"
$envCount = 0
$envLines = Get-Content $envFile
foreach ($line in $envLines) {
$line = $line.Trim()
if (-not $line -or $line.StartsWith("#") -or -not $line.Contains("=")) { continue }
$idx = $line.IndexOf("=")
$key = $line.Substring(0, $idx).Trim()
$val = $line.Substring($idx + 1).Trim()
# 移除可能的引号
if ($val.StartsWith('"') -and $val.EndsWith('"')) { $val = $val.Substring(1, $val.Length - 2) }
if ($val.StartsWith("'") -and $val.EndsWith("'")) { $val = $val.Substring(1, $val.Length - 2) }
[Environment]::SetEnvironmentVariable($key, $val, "Process")
$envCount++
}
Write-Host " DEV_MODE=true (dev-token bypass)" -ForegroundColor Green
Write-Host " OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318" -ForegroundColor Green
# 补充 .env 中未定义但 dev 模式需要的变量
if (-not $env:DEV_MODE) { $env:DEV_MODE = "true" }
if (-not $env:OTEL_EXPORTER_OTLP_ENDPOINT) { $env:OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318" }
if (-not $env:NEO4J_URI) { $env:NEO4J_URI = "bolt://localhost:7687" }
if (-not $env:NEO4J_USER) { $env:NEO4J_USER = "neo4j" }
if (-not $env:CLICKHOUSE_URL) { $env:CLICKHOUSE_URL = "http://localhost:8123" }
if (-not $env:ES_URL) { $env:ES_URL = "http://localhost:9200" }
if (-not $env:ROUTER_AUTH_SECRET) { $env:ROUTER_AUTH_SECRET = "dev-router-secret" }
Write-Host " [OK] Loaded $envCount env vars from .env (DEV_MODE=$($env:DEV_MODE))" -ForegroundColor Green
Write-Host ""
# ===== 3. Port conflict check =====
Write-Host "[3/6] Checking port conflicts..." -ForegroundColor Yellow
# ===== 1. 启动 Docker 基础设施 =====
# 混合部署仅启动基础设施容器MySQL/Redis/Kafka/ClickHouse/Neo4j/ES/Debezium/observability
# Temporal 暂不启动auto-setup 镜像需调整配置,非关键路径)
# 应用服务config-service/apollo-router/portal-shell本地运行避免 Docker 构建失败
if (-not $SkipInfra) {
Write-Host "[1/7] Starting Docker infrastructure..." -ForegroundColor Yellow
Push-Location "$ProjectRoot\infra"
# docker compose 把进度输出到 stderr临时放宽 ErrorActionPreference 避免误判
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
# 启用 p3+p5 profile显式启动基础设施服务跳过 temporal 和需构建镜像的 app
$composeCmd = "docker compose -f docker-compose.yml --profile p3 --profile p5 up -d mysql redis kafka zookeeper clickhouse neo4j elasticsearch debezium-connect"
Write-Host " $composeCmd" -ForegroundColor DarkGray
Invoke-Expression $composeCmd 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Host " [WARN] Bulk start had issues, retrying individually..." -ForegroundColor Yellow
docker compose -f docker-compose.yml up -d mysql redis 2>&1 | Out-Null
docker compose -f docker-compose.yml --profile p3 up -d kafka zookeeper clickhouse neo4j debezium-connect 2>&1 | Out-Null
docker compose -f docker-compose.yml --profile p5 up -d elasticsearch 2>&1 | Out-Null
}
# 可观测性栈(独立 profile
docker compose -f docker-compose.yml --profile observability up -d 2>&1 | Out-Null
Pop-Location
$ErrorActionPreference = $prevEAP
Write-Host " Waiting 20s for containers to initialize..." -ForegroundColor Gray
Start-Sleep -Seconds 20
Write-Host " [OK] Docker infrastructure started" -ForegroundColor Green
} catch {
Pop-Location
$ErrorActionPreference = $prevEAP
Write-Host " [FAIL] $_" -ForegroundColor Red
exit 1
}
} else {
Write-Host "[1/7] Skipping Docker infrastructure (-SkipInfra)" -ForegroundColor Gray
}
Write-Host ""
$portMap = @{
3001="classes"; 3002="iam"; 3003="teacher-bff"; 3004="core-edu"
3005="content"; 3006="data-ana"; 3007="msg"; 3008="ai"
8080="api-gateway"; 8081="push-gateway"; 3000="teacher-portal"
# ===== 2. 基础设施健康检查 =====
Write-Host "[2/7] Checking infrastructure health..." -ForegroundColor Yellow
$infraServices = @(
@{Name="MySQL"; Container="edu-mysql"},
@{Name="Redis"; Container="edu-redis"},
@{Name="Kafka"; Container="edu-kafka"},
@{Name="Zookeeper"; Container="edu-zookeeper"},
@{Name="ClickHouse"; Container="edu-clickhouse"},
@{Name="Neo4j"; Container="edu-neo4j"},
@{Name="Elasticsearch"; Container="edu-es"},
@{Name="Debezium"; Container="edu-debezium"},
@{Name="Jaeger"; Container="edu-jaeger"},
@{Name="Prometheus"; Container="edu-prometheus"},
@{Name="Grafana"; Container="edu-grafana"}
)
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$infraOkCount = 0
foreach ($svc in $infraServices) {
$running = docker inspect -f '{{.State.Running}}' $svc.Container 2>$null
if ($running -ne "true") {
Write-Host " [SKIP] $($svc.Name) not running (may be optional)" -ForegroundColor Gray
continue
}
$health = docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' $svc.Container 2>$null
if ($health -eq "healthy" -or $health -eq "") {
Write-Host " [OK] $($svc.Name)" -ForegroundColor Green
$infraOkCount++
} elseif ($health -eq "starting") {
Write-Host " [WAIT] $($svc.Name) still starting..." -ForegroundColor Yellow
} else {
Write-Host " [WARN] $($svc.Name) health=$health" -ForegroundColor Yellow
}
}
$ErrorActionPreference = $prevEAP
Write-Host " $infraOkCount/$($infraServices.Count) infrastructure services OK" -ForegroundColor Green
Write-Host ""
if ($SkipApps) {
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Infrastructure only (-SkipApps). Done." -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
exit 0
}
# ===== 3. 端口冲突检查 =====
Write-Host "[3/7] Checking port conflicts..." -ForegroundColor Yellow
$portMap = @{
3001="classes"; 3002="iam"; 3004="core-edu"; 3005="content"
3006="data-ana"; 3007="msg"; 3008="ai"; 3011="config-service"
8080="api-gateway"; 8081="push-gateway"; 4010="portal-shell"
}
$conflicts = @()
foreach ($port in $portMap.Keys | Sort-Object) {
$conn = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
@@ -110,31 +176,20 @@ foreach ($port in $portMap.Keys | Sort-Object) {
$conflicts += [PSCustomObject]@{Port=$port; Service=$svcName; PID=$procId; Process=$procName}
}
}
if ($conflicts.Count -gt 0) {
Write-Host ""
Write-Host " $($conflicts.Count) port(s) already in use." -ForegroundColor Yellow
Write-Host " $($conflicts.Count) port(s) in use." -ForegroundColor Yellow
$shouldKill = $false
if ($Force) {
Write-Host " -Force specified, killing automatically..." -ForegroundColor White
$shouldKill = $true
} else {
Write-Host " These services may already be running. Options:" -ForegroundColor White
Write-Host " 1. Run .\scripts\stop-all.ps1 -KillByPort first, then re-run start-all" -ForegroundColor White
Write-Host " 2. Re-run with -Force to auto-kill and continue" -ForegroundColor White
Write-Host ""
$answer = Read-Host " Kill existing processes and continue? (y/N)"
if ($answer -eq "y" -or $answer -eq "Y") {
$shouldKill = $true
}
if ($answer -eq "y" -or $answer -eq "Y") { $shouldKill = $true }
}
if ($shouldKill) {
foreach ($c in $conflicts) {
try {
Stop-Process -Id $c.PID -Force -ErrorAction Stop
Write-Host " [OK] Killed PID $($c.PID) ($($c.Process)) on port $($c.Port)" -ForegroundColor Green
Write-Host " [OK] Killed PID $($c.PID) on port $($c.Port)" -ForegroundColor Green
Start-Sleep -Milliseconds 500
} catch {
Write-Host " [WARN] Cannot kill PID $($c.PID): $($_.Exception.Message)" -ForegroundColor Yellow
@@ -142,7 +197,7 @@ if ($conflicts.Count -gt 0) {
}
Start-Sleep -Seconds 2
} else {
Write-Host " Aborting. Please stop existing services first." -ForegroundColor Red
Write-Host " Aborting." -ForegroundColor Red
exit 1
}
} else {
@@ -150,20 +205,26 @@ if ($conflicts.Count -gt 0) {
}
Write-Host ""
# ===== 4. Build NestJS services (required for nest start --watch) =====
Write-Host "[4/6] Building NestJS services (first time required)..." -ForegroundColor Yellow
# ===== 4. 确保 shared-ts 已编译 =====
Write-Host "[4/7] Ensuring shared-ts is built..." -ForegroundColor Yellow
$sharedTsPath = Join-Path $ProjectRoot "packages\shared-ts\dist"
if (-not (Test-Path $sharedTsPath)) {
Write-Host " Building @edu/shared-ts..." -ForegroundColor Gray
pnpm --filter @edu/shared-ts build 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] shared-ts built" -ForegroundColor Green
} else {
Write-Host " [FAIL] shared-ts build failed" -ForegroundColor Red
exit 1
}
} else {
Write-Host " [OK] shared-ts already built" -ForegroundColor Green
}
Write-Host ""
$nestjsServices = @(
"@edu/classes-service",
"@edu/iam-service",
"@edu/teacher-bff",
"@edu/core-edu-service",
"@edu/content-service",
"@edu/msg-service"
)
# Clean tsbuildinfo cache (incremental mode leftover causes tsc to skip emit)
$nestjsDirs = @("classes", "iam", "teacher-bff", "core-edu", "content", "msg")
# ===== 5. 清理 NestJS 增量缓存 =====
Write-Host "[5/7] Cleaning NestJS tsbuildinfo cache..." -ForegroundColor Yellow
$nestjsDirs = @("iam", "config-service", "classes", "core-edu", "content", "msg")
foreach ($dir in $nestjsDirs) {
$svcPath = Join-Path $ProjectRoot "services\$dir"
if (Test-Path $svcPath) {
@@ -171,131 +232,250 @@ foreach ($dir in $nestjsDirs) {
Remove-Item -Force -ErrorAction SilentlyContinue
}
}
foreach ($svc in $nestjsServices) {
Write-Host " Building $svc..." -ForegroundColor Gray -NoNewline
$buildResult = pnpm --filter $svc build 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK]" -ForegroundColor Green
} else {
Write-Host " [FAIL]" -ForegroundColor Red
Write-Host " $buildResult" -ForegroundColor DarkGray
}
}
Write-Host " [OK] Cache cleaned" -ForegroundColor Green
Write-Host ""
# ===== 5. Start application services =====
Write-Host "[5/6] Starting application services (11 windows)..." -ForegroundColor Yellow
# ===== 6. 启动应用服务 =====
Write-Host "[6/7] Starting application services (v2.1)..." -ForegroundColor Yellow
# Python 服务环境变量
$pyEnv = @{
CLICKHOUSE_HOST = "localhost"
CLICKHOUSE_PORT = "8123"
CLICKHOUSE_USER = "default"
CLICKHOUSE_PASSWORD = "clickhouse"
CLICKHOUSE_DATABASE = "edu_analytics"
CLICKHOUSE_URL = "http://localhost:8123"
KAFKA_BROKERS = "localhost:9092"
OTEL_ENDPOINT = "http://localhost:4318"
OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318"
DEV_MODE = "true"
NEO4J_URI = "bolt://localhost:7687"
NEO4J_USER = "neo4j"
NEO4J_PASSWORD = "changeme"
ES_URL = "http://localhost:9200"
}
# Go 服务环境变量默认值(在当前进程设置,子进程会继承)
if (-not $env:CLASSES_SERVICE_URL) { $env:CLASSES_SERVICE_URL = "http://localhost:3001" }
if (-not $env:IAM_SERVICE_URL) { $env:IAM_SERVICE_URL = "http://localhost:3002" }
if (-not $env:APOLLO_ROUTER_URL) { $env:APOLLO_ROUTER_URL = "http://localhost:3000" }
if (-not $env:CORE_EDU_SERVICE_URL) { $env:CORE_EDU_SERVICE_URL = "http://localhost:3004" }
if (-not $env:CONTENT_SERVICE_URL) { $env:CONTENT_SERVICE_URL = "http://localhost:3005" }
if (-not $env:DATA_ANA_SERVICE_URL) { $env:DATA_ANA_SERVICE_URL = "http://localhost:3006" }
if (-not $env:MSG_SERVICE_URL) { $env:MSG_SERVICE_URL = "http://localhost:3007" }
if (-not $env:AI_SERVICE_URL) { $env:AI_SERVICE_URL = "http://localhost:3008" }
if (-not $env:CORS_ORIGINS) { $env:CORS_ORIGINS = "http://localhost:4000,http://localhost:4010" }
if (-not $env:JWKS_URL) { $env:JWKS_URL = "http://localhost:3002/v1/iam/.well-known/jwks.json" }
if (-not $env:PUSH_INTERNAL_TOKEN) { $env:PUSH_INTERNAL_TOKEN = "edu-internal-token" }
$services = @(
@{Title="edu-app-classes"; Cmd="pnpm"; Args=@("--filter","@edu/classes-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-iam"; Cmd="pnpm"; Args=@("--filter","@edu/iam-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-teacher-bff"; Cmd="pnpm"; Args=@("--filter","@edu/teacher-bff","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-core-edu"; Cmd="pnpm"; Args=@("--filter","@edu/core-edu-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-content"; Cmd="pnpm"; Args=@("--filter","@edu/content-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-msg"; Cmd="pnpm"; Args=@("--filter","@edu/msg-service","dev"); Dir="$ProjectRoot"},
@{Title="edu-app-data-ana"; Cmd="uv"; Args=@("run","uvicorn","data_ana.main:app","--app-dir","src","--host","0.0.0.0","--port","3006","--reload"); Dir="$ProjectRoot\services\data-ana"; PyEnv=$true},
@{Title="edu-app-ai"; Cmd="uv"; Args=@("run","uvicorn","ai.main:app","--app-dir","src","--host","0.0.0.0","--port","3008","--reload"); Dir="$ProjectRoot\services\ai"; PyEnv=$true},
@{Title="edu-app-api-gateway"; Cmd="go"; Args=@("run","."); Dir="$ProjectRoot\services\api-gateway"; GoEnv=$true},
@{Title="edu-app-push-gateway"; Cmd="go"; Args=@("run","."); Dir="$ProjectRoot\services\push-gateway";GoEnv=$true},
@{Title="edu-app-teacher-portal";Cmd="pnpm";Args=@("--filter","teacher-portal","dev"); Dir="$ProjectRoot"}
@{Title="edu-app-iam"; Cmd="pnpm"; Args=@("--filter","@edu/iam-service","exec","nest","start"); Dir="$ProjectRoot"},
@{Title="edu-app-config-service"; Cmd="pnpm"; Args=@("--filter","@edu/config-service","exec","nest","start"); Dir="$ProjectRoot"},
@{Title="edu-app-classes"; Cmd="pnpm"; Args=@("--filter","@edu/classes-service","exec","nest","start"); Dir="$ProjectRoot"},
@{Title="edu-app-core-edu"; Cmd="pnpm"; Args=@("--filter","@edu/core-edu-service","exec","nest","start"); Dir="$ProjectRoot"},
@{Title="edu-app-content"; Cmd="pnpm"; Args=@("--filter","@edu/content-service","exec","nest","start"); Dir="$ProjectRoot"},
@{Title="edu-app-msg"; Cmd="pnpm"; Args=@("--filter","@edu/msg-service","exec","nest","start"); Dir="$ProjectRoot"},
@{Title="edu-app-data-ana"; Cmd="uv"; Args=@("run","uvicorn","data_ana.main:app","--app-dir","src","--host","0.0.0.0","--port","3006","--reload"); Dir="$ProjectRoot\services\data-ana"; PyEnv=$true},
@{Title="edu-app-ai"; Cmd="uv"; Args=@("run","uvicorn","ai.main:app","--app-dir","src","--host","0.0.0.0","--port","3008","--reload"); Dir="$ProjectRoot\services\ai"; PyEnv=$true},
@{Title="edu-app-api-gateway"; Cmd="go"; Args=@("run","."); Dir="$ProjectRoot\services\api-gateway"; GoEnv=$true},
@{Title="edu-app-push-gateway"; Cmd="go"; Args=@("run","."); Dir="$ProjectRoot\services\push-gateway"; GoEnv=$true},
@{Title="edu-app-portal-shell"; Cmd="pnpm"; Args=@("--filter","@edu/portal-shell","dev"); Dir="$ProjectRoot"}
)
$startedCount = 0
foreach ($svc in $services) {
$cmdStr = "$($svc.Cmd) $($svc.Args -join ' ')"
$psCmd = "Set-Location '$($svc.Dir)'; "
# Python 服务额外设置环境变量
if ($svc.PyEnv) {
foreach ($kv in $pyEnv.GetEnumerator()) {
$psCmd += "`$env:$($kv.Key)='$($kv.Value)'; "
}
}
# Go 服务确保 PATH 包含 Go bin
if ($svc.GoEnv) {
$psCmd += "`$env:Path = 'C:\Program Files\Go\bin;' + `$env:Path; "
}
$cmdStr = "$($svc.Cmd) $($svc.Args -join ' ')"
$psCmd += "$cmdStr; Write-Host ''; Write-Host 'Service stopped. Press any key to close...' -ForegroundColor Yellow; `$null = `$Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown')"
Start-Process -FilePath "powershell" -ArgumentList "-NoExit","-Command",$psCmd -WindowStyle Normal | Out-Null
Write-Host " [START] $($svc.Title)..." -ForegroundColor Green
Start-Sleep -Milliseconds 500
Write-Host " [START] $($svc.Title)" -ForegroundColor Green
$startedCount++
Start-Sleep -Milliseconds 800
}
Write-Host ""
Write-Host " All services started in new windows. Waiting 40s for init..." -ForegroundColor Yellow
Start-Sleep -Seconds 40
Write-Host " $startedCount services launched. Waiting 60s for init..." -ForegroundColor Yellow
Start-Sleep -Seconds 60
# ===== 6. Health check =====
Write-Host "[6/6] Health check..." -ForegroundColor Yellow
# ===== 7. 健康检查 =====
Write-Host "[7/7] Health check..." -ForegroundColor Yellow
Write-Host ""
$healthServices = @(
@{Name="classes"; Url="http://localhost:3001/healthz"},
@{Name="iam"; Url="http://localhost:3002/healthz"},
@{Name="teacher-bff"; Url="http://localhost:3003/healthz"},
@{Name="core-edu"; Url="http://localhost:3004/healthz"},
@{Name="content"; Url="http://localhost:3005/healthz"},
@{Name="data-ana"; Url="http://localhost:3006/healthz"},
@{Name="msg"; Url="http://localhost:3007/healthz"},
@{Name="ai"; Url="http://localhost:3008/healthz"},
@{Name="api-gateway"; Url="http://localhost:8080/healthz"},
@{Name="push-gateway"; Url="http://localhost:8081/healthz"},
@{Name="teacher-portal";Url="http://localhost:3000/"}
@{Name="iam"; Url="http://localhost:3002/healthz"; Critical=$true},
@{Name="config-service"; Url="http://localhost:3011/healthz"; Critical=$true},
@{Name="classes"; Url="http://localhost:3001/healthz"; Critical=$false},
@{Name="core-edu"; Url="http://localhost:3004/healthz"; Critical=$false},
@{Name="content"; Url="http://localhost:3005/healthz"; Critical=$false},
@{Name="data-ana"; Url="http://localhost:3006/healthz"; Critical=$false},
@{Name="msg"; Url="http://localhost:3007/healthz"; Critical=$false},
@{Name="ai"; Url="http://localhost:3008/healthz"; Critical=$false},
@{Name="api-gateway"; Url="http://localhost:8080/healthz"; Critical=$false},
@{Name="push-gateway"; Url="http://localhost:8081/healthz"; Critical=$false},
@{Name="portal-shell"; Url="http://localhost:4010/api/health"; Critical=$true}
)
$okCount = 0
$failCount = 0
$criticalFail = 0
$failedServices = @()
foreach ($svc in $healthServices) {
$retries = 0
$maxRetries = 3
$maxRetries = 5
$success = $false
$lastError = ""
while ($retries -lt $maxRetries -and -not $success) {
try {
$null = Invoke-RestMethod -Uri $svc.Url -Method Get -TimeoutSec 5 -ErrorAction Stop
Write-Host " [OK] $($svc.Name)" -ForegroundColor Green
$tag = if ($svc.Critical) { "CRITICAL" } else { "optional" }
Write-Host " [OK] $($svc.Name) ($tag)" -ForegroundColor Green
$success = $true
$okCount++
} catch {
$lastError = $_.Exception.Message
$retries++
if ($retries -lt $maxRetries) {
Start-Sleep -Seconds 5
}
if ($retries -lt $maxRetries) { Start-Sleep -Seconds 4 }
}
}
if (-not $success) {
Write-Host " [FAIL] $($svc.Name) (after $maxRetries retries: $lastError)" -ForegroundColor Red
$tag = if ($svc.Critical) { "CRITICAL" } else { "optional" }
Write-Host " [FAIL] $($svc.Name) ($tag)" -ForegroundColor Red
$failCount++
$failedServices += $svc.Name
if ($svc.Critical) { $criticalFail++ }
}
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Started: [OK] $okCount ready / [FAIL] $failCount failed" -ForegroundColor Cyan
Write-Host " Result: [OK] $okCount / [FAIL] $failCount" -ForegroundColor Cyan
if ($criticalFail -gt 0) {
Write-Host " CRITICAL failures: $criticalFail" -ForegroundColor Red
}
Write-Host "========================================" -ForegroundColor Cyan
if ($failCount -gt 0) {
Write-Host ""
Write-Host "Failed services: $($failedServices -join ', ')" -ForegroundColor Red
Write-Host "Failed: $($failedServices -join ', ')" -ForegroundColor Yellow
Write-Host ""
Write-Host "Troubleshooting:" -ForegroundColor Yellow
Write-Host " 1. Check the service window for error output" -ForegroundColor White
Write-Host " 2. Verify dependencies: pnpm install / uv sync / go mod tidy" -ForegroundColor White
Write-Host " 3. Re-run health check: .\scripts\health-check.ps1" -ForegroundColor White
Write-Host " 4. Stop and retry: .\scripts\stop-all.ps1 -KillByPort then .\scripts\start-all.ps1" -ForegroundColor White
Write-Host " 1. Check service window for errors" -ForegroundColor White
Write-Host " 2. Health check: .\scripts\health-check.ps1" -ForegroundColor White
Write-Host " 3. Stop & retry: .\scripts\stop-all.ps1 -KillByPort then .\scripts\start-all.ps1 -SkipInfra -Force" -ForegroundColor White
Write-Host ""
exit 1
}
# ===== 8. 启动 Apollo RouterDocker 容器) =====
# Apollo Router 需要所有 Federation 2 子图iam/core-edu/content/msg/config就绪后才能 compose supergraph
# 子图在 host 上运行router 容器通过 host.docker.internal 访问
if (-not $SkipRouter -and -not $SkipApps) {
Write-Host "[8/8] Starting Apollo Router (Docker)..." -ForegroundColor Yellow
$routerImage = "edu/apollo-router:dev"
$routerDir = Join-Path $ProjectRoot "infra\apollo-router"
# 检查镜像是否存在,不存在则提示
$imageExists = docker image inspect $routerImage 2>$null
if (-not $imageExists) {
Write-Host " [WARN] Image $routerImage not found. Building..." -ForegroundColor Yellow
Push-Location $routerDir
try {
docker build -t $routerImage . 2>&1 | Out-Host
if ($LASTEXITCODE -ne 0) {
Write-Host " [FAIL] Apollo Router image build failed" -ForegroundColor Red
Write-Host " Skip with -SkipRouter. Portal-shell will fall back to config-service direct connection." -ForegroundColor Yellow
Pop-Location
$SkipRouter = $true
} else {
Write-Host " [OK] Image built" -ForegroundColor Green
}
} catch {
Pop-Location
Write-Host " [FAIL] Build error: $_" -ForegroundColor Red
$SkipRouter = $true
}
if (-not $SkipRouter) { Pop-Location }
}
if (-not $SkipRouter) {
# 移除旧容器
docker rm -f edu-apollo-router 2>&1 | Out-Null
# 检查 3000 端口是否被占用
$port3000 = Get-NetTCPConnection -LocalPort 3000 -State Listen -ErrorAction SilentlyContinue
if ($port3000) {
Write-Host " [WARN] Port 3000 already in use, skipping router startup" -ForegroundColor Yellow
Write-Host " (use docker rm -f edu-apollo-router to clean up old container)" -ForegroundColor Gray
} else {
$proxyVar = $env:HTTP_PROXY
if (-not $proxyVar) { $proxyVar = "http://host.docker.internal:7897" }
$dockerArgs = @(
"run","-d","--name","edu-apollo-router",
"--add-host=host.docker.internal:host-gateway",
"-p","3000:3000","-p","8088:8088",
"-v","$routerDir\dev-supergraph.yaml:/dist/supergraph.yaml",
"-v","$routerDir\dev-entrypoint.sh:/dist/entrypoint.sh",
"-v","$routerDir\router.yaml:/dist/configuration.yaml",
"-e","ROUTER_AUTH_SECRET=dev-router-secret",
"-e","APOLLO_ELV2_LICENSE=accept",
"-e","HTTP_PROXY=$proxyVar",
"-e","HTTPS_PROXY=$proxyVar",
"-e","http_proxy=$proxyVar",
"-e","https_proxy=$proxyVar",
"-e","NO_PROXY=localhost,127.0.0.1,host.docker.internal",
$routerImage,
"/dist/entrypoint.sh"
)
Write-Host " Starting container..." -ForegroundColor Gray
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& docker @dockerArgs 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
if ($LASTEXITCODE -eq 0) {
Write-Host " [START] edu-apollo-router (waiting 25s for supergraph compose)..." -ForegroundColor Green
Start-Sleep -Seconds 25
# 验证健康
try {
$null = Invoke-RestMethod -Uri "http://localhost:8088/health" -Method Get -TimeoutSec 5 -ErrorAction Stop
Write-Host " [OK] Apollo Router healthy (GraphQL: http://localhost:3000/graphql)" -ForegroundColor Green
} catch {
Write-Host " [WARN] Router not yet healthy, check: docker logs edu-apollo-router" -ForegroundColor Yellow
}
} else {
Write-Host " [FAIL] docker run failed" -ForegroundColor Red
Write-Host " Portal-shell will fall back to config-service direct connection (http://localhost:3011)" -ForegroundColor Yellow
}
}
}
} else {
if ($SkipRouter) {
Write-Host "[8/8] Skipping Apollo Router (-SkipRouter)" -ForegroundColor Gray
} else {
Write-Host "[8/8] Skipping Apollo Router (-SkipApps: no subgraphs running)" -ForegroundColor Gray
}
}
Write-Host ""
Write-Host "Next steps:" -ForegroundColor Yellow
Write-Host " Health check: .\scripts\health-check.ps1" -ForegroundColor White
Write-Host " CDC test: .\scripts\test-cdc.ps1" -ForegroundColor White
Write-Host " Stop all: .\scripts\stop-all.ps1" -ForegroundColor White
Write-Host "Access points:" -ForegroundColor Yellow
Write-Host " Portal Shell (前端): http://localhost:4010" -ForegroundColor White
Write-Host " Apollo Router (GraphQL): http://localhost:3000" -ForegroundColor White
Write-Host " API Gateway: http://localhost:8080" -ForegroundColor White
Write-Host " IAM Health: http://localhost:3002/healthz" -ForegroundColor White
Write-Host " Grafana: http://localhost:3030" -ForegroundColor White
Write-Host " Jaeger: http://localhost:16686" -ForegroundColor White
Write-Host ""

View File

@@ -1,33 +1,61 @@
<#
.SYNOPSIS
Edu stop all application services script
Edu v2.1 一键关闭脚本
.DESCRIPTION
Closes all edu-app-* terminal windows (started by start-all.ps1)
Optional: kill processes by port (fallback when windows are closed but processes linger)
关闭顺序:
1. 关闭 Apollo Router Docker 容器(避免子图关闭时 router 报错)
2. 关闭应用服务窗口edu-app-* 标题匹配)
3. 按端口杀残留进程(可选 -KillByPort
4. 关闭 Docker 基础设施(可选 -IncludeDocker
.PARAMETER KillByPort
Kill processes by port (fallback when windows are closed but processes still alive)
按端口强制杀进程(窗口关闭后进程残留时使用)
.PARAMETER IncludeDocker
同时关闭 Docker 基础设施容器
.PARAMETER SkipRouter
跳过 Apollo Router 容器关闭
.EXAMPLE
.\scripts\stop-all.ps1
.\scripts\stop-all.ps1 -KillByPort
.\scripts\stop-all.ps1 # 关闭应用服务 + Router
.\scripts\stop-all.ps1 -KillByPort # 强制杀端口进程
.\scripts\stop-all.ps1 -IncludeDocker # 关闭应用 + Router + Docker
.\scripts\stop-all.ps1 -KillByPort -IncludeDocker # 全部关闭
#>
param(
[switch]$KillByPort
[switch]$KillByPort,
[switch]$IncludeDocker,
[switch]$SkipRouter
)
$ErrorActionPreference = "Continue"
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Edu Stop All Services" -ForegroundColor Cyan
Write-Host " Edu v2.1 Stop All Services" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# ===== 1. Close app service terminal windows =====
Write-Host "[1/2] Closing app service windows..." -ForegroundColor Yellow
# ===== 0. 关闭 Apollo Router 容器 =====
# 先关闭 router避免子图关闭时 router 报连接错误刷日志
if (-not $SkipRouter) {
Write-Host "[0/3] Stopping Apollo Router container..." -ForegroundColor Yellow
$routerRunning = docker inspect -f '{{.State.Running}}' edu-apollo-router 2>$null
if ($routerRunning -eq "true") {
docker rm -f edu-apollo-router 2>&1 | Out-Null
Write-Host " [OK] edu-apollo-router removed" -ForegroundColor Green
} else {
Write-Host " [--] edu-apollo-router not running" -ForegroundColor Gray
}
Write-Host ""
} else {
Write-Host "[0/3] Skipping Apollo Router (-SkipRouter)" -ForegroundColor Gray
Write-Host ""
}
# ===== 1. 关闭应用服务窗口 =====
Write-Host "[1/3] Closing app service windows..." -ForegroundColor Yellow
$appTitles = @(
"edu-app-classes",
"edu-app-iam",
"edu-app-teacher-bff",
"edu-app-config-service",
"edu-app-classes",
"edu-app-core-edu",
"edu-app-content",
"edu-app-msg",
@@ -35,14 +63,13 @@ $appTitles = @(
"edu-app-ai",
"edu-app-api-gateway",
"edu-app-push-gateway",
"edu-app-teacher-portal"
"edu-app-portal-shell"
)
$closedCount = 0
foreach ($title in $appTitles) {
$procs = Get-Process -Name "powershell","pwsh","node","python","uvicorn","go" -ErrorAction SilentlyContinue |
Where-Object { $_.MainWindowTitle -like "*$title*" }
if ($procs) {
foreach ($p in $procs) {
try {
@@ -57,17 +84,14 @@ foreach ($title in $appTitles) {
Write-Host " [--] $title window not found" -ForegroundColor Gray
}
}
Write-Host ""
Write-Host " Closed $closedCount windows" -ForegroundColor Green
Write-Host ""
# ===== 2. Kill by port (optional) =====
# ===== 2. 按端口杀残留进程 =====
if ($KillByPort) {
Write-Host "[2/2] Killing processes by port..." -ForegroundColor Yellow
$ports = @(3000,3001,3002,3003,3004,3005,3006,3007,3008,8080,8081)
Write-Host "[2/3] Killing processes by port..." -ForegroundColor Yellow
$ports = @(3000,3001,3002,3004,3005,3006,3007,3008,3011,8080,8081,4010,50052,50053,50054,50055,50056,50058,50059)
foreach ($port in $ports) {
$connections = Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue
if ($connections) {
@@ -85,14 +109,35 @@ if ($KillByPort) {
}
}
} else {
Write-Host "[2/2] Skipping port kill (use -KillByPort to enable)" -ForegroundColor Gray
Write-Host "[2/3] Skipping port kill (use -KillByPort to enable)" -ForegroundColor Gray
}
Write-Host ""
# ===== 3. 关闭 Docker 基础设施 =====
if ($IncludeDocker) {
Write-Host "[3/3] Stopping Docker infrastructure..." -ForegroundColor Yellow
$ProjectRoot = Split-Path -Parent $PSScriptRoot
Push-Location "$ProjectRoot\infra"
try {
# 停止所有 profile 的所有容器(包括可能存在的 app 容器)
docker compose -f docker-compose.yml --profile p3 --profile p5 --profile observability down 2>&1 | Out-Host
Write-Host " [OK] Docker containers stopped" -ForegroundColor Green
} catch {
Write-Host " [WARN] $_" -ForegroundColor Yellow
}
Pop-Location
} else {
Write-Host "[3/3] Skipping Docker (-IncludeDocker to enable)" -ForegroundColor Gray
}
Write-Host ""
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " Stop complete" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "To stop infrastructure:" -ForegroundColor Yellow
Write-Host " docker compose -f infra/docker-compose.yml --profile p6 --profile observability down" -ForegroundColor White
if (-not $IncludeDocker) {
Write-Host ""
Write-Host "To stop Docker infrastructure:" -ForegroundColor Yellow
Write-Host " .\scripts\stop-all.ps1 -IncludeDocker" -ForegroundColor White
Write-Host " or: docker compose -f infra/docker-compose.yml --profile p6 --profile observability down" -ForegroundColor White
}
Write-Host ""

View File

@@ -0,0 +1 @@
{"query":"{ _service { sdl } }"}

View File

@@ -0,0 +1 @@
{"query":"query GetPluginConfig($userId: ID!, $role: String) { pluginConfig(userId: $userId, role: $role) { plugins { pluginId slot sortOrder isVisible } activeLayout { layoutId displayName } } }","variables":{"userId":"dev-user","role":"teacher"},"operationName":"GetPluginConfig"}

View File

@@ -0,0 +1 @@
{"query":"{ __schema { queryType { fields { name } } } }"}

View File

@@ -0,0 +1 @@
{"query":"{ __schema { queryType { fields { name } } } }"}

View File

@@ -0,0 +1 @@
{"query":"{ _service { sdl } }"}